跳到主要内容

LabVIEW 程序中的线程

· 阅读需 27 分钟

一. LabVIEW 是自动多线程语言

一般情况下,运行一个 VI,LabVIEW 至少会在两个线程内运行它:一个界面线程(UI Thread),用于处理界面刷新,用户对控件的操作等等;还有一个执行线程,负责 VI 除界面操作之外的其它工作。LabVIEW 是自动多线程的编程语言,只要 VI 的代码可以并行执行,LabVIEW 就会将它们分配在多个执行线程内同时运行。

图1 是一个正在运行的简单 VI,它由单独一个一直在运行的循环组成。在此情况下,这个执行循环的线程运算负担特别重,其它线程则基本空闲。在单 CPU 计算机上,这个线程将会占用几乎 100% 的 CPU 时间。图1 中的任务管理器是在一个双核 CPU 计算机上截取的。这个循环虽然在每一个时刻只能运行在一个线程上,但这并不表示他始终不变的就固定在一个线程上。他可能在这个时刻运行在这个线程上,另一时刻又被调度到其他线程上去运行了。(关于这一段,在看完本文第二章:LabVIEW 的执行系统,会有更深刻的理解。)

因此,图1 这个程序最多只能占用两个 CPU 内核 50% 的总 CPU 时间,两个 CPU 内核各被占用一些。

图1:双核 CPU 计算机执行一个计算繁重的任务

图2 是当程序有两个并行的繁重计算任务时的情况,这时 LabVIEW 会自动把两个任务分配到两个线程中去。这时即便是双核 CPU 也会被 100% 占用。

图2:双核 CPU 计算机执行两个计算繁重的任务

从上面的例子,我们可以得出如下两个结论。

1. 在 LabVIEW 上编写多线程程序非常方便,我们应该充分利用这个优势。一般情况下,编写程序时应当遵循这样的原则:可以同时运行的模块就并排摆放,千万不要用连线,顺序框等方式强制它们依次执行。在并行执行时, LabVIEW 会自动地把它们安排在在不同线程下同时运行,以提高程序的执行速度,节省程序的运行时间。今后多核计算机将成为主流配置,多线程的优势会更为明显。

特殊的情况也是有的,即用多线程时,运行速度反而慢。 以后我们再来详细介绍此类特殊情况。

2. 假如有一个或某几个线程占用了 100% 的 CPU,此时系统对其他线程就会反应迟钝。例如,程序的执行线程占用了100% 的 CPU,那么用户对界面的操作就会迟迟得不到响应,甚至于用户会误认为程序死锁了。所以在程序中要尽量避免出现 100% 占用 CPU 的情况。 目前大多数的计算机还是单核单个 CPU 的,因此要避免任何一个线程试图 100% 占用 CPU 的情况(如图1、图2 所示的程序)。

此类问题最简单的解决方法就是在循环内加一个延时。在图1、图2 的例子中,如果在每个循环内加上 100 毫秒的延时,CPU 占用率就会接近为 0。

对于总运行时间较短的循环(假如CPU 占用总时间不足 100毫秒)就没有必要再加延时了。

在很多情况下,运行时间很长的循环往往都只是为了等待某一个任务的完成,在此类循环体的内部几乎没有耗时较多的、又有意义的运算,所以必须在循环框内加延时。

对于那些确实非常耗费 CPU资源 的运算(如需要 100% 地占用 CPU 几秒钟甚至更长的时间),最好也在循环内插入少量延时,从而让 CPU 至少 空出 10% 的时间给其它线程或进程。你的程序会因此而多运行 10% 的时间。 但是由于 CPU 可以及时处理其他线程的需求,比如界面操作等,其他后台程序也不会被打断,用户反而会感觉到程序似乎运行得更加流畅。反之,假如你的程序太霸道了,CPU长期被某些运算所霸占,而别的什么都不能做,这样的程序,用户是不可能满意的。

还有这样一种情况,比如某些运算可能需要程序循环 1,000,000次,每执行一次仅需要 0.1 毫秒。此时如果在每次循环里都插入延时,即使是 1 毫秒的延时,也会令程序速度减慢 10 倍。 这当然是不能容忍的。这种情况下,就不能在每次循环都加延时了,但可以采用每一千次循环后加上 10 毫秒延时的策略。此时,程序仅减慢 10% 左右,而 CPU 也有处理其他工作的时间了。

在处理界面操作的 VI 中,常常会使用到 While 循环内套一个 Event Structure 这种结构形式。在这种情况下,就没有必要再在循环内添加延时了。因为程序在执行到 Event Structure 时,如果没有事件产生,程序不再继续执行下去,而是等待某一事件的发生。这是,运行这段代码的线程会暂时休眠,不占用任何 CPU 资源,一直等到有事件发生,这个线程才会重新被唤醒,继续工作。

二、LabVIEW 的执行系统

1. 什么是执行系统

    早期 LabVIEW 的 VI 都是单线程运行的,LabVIEW 5.0 后才引入了多线程运行。其实,对于并排摆放的LabVIEW 函数模块而言,即使LabVIEW 不为它们分配不同的线程,通常也是“并行执行”的。LabVIEW 会把它们拆成片断,轮流执行:这有一点像是 LabVIEW 为自己设计了一套多线程调度系统,在系统的单个线程内并行执行多个任务。
    LabVIEW 中这样一套把 VI 代码调度、运行起来的机制叫做执行系统。现在的 LabIVEW 有六个执行系统,分别是:用户界面执行系统、标准执行系统、仪器I/O执行系统、数据采集执行系统、以及其他1、其他2系统。一个应用程序中使用到的众多子 VI 可以是分别放在不同的执行系统里运行的。用户可以VI 属性面板上选择 Execution 页面,可以在这个页面指定或更改某个 VI 的首选执行系统。

2. 执行系统与线程的关系

    LabVIEW 在支持多线程以后,不同的执行系统中的代码肯定是运行在不同线程下的。用户界面执行系统只有一个线程,并且是这个程序的主线程。 这一点与其他执行系统都不一样,其他的执行系统都可以开辟多个线程来执行代码。用户除了可以设置 VI 的执行系统,还可以设置它的优先级。优先级分 5 个档次(暂先不考虑 subroutine)。在 LabVIEW 7.0 之前, LabVIEW 在默认情况下为同一个执行系统下每个档次的优先级开启一条独立的线程;而在LabVIEW 7.0 之后,LabVIEW 在默认会默认的为每个执行系统下每个档次的优先级开启 4 条线程。当然你使用 \vi.lib\Utility\sysinfo.llb\threadconfig.vi 可以更改这一设置。但是对于普通用户来说最好不要改动它。
    在用 C 语言编写多线程程序时,你还要注意不能开辟太多的线程,因为线程开辟、销毁、切换等也是有消耗的。线程太多可能效率反而更差。但是使用 LabVIEW 就方便多了。在使用默认设置的情况下,LabVIEW 最多为你的程序开辟 5 条线程:一条用户界面线程,四条标准执行系统标准优先级下的线程。五条线程不会引起明显的效率损失。

3. 用户界面执行系统

    程序中所有与界面相关的代码都是放在用户界面执行系统下执行的。就算你为一个 VI 设置了其他的执行系统,这个 VI 的前面板被打开后,他上面的数据更新的操作也会被放在用户界面执行系统下运行。还有一些工作,比如利用 Open VI Reference 节点动态的把一个 VI 加载到内存的工作,也是在用户界面执行系统下运行的。
    前面提到了,用户界面执行系统一个最特殊的执行系统,因为它只有一个线程(我们就给这个线程起名叫用户界面线程好了)。LabVIEW 一启动,这个线程就被创建出来了,而其他执行系统下的线程只有在被使用到时才会被 LabVIEW 创建。

    在图1 中的例子中,如果是运行在其他的线程下,都会把我的双核 CPU 占满。原因参考本文第一章(LabVIEW 是自动多线程语言)的图2。但是如果我们把 VI 的执行系统改为用户界面执行系统,那么这两个循环就会运行在同一线程下,我的双核 CPU 其中一个核将被占用 100%,另一个则基本空闲。

    图2 是 VI 在运行过程中的一幅截图,虽然程序在单线成下运行,两个循环仍然是并行运行的,两个显示控件的数据会交替增加。

    
图1、2:在界面线程-单线程下运行的并行任务

    因为 LabVIEW 是自动多线程的,如果一些模块不能保证多线程安全,就需要把他们设定为在用户界面线程运行。这样就等于强制他们在同一个线程下执行,以保证安全。具体例子在下一节讨论。

4. 其他几个执行系统

    在 执行系统一栏还有其他几个条目可选。
    “same as caller”是默认选项,它表示这个 VI 沿用调用它的上层 VI 设置的执行系统。如果顶层 VI 也选择“same as caller”,那么就等于它选择了标准执行系统。
    “standard”标准执行系统是最常用的配置方式。
    “Instrument I/O”仪器I/O执行系统一般用于发送命令到外部仪器,或从仪器中读取数据。这是程序中较为重要的操作,需要及时运行。所以仪器I/O执行系统中的线程的优先级比其他执行系统中的线程要高一些。
    “data acquisition”数据采集执行系统一般用于快速数据采集。数据采集执行系统中的线程的数据堆栈区比较大。
    “other 1”,“other 2”其他1、其他2执行系统没什么特别之处。如果你一定要让某些 VI 运行在独立的线程内,则可以使用这两个选项。
    绝大多数情况下,用户使用界面执行系统、标准执行系统就已经足够了。

三、线程的优先级

在 VI 的属性设置面板 VI Properties -> Execution 中还有一个下拉选项控件是用来设置线程优先级的(Priority)。这一选项可以改变这个 VI 运行线程的优先级。

优先级设置中共有六项,其中前五项是分别从低到高的五个优先级。优先级越高,越容易抢占到 CPU 资源。比如你把某个负责运算的 VI 的优先级设为最高级(time critical priority),程序在运行时,CPU 会更频繁地给这个 VI 所在线程分配时间片段,其代价是分配给其它线程的运算时间减少了。如果这个程序另有一个线程负责界面刷新,那么用户会发现在把执行线程的优先级提高后,界面刷新会变得迟钝,甚至根本就没有响应。

优先级设置的最后一项是 subroutine, 它与前五项别有很大的不同。严格的说 subroutine 不能作为一个优先级,设置 subroutine 会改变 VI 的一些属性: 设置为 subroutine 的 VI 的前面板的信息会被移除。所以这样的 VI 不能用作界面,也不能单独执行。 设置为 subroutine 的 VI 的调试信息也会被移除。这样的 VI 无法被调试。 当程序执行到被设置为 subroutine 的 VI 的时候,程序会暂时变为单线程执行方式。即程序在 subroutine VI 执行完之前,不会被别的线程打断。 以上的三点保证了 subroutine VI  在执行时可以得到最多的 CPU 资源,某些作为关键运算的 VI,又不是特别耗时的,就可以被设置为 subroutine 以提高运行速度。比如有这样一个 VI,他的输入是一个数值数组,输出是这组数据的平均值。这个运算在程序中需要被尽快完成,以免拖延数据的显示,这个 VI 就是一个蛮适合的 subroutine VI。

在设置 VI 优先级的时候有几点需要注意的。 提高一个 VI 的优先级一般不能显著缩短程序的运行时间。提高了优先级,它所需要的 CPU 时间还是那么多,但是 CPU 被它占用的频率会有所提高。 高优先级的 VI 不一定在低优先级 VI 之前执行。现在常用的多线程操作系统采用的都是抢占式方式,线程优先级别高,抢到 CPU 的可能性比低级别的线程大,但也不是绝对的。 使用 subroutine 时要格外注意,因为他会让你的程序变成单线程方式执行,这在很多情况下反而会降低你的程序的效率。比如一个 VI 并非只是用来运算,它还需要等待其它设备传来的数据,这样的 VI 就绝对不能被设置为 subroutine。现在多核 CPU 已经很流行了,在这样的计算机上,单线程运行的程序通常比多线程效率低,这也是需要考虑的。

四、动态连接库函数的线程

1. CLN 中的线程设置

LabVIEW 可以通过 CLN(Call Library Function Node)节点来掉用动态连接库中的函数,在 Windows 下就是指 .DLL 文件中的函数。用户可以通过 CLN 节点的配置面板来指定被调用函数运行所在的线程。相对于 VI 的线程配置,CLN 的线程选项非常简单,只有两项:界面线程(Run in UI thread)和可重入方式(reentrant)。(新版本 LabVIEW 把这里的 reentrant 改为 Run in any thread 了)

  图1:在 CLN 的配置面板上选择函数运行的线程

   在 LabVIEW 的程序框图上直接就可以看出一个 CLN 节点是选用的什么线程。如果是在界面线程,则节点颜色是较深的橘红色的;如果是可重入方式的,自节点是比较淡的黄色。

  图2:不同颜色表示 CLN 不同的线程设置

2. 如何选择合适的线程

对于在 CLN 中选取何种线程,有一个简单的判断方法。如果你要使用的动态连接库是多线程安全的,就选择可重入方式;否则,动态连接库不是多线程安全的,就选择界面线程方式。 判断一个动态连接库是不是线程安全的,也比较麻烦。如果这个动态连接库文档中没用明确说明它是多线程安全的,那么就要当他是非线性安全的;如果能看到动态连接库的源代码,代码中存在全局变量、静态变量或者代码中看不到有 lock 一类的操作,这个动态连接库也就肯定不是多线程安全的。

选择了可重入方式,LabVIEW 会在最方便的线程内运行动态连接库函数,一般会与调用它的 VI 运行在同一个线程内。因为 LabVIEW 是自动多线程的语言,它也很可能会把动态连接库函数分配一个单独的线程运行。如果程序中存在没有直接或间接先后关系的两个 CLN 节点,LabVIEW 很可能会同时在不同的线程内运行它们所调用的函数,也许是同一函数。对于非多线程安全的动态连接库,这是很危险的操作。很容易引起数据混乱,甚至是程序崩溃。

选择界面线程方式:因为 LabVIEW 只有一个界面线程,所以如果所有的 CLN 设置都是界面线程,那么就可以保证这些 CLN 调用的函数肯定全部都运行在同一线程下,肯定不会被同时调用。对于非多线程安全的动态连接库,这就保证了它的安全。

3. 与 VI 的线程选项相配合

如果你的程序中大量频繁的调用了动态连接库函数,那么效率就是一个非常值得注意的问题了。

我曾经编写过一个在 LabVIEW 中使用 OpenGL 的演示程序(为了演示我们开发的“Import Shared Library 功能”),对 OpenGL 的调用全部是通过 CLN 方式完成的。由于 OpenGL 的全部操作必需在同一线程内完成,我把所有的 CLN 都设置为在界面线程运行的方式。对 VI 的线程选项没有修改,还是默认的选项。结果程序运行极慢,每秒钟只能刷新一帧图像,CPU 占用 100%。但是作为动画每秒至少25帧才能看着比较流畅。 我开始试图用 LabVIEW 的 profile 工具来查找效率低下的 VI,结果居然查找不到。在 Profile Performance and Memory 工具上显示的 CPU 占用时间只有一点点。这个工具竟然显示不出程序中最耗时的操作在哪里,自然我也对如何优化这个程序无从下手了。后来这个演示程序被搁置了一段时间。

直到有一天我从同事给我的提供的一些信息中得到了启发,才突然想通,这些 CPU 全部被消耗在线程切换中了。我们调用 OpenGL 方法是为每个 OpenGL API 函数包装一个 API VI,这些 API VI 非常简单,程序框图就只有一个 CLN 节点,调用相应的 OpenGL 函数。由于每个 VI 都是在默认的执行线程中运行,而 CLN 调用的函数却是在界面线程下运行的。所以每次执行一次这样的 API VI,LabVIEW 都要做两次线程切换,从执行线程切换到界面线程,执行完函数,在切换回执行线程。 线程切换是比较耗时的。我的演示程序刷新一帧要调用大约两千次 OpenGL API VI,总耗时接近一秒。

解决这个问题,要么把所有 API VI 中的 CLN 都改为可重入方式,但编写程序时要保证所有被调用的函数都运行在同一线程内,这比较困难。比较容易实现的是,把程序中对 OpenGL 操作相关的 VI 也全部都设置为在界面线程下运行。我选择的就是后一种方法。改进后的程序,每秒钟画30帧图像也不会占满 CPU。

由此,我也想通了另一个问题。就是我曾经发现调用 Windows API 函数遇到的错误信息丢失的问题。在调用某一 Windows API 函数返回值为0时,表示有错误发生了。这时你可以调用 GetLastErr 和 FormatMessage 得到错误代码和信息。但是我经常遇到的问题是:前一个函数明明返回值为0,但是随后调用的 GetLastErr 函数却无法查到错误代码。 我想这一定是看上去两个函数是先后被 LabVIEW 调用的,但实际上 LabVIEW 在它们之间还要做两次线程切换才行。错误代码就是在线程切换的过程中被丢失了。解决这个问题的办法也是:把调用这三个函数的 CLN 和调用它们的 VI 全部设置为在界面线程下运行就可以了。