本系列意在记录Windwos线程的相关知识点,包括线程基础、线程调度、线程同步、TLS、线程池等。

多线程同步的难题

我们知道单核处理器同一时刻只能处理一条指令,操作系统通过时间片调度实现了多任务和多线程。在这个过程中,操作系统随时会中断一个线程(这种中断是以指令为单位的),也就是说完全有可能在一个不确定的时候,线程用完了时间片,控制权交给了另一个线程,另一个线程用完时间片,控制权转回,但是这一进一出有可能一个被共享的全局变量的值已经变了!这也许会带来灾难性的后果,也许不会。因此,站在系统层面考虑,每当属于线程的时间片用完之后,系统要把当前CPU寄存器的值(比如,指令寄存器,栈指针寄存器)写入线程内核对象以“保存现场”,当线程再次获得时间片后,应该从内核对象中把上一次的“现场”恢复到CPU寄存器中。

需要强调的是,线程被中断的时间完全不确定。对于CPU来说,真正的“原子操作”应该是一条指令,而不是高级语言的语句。假设 g_x++ 这样的C语句操作需要如下的汇编指令:

MOV EAX, [g_x]
INC EAX
MOV [g_x], EAX

可能执行完第二句指令,新的g_x值还没有回写内存,线程的时间片到了,控制权交给了另外一个线程的,另一个线程也要操作g_x,那么结果将是不可预知的。

可见线程同步的难度似乎比我们想象的要大一些。幸好,Windows或各种语言或者各种类库为我们提供了很多线程同步的方法。这篇开始讨论Win32下的线程同步的话题。

 

原子访问:Interlocked系列函数

为了解决上面对g_x++这样的操作的原子访问(即保证g_x++不会被打断),可以用如下方法:

long g_x = 0;
DWORD WINAPI ThreadFunc1(PVOID pvParam){
	InterlockedExchangeAdd(&g_x,1);
	return(0);
}

DWORD WINAPI ThreadFunc2(PVOID pvParam){
	InterlockedExchangeAdd(&g_x,1);
	return(0);
}

上面代码的InterlockedExchangeAdd保证加法运算以“原子访问”的方式进行。InterlockedExchangeAdd的工作原理根据不同的CPU会有所不同。但是,我们必须保证传给这些Interlocked函数的变量地址是经过对齐的。

所谓对齐,是指数据的地址模除数据的大小应该为0,比如WORD的起始地址应该能被2整除,DWORD的地址能被4整除。x86架构的CPU能够自动处理数据错位,而IA-64的处理器不能处理,而会将错误抛给Windows,Windows能决定是抛出异常还是帮助CPU处理错位。总之,数据错位不会导致错误,但由于CPU将至少多耗费一个读内存操作,因此将影响程序的性能。

InterlockedExchange用于以原子的方式设置一个32位的值,并返回它之前的值,可以用来实现旋转锁(spinlock):

//全局变量指示共享资源是否被占用
BOOL g_fResourceInUse = FALSE;
...
void Func1(){
	//等待共享资源释放
	while ( InterlockedExchange ( &g_fResourceInUse, TRUE ) == TRUE )
		Sleep(0);
	//访问共享资源
	...
	//不再需要共享资源时释放
	InterlockedExchange ( &g_fResourceInUse, FALSE );
}

 

while循环不停的进行,并且设置g_fResourceInUse为TRUE,如果返回值为TRUE表示资源已经被占用,于是线程Sleep(0)意味着线程立即放弃属于自己的时间片,这样将导致CPU调度其他线程。如果返回值为FLASE,表示资源当前没有被占用,可以访问共享资源。不过在使用这项技术的时候要很小心,因为旋转锁将浪费CPU时间。

 

 

高速缓存行与volatile

众所周知,CPU拥有高速缓存,CPU高速缓存的大小是评判CPU性能的一个指标。现如今的CPU一般拥有3级的缓存,CPU总是优先从一级缓存中中读取数据,如果读取失败则会从二级缓存读取数据,最后从内存中读取数据。CPU的缓存由许多缓存行组成,对于X86架构的CPU来说,高速缓存行一般是32个字节。当CPU需要读取一个变量时,该变量所在的以32字节分组的内存数据将被一同读入高速缓存行,所以,对于性能要求严格的程序来说,充分利用高速缓存行的优势非常重要。一次性将访问频繁的32字节数据对齐后读入高速缓存中,减少CPU高级缓存与低级缓存、内存的数据交换。

但是对于多CPU的计算机,情况却又不一样了。例如:

  1. CPU1 读取了一个字节,以及它和它相邻的字节被读入 CPU1 的高速缓存。
  2. CPU2 做了上面同样的工作。这样 CPU1 , CPU2 的高速缓存拥有同样的数据。
  3. CPU1 修改了那个字节,被修改后,那个字节被放回 CPU1 的高速缓存行。但是该信息并没有被写入RAM 。
  4. CPU2 访问该字节,但由于 CPU1 并未将数据写入 RAM ,导致了数据不同步。

当然CPU设计者充分考虑了这点,当一个 CPU 修改高速缓存行中的字节时,计算机中的其它 CPU会被通知,它们的高速缓存将视为无效。于是,在上面的情况下, CPU2 发现自己的高速缓存中数据已无效, CPU1 将立即把自己的数据写回 RAM ,然后 CPU2 重新读取该数据。 可以看出,高速缓存行在多处理器上会导致一些不利。

以上背景知识对于我们编程至少有如下两个意义:

1、有些编译器会对变量进行优化,这种优化可能导致CPU对变量的读取指令始终指向高速缓存,而不是内存。这样的话,当一个变量被多个线程共享的时候,可能会导致一个线程对变量的设置始终无法在另一个线程中体现,因为另一个线程在另一个CPU上运行,并且变量的值在该CPU的高速缓存中!volatile关键字告诉编译器生成的代码始终从内存中读取变量,而不要做类似优化。

2、在多CPU环境下,合理的设置高速缓存对齐,以使得CPU之间的高速缓存同步动作尽量的少发生,以提升性能。要对齐高速缓存,首先要知道目标CPU的高速缓存行的大小,然后用__declspec(align(#))来告诉编译器为变量或结构设置指定符合高速缓存行大小的数据大小,例如:

struct CACHE_ALIGN S1 { // cache align all instances of S1
   int a, b, c, d;
};
struct S1 s1;   // s1 is 32-byte cache aligned

更多内容可参见:http://msdn.microsoft.com/en-us/library/83ythb65.aspx

 

具体的,高速缓存行对齐的目标可以是:在结构中,把经常读操作的字段和经常写操作的字段分开,使得读操作的字段与写操作的字段出现在不同的高速缓存行中。这样就减少了CPU高速缓存行同步的次数,一定程度上提升了性能。

劳动果实,转载请注明出处: http://www.cnblogs.com/P_Chou/archive/2012/06/17/interlocked-in-thread-sync.html