ITPub博客

首页 > Linux操作系统 > Linux操作系统 > 第24章 美丽的并发

第24章 美丽的并发

原创 Linux操作系统 作者:hzbook2008 时间:2008-10-30 11:07:05 0 删除 编辑
Normal 0 7.8 磅 0 2 false false false MicrosoftInternetExplorer4 24 美丽的并发

[1]Herb Sutter, "The free lunch is over: a fundamental turn toward concurrency in software," Dr. Dobb's Journal, March 2005.

[2]Herb Sutter and James Larus, "Software and the concurrency revolution," ACM Queue, Vol. 3, No. 7, September 2005.

并行程序的执行是非确定性的,因而测试起来自然也就不容易;并发程序中有的bug甚至可能会无法重现。我对漂亮程序的定义是“简单而优雅,乃至于显然没有任何错误”,而不仅仅是“几乎没有任何明显的错误” [3]。要想编写出能够可靠运行的并行程序,程序的美感是尤其要注意的方面。可惜一般来说并行程序总归没有它们相应的非并行(顺序式的)版本漂亮;尤其是在模块性方面:并行程序的模块性相对较弱。这一点我们后面会看到。

[3]This turn of phrase is due to Tony Hoare.

本章将介绍软件事务内存(STM)。软件事务内存是一项很有前景的技术,它提出了一种针对共享内存并行处理器编程的新手段,正如刚才所言,传统并行程序的模块性较弱,而这正是软件事务内存的强项。我希望你读完本章后能和我一样对这项新技术感到振奋。当然,软件事务内存也并非万灵药,但它的确对并发领域中令人望而却步的问题发起了一次漂亮且令人鼓舞的冲击。

24.1   一个简单的例子:银行账户

假设有这样一个简单的编程任务:

编写一段程序,将钱从一个银行账户转移到另一个账户。为了简化起见,两个账户都是存放在内存里面的——也就是说你不用考虑跟数据库的交互。要求是你的代码在并发环境中也必须能够正确执行,这里所谓的并发环境也就是指可能存在多个线程同时调用你的转账函数,而所谓能够正确执行则是指任何线程都不能“看到”系统处于不一致的状态(比如看到其中一个账户显示已被取出了一笔钱然而同时另一个账户却显示还没有收到这笔钱)。

这个例子虽说有点人为捏造的味道,但它的优点是简单,因而我们便能将注意力集中在它的解决方案上,后面你会看到,Haskell结合事务内存将会给这个问题带来新的解决方案。不过此前还是让我们先来简单回顾一下传统的方案。

24.1.1  加锁的银行账户

目前,用于协调并发程序的主导技术仍是锁和条件变量。在一门面向对象的语言中,每个对象上都带有一个隐式的锁,而加锁则由synchronized方法来完成,但原理与经典的加锁解锁是一样的。在这样一门语言中,我们可以将银行账户类定义成下面这样:

        class Account {

          Int balance;

          synchronized void withdraw( Int n ) {

            balance = balance - n; }

          void deposit( Int n ) {

            withdraw( -n ); }

        }

withdraw方法必须是synchronized的,这样两个线程同时调用它的时候才不会把balance减错了。synchronized关键字的效果就等同于先对当前账户对象加锁,然后运行withdraw方法,最后再对当前账户对象解锁。

有了这个类之后,我们再来编写transfer转账函数:

        void transfer( Account from, Account to, Int amount ) {

          from.withdraw( amount );

          to.deposit( amount ); }

对于非并发程序来说以上代码完全没问题,然而在并发环境中就不一样了,另一个线程可能会看到转账操作的“中间状态”:即钱从一个账户内被取走了,而同时另一个账户却还没收到这笔钱。值得注意的是,虽然withdrawdeposit这两个函数都是synchronized,但这种情况还是会出现。在from上调用withdraw会将from加锁,执行提款操作,然后对其解锁。类似的,在to上调用deposit会将to加锁,执行存款操作,然后对其解锁。然而,关键是在这两个调用之间有一个间隙,在这个状态下待转账的钱既不在from账户中也不在to账户中。

在一个金融程序中,这种情况可能是无法容忍的。那我们该如何解决这个问题呢?通常的方案可能是在外面再包一层显式的加锁解锁操作,如下:

        void transfer( Account from, Account to, Int amount ) {

          from.lock(); to.lock();

            from.withdraw( amount );

            to.deposit( amount );

          from.unlock(); to.unlock() }

但这种做法有一个致命的缺陷:它可能会导致死锁。我们考虑这样一种情况:在一个线程将一笔钱从A账户转到B账户的同时,另一个线程也正在将一笔钱从B账户转到A账户(当然,发生这种事情的几率很小)。这时便可能会出现两个线程各锁一个账户并都在等着对方释放另一账户的情况。

问题找出来了(不过,并发环境下的问题可不总是像这么容易就能找出来的),标准的解决方案是规定一个全局统一的锁顺序,并按照递增顺序来进行加锁。采用这种做法的代码如下:

        if from < to

          then { from.lock(); to.lock(); }

          else { to.lock(); from.lock(); }

这个方法是可行的,但前提是必须事先知道要对哪些锁进行加锁,而后面这个条件并不是总能满足的。例如,假设from.withdraw的实现当账户余额不足时就会从from2上提款。遇到这种情况除非等到我们从from中提了钱否则是无法知道是否该对from2加锁的,而另一方面,一旦已经从from中提了钱,再想按“正确”顺序加锁便不可能了。更何况from2这个账户可能根本就只应对from可见,而不应被transfer知道。而且退一步说,就算transfer知道from2的存在,现在需要加的锁也已经由两个变成了三个(事先还要记得将这些锁正确排序)。

还有更糟的,如果我们需要在某些情况下阻塞(block),情况就会更加复杂。例如,要求transferfrom账户内余额不足的时候阻塞。这类问题通常的解决办法是在一个条件变量上等待,并同时释放from的锁。但更进一步,如果要求当fromfrom2中的总余额不够的时候阻塞呢?

24.1.2  “生锈”的锁

简而言之,在如今的并发编程领域占主导地位的技术,锁和条件变量,从根本上是有缺陷的。以下便是基于锁的并发编程中的一些公认的困难(其中有些我们在前文的例子中已经看到过了)。

锁加少了

容易忘记加锁,从而导致两个线程同时修改同一个变量。

锁加多了

锁加错了

在基于锁的并发编程中,锁和锁所保护的数据之间的联系往往只存在于程序员的大脑里,而不是显式地表达在程序代码中。结果就是一不小心便会加错了锁。

加锁的顺序错了

在基于锁的并发编程中,我们必须小心翼翼地按照正确的顺序来加锁(解锁),以避免随时可能会出现的死锁;这种做法既累人又容易出错,而且,有时候极其困难。

错误恢复

错误恢复也是个很麻烦的问题,因为程序员必须确保任何错误都不能将系统置于一个不一致的、或锁的持有情况不确定的状态下。

忘记唤醒和错误的重试

容易忘记叫醒在条件变量上等待的线程;叫醒之后又容易忘记重设条件变量。

然而,基于锁的编程,其最根本的缺陷,还是在于锁和条件变量不支持模块化编程。这里“模块化编程”是指通过粘合多个小程序来构造大程序的过程。而基于锁的并发程序是做不到这一点的。还是拿前面的例子来说吧:虽然withdrawdeposit这两个方法都是并发正确的,但如果原封不动的话,你能直接用它们实现出一个transfer来吗?不能,除非让锁协议暴露出来。而且遇到选择和阻塞的话还会更头疼。例如,假设withdraw在账户余额不足的情况下会阻塞。你就会发现,除非暴露锁条件,否则你根本无法直接利用withdraw函数从“A账户或B账户(取决于哪个账户有足够余额)”进行提款;而且就算知道了锁条件,事情仍还是麻烦。另一些文献中也对锁并发的这种困难作了论述。[4]

Edward A. Lee, "The problem with threads,"IEEE Computer, Vol. 39, No. 5, pp. 33–42, May 2006; J. K. Ousterhout, "Why threads are a bad idea (for most purposes)," Invited Talk, USENIX Technical Conference, January 1996; Tim Harris, Simon Marlow, Simon Peyton Jones, and Maurice Herlihy, "Composable memory transactions," ACM Symposium on Principles and Practice of Parallel Programming (PPoPP '05), June 2005.

24.2  软件事务内存

软件事务内存(STM)是迎接并发挑战的一种很有前途的新技术,本节将会详细说明这一点。我选用Haskell来介绍STMHaskell是我见过的最美丽的编程语言,而且STM能够特别优雅地融入到Haskell中。如果你还不了解Haskell,别担心,边看边学。

24.2.1   Haskell中的副作用(Side Effects)和输入/输出(I/O

Haskell中的transfer函数写出来就像这样:

        transfer :: Account -> Account -> Int -> IO ( )
        -- Transfer 'amount' from account 'from' to account 'to'
        transfer from to amount = ...

 

以上代码的第二行,即以“--”开头的那行,是一个注释。代码的第一行是对transfer的函数类型的声明(类型声明以“::”前导)[5],“Account -> Account -> Int -> IO ( )”读作“接受一个Account,加上另一个Account(两个Account,代表转账的源账户和目标账户),以及一个Int(转账的数额),返回一个IO( )类型的值”。最后一个类型(即返回类型)“IO( )”说的是“transfer函数返回的是一个动作(action),这个动作被执行的时候可能会产生副作用(side effects),并返回一个‘( )’类型的值。“( )”类型读作“单元(unit)”,该类型只有一个可能的值,也写作“( )”,有点类似于C里面的voidtransferIO( )作为返回类型说明了执行过程中的副作用是我们调用transfer的惟一原因。那么,在介绍下面的内容之前,我们就必须首先知道Haskell是怎么对待副作用的。

[5]你可能会觉得奇怪,为什么在这个类型签名里面有三个“->”(难道不应该是一个吗——位于参数类型与返回类型之间?)其实这是因为Haskell支持所谓的currying,后者在任何介绍Haskell的书(比如Haskell: The Craft of Functional Programming, by S.J. Thompson [Addison-Wesley])中或wikipedia上都能见到它的踪影。但currying不是本章要讲的重点,你大可以忽略除了最后一个“->”之外的所有“->”,即除了最后一个类型之外,其他都是函数的参数类型。

那么副作用是什么呢?副作用就是我们读写可变(mutable)状态所造成的影响(effect)。输入/输出是副作用的绝佳范例。例如,下面是两个Haskell函数,它们都具有输入/输出副作用:

        hPutStr  :: Handle -> String -> IO ()
        hGetLine :: Handle -> IO String

[1]Haskell里面调用一个函数很简单,只须将函数名和它的各个参数并排写在一块儿就行了。在大多数语言中你都需要写成hPutStr(h, “hello”),但Haskell里面只要写成(hPutStr h “hello”)就行了。

[2]Haskell中的句柄相当于C里面的文件描述(file descriptor):指明对哪个文件或管道进行读写。跟Unix里面一样,Haskell里面也预定义了三个句柄:stdinstdoutstderr

        hEchoLine :: Handle -> IO String
        hEchoLine h = do { s <- hGetLine h
                         ; hPutStr h ("I just read: " ++ s ++ "\n")
                         ; return s }

do {a1; …; an}结构将数个较小的动作(a1an)粘合成一个较大的动作。因此在上面的代码中,hEchoLine h这个动作被执行的时候便会首先调用hGetLine h来读取一行输入,并将这行输入命名为s。接着调用hPutStr,将s加上前导的“I just read: [3]一并打印出来。最后串s被返回。最后一行return s比较有趣,因为return并非像在其他命令式语言中那样是语言内建的操作,而只是一个普普通通的函数,其函数类型如下:

[3]++操作符的作用是将两个串拼接起来。

        return :: a -> IO a

 

也就是说,像return v这样一个操作被执行的时候,将会返回v,同时并不会导致任何副作用[9]return函数可以作用于任何类型的值,这一点体现在其函数类型中:a是一个类型变量,代表任何类型。

[9]IO”的意思是一个函数可能有副作用,但并不代表它就一定会带来副作用。

输入/输出是一类重要的副作用。还有一类重要的副作用便是对可变(mutable)变量的读写。例如,下面这个函数将一个可变变量的值增1

        incRef :: IORef Int -> IO ( )
        incRef var = do { val <- readIORef var
                        ; writeIORef var (val+1) }

incRef var是一个动作。它首先执行readIORef var来获得变量var的值,并将该值绑定到val;接着它调用writeIORefval+1写回到var里面。readIORefwriteIORef的类型如下:

        readIORef  :: IORef a -> IO a
        writeIORef :: IORef a -> a -> IO ( )

类型形如IORef t的值相当于一个指针或引用,指向或引用一个t类型的可变值(类似于C里面的t*)。具体到incRef,其参数类型为IORef Int,因为incRef只操作Int型变量。

现在,我们已经知道了如何将数个较小的动作组合成一个较大的——但一个动作到底如何才算被真正调用呢?在Haskell里,整个程序其实就是一个IO动作,名叫main。要运行这个程序,我们只需执行main。例如下面就是一个完整的程序:

        main :: IO ( )
        main = do { hPutStr stdout "Hello"
                  ; hPutStr stdout " world\n" }

该程序是一个顺序式(sequential)程序,因为do块将两个IO动作按顺序连接了起来。另一方面,要构造并发程序的话,我们便需要另一个原语(primitive):forkIO

        forkIO :: IO a -> IO ThreadId

forkIOHaskell内建的函数,它的参数是一个IO动作,forkIO所做的事情就是创建一个并发的Haskell线程来执行这个IO动作。一旦这个新线程建立,Haskell的运行时系统便会将它与其他Haskell线程并行执行。例如假设我们将前面的main函数修改为[1]

[10]其实,main的第一行我们本可以写成“tid <- forkIO(hPutStr …)”的,这行语句会把forkIO的返回值(一个ThreadId)绑定到tid。然而由于本例中我们并不使用返回的ThreadId,所以就把“tid <-”省略了。

        main :: IO ( )
        main = do { forkIO (hPutStr stdout "Hello")
                  ; hPutStr stdout " world\n" }

现在,这两个hPutStr操作便能够并发执行了。至于哪个先执行(从而先打印出它的字符串)则是不一定的。Haskell里面由forkIO产生出来的线程是非常轻量级的:只占用几百个字节的内存,所以一个程序里面就算产生上千个线程也是完全正常的。

读到这里,你可能会觉得Haskell实在是门又笨拙又麻烦的语言,incRef的三行代码说穿了就做了C里面的一个x++而已!没错,在Haskell里面,实施副作用的方式是非常显式且冗长的。然而别忘了,首先Haskell主要是一门函数式编程语言。大多数代码都是在Haskell的函数式内核里写的,后者的特点是丰富、高表达力、简洁。因而Haskell编程的精神就是“有节制地使用副作用”。

其次我们注意到,在代码中显示声明副作用的好处是代码能够携带许多有用的信息。考虑下面两个函数:

        f :: Int -> Int
        g :: Int -> IO Int

通过它们的类型我们便可以一眼看出,f是一个纯函数,无副作用。给它一个特定的数(比如42),那么每次对它的调用(f 42)都会返回同样的结果。相比较之下,g就具有副作用了——这一点明明白白地显示在它的类型中。对g的不同的调用,就算参数相同,也可能会得到不同的值,因为,比如说,它可以通过stdin读取数据,或对一个可变变量进行修改。后面你会发现,这种显式声明副作用的做法其实非常有用。

最后,前面提到的所谓动作(action,比如I/O动作)其实本身也是值(Haskell中的动作也是一等公民):它们也可以被作为参数传递或作为返回值返回。比如下面就是一个纯粹用Haskell写的(而非内建的)模拟(简化的)for循环的函数:

        nTimes :: Int -> IO ( ) -> IO ( )
        nTimes 0 do_this = return ( )
        nTimes n do_this = do { do_this; nTimes (n-1) do_this }

这是一个递归函数,其第一个参数是一个Int,表示要循环多少次,第二个参数则是一个动作(action):do_this;该函数返回一个动作,后者被执行的时候会把do_this重复做n遍。比如下面这段代码利用nTimes来重复输出10个“Hello”:

        main = nTimes 10 (hPutStr stdout "Hello\n")

这其实从效果上就等同于允许用户自定义程序的控制结构了。

话说回来,本章的目的并不是要对Haskell作一个全面的介绍,而且即便是对于Haskell里面的副作用我们也只是稍加阐述。如果你想进一步了解的话,可以参考我写的一篇指南“Tackling the awkward squad [11]

Simon Peyton Jones, "Tackling the awkward squad: monadic input/output, concurrency, exceptions, and foreign-language calls in Haskell," C. A. R. Hoare, M. Broy, and R. Steinbrueggen, editors, Engineering theories of software construction, Marktoberdorf Summer School 2000, NATO ASI Series, pp. 47–96, IOS Press, 2001.

24.2.2  Haskell中的事务

OK,终于可以回到我们的transfer函数了。其代码如下:

        transfer :: Account -> Account -> Int -> IO ( )
        -- Transfer 'amount' from account 'from' to account 'to'
        transfer from to amount
         = atomically (do { deposit to amount
                          ; withdraw from amount })

里面的那个do块你应该不觉得陌生了吧:它先是调用depositamount数目的钱存入to账户,然后再从from账户中提取amount数目的钱。至于depositwithdraw这两个辅助函数我们待会再来写,现在我们先来看看对atomically的调用。atomically的参数是一个动作,它会将该动作作为一个原子来执行。更精确地说,atomically有如下两个特性作保证:

原子性

atomically act调用所产生的副作用对于其他线程来说是“原子的”。这就保证了另一个线程不可能观察到钱被从一个账户中取出而同时又没来得及存入另一个账户中去的中间状态。

隔离性

atomically act执行的过程中,act这个动作与其他线程完全隔绝,不受影响。这就好像在act开始执行的时候世界停顿了,直到act执行完毕之后世界才又开始恢复运行。

至于atomically函数的执行模型,简单的做法是:存在一个惟一的全局锁;atomically act首先获取该锁,然后执行动作act,最后释放该锁。这个实现虽然保证了原子性,但粗暴地禁止了任意两个原子块在同一时间执行。

上面说的这个模型有两个问题。第一,它并没有保证隔离性:比如一个线程在执行一个原子块的过程中访问了一个IORef(此时该线程持有全局锁),另一个线程此时照样可以直接对同一个IORef进行写操作(只要这个写操作不在原子块内)。这就破坏了隔离性保证。第二,它极大的损害了执行性能,因为即便各个原子块之间互不相干,也必须被串行化执行。

第二个问题待会在“事务内存实现”一节会详细讨论。目前先来看第一个问题。第一个问题可以通过类型系统轻易解决。我们将atomically函数赋予如下类型:

        atomically :: STM a -> IO a

atomically的参数是一个类型为STM a的动作。STM动作类似于IO动作,它们都可能具有副作用,但STM动作的副作用的容许范围要小得多。STM中你可以做的事情主要就是对事务变量(类型为TVar a)进行读写,就像我们在IO动作里面主要对IORef进行读写一样[12]

[12]这儿其实有一个命名上的不一致:STM变量被命名为TVar,然而普通变量却被命名为IORef——其实要么应该是TVar/IOVar,要么应该是TRef/IORef才对。但事到如今已经没法再改了。

        readTVar  :: TVar a -> STM a
        writeTVar :: TVar a -> a -> STM ( )

IO动作一样,STM动作也可以由do块组合起来,实际上,do块针对STM动作进行了重载,return也是;这样它们便可以运用于STMIO两种动作了[13]。例如,下面是withdraw的代码:

其实Haskell并没有特别针对IOSTM动作来重载doreturnIOSTM其实只是一个更一般的模式的特例,这个更一般的模式便是所谓的monadP. L. Wadler在“The essence of functional programming 20th ACM Symposium on Principles of Programming Languages [POPL '92], Albuquerque, pp. 1–14, ACM, January 1992中有描述),doreturn的重载便是通过用Haskell的非常泛化的“类型的类型”(type-class)系统来表达monad而得以实现的(described in P. L. Wadler and S. Blott, "How to make ad-hoc polymorphism less ad hoc," Proc 16th ACM Symposium on Principles of Programming Languages, Austin, Texas, ACM, January 1989; and Simon Peyton Jones, Mark Jones, and Erik Meijer, "Type classes: an exploration of the design space," J. Launch-bury, editor, Haskell workshop, Amsterdam, 1997)。

        type Account = TVar Int
 
        withdraw :: Account -> Int -> STM ( )
        withdraw acc amount
          = do { bal <- readTVar acc
               ; writeTVar acc (bal - amount) }

我们用一个包含一个Int(账户余额)的事务变量来表示一个账户。withdraw是一个STM动作,将账户中的余额提走amount

为了完成transfer的定义,我们可以通过withdraw来定义deposit

        deposit :: Account -> Int -> STM ( )
        deposit acc amount = withdraw acc (- amount)

注意,transfer从根本上执行了四个基本的读写操作:对to账户的一次读和一次写;以及对from账户的一次读和一次写。这四个操作是被当成一个原子来执行的,其执行满足本节(“一个简单的例子:银行账户”)开头的要求。

        bad :: Account -> IO ( )
        bad acc = do { hPutStr stdout "Withdrawing..."
                     ; withdraw acc 10 }

以上代码不能通过编译,因为hPutStr是一个IO动作,而withdraw则是一个STM动作,这两者不能放在同一个do块中。但如果我们把withdraw再放在一个atomically调用当中就可以了,如下:

        good :: Account -> IO ( )
        good acc = do { hPutStr stdout "Withdrawing..."
                      ; atomically (withdraw acc 10) }

24.2.3  事务内存实现

有了前面提到过的原子性和隔离性保证,我们其实便可以放心使用STM了。不过我常常还是觉得一个合理的实现模型会给直觉理解带来很大的帮助,本节就来介绍这么一个实现模型。但要注意的是,这只是所有可能实现中的一种。STM抽象的一个漂亮之处就在于它提供了一个小巧干净的接口,而实现这个接口可以有多种方式,可简单可复杂。

在可行的实现方案中,有一个方案特别吸引人,那就是在数据库实现里被采用的所谓的“乐观执行(optimistic execution”。当atomically act被执行的时候,Haskell运行时系统会为它分配一个线程本地的事务日志,该日志最初的时候是空的,随着act动作被一步步执行(其间并不加任何形式的锁),每次对writeTVar的调用都会将目标TVar变量的地址和新值写入日志;而并不是直接写入到那个TVar变量本身。每次对readTVar的调用都会首先寻找日志里面有没有早先被写入的新值,没有的话才会从目标TVar本身读取,并且,在读取的时候,一份拷贝会被顺便存入到日志中。同一时间,另一个线程可能也在运行着它自己的原子块,疯狂地读写同样一组TVar变量。

act这个动作执行完毕之后,运行时系统首先会对日志进行验证,如果验证成功,就会提交(commit)日志。那么验证是怎么进行的呢?运行时系统会检查日志中缓存的每个readTVar的值是否与它们对应的真正的TVar相匹配。是的话便验证成功,并将日志中缓存的写操作结果全都提交到相应的TVar变量上。

必须注意的是,以上验证-提交的整个过程是完全不可分割的:底层实现会将中断禁止掉,或使用锁或CAScompare-and-swap)指令等任何可行的方法来确保这个过程对于其他线程来说就像“一瞬间”的事情一样。但由于所有这些底层工作都由实现来完成,所以程序员不用担心也不用考虑它是怎么完成的。

一个自自而然的问题是:如果验证失败呢?如果验证失败,就代表该事务看到的是不一致的内存视图。于是事务被中止(abort),日志被重新初始化,然后整个事务从头再来过。这个过程就叫做重新执行(re-execution)。由于此时act动作的所有副作用都还没有真正提交到内存中,因此重新执行它是完全没问题的。然而有一点必须注意:act不能包含任何除了对TVar变量读写之外的副作用,比如下面这种情况:

        atomically (do { x <- readTVar xv
                       ; y <- readTVar yv
                       ; if x>y then launchMissiles
                                   else return () })

launchMissiles::IO ( )这个函数的副作用是“头晕、恶心、呕吐”。由于这个原子块执行的时候并没有加锁,所以如果同时有其他线程也在修改变量xvyv的话,该线程就可能观察到不一致的内存视图。而一旦这种情况发生,发射导弹(launchMissiles)可就闯了大祸了,因为等到导弹发射完了才发现验证失败就已经来不及了。不过幸运的是,Haskell的类型系统会阻止冒失的程序员把IO动作(比如这个launchMissiles)放在STM动作中执行,所以,以上代码会被类型系统阻止。这从另一个方面显示了将IO动作跟STM动作区分开来的好处。

24.2.4  阻塞和选择

到目前为止我们介绍的原子块从根本上还缺乏一种能力:无法用来协调多个并发线程。这是因为还有两个关键的特性不具备:阻塞和选择。本节就来介绍如何扩充基本的STM接口从而使之包含以上两个特性(当然,在完全不破坏模块性的前提下)。

假设当一个线程试图从一个账户中提取超过账户余额的钱时这个线程便会阻塞。并发编程中这类情况很常见:例如一个线程在读取到一个空的缓冲区时阻塞;或在

来自 “ ITPUB博客 ” ,链接:http://blog.itpub.net/16502878/viewspace-481924/,如需转载,请注明出处,否则将追究法律责任。

上一篇: 第7章 美丽测试
请登录后发表评论 登录
全部评论

注册时间:2008-10-23

  • 博文量
    209
  • 访问量
    765200