第2章 线程同步精要
第2章 线程同步精要
线程同步的四项原则,按重要性排列:
- 1.首要原则是尽量最低限度地共享对象,减少需要同步的场合。一个对象能不暴露给别的线程就不要暴露;如果要暴露,优先设置对象不可更改;实在不行才暴露可修改的对象,并用同步措施来充分保护它。
- 2.其次是使用高级的并发编程构件,如TaskQueue、Producer- Consumer Queue、CountDownLatch等等。
- 3.最后不得已必须使用底层同步原语(primitives)时,只用非递归的互斥器和条件变量,慎用读写锁,不要用信号量。
- 4.除了使用atomic整数之外,不自己编写lock-free代码3,也不要用“内核级”同步原语4 5。不凭空猜测“哪种做法性能会更好”,比如spin lock vs. mutex。
2.1 互斥器(mutex)
单独使用mutex时,我们主要为了保护共享数据。我个人的原则是:
- 使用unique_lock/lock_guard等管理mutex,使用构造和析构函数来进行加锁和解锁,而不是自己收到加锁和解锁。C++11 std::unique_lock与std::lock_guard:std::unique_lock要比std::lock_guard更灵活但是占用空间更大且更慢、condition_variable中的wait只接收unique_lock,因为unique_lock可以在wait期间解锁mutex,并在之后重新将其锁定
- 只用非递归的mutex(即不可重入的mutex)。
- unique_lock对象一般设置为栈上对象,然后看函数调用栈就能分析用锁的情况,非常便利。
次要原则有:
- 不使用跨进程的mutex,进程间通信只用TCP sockets。
- 加锁、解锁在同一个线程,线程a不能去unlock线程b已经锁住的mutex(unique_lock自动保证)。
- 必要的时候可以考虑用PTHREAD_MUTEX_ERRORCHECK来排错。【不懂】
2.1.1 只使用非递归的mutex
在同一个线程里多次对non-recursive mutex加锁会立刻导致死锁,我认为这是它的优点,能帮助我们思考代码对锁的期求,并且及早(在编码阶段)发现问题。
recursive mutex可能会隐藏代码里的一些问题。典型情况是你以为拿到一个锁就能修改对象了,没想到外层代码已经拿 到了锁,正在修改(或读取)同一个对象呢。如下:
#include<mutex>
#include<thread>
#include <vector>
#include <stdio.h>
class Foo
{
public:
void doit() const;
};
std::mutex mutex;
std::vector<Foo> foos;
void post(const Foo& f)
{
std::lock_guard lock(mutex);
foos.push_back(f);
}
void traverse()
{
std::lock_guard lock(mutex);
for (std::vector<Foo>::const_iterator it = foos.begin();
it != foos.end(); ++it)
{
it->doit();
}
}
void Foo::doit() const
{
Foo f;
post(f);
}
int main()
{
Foo f;
post(f);
traverse();
}
说明:
- 1.mutex是非递归的,于是死锁了。原因:traverse()获取到mutex,但是
it->doit()
中的post也需要获取到mutex,但是mutex是非递归的,所以同一线程不能多次获取此mutex。这就导致post()一直在等待traverse()释放锁,而traverse()一直在等待post()执行完成。 - 2.mutex是递归的,由于push_back()可能(但不总是)导致vector迭代器失效,程序偶尔会crash。程序crash的愿意:当vector需要扩容时,它会创建一个新的存储空间,并将元素从旧的存储空间复制到新的存储空间。如果在复制期间进行迭代器操作,例如解引用、递增等,就会导致迭代器失效,因为指向旧存储空间的迭代器现在指向无效的内存位置。
这时候就能体现non-recursive的优越性:把程序的逻辑错误暴露出来。死锁比较容易debug,把各个线程的调用栈打出来8,只要每个函数不是特别长,很容易看出来是怎么死的。程序反正要死,不如死得有意义一点,留个“全尸”,让验尸(post-mortem)更容易些。如果确实需要在遍历的时候修改vector,有两种做法:
- 一是把修改后,记住循环中试图添加或删除哪些元素,等循环结束了再依记录修改foos;
- 二是用copy-on-write。(2.8中会介绍)
2.1.2 死锁
线程自己与自己死锁实例:
#include<mutex>
class Request
{
public:
void process() // __attribute__ ((noinline))
{
muduo::MutexLockGuard lock(mutex_);
print();
}
void print() const // __attribute__ ((noinline))
{
muduo::MutexLockGuard lock(mutex_);
}
private:
mutable muduo::MutexLock mutex_;
};
int main()
{
Request req;
req.process();
}
问题:process()中获取了锁mutex,但是process()中调用print(),需要在print()中再次获取锁,如果mutex是非递归锁,那么就会产生死锁。
解决方法:如果一个函数既可能在已加锁的情况下调用,又可能在未加锁的情况下调用,那么就拆成两个函数:一个函数是加锁的,一个函数的未加锁的,加锁的函数名为A,那么未加锁的函数名可以定义为AWithLockHold。
书中还介绍了一种死锁情况,如下:
main()中调用了对象A的printAll()(获取mutex_a),线程2调用了对象B的~Request()
(获取mutex_b)。当printAll()需要调用对象B的print(),线程2占用着mutex_b,导致成printAll()无法继续执行。当~Request()
需要调用对象A的remove(),线程1占用着mutex_a,导致成~Request()
无法继续执行。所以调用两个带锁的对象的函数的时候需要小心。
2.2 条件变量(condition variable)
条件变量的学名叫管程(monitor)
条件变量只有一种正确使用的方式,几乎不可能用错。可以参考我写的另一篇文章:C++ 条件变量(condition_variable)、notify、wait、互斥量(mutex)、同步过程、BlockingQueue
对于wait端:
- 1.必须与mutex一起使用,该布尔表达式的读写需受此mutex保护。
- 2.在mutex已上锁的时候才能调用wait()。
- 3.把判断布尔条件和wait()放到while循环中
对于signal/broadcast端:
- 1.不一定要在mutex已上锁的情况下调用signal(理论上)。
- 2.在signal之前一般要修改布尔表达式。修改布尔表达式通常要用mutex保护(至少用作full memory barrier)。
- 3.注意区分signal与broadcast:“broadcast通常用于表明状态变化,signal通常用于表示资源可用。
2.3 不要用读写锁和信号量
2.3.1 不要用读写锁
读写锁:
- 当有线程持有写锁时,其他线程不能获取读锁和写锁。如获取读锁时会判断是否有线程已经获取写锁,如果有,说明有线程在执行写,此时就会阻塞,直到写锁释放。
- 当有线程持有读锁时,其他线程可以同时获取读锁,但不能获取写锁。
【加读锁的原因】因为写的操作可能经过一系列的过程才能最终得到结果,如果不加读锁,那么可能读到写操作过程中的一个中间值。(参考:链接)
在C++中,读写锁可以使用标准库提供的 std::shared_mutex 类型来表示。std::shared_mutex 类型在C++14中引入,可以用于实现读写锁。下面是使用读写锁的实例:
#include <iostream>
#include <thread>
#include <shared_mutex>
#include<vector>
std::shared_mutex mutex;
int data = 0;
void reader(int id) {
while (true) {
// 读取数据,使用 shared_lock
std::shared_lock<std::shared_mutex> lock(mutex); // 读锁
std::cout << "Reader " << id << " read data: " << data << std::endl;
}
}
void writer(int id) {
while (true) {
// 写入数据,使用 unique_lock
std::unique_lock<std::shared_mutex> lock(mutex); // 写锁
data++;
std::cout << "Writer " << id << " wrote data: " << data << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}
int main() {
// 创建 5 个读者线程和 2 个写者线程
std::vector<std::thread> readers(5);
std::vector<std::thread> writers(2);
for (int i = 0; i < readers.size(); i++) {
readers[i] = std::thread(reader, i);
}
for (int i = 0; i < writers.size(); i++) {
writers[i] = std::thread(writer, i);
}
// 等待所有线程结束
for (auto& reader : readers) {
reader.join();
}
for (auto& writer : writers) {
writer.join();
}
return 0;
}
代码说明:
在这个示例代码中,我们创建了 5 个读者线程和 2 个写者线程。每个读者线程会不停地读取 data 的值,并使用 std::shared_lock 进行加锁;每个写者线程会不停地将 data 的值加 1,并使用 std::unique_lock 进行加锁。
使用 std::shared_lock 可以允许多个线程同时对共享数据进行读取操作,而使用 std::unique_lock 可以保证在写入数据时只有一个线程可以访问共享数据。这样就可以避免数据竞争和死锁的问题。
初学者常干的一件事情是,一见到某个共享数据结构频繁读而很少写,就把mutex替换为rwlock。甚至首选rwlock来保护共享状态,这不见得是正确的:
-
从正确性方面来说,一种典型的易犯错误是在持有read lock的时 候修改了共享数据。这通常发生在程序的维护阶段,为了新增功能, 程序员不小心在原来read lock保护的函数中调用了会修改状态的函数。 这种错误的后果跟无保护并发读写共享数据是一样的。
-
从性能方面来说,读写锁不见得比普通mutex更高效。无论如何 reader lock加锁的开销不会比mutex lock小,因为它要更新当前reader的数目。如果临界区很小,读锁竞争不激烈,那么mutex往往会更快。
-
reader lock可能允许提升(upgrade)为writer lock,也可能不允许 提升。考虑§2.1.1的post()和traverse()示例,如果用读写锁来保护foos 对象,那么post()应该持有写锁,而traverse()应该持有读锁。如果允许 把读锁提升为写锁,后果跟使用recursive mutex一样,会造成迭代器失 效,程序崩溃。如果不允许提升,后果跟使用non-recursive mutex一 样,会造成死锁。我宁愿程序死锁,留个“全尸”好查验。
-
通常reader lock是可重入的,writer lock是不可重入的。但是为了 防止writer饥饿,writer lock通常会阻塞后来的reader lock,因此reader lock在重入的时候可能死锁。另外,在追求低延迟读取的场合也不适用读写锁。
从正确性方面来说,一种典型的易犯错误是在持有read lock的时 候修改了共享数据。这通常发生在程序的维护阶段,为了新增功能, 程序员不小心在原来read lock保护的函数中调用了会修改状态的函数。 这种错误的后果跟无保护并发读写共享数据是一样的。举例说明如下:
#include <iostream>
#include <thread>
#include <mutex>
#include <shared_mutex>
std::shared_mutex mtx;
int shared_data = 0;
void read_data() {
std::shared_lock<std::shared_mutex> lock(mtx);
std::cout << "Read shared data: " << shared_data << std::endl;
}
void modify_data() {
std::unique_lock<std::shared_mutex> lock(mtx);
++shared_data;
}
void error_function() {
std::shared_lock<std::shared_mutex> lock(mtx);
modify_data(); // 会导致错误的修改共享数据的函数
}
int main() {
// 读取共享数据
std::thread t1(read_data);
std::thread t2(read_data);
t1.join();
t2.join();
// 修改共享数据
std::thread t3(modify_data);
t3.join();
// 错误的调用导致数据破坏
std::thread t4(error_function);
t4.join();
return 0;
}
muduo线程库有意不提供读写锁的封装,因为我还没有在工作中遇到过用rwlock替换普通mutex会显著提高性能的例子。相反,我们一般建议首选mutex。
遇到并发读写,如果条件合适,我通常会用§2.8的办法,而不用读 写锁,同时避免reader被writer阻塞。如果确实对并发读写有极高的性 能要求,可以考虑read-copy-update。
2.3.2 不要用信号量
信号量(Semaphore):我没有遇到过需要使用信号量的情况,无 从谈及个人经验。我认为信号量不是必备的同步原语,因为条件变量 配合互斥器可以完全替代其功能,而且更不易用错。除了[RWC]指出的 “semaphore has no notion of ownership”之外,信号量的另一个问题在于 它有自己的计数值,而通常我们自己的数据结构也有长度值,这就造 成了同样的信息存了两份,需要时刻保持一致,这增加了程序员的负 担和出错的可能。如果要控制并发度,可以考虑用muduo::ThreadPool。
说一句不知天高地厚的话,如果程序里需要解决如“哲学家就餐” 之类的复杂IPC问题,我认为应该首先检讨这个设计:为什么线程之间 会有如此复杂的资源争抢(一个线程要同时抢到两个资源,一个资源 可以被两个线程争夺)?如果在工作中遇到,我会把“想吃饭”这个事 情专门交给一个为各位哲学家分派餐具的线程来做,然后每个哲学家 等在一个简单的condition variable上,到时间了有人通知他去吃饭。从 哲学上说,教科书上的解决方案是平权,每个哲学家有自己的线程, 自己去拿筷子;我宁愿用集权的方式,用一个线程专门管餐具的分 配,让其他哲学家线程拿个号等在食堂门口好了。这样不损失多少效 率,却让程序简单很多。
2.5 线程安全的Singleton实现
使用 C++11 std::call_once 实现单例(C++11线程安全)
#include <iostream>
#include <memory>
#include <mutex>
class Singleton {
public:
static std::shared_ptr<Singleton> getSingleton();
void print() {
std::cout << "Hello World." << std::endl;
}
~Singleton() {
std::cout << __PRETTY_FUNCTION__ << std::endl;
}
private:
Singleton() {
std::cout << __PRETTY_FUNCTION__ << std::endl;
}
};
static std::shared_ptr<Singleton> singleton = nullptr;
static std::once_flag singletonFlag;
std::shared_ptr<Singleton> Singleton::getSingleton() {
std::call_once(singletonFlag, [&] {
singleton = std::shared_ptr<Singleton>(new Singleton());
});
return singleton;
}
call_once:多线程环境中,线程的任务函数中调用函数A,但只希望A被调用一次。参考链接
2.6 sleep(3)不是同步原语
在程序的正常执行中,如果需要等待一段已知的时间,应该往 event loop里注册一个timer,然后在timer的回调函数里接着干活,因为 线程是个珍贵的共享资源,不能轻易浪费(阻塞也是浪费)。如果等 待某个事件发生,那么应该采用条件变量或IO事件回调,不能用sleep 来轮询。
如果多线程的安全性和效率要靠代码主动调用sleep来保证,这显然是设计出了问题。等待某个事件发生,正确的做法是用select()等价物或Condition,抑或(更理想地)高层同步工具;在用户态做轮询(polling)是低效的
【问题】“应该往 event loop里注册一个timer,然后在timer的回调函数里接着干活”是什么意思?
2.7 归纳与总结
前面几节内容归纳如下:
- 线程同步的四项原则,尽量用高层同步设施(线程池、队列、倒计时);
- 使用普通互斥器和条件变量完成剩余的同步任务,采用RAII和Scoped Locking(使用unique_lock/lock_guard等管理mutex)。
2.8 借shared_ptr实现copy-on-write
2.8.1 解决同一个线程里多次对non-recursive mutex加锁会立刻导致死锁
2.1.1中的代码展示了在同一个线程里多次对non-recursive mutex加锁会立刻导致死锁,2.1.1中的代码的问题是traverse()获取了锁,但是traverse()中调用的post想要再次获取锁,从而导致了死锁。本小节使用非递归的mutex并且保证不出现2.1.1中的代码不出现死锁,代码修改如下:
#include <stdio.h>
#include <cassert>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
class Foo {
public:
void doit() const;
};
typedef std::vector<Foo> FooList;
typedef std::shared_ptr<FooList> FooListPtr;
std::mutex mutex;
FooListPtr g_foos = std::make_shared<FooList>();
void post(const Foo& f) {
std::lock_guard<std::mutex> lock(mutex);
if (!g_foos.unique()) {
// reset():reset()包含两个操作。当智能指针中有值的时候,调用reset()会使引用计数减1.当调用reset(new xxx())
// 重新赋值时,智能指针首先是生成新对象,然后将就对象的引用计数减1(当然,如果发现引用计数为0时,则析构旧对象),
// 然后将新对象的指针交给智能指针保管。
g_foos.reset(new FooList(*g_foos));
printf("copy the whole list\n");
}
assert(g_foos.unique());
g_foos->push_back(f);
}
void traverse() {
FooListPtr foos;
{
std::lock_guard<std::mutex> lock(mutex);
foos = g_foos;
assert(!g_foos.unique());
}
for (std::vector<Foo>::const_iterator it = foos->begin(); it != foos->end();++it) {
it->doit();
}
}
void Foo::doit() const {
Foo f;
post(f);
}
int main() {
Foo f;
post(f);
traverse();
}
上述代码中:
- traverse()用一个栈上局部变量foos,它使得g_foos的引用计数增加(代表有线程正在调用traverse()),然后使用将锁释放,从而防止下面调用post()时产生死锁。
- post():如果g_foos.unique()为true,我们可以放心地在原地(in-place)修改FooList。如果g_foos.unique()为false,说明这时别的线程正在读取FooList,我们不能原地修改,而是复制一份(L23),在副本上修改(L27)。由于是在副本上进行修改的,所以不会影响traverse()中对原始g_foos的遍历。由于traverse()早就释放了锁,所以这样就避免了死锁。
上述代码常见的几种错误:
// 错误一:直接修改g_foos 所指的FooList
void post(const Foo& f) {
std::lock_guard<std::mutex> lock(mutex);
g_foos->push_back(f);
}
错误原因:这会导致traverse()中遍历的foos也发生改变,当foos增加到一定程度,需要扩容时,它会创建一个新的存储空间,并将元素从旧的存储空间复制到新的存储空间。如果在复制期间进行迭代器操作,例如解引用、递增等,就会导致迭代器失效,因为指向旧存储空间的迭代器现在指向无效的内存位置。
//错误二:试图缩小临界区,把copying移出临界区
void post(const Foo& f) {
FooListPtr newFoos(new FooList(*g_foos));
newFoos->push_back(f);
std::lock_guard<std::mutex> lock(mutex);
g_foos = newFoos; // 或者g_foos.swap(newFoos);
}
错误原因:线程1执行完new FooList(*g_foos)
生成新对象,可能还未将新对象的指针交给g_foos保管,此时线程2对g_foos进行了修改,这时候线程1已经生成的对象就过期了。
//错误三:把临界区拆成两个小的,把copying 放到临界区之外
void post(const Foo& f) {
FooListPtr oldFoos;
{
std::lock_guard<std::mutex> lock(mutex);
oldFoos = g_foos;
}
FooListPtr newFoos(new FooList(*oldFoos)) ;
newFoos->push_back(f);
std::lock_guard<std::mutex> lock(mutex);
g_foos = newFoos; // 或者g_foos.swap(newFoos);
}
错误原因:和错误二的原因一样。可能在还未执行FooListPtr newFoos(new FooList(*oldFoos))
的时候,g_foos就被其他线程修改了。
2.8.2 2.1.2中的死锁问题
2.1.2中的死锁问题:main()中调用了对象A的printAll()(获取mutex_a),线程2调用了对象B的Request()(获取mutex_b)。当printAll()需要调用对象B的print(),线程2占用着mutex_b,导致成printAll()无法继续执行。当Request()需要调用对象A的remove(),线程1占用着mutex_a,导致成~Request()无法继续执行。解决方法:采用本节前面post()和traverse()的方案【以后再来思考。。。】
2.8.3 用普通mutex替换读写锁的一个例子
#include <cassert>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
using std::string;
class CustomerData {
public:
CustomerData() : data_(new Map) {}
int query(const string& customer, const string& stock) const;
private:
CustomerData(const CustomerData&) = delete;
CustomerData& operator=(const CustomerData&) = delete;
typedef std::pair<string, int> Entry;
typedef std::vector<Entry> EntryList;
typedef std::map<string, EntryList> Map;
typedef std::shared_ptr<Map> MapPtr;
void update(const string& customer, const EntryList& entries);
void update(const string& message);
static int findEntry(const EntryList& entries, const string& stock);
static MapPtr parseData(const string& message);
MapPtr getData() const {
std::lock_guard<std::mutex> lock(mutex_);
return data_;
}
mutable std::mutex mutex_;
MapPtr data_;
};
int CustomerData::query(const string& customer, const string& stock) const {
MapPtr data = getData();
Map::const_iterator entries = data->find(customer);
if (entries != data->end())
return findEntry(entries->second, stock);
else
return -1;
}
void CustomerData::update(const string& customer, const EntryList& entries) {
std::lock_guard<std::mutex> lock(mutex_);
if (!data_.unique()) {
MapPtr newData(new Map(*data_));
data_.swap(newData);
}
assert(data_.unique());
(*data_)[customer] = entries;
}
void CustomerData::update(const string& message) {
MapPtr newData = parseData(message);
if (newData) {
std::lock_guard<std::mutex> lock(mutex_);
data_.swap(newData);
}
}
int main() { CustomerData data; }
代码说明:
- 关键看CustomerData::update()怎么写。既然要更新数据,那肯定得加锁,如果这时候其他线程正在读,那么不能在原来的数据上修改,得创建一个副本,在副本上修改,修改完了再替换。如果没有用户在读,那么就能直接修改,节约一次Map拷贝。
注意其中用了shared_ptr::unique()来判断是不是有人在读,如果有人在读,那么我们不能直接修改,因为query()并没有全程加锁,只在getData()内部有锁。shared_ptr::swap()把data_替换为新副本,而且我们还在锁里,不会有别的线程来读,可以放心地更新。如果别的reader线程已经刚刚通过getData()拿到了MapPtr,它会读到稍旧的数据。这不是问题,因为我们假设数据更新来自网络,如果网络稍有延迟,反正reader线程也会读到旧的数据。