Pattern of EAA 试译之 并发控制(50分)

  • 主题发起人 kidneyball
  • 开始时间
K

kidneyball

Unregistered / Unconfirmed
GUEST, unregistred user!
Concurrency
并发控制
--------------------------------------------------------------------------------
by Martin Fowler and David Rice
(因为是两人合写,所以下文用“我们”指作者)
  并发性是软件开发中最需要技巧的方面之一。当你使用多个进程或线程处理同一数据的时候,你就会面对并发性问题。你很难思考并发问题,原因是你很难列举所有可能导致麻烦的问题。似乎无论你怎样努力,你都会遗漏一些地方。而且,并发性很难测试。我们是一群热衷于使用自动测试作为软件开发的基础的人,但要得到能提供我们需要的足够的安全性的测试,是非常困难的。 
  在企业应用开发中最大的讽刺之一是,一些软件开发的分支方法使用并发越多,担心它会导致的问题却越少。而令企业软件开发人员能摆脱这种对于并发性的天真看法的原因往往是来自于事务管理。事务为企业应用系统环境提供了一个框架,有助于避免许多并发性方面的陷阱。只要你把所有数据操作都在一个事务中完成,那么就不会有真正的糟糕事情发生在你身上。
  可惜这并不表示我们能够完全忽略并发性的问题了。最主要的原因是系统中许多交互行为并不能放在单一的数据库事务中。这迫使我们在需要处理跨事务的数据时自己管理并发。我们在这里使用一个术语offline concurrency(离线并发):关于在多个数据库事务之间操作数据的并发控制。
  并发控制会向企业软件开发者扮丑脸的另一个地方是应用程序服务器并发:在一个应用程序服务器系统中支持多线程。好在我们在这个问题上花费的时间相对要少得多,因为它处理起来比较简单,或者你可以选择能帮你解决大部分问题的服务器平台。
  可惜,要理解这类并发问题,你至少需要理解一些关于并发性的普通问题。所以我们会以这些普通问题来开始本章的叙述。我们并不想谎称这一章是关于软件开发中遇到的并发问题的通用解决方案,要达到这个目的我们需要一整本书。本章要做的是概述企业应用系统开发中的并发问题。一旦我们实现这一目的,我们就转入关于处理offline concurrency并发控制的模式的描述,以及对应用程序服务器并发控制作个简短的概述。
  本章的大部分将会围绕一个我们想你们会非常熟悉的例子来描述——一个编程小组用于协调代码改动的源代码控制系统。我们这样做是因为你熟悉它,因此相对来说容易理解它。毕竟,如果你对源代码控制程序不够熟悉,说真的你就不应该去开发企业应用系统。
Concurrency Problems
并发问题

  要走进并发的世界,我们会从一些并发的根本问题开始。我们称之为“根本问题”(essential problems),因为这些是并发控制系统要防止的最基本的问题。它们并不就是并发控制的所有问题,因为通常这些控制机制本身就引入了一系列新的问题。但它们的焦点是放在这些并发控制的根本问题上。
  “丢失更新”是最容易理解的一个问题。比如说马丁编辑了一个文件,在checkConcurrency方法中作了一些修改——这个任务要用好几分钟时间。当他正在修改的时候,大卫在同一文件中改动了updateImportantParameter方法。大卫非常快地完成了他的工作,快到虽然他比马丁晚开工,却在马丁之前完成。不幸的是因为马丁读取文件时,大卫的更新还没有开始,所以当马丁写回文件时,它覆盖了大卫更新的版本。于是大卫所做的更新从此永远消失了。
  一个“读取矛盾”发生在你在不同时间分别看到关于某事物的两个部分的正确信息,但它们并不是同时都正确的。例如马丁希望知道在concurrency包中有多少个类。而concurrency包又包括了两个子包:locking与multiphase。马丁打开locking包,看到里面有7个类。这时,莱打来一个电话,讨论一些深奥的问题。当马丁接电话的时候,大卫解决了那个关于四步锁的讨厌的臭虫并向locking包添加了两个新类,同时也向multiphase包加入了3个新类,而那里本来有5个类。马丁讲完电话,继续打开multiphase包,发现里面有8个类。于是他得出合计concurrency包里有15个类。
  不幸地,15并不是正确答案。正确答案是“在大卫更新之前有12个类,更新之后有17个类”。不考虑时效性的话,这两个答案都是正确的。但15则从头到尾是错的。这个问题称为“读取矛盾(inconsistent read)”因为马丁读取的数据是不一致的。
  这两个问题都导致了正确性(或安全性)上的失误。它们引起了错误,而这种错误只有在有两个人或以上同时操作同一数据的情况下才会发生。但是,如果正确性是唯一的问题,那么这些问题并不是那么严重。毕竟我们可以作一定安排,使对于某一数据,我们中只有一人能进行操作。然而虽然这样做保证了正确性,但却降低了并发能力。任何并发编程的根本问题在于,正确性并不是唯一要担心的事情,你还需要担心活跃度(liveness):有多少并发行为能同时进行。通常人们需要牺牲一定的正确性来获得更多的活跃度,这取决于发生错误的严重性和可能性,还有人们对于并发处理数据的需求程度。
  以上并不是你在并发性上会遇到的所有问题,但我们觉得这些是最基本的。为了避免这些问题我们使用了各种各样的机制来控制并发,但可惜世上没有免费的午餐。这些问题的解决方案本身又引入了它们自身的问题。好在至少那些由控制机制引入的问题相对于根本问题来讲不那么严重。当然从另一个角度看,如果你能够忍受这些根本问题,你就可以避免任何形式的并发控制。这种情况非常罕见,但你偶然也会发现某些特定环境允许你这样做。

Execution Contexts
执行上下文
  当处理进程在一个系统中发生时,它肯定发生于某种形式的上下文(context)之中,而且通常不止一个上下文。并没有关于执行上下文(Execution Context)的专门术语,因此在这里我们将为本书定义这样一个概念。
  从与外界交互的角度来看,两个重要的上下文是:request和session。一个request对应了一个来自外界环境的调用,而软件系统本身就工作于这个环境之中,有时需要向其返回一个response。在处理请求的期间,大部分处理是在在服务器端完成的,而客户端则被认为在等待response。某些协议允许客户端在获得response之前中断request,但这种情况非常罕见。更多的情况是一个客户端可能发出另一个request,而这个request可能干扰了它刚刚发出去的前一个request。例如,一个客户端可能请求添加一份订单,然后又发出另一个独立的request来取消同一份订单。在用户的角度来看,这两个动作明显是有关联的。但在服务器看来,关联就未必那么明显了,这取决于服务器使用的协议。
  一个session是一个客户端与服务器的长期交互动作。一个session可能包含一个单一的request,但更常见的是它包含了一系列的requests——用户认为符合一致逻辑次序的一系列requests。通常它以一个用户的登陆请求开始,包括各种工作,可能涉及到发出查询,又可能涉及到一个或多个商业事务(下文会详述),最后用户退出登陆。或者用户离开了,并假定系统会把“离开”解释为“退出登陆”
  企业应用系统的服务器软件从两种角度去看待requests与sessions:一是作为客户端的服务器,一是作为其他系统的客户端。因此,你通常会看到多个sessions,与客户端之间的http session,以及与各个数据库之间的多个数据库session。
  有两个来自操作系统的重要术语是进程与线程。一个进程是个——通常是重量级的——提供了独立内部数据的执行上下文。一个线程则是轻量级的行为代理,多条线程可以在单一进程中运作。人们比较喜欢线程,因为这样一来你就能在单一进程里处理多个request,这有助于合理利用资源。但线程通常共享内存。这种共享造成了并发性问题。某些环境允许你控制一条线程能访问的数据,允许你分离线程而不共享内存。
  与执行上下文有关的难点在于它们并不象我们所希望那样有顺序地排列起来。理论上,比较好的情况是如果每个session在它的整个生命周期内,都与某个进程保持一种独占性(排它性)的关系。因为进程一般都是相互独立的,这样就有助于减少并发冲突。但到目前为止,据我们所知并没有任何一种服务器工具能让你这样做。最接近的一种替代方法是,为每个request开一条新的进程——这是早期的perl系统的普遍做法。因为开启进程将会占用很多资源,人们现在倾向于避免这种做法,但“一个进程在一个时间内只处理一个request”的做法在计算机系统中是非常普遍的——这样能减少许多并发性问题。
  如果你在做数据库操作,那么就存在着另一个重要的执行上下文——事务。事务把几个request组织在一起,使得客户端能够象看待一个request那样看待这个整体。事务处理可以出现在应用系统与数据库之间:系统事务,或者从用户到应用系统之间:业务事务。我们稍后会具体谈到这些词汇。

Isolation and Immutability
隔离与不变性
  并发性的问题已经出现了好一阵子,开发人员也想出了五花八门的解决方案。就企业应用系统来讲,其中两个是特别重要的:分离与不变性
  当多个行为者(active agent)——例如进程或线程——同时访问同一笔数据,就可能引发各类并发性问题。因此其中一种并发性的处理方法是:把数据分为多个部分,每一部分只能被单一的行为者访问。实际上这跟进程在操作系统的内存中工作的情况一样。操作系统把内存排它地(exclusively)分配给单个进程。只有这个进程能读写这笔与它关联了的数据。同样地,你可以在许多产出性的应用程序中找到文件锁这一概念:如果马丁打开了一个文件,其他任何人都不能同时打开这个文件。系统可能允许其他人打开一份只读的复制版本,这个复制版本保持着马丁打开它之前的内容,但他们不能改动文件,也不能即时看到马丁所做的更新。
  隔离是一个至关重要的技术,因为它减少了出错的机会。事实上,我们经常看到人们在自找麻烦地采用一些使得任何人时时都要惦记着并发性的技术。而通过使用隔离技术,你把程序安排到了一个隔离地带,在这个隔离地带内部,你不用担心并发性问题。所以,一个良好的并发性设计是找到一种方法来创建这种隔离地带,并保证尽可能多的编程工作是在其中一个隔离地带内进行的。
  只有在你共享的数据会被更改时,你才会遇到并发问题。因此避免并发冲突的一种方法是判定不变数据。显然,我们不能把所有数据都设定为不变数据,因为许多应用系统的根本目的就是在于修改数据。但通过把判别出一些不变数据,或者判别出至少对绝大部分用户来说不变的数据,我们就能解除这些数据上为并发问题所作的所有限制,在更大的范围内共享它们。另一种选择是把只涉及读数据操作的应用程序分离出来,并让它们使用数据源的一个复制版本。这样我们就可以解除这个复制版本上的所有并发性控制。
Optimistic and Pessimistic Concurrency Control
乐观(Optimistic)与悲观(Pessimistic)并发控制
  那么如果我们遇到了无法隔离的可变数据时该怎么办呢?广义来讲,我们可以采用两种形式的并发性控制:Optimistic与Pessimistic
  假定马丁与大卫同时希望编辑Customer文件。使用optimistic locking, 他们都能各自使用原文件的一份复制,并在复制版本上面自由工作。如果大卫先完成他的工作,他可以畅通无阻地提交他的改动。而当马丁之后提交改动时,并发控制系统会出来干预。在这里,并发控制系统察觉了马丁的改动与大卫的改动存在并发冲突。马丁的提交被拒绝后,该怎么处理手头的修改版本就由他自己来决定了。而如果使用Pessimistic locking,第一个人一旦check out了文件,其他任何人都无法修改这个文件。所以如果马丁先check out了文件,那么在他完成修改并提交之前,大卫无法对这个文件进行工作。
  可以这样来看待这两种方法:optimistic lock是基于“发现”冲突的,而pessimistic lock是基于“防止”冲突的。在这里的例子中,两种锁都适合用于源代码控制系统。不过最近大多数开发人员都倾向于使用optimisitic lock来开发源程序控制系统。
  两种方法都各自的优点和缺点。pessimistic lock的缺点是它削弱了并发能力。例如,当马丁编辑一个文件时,他会锁住该文件,那样所有要同一文件的其他人都必须要等待。如果你曾经接触过使用pessimistic lock的源程序控制系统,你会知道这种情况会多么令人不快。而对于企业应用系统中的数据,情况则更为严重,因为如果有人在编辑数据,那么任何其他人都不能读这个文件,更不用谈去修改它了。
  Optimistic lock则具备了让人们共同工作的能力,因为只有在提交的时候才会加锁。Optimistic lock的问题在于:你发现了一个冲突之后该怎样做。在上面的例子中,基本上,在大卫提交文件之后,任何其他人都应该重新提取这份文件,尝试把他们的修改与大卫的更新整合起来,然后把新版本提交回给系统。就源代码来说,这样的操作并不是非常困难。事实上在许多情况下源代码控制系统会帮你完成整合。即使不能自动整合,你都能找到一些能帮你找出不同版本的差异的工具。但是对于业务数据来讲,自动整合是十分困难的,因此在许多情况下,你只能丢弃你已经做的修改,重头开始。
  在optimistic与pessimistic之间作出选择的关键是发生冲突的频率与严重性。如果冲突足够少,或者后果并不严重,那么你应该尽量选择optimistic lock,因为它提供了更好的并发能力,而且通常更容易实现。但是如果冲突会给用户带来相当痛苦的后果,那么你就应该使用pessimistic技术来避免它们。
  这两种技术本身都不是完美无缺的。事实上使用它们去避免那些基本的并发问题时,你很容易就会引入一些同样麻烦的其他问题。我们会把这些派生出来的问题留给另一本更为适合的书去讨论,在这里,我们仅仅提出一些要经常记住的重点。
Preventing Inconsistent Reads
避免“读取矛盾”
  考虑这样一种情况。马丁修改了Customer类,而这个类会调用Order类。同时,大卫修改了Order类并且改动了它的接口部分。大卫先编译并且提交的他的修改,然后马丁编译并提交。现在,这些共享的代码已经被破坏了,因为马丁没有意识到Order类已经被修改了。一些源代码控制程序能发现这种“读取矛盾”,但另外一些则需要加入一些手工规则来实现,例如在你提交之前需要从主干线上更新你的文件(译注:such as updating your files from the trunk before you check in)。
  从本质来说,以上例子属于“读取矛盾”问题,但它往往容易被人忽视,因为大多数人都倾向于把“丢失更新”作为并发性的关键问题。Pessimistic lock对解决这类“读取矛盾”有一个非常古老的办法:通过读锁与写锁。如果你要读数据,你需要先加一个读锁(或叫共享锁);如果你要写数据,你需要先加一个写锁(或叫排它锁)。同一个数据资源能被许多人加上读锁,但只要有人加了读锁,那么任何人都不能加写锁。相应地,只要一旦有人加了写锁,那么任何其他人都不能加上任何锁。通过这样做,你就能利用pessimistic lock来避免“读取矛盾”
  Optimistic lock的冲突检测机制通常建立在数据中的某种“版本标志”上。它可以是一个“时间戳”或者一系列计数器。在检测“丢失更新”的时候,系统检查你要更新的数据与源共享数据的版本标志。如果它们相同,则系统允许更新,并同时更新版本标志。
  检测“读取矛盾”基本上也是类似的:在此情况下任何已被读入的数据也具有各自的“版本标志”,在回写的时候也需要检查与原来的共享数据比较。若版本标志存在差异,则表示存在并发冲突。
  但是,如果要对每一位(bit)已读入的数据都进行访问控制,常常会导致一些不必要的问题,因为有些数据并不是十分重要(译注:指肯定不会发生冲突,或者冲突的后果不严重)。要减轻这个担子,你可以把你已经修改的数据与你仅仅只是读取的数据分离开来。例如一个挑选产品的列表,如果在你开始你的修改之后又有新的产品被加入列表,并不会引起很大问题。但如果是一个用来统计帐单的列表,则重要性会大得多。困难在于要区分开它们就需要一些谨慎的分析来搞清楚什么东西是作什么用的。客户地址信息中的邮政编码看上去是不重要的,但如果有一个税收计算是基于用户的居住范围的,那么地址信息就应该进行并发控制。因此,无论你使用什么形式的并发控制手段,区分开什么数据需要进行控制而什么不需要,都是一项非常棘手的工作。
  处理“矛盾读取”的另一个方法是使用Temporal Reads。这种方法为读取的数据加入某种类似时间戳或不变标签的前缀。数据库根据前缀信息返回与这个时间或标签对应的数据。很少数据库提供类似这样的功能,但开发人员在源代码控制系统往往可以遇到这种功能。这种方法的问题是数据源需要提供完整的更新历史记录,这将耗费时间与空间资源。对源代码控制来说,使用这个方法还算是合理的。但对于通用的数据库,则显得困难而且昂贵了。在实际应用中,你可能需要在业务逻辑中某些特别领域中提供这种能力。参考[snodgrass]与[Fowler, temporal patterns]
(未完待续)
 
顶部