8用户模式下的线程同步.docx
- 文档编号:9848823
- 上传时间:2023-05-21
- 格式:DOCX
- 页数:30
- 大小:34.07KB
8用户模式下的线程同步.docx
《8用户模式下的线程同步.docx》由会员分享,可在线阅读,更多相关《8用户模式下的线程同步.docx(30页珍藏版)》请在冰点文库上搜索。
8用户模式下的线程同步
第8章用户方式中线程的同步
系统中的所有线程都必须拥有对各种系统资源的访问权,这些资源包括内存堆栈,串口,文件,窗口和许多其他资源。
如果一个线程需要独占对资源的访问权,那么其他线程就无法完成它们的工作。
反过来说,也不能让任何一个线程在任何时间都能访问所有的资源。
如果在一个线程从内存块中读取数据时,另一个线程却想要将数据写入同一个内存块,那么这就像你在读一本书时另一个人却在修改书中的内容一样。
这样,书中的内容就会被搞得乱七八糟,结果什么也看不清楚。
线程需要在下面两种情况下互相进行通信:
•当有多个线程访问共享资源而不使资源被破坏时。
•当一个线程需要将某个任务已经完成的情况通知另外一个或多个线程时。
线程的同步包括许多方面的内容,下面几章将分别对它们进行介绍。
值得高兴的是,Windows提供了许多方法,可以非常容易地实现线程的同步。
但是,要想随时了解一连串的线程想要做什么,那是非常困难的。
我们的头脑的工作不是异步的,我们希望以一种有序的方式来思考许多事情,每次前进一步。
不过多线程环境不是这样运行的。
我是在大约1992年的时候开始从事多线程的编程工作的。
最初,我犯过许多编程错误,在我出版的书籍和杂志文章中实际上都存在着与线程同步相关的错误。
现在我的编程工作熟练了许多,但是并未做到完美无缺。
希望本书中的全部内容不存在任何错误(尽管现在我知道我可以做得更好些)。
要搞好线程的同步,唯一的办法是通过实践。
下面几章将要介绍系统是如何运行的,并展示如何实现线程的正确同步,不过应该面对这样一个问题:
即取得经验的同时,难免要犯错误。
8.1原子访问:
互锁的函数家族
线程同步问题在很大程度上与原子访问有关,所谓原子访问,是指线程在访问资源时能够确保所有其他线程都不在同一时间内访问相同的资源。
让我们来看一看下面这个简单例子:
//Defineaglobalvariable.
longg_x=0;
DWORDWINAPIThreadFunc1(PVOIDpvParam)
{
g_x++;
return(0);
}
DWORDWINAPIThreadFunc2(PVOIDpvParam)
{
g_x++;
return(0);
}
在这个代码中,声明了一个全局变量g_x,并将它初始化为0。
现在,假设创建两个线程,一个线程执行ThreadFunc1,另一个线程执行ThreadFunc2。
这两个函数中的代码是相同的,它们都将1添加给全局变量g_x。
因此,当两个线程都停止运行时,你可能希望在g_x中看到2这个值。
但是你真的看到了吗?
回答是,也许看到了。
根据代码的编写方法,你无法说明g_x中最终包含了什么东西。
下面我们来说明为什么会出现这种情况。
假设编译器生成了下面这行代码,以便将g_x递增1:
MOVEAX,[g_x];Movethevalueing_xintoaregister.
INCEAX;Incrementthevalueintheregister.
MOV[g_x],EAX;Storethenewvaluebacking_x.
两个线程不可能在完全相同的时间内执行这个代码。
因此,如果一个线程在另一个线程的后面执行这个代码,那么下面就是实际的执行情况:
MOVEAX,[g_x];Thread1:
Move0intoaregister.
INCEAX;Thread1:
Incrementtheregisterto1.
MOV[g_x],EAX;Thread1:
Store1backing_x.
MOVEAX,[g_x];Thread2:
Move1intoaregister.
INCEAX;Thread2:
Incrementtheregisterto2.
MOV[g_x],EAX;Thread2:
Store2backing_x.
当两个线程都将g_x的值递增之后,g_x中的值就变成了2。
这很好,并且正是我们希望的:
即取出零(0),两次将它递增1,得出的值为2。
太好了。
不过不要急,Windows是个抢占式多线程环境。
一个线程可以随时中断运行,而另一个线程则可以随时继续执行。
这样,上面的代码就无法完全按编写的那样来运行。
它可能按下面的形式运行:
MOVEAX,[g_x];Thread1:
Move0intoaregister.
INCEAX;Thread1:
Incrementtheregisterto1.
MOVEAX,[g_x];Thread2:
Move0intoaregister.
INCEAX;Thread2:
Incrementtheregisterto1.
MOV[g_x],EAX;Thread2:
Store1backing_x.
MOV[g_x],EAX;Thread1:
Store1backing_x.
如果代码按这种形式来运行,g_x中的最后值就不是2,而是你预期的1。
这使人感到非常担心,因为你对调度程序的控制能力非常小。
实际上,如果有100个线程在执行相同的线程函数,当它们全部退出之后,g_x中的值可能仍然是1。
显然,软件开发人员无法在这种环境中工作。
我们希望在所有情况下两次递增0产生的结果都是2。
另外,不要忘记,编译器生成代码的方法,哪个CPU在执行这个代码,以及主计算机中安装了多少个CPU等因素,决定了产生的结果可能是不同的。
这就是该环境的运行情况,我们对此无能为力。
但是,Windows确实提供了一些函数,如果正确地使用这些函数,就能确保产生应用程序的代码得到的结果。
为了解决上面的问题,需要某种比较简单的方法。
我们需要一种手段来保证值的递增能够以原子操作方式来进行,也就是不中断地进行。
互锁的函数家族提供了我们需要的解决方案。
互锁的函数尽管用处很大,而且很容易理解,却有些让人望而生畏,大多数软件开发人员用得很少。
所有的函数都能以原子操作方式对一个值进行操作。
让我们看一看下面这个InterlockedExchangeAdd函数:
LONGInterlockedExchangeAdd(
PLONGplAddend,
LONGIncrement);
这是个最简单的函数了。
只需调用这个函数,传递一个长变量地址,并指明将这个值递增多少即可。
但是这个函数能够保证值的递增以原子操作方式来完成。
因此可以将上面的代码重新编写为下面的形式:
//Defineaglobalvariable.
longg_x=0;
DWORDWINAPIThreadFunc1(PVOIDpvParam)
{
InterlockedExchangeAdd(&g_x,1);
return(0);
}
DWORDWINAPIThreadFunc2(PVOIDpvParam)
{
InterlockedExchangeAdd(&g_x,1);
return(0);
}
通过这个小小的修改,g_x就能以原子操作方式来递增,因此可以确保g_x中的值最后是2。
这样是不是感到好一些?
注意,所有线程都应该设法通过调用这些函数来修改共享的长变量,任何线程都不应该通过调用简单的C语句来修改共享的变量:
//Thelongvariablesharedbymanythreads
LONGg_x;
...
//Incorrectwaytoincrementthelong
g_x++;
...
//Correctwaytoincrementthelong
InterlockedExchangeAdd(&g_x,1);
互锁函数是如何运行的呢?
答案取决于运行的是何种CPU平台。
对于x86家族的CPU来说,互锁函数会对总线发出一个硬件信号,防止另一个CPU访问同一个内存地址。
在Alpha平台上,互锁函数能够执行下列操作:
1)打开CPU中的一个特殊的位标志,并注明被访问的内存地址。
2)将内存的值读入一个寄存器。
3)修改该寄存器。
4)如果CPU中的特殊位标志是关闭的,则转入第二步。
否则,特殊位标志仍然是打开的,寄存器的值重新存入内存。
你也许会问,执行第4步时CPU中的特殊位标志是如何关闭的呢?
答案是:
如果系统中的另一个CPU试图修改同一个内存地址,那么它就能够关闭CPU的特殊位标志,从而导致互锁函数返回第二步。
不必清楚地了解互锁函数是如何工作的。
重要的是要知道,无论编译器怎样生成代码,无论计算机中安装了多少个CPU,它们都能保证以原子操作方式来修改一个值。
还必须保证传递给这些函数的变量地址正确地对齐,否则这些函数就会运行失败(第13章将介绍数据对齐问题)。
对于互锁函数,需要了解的另一个重要问题是,它们运行的速度极快。
调用一个互锁函数通常会导致执行几个CPU周期(通常小于50),并且不会从用户方式转换为内核方式(通常这需要执行1000个CPU周期)。
当然,可以使用InterlockedExchangeAdd减去一个值—只要为第二个参数传递一个负值。
InterlockedExchangeAdd将返回在*plAddend中的原始值。
下面是另外两个互锁函数:
LONGInterlockedExchange(PLONGplTarget,
LONGlValue);
PVOIDInterlockedExchangePointer(PVOID*ppvTarget,
PVOIDpvValue);
InterlockedExchange和InterlockedExchangePointer能够以原子操作方式用第二个参数中传递的值来取代第一个参数中传递的当前值。
如果是32位应用程序,两个函数都能用另一个32位值取代一个32位值。
但是,如果是个64位应用程序,那么InterlockedExchange能够取代一个32位值,而InterlockedExchangePointer则取代64位值。
两个函数都返回原始值。
当实现一个循环锁时,InterlockedExchange是非常有用的:
//Globalvariableindicatingwhetherasharedresourceisinuseornot
BOOLg_fResourceInUse=FALSE;
...
voidFunc1()
{
//Waittoaccesstheresource.
while(InterlockedExchange(&g_fResourceInUse,TRUE)==TRUE)
Sleep(0);
//Accesstheresource.
...
//Wenolongerneedtoaccesstheresource.
InterlockedExchange(&g_fResourceInUse,FALSE);
}
while循环是循环运行的,它将g_fResourceInUse中的值改为TRUE,并查看它的前一个值,以了解它是否是TRUE。
如果这个值原先是FALSE,那么该资源并没有在使用,而是调用线程将它设置为在用状态并退出该循环。
如果前一个值是TRUE,那么资源正在被另一个线程使用,while循环将继续循环运行。
如果另一个线程要执行类似的代码,它将在while循环中运行,直到g_fResourceInUse重新改为FALSE。
调用函数结尾处的InterlockedExchange,可显示应该如何将g_fResourceInUse重新设置为FALSE。
当使用这个方法时必须格外小心,因为循环锁会浪费CPU时间。
CPU必须不断地比较两个值,直到一个值由于另一个线程而“奇妙地”改变为止。
另外,该代码假定使用循环锁的所有线程都以相同的优先级等级运行。
也可以把执行循环锁的线程的优先级提高功能禁用(通过调用SetProcessPriorityBoost或setThreadPriorityBoost函数来实现之)
此外,应该保证将循环锁变量和循环锁保护的数据维护在不同的高速缓存行中(本章后面部分介绍)。
如果循环锁变量与数据共享相同的高速缓存行,那么使用该资源的CPU将与试图访问该资源的任何CPU争用高速缓存行。
应该避免在单个CPU计算机上使用循环锁。
如果一个线程正在循环运行,它就会浪费前一个CPU时间,这将防止另一个线程修改该值。
我在上面的while循环中使用了Sleep,从而在某种程度上解决了浪费CPU时间的问题。
如果使用Sleep,你可能想睡眠一个随机时间量;每次请求访问该资源均被拒绝时,你可能想进一步延长睡眠时间。
这可以防止线程浪费CPU时间。
根据情况,最好是全部删除对Sleep的调用。
或者使用对SwitchToThread(Windows98中没有这个函数)的调用来取代它。
勇于试验和不断纠正错误,是学习的最好方法。
循环锁假定,受保护的资源总是被访问较短的时间。
这使它能够更加有效地循环运行,然后转为内核方式并进入等待状态。
许多编程人员循环运行一定的次数(比如400次),如果对资源的访问仍然被拒绝,那么该线程就转为内核方式,在这种方式下,它要等待(不消耗CPU时间),直到该资源变为可供使用为止。
这就是关键部分实现的方法。
循环锁在多处理器计算机上非常有用,因为当一个线程循环运行的时候,另一个线程可以在另一个CPU上运行。
但是,即使在这种情况下,也必须小心。
不应该让线程循环运行太长的时间,也不能浪费更多的CPU时间。
本章后面将进一步介绍循环锁。
第10章将介绍如何使用循环锁。
下面是最后两个互锁函数:
PVOIDInterlockedCompareExchange(PLONGplDestination,
LONGlExchange,LONGlComparand);
PVOIDInterlockedCompareExchangePointer(PVOID*ppvDestination,
PVOIDpvExchange,PVOIDpvComparand);
这两个函数负责执行一个原子测试和设置操作。
如果是32位应用程序,那么两个函数都在32位值上运行,但是,如果是64位应用程序,InterlockedCompareExchange函数在32位值上运行,而InterlockedCompareExchangePointer函数则在64位值上运行。
在伪代码中,它的运行情况如下面所示:
LONGInterlockedCompareExchange(PLONGplDestination,
LONGlExchange,LONGlComparand)
{
LONGlRet=*plDestination;//Originalvalue
if(*plDestination==lComparand)
*plDestination=lExchange;
return(lRet);
}
该函数对当前值(plDestination参数指向的值)与lComparand参数中传递的值进行比较。
如果两个值相同,那么*plDestination改为lExchange参数的值。
如果*plDestination中的值与lExchange的值不匹配,*plDestination保持不变。
该函数返回*plDestination中的原始值。
记住,所有这些操作都是作为一个原子执行单位来进行的。
没有任何互锁函数仅仅负责对值进行读取操作(而不改变这个值),因为这样的函数根本是不需要的。
如果线程只是试图读取值的内容,而这个值始终都由互锁函数来修改,那么被读取的值总是一个很好的值。
虽然你不知道你读取的是原始值还是更新值,但是你知道它是这两个值中的一个。
对于大多数应用程序来说,这一点很重要。
此外,当要对共享内存区域(比如
内存映象文件)中的值的访问进行同步时,互锁函数也可以供多进程中的线程使用(第9章中包含了几个示例应用程序,以显示如何正确地使用互锁函数)。
虽然Windows还提供了另外几个互锁函数,但是上面介绍的这些函数能够实现其他函数能做的一切功能,甚至更多。
下面是两个其他的函数:
LONGInterlockedIncrement(PLONGplAddend);
LONGInterlockedDecrement(PLONGplAddend);
InterlockedExchangeAdd函数能够取代这些较老的函数。
新函数能够递增或递减任何值,老的函数只能加1或减1。
8.2高速缓存行
如果想创建一个能够在多处理器计算机上运行的高性能应用程序,必须懂得CPU的高速缓存行。
当一个CPU从内存读取一个字节时,它不只是取出一个字节,它要取出足够的字节来填入高速缓存行。
高速缓存行由32或64个字节组成(视CPU而定),并且始终在第32个字节或第64个字节的边界上对齐。
高速缓存行的作用是为了提高CPU运行的性能。
通常情况下,应用程序只能对一组相邻的字节进行处理。
如果这些字节在高速缓存中,那么CPU就不必访问内存总线,而访问内存总线需要多得多的时间。
但是,在多处理器环境中,高速缓存行使得内存的更新更加困难,下面这个例子就说明了这一点:
1)CPU1读取一个字节,使该字节和它的相邻字节被读入CPU1的高速缓存行。
2)CPU2读取同一个字节,使得第一步中的相同的各个字节读入CPU2的高速缓存行。
3)CPU1修改内存中的该字节,使得该字节被写入CPU1的高速缓存行。
但是该信息尚未写入RAM。
4)CPU2再次读取同一个字节。
由于该字节已经放入CPU2的高速缓存行,因此它不必访问内存。
但是CPU2将看不到内存中该字节的新值。
这种情况会造成严重的后果。
当然,芯片设计者非常清楚这个问题,并且设计它们的芯片来处理这个问题。
尤其是,当一个CPU修改高速缓存行中的字节时,计算机中的其他CPU会被告知这个情况,它们的高速缓存行将变为无效。
因此,在上面的情况下,CPU2的高速缓存在CPU1修改字节的值时变为无效。
在第4步中,CPU1必须将它的高速缓存内容迅速转入内存,CPU2必须再次访问内存,重新将数据填入它的高速缓存行。
如你所见,高速缓存行能够帮助提高运行的速度,但是它们也可能是多处理器计算机上的一个不利因素。
这一切意味着你应该将高速缓存行存储块中的和高速缓存行边界上的应用程序数据组合在一起。
这样做的目的是确保不同的CPU能够访问至少由高速缓存行边界分开的不同的内存地址。
还有,应该将只读数据(或不常读的数据)与读写数据分开。
同时,应该将同一时间访问的数据组合在一起。
下面是设计得很差的数据结构的例子:
structCUSTINFO
{
DWORDdwCustomerID;//Mostlyread-only
intnBalanceDue;//Read-write
charszName[100];//Mostlyread-only
FILETIMEftLastOrderDate;//Read-write
};
下面是该结构的改进版本:
//DeterminethecachelinesizeforthehostCPU.
#ifdef_X86_
#defineCACHE_ALIGN32
#endif
#ifdef_ALPHA_
#defineCACHE_ALIGN64
#endif
#ifdef_IA64_
#defineCACHE_ALIGN?
?
#endif
#defineCACHE_PAD(Name,BytesSoFar)\
BYTEName[CACHE_ALIGN-((BytesSoFar)%CACHE_ALIGN)]
structCUSTINFO
{
DWORDdwCustomerID;//Mostlyread-only
charszName[100];//Mostlyread-only
//Forcethefollowingmemberstobeinadifferentcacheline.
CACHE_PAD(bPad1,sizeof(DWORD)+100);
intnBalanceDue;//Read-write
FILETIMEftLastOrderDate;//Read-write
//Forcethefollowingstructuretobeinadifferentcacheline.
CACHE_PAD(bPad2,sizeof(int)+sizeof(FILETIME));
};
上面定义的CACHE_ALIGN宏是不错的,但是并不很好。
问题是必须手工将每个成员变量的字节值输入该宏。
如果增加、移动或删除数据成员,也必须更新对CACHE_PAD宏的调用。
将来,Microsoft的C/C++编译器将支持一种新句法,该句法可以更容易地调整数据成员。
它的形式类似__declspec(align(32))。
注意最好是始终都让单个线程来访问数据(函数参数和局部变量是确保做到这一点的最好方法),或者始终让单个CPU访问这些数据(使用线程亲缘性)。
如果采取其中的一种方法,就能够完全避免高速缓存行的各种问题。
8.3高级线程同步
当必须以原子操作方式来修改单个值时,互锁函数家族是相当有用的。
你肯定应该先试试它们。
但是大多数实际工作中的编程问题要解决的是比单个32位或64位值复杂得
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 用户 模式 线程 同步