可重入 VI 和递归算法
可重入 VI
重入执行是子 VI 的一个属性。默认情况下,VI 是不会被设置这个属性的;需要设置它时,可以在 VI 属性对话框中,选中“重入执行”选项。设置了这一属性的 VI,称为可重入 VI。

如果某个子 VI 被设置为可重入,那么在程序的不同地方调用这个子 VI 时,它会在内存中为每一处的调用各生成一个新的 VI 实例。即,尽管程序在不同地方调用的这个子 VI 的内容都相同,但是这些子 VI 在内存中却是相互独立的。如果是非可重入的子 VI,在内存中只能有一个实例,所有的调用者都访问这个实例。
被设置为重入执行后,还有两个选项:“为各个实例预分配副本”表示每处调用生成的实例 VI 都拥有各自独立的数据区,它们之间的数据互不干扰。本书后续章节提到的可重入 VI,如无特殊说明,都是指这种可重入 VI。“在实例间共享副本”是 LabVIEW 8.5 之后出现的选项,它是指这些实例 VI 使用同一块数据区。
同一 VI 的并行运行
下图是一个简单的 VI,它的程序框图有上下两部分,都调用了同一个子 VI。上下两部分的代码之间没有数据线相连。LabVIEW 是自动多线程的语言,图中的两个子 VI 是否会同时运行呢?
如果程序中调用的是两个毫无关联的不同的子 VI,LabVIEW 通常会同时在不同的线程执行它们;但对于两处调用相同的子 VI,那就得看子 VI 是如何设置的了。如果子 VI “简单运算.vi”是非可重入的,那么它们一定不会同时运行。LabVIEW 一定要等一个执行完,才会执行另一个。
非可重入的子 VI 在内存中只有一个实例,它的代码和数据都只有一份。在不同的地方调用这个子 VI 时,它运行所需的数据内容可能都是不同的。如果多个在不同地方被调用的同一个子 VI 可以同时运行,子 VI 内部的运行状态和数据就可能就会发生混乱。所以 LabVIEW 要禁止这种情况的发生。
VI 的这一特性在有些场合表现出了非常好的优势。比如,一个子 VI,是专门用来读写文件“foo.txt”的。应用程序中可能有多处都调用了这个子 VI 来读写“foo.txt”,如果允许多个线程的程序同时读写它,就很容易造成其内容的混乱。比如一个线程刚刚写入数据 22,另一个线程就写入新的数据 33 覆盖了原来的数据,第一个线程写入数据后立刻读出,却发现读回来的数据与写入的完全不同。同一子 VI 不可在不同线程同时运行的特性恰好保护了“foo.txt”,使它不被不同线程同时读写。
但在某些场合,这一特性又显得非常糟糕。比如,有一个用于读写所有文件的子 VI。不同的文件应当是可以同时被访问的,但这个子 VI 却不允许应用程序同时读写不同的文件。需要同时访问几个文件时,也必须一个一个的访问,暂时不能访问文件的那个线程只好等待着。这样,就造成了程序效率的低下。
在这种场合,只要把子 VI 设置为可重入,就可以在不同线程中同时运行了:一个线程正在运行这个子 VI 时,另一个线程也可以调用它。在 LabVIEW 中,形象地来看,就是在程序框图的某处,数据流入了一个子 VI;在这个框图另外一处,数据也可以片刻不停地同时流入这个子 VI。因此,叫做“可重入”。前面已经讲过,应用程序每处调用可重入的子 VI 时,都生成了一个独立的实例。这就相当于,在应用程序中各处调用的都是不同的子 VI,只不过这些子 VI 内部的代码相同而已。既然是不同的子 VI,当然也就可以在不同的线程中同时运行。
下图是一个延时子 VI 的程序框图,程序执行到这个子 VI 时,如果没有错误,会暂停 1 秒钟,再返回:
下图是一个调用了这个延时子 VI 的应用程序,它并行的调用了这个延时子 VI 两次,那么这个应用程序总的运行时间是多少呢?
如果延时子 VI 是非可重入的,则由于两处调用只能先后分别运行,程序总运行时间为 2 秒。若延时子 VI 是可重入的,则两处调用可以同时运行,程序总运行时间为 1 秒。
可重入 VI 的副本
若可重入 VI 的多个实例共用一份副本,就意味着它们共用同一数据区。不同实例运行时,可能会把不同数据写入这个唯一的数据空间中,这样就造成了数据的混乱。如果需要可重入 VI 不同实例会同时运行,并且它们运行时会使用不同的数据,那么就一定要把这个子 VI 设置为 "为各个实例预分配副本"。
下图是一个简单的子 VI,这个 VI 的功能是每执行一次,输出的数据加一:
这个程序利用了反馈节点。每次运行这个 VI 时,反馈节点首先输出上次 VI 运行后传递给它的数据。VI 在此基础上加一,再返回给反馈节点,以便下次调用时使用。反馈节点下方的 0 是它的初始值,主程序启动后,首次调用这个子 VI 时,反馈节点会输出初始值。
下图是一个调用了“运行次数”子 VI 的应用程序。执行这个程序后,输出“次数 1”和“次数 2”分别是几?
运行结果同样与“运行次数”子 VI 的设置有关。主程序中两个循环执行次数一为 10,一为 20。它们之间没有数据连线,所以可以被同时执行。但哪个循环会先执行完是不确定的。
如果“运行次数”子 VI 是非可重入的,则每次运行完主程序“次数 1”和“次数 2”的值是不确定的,但它们之间必然有一个值为 30。虽然循环运行次序不能确定,但是能够确定,运行次数子 VI 总共被调用了 30 次,所以它最后一次被调用后,输出的值一定是 30。只不过,哪个循环中的 VI 是最后一次被调用的并不确定。
如果运行次数子 VI 是可重入的,并且被设置为“为各个实例预分配副本”,那么主程序的执行结果就会是确定的。“次数 1”的值一定为 10,“次数 2”的值一定为 20。由于子 VI 是可重入的,所以程序中的两处调用行为相当于调用两个不同的子 VI。它们分别运行,不论运行次序如何,左侧的子 VI 被调用了 10 次,而右侧的子 VI 被调用了 20 次。
如果运行次数子 VI 是可重入的,并且被设置为“在实例间共享副本”。那么主程序的执行结果又是不确定的了:“次数 1”和“次数 2”的值可能是小于 30 的任何一个数值。子 VI 可重入,意味着程序中的两处子 VI 调用是可以同时运行的。但是,它们共用一个副本,这就造成了数据的混乱。假如,左侧子 VI 正在运行,内部记录运行次数的数据是 8,而这时右侧子 VI 也同时运行起来,并往内部记录运行次数的数据区写入一个数值 3。这样,左侧子 VI 再读出该数据是就是一个错误数值了。
如果运行次数子 VI 是可重入的,并且被设置为 “在实例间共享副本” (Shared Clone)。那么主程序的执行结果是不确定的,且通常是错误的。这里需要纠正一个常见的误区:“共享副本”并不意味着多个线程会在同一时刻操作同一块内存空间。LabVIEW 采用的是 “克隆池” (Clone Pool) 机制:
当程序需要调用子 VI 时,LabVIEW 会去“池”里找有没有空闲的副本。如果两处调用是同时发生的,LabVIEW 会分配(或创建)两个独立的副本分别给它们使用,互不干扰。但是,当一个副本运行结束,它会带着它内部的数据状态(例如移位寄存器中的值)回到“池”中等待下一次被使用。
数据混乱的真正原因在于“状态残留”: 假设左侧循环先运行了一次,使用了一个副本,将其内部计数器加到了 1,然后该副本回到了池中。接着,右侧循环申请调用子 VI,LabVIEW 恰好把刚才那个计数器为 1 的副本分配给了它。右侧循环本以为自己是从 0 开始计数,结果一上来就是 1。
由于我们无法预测 LabVIEW 何时复用哪个副本,这就导致了结果的不可预测性。因此,带有状态记忆(如未初始化的移位寄存器)的 VI,严禁设置为“在实例间共享副本”,除非你非常清楚自己在做什么。
“在实例间共享副本”虽然会引起数据的混乱,但是它可以大大节约程序的内存。每生成一个副本,就会多消耗一份内存空间。因此,在确保不会发生数据混乱的情况下可以把可重入子 VI 设置为“在实例间共享副本”。对于初学者来说,如果不确定程序中是否有数据混乱的可能性,最好不要使用这一设置。只有一种情况除外,就是在实现递归算法时,参与递归的子 VI 必须被设置为“在实例间共享副本”。