1

常见并发问题

多年来,研究人员花了大量的时间和精力研究并发编程的缺陷。并发缺陷有很多常见的模式,从大的方面来说可以分为两类:非死锁缺陷和死锁缺陷。了解这些模式是写出健壮、正确程序的第一步。

非死锁缺陷

研究表明,非死锁问题占了并发问题的大多数。它们是怎么发生的?以及如何修复?我们主要讨论其中两种:违反原子性(atomicity violation)缺陷和违反顺序(order violation)缺陷。

违反原子性缺陷

这是一个MySQL中出现的例子。

1    Thread 1::
2    if (thd->proc_info) {
3      ...
4      fputs(thd->proc_info, ...);
5      ...
6    }
7
8    Thread 2::
9    thd->proc_info = NULL;

这个例子中,两个线程都要访问thd结构中的成员proc_info。第一个线程检查proc_info非空,然后打印出值;第二个线程设置其为空。显然,假如当第一个线程检查之后,在fputs()调用之前被中断,第二个线程把指针置为空;当第一个线程恢复执行时,由于引用空指针,会导致程序崩溃。

正式的违反原子性的定义是:“违反了多次内存访问中预期的可串行性(即代码段本意是原子的,但在执行中并没有强制实现原子性)”

这种问题的修复通常很简单。我们只要给共享变量的访问加锁,确保每个线程访问proc_info字段时,都持有锁。当然,访问这个结构的所有其他代码,也应该先获取锁。

1    pthread_mutex_t proc_info_lock = PTHREAD_MUTEX_INITIALIZER;
2
3    Thread 1::
4    pthread_mutex_lock(&proc_info_lock);
5    if (thd->proc_info) {
6      ...
7      fputs(thd->proc_info, ...);
8      ...
9    }
10    pthread_mutex_unlock(&proc_info_lock);
11
12   Thread 2::
13   pthread_mutex_lock(&proc_info_lock);
14   thd->proc_info = NULL;
15   pthread_mutex_unlock(&proc_info_lock);
违反顺序缺陷

下面是一个简单的例子。

1    Thread 1::
2    void init() {
3        ...
4        mThread = PR_CreateThread(mMain, ...);
5        ...
6    }
7
8    Thread 2::
9    void mMain(...) {
10       ...
11       mState = mThread->State;
12       ...
13   }

你可能已经发现,线程2的代码中似乎假定变量mThread已经被初始化了。然而,如果线程1并没有率先执行,线程2就可能因为引用空指针崩溃(假设mThread初始值为空,否则可能会产生更加奇怪的问题,因为线程2中会读到任意的内存位置并引用)。

违反顺序更正式的定义是:“两个内存访问的预期顺序被打破了(即A应该在B之前执行,但是实际运行中却不是这个顺序)”

我们可以通过强制顺序来修复这种缺陷,条件变量(condition variables)就是一种简单可靠的方式。在上面的例子中,我们可以把代码修改成这样:

1    pthread_mutex_t mtLock = PTHREAD_MUTEX_INITIALIZER;
2    pthread_cond_t mtCond = PTHREAD_COND_INITIALIZER;
3    int mtInit            = 0;
4
5    Thread 1::
6    void init() {
7       ...
8       mThread = PR_CreateThread(mMain, ...);
9
10      // signal that the thread has been created...
11      pthread_mutex_lock(&mtLock);
12      mtInit = 1;
13      pthread_cond_signal(&mtCond);
14      pthread_mutex_unlock(&mtLock);
15      ...
16   }
17
18   Thread 2::
19   void mMain(...) {
20      ...
21      // wait for the thread to be initialized...
22      pthread_mutex_lock(&mtLock);
23      while (mtInit == 0)
24          pthread_cond_wait(&mtCond,  &mtLock);
25      pthread_mutex_unlock(&mtLock);
26
27      mState = mThread->State;
28      ...
29   }

死锁缺陷

除了上面提到的并发缺陷,死锁(deadlock)是一种在许多复杂并发系统中出现的经典问题。例如,当线程1持有锁L1,正在等待另外一个锁L2,而线程2持有锁L2,却在等待锁L1释放时,死锁就产生了。以下的代码片段就可能出现这种死锁:

Thread 1:    Thread 2:
lock(L1);    lock(L2);
lock(L2);    lock(L1);

这段代码运行时,不是一定会出现死锁的。当线程1占有锁L1,上下文切换到线程2。线程2锁住L2,试图锁住L1。这时才会产生死锁,两个线程互相等待。如图所示,其中的圈(cycle)表明了死锁。

image.png

产生死锁的条件

死锁的产生需要如下4个条件:

  • 互斥:线程对于需要的资源进行互斥的访问。
  • 持有并等待:线程持有了资源,同时又在等待其他资源。
  • 非抢占:线程获得的资源,不能被抢占。
  • 循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这个资源又是下一个线程要申请的。

如果这4个条件的任何一个没有满足,死锁就不会产生。因此,解决死锁的方法也显而易见:只要设法阻止其中某一个条件即可。

死锁预防
循环等待

也许最实用的预防技术,就是让代码不会产生循环等待。最直接的方法就是获取锁时提供一个全序(total ordering)。假如系统共有两个锁(L1和L2),那么我们每次都先申请L1然后申请L2,这样严格的顺序避免了循环等待,也就不会产生死锁。

当然,更复杂的系统中不会只有两个锁,锁的全序可能很难做到。因此,偏序(partial ordering)可能是一种有用的方法,安排锁的获取顺序并避免死锁。Linux中的内存映射代码就是一个偏序锁的优秀范例。代码开头的注释表明了10组不同的加锁顺序,包括简单的关系,比如i_mutex早于i_mmap_mutex,也包括复杂的关系,比如i_mmap_mutex早于private_lock,早于swap_lock,早于mapping->tree_lock。

不过,全序和偏序都需要细致的锁策略的设计和实现。另外,顺序只是一种约定,粗心的程序员很容易忽略,导致死锁。最后,有序加锁需要深入理解代码库,了解各种函数的调用关系,即使一个错误,也会导致严重的后果。

注:可以根据锁的地址作为获取锁的顺序,按照地址从高到低,或者从低到高的顺序加锁。

持有并等待

死锁的持有并等待条件,可以通过原子地抢锁来避免。实践中,可以通过如下代码来实现:

lock(prevention);
lock(L1);
lock(L2);
...
unlock(prevention);

代码保证了某个线程先抢到prevention这个锁之后,即使有不合时宜的线程切换,其他线程也抢不到任何锁。

不过,这个方案的问题也显而易见。首先它不适用于封装,因为这个方案需要我们准确地知道要抢哪些锁,并且提前抢到这些锁。并且因为要提前抢到所有锁,而不是在真正需要的时候,所以可能降低了并发。

非抢占

在调用unlock之前,都认为锁是被占有的。多个抢锁操作通常会带来麻烦,因为我们等待一个锁时,可能会同时持有另一个锁。很多线程库提供更为灵活的接口来避免这种情况。具体来说,trylock()函数会尝试获得锁,返回−1则表示锁已经被占有,线程并不会挂起。

可以用这一接口来实现无死锁的加锁方法:

top:
    lock(L1);
    if (trylock(L2) == -1) {
        unlock(L1);
        goto top;
    }

注意,当另一个线程使用相同的加锁方式,但是不同的加锁顺序(L2然后L1),程序仍然不会产生死锁。但是会引来一个新的问题:活锁(livelock)。两个线程有可能一直重复这一序列,又同时都抢锁失败。这种情况下,系统一直在运行这段代码,因此名为活锁。也有活锁的解决方法:例如,可以在循环结束的时候,先随机等待一个时间,然后再重复整个动作,这样可以降低线程之间的重复互相干扰。

使用trylock方法可能还会有其他一些困难。第一个问题仍然是封装:如果其中的某一个锁是封装在函数内部的,那么这个跳回开始处就很难实现。还有如果代码在中途获取了某些资源,必须要确保也能释放这些资源。例如,在抢到L1后,我们的代码分配了一些内存,当抢L2失败时,在goto之前,需要释放这些内存。当然,在某些场景下,这种方法很有效。

互斥

最后的预防方法是完全避免互斥。通常来说,代码都会存在临界区,因此很难避免互斥。那么我们应该怎么做呢?想法很简单:通过强大的硬件指令,我们可以构造出不需要锁的数据结构

比如,我们可以使用比较并交换(compare-and-swap)指令来实现一个无锁同步的链表插入操作。
这是在链表头部插入元素的代码:

void insert(int value) {
    node_t *n = malloc(sizeof(node_t));
    assert(n != NULL);
    n->value = value;
    n->next = head;
    head = n;
}

一种可能的实现是:

void insert(int value) {
    node_t *n = malloc(sizeof(node_t));
    assert(n != NULL);
    n->value = value;
    do {
        n->next = head;
    } while (CompareAndSwap(&head, n->next, n) == 0);
}

这段代码,首先把next指针指向当前的链表头head,然后试着把新节点交换到链表头。如果此时其他的线程成功地修改了head的值,这里的交换就会失败,线程会一直重试。

死锁避免

除了死锁预防,某些场景更适合死锁避免(avoidance)。我们需要了解全局的信息,包括不同线程在运行中对锁的需求情况,从而使得后续的调度能够避免产生死锁。

例如,假设我们需要在两个处理器上调度4个线程,进一步假设我们知道线程1(T1)需要用锁L1和L2,T2也需要抢L1和L2,T3只需要L2,T4不需要锁。我们用下表来表示线程对锁的需求。

image.png

一种可行的调度方式是,只要T1和T2不同时运行,就不会产生死锁。下面就是这种方式:

image.png

Dijkstra提出的银行家算法也是一种类似的解决方案。不过这些方案的适用场景很局限。例如,在嵌入式系统中,你知道所有任务以及它们需要的锁。另外这种方法会限制并发。因此,通过调度来避免死锁不是广泛使用的通用方案。

死锁检查和恢复

最后一种常用的策略就是允许死锁偶尔发生,检查到死锁时再采取行动。如果死锁很少见,这种不是办法的办法也很实用。

很多数据库系统使用了死锁检测和恢复技术。死锁检测器会定期运行,通过构建资源图来检查循环。当循环(死锁)发生时,系统会根据既定的策略进行回滚甚至重启。如果还需要更复杂的数据结构相关的修复,那么需要人工参与。

注:也许最好的解决方案是开发一种新的并发编程模型:在类似MapReduce这样的系统中,程序可以完成一些类型的并行计算,无须任何锁。锁必然带来各种困难,我们应该尽可能地避免使用锁,除非确信必须使用。

基于事件的并发

目前为止,我们提到的并发,似乎只能用线程来实现。这不完全对,一些基于图形用户界面(GUI)的应用,或某些类型的网络服务器,常常采用另一种并发方式。这种方式称为基于事件的并发(event-based concurrency),在一些现代系统中较为流行。

基于事件的并发针对两方面的问题。一方面是多线程应用中,正确处理并发很有难度。另一方面,开发者无法控制多线程在某一时刻的调度。程序员只是创建了线程,然后就依赖操作系统能够合理地调度线程,但是某些时候操作系统的调度并不是最优的。

基本想法:事件循环

我们的想法很简单:我们等待某些事件的发生,当它发生时,检查事件类型,然后做少量的相应工作(可能是I/O请求,或者调度其他事件准备后续处理)

我们看一个典型的基于事件的服务器。这种应用都是基于一个简单的结构,称为事件循环(event loop)。事件循环的伪代码如下:

while (1) {
    events = getEvents(); 
    for (e in events)
        processEvent(e);
}

主循环等待某些事件发生,然后依次处理这些发生的事件。处理事件的代码叫作事件处理程序(event handler)。处理程序在处理一个事件时,它是系统中发生的唯一活动。因此,调度就是决定接下来处理哪个事件。这种对调度的显式控制,是基于事件方法的一个重要优点。

但这也带来一个更大的问题:基于事件的服务器如何知道哪个事件发生,尤其是对于网络和磁盘I/O?

重要API:select()/poll()

知道了基本的事件循环,我们接下来必须解决如何接收事件的问题。大多数系统提供了基本的API,即通过select()或poll()系统调用。这些接口对程序的支持很简单:检查是否接收到任何应该关注的I/O。例如,假定网络应用程序(如Web服务器)希望检查是否有网络数据包已到达,以便为它们提供服务。

下面以select()为例,它的定义如下:

int select(int nfds,
           fd_set *restrict readfds, 
           fd_set *restrict writefds, 
           fd_set *restrict errorfds,
           struct timeval *restrict timeout);

select()检查I/O描述符集合,它们的地址通过readfds、writefds和errorfds传入,分别查看它们中的某些描述符是否已准备好读取,是否准备好写入,或有异常情况待处理。在每个集合中检查前nfds个描述符,返回时用给定操作已经准备好的描述符组成的子集替换给定的描述符集合。select()返回所有集合中就绪描述符的总数。

这里的一个常见用法是将超时设置为NULL,这会导致select()无限期地阻塞,直到某个描述符准备就绪。但是,更健壮的服务器通常会指定某个超时时间。一种常见的做法是将超时设置为零,让调用select()立即返回。

使用select()

我们来看看如何使用select()来查看哪些描述符有接收到网络消息,下面是一个简单示例:

1    #include <stdio.h>
2    #include <stdlib.h>
3    #include <sys/time.h>
4    #include <sys/types.h>
5    #include <unistd.h>
6
7    int main(void) {
8        // open and set up a bunch of sockets (not shown)
9        // main loop
10        while (1) {
11           // initialize the fd_set to all zero
12           fd_set readFDs;
13           FD_ZERO(&readFDs);
14
15           // now set the bits for the descriptors
16           // this server is interested in
17           // (for simplicity, all of them from min to max)
18           int fd;
19           for (fd = minFD; fd < maxFD; fd++)
20               FD_SET(fd, &readFDs);
21
22           // do the select
23           int rc = select(maxFD+1, &readFDs, NULL, NULL, NULL);
24
25           // check which actually have data using FD_ISSET()
26           int fd;
27           for (fd = minFD; fd < maxFD; fd++)
28               if (FD_ISSET(fd, &readFDs))
29                   processFD(fd);
30       }
31   }

这段代码很容易理解。初始化完成后,服务器进入无限循环。在循环内部,它使用FD_ZERO()宏首先清除文件描述符集合,然后使用FD_SET()将所有从minFD到maxFD的文件描述符包含到集合中。最后,服务器调用select()来查看哪些连接有可用的数据。然后,通过在循环中使用FD_ISSET(),事件服务器可以查看哪些描述符已准备好数据并处理传入的数据。

使用单个CPU和基于事件的应用程序,并发程序中常见的问题不再存在。因为一次只处理一个事件,所以不需要获取或释放锁。基于事件的服务器是单线程的,因此也不能被另一个线程中断。

问题:阻塞系统调用

不过,这里存在一个问题:如果某个事件要求你发出可能会阻塞的系统调用,该怎么办?

例如,假定一个请求从客户端进入服务器,要从磁盘读取文件并将其内容返回给发出请求的客户端。为了处理这样的请求,某些事件处理程序会发出open()系统调用来打开文件,然后通过read()调用来读取文件。当文件被读入内存时,服务器可能会开始将结果发送到客户端。

open()和read()调用都可能向存储系统发出I/O请求,因此可能需要很长时间才能提供服务。使用基于线程的服务器时,这不是问题:在发出I/O请求的线程挂起时,其他线程可以运行。但是,使用基于事件的方法时,没有其他线程可以运行。这意味着如果一个事件处理程序发出一个阻塞的调用,整个服务器就会阻塞直到调用完成。当事件循环阻塞时,系统处于闲置状态,因此是潜在的巨大资源浪费。因此,我们在基于事件的系统中必须遵守一条规则:不允许阻塞调用

解决方案:异步I/O

为了克服这个限制,许多现代操作系统引入了新的方法来向磁盘系统发出I/O请求,一般称为异步I/O(asynchronous I/O)。这些接口使应用程序能够发出I/O请求,在I/O完成之前可以立即将控制权返回给调用者,并可以让应用程序能够确定各种I/O是否已完成。

当程序需要读取文件时,可以调用异步I/O的相关接口。如果成功,它会立即返回,应用程序可以继续其工作。对于每个未完成的异步I/O,应用程序可以通过调用接口来周期性地轮询(poll)系统,以确定所述I/O是否已经完成。

如果一个程序在某个特定时间点发出数十或数百个I/O,重复检查它们中的每一个是否完成是很低效的。为了解决这个问题,一些系统提供了基于中断(interrupt)的方法。此方法使用UNIX信号(signal)在异步I/O完成时通知应用程序,从而消除了重复询问系统的需要

信号提供了一种与进程进行通信的方式。具体来说,可以将信号传递给应用程序。这样做会让应用程序停止当前的任何工作,开始运行信号处理程序(signal handler),即应用程序中某些处理该信号的代码。完成后,该进程就恢复其先前的行为。

另一个问题:状态管理

基于事件的方法的另一个问题是,当事件处理程序发出异步I/O时,它必须打包一些程序状态,以便下一个事件处理程序在I/O最终完成时使用。

我们来看一个简单的例子,在这个例子中,一个基于线程的服务器需要从文件描述符(fd)中读取数据,一旦完成,将从文件中读取的数据写入网络套接字描述符sd。

在一个多线程程序中,做这种工作很容易。当read()最终返回时,程序立即知道要写入哪个套接字,因为该信息位于线程堆栈中。在基于事件的系统中,为了执行相同的任务,我们使用AIO调用异步地发出读取,然后定期检查读取的完成情况。当读取完成时,基于事件的服务器如何知道该怎么做?也即该向哪个套接字写入数据?

解决方案很简单:在某些数据结构中,记录完成处理该事件需要的信息。当事件发生时(即磁盘I/O完成时),查找所需信息并处理事件。

在这个特定例子中,解决方案是将套接字描述符(sd)记录在由文件描述符(fd)索引的某种数据结构(例如,散列表)中。当磁盘I/O完成时,事件处理程序将使用文件描述符来查找该数据结构,这会将套接字描述符的值返回给调用者。然后,服务器可以完成最后的工作将数据写入套接字。


与昊
225 声望636 粉丝

IT民工,主要从事web方向,喜欢研究技术和投资之道