多个进程或线程并发执行,其复杂性在于,一旦它们相互之间有依赖关系,则它们的交互过程或计算步骤的顺序将可能存在不确定性,从而使各个进程或线程的正确性难以保证和分析。通常,操作系统会提供一些同步原语供这些进程或线程使用,从而限制它们的计算必须按照特点的约束来进行。另一方面,程序设计语言有可能会提供一些语法要素来保证数据结构的一致性或代码片段的不可重入特性。
我们看一个简单的程序。图5.1显示了一个计算线程和一个控制线程。计算线程根据全局变量g_nCount的值来循环执行计算任务,当g_nCount达到100时,循环结束,从而计算线程退出。控制线程在创建了计算线程以后,在它终止以前,可以通过将g_nCount赋值为100的方式通知计算线程不必再执行计算任务了。因此,计算线程和控制线程通过一个全局变量进行通信。
这两段代码逻辑上看是没有错,并且运行时候也许符合预期的效果,但若仔细检查生成的汇编代码,就发现,如果线程切换发生在图5.1(a)展示的5条汇编指令的第2条或第3条之后,那么,即使控制线程已经将全局变量g_nCount赋值为100,计算线程接下来的mov指令也会覆盖掉刚刚的赋的值,从而使控制线程中的g_nCount=100,这条语句完全不起作用。更有甚者,如果编译优化打开了的话,计算线程的循环过程可能直接通过一个寄存器来控制,而根本不访问g_nCount的内存单元。为避免这种优化,可以将g_nCount变量声明为volatile(volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了)。
这个例子说明,两个或多个线程在用全局变量进行通信时,如果对全局变量的操作不是原子的,则可能存在潜在的副作用。为确保不会有这种不确定的副作用,必须要对关与全局变量的操作进行保护,要么保证所有的操作都是原子的,要么确保对变量的访问总是互斥的。这正是在多线程环境中有必要对关与共享数据的访问进行同步的一个重要原因。
在初步了解了多线程并发执行可能引起的问题以后,我们来看一看这种并发性的来源。在现代操作系统环境中,进程或线程的并发性有以下一些可能的来源:
● 多处理器环境。各个处理器有独立的指令流、寄存器组和片上缓存,这些处理器通过共享的总线访问内存单元。系统的内存结构对于各个处理器可能对称,也可能不对称。
● 多核环境,即一个处理器中的多个内核。各个核有独立的指令流和寄存器组,这些核可能既有独立的缓存,又有共享的缓存。所有的核通过共享的总线访问内存单元。
● 超线程环境。在一个处理器中可以运行多个指令流,但并不是所有的指令都可以并行执行。超线程处理器虽然有多个寄存器组,但只有一套缓存和计算单元(包括整数和浮点数),所以,这些指令流并不能完全地并行执行。
● 处理器外部中断。外部设备通过APIC(高级可编程中断控制器)与Intel x86处理器通信。当外部设备要跟处理器通信时,会通过一个连接至I/O APIC的引脚中断处理器,并告诉它一个终端号。只要处理器没有屏蔽中断,它的指令流就会转到一个预先设定的地址处,进行中断处理。因此,处理器上正在执行的指令流可能会被外部设备中断。比较典型的外部中断是I/O设备的中断和时钟定时器中断。操作系统利用时钟中断来调度线程。另外,在I/O中断的处理例程中,可能需要访问与I/O相关的数据结构。
● 内部中断。即使没有外部中断,一个正在执行的指令流也有可能被打断,比如一条指令引发了一个异常,从而使处理器转到了一个异常处理例程中;或者,指令流本身引发了一个软件中断,从而进入了一个中断处理例程中。
● 线程放弃执行权。线程有可能在执行过程中主动放弃执行权,从而使其他的线程获得处理器资源。当线程执行I/O操作时,这是一种很常见的情形。
从以上并发性来源可以看出,一个线程在其执行过程中会经历各种不确定因素,如果它操作的内存单元也有可能被其他的线程访问,则这些不确定的并发访问有可能带来灾难性后果,因此,对共享数据的保护是多线程程序设计中不可缺少的一个环节。而操作系统的任务是,提供多种同步原语,供系统模块或应用程序用来保护它们的数据。
正如我们在本节前面的例子中所看到的那样,为了保护对一个内存单元(比如全局变量)的操作,我们必须使该操作成为一个原子操作,也就是说,这个操作不可被打断,其中间状态不会被其他线程看到。显然,像g_nCount++这样的操作(被编译成三条指令)不满足这一条件。实际上,由处理器来提供一些基本的原子运算在这种情况下非常有意义。
在Intel x86指令体系中,有些运算指令加上lock前缀就可以保证其原子性。以下是使用lock前缀的两个条件:
1.指令的目标操作数必须是一个内存操作数;
2.仅适用于ADD,ADC,AND,BTC,BTR,BTS,CMPXCHG,CMPXCH8B,DEC,INC,NEG,NOT,OR,SBB,SUB,XOR,XADD和XCHG指令。这些指令的含义见下图
Windows充分利用了这些原子指令来实现一些基本的数据保护。譬如,在WRK中,InterlockedIncrement实现了原子的加一操作。下面的代码可以在多处理器上工作。
LONG FASTCALL InterlockedIncrement(IN OUT LONG volatile *Addend)
{
__asm {
mov eax,1
mov ecx,Addend
lock xadd [ecx],eax
inc eax
}
}
带lock前缀的xadd指令实现了在内存单元Addend中加一的原子操作,如果另一个线程往Addend内存单元中赋值100,则此赋值要么在加一前完成,要么在加一后完成,而不会被覆盖掉,从而避免了本节例子中描述的潜在问题。