多线程并发(二)

条件变量

在讲到锁的时候我们已经提到,在一个锁已经被一个线程持有时,我们应该把想要获得这个锁的线程加入等待名单,然后阻塞这些线程,在解锁时从等待名单中选择一个线程解锁,从而提高运行效率。你可能已经注意到,这个过程跟我们之前提到的互斥有所不同——互斥的定义是一段代码不能同时被两个线程运行,而我们现在希望达到的目的是使得两个线程不能同时运行两段需要锁的代码,因此我们需要一个工具通知一个线程另一个线程已经解锁,然后让想要获得锁的线程按一定顺序获得锁、分别运行。这种协调多个线程运行速度的行为被称作 同步(synchronization) 。
与同步关系最为密切的概念就是条件变量,顾名思义, 条件变量(conditional variable) 能够基于某一条件的发生协调相关线程的运行。和锁一样,一个条件变量会被基于一个或一些共享数据。我们前面已经说到,共享数据都需要被锁保护,以求同时只有一个线程可以修改这个数据,因此一个条件变量相关的条件被修改时,修改条件的线程肯定持有保护该条件的锁。

在某个条件不成立时,需要该条件成立的线程可以对与这个条件相对应的条件变量调用wait()函数,wait()函数会将这个线程加入等待名单,并进入阻塞态,并 自动解锁 ;当某个线程修改这个条件涉及到的变量时,线程必须自行调用signal()或broadcast()。signal()和broadcast()的区别是signal()只会将等待名单上一个线程唤醒,由阻塞态变为就绪态,而broadcast()会将等待名单上所有线程都有阻塞态变为就绪态。收到信号的线程回到就绪态的线程此时仍然处于wait()函数中 没有返回 ,当内核选择继续运行该线程时,线程会获得其 原来持有的锁 ,然后再从wait()函数返回。
你可能注意到**wait()signal()这两个函数恰好和 Unix 系统中等待子进程结束的wait()函数和修改进程对信号的处理方式的signal()函数的命名冲突了,因此我们不能直接叫它们wait()和signal()。在下面的实例中,我们会管这两个函数叫void cond_var_wait(struct cond_var *variable, struct spinlock *lock)**和void cond_var_signal(struct cond_var *variable)。假设我们已经实现了这两个函数,以及一个条件变量的结构struct cond_var,一个自旋锁struct spinlock,以及用来获得自旋锁和解锁自旋锁的函数void spinlock_acquire(struct spinlock *lock);和void spinlock_unlock(struct spinlock *lock);。现在我们就来看一看锁结构的实现。

struct my_lock{
    int value;
    struct spinlock *spin;
    struct cond_var *condition;
}

我们先来定义一个数据结构。
struct my_lock是我们新定义的锁,它包含三个成员:value与前两节中我们定义的自旋锁中的value功能相同,其值为 1 时表示锁已被一个进程持有,其值为 0 时表示锁空闲spin是一个自旋锁,用来保护struct my_lock的内部结构;condition是一个指向我们自己定义的条件变量结构的指针,它与这个锁相匹配,用来在锁已被持有时使想要获得锁的线程等待。下面我们就来写获得锁的void acquire_my_lock(struct my_lock *lock)函数与解锁的void unlock_my_lock(struct my_lock *lock)函数。

void acquire_my_lock(struct my_lock *lock){
    spinlock_acquire(lock->spin);
    while (lock->value) {
        cond_var_wait(lock->condition);
    }
    lock->value = 1;
    spinlock_unlock(lock->spin);
}

void unlock_my_lock(struct my_lock *lock){
    spinlock_acquire(lock->spin);
    lock->value = 0;
    cond_var_signal(lock->condition);
    spinlock_unlock(lock->spin);
}

由于我们用自旋锁保护锁的内部状态,在想要获得锁的时候,我们必须先获得锁内部的自旋锁,然后才能获得查看、修改锁的内部状态的权限。你可能会问一个问题:既然我们仍然要用自旋锁来锁住struct my_lock的内部状态,那这种锁的效率为什么会比普通的自旋锁高呢?

这个问题的答案与锁的持有时间有关。

首先,除了自旋锁以外,我们几乎只有一种其它实现锁的办法,那就是禁用中断——在禁用中断的情况下,我们不会收到系统的计时器或任何硬件发来的中断,因此我们的代码在获得锁后一定可以作为一个整体执行完成。然而,禁用中断这种做法比自旋锁更不被提倡,因为在禁用中断的过程中,我们可能失去硬件发来的 I/O 中断等重要的信息。尽管我们在少数情况下必须禁用中断,在持续时间未知的情况下我们还是不希望使用禁用中断的手段达到锁的效果。

因此,我们只有尽量缩短持有自旋锁的时间。我们无法控制一个线程持有一个锁的时间,但我们知道,一个线程花在获得锁的函数里的时间是固定且较短的。因此我们用一个自旋锁来保护锁的内部状态,而不是直接在 while loop 里反复检查锁的状态。

在上面很短的代码中,有一点我们要特别提醒你注意:我们在一个 while loop 中执行cond_var_wait(lock->condition);,而不是在一个 if 语句后执行。我们将在下一页中具体讲解这一做法的原因.

条件变量的wait()函数是包含在 while 还是 if 中其实是区分两种条件变量的实现方法的重要特点。当wait()在一个 while loop 中被调用时,这种条件变量的实现方式就是 Mesa 风格;如果wait()在一个 if 条件后被调用,那么这种条件变量的实现方式就是 Hoare 风格。Mesa 与 Hoare 的区别主要在于对于signal()后线程的状态的假设不同——Mesa 风格假设,signal()被调用后进入就绪态的线程不会马上继续运行,因此还没有获得原来的锁,其它运行的线程可能获得锁,然后使线程等待的条件不再成立,因此从wait()返回后必须再次检查条件是否成立;Hoare 对于signal()的假设则是被唤醒的线程会马上进入 运行态 、获得锁、并一直运行到它释放锁,因此它不需要检查条件是否已不再成立。它释放锁后锁的所有权会归还给调用signal()的线程,使这个线程继续运行。

显然,Hoare 风格中条件变量和锁的实现都比 Mesa 风格中的实现复杂得多,因此现代的操作系统实现的大多是 Mesa 风格的条件变量,我们这里也遵循这种传统。

看了上面的例子,相信你已经明白了条件变量的用法,接下来我们就来实现条件变量。我们首先来实现两个数据结构:

struct list_element{
    TCB *thread;
    struct list_element *next;
}

struct cond_var{
    struct list_elem *waiters;
} 

struct list_element是我们用来构建等待名单的链表中的一个元素。它里面包含了两个成员,其中thread是一个指向线程控制块(TCB)的指针,用来记录被阻塞的线程,我们在将线程唤醒时需要用到它;next是指向等待名单中下一项的指针。在struct cond_var中,我们用一个struct list_element的指针表示等待名单的开头。

在给你看void cond_var_wait(struct cond_var *condition, struct spinlock *lock)和void cond_var_signal(struct cond_var *condition)的实现方式以前,我们先来思考一下这两个函数要做什么cond_var_wait()需要将调用它的线程加入等待名单,并释放它持有的锁,将其设为阻塞态;在线程重新开始运行后,它还需要负责重新获得锁。cond_var_signal()需要从等待名单中选出一个线程,将线程改为就绪态,我们这里使用最简单的**先进先出算法(First In First Out,FIFO)**选择最先进入等待名单的线程唤醒。为了能够阻塞和唤醒线程,我们需要能够获取现在运行的线程的控制块。

在内核中一般有一个就绪队列(实际上它可能包含了多个队列,我们在下一章中会详细地介绍这一部分内容),内含处于就绪态的任务。假设我们可以用TCB* current_thread()获得当前线程的线程控制块,并可以用void thread_block(TCB *thread)和void thread_unblock(TCB *thread)这两个函数来将线程移出就绪队列和加入就绪队列。另外,假设我们可以调用void disable_interrupt()这个函数来禁用中断,并可以用void enable_interrupt()恢复接收中断。下面我们就来调用这些函数,实现我们的条件变量:

void cond_var_wait(struct cond_var *condition, struct spinlock *lock){
    TCB *curr = current_thread();
    struct list_elem *new_waiter = calloc(1, sizeof(struct list_elem));
    new_waiter->thread = curr;
    new_waiter->next = NULL;
    struct list_element *temp = condition->waiters;
    if (!temp) {
        condition->waiters = new_waiter;
    } else {
        while (temp->next) {
            temp = temp->next;
        }
        temp->next = new_waiter;
    }
    disable_interrupt();
    spinlock_unlock(lock);
    thread_block(curr);
    spinlock_acquire(lock);
    enable_interrupt();
}

void cond_var_signal(struct cond_var *condition){
    if(!condition->waiters) return;
    struct list_element *head = condition->waiters;
    condition->waiters = head->next;
    thread_unblock(head->TCB);
    free(head);
}

信号量

在前面的章节中,我们讲解了互斥和同步这两个概念,以及用来实现互斥和同步的锁和条件变量。现在我们来讲一个介于锁和条件变量之间的工具,信号量。信号量(semaphore) 在被初始化时带有一个自定的非负整数值和一个空的等待名单,之后线程可以对这个信号量进行两个操作P()与V()。P()会等待信号量的值变为正数,然后将信号量的值减 1;P()是一个不可分割的操作,因此我们不用担心检查信号量的值变为正数后会有其它线程先把信号量的值降低到 \ 0 0。V()会将信号量的值加 1,如果此时信号量的等待名单上有线程,则唤醒其中一个。

我们可以看到,信号量的两个操作似乎与条件变量的wait()和signal()非常相似,但它其实更适合被用来实现互斥。我们可以在初始化时将它的值设为 1,这时P()就相当于acquire_lock(),V()就相当于unlock_lock()。由于初始值为 1,且P()是一个不可分割的操作,我们知道同时只能有一个线程会成功地从P()返回,进入临界区。这个线程完成临界区代码后可以调用V(),使信号量的值重新变为 1,这时下一个线程又可以进入临界区。

如果我们想要用信号量来模仿条件变量的用法,那就比较困难了。信号量与条件变量的一大区别在于条件变量 没有内部状态 。比如,如果一个线程调用了signal(),此时没有变量在等待这个条件发生变化,那么这个signal()就不会产生任何影响。在条件变为不成立后,下一个来等待这个条件的线程不会因为前面曾经有线程调用过signal()就不等待。

信号量就不同了。如果一个信号量的初始值为 0,一个线程在没有线程在P()中等待时调用了V(),信号量的值就会被增加至 1。这时如果有一个线程调用P(),则它无需经过任何等待,因为前面线程的历史被信号量保留了下来。

信号量与条件变量相比还有一个缺点,那就是条件变量在等待时会将保护共享数据的锁自动解锁,但P()没有这个功能,因此我们一般会在调用P()以前解锁,否则其它线程就无法修改共享数据,造成永远等待的局面。

所幸,还是有一种可以用信号量模仿条件变量的方法的。这种方法由 Andrew Birrell 在微软 Windows 支持条件变量以前实现。下面我们写的代码没有包含有关的数据结构和函数的定义,你可以联系前几节讲的锁和条件变量的定义和这一节讲的信号量的定义、将这段代码当做伪代码来理解:

void cond_var_wait(struct cond_var *condition, struct lock *my_lock){
    struct semaphore *my_sema;
    semaphore_init(my_sema, 0); // 将信号量初始值设为 1
    // 将信号量加入条件变量的等待名单
    append_to_list(condition->waiters, my_sema);
    lock_unlock(my_lock);
    semaphore_P(my_sema);
    lock_acquire(my_lock);
}

void cond_var_signal(struct cond_var *condition){
    if (condition->waiters) {
        semaphore_V(remove_from_list(condition->waiters));
    }
}

从上面的代码中我们可以看出,用信号量可以实现互斥和同步的功能,但这两种用法背后的想法是截然不同的,刚刚接触多线程编程的人很容易混淆这两种用法。如果你觉得自己对于同步和互斥的概念的理解仍然不透彻,那么我们就建议你使用锁和条件变量,以巩固你对于互斥和同步的认识。

Pthread 库中的锁和条件变量

前几节中我们讲了如何实现锁和条件变量;这些知识虽然能帮助你对锁和条件变量有更深的认识,但除非你在设计一个操作系统,否则你是不需要自己实现锁和条件变量的。如果你在写一个用户级别的程序,那么你需要的就只是了解 Linux 中提供了哪些已经实现好的锁和条件变量。这一节中,我们就来讲一讲 Linux Pthread 库中包含的锁和条件变量的相关函数。

我们先来讲一讲 Pthread 库中的锁,pthread_mutex_t。mutex 是 mutual exclusion(互斥)的缩写,你可以用pthread_mutex_init这个函数初始化一个锁:

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);

这个函数的第一个参数是一个指向一个未初始化的pthread_mutex_t,第二个参数是我们希望这个锁应具有的属性;后一个参数可以包含锁是否能够被其它进程使用、锁是否能检测死锁等属性,我们在讲到死锁时再讨论这些属性。现在我们把这个参数设为NULL,这时系统就会用默认的属性建立这个锁,初始化一个处于空闲状态的锁。在后一个参数为NULL时,下面两种调用等价:

pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
mutex = PTHREAD_MUTEX_INITIALIZER;

初始化pthread_mutex_t后,我们就可以把它当做一个普通的锁来用了。下面两个函数分别用来获得锁和解锁:

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

这两个函数都以指向锁的指针为参数,成功时返回 0,否则返回错误码。注意,如果一个线程企图获得锁时另一个线程已经持有了这个锁,那么调用pthread_mutex_lock的线程就会被阻塞,直至解锁后这个线程被内核选择作为下一个锁的持有者时才能继续运行。为了避免在这个等待过程中浪费太多时间,我们可以调用下面这个函数

int pthread_mutex_trylock(pthread_mutex_t *mutex);

它的行为与pthread_mutex_lock一致,但是在无法获得锁时它会立即返回,返回值等于EBUSY,成功获得锁时它则会返回 0.

注意,在用完一个锁后我们最好使用下面这个函数来摧毁这个锁:

int pthread_mutex_destroy(pthread_mutex_t *mutex);

虽然现在的 API 中,这个函数可能只会把指针所指的锁设为一个无效的值,但我们不能保证以后的 API 中不会对此做出改变,因此遵循着 API 的指导我们还是应该调用这个函数。注意,我们不能在锁仍然被一个线程持有时就试图摧毁这个锁,这种行为是未被定义的。在摧毁一个锁以后我们还可以对同一个锁调用pthread_mutex_init,初始化一个新的锁。
pthread_cond_t与pthread_mutex_t非常类似,也需要初始化和摧毁,并且在没有属性参数的情况下也可以用一个宏初始化:

int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

pthread_condattr_t可以被用来设置一个条件变量的处理器时钟和条件变量是否可以被其他进程使用,我们这里不做讲解,有兴趣的同学可以自己进行查阅。
建立了条件变量以后,我们就可以对这个条件变量进行wait(),signal()和broadcast()。signal()和broadcast()的函数与我们之前讲过的用法相同:

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

它们都以调用的条件变量的指针为参数,成功时返回 0,否则返回EINVAL,代表条件变量没有被初始化。
pthread_cond_t在被等待时与我们之前讲的稍有不同,它有两个函数:

int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);

其中后一个函数和我们之前学过的函数就是一样的,前一个函数的行为与一般的wait()只有一点不同,那就是它会代入一个指向struct timespec的指针。这种结构代表了一个系统时间点,这个时间点是我们可以接受的信号出现的最晚时间。如果在调用pthread_cond_timedwait这个函数时这个时间点已经过去,或调用后这个时间点过去仍然没有被signal()或broadcast()唤醒,那么函数就会返回一个错误值ETIMEDOUT。

这两个等待函数在成功运行时都会返回 \ 0 0。除了ETIMEDOUT以外,这两个函数还有两种错误码:EINVAL:参数中有至少一个无效或同一个条件变量已经被同一个线程利用另一个锁等待;EPERM:调用函数的线程未持有mutex所指的锁。

生产者消费者

读者与写者

上一节中我们运用管程的思想,实现了一个在多线程环境下能够安全运行的栈,它体现了线程之间生产者与消费者的关系。除了这种关系外,多个线程之间的关系还可能是读者与写者

如果你建立了一个数据库,同时可以通过多个线程被多个远程用户使用,其中一些用户只需要查看数据,而另一些用户需要修改数据。显然,你不能允许两个用户同时修改数据,但你也不能允许一个用户在修改数据时、另一个用户来读取数据,因为这时后一个用户读取到的数据可能是没有被完全更新的数据。因此我们需要一个特殊的管程,在没有写入数据的线程时,它允许多个线程同时读取数据;在有线程读取数据时,想要写入数据的线程就需要等待;在有线程写入数据时,所有其它线程均需要等待;在同时有读者和写者等待时,优先运行写者线程,使得读者获取最新数据。

我们现在就来实现一个这样的管程。我们可以定义一个新的锁,叫做rdwr_lock,每个用户进入这个系统时都需要先获得这个锁,然后才能执行读写操作。虽然我们叫它“锁”,但严格意义上来讲,它并不是完全互斥的,多个读者可以同时获得锁,我们需要实现的是写者之间的互斥和写者与读者之间的互斥,因此我们需要两个变量:读者数量与写者数量,以支持两种不同的获得锁的模式。与前几节我们实现的非自旋锁类似,我们需要一个锁来保护rdwr_lock的内部数据。那么我们可以初步定义struct rdwr_lock如下:

struct rdwr_lock{
    pthread_mutex_t mutex;
    int reader_number;
    int writer_number;
};

按照我们之前提到的条件, 当写者数量大于0 时, 读者和写者都需要等待,而当读者数量大于0 时,写者也需要等待,因此我们需要两个条件变量来实现针对这两个条件的等待。另外,为了能够在同时有读者和写者等待时优先运行写者,我们需要记录系统内等待的读者和写者的数量。因此我们需要更新struct rdwr_lock如下

struct rdwr_lock{
	//  记录系统内读者和写者等待时优先 
    pthread_mutex_t mutex;
    pthread_cond_t can_read;
    pthread_cond_t can_write;
    int reader_number;
    int writer_number;
    int waiting_readers;
    int waiting_writers;
};

由于读者与写者获取锁的条件不同, 我们现在定义如下的四个函数,分别给读者、写者用来获取、释放锁:

void acquire_read_lock(struct rdwr_lock *lock);
void unlock_read_lock(struct rdwr_lock *lock);
void acquire_write_lock(struct rdwr_lock *lock);
void unlock_write_lock(struct rdwr_lock *lock);

现在我们先来实现第一个函数void acquire_read_lock(struct rdwr_lock *lock);。像我们前面提到的那样,在有线程正在写入数据或有写者线程正在等待时,我们就需要等待,因此函数实现如下:

void acquire_read_lock(struct rdwr_lock *lock){
    pthread_mutex_lock(&lock->mutex);
    while (lock->writer_number + lock->waiting_writers > 0) {
        lock->waiting_readers += 1;
        pthread_cond_wait(&lock->can_read, lock);
        lock->waiting_readers -= 1;
    }
    lock->reader_number += 1;
    pthread_mutex_unlock(&lock->mutex);
}

鉴于读者的存在不会导致其它读者被阻塞,在一个读者离开系统时,它只需要查看系统内是否有等待的写者。因此我们可以实现void unlock_read_lock(struct rdwr_lock *lock);如下:

void unlock_read_lock(struct rdwr_lock *lock){
    pthread_mutex_lock(&lock->mutex);
    lock->reader_number -= 1;
    if (lock->waiting_writers>0) {
        pthread_cond_signal(&lock->can_write);
    }
    pthread_mutex_unlock(&lock->mutex);
}

类似的,我们可以实现写者进入和离开系统的函数:

void acquire_write_lock(struct rdwr_lock *lock){
    pthread_mutex_lock(&lock->mutex);
    while (lock->writer_number + lock->reader_number > 0) {
        lock->waiting_writers += 1;
        pthread_cond_wait(&lock->can_write, lock);
        lock->waiting_writers -= 1;
    }
    lock->writer_number += 1;
    pthread_mutex_unlock(&lock->mutex);
}

void unlock_write_lock(struct rdwr_lock *lock){
    pthread_mutex_lock(&lock->mutex);
    lock->write_number -= 1;
    if (lock->waiting_writers>0) {
        pthread_cond_signal(&lock->can_write);
    } else if (lock->waiting_readers>0) {
        pthread_cond_broadcast(&lock->can_read);
    }
    pthread_mutex_unlock(&lock->mutex);
}

需要注意的是,一个写者既能阻塞写者,也能阻塞读者,所以它在离开系统时必须检查系统中是否存在等待的写者和读者。如果有等待的写者,则它优先唤醒写者,否则它唤醒 所有等待的读者。

有了上面的函数,我们就可以写两个函数,分别用于读和写,在函数的开头和结尾调用刚才我们实现的四个函数,实现封装。用户在读写文件时,只能调用我们写好的函数,这一抽象层允许我们隐藏多线程的复杂性,给用户提供一个干净好用的界面

锁与效率

我们前面已经看到,管程设计的核心就是利用锁和条件变量保护共享数据;它使得一个管程所管理的数据同时只能被一个线程使用。这样做虽然保持了数据的安全性,但一旦共享数据的数量变多,锁的使用就会降低系统的速度。

上一节中,我们设计了一个数据库,中间可能有几百个文件,但由于我们只有一个锁,即使两个线程修改的完全是不同的文件也必须为了拿到锁而等待,这个锁就会成为系统速度的瓶颈。我们可以这样想,假如系统平均需要花费 5% 的时间在等待锁的过程中,那么就算你有无限多个处理器,你最多也只能加速20 倍。因此我们希望减少这种获得锁带来的等待时间。

这种减速在多核计算机中尤其明显,因为每个处理器有独立的 高速缓存(cache) ,如果一个核用了一个锁后,然后另一个核需要这个锁,那么他们就需要将数据从一个高速缓存向另一个高速缓存迁移,导致速度变慢。

减少等待时间的方法主要有四种,其中第一种就是使用 细粒度锁(fine-grained locking) 。细粒度锁的核心思想就是减小一个锁所控制的共享数据量。比如,如果你在设计多线程内核中分配堆内存的函数,那么你就需要给堆内存上锁。如果整个堆内存由一个锁保护,那么所有内存分配都要通过这一个锁,它就会成为我们的瓶颈;如果我们取而代之使用细粒度锁,那么我们可能给每个分区上一个锁,这样不在同一个页面的内存分配就不会使用同一个锁。

这种方法的好处是单个锁管理的共享数据量越小、对单个数据的读写操作就越快。它的缺点与优点对应,也来自于单个锁管理的共享数据量小——如果我们需要进行全局操作,那么单个锁管理的数据量越小我们需要获得的锁就越多,这就会导致系统速度变慢。

第二种减少等待时间的方法主要针对多核计算机带来的速度瓶颈。为了避免多个处理器争抢同一个锁,我们可以将共享数据分成数量等于处理器数量的几份,我们给每一份数据设立一个锁,然后将每份数据和它的锁分配给一个单独的处理器。这样,虽然在同一个处理器上运行的线程会争抢一个锁,但不会再出现多核之间数据迁移的情况。(前提是我们需要保证一个处理器上运行的线程是固定的)

这种方法的优势是提升了高速缓存的效率,但它的缺点是在不同的核上运行的线程无法沟通它们的数据。如果一些数据相互的依存关系并不明显、可以分别被读取和使用,那么这种解决方法就是一个很好的解决方法。

**第三种方法是划分阶段并在每个阶段进行数据“私有化”——我们先将一个并发程序分为几个有先后顺序的阶段,然后在两个阶段之间加入队列,前一阶段的线程将消息放到队列中,由下一阶段的某一个线程拿走后运行。**这样的实现方式使数据只能被将这个数据移出队列的线程使用,而我们只会在对队列进行操作时使用锁,而实际处理数据时我们则不需要使用锁。

从逻辑上来讲,这种方法的优点是它使程序在一个阶段内的运行变得模块化,使得不同阶段之间的联系脉络更加清晰。从效率上来讲,这种方法提高了高速缓存的命中率,因为一个线程使用的数据都集中在一段区间里。它的缺点自然是所有阶段之间的队列都需要锁和条件变量来保护,为了减少这一部分对于整体效率的影响,我们一般会在中间阶段运行计算量大、时间长的内容。

死锁

在一个复杂的多线程程序中,为了提高效率我们需要使用多个锁。如果两个线程需要获得的锁不重合,那么这两个线程就不会相互干涉;然而,一旦两个线程的锁有所重合,我们就可能面临 死锁(deadlock) ,也就是几个线程循环等待其它线程持有的锁、导致所有线程都不能继续运行的情况。
下面的代码在一些情况下就可能产生死锁:

#include <stdlib.h>
#include <pthread.h>

pthread_mutex_t lock_A;
pthread_mutex_t lock_B;

void *thread_one(void *arg){
    pthread_mutex_lock(lock_A);
    pthread_mutex_lock(lock_B);
    /*Do something*/
    pthread_mutex_unlock(lock_A);
    pthread_mutex_unlock(lock_B);
}

void *thread_two(void *arg){
    pthread_mutex_lock(lock_B);
    pthread_mutex_lock(lock_A);
    /*Do something*/
    pthread_mutex_unlock(lock_B);
    pthread_mutex_unlock(lock_A);
}

int main(){
    pthread_t thread1;
    pthread_t thread2;

    pthread_mutex_init(&lock_A, NULL);
    pthread_mutex_init(&lock_B, NULL);
    pthread_create(&thread1, NULL, thread_one, NULL);
    pthread_create(&thread2, NULL, thread_two, NULL);
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    pthread_mutex_destroy(&lock_A);
    pthread_mutex_destroy(&lock_B);
    return 0;
}

一般情况下,这段代码大概都可以正常运行,但是在个别情况下,它能够产生死锁。如果thread1先运行,获得lock_A后就被系统调度程序终止,然后thread2开始运行、获得了lock_B,那么死锁就产生了。接下来thread2会试图获得lock_A、被阻塞,这时thread1继续运行、试图获得lock_B、也被阻塞,主线程调用pthread_join等待这两个线程运行完毕、也会被阻塞。两个线程之间的循环等待导致任何线程都不能继续运行,死锁就诞生了

这种死锁的情况不仅会出现在程序中,也会出现在现实生活中——比如你开车到了一个单车道的小路上,迎面开来另一辆车,你们后面都有别的车在等着,这时你们就无法移动了。除非某一个方向的所有车都主动倒出,否则所有车就都会卡在这里。你可以把这条小路两个方向的入口看成是两个锁,你和对面的车分别获得了一个锁后想要获得对方的锁,于是就产生了循环等待。

另一个著名的例子是哲学家就餐问题——如果有五个哲学家要去吃桌上的一盘菜,桌上放有五只筷子,五个哲学家各拿起一只筷子,然后所有人就陷入了僵局——每个人都需要一双筷子才能吃菜,但没有人愿意放下一只筷子来给别人。

从上面这三个例子中,我们可以总结出死锁出现的四个必要条件(注意,这四个条件 不是死锁产生的充分条件 )。首先,为了产生死锁,几个线程必须争抢资源,这就说明资源必须是有限的。在资源无限的情况下,我们永远不可能产生死锁,因为等待的线程可以获得另一个空闲的等价资源。其次,锁一旦被一个线程获得,就不能再被召回,除非这个线程主动放弃这个锁。如果系统可以强制剥夺一个线程对于锁的所有权,那么它就可以在线程循环等待时从一个线程处取回锁分配给另一个线程,打破循环等待。另外,线程在等待时必须保留它之前获得的锁,否则循环等待永远无法产生。最后,几个线程间必须有循环等待(比如,A 等 B,B 等 C,C 等 A)。由于这四个条件是死锁产生的必要条件,只要我们保证这四个条件中有一个不成立,我们就可以防止死锁产生。

为了使第一个条件不成立,我们可以保证有足够多的资源——在哲学家就餐问题中,我们只要再增加一只筷子,根据抽屉原理,我们就可以确定至少一个人可以拿到一双筷子,因此不会产生死锁。

为了使第二个条件不成立,我们可以给予系统在必要的情况下回收资源的能力。

为了使第三个条件不成立,我们可以将获得多个锁的过程拆分成多个只获得一个锁的过程,也就是说如果我需要修改被锁 A,B,C 分别保护的三组数据,那么我先获得锁 A、修改它对应的数据,然后释放 A、再获得 B。这样一个线程同时只持有一个锁,循环等待就不可能出现了。

为了使第四个条件不成立,我们可以规定获取锁的顺序。前几页中我们展示的代码之所以会出现死锁,是因为两个线程获取锁的顺序不同。如果两个线程都先获取 A、再获取 B,那么一个线程要么获得两个锁、要么一个锁都获得不了,循环等待也就不会出现了。

除了上面这几种预防方法以外,还有一种预防死锁的方法,由 Dijkstra 提出,叫做银行家算法。下一节中我们就会探讨银行家算法。

这一节的最后我们来看 Linux 系统中一种特殊的死锁,它由试图重复获取一个已经获取的锁造成。在你初始化一个pthread_mutex_t时,如果你不使用自定义的属性,那么你建立的这个锁就不能够多次被同一个线程上锁。相反,如果你使用pthread_mutexattr_init初始化属性值后调用pthread_mutexattr_settype将锁的类型设为PTHREAD_MUTEX_RECURSIVE,那么你就可以反复锁一个锁。**这种类型的锁内部有一个计数器,可以记住你锁了这个锁多少次,解锁时必须解锁相应的次数才能使这个锁重新变为空闲。我们将这种锁称为递归锁。**你可能会问,我们为什么会需要递归锁这种锁。这个问题的答案就在递归锁的名字里——如果你有一个递归的函数,需要修改一段共享的数据,那么如果没有递归锁,你就需要在另一个函数中先获得锁,然后调用这个递归函数。递归锁的存在省去了写这个“包装”函数的必要。

银行家算法

前一节中我们讲了如何通过打破死锁的条件防止死锁的出现,它们从根本上杜绝了死锁在一个系统中出现的可能性。银行家算法与它们的不同点在于,它并不会从根本上消除系统中出现死锁的可能性,但是在可能出现死锁的系统中,这个算法能帮助我们选择不会产生死锁的资源分配顺序

一个可能出现死锁的系统可能处于三种状态——安全状态,不安全状态和死锁状态。在安全状态下的系统可以保证,对于任何一系列资源请求,至少有一种请求处理顺序能够使得所有请求得到满足而不产生死锁。在不安全状态下的系统意味着,在所有可能的资源请求序列中,至少有一个请求序列无论以何种顺序处理、都会导致死锁。银行家算法的目标就是使得我们的程序一直处于安全状态。如果处理某一请求会导致我们的系统从安全状态变为不安全状态,那么银行家算法就会延迟这个请求的处理。
具体地来说,银行家算法需要记录系统当前空闲的各类资源的总量、已分配给各个线程的各类资源的量和每个线程需要的各类资源的最大量。每次一个线程开始运行前,需要先报告它需要的资源的总量,然后在运行过程中不断请求资源。当系统收到一个资源请求时,它可以假设已经受理该请求,然后查看在这个情况下,尚未分配的资源能否满足任何线程运行完毕,而该线程释放所有资源后,是否能够满足下一个线程运行完毕,直至所有线程都能运行完毕。如果这一条件成立,则请求安全;否则请求不安全。
设 n 是线程总数, m 是资源类型的总数,那么下面就是这个算法用来计算系统状态是否安全的的伪代码:

available_resources_now[m]
max_request[n][m]
allocated_resources[n][m]

is_safe():
    available_resources = [available_resources_now[i] for i in range(m)]
    /*上一步中我们复制了一份现在可用资源的记录,以免修改原数组*/
    finished = [false for all i in range(n)]
    while true:
        let j be a thread such that \
            for all i in range(m) \
            available_resources[i] >= \
            max_request[j][i]-allocated_resources[j][i] \
            && finished[j] == false
        if not j:
            for i in range(n):
                if not finished[i]: return false
            return true
        for i in range(m):
            available_resources[i] += allocated_resources[j][i]
        finished[j] = true

为什么满足上面的条件系统就是安全的呢?在伪代码中,我们可以方便的看出,如果系统调用is_safe()时返回了true,那么目前的状态肯定有一种分配资源的顺序可以使得所有线程都运行完毕。我们需要考虑的是,会不会有一种情况下,系统实际上处于安全状态,但is_safe()返回了false呢?

假设有这么一种情况,使得is_safe()错误地返回了false,那么根据定义我们可以知道,目前的系统一定会有一种顺序能够满足所有线程运行完毕,而is_safe()没有选择这种顺序。假设安全顺序与is_safe()选择的顺序第一次产生分歧时前者选择了线程j、后者选择了线程k。由于j存在,我们知道k一定存在,因为这一步系统拥有的资源至少可以保证j运行完毕,即使没有任何一个其它线程能运行完毕is_safe()仍然可以选择j。而即使k与j不同,我们也知道,这一步后k释放所有它占有的资源后,系统总资源量只会增加而不会减少,因此我们可以知道这一步之后系统仍然可以满足j的需要。接下来的步骤中,每一步的运行都与这一步相似,会使总资源量有增无减,因此对于is_safe()与安全顺序的任何分歧,我们都可以保证后面任何一步中我们都有能力重新满足这些前面没有满足的请求,所以如果安全顺序可以使得所有线程运行完毕,那么is_safe()一定也可以,矛盾。由此我们就证明,is_safe()返回true确实是系统出于安全状态的充分必要条件。
有了is_safe()函数以后,我们就可以在一个请求进入系统时假设我们受理该请求,然后查看系统是否安全。如果不安全,我们就拖延对于这个请求的满足,阻塞该线程,直至我们的资源可以使该线程安全运行为止.

死锁的检测与补救

死锁出现我们该如何补救。首先,我们需要一个检测死锁的机制。如果我们不能够检测死锁的话,那么我们就不知道何时应该触发补救机制。死锁产生时,我们知道资源中一定会出现循环等待,因此我们可以通过资源和线程之间的关系图来判断死锁是否产生。

我们可以根据系统内资源的使用情况画一个有向图,每个线程和每个资源各为一个顶点(如果我们有 n 个资源 A 的实例,那么这个资源顶点就最多有 n 条向外的边)当一个线程拥有一个资源实例时,我们就加入一条由这个资源实例指向这个线程的边;当一个线程在等待一个资源实例时我们就加入一条由这个线程指向这个资源的边。在这个有向图中存在环是存在死锁的必要不充分条件。(为什么不充分?下一节中我们会考一考你对于这一点的理解)在一个有向图中,我们可以用深度优先搜索寻找环的存在,然后通过分析环中的线程和资源来判断系统是否处于死锁。
下面的图就是一个表示资源和线程之间关系的有向图,中间存在一个死锁 .
在这里插入图片描述
除了上面这种方法,我们还可以用银行家算法的改版来检测死锁**。银行家算法能够在已知未来的最大请求量的基础上计算是否有一种处理请求的顺序使得未来的所有需求都能够被满足;如果我们不知道未来的最大请求量,那么我们就可以用类似的算法来计算已经收到的所有请求能否按某种顺序被满足**。如果现在已经收到的所有请求无法被全部满足,那么我们就已经进入了死锁的状态。

发现了死锁之后有什么办法呢?第一种最为直接的办法就是重启计算机。这种办法直接结束所有进程(包括实际没有参与到死锁里的进程),代价很大,但它实际是很多操作系统对于死锁的解决方法。第二种方法是结束与死锁有关的进程,但这几个进程的进度也会全部消失,因此这也不是一个好的解决方案。第三种方法是剥夺产生死锁的线程的资源而不结束这个线程,直至死锁已经解除。如果系统有对于进程进度的记录,那么系统就可以把进程的进度倒退,直至回到死锁可以被解除的状态,再重新开始运行。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值