《解决多线程中11个常见问题.docx》由会员分享,可在线阅读,更多相关《解决多线程中11个常见问题.docx(13页珍藏版)》请在课桌文档上搜索。
1、并发危急解决多线程代码中的11个常见的问题JoeDuffy本文将介绍以下内容:本文使用了以下技术:基本并发概念多线程、.NETFramework并发问题和抑制措施实现平安性的模式横切概念名目数据争用遗忘同步粒度错误读写撕裂无锁定重新排序重新进入死锁锁爱护戳记两步舞曲优先级反转实现平安性的模式不变性纯度隔离并发觉象无处不在。服务器端程序长期以来都必需负责处理基本并发编程模型,而随着多核处理器的日益普及,客户端程序也将需要执行一些任务。随着并发操作的不断增加,有关确保平安的问题也消失出来。也就是说,在面对大量规律并发操作和不断变化的物理硬件并行性程度时,程序必需连续保持同样级别的稳定性和牢靠性。与
2、对应的挨次代码相比,正确设计的并发代码还必需遵循一些额外的规章。对内存的读写以及对共享资源的访问必需使用同步机制进行管制,以防发生冲突。此外,通常有必要对线程进行协调以协同完成某项工作。这些附加要求所产生的直接结果是,可以从根本上确保线程始终保持全都并且保证其顺当向前推动。同步和协调对时间的依靠性很强,这就导致了它们具有不确定性,难于进行猜测和测试。这些属性之所以让人觉得有些困难,只是由于人们的思路还未转变过来。没有可供学习的特地API,也没有可进行复制和粘贴的代码段。实际上的确有一组基础概念福要您学习和适应。很可能随着时间的推移某些语言和库会隐蔽一些概念,但假如您现在就开头执行并发操作,则不
3、会遇到这种状况。本文将介绍需要留意的一些较为常见的挑战,并针对您在软件中如何运用它们给出一些建议。首先我将争论在并发程序中常常会出错的一类问题。我把它们称为“平安隐患”,由于它们很简洁发觉并且后果通常比较严峻。这些危急会导致您的程序因崩溃或内存问题而中断。当从多个线程并发访问数据时会发生数据争用(或竞争条件)。特殊是,在一个或多个线程写入一段数据的同时,假如有一个或多个线程也在读取这段数据,则会发生这种状况。之所以会消失这种问题,是由于Windows程序(如C+和Microsoft.NETFramework之类的程序)基本上都基于共享内存概念,进程中的全部线程均可访问驻留在同一虚拟地址空间中的
4、数据。静态变量和堆安排可用于共享。请考虑下面这个典型的例子:staticclassCounterinternalstaticints_curr=0;internalstaticintGetNext()returns_curr+;Counter的目标可能是想为GetNext的每个调用分发一个新的唯一数字。但是,假如程序中的两个线程同时调用GetNext,则这两个线程可能被给予相同的数字。缘由是s-curr+编译包括三个独立的步骤:.将当前值从共享的S-CUIT变量读入处理器寄存器。.递增该寄存器。.将寄存器值重新写入共享s_curr变量。依据这种挨次执行的两个线程可能会在本地从s_curr读取了
5、相同的值(比如42)并将其递增到某个值(比如43),然后发布相同的结果值。这样一来,GetNext将为这两个线程返回相同的数字,导致算法中断。虽然简洁语句s,curr看似不行分割,但实际却并非如此。遗忘同步这是最简洁的一种数据争用状况:同步被完全遗忘。这种争用很少有良性的状况,也就是说虽然它们是正确的,但大部分都是由于这种正确性的根基存在问题。这种问题通常不是很明显。例如,某个对象可能是某个大型简单对象图表的一部分,而该图表恰好可使用静态变量访问,或在创建新线程或将工作排入线程池时通过将某个对象作为闭包的一部分进行传递可变为共享图表。当对象(图表)从私有变为共享时,肯定要多加留意。这称为发布,
6、在后面的隔离上下文中会对此加以争论。反之称为私有化,即对象(图表)再次从共享变为私有。百竺种问题的解决方案是添加正确的日楚计数器示例中,我可以使用简洁的联锁:staticclassCounterinternalstaticvolatileints_curr=0;internalstaticintGetNext()returnInterlocked.Increment(refscurr);)它之所以起作用,是由于更新被限定在单一内存位置,还由于(这一点特别便利)存在硬件指令(LOCKINC),它相当于我尝试进行原子化操作的软件语句。或者,我可以使用成熟的锁定:staticclassCounter
7、internalstaticints_curr=0;privatestaticobjects_currLock=newobject();internalstaticintGetNext()lock(s_currLock)returns_curr+;)lock语句可确保试图访问GetNext的全部线程彼此之间互斥,并且它使用CLRSystem1Threading.Monitor类。C+程序使用CRITICAL_SECTION来实现相同目的。虽然对这个特定的示例不必使用锁定,但当涉及多个操作时,几乎不行能将其并入单个互锁操作中。粒度错误即使使用正确的同步对共享状态进行访问,所产生的行为仍旧可能是错
8、误的。粒度必需足够大,才能将必需视为原子的操作封装在此区域中。这将导致在正确性与缩小区域之间产生冲突,由于缩小区域会削减其他线程等待同步进入的时间。例如,让我们看一看图1所示的银行帐户抽象。一切都很正常,对象的两个方法(DePoSit和W汕draw)看起来不会发生并发错误。一些银行业应用程序可能会使用它们,而且不担忧余额会由于并发访问而遭到损坏。图1银行帐户classBankAccountprivatedecimalm_balance=0.0M;privateobjectm_balanceLock=newobject();internalvoidDeposit(decimaldelta)loc
9、k(m_balanceLock)m_balance+=delta;internalvoidWithdraw(decimaldelta)lock(mbalanceLock)if(m_balancedelta)thrownewException(Insufficientfunds);m_balance-=delta;)但是,假如您想添加一个TranSfer方法该怎么办?一种天真的(也是不正确的)想法会认为由于Deposit和Withdraw是平安隔离的,因此很简洁就可以合并它们:classBankAccountinternalstaticvoidTransfer(BankAccounta,Bank
10、Accountbzdecimaldelta)Withdraw(a,delta);Deposit(bzdelta);)/Asbefore)这是不正确的。实际上,在执行Withdraw与Deposit调用之间的一段时间内资金会完全丢失。正确的做法是必需提前对a和b进行锁定,然后再执行方法调用:classBankAccountinternalstaticvoidTransfer(BankAccounta,BankAccountbzdecimaldelta)lock(a.m_balanceLock)lock(b.m_balanceLock)Withdraw(azdelta);Deposit(bzdel
11、ta);)/Asbefore事实证明,此方法可解决粒度问题,但却简洁发生死锁。稍后,您会了解到如何修复它。读写撕裂如前所述,良性争用允许您在没有同步的状况下访问变量。对于那些对齐的、自然分割大小的字一例如,用指针分割大小的内容在32位处理器中是32位的(4字节),而在64位处理器中则是64位的(8字节)一读写操作是原子的。假如某个线程只读取其他线程将要写入的单个变量,而没有涉及任何简单的不变体,则在某些状况下您完全可以依据这一保证来略过同步。但要留意。假如试图在未对齐的内存位置或未采纳自然分割大小的位置这样做,可能会遇到读写撕裂现象。之所以发生撕裂现象,是由于此类位置的读或写实际上涉及多个物理
12、内存操作。它们之间可能会发生并行更新,并进而导致其结果可能是之前的值和之后的值通过某种形式的组合。例如,假设ThreadA处于循环中,现在需要仅将OXoL和OXaaaabbbbCCCCddddL写入64位变量s_x中。ThreadB在循环中读取它(参见图2)。图2将要发生的撕裂现象internalstaticvolatilelongs_x;voidThreadAOinti=0;while(true)s_x=(i&1)=0?OxOL:OxaaaabbbbccccddddL;i+;)voidThreadBOwhile(true)longx=s_x;Debug.Assert(x=OxOLIIx=Ox
13、aaaabbbbccccddddD;)您可能会惊异地发觉ThreadB的声明可能会被触发。缘由是ThreadA的写入操作包含两部分(高32位和低32位),具体挨次取决于编译器。ThreadB的读取也是如此。因此ThreadB可以见证值OxaaaabbbbOOOOOOOOL或Ox(XXXX)OOOaaaabbbbLo无锁定重新排序有时编写无锁定代码来实现更好的可伸缩性和牢靠性是一种特别迷人的想法。这样做需要深化了解目标平台的内存模型(有关具体信息,请参阅VanCeMOiTiSon的文章MemoryModelsrUnderstand(heImpactofLow-LockTechniquesinMu
14、ltithreadedApps,网址为)。假如不了解或不留意这些规章可能会导致内存重新排序错误。之所以发生这些错误,是由于编译器和处理器在处理或优化期间可自由重新排序内存操作。例如,假设s_x和s_y均被初始化为值0,如下所示:internalstaticvolatileints_x=0;internalstaticvolatileintsxa=0;internalstaticvolatileints_y=0;internalstaticvolatileints_ya=0;voidThreadA()sx=1;s_ya=s_y;voidThreadBOs_y=1;s_xa=s_x;)是否有可能在
15、ThreadA和ThreadB均运行完成后,s_ya和s_xa都包含值0?看上去这个问题很可笑。或者s_x=l或者s_y=l会首先发生,在这种状况下,其他线程会在开头处理其自身的更新时见证这一更新。至少理论上如此。圆满的是,处理器随时都可能重新排序此代码,以使在写入之前加教操作更有效。您可以借助一个显药场屏障来避开此问题voidThreadAOs_x=1;Thread-MemoryBarrier();s_ya=s_y;.NETFramework为此供应了一个特定API,C+供应了.MemoryBarrier和类似的宏。但这个示例并不是想说明您应当在各处都插入内存屏障。它要说明的是在完全弄清内存
16、模型之前,应避开使用无锁定代码,而且即使在完全弄清之后也应谨慎行事。在Windows(包括Win32和.NETFramework)中,大多数锁定都支持递归获得。这只是意味着,即使当前线程已持有锁但当它试图再次获得时,其要求仍会得到满意。这使得通过较小的原子操作构成较大的原子操作变得更加简洁。实际上,之前给出的BankAccount示例依靠的就是递归获得:Transfer对Withdraw和Deposit都进行了调用,其中每个都重复获得了Transfer已获得的锁定。但是,假如最终发生了递归获得操作而您实际上并不盼望如此,则这可能就是问题的根源。这可能是由于重新进入而导致的,而发生重新进入的缘由
17、可能是由于对动态代码(如虚拟方法和托付)的显式调用或由于隐式重新输入的代码(如STA消息提取和异步过程调用)。因此,最好不要从锁定区域对动态方法进行调用。例如,设想某个方法临时破坏了不变体,然后又调用托付:classCprivateintm_x=O;privateobjectm_xLock=newobject();privateActionm_action=internalvoidM()lock(m_xLock)m_x+;trym_action();finallyDebug.Assert(m_x=1);m_x一;)C的方法M可确保m_x不发生转变。但会有很短的一段时间,m_x会先递增1,然后再
18、重新递减。对m.action的调用看起来没有任何问题。圆满的是,假如它是从C类用户接受的托付,则表示任何代码都可以执行它所恳求的操作。这包括回调到同一实例的M方法。假如发生了这种状况,finally中的声明可能会被触发;同一堆栈中可能存在多个针对M的活动的调用(即使您未直接执行此操作),这必定会导致m_x包含的值大于k当多个线程遇到死锁时,系统会直接停止响应。多篇MSDN杂志文章都介绍了死锁的发生缘由以及使死锁变得能够接受的一些方法,其中包括我自己的文章NoMoreHangsiAdvancedTechniques(oAvoidandDetectDeadlocksin.NETApps(网址为)以
19、及StephenToub的2007年IO月.NET相关问题专栏(网址为),因此这里只做简洁的争论。总而言之,只要消失了循环等待链一例如,ThreadA正在等待ThreadB持有的资源,而ThreadB反过来也在等待ThreadA持有的资源(或许是间接等待第三个ThreadC或其他资源)一则全部向前的推动工作都可能会停下来。此问题的常见根源是互斥锁。实际上,之前所示的BankAccount示例遇到的就是这个问题。假如ThreadA试图将$500从帐户#1234转移到帐户#5678,与此同时ThreadB试图将$500从#5678转移到#1234,则代码可能发生死锁。使用全都的获得挨次可避开死锁,
20、如图3所示。此规律可概括为“同步锁获得”之类的名称,通过此操作可依照各个锁之间的某种挨次动态排序多个可锁定的对象,从而使得在以全都的挨次获得两个锁的同时必需维持两个锁的位置。另一个方案称为“锁矫正”,可用于拒绝被认定以不全都的挨次完成的锁获得。图3全都的获得挨次classBankAccountprivateintm_id;/UniquebankaccountID.internalstaticvoidTransfer(BankAccounta,BankAccountb,decimaldelta)if(a.m_idb.m_id)Monitor.Enter(a.m_balanceLock);/Afi
21、rstMonitor-Enter(b.m_balanceLock);/.andthenBelseMonitor-Enter(b.m_balanceLock);/BfirstMonitor.Enter(a.m_balanceLock);/.andthenAtry(Withdraw(azdelta);Deposit(b,delta);finallyMonitor.Exit(a.m_balanceLock);Monitor-Exit(b.m_balanceLock);)/Asbefore.但锁并不是导致死锁的唯一根源。唤醒丢失是另一种现象,此时某个大事被遗漏,导致线程永久休眠。在Win32自动重置和
22、手动重置大事、CONDrnoN.VARIABLE、CLRMOnitor.Wait、Pulse以及PuIseAIl调用等同步大事中常常会发生这种状况。唤醒丢失通常是一种迹象,表示同步不正确,无法重置等待条件或在wake-all(WakeAlIConditionVariable或Monitor.PulseAll)更为适用的状况下使用Twake-single基元(WakeConditionVariable或MonitonPulse)。此问题的另一个常见根源是自动重置大事和手动重置大事信号丢失。由于此类大事只能处于一个状态(有信号或无信号),因此用于设置此大事的冗余调用实际上将被忽视不计。假如代码认定
23、要设置的两个调用始终需要转换为两个唤醒的线程,则结果可能就是唤醒丢失。锁爱护当某个锁的到达率与其锁获得率相比始终居高不下时,可能会产生锁爱护。在极端的状况下,等待某个锁的线程超过了其承受力,就会导致灾难性后果。对于服务器端的程序而言,假如客户端所需的某些受锁爱护的数据结构需求量大增,则常常会发生这种状况。例如,请设想以下状况:平均来说,每100亳秒会到达8个恳求。我们将八个线程用于服务恳求(由于我们使用的是8-CPU计算机)。这八个线程中的每一个都必需获得一个锁并保持20亳秒,然后才能绽开实质的工作。圆满的是,对这个锁的访问需要进行序列化处理,因此,全部八个线程需要160亳秒才能进入并离开锁。
24、第一个退出后,需要经过140亳秒第九个线程才能访问该锁。此方案本质上无法进行调整,因此备份的恳求会不断增长。随着时间的推移,假如到达率不降低,客户端恳求就会开头超时.,进而发生灾难性后果。众所周知,在锁中是通过公正性对锁进行爱护的。缘由在于在锁原来已经可用的时间段内,锁被人为封闭,使得到达的线程必需等待,直到所选锁的拥有者线程能够唤醒、切换上下文以及获得和释放该锁为止。为解决这种问题,Windows已渐渐将全部内部锁都改为不公正锁,而且CLR监视器也是不公正的。对于这种有关爱护的基本问题,唯一的有效解决方案是削减锁持有时间并分解系统以尽可能削减热锁(假如有的话)。虽然说起来简洁做起来难,但这对
25、于可伸缩性来说还是特别重要的。“蜂拥”是指大量线程被唤醒,使得它们全部同时从Windows线程方案程序争夺关注点。例如,假如在单个手动设置大事中有100个堵塞的线程,而您设置该大事嗯,和了吧,您很可能会把事情弄得一团糟,特殊是当其中的大部分线程都必需再次等待时。实现堵塞队列的一种途径是使用手动设置大事,当队列为空时变为无信号而在队列非空时变为有信号。圆满的是,假如从零个元素过渡到一个元素时存在大量正在等待的线程,则可能会发生蜂拥。这是由于只有一个线程会得到此单一元素,此过程会使队列变空,从而必需重置该大事。假如有100个线程在等待,那么其中的99个将被唤醒、切换上下文(导致全部缓存丢失),全部
26、这些换来的只是不得不再次等待。两步舞曲有时您需要在持有锁的状况下通知一个大事。假如唤醒的线程需要获得被持有的锁,则这可能会很不凑巧,由于在它被唤醒后只是发觉了它必需再次等待。这样做特别铺张资源,而且会增加上下文切换的总数。此状况称为两步舞曲,假如涉及到很多锁和大事,可能会远远超出两步的范畴。Win32和CLR的条件变量支持在本质上都会遇到两步舞曲问题。它通常是不行避开的,或者很难解决。两步舞曲问题在单处理器计.算机上状况更糟。在涉及到大事时,内核会将优先级提升应用到唤醒的线程。这几乎可以保证抢先占用线程,使其能够在有机会释放锁之前设置大事。这是在极端状况下的两步舞曲,其中设置ThreadA已切
27、换出上下文,使得唤醒的ThreadB可以尝试获得锁;当然它无法做到,因此它将进行上下文切换以使ThreadA可再次运行;最终,ThreadA将释放锁,这将再次提升ThreadB的优先级,使其优先于ThreadA,以便它能够运行。如您所见,这涉及了多次无用的上下文切换。优先级反转修改线程优先级常常是自找苦吃。当不同优先级的很多线程共享对同样的锁和资源的访问权时,可能会发生优先级反转,即较低优先级的线程实际无限期地阻挡较高优先级线程的进度。这个示例所要说明的道理就是尽可能避开更改线程优先级。下面是一个优先级反转的极端示例。假设低优先级的ThreadA获得某个锁L。随后高优先级的ThreadB介入。
28、它尝试获得L,但由于ThreadA占用使得它无法获得。下面就是“反转”部分:似乎ThreadA被人为临时给予了一个高于ThreadB的优先级,这一切只是由于它持有ThreadB所需的锁。当ThreadA释放了锁后,此状况最终会自行解决。圆满的是,假如涉及到中等优先级的ThreadC,设想一下会发生什么状况。虽然ThreadC不需要锁L,但它的存在可能会从根本上阻挡ThreadA运行,这将间接地阻挡高优先级ThreadB的运行。最终,WindowsBalanceSetManager线程会留意到这一状况。即使ThreadC保持永久可运行状态,ThreadA最终(四秒钟后)也将接收到操作系统发出的临
29、时优先级提升指令。但愿这足以使其运行完毕并释放锁。但这里的延迟(四秒钟)相当巨大,假如涉及到任何用户界面,则应用程序用户确定会留意到这一问题。实现平安性的模式现在我已经找出了一个又一个的问题,好消息是我这里还有几种设计模式,您可以遵循它们来降低上述问题(尤其是正确性危急)的发生频率。大多数问题的关键是由于状态在多个线程之间共享。更糟的是,此状态可被随便掌握,可从全都状态转换为不全都状态,然后(但愿)又重新转换回来,具有令人惊异的规律性。当开发人员针对单线程程序编写代码时,全部这些都特别有用。在您向最终的正确目标迈进的过程中,很可能会使用共享内存作为一种暂存器。多年来C语言风格的命令式编程语言始
30、终使用这种方式工作。但随着并发觉象越来越多,您需要对这些习惯亲密加以关注。您可以依据HaskellsLISP、Scheme、ML甚至F#(一种符合.NET的新语言)等函数式编程语言行事,即采纳不变性、纯度和隔离作为一类设计概念。不变性具有不变性的数据结构是指在构建后不会发生转变的结构。这是并发程序的一种奇异属性,由于假如数据不转变,则即使很多线程同时访问它也不会存在任何冲突风险。这意味着同步并不是一个需要考虑的因素。不变性在C+中通过const供应支持,在C#中通过只读修饰符支持。例如,仅具有只读字段的.NET类型是浅层不变的。默认状况下,F#会创建固定不变的类型,除非您使用可变修饰符。再进一
31、步,假如这些字段中的每个字段本身都指向字段均为只读(并仅指向深层不行变类型)的另一种类型,则该类型是深层不行变的。这将产生一个保证不会转变的完整对象图表,它会特别有用。全部这一切都说明不变性是一个静态属性。依据惯例,对象也可以是固定不变的,即在某种程度上可以保证状态在某个时间段不会转变。这是一种动态属性。WindowsPresentationFoundation(WPF)的可冻结功能恰好可实现这一点,它还允许在不同步的状况下进行并行访问(但是无法以处理静态支持的方式对其进行检查)。对于在整个生存期内需要在固定不变和可变之间进行转换的对象来说,动态不变性通常特别有用。不变性也存在一些弊端。只要有
32、内容需要转变,就必需生成原始对象的副本并在此过程中应用更改。此外,在对象图表中通常无法进行循环(除动态不变性外)例如,假设您有一个ImmutableStack,如图4所示。您需要从包含已应用更改的对象中返回新的ImmutableStack对象,而不是一组变化的Push和Pop方法。在某些状况下,可以敏捷使用一些技巧(与堆栈一样)在各实例之间共享内存。图4使用ImmutableStackpublicclassImmutableStackprivatereadonlyTm_value;privatereadonlyImInUtableStaCkm_next;privatereadonlyboolm
33、_empty;publicImmutableStack()m_empty=true;internalIininutableStack(Tvalue,Nodenext)m_value=value;m_next=next;m_emptyfalse;publicImmutableStackPush(Tvalue)returnnewIininutableStack(value,this);)publicImmutableStackPop(outTvalue)if(m_empty)thrownewException(Empty.,);returnm_next;节点被推入时,必需为每个节点安排一个新对象。
34、在堆栈的标准链接列表实现中,必需执行此操作。但是要留意,当您从堆栈中弹出元素时,可以使用现有的对象。这是由于堆栈中的每个节点是固定不变的。固定不变的类型无处不在。CLR的System.String类是固定不变的,还有一个设计指导原则,即全部新值类型都应是固定不变的。此处给出的指导原则是在可行和合适的状况下使用不变性并反抗执行变化的诱惑,而最新一代的语言会使其变得特别便利。纯度即使是使用固定不变的数据类型,程序所执行的大部分操作仍是方法调用。方法调用可能存在一些副作用,它们在并发代码中会引发问题,由于副作用意味着某种形式的变化。通常这只是表示写入共享内存,但它也可能是实际变化的操作,如数据库事务
35、、Web服务调用或文件系统操作。在很多状况下,我盼望能够调用某种方法,而又不必担忧它会导致并发危急。有关这一方面的一些很好示例就是GetHashCode和TOStringonSyStem.Object等简洁的方法。很多人都不盼望它们带来副作用。纯方法始终都可以在并发设置中运行,而无需添加同步。尽管纯度没有任何常见语言支持,但您可以特别简洁地定义纯方法:.它只从共享内存读取,并且只读取不变状态或常态。.它必需能够写入局部变量。.它可以只调用其他纯方法。因此,纯方法可以实现的功能特别有限。但当与不变类型结合使用时,纯度就会成为可能而且特别便利。一些函数式语言默认状况下都采纳纯度,特殊是Haskel
36、L它的全部内容都是纯的。任何需要执行副作用的内容都必需封装到一个被称为monad的特殊内容中。但是我们中的多数人都不使用HaskelL因此我们必需遵照纯度商定。隔离前面我们只是简洁提及了发布和私有化,但它们却击中了一个特别重要的问题的核心。由于状态通常在多个线程之间共享,因此同步是必不行少的(不变性和纯度也很好玩味)。但假如状态被限制在单个线程内,则无需进行同步。这会导致软件在本质上更具伸缩性。实际上,假如状态是隔离的,则可以自由变化。这特别便利,由于变化是大部分C风格语言的基本内置功能。程序员已习惯了这一点。这需要进行训练以便能够在编程时以函数式风格为主,对大多数开发人员来说这都相当困难。尝试一下,但不要自欺欺人地认为世界会在一夜之间改为使用函数式风格编程。全部权是一件很难跟踪的事情。对象是何时变为共享的?在初始化时,这是由单线程完成的,对象本身还不能从其他线程访问。将对某个对象的引用存储在静态变量中、存储在已在线程创建或排列队列时共享的某个位置或存储在可从其中的某个位置传递性访问的对象字段中之后,该对象就变为共享对象。开发人员必需特殊关注私有与共享之间的这些转换,并当心处理全部共享状态。