2

条件变量

之前我们介绍了锁,然而锁并不是并发程序设计中所需的唯一原语。在很多情况下,线程需要检查某一条件(condition)满足之后,才会继续运行。例如,父线程需要检查子线程是否执行完毕。这种等待如何实现呢?

注:并发程序有两大需求,一是互斥,二是等待。互斥是因为线程间存在共享数据,等待则是因为线程间存在依赖。

我们可以尝试用一个共享变量,如图所示。这种解决方案一般能工作,但是效率低下,因为主线程会自旋检查,浪费CPU时间。我们希望有某种方式让父线程休眠,直到等待的条件满足(即子线程完成执行)。

1    volatile int done = 0;
2
3    void *child(void *arg) {
4        printf("child\n");
5        done = 1;
6        return NULL;
7    }
8
9    int main(int argc, char *argv[]) {
10       printf("parent: begin\n");
11       pthread_t c;
12       Pthread_create(&c, NULL, child, NULL); // create child
13       while (done == 0)
14           ; // spin
15       printf("parent: end\n");
16       return 0;
17   }

定义和使用

线程可以使用条件变量(condition variable),来等待一个条件变成真。条件变量是一个显式队列,当某些执行状态(即条件,condition)不满足时,线程可以把自己加入队列,等待该条件。当其他线程改变了上述状态时,就可以通过在该条件上发送信号唤醒队列中的等待线程,让它们继续执行。

在POSIX库中,要声明一个条件变量,只要像这样写:pthread_cond_t c(注意:还需要适当的初始化)。条件变量有两种相关操作:wait()和signal()。线程要睡眠的时候,调用wait();当线程想唤醒等待在某个条件变量上的睡眠线程时,调用signal()。下面是一个典型示例:

1    int done = 0;
2    pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
3    pthread_cond_t c = PTHREAD_COND_INITIALIZER;
4
5    void thr_exit() {
6        Pthread_mutex_lock(&m);
7        done = 1;
8        Pthread_cond_signal(&c);
9        Pthread_mutex_unlock(&m);
10    }
11
12    void *child(void *arg) {
13        printf("child\n");
14        thr_exit();
15        return NULL;
16   }
17
18   void thr_join() {
19       Pthread_mutex_lock(&m);
20       while (done == 0)
21           Pthread_cond_wait(&c, &m);
22       Pthread_mutex_unlock(&m);
23   }
24
25   int main(int argc, char *argv[]) {
26       printf("parent: begin\n");
27       pthread_t p;
28       Pthread_create(&p, NULL, child, NULL);
29       thr_join();
30       printf("parent: end\n");
31       return 0;
32   }

wait()调用除了条件变量外还有一个参数,它是一个互斥锁。它假定在wait()调用时,这个互斥锁是已上锁状态。wait()的职责是原子地释放锁,并让调用线程休眠。当线程被唤醒时,它必须重新获取锁,再返回调用者。这样复杂的步骤也是为了避免在线程陷入休眠时,产生一些竞态条件。

有两种情况需要考虑。第一种情况是父线程创建出子线程,但自己继续运行,然后马上调用thr_join()等待子线程。在这种情况下,它会先获取锁,检查子线程是否完成,然后调用wait(),让自己休眠。子线程最终得以运行,打印出“child”,并调用thr_exit()函数唤醒父线程,这段代码会在获得锁后设置状态变量done,然后向父线程发信号唤醒它。最后,父线程会运行(从wait()调用返回并持有锁),释放锁,打印出“parent:end”。

第二种情况是,子线程在创建后,立刻运行,设置变量done为1,调用signal函数唤醒其他线程(这里没有其他线程),然后结束。父线程运行后,调用thr_join()时,发现done已经是1了,就直接返回。

需要注意的是,在上面的代码中,状态变量done和互斥锁c都是必需的。假如我们不使用状态变量,代码像下面这样,会出现什么问题?

1    void thr_exit() {
2        Pthread_mutex_lock(&m);
3        Pthread_cond_signal(&c);
4        Pthread_mutex_unlock(&m);
5    }
6
7    void thr_join() {
8        Pthread_mutex_lock(&m);
9        Pthread_cond_wait(&c, &m);
10       Pthread_mutex_unlock(&m);
11   }

假设子线程立刻运行,并且调用thr_exit()。在这种情况下,子线程发送信号,但此时却没有在条件变量上睡眠等待的线程。父线程运行时,就会调用wait并卡在那里,没有其他线程会唤醒它。通过这个例子,你应该认识到变量done的重要性,它记录了线程感兴趣的值。睡眠、唤醒和锁都离不开它。

在下面的例子中,我们假设线程在发信号和等待时都不加锁。又会发生什么问题?

1    void thr_exit() {
2        done = 1;
3        Pthread_cond_signal(&c);
4    }
5
6    void thr_join() {
7        if (done == 0)
8            Pthread_cond_wait(&c);
9    }

这里的问题是一个微妙的竞态条件。具体来说,如果父进程调用thr_join(),检查完done的值为0,然后试图睡眠。但在调用wait进入睡眠之前,父进程被中断。随后子线程修改变量done为1,发出信号,此时同样没有等待线程。当父线程再次运行时,就会长眠不醒。

所以,我们可以坚持这样一条原则:在使用条件变量时,调用signal和wait时要持有锁

生产者/消费者问题

假设有一个或多个生产者线程和一个或多个消费者线程。生产者把生成的数据项放入缓冲区,消费者从缓冲区取走数据项,以某种方式消费。很多实际的系统中都会有这种场景。例如,在多线程的网络服务器中,一个生产者将HTTP请求放入工作队列,消费线程从队列中取走请求并处理。

因为有界缓冲区是共享资源,所以我们必须通过同步机制来访问它,以免产生竞态条件。为了更好地理解这个问题,我们来尝试一些实际的代码。

首先需要一个共享缓冲区,让生产者放入数据,消费者取出数据。简单起见,我们就拿一个整数来做缓冲区,两个内部函数将值放入缓冲区,从缓冲区取值。

1    int buffer;
2    int count = 0; // initially, empty
3
4    void put(int value) {
5        assert(count == 0);
6        count = 1;
7        buffer = value;
8    }
9
10   int get() {
11       assert(count == 1);
12       count = 0;
13       return buffer;
14   }

put()函数会假设缓冲区是空的,把一个值存在缓冲区,然后把count设置为1表示缓冲区满了。get()函数刚好相反,把缓冲区清空后,并返回该值。

现在我们需要编写一些函数,用于生产和消费数据。调用生产函数的我们称之为生产者(producer)线程,调用消费函数的我们称之为消费者(consumer)线程。下面展示了一对非线程安全的生产者和消费者的代码,生产者将一个整数放入共享缓冲区loops次,消费者持续从该共享缓冲区中获取数据,并打印出数据项。我们的目标就是使用条件变量将其改造成线程安全的版本。

1    void *producer(void *arg) {
2        int i;
3        int loops = (int) arg;
4        for (i = 0; i < loops; i++) {
5            put(i);
6        }
7    }
8
9    void *consumer(void *arg) {
10       int i;
11       while (1) {
12           int tmp = get();
13           printf("%d\n", tmp);
14       }
15   }
有问题的方案

显然,put()和get()函数之中会有临界区,因为put()更新缓冲区,get()读取缓冲区。我们的首次尝试如下:

1    cond_t cond;
2    mutex_t mutex;
3
4    void *producer(void *arg) {
5        int i;
6        for (i = 0; i < loops; i++) {
7            Pthread_mutex_lock(&mutex);              // p1
8            if (count == 1)                          // p2
9                Pthread_cond_wait(&cond, &mutex);    // p3
10           put(i);                                  // p4
11           Pthread_cond_signal(&cond);              // p5
12           Pthread_mutex_unlock(&mutex);            // p6
13       }
14   }
15
16   void *consumer(void *arg) {
17       int i;
18       for (i = 0; i < loops; i++) {
19           Pthread_mutex_lock(&mutex);              // c1
20           if (count == 0)                          // c2
21               Pthread_cond_wait(&cond, &mutex);    // c3
22           int tmp = get();                         // c4
23           Pthread_cond_signal(&cond);              // c5
24           Pthread_mutex_unlock(&mutex);            // c6
25           printf("%d\n", tmp);
26       }
27   }

当生产者想要填充缓冲区时,它等待缓冲区变空(p1~p3)。消费者具有完全相同的逻辑,但等待不同的条件——变满(c1~c3)。

当只有一个生产者和一个消费者时,上图的代码能够正常运行。但如果有超过一个线程,这个方案会有两个严重的问题。

先来看第一个问题,它与等待之前的if语句有关。假设有两个消费者(Tc1和Tc2),一个生产者(Tp)。首先,一个消费者(Tc1)先开始执行,它获得锁(c1),检查缓冲区是否可以消费(c2),然后等待(c3)。

接着生产者(Tp)运行。它获取锁(p1),检查缓冲区是否满(p2),发现没满就给缓冲区加入一个数字(p4)。然后生产者发出信号,说缓冲区已满(p5)。关键的是,这让第一个消费者(Tc1)不再睡在条件变量上,进入就绪队列。生产者继续执行,直到发现缓冲区满后睡眠(p6,p1-p3)。

这时问题发生了:另一个消费者(Tc2)抢先执行,消费了缓冲区中的值。现在假设Tc1运行,在从wait返回之前,它获取了锁,然后返回。然后它调用了get() (p4),但缓冲区已无法消费。断言触发,代码不能像预期那样工作。

问题产生的原因很简单:在Tc1被生产者唤醒后,但在它运行之前,由于Tc2抢先运行,缓冲区的状态改变了。发信号给线程只是唤醒它们,暗示状态发生了变化,但并不会保证在它运行之前状态一直是期望的情况。

仍有缺陷的方案:使用While替代If

修复这个问题很简单:把if语句改为while。当消费者Tc1被唤醒后,立刻再次检查共享变量(c2)。如果缓冲区此时为空,消费者就会回去继续睡眠(c3)。生产者中相应的if也改为while(p2)。

1    cond_t cond;
2    mutex_t mutex;
3
4    void *producer(void *arg) {
5        int i;
6        for (i = 0; i < loops; i++) {
7            Pthread_mutex_lock(&mutex);               // p1
8            while (count == 1)                         // p2
9                Pthread_cond_wait(&cond, &mutex);      // p3
10           put(i);                                   // p4
11           Pthread_cond_signal(&cond);               // p5
12           Pthread_mutex_unlock(&mutex);             // p6
13       }
14   }
15
16   void *consumer(void *arg) {
17       int i;
18       for (i = 0; i < loops; i++) {
19           Pthread_mutex_lock(&mutex);                  // c1
20           while (count == 0)                           // c2
21               Pthread_cond_wait(&cond, &mutex);         // c3
22           int tmp = get();                             // c4
23           Pthread_cond_signal(&cond);                  // c5
24           Pthread_mutex_unlock(&mutex);             // c6
25           printf("%d\n", tmp);
26       }
27   }

我们要记住一条关于条件变量的简单规则:总是使用while循环。

但是,这段代码仍然有一个问题,也是上文提到的两个问题之一,它和我们只用了一个条件变量有关。

假设两个消费者(Tc1和Tc2)先运行,都睡眠了(c3)。生产者开始运行,在缓冲区放入一个值,唤醒了一个消费者(假定是Tc1),并开始睡眠。现在是一个消费者马上要运行(Tc1),两个线程(Tc2和Tp)都等待在同一个条件变量上。

消费者Tc1醒过来并从wait()调用返回(c3),重新检查条件(c2),发现缓冲区是满的,消费了这个值(c4)。这个消费者然后在该条件上发信号(c5),唤醒一个在睡眠的线程。但是,应该唤醒哪个线程呢?

因为消费者已经清空了缓冲区,很显然,应该唤醒生产者。但是,如果它唤醒了Tc2,问题就出现了。消费者Tc2会醒过来,发现队列为空(c2),又继续回去睡眠(c3)。生产者Tp刚才在缓冲区中放了一个值,现在在睡眠。消费者Tc1继续执行后也回去睡眠了。3个线程都在睡眠,显然是一个大问题。

我们可以看出:信号显然需要,但必须更有指向性。消费者不应该唤醒消费者,而应该只唤醒生产者,反之亦然。

单值缓冲区的正确方案

这个问题的解决方案也很简单:使用两个而不是一个条件变量,以便在系统状态改变时,能正确地发出信号唤醒哪类线程。下面展示了最终的代码。

1    cond_t empty, fill;
2    mutex_t mutex;
3
4    void *producer(void *arg) {
5        int i;
6        for (i = 0; i < loops; i++) {
7            Pthread_mutex_lock(&mutex);
8            while (count == 1)
9                Pthread_cond_wait(&empty,  &mutex);
10           put(i);
11           Pthread_cond_signal(&fill);
12           Pthread_mutex_unlock(&mutex);
13       }
14   }
15
16   void *consumer(void *arg) {
17       int i;
18       for (i = 0; i < loops; i++) {
19           Pthread_mutex_lock(&mutex);
20           while (count == 0)
21               Pthread_cond_wait(&fill, &mutex);
22           int tmp = get();
23           Pthread_cond_signal(&empty);
24           Pthread_mutex_unlock(&mutex);
25           printf("%d\n", tmp);
26       }
27   }
最终方案

我们现在有了可用的生产者/消费者方案,但不太通用,我们最后所做的修改是为了提高并发和效率。具体来说就是增加更多缓冲区槽位,这样在睡眠之前,生产者可以生产多个值;同样,消费者在睡眠之前可以消费多个值

单个生产者和消费者时,这种方案因为上下文切换少,提高了效率。多个生产者和消费者时,它可以支持并发生产和消费。和现有方案相比,改动也很小。

第一处修改是缓冲区结构本身,以及对应的put()和get()方法:

1    int buffer[MAX];
2    int fill = 0;
3    int use   = 0;
4    int count = 0;
5
6    void put(int value) {
7        buffer[fill] = value;
8        fill = (fill + 1) % MAX;
9        count++;
10   }
11
12   int get() {
13       int tmp = buffer[use];
14       use = (use + 1) % MAX;
15       count--;
16       return tmp;
17   }

下面展示了最终的代码逻辑。至此,我们解决了生产者/消费者问题。

1    cond_t empty, fill;
2    mutex_t mutex;
3
4    void *producer(void *arg) {
5        int i;
6        for (i = 0; i < loops; i++) {
7            Pthread_mutex_lock(&mutex);                 // p1
8            while (count == MAX)                        // p2
9                Pthread_cond_wait(&empty, &mutex);      // p3
10           put(i);                                     // p4
11           Pthread_cond_signal(&fill);                 // p5
12           Pthread_mutex_unlock(&mutex);               // p6
13       }
14   }
15
16   void *consumer(void *arg) {
17       int i;
18       for (i = 0; i < loops; i++) {
19           Pthread_mutex_lock(&mutex);               // c1
20           while (count == 0)                            // c2
21               Pthread_cond_wait(&fill, &mutex);     // c3
22           int tmp = get();                              // c4
23           Pthread_cond_signal(&empty);              // c5
24           Pthread_mutex_unlock(&mutex);             // c6
25           printf("%d\n", tmp);
26       }
27   }

覆盖条件

现在再来看条件变量的一个例子。这段代码是一个简单的多线程内存分配库中的问题片段:

1    // how many bytes of the heap are free?
2    int bytesLeft = MAX_HEAP_SIZE;
3
4    // need lock and condition too
5    cond_t c;
6    mutex_t m;
7
8    void *allocate(int size) {
9        Pthread_mutex_lock(&m);
10       while (bytesLeft < size)
11           Pthread_cond_wait(&c, &m);
12       void *ptr = ...; // get mem from heap
13       bytesLeft -= size;
14       Pthread_mutex_unlock(&m);
15       return ptr;
16   }
17
18   void free(void *ptr, int size) {
19       Pthread_mutex_lock(&m);
20       bytesLeft += size;
21       Pthread_cond_signal(&c); // whom to signal??
22       Pthread_mutex_unlock(&m);
23   }

从代码中可以看出,当线程调用进入内存分配代码时,它可能会因为内存不足而等待。相应的,线程释放内存时,会发信号说有更多内存空闲。但是,代码中有一个问题:应该唤醒哪个等待线程(可能有多个线程)?

解决方案也很直接:用pthread_cond_broadcast()代替上述代码中的pthread_cond_signal(),唤醒所有的等待线程。这样做,确保了所有应该唤醒的线程都被唤醒。当然,不利的一面是可能会影响性能,因为不必要地唤醒了其他许多不该被唤醒的线程。这些线程被唤醒后,重新检查条件,马上再次睡眠。

这种条件变量叫作覆盖条件(covering condition),因为它能覆盖所有需要唤醒线程的场景(保守策略)。一般来说,如果你发现程序只有改成广播信号时才能工作,可能是程序有缺陷。但在某些情景下,就像上述内存分配的例子中,广播可能是最直接有效的方案。

信号量

信号量是Dijkstra及其同事发明的,作为与同步有关的所有工作的唯一原语,可以使用信号量作为锁和条件变量。

定义

信号量是有一个整数值的对象,可以用两个函数来操作它。在POSIX标准中,是sem_wait()和sem_post()。因为信号量的初始值能够决定其行为,所以首先要初始化信号量,才能调用其他函数与之交互。

#include <semaphore.h>
sem_t s;
sem_init(&s, 0, 1);

其中申明了一个信号量s,通过第三个参数,将它的值初始化为1。sem_init()的第二个参数,在我们的所有例子中都被设置为0,表示信号量是在同一进程的多个线程共享的。信号量初始化之后,我们可以调用sem_wait()或sem_post()与之交互。

sem_wait()对信号量的值进行原子减一操作,当信号量的值大于等于1时立刻返回,否则会将调用线程放入信号量关联的队列中等待被唤醒。sem_post()对信号量的值进行原子加一操作,它不用等待某些条件满足,直接增加信号量的值,如果有等待线程,就唤醒其中一个。当信号量的值为负数时,这个值就是等待线程的个数。

二值信号量(锁)

信号量的第一种用法是我们已经熟悉的:用信号量作为锁。在下面的代码片段里,我们直接把临界区用一对sem_wait()/sem_post()环绕。为了使这段代码正常工作,信号量m的初始值X是至关重要的。X应该是多少呢?

sem_t m;
sem_init(&m, 0, X); // initialize semaphore to X; what should X be?

sem_wait(&m);
// critical section here
sem_post(&m);

回顾sem_wait()和sem_post()函数的定义,我们发现初值应该是1。

我们假设有两个线程的场景。第一个线程(线程1)调用了sem_wait(),它把信号量的值减为0。因为值是0,线程1从函数返回并进入临界区。如果没有其他线程尝试获取锁,当它调用sem_post()时,会将信号量重置为1(因为没有等待线程,不会唤醒其他线程)。

如果线程1持有锁,另一个线程(线程2)调用sem_wait()尝试进入临界区。这种情况下,线程2把信号量减为−1,然后等待。线程1再次运行,它最终调用sem_post(),将信号量的值增加到0,唤醒等待的线程,然后线程2就可以获取锁。线程2执行结束时,再次增加信号量的值,将它恢复为1。

因为锁只有两个状态(持有和没持有),所以这种用法有时也叫作二值信号量(binary semaphore)。

信号量用作条件变量

下面是一个简单的例子。假设一个线程创建另一个线程,并且等待它结束,那么信号量的初始值X应该是多少?

1    sem_t s;
2
3    void *
4    child(void *arg) {
5        printf("child\n");
6        sem_post(&s); // signal here: child is done
7        return NULL;
8    }
9
10   int
11   main(int argc, char *argv[]) {
12       sem_init(&s, 0, X); // what should X be?
13       printf("parent: begin\n");
14       pthread_t c;
15       Pthread_create(c, NULL, child, NULL);
16       sem_wait(&s); // wait here for child
17       printf("parent: end\n");
18       return 0;
19   }

有两种情况需要考虑。第一种,父线程创建了子线程,但是子线程并没有运行。这种情况下,父线程调用sem_wait()会先于子线程调用sem_post()。我们希望父线程等待子线程运行,唯一的办法是让信号量的值不大于0。因此,初值值为0。父线程运行,将信号量减为−1,然后睡眠等待;子线程运行的时候,调用sem_post(),信号量增加为0,唤醒父线程,父线程然后从sem_wait()返回,完成该程序。

第二种情况是子线程在父线程调用sem_wait()之前就运行结束。在这种情况下,子线程会先调用sem_post(),将信号量从0增加到1。然后当父线程有机会运行时,会调用sem_wait(),发现信号量的值为1。于是父线程将信号量从1减为0,没有等待,直接从sem_wait()返回,也达到了预期效果。

生产者/消费者问题

在这里,我们讨论如何使用信号量来解决上面提到的生产者/消费者,也即有界缓冲区问题。封装的put()和get()函数如下:

1    int buffer[MAX];
2    int fill = 0;
3    int use = 0;
4
5    void put(int value) {
6        buffer[fill] = value;    // line f1
7        fill = (fill + 1) % MAX; // line f2
8    }
9
10   int get() {
11       int tmp = buffer[use];    // line g1
12       use = (use + 1) % MAX;    // line g2
13       return tmp;
14   }
第一次尝试

我们用两个信号量empty和full分别表示缓冲区空或者满,下面是我们尝试解决生产者/消费者问题的代码。

1    sem_t empty;
2    sem_t full;
3
4    void *producer(void *arg) {
5        int i;
6        for (i = 0; i < loops; i++) {
7            sem_wait(&empty);             // line P1
8            put(i);                       // line P2
9            sem_post(&full);              // line P3
10       }
11   }
12
13   void *consumer(void *arg) {
14       int i, tmp = 0;
15       while (tmp != -1) {
16           sem_wait(&full);            // line C1
17           tmp = get();                // line C2
18           sem_post(&empty);            // line C3
19           printf("%d\n", tmp);
20       }
21   }
22
23   int main(int argc, char *argv[]) {
24       // ...
25       sem_init(&empty, 0, MAX); // MAX buffers are empty to begin with...
26       sem_init(&full, 0, 0);    // ... and 0 are full
27       // ...
28   }

我们先假设MAX=1,验证程序是否有效。假设有两个线程,一个生产者和一个消费者。我们来看在一个CPU上的具体场景。消费者先运行,执行到C1行,调用sem_wait(&full)。因为full初始值为0,wait调用会将full减为−1,导致消费者睡眠,等待另一个线程调用sem_post(&full),符合预期。

假设生产者然后运行。执行到P1行,调用sem_wait(&empty)。生产者将继续执行,因为empty被初始化为MAX(在这里是1)。因此,empty被减为0,生产者向缓冲区中加入数据,然后执行P3行,调用sem_post(&full),把full从−1变成0,唤醒消费者。

在这种情况下,可能会有两种情况。如果生产者继续执行,再次循环到P1行,由于empty值为0,它会阻塞。如果生产者被中断,而消费者开始执行,调用sem_wait(&full),发现缓冲区确实满了,消费它。这两种情况都是符合预期的。

可以继续推导,在MAX=1时,即便有多个生产者和消费者的情况下,本示例代码仍然正常运行。

我们现在假设MAX大于1,同时假定有多个生产者,多个消费者。那么就有问题了:竞态条件。假设两个生产者(Pa和Pb)几乎同时调用put()。当Pa先运行,在f1行先加入第一条数据(fill=0),假设Pa在将fill计数器更新为1之前被中断,Pb开始运行,也在f1行给缓冲区的0位置加入一条数据,这意味着那里的数据被覆盖,这也就意味着生产者的数据丢失。

增加互斥

可以看到,向缓冲区加入元素和增加缓冲区的索引是临界区,需要小心保护起来。所以,我们使用二值信号量作为锁来进行互斥。下面是对应的代码。

1    sem_t empty;
2    sem_t full;
3    sem_t mutex;
4
5    void *producer(void *arg) {
6        int i;
7        for (i = 0; i < loops; i++) {
8            sem_wait(&mutex);           // line p0 (NEW LINE)
9            sem_wait(&empty);           // line p1
10           put(i);                     // line p2
11           sem_post(&full);            // line p3
12           sem_post(&mutex);           // line p4 (NEW LINE)
13       }
14   }
15
16   void *consumer(void *arg) {
17       int i;
18       for (i = 0; i < loops; i++) {
19           sem_wait(&mutex);           // line c0 (NEW LINE)
20           sem_wait(&full);            // line c1
21           int tmp = get();            // line c2
22           sem_post(&empty);           // line c3
23           sem_post(&mutex);           // line c4 (NEW LINE)
24           printf("%d\n", tmp);
25       }
26   }
27
28   int main(int argc, char *argv[]) {
29       // ...
30       sem_init(&empty, 0, MAX); // MAX buffers are empty to begin with...
31       sem_init(&full, 0, 0);    // ... and 0 are full
32       sem_init(&mutex, 0, 1);   // mutex=1 because it is a lock (NEW LINE)
33       // ...
34   }

现在我们给整个put()/get()部分都增加了锁,就是注释中有NEW LINE的几行。这似乎是正确的思路,但仍然有问题——死锁。

假设有两个线程,一个生产者和一个消费者。消费者首先运行,获得锁,然后对full信号量执行sem_wait()。因为还没有数据,所以消费者阻塞,让出CPU。但是,问题来了,此时消费者仍然持有锁。然后生产者运行,它首先对二值互斥信号量调用sem_wait()。锁已经被消费者持有,因此生产者也被卡住。

这里出现了一个循环等待。消费者持有互斥量,等待在full信号量上。生产者可以发送full信号,却在等待互斥量。因此,生产者和消费者互相等待对方——典型的死锁。

最终方案

要解决这个问题,只需减少锁的作用域,下面是最终的可行方案。可以看到,我们把获取和释放互斥量的操作调整为紧挨着临界区,把full、empty的唤醒和等待操作调整到锁外面。就得到了简单而有效的有界缓冲区,多线程程序的常用模式。

1    sem_t empty;
2    sem_t full;
3    sem_t mutex;
4
5    void *producer(void *arg) {
6        int i;
7        for (i = 0; i < loops; i++) {
8            sem_wait(&empty);            // line p1
9            sem_wait(&mutex);            // line p1.5 (MOVED MUTEX HERE...)
10           put(i);                      // line p2
11           sem_post(&mutex);            // line p2.5 (... AND HERE)
12           sem_post(&full);             // line p3
13       }
14   }
15
16   void *consumer(void *arg) {
17       int i;
18       for (i = 0; i < loops; i++) {
19           sem_wait(&full);             // line c1
20           sem_wait(&mutex);            // line c1.5 (MOVED MUTEX HERE...)
21           int tmp = get();             // line c2
22           sem_post(&mutex);            // line c2.5 (... AND HERE)
23           sem_post(&empty);            // line c3
24           printf("%d\n", tmp);
25       }
26   }
27
28   int main(int argc, char *argv[]) {
29       // ...
30       sem_init(&empty, 0, MAX);  // MAX buffers are empty to begin with...
31       sem_init(&full, 0, 0);     // ... and 0 are full
32       sem_init(&mutex, 0, 1);    // mutex=1 because it is a lock
33       // ...
34   }

读者—写者锁

另一个经典问题源于对更加灵活的锁定原语的渴望,它承认不同的数据结构访问可能需要不同类型的锁。例如,一个并发链表有很多插入和查找操作。插入操作会修改链表的状态,而查找操作只是读取该结构,只要没有进行插入操作,我们可以并发的执行多个查找操作。读者—写者锁(reader-writer lock)就是用来完成这种操作的。下面是这种锁的代码。

1    typedef struct _rwlock_t {
2      sem_t lock;      // binary semaphore (basic lock)
3      sem_t writelock; // used to allow ONE writer or MANY readers
4      int    readers;  // count of readers reading in critical section
5    } rwlock_t;
6
7    void rwlock_init(rwlock_t *rw) {
8      rw->readers = 0;
9      sem_init(&rw->lock, 0, 1);
10     sem_init(&rw->writelock, 0, 1);
11   }
12
13   void rwlock_acquire_readlock(rwlock_t *rw) {
14     sem_wait(&rw->lock);
15     rw->readers++;
16     if (rw->readers == 1)
17       sem_wait(&rw->writelock); // first reader acquires writelock
18     sem_post(&rw->lock);
19   }
20
21   void rwlock_release_readlock(rwlock_t *rw) {
22     sem_wait(&rw->lock);
23     rw->readers--;
24     if (rw->readers == 0)
25       sem_post(&rw->writelock); // last reader releases writelock
26     sem_post(&rw->lock);
27   }
28
29   void rwlock_acquire_writelock(rwlock_t *rw) {
30     sem_wait(&rw->writelock);
31   }
32
33   void rwlock_release_writelock(rwlock_t *rw) {
34     sem_post(&rw->writelock);
35   }

如果某个线程要更新数据结构,需要调用rwlock_acquire_writelock()获得写锁,调用rwlock_release_writelock()释放写锁。内部通过一个writelock的信号量保证只有一个写者能获得锁进入临界区,从而更新数据结构。

获取读锁时,读者首先要获取lock,然后增加reader变量,追踪目前有多少个读者在访问该数据结构。当第一个读者获取读锁时,同时也会获取写锁,即在writelock信号量上调用sem_wait(),最后调用sem_post()释放lock。

一旦一个读者获得了读锁,其他的读者也可以获取这个读锁。但是,想要获取写锁的线程,就必须等到所有的读者都结束。最后一个退出的读者在writelock信号量上调用sem_post(),从而让等待的写者能够获取该锁。

这一方案可行,但有一些缺陷,尤其是公平性,读者很容易饿死写者。存在复杂一些的解决方案,比如有写者等待时,避免更多的读者进入并持有锁。最后,读者-写者锁通常加入了更多锁操作,因此和其他一些简单快速的锁相比,读者—写者锁在性能方面没有优势。

如何实现信号量

最后,我们用底层的同步原语锁和条件变量,来实现自己的信号量,名字叫作Zemaphore。

1    typedef struct  _Zem_t {
2        int value;
3        pthread_cond_t cond;
4        pthread_mutex_t lock;
5    } Zem_t;
6
7    // only one thread can call this
8    void Zem_init(Zem_t *s, int value) {
9        s->value = value;
10       Cond_init(&s->cond);
11       Mutex_init(&s->lock);
12   }
13
14   void Zem_wait(Zem_t *s) {
15       Mutex_lock(&s->lock);
16       while (s->value <= 0)
17           Cond_wait(&s->cond, &s->lock);
18       s->value--;
19       Mutex_unlock(&s->lock);
20   }
21
22   void Zem_post(Zem_t *s) {
23       Mutex_lock(&s->lock);
24       s->value++;
25       Cond_signal(&s->cond);
26       Mutex_unlock(&s->lock);
27   }

我们实现的信号量和Dijkstra定义的信号量有一点细微区别,就是我们没有保持当信号量的值为负数时,让它反映出等待的线程数。事实上,该值永远不会小于0。这一行为更容易实现,并符合现有的Linux实现。


与昊
225 声望636 粉丝

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