条件变量与锁

本文以linux c为例子, 介绍锁和共享变量的基本使用方法, 并介绍一个基于条件变量实现生产者消费者模型的例子, 然后在文末给出相关书籍的参考.

多线程情况下, 锁的使用主要涉及以下5个函数, 它们都包含在pthread.h头文件中.

  • pthread_mutex_init(pthread_mutex_t * mutex,const pthread_mutexattr_t *attr)

  • pthread_mutex_lock(pthread_mutex_t *mutex)

  • pthread_mutex_tylock(pthread_mutex_t *mutex)

  • pthread_mutex_unlock(pthread_mutex_t *mutex)

  • pthread_mutex_destroy(pthread_mutex_t *mutex)

其中, 锁变量类型为pthread_mutex_t, 锁的使用包含三个步骤, 分别是锁的初始化, 加锁, 以及释放锁.

下面分别介绍各个函数的用法

锁初始化

pthread_mutex_init该函数用于锁的初始化, 其函数头是

*pthread_mutex_init(pthread_mutex_t * mutex,const pthread_mutexattr_t attr)

要使用锁, 首先需要声明一个pthread_mutex_t变量,然后用该函数进行初始化, 如下

1
2
3
pthread_mutex_t mutex;
pthread_mutex_init(&mutex,NULL);

初始化的时候, 第二个参数可以用于设置锁的性质, 设置方法可以参考文末的参考文献. 经过这一步, 我们完成了锁的初始化. 在第二个参数设置NULL的时候, 一个线程加锁, 另外一个线程再执行加锁操作, 就会阻塞, 直到另外的线程释放锁. 加锁可以用下面的函数来完成.

加锁

pthread_mutex_lock与pthread_mutex_tylock这两个函数可以用于加锁, 其函数头分别是:

*pthread_mutex_lock(pthread_mutex_t mutex)

*pthread_mutex_tylock(pthread_mutex_t mutex)

这两个函数都完成了加锁的功能, 在获得了变量初始化后的mutex以后, 直接调用函数即可完成加锁功能. 其中第一个函数在另外一个线程已经获得锁的情况下, 会一直阻塞, 而第二个函数则会直接返回, 不会阻塞.

释放锁

**pthread_mutex_unlock(pthread_mutex_t *mutex)**函数可以用于释放锁.

回收资源

**pthread_mutex_destroy(pthread_mutex_t *mutex)**该函数用于释放资源, 在使用pthread_mutex_init函数进行锁初始化的情况下, 使用结束以后,需要使用该函数释放资源.

共享变量

共享变量应用于这样一种场景: 一个线程先对某一条件进行判断, 如果条件不满足则进入等待, 条件满足的时候, 该线程被通知条件满足, 继续执行任务. 共享变量涉及的函数有如下6个

  • int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr)
  • int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex)
  • int pthread_cond_signal(pthread_cond_t *cond)
  • int pthread_cond_broadcast(pthread_cond_t *cond)
  • int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime)

初始化

**int pthread_cond_init(pthread_cond_t cond, pthread_condattr_t cond_attr)

要使用条件变量, 首先要声明一个pthread_cond_t变量, 然后用该函数进行初始化. 第二个参数使用NULL, 具体的参数设置, 限于篇幅, 参考文末的参考文献.

等待条件成立

**int pthread_cond_wait(pthread_cond_t cond, pthread_mutex_t mutex)

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

在条件不满足的时候, 调用该函数进入等待. 当条件满足的时候, 该函数会停止等待, 继续执行. 该函数的第二个参数是pthread_mutex_t类型, 这是因为在条件判断的时候, 需要先进行加锁来防止出现错误, 在执行改函数前需要主动对这个变量执行加锁操作, 进入这个函数以后, 其内部会对mutex进行解锁操作, 而函数执行完以后(也就是停止阻塞以后), 又会重新加锁. 具体原因, 在介绍完本组函数以后进行说明. 其中第二个函数可以指定等待的时间, 而不是一直在阻塞.

通知

*int pthread_cond_signal(pthread_cond_t cond)

*int pthread_cond_broadcast(pthread_cond_t cond)

上面说到, 在条件不满足的时候, 一个线程会调用pthread_cond_wait函数, 阻塞等待. 而此时如果其他线程检查到条件满足, 则可以调用这两个函数, 让处于等待状态的线程重新开始执行. 当有多个线程在等待的时候, 第一个函数会唤醒其中一个线程, 而第二个函数会唤醒所有的等待的线程.

共享变量与锁

介绍完了基本的函数功能, 接下来介绍这两套函数配合使用的一个常见场景: 有两个线程, 其中一个线程先对一个条件进行检查, 这个检查动作需要先加锁. 如果条件成立, 则执行操作, 否则阻塞等待, 直到条件成立, 这个线程才会被通知继续执行. 另一个线程先做加锁处理, 然后置条件为真, 并通知其他等待的线程条件已经满足, 可以继续执行.

上面说在检查共享变量的时候, 要加锁, 其原因通过以下伪代码来说明.

第一种情况

1
2
3
4
5
6
7
8
9
10
线程1
pthread_mutex_lock(&mutex);
while (condition == FALSE) {
pthread_cond_wait(&cond, &mutex);
}
pthread_mutex_unlock(&mutex);

线程2
condition = TRUE;
pthread_cond_signal(&cond);

可以看到, 线程1先检查一个条件是否成立, 在不成立的情况下, 就调用wait函数进行等待. 而在这之前, 先对这步过程进行了加锁操作. 线程2则是把条件设置为true(假设其通过某种方式知道了这个时候该条件应当为true), 然后用pthread_cond_signal函数通知线程1停止阻塞继续执行. 上面的程序在多个线程并发执行的时候有如下的问题:
如果线程1先判断, 发现条件不满足, 准备进入等待, 在这个时候, 线程2中条件被置为真, 且发送通知. 然后线程1才阻塞等待, 这样的话, 线程1错过了一次通知, 导致其在条件满足的情况下依然在阻塞等待.

1
2
3
4
5
6
        线程1                               线程2
pthread_mutex_lock(&mutex);
while (condition == FALSE)
condition = TRUE;
pthread_cond_signal(&cond);
pthread_cond_wait(&cond, &mutex);

为了解决上面说的问题, 对程序进行了如下的改进. 通过线程2的加锁操作, 避免了这样的问题. 这也解释了为什么pthread_cond_wait函数在进入以后要进行解锁操作, 如果起不解锁, 那么线程2在进行条件置为true的操作就没有办法执行, 因为线程1在进入等待之前已经对这个变量加锁了. 这样线程1会一直等待, 而线程2也会等待, 导致死锁.

1
2
3
4
5
6
线程1                                       线程2
pthread_mutex_lock(&mutex); pthread_mutex_lock(&mutex);
while (condition == FALSE) { condition = TRUE;
pthread_cond_wait(&cond, &mutex); pthread_cond_signal(&cond);
} pthread_mutex_unlock(&mutex);
pthread_mutex_unlock(&mutex);

补充说明一点,因为wait重新执行的时候需要再次加锁,所以上面的pthread_cond_signal调用以后, 必须释放锁,才能够完成wait. 另外, 也可以先解锁, 然后调用pthread_cond_signal,这两种写法都是正确的. 虽然共享变量的访问一般需要加锁, 但在这个场景下不加锁造成的竞争不会产生错误, 只是会造成线程调度效率上的问题, 所以也可以这么写, 但是一般推荐标准的写法. 具体可以看参考文献.

条件变量的使用例子

下面的链接以redis 3.2.3的代码中的BIO模块为例子, 给出实际系统中的条件变量使用的方法. 可以发现, redis的BIO模块就是用上面介绍的模型实现的.

Redis BIO系统

总结

锁的基本使用包括了锁初始化, 加锁, 解锁三个步骤. 使用默认的锁性质时, 一个锁变量只能由一个线程获得, 在这个线程释放锁之前, 其他线程如果尝试获得锁, 就会进入阻塞的状态. 这样, 加锁和解锁之间的这段代码只有一个线程执行, 从而能够保证并发访问的正确性.

对于条件变量, 其基本的使用场景是, 某些线程对条件进行判断, 如果不满足条件, 就进入等待状态. 在进行条件判断之前, 先进行加锁操作. 另外一些线程则是负责对条件赋值为真, 然后通知等待的线程继续执行, 线程被唤醒后, 继续进入判断的环节以及后续的操作.

以上面例子来看, 也就是可以分为以下两部分:

A类线程:

  • 加锁
  • 检查(条件不成立则等待,知道成立再次进入检查阶段)
  • 执行
  • 解锁

B类线程:

  • 加锁
  • 条件置为真
  • 通知
  • 解锁

参考文献

[1] Unix Networking Programming, Volume 2, W. Richard Stevens, chapter 7, 8
[2] stackoverflow/questions/4544234/
[3] Redis BIO系统
[4] http://stackoverflow.com/questions/6312342/