6.3 多线程编程

多线程编程是一种比多进程编程更加灵活和高效的并发编程方式。绝大多数现代操作系统也已经对它提供了支持。在Unix的世界中,POSIX标准中定义的线程及其属性和操作方法已经被广泛认可和遵循。Linux操作系统作为Unix世界中的一员当然也提供了以POSIX标准中定义的线程(以下简称POSIX线程)为中心的各种系统调用。在Linux操作系统中,最贴近POSIX线程标准的线程实现被称为NPTL(Native POSIX Threads Library)。除了更加贴近POSIX标准,POSIX线程的出现的主要目的是对Linux操作系统原有的线程实现进行大幅改进。自2.6版本的Linux操作系统内核起,NPTL已经逐渐成为了其默认的线程实现。

这里顺带地解释一下POSIX。POSIX是Portable Operating System Interface of Unix的缩写,中译名为可移植性操作系统接口。它是由美国的电气电子工程师学会(IEEE)为了提高运行在各种类Unix操作系统下的应用程序的可移植性而开发的一套规范。该规范之后被美国国家标准协会(ANSI)和国际标准化组织(ISO)标准化。

为了更加通用,我们在这里只对POSIX线程及其相关概念进行介绍。这也是Go语言并发编程模型在Linux操作系统下真正使用的内核接口。

在本节中,我们首先会对POSIX线程的基本定义和概念进行说明。然后,我们将讨论一些与线程有关的关键问题,比如线程间的同步方法、线程安全性以及线程本地存储,等等。

Go语言的并发编程模型在底层是由操作系统所提供的线程库支撑的。所以,我们很有必要在讲解该并发编程模型之前对多线程编程进行一番介绍。撰写本小节的目的不但在于让读者对这个当今最主流的并发编程方法有所了解,更是为了向读者提供理解Go语言并发编程模型所必需的基础知识。

6.3.1 线程

一个线程可以被看作是在某个进程中的一个控制流。一个进程至少会包含一个线程,因为其中至少会有一个控制流持续运行。因而,一个进程的第一个线程会随着它的启动而被创建。这个线程被称为该进程的主线程。当然,一个进程也可以包含多个线程。这些线程都是由当前进程中已存在的线程所执行的相应系统调用(更确切的说,是pthread_create函数)创建出来的。拥有多个线程的进程可以并发的执行多个任务,并且即使某个或某些任务被阻塞也不会影响到其他任务的正常执行。这可以大大改善程序的响应时间和吞吐量。另一方面,线程不可能独立于进程存在。一个线程必属于某一个进程。它的生命周期不可能逾越其所属进程的生命周期。

一个进程中的所有线程都拥有自己的线程栈,并以此存储自己的私有数据。这些线程的线程栈都被包含在操作系统内核分配给其所属进程的虚拟内存地址中。然而,一个进程中的很多资源都会被其中的所有线程共享。这些被线程共享的资源包括在当前进程的虚拟内存地址中存储的代码段、数据段、堆、信号处理函数,以及当前进程所持有的文件描述符,等等。正因为如此,同一进程中的多个线程运行的一定是同一个程序(当然,具体的控制流程和执行的函数可能会不同),并且在它们之间共享数据也是非常轻松和自然的事情。此外,创建一个新线程也不会像创建一个新进程那样耗时费力,因为在其所属进程的虚拟内存地址中存储的代码、数据和资源都不需要被复制。

操作系统内核提供了若干个系统调用以使应用程序能够管理当前进程中的所有线程。此外,应用程序还可以通过相应的系统功能协调这些线程的运行。这些系统功能是由一些同步原语代表的。

下面,我们就对这些与线程紧密相关的知识进行更详细的阐述。

1. 线程的标识

和进程一样,每个线程也都有属于它自己的ID。这类ID也被称为线程ID或者TID。但与进程不同,线程ID在系统范围内可以不是唯一的,但在其所属进程的范围内必须是唯一的。不过,Linux操作系统的线程实现则确保了每个线程ID在系统范围内的唯一性。另外,当线程已不复存在之后,其线程ID是可以被其他线程复用的。

线程的ID是由操作系统内核分配和维护的。应用程序一般无需对它过多的关注。如果应用程序依赖线程ID,那么将会给它的移植带来困扰。不过,在我们对应用程序进行调试的时候,线程ID是非常有用的。它们可以帮助我们区分不同的线程。

2. 线程间的控制

我们已经知道,系统中的每个进程都有它的父进程,而由某个进程创建出来的进程都被称为该进程的直接子进程。与这种家族式的树状结构不同,同一个进程中的任意两个线程的关系都是平等的。也就是说,它们之间并不存在层级关系。任何线程都可以对其所属进程中的其他线程进行有限的管理。这里所说的有限的管理主要有以下4种。

  • 创建线程:主线程在其所属进程启动的时候被创建。因此,它的创建并不在此论述的范围之内。这里仅指对其他线程的创建。我们已经说过,任何线程都可以通过调用系统调用pthread_create来创建新的线程。为了言简意赅,我们自此把调用系统调用或函数的线程简称为调用线程。在创建新线程的时候,调用线程需要给定新线程将要执行的函数以及传入该函数的参数值。由于代表该函数的参数被命名为start,因此我们通常称这个函数为start函数。start函数是可以有返回值的。我们可以在其他线程中通过与新线程的连接得到在该新线程中执行的start函数的返回值。如果新线程创建成功,调用线程会得到新线程的ID。

  • 终止线程:线程可以通过多种方式终止其所属进程中的其他线程。其中一种方式就是调用系统调用pthread_cancelpthread_cancel函数的作用是取消掉给定的线程ID代表的线程。更明确地讲,它会向目标线程发出一个请求,要求它立即终止执行。但是,该函数只是发送请求并立即返回,而不会等待目标线程对该请求做出响应。至于目标线程什么时候对此请求做出响应、做出怎样的响应,则取决于另外的因素(比如目标线程的取消状态及类型)。在默认情况下,目标线程总是会接受线程取消请求,但等到时机成熟(执行到某个取消点)的时候目标线程才会去响应线程取消请求。

  • 连接已终止的线程:此操作由系统调用pthread_join代表。该函数会一直等待(或者说阻塞)与给定的线程ID对应的那个线程的终止,并把该线程执行的start函数的返回值告知给调用线程。如果目标线程已经处于终止状态,那么该函数会立即返回。这就像是把调用线程放置在了目标线程的后面,当目标线程把流程控制权交出时,调用线程会接过流程控制权并继续执行pthread_join函数调用之后的代码。这也是把这一操作称为“连接”的缘由之一。实际上,如果一个线程是可被连接的,那么在它终止之时就必须被连接,否则它就会变成一个僵尸线程。僵尸线程不但会导致系统资源的浪费,还会使其所属进程的可创建线程数量被无意义地减少。

  • 分离线程:将一个线程分离意味着,它不再是一个可被连接的线程。而在默认情况下,一个线程总是可以被其他线程连接的。分离操作的另一个作用是让操作系统内核在目标线程终止时自动进行清理和销毁工作。注意,分离操作是不可逆的。也就是说,我们无法使一个不可被连接的线程变回到可被连接的状态。不过,对于一个已处于分离状态的线程执行终止操作仍然会起作用。分离操作由系统调用pthread_detach代表。它接受一个代表了线程ID的参数值。

当然,一个线程对自身也可以进行两种控制:终止和分离。线程终止自身的方式有很多种。在线程执行的start函数中执行return语句会使该线程随着start函数的执行结束而终止。需要注意的是,如果在主线程中执行了return语句,那么当前进程中的所有线程都会被终止。另外,在任意线程中调用系统调用exit也会达到这种效果。另一种终止自身的方式是,显式地调用系统调用pthread_exit。与执行return语句或调用exit函数不同,如果在主线程中调用了pthread_exit函数,那么只有主线程自己会被终止,而其他线程仍然会照常运行。这是很重要的区别。线程分离自身与分离其他线程的方式并无不同,即调用pthread_detach函数。区别仅在于调用线程传递给该函数的线程ID是自己的ID还是其他线程的ID。

3. 线程的状态

从前面的论述中可知,一个线程在从被创建到被终止的完整生命周期中也经常会在多个状态之间切换。由于线程只是进程中的一个控制流,所以对进程的状态描述几乎都适用于线程。不过,正如前面所说的那样,线程的状态及其切换规则还是有它的特点的,如图6-13所示。

{%}

图 6-13 Linux内核线程的状态转换

图6-13是以系统调用的视角来描述线程在不同状态之间的转换的。在图6-13中,一些系统调用操作描述或语句执行操作描述的左侧被插入了一个前缀。这些前缀都由中文的方括号“【”和“】”括起来以示强调。前缀“【另】”代表描述的操作是由当前进程中的其他线程执行的。前缀“【自】”代表描述的操作是由当前线程执行的。而前缀“【主】”则代表描述的操作是由主线程执行的。如果在操作描述的左侧没有前缀,那么就说明该操作可能由当前进程中的任何线程执行。另外,请注意,当其他线程调用pthread_cancel函数或pthread_join函数,以及任一线程调用pthread_detach函数的时候,传递给它们的参数值代表的都应该是当前线程的ID。这样,它们的执行才会对当前线程起作用。

如图6-13所示,线程在被创建出来之后就会进入就绪状态。处于就绪状态的线程会等待被运行的时机。一旦该线程被真正地运行,它就会由就绪状态转换至运行状态。正在运行的线程可能会由于某些原因被阻塞,进而由运行状态转换至睡眠状态。这里可能的原因包括但不限于等待未完成的I/O操作、等待还未接收到的信号、等待获得互斥量,以及等待某个条件变量。后两个原因都属于因同步而产生的线程阻塞。我们在后面会专门对它们进行说明。当阻塞线程等待的那个事件或条件发生或满足时,该线程会被唤醒。也就是说,它会从睡眠状态转出。但是,它并不会直接进入运行状态,而是先进入就绪状态以等待运行时机。如果CPU正处于空闲状态,那么它会被立即运行。此外,处于运行状态的线程有时也会因CPU被其他线程抢占而失去运行时机,从而转回至就绪状态并等待下一个运行时机。操作系统内核的调度器会按照一定的算法和策略使线程在这3个状态之间转换。线程在其生命周期的大部分时间里都会处于就绪状态、运行状态或睡眠状态之中。

在当前线程自我终结或者其他线程向当前线程发出取消请求且取消时机已到之后,当前线程就会试图进入终止状态。不过,如果当前线程之前没有被分离过,并且此时并没有其他线程与它连接,那么当前线程就会进入到僵尸状态而非终止状态。当且仅当有其他线程与之连接之后,当前线程才会从僵尸状态转换至终止状态。处于终止状态的线程才会被操作系统内核回收。不过,有两种操作可以直接使当前线程进入终止状态,而不管它是否已经被分离。在任意线程中调用exit函数以及在主线程中执行return语句,都不但会使其所属进程中的所有线程立即终止,还会结束该进程的运行。

4. 线程的调度

在前面的论述中,我们只是一笔带过了线程调度方面的内容。实际上,在线程的生命周期中,操作系统内核对线程的调用是非常核心的部分。正因为有了调度器的实时调度和切换,才能给我们一种众多线程被并行运行的幻觉。调度器会把时间划分成极小的时间片并把这些时间片分配给不同的线程,以使众多线程都能有机会在CPU上运行。一个线程什么时候能够获得CPU时间,以及它能够在CPU上被运行多久,都属于调度器的工作范畴。线程调度(也被称为线程间的上下文切换)是一项非常复杂的工作。因此,我们在这里只对线程调度的最基本的规则和策略进行阐述。

线程的执行总是趋向于CPU受限或I/O受限。换句话说,一些线程需要花费一定的时间使用CPU进行计算,而另外一些线程则会花费一些时间等待相对较慢的I/O操作的完成。一个被用于计算16位整数的14次方根的线程属于前者,而一个等待人类用户通过敲击键盘提供输入数据的线程则属于后者。但是,在通常情况下,一个线程的趋向性并不总是那么清晰。因此,调度器往往需要去猜测它们。这是非常困难的任务。调度器会依据它对线程的趋向性的猜测把它们分类,并让I/O受限的线程具有更高的动态优先级以优先使用CPU。这倒不是为了讨好与I/O受限的线程进行交互的数据输入人。实际上,调度器根本无法判断I/O操作的数据输入方是人类、磁盘还是网络中的另一个应用程序。真正的原因是,调度器认为I/O操作往往会花费很长的时间,应该让它们尽早地开始执行。事实上也确实如此。这也是为了让众多线程运行得更加有效率。在人决定下一个要敲击的按键、磁盘在磁道中定位簇或者网卡从网络中接收数据帧的时候,CPU可以腾出手来为其他线程服务。这些时间已经可以让CPU见缝插针地完成很多事情了。

注意,我们刚刚所说的线程的动态优先级是可以被调度器实时调整的。而与之相对应的线程的静态优先级则只能由应用程序指定。如果应用程序没有显式地指定一个线程的静态优先级,那么它将被设定为0。调度器并不会改变线程的静态优先级。线程的动态优先级就是调度器在其静态优先级的基础上调整得出的。它在线程的运行顺序上起到了关键的作用。而线程的静态优先级则决定了线程单次能够在CPU上运行的最长时间,亦即调度器分配给它的时间片的大小。后面我们会看到线程的时间片所起到的作用。

所有等待使用CPU的线程会被按照动态优先级从高到低的顺序排入到与该CPU对应的运行队列中。因此,下一个被运行的线程总是动态优先级最高的那一个。实际上,每一个CPU的运行队列中都包含两个优先级阵列。其中的一个被用于存放正在等待运行的线程。我们暂且称之为激活的优先级阵列。而另一个则被用于存放已经运行过但还未完成的线程。我们暂且称之为过期的优先级阵列。更确切地讲,优先级阵列是一个由若干个链表组成的数组。一个链表只会包含具有相同优先级的线程,而一个线程也只会被放到与它的优先级相对应的那一个链表中。当一个线程被放入某个优先级阵列的时候,它实际上是被放到了与它的优先级相对应的那个链表的末尾处,如图6-14所示。

6.3.1 线程 - 图2

图 6-14 线程运行队列的内部结构示意图

下一个被运行的线程总是会从激活的优先级阵列中选出。如果调度器发现某个线程已经占用了CPU很长时间(该时间只会小于或等于给予该线程的时间片),并且激活的优先级阵列中还有优先级与它相同的线程在等待运行,那么调度器就会让那个等待的线程在CPU上运行。被换下的线程会被排入过期的优先级阵列。当激活的优先级阵列中没有待运行的线程的时候,调度器就会把这两个优先级阵列的身份互换,即之前的激活的优先级阵列成为新的过期的优先级阵列。而之前的过期的优先级阵列则会成为新的激活的优先级阵列。这样,之前被放入过期的优先级阵列的线程就又有机会被运行了。

当然,线程不会总是在就绪状态和运行状态之间徘徊,它还有可能被阻塞而进入睡眠状态。处于睡眠状态的线程不能够被调度和运行。换句话说,它们会从运行队列中被移除。线程的睡眠状态也可以被细分为可中断的睡眠状态和不可中断的睡眠状态。不过,我们在这里并不打算区分它们。相信读者已经通过阅读上一节对它们有所了解了。

线程会因等待某个事件或条件的发生而被加入到对应的等待队列中,并随即进入睡眠状态。当事件或条件发生时,内核会通知对应的等待队列中的所有线程。这些线程会因此而被唤醒并从等待队列转移至适当的运行队列中。调度器往往会稍稍调高被唤醒的线程动态优先级。这算是一个小小的额外恩惠,以使这类线程能够更早地被运行。

如果当前计算机上有多个CPU,那么平衡它们之间的负载也将会是调度器的职责之一。调度器会尽量使一个线程在一个特定的CPU上运行。这有很多好处,比如维护高速缓存的高命中率以及高效使用就近的内存,等等。然而,有时候一个CPU需要运行太多的线程以至于造成了多CPU之间的负载的不平衡。也就是说,一些CPU过于忙碌或另一些CPU则被闲置。在这种情况下,调度器会把一些原本在较忙碌的CPU上运行的线程迁移至其他较空闲的CPU上运行。由于内核会为每个CPU都建立一个运行队列,所以线程的这种迁移并不困难。事实上,每个运行队列中都会保存对应CPU的负载系数。调度器可以根据这一系数了解并调整各个CPU的负载。当然,CPU负载平衡的调度逻辑相当复杂,负载系数仅仅是冰山一角。但由于篇幅原因,我们只介绍这么多。

总体来说,操作系统内核的调度器就是使用若干策略对众多线程在CPU上的运行进行干涉,以使得操作系统中的各个任务都能够有条不紊地进行,同时还要兼顾效率和公平性。从线程的角度来看,调度器是通过协调各个线程的状态来达到调度目的的。单台计算机上的资源是很有限的,尤其是以CPU为代表的计算资源。因此,操作系统内核对线程的调度非常重要。随着开发者们对操作系统内核的不断改进和完善,调度器日趋强大,同时也愈加复杂。但是,它的总体目标和基本规则原则是未曾变化的。相信读者通过以上介绍已经对线程调度有了一个宏观上的认识。

5. 线程实现模型

线程的实现模型主要有3个,分别是:用户级线程模型、内核级线程模型和两级线程模型。它们之间最大的差异就在于线程与内核调度实体(Kernel Scheduling Entity,简称KSE)之间的对应关系上。顾名思义,内核调度实体就是可以被内核的调度器调度的对象。在很多文献和书中,它也被称为内核级线程。它是操作系统内核的最小调度单元。下面,我们就来说说这3个线程实现模型的特点以及优劣。

  • 用户级线程模型:此模型下的线程是由用户级别的线程库全权管理的。线程库并不是内核的一部分。它只被存储在进程的用户空间之中。进程中的线程的存在对于内核来说是无法感知的。显然,这些线程也不是内核的调度器的调度对象。对线程的各种管理的和协调完全是用户级程序的自主行为,与内核无关。应用程序在对线程进行创建、终止、切换或同步等操作的时候,并不需要让CPU从用户态切换到内核态。从这方面讲,用户级线程模型确实在线程操作的速度上存在优势。并且,由于对线程的管理完全不需要内核的参与,所以使得程序的移植性更强一些。但是,这一特点导致在此模型下的多线程并不能够被真正地并发运行。例如,如果线程在I/O操作过程中被阻塞,那么其所属进程也会被阻塞。这正是由线程无法被内核调度造成的。在调度器的眼里,进程是一个无法再被分割的调度单元,无论其中存在多少个线程。另外,即使计算机上存在多个CPU,进程中的多个线程也无法被分配给不同的CPU运行。对于CPU的负载均衡来说,进程的粒度太粗了。因而让不同的进程在不同的CPU上运行的意义也微乎其微。显然,线程的所谓优先级也会形同虚设。同一个进程中的所有线程的优先级只能由该进程的优先级来体现。同时,线程库对线程的调度完全不受内核控制,它与内核为进程设定的优先级是没有关系的。正因为用户级线程模型存在这些严重的缺陷,所以现代操作系统都不是使用这种模型来实现线程的。但是,在早期,以这种模型作为线程的实现方式的案例确实存在。由于包含了多个用户级线程的进程只与一个KSE相对应,因此这种线程实现模型又被称为多对一(M∶1)的线程实现。

  • 内核级线程模型:该模型下的线程是由内核负责管理的。它们是内核的一部分。应用程序对线程的创建、终止和同步都必须通过内核提供的系统调用来完成。进程中的每一个线程都与一个KSE相对应。也就是说,内核可以分别对每一个线程进行调度。由此,内核级线程模型又被称为一对一(1∶1)的线程实现。一对一线程实现消除了多对一线程实现的很多弊端。在这样的实现之下,可以真正实现线程的并发运行。因为这些线程完全是由内核来管理和调度的。正如前文所述,内核可以在不同的时间片内让CPU运行不同的线程。内核在极短的时间内快速切换和运行各个线程使得它们看起来像正在被同时运行。即使进程中的一个线程由于某种原因进入到了阻塞状态,其他线程也不会受到影响并可以正常地运行。这也使得内核在多个CPU上进行负载平衡变得容易和有效。当然,如果一个线程与被阻塞的线程之间存在同步关系,那么它也可能会受到牵连。但是,这是一种应用级别的干预,并不属于线程本身的特质。同时,内核对线程的全权接管使操作系统在库级别几乎无需为线程管理做什么事情。这与用户级别线程模型形成了鲜明的对比。但是,内核线程的管理成本显然要比用户级线程高出很多。线程的创建会使用到更多的内核资源。并且,像创建线程、切换线程,同步线程这类操作所花费的时间也会更多。如果一个进程包含了大量的线程,那么它会给内核的调度器造成非常大的负担,甚至会影响到操作系统的整体性能。因此,采用内核级线程模型的操作系统对一个进程中可以创建的线程的数量都有直接或间接的限制。尽管内核级线程模型有资源消耗较大、调度速度较慢等缺点,但是与用户级线程的实现方式相比它还是有较大的优势的。很多现代操作系统都是以内核级线程模型实现线程的,包括Linux操作系统。实际上,Linux操作系统的最新线程库实现(NPTL)为最小化内核级线程模型的劣势付出了巨大的努力。这也使得在Linux操作系统中使用线程更加高效。

  • 两级线程模型:两级线程模型的目标是取前两种模型之精华,并去二者之糟粕。它也被称为多对多(M:N)的线程实现。与其他模型相比,两级线程模型提供了更多的灵活性。在此模型下,一个进程可以与多个KSE相关联。这与内核级线程模型是相似的。但与内核级线程模型不同的是,进程中的线程(以下称之为应用程序线程)并不与KSE一一对应。这些应用程序线程可以被映射到同一个已关联的KSE上。首先,已被加载到进程的虚拟内存中的实现两级线程模型的线程库会通过操作系统内核创建多个内核级线程。然后,它再通过这些内核级线程对应用程序线程进行调度。大多数此类线程库都可以为实际运行的应用程序线程动态地分配若干个内核级线程。这样的设计显然使线程的管理工作更加复杂一些,因为这需要内核和线程库的共同努力和协作才能正确、有效地进行。但是,也是由于这样的设计,内核资源的消耗才得以大大减少,同时也使线程管理操作的效能有了不少的提高。因为两级线程模型的实现的复杂性,它往往不会被操作系统内核的开发者采纳。但是,这样的模型却可以很好地在编程语言层面上实现并发挥出其应有的作用。就拿Go语言来说,其并发编程模型就与两级线程模型在理念上非常类似,只不过它的具体实现方式更加高级和优雅一些。在Go语言的并发编程模型中,不受操作系统内核管理的独立控制流并不被叫作应用程序线程或者线程,而被称为Goroutine(也可以被称为Go程)。3种线程实现模型如图6-15所示。

{%}

图 6-15 3种线程实现模型

至此,我们已经讨论很多与线程及其实现模型有关的概念和知识。下面,我们会讨论一些更加具体的主题。它们在多线程编程的过程中是十分常用的,同时也是非常重要的。在我们使用Go语言进行并发编程的时候也会常常涉及它们。因此,了解它们既有利于我们对底层实现的理解,也会让我们在实际的编程过程中更容易作出正确的决策。

6.3.2 线程的同步

同步,永远是多线程编程中最核心和最重要的话题之一。在上一节,我们提及过一些与线程中的同步有关的概念和工具,比如临界区、原子操作以及互斥量,等等。在本小节,我们会对它们以及其他相关内容进行深入且详细的介绍。

总的来说,在多个线程之间采取同步措施,无非是为了让它们更好地协同工作或者维持共享数据的一致性。以后者作为目的的同步较为常见,但在以前者作为控制流管理手段的程序中同步的意义更大。而在实际的场景中,同步的目的则更可能是两者兼而有之的。就像我们在上一节讲解同步概念的时候举的那个计数器的例子一样。

1. 共享数据的一致性

包含多个线程的程序(以下简称多线程程序)多以共享数据作为在线程之间传递数据的手段。由于一个进程所拥有的相当一部分虚拟内存地址都可以被该进程中的所有线程所共享,因此这些被共享的数据也大多是以内存空间作为载体的。如果两个线程同时读取同一块被共享的内存但获取到的数据却是不同的,那么程序很可能就会出现某种错误。这是因为,共享数据的一致性往往代表着某种约定,而只有在该约定成立的前提下,多线程程序中的各个线程才能够使相应的流程被正确地执行。换句话说,如果操作共享数据的实际结果总是与我们约定的(或称期望的)操作结果相符,那么就可以说该共享数据的一致性得到了保证。而共享数据的一致性保证则是多线程程序中的各个控制流得以正确执行的前提。当然,即使这种约定是成立的,线程在执行流程的过程中也不一定不会出错。不过那就属于另外的情况了,并不在这里的讨论范围之内。支撑共享数据一致性的约定,一般会被直接地或间接地包含在我们对多线程程序的设计方案之中。因此,这种一致性的保证也关系到我们的程序设计方案能否被正确地实施。在上一节讲同步概念的时候,我们用了一定的篇幅描述了因共享数据的不一致而可能导致的种种错误。在多线程编程的过程中,我们总是要想方设法地保证共享数据的一致性,除非该共享数据永远只可会被同一个线程访问。

实际上,保证共享数据的一致性的最简单且最好的方法,就是使该数据成为一个不变量。例如,我们在程序中声明的常量就是一个绝对的不变量。常量是不可能被改变的,也就不可能出现不一致的情况。这是由编程语言来保证的。因此,无论当前程序中有多少个可能访问该常量的线程,我们都不需要采取任何措施。但是,把计数器变成一个常量是不现实的。一个可以并需要被改变的计数器只能被看作一个变量。我们需要通过额外的手段,来保证被多个线程所共享的变量的一致性。这才有了临界区这个概念。我们已经知道,临界区是只能被串行化地访问或执行的某个资源或某段代码。因而临界区也常被称为串行区域。保证临界区有效的最佳方式就是利用同步机制。在针对多线程程序的同步机制中包含了很多同步方法,包括我们之前提及过的原子操作和互斥量,也包括我们还未曾讲到的条件变量。在后面的内容中,我们会着重介绍两种可以帮助线程同步对共享数据的使用的方法(以下简称线程同步方法):互斥量和条件变量。

2. 互斥量

在同一时刻,只允许一个线程处于临界区之内的约束被称为互斥(mutex)。每一个线程在进入临界区之前都必须先锁定某个对象。只有成功锁定对象的线程才会被允许进入到临界区之内,否则就会被阻塞。这个对象被称为互斥对象或互斥量。

由上面这段描述可知,互斥量有两种可能的状态,即已锁定状态和未锁定状态。互斥量每次只能被锁定一次。也就是说,处于已锁定状态的互斥量不能被再次锁定。除非它已被解锁,否则任何线程都不能对它进行二次加锁。如果对一个已被锁定的互斥量进行加锁操作,那么这个操作必定会失败。成功锁定互斥量的线程会成为该互斥量的所有者。只有互斥量的所有者才能对该互斥量进行解锁。从这个角度讲,多个线程对同一个互斥量的争相锁定也可以被看作是对该互斥量的所有权的争夺。从而,锁定可以被看作是对互斥量的获取,解锁也可以被看作是对互斥量的释放。对于互斥量来说,这两对术语具有相同的含义。在相关的资料和图书中,它们也会成对地出现。在本书中,我们主要使用“锁定”和“解锁”这对术语。

另一方面,线程在离开临界区的时候,必须要对相应的互斥量进行解锁。这样,其他为进入该临界区而被阻塞的线程才会被唤醒并有机会再次尝试锁定该互斥量。而在这些线程中,只可能有一个线程成功锁定该互斥量。注意,对同一个互斥量的锁定和解锁操作应该成对地出现。我们既不应该对一个互斥量进行重复锁定,也不应该对一个互斥量进行多次解锁。与后者相比,前者有时会造成严重的后果。

为了合理、安全地使用共享数据,我们应该把操作同一个共享数据的代码都置于一个或多个临界区之内,并使用同一个互斥量对它们进行保护。需要注意的是,在使用互斥量的过程中,我们必须遵循既定的使用规则。我们刚刚已经讲述了一些基本原则。不过,在后面,我们还会分别列举一些正例和反例,并借此进一步对相关规则进行说明。互斥量的使用会涉及操作系统提供的线程库中的一些函数。我们并不打算讲解这些函数,所以下面的示例主要以示意图的形式展现。

我们的第一个示例改编自那个与计数器有关的例子(参见6.2.2节)。我们将会在当前的示例中给出针对那个计数器的同步问题的解决方案,如图6-16所示。

{%}

图 6-16 互斥量保护下的计数器操作

图6-16展示了两个线程共同使用一个计数器的情形。在这一过程中,我们使用互斥量作为线程间同步的工具。这幅图的信息量有些大。不过别担心,我们下面对其中的要点逐一进行解释。

首先,互斥量与计数器一样也属于共享资源。互斥量必须能够被使用相应的共享资源的线程访问到。因此,代表互斥量的变量或常量一般不是局部的。不过,为了尽量少地暴露程序的实现细节,我们应该在满足上述要求的前提下最小化互斥量的访问权限。

其次,初始化互斥量的操作总是应该在任何线程真正使用它之前进行。从图6-16我们也可以看到,线程A执行的第一个操作即是初始化互斥量。经过初始化的互斥量会处于未锁定状态。注意,如果多个线程将要执行的代码中都包含了对同一个互斥量的初始化操作,那么我们必须保证该互斥量只会被初始化一次。操作系统的线程库中专门提供了满足此要求的函数。在Go语言中,我们也有很多可选择的实现方式。

在初始化互斥量并创建计数器之后,线程A开始使用计数器。它会获取并更新计数器的值。不过,在这之前,线程A会先试图锁定那个已被初始化过的互斥量。这是当前示例与源示例之间最主要也是最重要的区别。线程A欲锁定互斥量,而互斥量当时又处于未锁定状态,因此锁定操作会成功完成。从图6-16中可知,在这一操作完成后,互斥量处于已锁定状态(Locked)。请读者注意图中由中文的方括号“【”和“】”括起来的注解。在成功锁定互斥量之后,线程A开始放心大胆地对计数器的值进行操作。

注意,在线程A对计数器的值进行获取操作之后、更新操作之前,线程B被运行并准备使用计数器。这与源示例中发生的情形一样。不过,线程B在使用计数器之前也不得不先试图锁定互斥量。但是由于当时互斥量已处于锁定状态,而它又不能被重复锁定,所以线程B对它的锁定操作会失败。并且,锁定操作的失败将导致线程B被阻塞并进入睡眠状态。直到线程A完成对计数器的值的获取和更新操作并解锁互斥量之后,线程B才会被唤醒并退出睡眠状态。被唤醒之后,线程B会立即再次试图锁定互斥量。由于这时的互斥量已处于未锁定状态(Unlocked),所以线程B的锁定操作会成功。之后,线程B开始操作计数器的值并处理相关数据。

互斥量对于每个想要锁定它的线程都是平等的。因此,线程A在处理完数据之后又立即开始争夺互斥量。但是这次它对互斥量的锁定也会失败,因为该互斥量已经被线程B锁定。这使得线程A被阻塞,直到线程B解锁互斥量。

由于我们对互斥量的合理使用,线程A和线程B都不会干扰到对方对计数器的使用。因而,原本存在于它们之间的竞态条件已被消除,使用互斥量的目的达到了。即使有更多的线程参与到其中也会是如此。另外,我们用互斥量保护的临界区中包含且仅包含了对计数器的值的获取、相加和更新操作。因此,这组操作的执行看上去具有了原子性。更确切地说,它们的执行是伪原子性的。之所以说是伪原子性,是因为它们在执行过程中是可以被中断的,并且它们既不与单一的汇编指令相对应也没有芯片级别的支持。实际上,能够使用得上真正的原子操作的应用场景并不多。所以,互斥量作为一个可以保证共享数据的一致性的工具常常会是首选。

对于这个示例,再强调两点。第一,对互斥量的初始化必须要保证唯一性。这永远是正确使用互斥量的第一个必要条件。第二,线程在离开临界区的时候必须要及时解锁互斥量,以免造成不必要的性能损耗甚至死锁。关于第二点,我们稍后还会举例说明。

上面的示例忽略了一个细节,那就是线程执行的程序在筛选数据之后要做的事情。在上一节我们说过,程序在每次数据筛选之后要把得到的数据集合写入文件,并在写入完成后关闭文件。注意,线程A和线程B执行的是相同的程序。因此,我们完全有必要对其中的文件操作进行同步,以免文件中的内容发生混乱。至此,我们在线程A和线程B执行的程序中使用了两个互斥量。在这里,我们把为了同步计数器操作的互斥量称为互斥量α,而把为了同步文件操作的互斥量称为互斥量β。互斥量α和互斥量β分别保护了两个毫不相干的临界区,而这两个临界区又分别仅包含了对于两个完全独立的共享资源的操作。也就是说,在这两个互斥量的作用域之间不存在任何的重叠。图6-17描绘了线程A和线程B争相进入两个在互斥量的保护之下的临界区的情形。其中,临界区α指代由互斥量α保护的临界区,而临界区β指代由互斥量β保护的临界区。

{%}

图 6-17 互斥量保护下的临界区

从图6-17中,我们可以清晰地看到互斥量α和互斥量β所起到的作用。在线程B欲进入临界区β的时候,由于线程A正在临界区β之内执行,所以线程B被迫进入睡眠状态。在线程A离开临界区β的时候,线程B被唤醒并再次尝试进入临界区β。

图6-17描绘的同时使用多个互斥量的情形已经算是相当简单的了。因为互斥量α和互斥量β的作用域的互不重叠。加之线程在离开临界区之前总会及时解锁相应的互斥量,所以程序不会因使用这两个互斥量而产生死锁。并且,它们对程序的复杂度的负面影响也是非常有限的。不过,因为锁定和解锁互斥量也需要时间,所以使用互斥量本身确实会稍许降低程序的性能。但如果我们在这里将两个临界区合二为一并且只使用一个互斥量来保护的话,又可能会使线程等待进入临界区的时间大大增加。相比之下,只使用一个互斥量会比使用两个互斥量更多的降低了程序性能。当然,这只是对于我们当前描述的这个程序来说的。使用互斥量的过程中总会存在着博弈和权衡。

在一般情况下,应该尽量少地使用互斥量。每个互斥量保护的临界区应该在合理范围内并尽量地大。但是,如果发现多个线程会频繁地出入某个较大的临界区,并且它们之间经常存在访问冲突,那么就应该把这个较大的临界区切分成若干个较小的临界区,并使用不同的互斥量加以保护。此举的意义是让等待进入同一个临界区的线程数变少,从而降低线程被阻塞的几率,并减少其处于睡眠状态的时间。这样就可以从一定程度上提高程序的整体性能。

请注意,如果在切分之后由不同的互斥量保护的临界区中包含了对同一个共享资源的同一种操作,那么此次临界区切分就是不成功的。我们要么重新考量,要么放弃切分。因为这种不对应的关系使互斥量的所谓的保护失去了意义。即使只是包含了对同一个共享资源的不同操作,我们也应该仔细考虑,怎样保证这些不同的操作在同时被执行的时候不会给共享资源或程序的正常流程造成破坏。例如,在前述的那个计数器的例子中,我们就不能把获取计数器的值的操作和更新计数器的值的操作分别置于两个临界区中。

除此之外,我们应该特别注意,尽量不要让不同的互斥量所保护的临界区重叠。因为这会大大增加死锁发生的几率。图6-18展示了因互斥量的使用不当而造成的死锁。

{%}

图 6-18 互斥量的使用不当造成的死锁

如图6-18所示,线程A和线程B在一开始就分别锁定了一个互斥量α和互斥量β。而后,在它们还未释放自己所持的互斥量的情况下,就想去锁定已被另一方锁定的互斥量。线程A在成功锁定互斥量β之前不会解锁互斥量α,而线程B在成功锁定互斥量α之前也不会解锁互斥量β。实际上,线程A永远不能解锁互斥量α,线程B也永远不能解锁互斥量β。它们相持不下并使另一方和自己永远处于睡眠状态。如此一来,死锁就产生了。并且,如果当前进程只包含了线程A和线程B,那么整个进程的运行就会停滞。即使它们不是当前进程所包含的全部线程,也会使该进程在功能和性能上都大打折扣。这同样是我们不希望看到的。

对于运行已停滞的进程,我们能做的只有强制重新启动它。这样不但会使所有的运行时数据丢失,还可能会造成各种不一致的状态。作为并发程序的设计者,我们应该坚决避免可预见的死锁的发生。我们已经知道,如果可以保证不同的互斥量所保护的临界区之间不存在任何重叠,那么就可以避免因互斥量的使用不当而造成的死锁。但是,如果出于某种原因,我们无法对此作出保证,或者必须要使用临界区重叠的多个互斥量,那又该怎么办呢?

在这种情况下,我们有两种通用的解决方法。其中的一个方法需要利用到操作系统提供的线程库的功能。它被叫作试锁定和回退。其核心思想是,如果在执行一个代码块的时候需要先后(顺序不定)锁定两个互斥量,那么在成功锁定其中一个互斥量之后应该使用“试锁定”的方法来锁定另一个互斥量。如果“试锁定”第二个互斥量的操作不成功,那么就把已锁定的第一个互斥量解锁,并重新对这两个互斥量进行锁定和“试锁定”。如果需要锁定的互斥量多于两个,那么应该总是先锁定其中的一个,然后再按照上面的方法“试锁定”其他互斥量并在必要时进行“回退”。这里的“试锁定”指的是操作系统的线程库所提供的一个函数。它会尝试对一个互斥量进行锁定。但是,若锁定失败则函数会直接返回一个错误码,而不是被阻塞在那里。这一点很重要,是避免产生死锁的关键。当然,“回退”环节也是很有必要的。因为,如果“试锁定”失败,就说明有其他线程已经先于当前线程进入或试图进入这个受多个互斥量保护的代码块。这时,当前线程应该知趣地退出对这些互斥量的争夺,并从头尝试锁定它们。这是为了避免因“试锁定”的使用而破坏互斥量和临界区的原本含义。虽然从理论上来看,这种方法几乎可以应对所有临界区重叠的情况,但是它却也大大增加了程序的复杂性。尤其是在分别对多个互斥量锁定的操作之间夹杂着其他操作的时候。我们在从头对多个互斥量进行锁定的同时往往还要考虑到其他状态的“回退”。而后者的复杂程度也决定着“回退”环节的可行性。另外,在使用试锁定和回退方法时,我们对多个互斥量解锁的操作的顺序最好要与锁定它们的顺序完全相反。特别是,一定要最后解锁那个被第一个锁定的互斥量。这可以大大减少“回退”的次数。

另一种通用解决方法更加廉价。它不需要用到更多的线程库函数,也不会像第一种方法那样对程序的复杂度有那么大的影响。它被称作“固定顺序锁定”。顾名思义,它的思路是在需要先后对多个互斥量进行锁定的场景下,总以固定不变的顺序锁定它们。就前面的示例而言,如果线程A和线程B总是先锁定互斥量α、再锁定互斥量β,那么对它们的锁定就不会造成死锁的发生。因为在成功锁定互斥量α之前,线程永远无法执行对互斥量β的锁定操作。从而避免了它们分别持有另一方想要锁定的互斥量的情况。另一方面,互斥量α和互斥量β的解锁操作的顺序与其锁定操作的顺序之间没有强制性的约束。这取决于具体的流程设计。在一般情况下,解锁操作的顺序是与其锁定操作的顺序相反的。这是因为我们在很多时候,需要保证在一个线程完全离开这些重叠的临界区之前,不会有其他同样需要锁定那些互斥量的线程进入到那里。这可能是由于这些临界区中的操作之间具有一些固有的关联性,也可能是由于需要保证这些操作的完整性。除上述情况之外,对多个互斥量的解锁操作的顺序就显得不那么重要了。另外,如果在程序的某处还存在着单独使用其中的某个互斥量的情况,我们还应该仔细考量,看看是否可以通过“缩短”对它的锁定操作和解锁操作之间的“距离”来减小因使用它而给程序性能带来的负面影响。不过,请记住,我们总是应该先实现功能,然后再在必要的时候对程序的性能进行优化,否则很可能会降低程序逻辑的清晰度甚至失去原有设计的优势。

显然,这两种方法都能够有效地解决死锁的问题。但是,在解决问题的同时,我们也需要付出一些代价。前一种方法虽然更加通用但却会使程序更加复杂。虽然后一种方法既简单又实用,但是它却由于固定的操作顺序而降低了程序的灵活性。同时,它在适用场景方面也有些许限制。例如,在不能确定线程对多个临界区的访问顺序的情况下,我们就无法使用“固定顺序锁定”的方法来预防死锁。当然,我们不一定要进行这种非黑即白的设计决策。混用这两种方法有时候也会是不错的选择。比如,在访问顺序可控的临界区之上使用“固定顺序锁定”的方法,而在访问顺序不可控或者需要更大灵活性的地方使用“试锁定-回退”方法。不过,无论怎样,它们都属于下策,并且都是一种对不优雅或者不得以的设计的补救措施。我们应该总能够想到,保持共享数据的独立性是预防因使用互斥量而导致的死锁的最佳方法。如果共享数据之间的关联无法消除,那么我们应该尽可能地使多个临界区之间没有重叠。仅当这两点都无法得到保证的的时候,我们才应该考虑使用“试锁定-回退”和“固定顺序锁定”的方法。

死锁几乎是我们在使用互斥量的时候需要特别注意的唯一问题。同时,它也是并发程序设计当中的最严重的问题之一。我们应该想尽一切办法避免它的发生。

互斥量简单而高效,并且适用于绝大部分的共享数据的同步场景。互斥量的实现会使用到机器语言级别的原子操作,并仅在锁定冲突的才会涉及系统调用的执行。这使得互斥量比其他同步方法(比如信号灯)的速度要快很多。

3. 条件变量

在我们可用的同步方法集中,还有一个可以与互斥量相提并论的同步方法——条件变量。与互斥量不同,条件变量的作用并不是保证在同一时刻仅有一个线程访问某一个共享数据,而是在对应的共享数据的状态发生变化时,通知其他因此而被阻塞的线程。条件变量总是与互斥量组合使用。互斥量为共享数据的访问提供互斥支持,而条件变量可以就共享数据的状态的变化向相关线程发出通知。当线程成功锁定互斥量从而访问到共享数据的时候,共享数据的状态并不一定正好满足它的要求。下面我们通过一个示例来了解条件变量的适用场景。

假设有一个容量有限的数据块队列和若干个会操作该队列的线程。我们可以把这里所说的数据块想象成具有明显边界的字节序列。其中的一些线程会生产数据块并把它们添加到数据块队列中,而另一些线程会消费数据块并把它们从数据块队列中删除。显然,这是一个经典的生产者-消费者问题。因为会有多个线程并发的访问这个数据块队列,所以我们应该想到把向数据块队列添加数据块的操作(以下简称添加操作)和从数据块队列中获取并删除数据块的操作(以下简称获取操作)都置于临界区之中,并用同一个互斥量加以保护。这样,我们就能保证在执行添加操作的线程(以下简称生产者线程)未完成添加操作之前,其他生产者线程和执行获取操作的线程(以下简称消费者线程)都无法进行相应的操作。同时也可以保证,一个数据块只会被某一个消费者线程取走。因而,我们使用互斥量保证了队列中的每一个数据块的正确和完整。但是,即使有了互斥量的保护也可能发生以下两种情况。

第一种情况是生产者线程获得到互斥量,但却发现数据块队列已满,无法再添加新的数据块。这时,生产者线程可能会在临界区内等待,直到数据块队列有空余空间以容纳新的数据块。其中的等待行为往往是通过循环的判断数据块队列的已满状态来实现的。一旦判断的结果为假,循环就会立即被结束执行,紧随其后的就是添加操作,如图6-19所示。

6.3.1 线程 - 图7

图 6-19 生产者线程添加数据块的流程1

生产者线程这样做会导致一个严重的问题。如果在当前线程第一次判断队列是否已满的时候结果就为真,那么它会一直循环的执行获取队列状态和判断队列是否已满这两个步骤,直到永远。由于当前线程在完成添加操作之前不会解锁互斥量,所以会使任何消费者线程都无法取走队列中的数据块。如果队列中的数据块不会被取走,判断队列是否已满的结果又怎么可能为假呢?注意,这样就产生了一个死循环!再次强调,我们应该尽力避免可预知的死锁的发生!由于上述流程的问题很明显,所以可以很容易改进它,如图6-20所示。

6.3.1 线程 - 图8

图 6-20 生产者线程添加数据块的流程2

从图6-20中可知,我们现在把锁定和解锁互斥量的操作与其他相关操作一起放到了循环体里面。这使得前面造成的那个死锁问题被消除掉了。因为无论队列是否已满、添加操作是否可以进行,互斥量都会被解锁。不过,这个进行过改进的流程还是存在缺陷的。如果队列长时间处于已满状态,则这里的循环体会被执行很多次。其中包括针对互斥量的那两个操作。这样的循环无疑会造成CPU资源的浪费。另外,如果生产者线程有很多,那么当判断队列是否已满的结果为假的时候添加操作就一定能够成功吗?请读者带着这些问题接着往下看。顺便提一句,如果添加操作在被执行的时候引发了一个异常并使该流程被意外终结,那么互斥量就永远不会被解锁。这又应该怎样解决?我们在第8章讲Go语言中的互斥量的时候再讨论这个问题。

第二种情况是消费者线程获得到互斥量,但却发现数据块队列为空。这时,消费者不得不迭代的检查队列的状态,并在队列中存在数据块的时候再尝试获取它。相应的流程如图6-21所示。

6.3.1 线程 - 图9

图 6-21 消费者线程获取数据块的流程

显然,这一流程与前面那个经过改进的生产者线程的流程非常类似,并且也存在着相同的缺陷。我们就不再在这里进行重复的描述了。

如果添加操作和获取操作能够在条件不满足时自行阻塞,并且一旦条件满足就立即进行相应的操作就好了,不是吗?这样的话,我们就不用在外部使用互斥量了。但遗憾的是,我们在这里虚构出来的数据块队列并没有这样的能力。幸好,我们能够使用条件变量来解决这个问题。更确切地说,条件变量恰恰是解决这类问题的利器。

与互斥量相同,我们在使用一个条件变量之前必须创建和初始化它。同样地,条件变量的初始化必须要保证唯一性。另外,条件变量在被真正使用之前还必须要与某个互斥量进行绑定。这与它的具体操作有关。我们可以在一个条件变量之上进行的操作有3种。

  • 等待通知(wait):等待通知的意思是阻塞当前线程,直至收到该条件变量发来的通知。

  • 单发通知(signal):单发通知的意思是让该条件变量向至少一个正在等待它的通知的线程发送通知,以表示某个共享数据的状态已经改变。与其他操作相同,这里的英文名称signal也是相应函数的名称的一部分。但是,请注意,这里的signal与我们在上一节所讲的信号是不同的两个概念。为了避免混淆,我们把条件变量发送的信号称为通知。

  • 广播通知(broadcast):广播通知的意思是指让条件变量给正在等待它的通知的所有线程都发送通知,以表示某个共享数据的状态已经改变。

实际上,等待通知的操作并不是简单的阻塞当前线程。在该操作被执行的时候会先解锁与该条件变量绑定在一起的那个互斥量,然后再使当前线程阻塞。这里隐藏着两个细节。第一个细节是,只有在当前的共享数据的状态不满足条件时,才应该执行等待通知操作,而检查共享数据的状态需要受到互斥量的保护。也就是说,检查共享数据状态的操作和等待通知操作都需要在相应的临界区内进行。因此,等待通知操作中包含的解锁互斥量的操作是不会造成任何问题的。它没有违反互斥量的基本使用原则,即不能对一个互斥量进行重复解锁。第二个细节与等待通知操作中包含解锁互斥量操作的原因有关。如果等待通知操作在阻塞当前线程之前不对互斥量进行解锁,那么其他线程也无法进入相应的临界区。这与我们在前面讲述的生产者线程的最初流程是相似的。如果当前线程因等待共享数据状态的改变而被阻塞,而其他的线程也因互斥量的阻挡而无法改变共享数据的状态,那么会立即形成死锁。因此,在阻塞当前线程之前,等待通知操作必须先解锁互斥量。更重要的是,等待通知操作所包含的解锁互斥量的操作和阻塞当前线程的操作共同形成了一个原子操作。也就是说,在等待通知操作使当前线程被阻塞之前,任何线程都无法锁定相应的互斥量。这样做的原因与其他线程在进行相关操作时所处的环境是有关系的,如图6-22所示。我们在稍后讲单发通知操作的时候再说明它。

6.3.1 线程 - 图10

图 6-22 条件变量的等待通知操作的内部流程

等待通知操作的精妙之处不止于此。当等待通知操作因收到条件变量发送的通知而唤醒当前线程之后,会首先重新锁定与该条件变量绑定在一起的那个互斥量。如果该互斥量已经被其他线程抢先锁定,那么当前线程会再次进入到睡眠状态。为什么要重新锁定那个互斥量?其主要原因是条件变量的设计者认为,线程在执行等待通知操作的地方被唤醒之后一般会立即访问共享数据。事实也确实如此。这倒不是由于当收到条件变量的通知时会立即对共享数据进行操作,而是因为当前线程在继续运行之后,应该马上再次检查共享数据的状态以判断其是否满足条件。为什么要这么做?请看下面的这个反例。我们在生产者线程向队列添加数据块的流程中加入对条件变量的运用,并且当该线程在执行等待通知操作处被唤醒之后,不再次检查队列的状态而直接向队列添加数据块,其流程如图6-23所示。

6.3.1 线程 - 图11

图 6-23 不正确地使用条件变量的生产者线程添加数据块的流程

在线圈之下的部分就是此流程的问题所在。在当前线程被唤醒之后直接执行了添加操作。如果生产者线程只有一个的话,可能不会出现什么问题。但是在真实的场景中往往没有这么简单。在队列已满的情况下,可能会有多个生产者线程会相继因执行等待通知操作而进入到睡眠状态。由于不知道在队列有空余空间的时候会有多少个生产者线程接收到该条件变量的通知,所以我们总是应该考虑多个生产者线程同时接收到通知的情况。当这种情况发生时,如果一个生产者线程被唤醒并直接执行添加操作,那么就有可能使该操作失败。因为可能会有其他生产者线程抢先向队列添加了数据块,致使该队列又处于已满的状态。如此一来,我们可能需要再次尝试添加数据块。如果再次尝试,为了保证成功率,我们必然还要先检查队列的状态。这样做的效率是很低的。因为线程始终会执行成功率低下的添加操作。如果不再次尝试,那么就等于甘愿承受数据块添加操作的失败。显然,无论是否再次尝试,都不是正确且高效的方案。其本质在于,我们错过了再次检查队列状态的最佳时机。如果在执行添加操作之前再次检查队列的状态,并保证仅在条件满足时才执行添加操作,那么就可以保证添加操作的成功。请看图6-24所示的流程图。

6.3.1 线程 - 图12

图 6-24 使用条件变量的生产者线程添加数据块的流程1

我们使用了一个循环体让线程在被唤醒时总是重新检查队列的状态。这样的话,即使被其他线程抢先了一步,当前线程仍然可以再次利用等待通知操作重新等待操作时机。如果再次确认了队列的不满状态,那么就可以放心地执行添加操作了。所以此流程是正确和高效的。注意,在有些多CPU的计算机系统中,即使没有接收到条件变量的通知,线程也有可能会被唤醒。所以,我们总是应该依据此流程运用等待通知操作。

下面我们来说说条件变量的单发通知操作和广播通知操作。这两个操作的作用都是向因执行相同条件变量的等待通知操作而被阻塞的线程(以下简称等待线程)发送通知。但不同的是,前者只保证至少会唤醒一个等待线程,而后者则必然会唤醒所有的等待线程。这就决定了这两项操作的适用场景。如果我们明知道等待线程都在等待共享数据的同一个状态,并且在某个等待线程被唤醒并执行相应操作之后,共享数据的状态就不再满足等待线程的条件了,那么再使用广播通知操作来通知等待线程肯定就是低效的。前文所述的数据块队列的例子就是这样的典型案例。所有的生产者线程都在等待队列非满的状态。如果我们使用广播的方式来发送通知,虽然所有已在等待的生产者线程都会接收到该通知,但是一旦某个生产者线程被唤醒并抢先向队列添加了一个数据块,那么该队列的状态就又会回到已满的状态。据此,我们只要通知一个生产者线程以示意可以向队列添加新的数据块就可以了。通知更多的生产者线程只会让它们白白浪费CPU的资源,从而使程序的整体性能降低。如果一个等待线程接收到通知但未能及时就此作出应有的响应,那么这个通知就属于过剩的通知。这种情况下,因接收到通知而唤醒等待线程的这个动作也被称为伪唤醒。请记住,它们会对程序的运行带来负面影响。

为了及时地向生产者线程告知等待队列的非满状态,我们需要对原有的消费者线程获取数据块的流程进行改进。改进后的流程如图6-25所示。

6.3.1 线程 - 图13

图 6-25 使用条件变量的消费者线程获取数据块的流程1

可以看到,我们只是在确认获取操作的执行完成之后添加了一个执行条件变量的单发通知操作的步骤。这意味着, 每当队列中的一个数据块被消费之后,相应条件变量的单发通知操作都会被执行一次。注意,条件变量的通知具有即时性。通知只是负责向等待线程发送一个信号以告知共享数据的状态发生了某种变化,而不会存储相关信息。在通知被发送的时候,如果没有任何线程正在等待此条件变量的通知,那么该通知就会被无视。并且,它也绝不会被传递到在它被发送之后才开始等待它的线程那里。换句话说,通知稍纵即逝,并且不会留下任何痕迹。因此,我们丝毫不用担心这样频繁的通知发送操作会给生产者线程的正常流程带来什么不利影响。另外,细心的读者可能会发现,消费者线程是在对互斥量进行解锁之后才执行条件变量的单发通知操作的。实际上,这两个操作之间的顺序是无关紧要的。我们完全可以颠倒它们的执行顺序。并且,在互斥量的保护下执行单发通知操作通常更加安全。

现在我们来解释一下前文埋下的那个问题。等待通知操作在被执行的时候会先解锁互斥量再阻塞当前线程。但是,由于这两个步骤共同组成了一个原子操作,所以在当前线程被阻塞之前其他线程无法锁定该互斥量。我们依然以在生产者线程中执行的等待通知操作为例。在生产者线程执行等待通知操作的过程中,消费者线程无法从队列那里获取数据块,并且也无法执行之后的单发通知操作。如果双方的操作被同时发起,那么消费者线程执行这些操作的时间势必会被推后一点。这点时间是极短的,几乎可以忽略不计。然而,为了这极短的时间而错过了本应接收到的通知则是非常不值得的。从另一个角度看,即使在生产者线程正在执行等待通知操作的过程中同一个条件变量发送的通知会被传递给它,它也无法做出任何响应。其原因是这时的生产者线程还没有为接收通知做好准备。它此时还未进入到睡眠状态并开始等待通知。更重要的是,在通知达到之后生产者线程依然会被阻塞。因此,这时达到的通知不会起到任何作用,并且还可能会引发一些冲突。可见,把等待通知操作中的两个步骤合为一个原子操作是非常有必要的。

到目前为止,我们仅讲到了生产者线程应该怎样利用条件变量来等待队列状态的更新并及时做出响应,以及消费者线程应该在什么时候让该条件变量向正在等待通知的生产者线程发送通知。但是,不要忘了在这个示例中还存在另外一种对称的情况,那就是消费者线程需要利用条件变量等待空队列的非空状态的出现并试图从队列那里获取新的数据块,同时发送者线程也应该在每次数据块添加操作完成之后让该条件变量发送通知给正在等待的消费者线程。

注意,为了向特定的等待线程告知队列已处于非满状态或非空状态,我们需要分别创建两个条件变量。为了加以区分,我们把被用来关注队列的非满状态的条件变量称为条件变量Ⅰ,而把被用来关注队列的非空状态的条件变量称为条件变量Ⅱ。由于在生产者线程和消费者线程中的对数据块队列的操作都由同一个互斥量保护的,所以条件变量Ⅰ和条件变量Ⅱ也必须都与这个互斥量进行绑定,这样才能达到预期的效果。

为了在添加操作完成后向消费者线程告知队列已处于非空状态,我们还需要对前述的那个生产者线程执行的流程稍作修改,新的流程图如图6-26所示。

6.3.1 线程 - 图14

图 6-26 使用条件变量的生产者线程添加数据块的流程2

对前面的消费者线程的流程的改造也是必须的。这与先前对生产者线程的流程的改进方式是非常类似的。我们需要让消费者线程在发现队列未处于非空状态时等待条件变量Ⅱ的通知。并且,让消费者线程在被唤醒时重新检查队列的状态,并在必要时再次等待条件变量Ⅱ的通知总是应该的。这样既可以避免在新的数据块被其他消费者线程抢先取走的情况下数据块获取操作的失败,又可以使之尽早地从队列中获取到数据块,如图6-27所示。

6.3.1 线程 - 图15

图 6-27 使用条件变量的消费者线程添加数据块的流程2

可以看到,经过几番改进之后,消费者线程添加数据块的流程依然与生产者线程添加数据块的流程非常相似。现在,这两类线程都利用条件变量实现了更高级的同步效果。这使得它们的运行效率和协作能力都得到了进一步的提高。

在我们真正地理解了条件变量的单发通知操作的特点和使用方法之后,广播通知操作就不难理解了。实际上,从易用性的角度来看,广播通知操作更胜一筹。只要等待线程可以处理好过剩的通知,那么广播通知总是能够达到目的的。而且在某些应用场景下,广播通知操作是更适合的。例如,如果多个等待同一个条件变量的通知的线程所期望的共享数据的状态存在多样性,那么以广播的方式发送通知就是最好的选择。准备发送通知的线程(以下简称发送线程)无法得知有哪些线程正在等待共享数据的当前状态,更不会知道在执行单发通知操作之后哪一个线程会接收到该通知。因此,发送线程只得执行广播通知操作以向所有的等待线程告知共享数据的状态已发生变化,且不会(也不可能)去关心哪些等待线程会对这一状态变化感兴趣。等待线程在被唤醒后会去重新检查共享数据的状态,并自行决定是对此做出响应还是继续等待下一个通知。

到这里,我们已经详细说明了多线程编程当中的两个非常重要的同步工具。互斥量使作为被同步对象的若干操作可以被有条不紊地执行,并且不会产生任何竞态条件。而条件变量则会使这些操作执行得更有效率。Go语言作为原生支持并发编程的语言提供了与它们有着类似功能的API。有了本小节所讲知识的铺垫,读者若要使用那些API将会是非常容易的事情。

4. 线程安全性

在本小节的最后,我们来简要地了解一下线程安全性这个概念。如果有一个代码块,它可以被多个线程并发地执行,且总能够产生预期的结果,那么该代码块就是线程安全的(thread-safe)。例如,如果代码块中包含了对共享数据的更新操作,那么这个代码块通常就是非线程安全的。但是,如果该代码块中的类似操作都处于临界区之中,那么这个代码块就是线程安全的。

经常被置于线程安全问题之中的代码块就是函数。这不仅仅是因为函数是最常用的独立代码块,更是因为函数的线程安全性有着更多的含义。让函数具有线程安全性的最有效方式就是使其可重入(reentrant)。如果某个进程中的所有线程都可以并发的对一个函数进行调用,并且无论它们调用该函数的实际执行情况怎样,该函数都可以产生预期的结果,那么就可以说这个函数是可重入的。更通俗地讲,如果多个线程并发的调用该函数与它们以任意顺序依次地调用它所产生的效果总是相同的,那么该函数就是一个可重入函数。

如果一个函数把共享数据的值作为其返回的结果或者包含于其返回的结果中,那么该函数就肯定不是一个可重入函数。因为,如果其他线程在该结果返回的过程中更新了相关的共享数据,那么函数的调用方得到的结果就很可能不是该函数真正想返回的那个结果。这种情况是无法通过使用互斥量来解决的。因此,为了使函数可重入,我们必须也只能杜绝在该函数的返回结果中掺杂任何共享数据。当然,如果这些共享数据都是完全不可被更新的话,就不会存在这样的隐患。一条更加通用的规则是,任何内含了对共享数据进行操作的代码的函数都可以被视为不可重入的函数。

使函数成为可重入函数是实现其线程安全性的最直接的一种方式,并且也是最简单和最高效的方式。我们应该尽量编写可重入的函数。但是,如果我们无法办到这一点,或者该函数中需要调用某些不可重入的函数,那么就需要采取另一种办法——使用互斥量把相关的代码保护起来。

当然,为了实现线程安全的函数,把其中的所有代码都置于临界区之中是可行的。但是,这往往也是最低效的一种方法。我们应该仔细地从函数体中查找出操作共享数据的代码并用互斥量把它们保护起来。如果可能的话,我们还应该把这些代码从函数体中分离出来。这样有利于在之后施加保护措施。分离的方式可以是把它们聚集在一起,也可以是把它们独立成一个或多个函数。或者,更进一步地,把它们抽象并组装成一个数据结构并使这个数据结构具有线程安全性。当然,是否分离这些代码以及以怎样的方式分离它们取决于很多因素。最简单的方式就是把每条涉及操作共享数据的语句都用互斥量保护起来。这通常也是我们最初采用的方式。

无论怎样,互斥量的使用总会耗费一定的系统资源和时间。正如前文所说,使用互斥量的过程中总会存在着某些博弈和权衡。如果在一个代码块中仅包含对共享数据的访问操作而不包含对它们的更新操作,那么在这个代码块之内就可以不使用互斥量。但一个必要的前提是,必须在真正使用共享数据之前就把它们完全复制到当前线程的线程栈之中。也就是说,线程需要自己维护一份其需要使用到的共享数据的副本。对于函数来说,这样的副本是作为其局部变量存在的。某个线程对某个函数的第一次调用会致使该函数中的局部变量陆续被创建在该线程的线程栈中。在不同线程的线程栈中,因调用相同的函数而被创建的同名局部变量之间是完全独立的,并且不会相互干扰。一般情况下,这样的函数(即只包含对共享数据的访问操作的函数)是可重入的,也是线程安全的。因为它们的执行效果与是否并发地执行它们无关。不过,有两个地方还需要我们注意。

第一,如果当前进程中还并发地执行了更新相关共享数据的代码的话,情况就会有些不同。这些函数的执行效果可能会受到这些更新操作的影响,无论这些函数是否被并发的执行。例如,在线程A正在更新一个较复杂的共享数据(无法仅用一条语句完成对它的更新)但还未完成的时候,线程B访问了该共享数据并把它的值赋给了一个局部变量。这时,线程B中的那个局部变量的值就只代表了该共享数据在被更新过程中的一个中间状态。这是不正确的。虽然这种情况的发生需要一定的条件,但是我们还是应该把它纳入到考虑范围之内。在这种情况下,是否可以省略掉对互斥量的使用,还需要依靠我们对程序的运行时状况的预判和评估。

第二,如果某个局部变量的值由共享数据本身提供,并在该共享数据中还包含了对其他共享数据的引用,那么就不能说该局部变量所属的函数是可重入的。因为被这个函数间接使用到的共享数据的改变也有可能影响到该函数的实际执行效果。这种情况极易成为一个漏洞,造成一些看起来匪夷所思的错误,并且非常难以被察觉和定位。这也是我们在前面强调必须对共享数据进行完全复制的原因。另外,若局部变量的值是指向某个共享数据的指针而不是其本身,也会使所谓的共享数据副本形同虚设。因此,如果我们确实需要在线程内创建一个共享数据的副本,就要对它进行深层的复制。也就是说,要把该共享数据中引用到的所有其他共享数据全部复制一份,并进行重新组装。有时候,这可能是非常费时、费力,以及耗费系统资源的。是否真的需要在涉及结构复杂的共享数据的时候依然使用这种方式使函数实现线程安全性还有待商榷。这又是一个需要权衡的地方。

再次强调,在你还未深入考虑过或者还没有最终确定应该使用哪一种方式保证线程安全性的时候,请使用互斥量保护好那些涉及共享数据操作(包括访问和更新)的代码。

我们在进行多线程编程的时候总会遇到同步的问题。只要处理好这些问题才能够让我们的程序正确并高效地运行。本小节为读者抛出了一些常见的问题以及可以利用的工具。在Go语言中,一些容易导致错误的问题被它的运行时系统有效地处理了,并在操作系统的多线程支持之上为我们提供了更加先进的并发编程模型。这为我们提供了极大的便利,使我们在编写Go语言程序的时候可以更少的在这些非业务性的问题上耗费精力。但是,Go语言依然通过标准库为我们保留了那两个最重要且使用最广泛的同步工具——互斥量和条件变量,以备我们的不时之需。