线程同步
线程同步的四项原则
- 最低限度地共享对象,一个对象尽量不暴露给其他线程,如果非要暴露,优先考虑
immutable
对象;实在不行才暴露可修改的对象,并用同步措施充分保护它。 - 使用高级的并发编程构件,如
TaskQueue, Producer-Consumer Queue, CountDownLatch
等 - 不得已使用底层同步原语的时候,只使用非递归的互斥器和条件变量,慎用读写锁,不要用信号量
- 除了使用
automic
变量外,不自己编写lock-free
代码,不要使用“内核级”同步原语
互斥器
使用原则:
- 用RAII手法封装mutex的创建、销毁、加锁、解锁这四个操作
- 只使用非递归的mutex
- 不手工调用
lock(), unlock()
函数,一切交给栈上的Guard对象的构造和析构函数负责 - 每次构造Guard对象需要关注调用栈上已经持有的锁,防止加锁顺序不同导致的死锁出现
只使用非递归的mutex
可重入mutex可以被同一个线程反复加锁,这就可能造成一些问题
MutexLock mutex;
std::vector<Foo> foos;
void post(const Foo& f) {
MutexLockGuard lock(mutex);
foos.push_back(f);
}
void traverse() {
MutexLockGuard lock(mutex);
for (std::vector<Foo>::const_iterator it = foos.begin();
it != foos.end(); ++it) {
it->doit(); // 这里可能有问题
}
}
如果it->doit()
间接的调用了post()
,可能会出现下面两种情况
- mutex是不可重入的,那么就会造成死锁
- mutex是可重入的,由于
push_back()
可能会导致迭代器失效,所以程序可能偶尔会crash
条件变量
若干个线程需要等待某个表达式为真,即等待别的线程“唤醒”它。条件变量的学名叫管程(monitor
)。Java object
内置的wait(), notify(), notifyAll()
是条件变量。
条件变量的正确使用方式,对于wait端:
- 必须与mutex一起使用,布尔表达式的读写需要受mutex的保护
- 在mutex已上锁的时候才能调用
wait()
- 把判断布尔条件和
wait()
放到while
循环中
std::mutex mutex;
std::condition_variable cv;
std::deque<int> queue;
int dequeue()
{
std::unique_lock<std::mutex> lock(mutex); // 这里不能使用lock_guard,因为lock_guard不支持手动解锁
while(queue.empty()) { // 必须用循环;必须在判断之后再wait()
cv.wait(lock); // 释放互斥量并等待条件变量,被唤醒时会重新尝试获取锁
}
assert(!queue.empty());
int top = queue.front();
queue.pop_front();
return top;
}
对上面的代码有这样的思考
- 为什么必须使用循环?
在使用void wait(unique_lock<mutex>& lck);
的时候,获取到信号量就会无条件的执行下面的语句。如果一次notify()
唤醒了多个线程,只有一个线程能正常执行,其他线程都会因为锁的争用阻塞到cv.wait();
语句;当锁被释放时,这些线程会直接执行下面的代码,造成了错误。
使用wait()
具有两个参数的回调可以避免使用循环。 - 为什么要加锁?
首先为了保证对条件变量的修改是串行的,其次为了保证对queue的修改是串行的
对于singal/broadcast
端:
- 不一定要在持有锁的情况下调用
signal
- 在
singal
之前一般要修改布尔表达式 - 修改布尔表达式通常要用mutex保护
- 注意区分
singal
和broadcast
:broadcast
通常表明状态的变化,所以需要唤醒所有等待该状态的线程;singal
通常用于资源可用,所以唤醒一小部分线程来消耗资源。
void enqueue(int x) {
std::lock_guard<std::mutex> lock(mutex);
queue.push_back(x);
cv.notify_one();
}
互斥器和条件变量构成了多线程编程的全部必备同步原语,用它们即可完成任何多线程同步任务,二者不能相互替代。