- 本文内容衔接于前一篇文章(线程安全的Singleton实现):javascript:void(0)
-
本节解决前面文章(javascript:void(0))的几个未决问题:
- post()和traverse()死锁
- 把Request::print()移出Inventory::printAll()临界区
- 解决Request对象析构的race condition:此篇文章没有介绍,一种可能的答案见recipes/thread/RequestInvectory_test2.c
-
解决办法都基于同一个思路,那就是用shared_ptr来管理共享数据。原理如下:
- shared_ptr是引用计数型智能指针,如果当前只有一个观察者,那么引用计数的值为1(实际代码中判断shared_ptr::unique()是否为true)
- 对于write端,如果发现引用计数为1,这时可以安全地修改共享对 象,不必担心有人正在读它
- 对于read端,在读之前把引用计数加1,读完之后减1,这样保证在读的期间其引用计数大于1,可以阻止并发写
- 比较难的是,对于write端,如果发现引用计数大于1,该如何处理?sleep()一小段时间肯定是错的
- 之后再示范用普通mutex替换读写锁
-
Foo::doit()间接调用了post(),那么会:
- mutex是非递归的,于是死锁了
- mutex是递归的,由于push_back()可能(但不总是)导致vector迭代器失效,程序偶尔会crash
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(); //doit()中会调用post()
}
}
二、把Request::print()移出Inventory::printAll()临界区修正代码如下:
- 我们首先更改数据结构:
- 将要操作的vector对象使用一个shared_ptr管理
typedef std::vector<Foo> FooList; typedef std::shared_ptr<FooList> FooListPtr; MutexLock mutex; FooList g_foos;
- 对于read端来说:
- 代码中使用一个栈上局部FooListPtr变量foos当做“观察者”,其使得g_foos的引用计数增加
- traverse()的临界区为第二个花括号所表示的区间,临界区内只读了一次共享变量g_foos(这里多线程并发读写shard_ptr,因此必须用mutex保护),比原来的写法大为缩减
- 而且多个线程同时调用traverse()也不会相互阻塞
void traverse() { FooListPtr foos; { MutexLockGuard lock(mutex); foos = g_foos; //使得g_foos的引用计数增加 assert(!g_foos.unique()); } //assert(!foos.unique())这个断言不成立 for (std::vector<Foo>::const_iterator it = foos->begin(); it != foos->end(); ++it) { it->doit(); } }
- 对于write端来说:
- 如果g_foos.unique()为false,说明别的线程正在读取FooList,我们不能原地修改,而是复制一份,在副本上修改
- 如果g_foos.unique()为true,说明没有任何读写端对g_foos操作,因此可以放心地在原地修改FooList
- 这样就避免了死锁
void post(const Foo& f) { printf("post\n"); MutexLockGuard lock(mutex); //g_foos不唯一,那么在副本上进行修改 if (!g_foos.unique()) { g_foos.reset(new FooList(*g_foos)); printf("copy the whole list\n"); } //如果g_foos唯一,那么可以直接操作 assert(g_foos.unique()); g_foos->push_back(f); }
- 关于post()的注意事项:
- 上面的post()的临界区包括整个函数,其他写法都是错误的
- 下面几种写法都是错误的
//错误一:直接修改g_foos所指的FooList void post(const Foo& f) { MutexLockGuard lock(mutex); g_foos->push_back(f); } //错误二:试图缩小临界区,把copying移除临界区 void post(const Foo& f) { FooListPtr newFoos(new FooList(*g_foos)); newFoos->push_back(f); MutexLockGuard lock(mutex); g_foos = newFoos; //或者g_foos.swap(newFoos); } //错误三:把临界区拆成两个小的,把copying放到临界区之外 void post(const Foo& f) { FooListPtr oldFoos; { MutexLockGuard lock(mutex); oldFoos = g_foos; } FooListPtr newFoos(new FooList(*g_foos)); newFoos->push_back(f); MutexLockGuard lock(mutex); g_foos = newFoos; //或者g_foos.swap(newFoos); }
做法①
- 把requests_复制一份,在临界区之外遍历这个副本
- 例如:
class Inventory { public: //其余同前面文章 void printAll()const { std::set<Request*> requests { muduo::MutexLockGuard lock(mutex_); requests = requests_; } //遍历局部变量requests,调用Request::print() } private: mutable muduo::MutexLock mutex_; std::set<Request*> requests_; };
- 这么做有一个明显的缺点:它赋值了整个std::set中的每个元素,开销比较大
三、用普通mutex替换读写锁的一个例子做法②(copy-on-wirte)
- 为了避免做法①所带来的的开销,如果遍历期间没有其他人修改requests_,那么我们可以减小开销
- 例如:
- 用shared_ptr管理std::set,在遍历的时候先增加引用计数,阻止并发修改
- 当然Inventory::add()和Inventory::remove()也要相应修改,原理与上面的post()和reaverse()原理相似。可以参阅:recipes/thread/test/Request-Inventory.cc
场景
- 一个多线程的C++程序,24h x 5.5d运行
- 有几个工作线程ThreadWorker{0, 1, 2, 3},处理客户发过来的交易请求
- 另外有一个背景线程ThreadBackground,不定期更新程序内部的参考数据
- 这些线程都跟一个hash表打交道,工作线程只读,背景线程读写,必然要用到一 些同步机制,防止数据损坏。这里的示例代码用std::map代替hash表, 意思是一样的:
- map的key是用户名,value是一个vector(里面存的是不同stock的最小交易间隔,vector已经排序好,可以用二分查找)
typedef std::map<std::string, std::vector<std::pair<std::string, int>>> Map;
- 我们的系统要求工作线程的延迟尽可能小,可以容忍背景线程的延迟略大。一天之内,背景线程对数据更新的次数屈指可数,最多一小时一次,更新的数据来自于网络,所以对更新的及时性不敏感。Map的数据量也不大,大约一千多条数据
四、总结代码实现
- 最简单的同步办法是用读写锁:工作线程加读锁,背景线程加写锁
- 但是读写锁的开销比普通mutex要大,而且是写锁优先,会阻塞后面的读锁。如果工作线程能用最普通的非重入mutex实现同步,就不必用读写锁,这能降低工作线程延迟。我们借助shared_ptr做到了这一 点:
class CustomerData :boost::noncopyable { public: CustomerData() :data_(new Map) {} int query(const std::string& customer, const std::string& stock)const; private: typedef std::pair<std::string, int> Entry; typedef std::vector<Entry> EntryList; typedef std::map<std::string, EntryList> Map; typedef std::tr1::shared_ptr<Map> MapPtr; void update(const std::string& customer, const EntryList& entries); //用lower_bound在entries里找stock static int findEntry(const EntryList& entries, const std::string& stock); MapPtr getData()const { MutexLockGuard lock(mutex_); return data_; } mutable MutexLock mutex_; MapPtr data_; }; //代码可参阅:recipes/thread/test/Customer.cc
- (read端)CustomerData::query()就用前面说的引用计数加1的办法,用局部MapPtr data变量来持有Map,防止并发修改:
int CustomerData::query(const std::string& customer, const std::string& stock)const { MapPtr data = getData(); //使shared_ptr引用计数加1 //data一旦拿到,就不再需要锁了,getData()中已经加锁 //取数据的时候只有getData()内部加锁,多线程并发读的性能很好 //因为只有getData()进行了加锁,所以getData()函数执行完之后锁自动释放 //因此下面可能会读取到旧的数据,但这不是问题(见下面注意事项) Map::const_iterator entries = data->find(customer); if (entries != data->end()) return findEntry(entries->second, stock); else return -1; }
- (write端)关键看CustomerData::update()怎么写,既然要更新数据,那肯定得加锁:
- 如果这时候其他线程正在读,那么不能在原来的数据上修改,得创建一个副本,在副本上修改,修改完了再替换
- 如果没有用户在读, 那么就能直接修改,节约一次Map拷贝
void CustomerData::update(const std::string& customer, const EntryList& entries) { MutexLockGuard lock(mutex_); //不唯一,说明有其他线程在读,那么在副本上修改 if (!data_.unique()) { MapPtr newData(new Map(*data_)); //可以在这里打印日志,然后统计日志来判断worst case发生的次数 data_.swap(newData); } //判断引用计数是否为1,如果为1说明无线程读取,直接修改 assert(data_.unique()); (*data_)[customer] = entries; }
- 注意事项:
其中用了shared_ptr::unique()来判断是不是有人在读,如果有人在读,那么我们不能直接修改,因为query()并没有全程加锁,只在getData()内部有锁
- shared_ptr::swap()把data_替换为新副本,而且我们还在锁里,不会有别的线程来读,可以放心地更新
- 如果别的reader线程已经刚刚通过getData()拿到了MapPtr,它会读到稍旧的数据。这不是问题,因为数据更新来自网络,如果网络稍有延迟,反正reader线程也会读到旧的数据
- 如果每次都更新全部数据,而且始终是在同一个线程更新数据,临界区还可以进一步缩小:
class CustomerData :boost::noncopyable { //其余同上 //修改update()函数 void update(const std::string& message); //添加一个函数,用来解析收到的消息,返回新的MapPtr MapPtr parseData(const std::string& message); }; void CustomerData::update(const std::string& message) { //解析数据,在临界区之外 MapPtr newData = parseData(message); if (newData) { MutexLockGuard lock(mutex_); data_.swap(newData); //不要用data_ = newData } //旧数据的析构也在临界区之外,进一步缩短了临界区 }
- 据我们测试,大多数情况下更新都是在原来数据上进行的,拷贝的比例还不到1%,很高效。更准确地说,这不是copy-on-write,而是copy-on-other-reading
- 我们将来可能会采用无锁数据结构,不过目前这个实现已经非常好,可以满足我们的要求
- 本文介绍的做法与read-copy-updaye颇有相似之处,但理解起来容器很多