30 线程同步
互斥量
互斥量 API
为了在 man 手册中看到这些内容,Debian 系统应该安装 glibc-doc 包。
#include <pthread.h>
pthread_mutex_t fastmutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t recmutex = PTHREAD_RECURSIVE_MUTEX_INITIALIZER_NP;
pthread_mutex_t errchkmutex = PTHREAD_ERRORCHECK_MUTEX_INITIALIZER_NP;
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
应该使用 PTHREAD_MUTEX_INITIALIZER
、PTHREAD_RECURSIVE_MUTEX_INITIALIZER_NP
、PTHREAD_ERRORCHECK_MUTEX_INITIALIZER_NP
之一,或者 pthread_mutex_init()
来初始化一个互斥量。只有 PTHREAD_MUTEX_INITIALIZER
是标准的,另外两个都是 Linux 提供的非标准的宏。
虽然书上说前者是用来初始化静态分配的互斥量的,但是没有实现禁止用其初始化在堆上分配的互斥量,也无法确定“静态分配”指的是有稳定地址还是一定要分配在静态数据存储区。另外,前面几个宏只能使用默认的属性,想要对属性进行更加精细的控制,则需要使用
pthread_mutex_init()
。
初始化一个已经初始化过的互斥量是 UB。
当互斥量不再被需要时,应该使用 pthread_mutex_destroy
将其销毁。书上说只有互斥量属于未锁定状态时销毁它才是安全的。
互斥量的性能
Linux 上互斥量是使用 futex 实现的,对锁争用的处理使用了 futex()
系统调用。而文件锁和 System V 的锁定和解锁总是需要用到系统调用,因而开销比互斥量(仅在特定情况下使用系统调用,其他情况都是在用户空间处理)大。
互斥量的类型
PTHREAD_MUTEX_NORMAL
:不带死锁检测功能,a) 一个线程持有锁并再次锁定会造成死锁。对一个 b) 不处于锁定状态、或者 c) 由其他线程锁定的互斥量解锁是未定义行为。PTHREAD_MUTEX_ERRORCHECK
:比前面的互斥量要慢,但是在以上三种情况时会给出错误,不会有未定义行为。通常作为一种调试手段。PTHREAD_MUTEX_RECURSIVE
:递归锁,额外维持一个计数。持有锁的进程可以再次上锁。
SUSv3 还定义了 PTHREAD_MUTEX_DEFAULT
类型,使用 PTHREAD_MUTEX_INITIALIZER
初始化、或者使用 pthread_mutex_init()
初始化时传入的属性为 NULL
,则创建的互斥量就是这种类型。标准没有规定默认的互斥量是什么类型。在 Linux 上,默认的互斥量类型为 PTHREAD_MUTEX_NORMAL
。
条件变量
条件变量 API
总体上来讲 API 和互斥量非常类似。
#include <pthread.h>
// 和互斥量类似,可以用宏来初始化,也可以用 pthread_cond_init
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr);
// 唤醒任何一个在条件变量上等待的线程,效率相对下面的 API 更高一点
int pthread_cond_signal(pthread_cond_t *cond);
// 唤醒所有在条件变量上等待的线程
int pthread_cond_broadcast(pthread_cond_t *cond);
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);
int pthread_cond_destroy(pthread_cond_t *cond);
如果在条件变量通知的时候没有线程在等待,那么这个通知就会丢失。
从条件变量在 Linux 上的实现来看,其中有个
unsigned int __g_signals[2]
,是用到了两个实时信号吗?
条件变量的工作方式
条件变量总是结合互斥量使用。等待条件变量的方式为:
- 先锁定互斥量
mutex
。 - 然后用
pthread_cond_wait()
在mutex
和条件变量cond
上做等待。如果条件不满足,条件等待会进入睡眠状态,但在这之前会先将锁释放,避免死锁的情况。等到条件变量被通知时,再重新获取锁。 - 执行某些操作。
- 释放
mutex
的锁。
而通过条件变量发送唤醒信号的方式为:
- 锁定互斥量。
- 修改一些共享数据。
- 通过条件变量发送唤醒信号,通知其他线程数据可用。
- 释放互斥量。
其中 3、4 步可以交换位置。
20 世纪末有人提议想要唤醒其他线程的线程先释放互斥量,然后再 signal 其他线程。这样是使得其他线程不会在被唤醒后又因为重新锁定互斥量而再次陷入短暂的睡眠状态。现在有的实现通过 wait morphing(等待变形)技术把等待接受信号的线程从条件变量的等待队列转移到互斥量的等待队列中,解决了这一问题。根据帖子 which-os-platforms-implement-wait-morphing-optimization,Linux 不支持这种操作,解释是后释放锁一定是安全的,所以好的实现也要考虑到这种场景(coding pattern)的性能。
为了保证条件变量的正常工作,应该使用同样的互斥量和同样的条件变量,不能把同一个条件变量用在不同的互斥量上。
一般应该在循环中使用 pthread_cond_wait()
,然后在循环的条件里写上 predicate,比如:
for (;;) {
s = pthread_mutex_lock(&mtx);
if (s != 0)
errExitEN(s, "pthread_mutex_lock");
/* 这里就是 predicate!!! */
while (avail == 0) { /* Wait for something to consume */
s = pthread_cond_wait(&cond, &mtx); if (s != 0)
errExitEN(s, "pthread_cond_wait");
}
while (avail > 0) { /* Consume all available units */
/* Do something with produced unit */ avail--;
}
s = pthread_mutex_unlock(&mtx); if (s != 0)
errExitEN(s, "pthread_mutex_unlock");
/* Perhaps do other work here that doesn't require mutex lock */
}
上面代码中的 while (avail == 0)
就是 predicate。要 predicate 是因为我们不能假设线程从 pthread_cond_wait()
中返回时的状态:
- 如果有多个线程在这个条件变量上等待,那么首先醒来的线程可能会修改数据,使得当前线程醒来时数据的状态已经发生了改变,某个条件不再成立。有时候这种情况也叫做虚假唤醒。
- 在一些多处理器系统上,为了保持实现高效,可能会出现虚假唤醒(spurious wake-ups)的情况,即没有任何线程明确对条件变量发起唤醒操作,但是仍有线程从条件等待中醒来。
提前醒来不是因为信号吗?
通过 SA_RESTART
标志重启系统调用 里面有个相同的疑问。看了网上的不少讨论,稍微总结一下。原书作者认为既然本来就要求在条件变量返回时要手动去检查条件,为了实现的高效,就应该允许出现虚假唤醒的情况(这给了更多实现上的自由)。同时,通过系统调用睡眠也是可能因为信号而唤醒的,这说明虚假唤醒在某些实现下是存在的。不过更多的原因还是前者,前者使得“系统调用中断后不对其进行重启”的行为合理化,如果规定不允许出现第 2 条的情况,那么让 pthread_cond_wait()
函数在系统调用中断后重启它用便是。
因此,条件变量的唤醒只是表示“某些事情可能需要做”或者“某个条件可能满足”,而不是“条件一定满足”。
Tip
C++ 中的条件变量等待(std::condition_variable::wait
)有两个版本,一个带有 predicate 参数,一个不带。而 pthreads API 的条件变量等待始终不带 predicate 参数,因此我们一般应该在循环中等待 pthreads API 的条件变量。
地址稳定性
注意:条件变量和互斥量都必须有稳定的地址,将他们拷贝之后再操作,结果未定义。