为什么需要线程同步:
为了避免在一线程对以数据操作过程中(一进行一部分操作但是尚未完成)CPU时间片耗尽当前线程挂起时,另一进城对修改不完全的数据进行操作。如下面这段书上的代码所示:
//Define a global variable. long g_x = 0; DWORD WINAPI ThreadFunc1(PVOID pvParam) { g_x++; return(0); } DWORD WINAPI ThreadFunc2(PVOID pvParam) { g_x++; return(0); }
最理想的汇编代码如下:
MOV EAX, [g_x] ;线程一:将0移入寄存器
INC EAX ;线程一::使寄存器自增一
MOV [g_x], EAX ;线程一:存储寄存器值到全局变量
MOV EAX, [g_x] ;线程二:将1移入寄存器
INC EAX ;线程二:寄存器自增到2
MOV [g_x], EAX ;线程二:将2移入全局变量
可是我们无法保证线程2会等待线程1执行完上述一系列操作才运行,汇编的结果可能是这样:
MOV EAX, [g_x] ;线程一:将0移入寄存器
INC EAX ;线程一::使寄存器自增一
MOV EAX, [g_x] ;线程二:将0移入寄存器
INC EAX ;线程二:寄存器自增到2
MOV [g_x], EAX ;线程一:存储寄存器值到全局变量
MOV [g_x], EAX ;线程二:存储寄存器值到全局变量
....悲剧发生了,现在g_x的值是1并不是我们需要的2.
如何实现线程同步:
1.保证操作以原子方式进行。
所谓原子访问,是指线程在访问资源时能够确保所有其他线程都不在同一时间内访问相同的资源。
//这个函数保证操作以原子方式进行 //返回原来的值 LONG InterlockedExchangeAdd( PLONG plAddend, //变量地址 LONG Increment, //要递增的值 );
现在要做的事简单了只需要用InterlockedExchangeAdd代替原来的g_x++;就可以解决进程同步的问题了。
使用这个函数要注意:
(1)所有线程中对共享变量的操作都要用InterlockedExchangeAdd代替简单的C++语句访问。
(2)传递给函数的地址必须是正确对齐的
(3)这些函数运行的速度极快通常小于50周期
2.循环锁,旋转锁
// 全局变量标示这个资源是否正在被使用 BOOL g_fResourceInUse = FALSE; … void Func1() { //等待访问这个资源,不停的假装告诉系统我正在使用这个资源 //但是,是通过InterlockedExchange这个原子函数告诉系统的, //这意味着如果这个资源正被别的线程使用的时候,CPU不会让这件事变为事实 //但是呢如果,没线程使用访问改变量的话,当前线程就得逞了。 //看来主动一些就会有收获的, //接下来这个非常主动的线程将要跳出辛苦的循环请求过程享受他的成果了。 while(InterlockedExchange(&g_fResourceInUse, TRUE) == TRUE) Sleep(0); //访问刚占有的资源 //Access the resource. … //We no longer need to access the resource. //我不用了,你拿去玩吧。 //其实可能有好多和他一样的进程在做一样的事 InterlockedExchange(&g_fResourceInUse, FALSE); }
使用旋转锁应该注意的地方:
(1)使用这种同步方式,我们需要保证需要同步的进程是运行在同一优先级的,如果两个线程优先级为6,另一个为4,那么优先级为4的线程不会得到等待资源的机会。
(2)应该避免资源标示和资源本身在同一告诉缓存行
(3)应避免在单处理器的系统上使用旋转锁
高速缓存行:
当一个C P U从内存读取一个字节时,它不只是取出一个字节,它要取出足够的字节来填入高速缓存行。高速缓存行的作用是为了提高C P U运行的性能。通常情况下,应用程序只能对一组相邻的字节进行处理。如果这些字节在高速缓存中,那么C P U就不必访问内存总线,而访问内存总线需要多得多的时间。
高速缓存行在多处理器系统中引发的问题
CPU1和CPU2读取同一内存数据把相同地址的内容放入高速缓存行,CPU1修改高速缓存行内容,CPU2再次访问同一内容 (此时不需要再读内存,因为数据已在高速缓存行中,但是悲剧了...因为CPU2想访问的内容已经被CPU1修改但是还没写入内存,)其实这种情况不会发生,因为处理器设计者会在CPU1修改告诉缓存的时候向每一个处理器发出通知告诉CPU2你的高速缓存行已经无效了,然后让CPU1将缓存数据写入内 存,CPU2更新缓存行。
引用书上的两段示例代码
读写混放的数据结构(只读与读写的数据放入同一个高速缓存,不好的做法):
struct CUSTINFO { DWORD dwCustomerID; //Mostly read-only int nBalanceDue; //Read-write char szName[100]; //Mostly read-only FILETIME ftLastOrderDate; //Read-write }; 改版后的结构定义: // Determine the cache line size for the host CPU. //为各种CPU定义告诉缓存行大小 #ifdef _X86_ #define CACHE_ALIGN 32 #endif #ifdef _ALPHA_ #define CACHE_ALIGN 64 #endif #ifdef _IA64_ #define CACHE_ALIGN ?? #endif #define CACHE_PAD(Name, BytesSoFar) \ BYTE Name[CACHE_ALIGN - ((BytesSoFar) % CACHE_ALIGN)] struct CUSTINFO { DWORD dwCustomerID; // Mostly read-only char szName[100]; // Mostly read-only //Force the following members to be in a different cache line. //这句很关键用一个算出来的Byte来填充空闲的告诉缓存行 //如果指定了告诉缓存行的大小可以简写成这样 //假设sizeof(DWORD) + 100 = 108;告诉缓存行大小为32 //BYTE[12]; //作用呢就是强制下面的数据内容与上面数据内容不在同一高速缓存行中。 CACHE_PAD(bPad1, sizeof(DWORD) + 100); int nBalanceDue; // Read-write FILETIME ftLastOrderDate; // Read-write //Force the following structure to be in a different cache line. CACHE_PAD(bPad2, sizeof(int) + sizeof(FILETIME)); };
最优的高速缓存行数据问题解决办法就是让一个线程或处理器来访问指定的数据...那对数据的操作就相当于单线程了,恩的确完美。
3.高级线程同步方式
前面说的两种方法(原子访问,旋转锁),消耗等待访问共享资源的CPU时间。最好有下面一种方式:
当线程想要访问共享资源,或者得到关于某个“特殊事件”的通知时,该线程必须调用一个操作系统函数,给它传递一些参数,以指明该线程正在等待什么。如果操作系统发现资源可供使用,或者该特殊事件已经发生,那么函数就返回,同时该线程保持可调度状态(该线程可以不必立即执行,它处于可 调度状态,)
如果资源不能使用,或者特殊事件还没有发生,那么系统便使该线程处于等待状态,使该线程无法调度。这可以防止线程浪费C P U时间。当线程处于等待状态时,系统作为一个代理,代表你的线程来执行操作。系统能够记住你的线程需要什么,当资源可供使用的时候,便自动使该线程退出等待状态,该线程的运行将与特殊事件实现同步。
从实际情况来看,大多数线程几乎总是处于等待状态。当系统发现所有线程有若干分钟均处于等待状态时,系统的强大的管理功能就会发挥作用。
4.应该避免的方式
volatile BOOL g_fFinishedCalculation = FALSE; int WINAPI WinMain(…) { CreateThread(…, RecalcFunc, …); … //Wait for the recalculation to complete. while(!g_fFinishedCalculation) ; … } DWORD WINAPI RecalcFunc(PVOID pvParam) { //Perform the recalculation. … g_fFinishedCalculation = TRUE; return(0); }
存在的缺点:
(1)浪费CPU时间
(2)主线程优先级必须和新建线程优先级相同,否则g_fFinishedCalculation永远不会被置True主线程将无限循环
(3)volatile关键字的作用,告诉编译器不要对变量进行任何的优化,始终在内存中获取fFinishedCalculation的值,
如果没有这个关键字的话汇编代码会是这样:
MOV Reg0, [g_fFinishedCalculation] ;Copy the value into a register
Label: TEST Reg0, 0 ;Is the value 0?
JMP Reg0 == 0, Label ;The register is 0, try again
… ;The register is not 0 (end of loop)
fFinishedCalculation的值会被放进寄存器...而且这个操作只进行一次,
fFinishedCalculation的值再也不会被改变了哪怕&fFinishedCalculation的值发生了变化我们的循环将会无限进行下去。