本文是《Linux多线程服务端编程》中P52 "2.8借shared_ptr实现copy-on-write"的读后分析。
感谢陈硕老师分享这么优秀的文章。欢迎大家阅读陈硕老师的原文,原文写的相当精彩。《Linux多线程服务端编程》https://book.douban.com/subject/20471211/
多线程对临界区的访问
class Connection /*表示与客户端的连接句柄*/
{
public:
/*向客户端发送数据*/
void send(const string& message){...}
};
/*服务端与客户端建立连接句柄的集合*/
set<Connection> connectionList;
/*当客户端与服务端建立连接的回调函数 写操作*/
void onConnection(const Connection & conn){
connectionList.insert(conn);
}
/*服务端给所有客户端发送消息 读操作*/
void onStringMessage(const string& message){
for(auto c : connectionList){
c.send(message);
}
}
客户端与服务端建立连接后,服务端都会生成一个Connection对象,作为这个连接的句柄。connectionList保存着所有的与客户端连接的句柄。
connectionList是服务端保存的所有连接客户端的句柄
当有新的客户端成功连接服务端后,onConnection函数会被调用,将新客户端的Connection插入到set中。
当服务端需要给所有的客户端发送消息时,会调用onStringMessage函数,遍历所有的Connection,依次给每个客户端发送数据。
如果所有的操作都在单线程内执行时,上面的代码是没有问题的,也不需要加锁。
当上面的操作在多线程中执行时,是需要加锁的。加锁后的代码如下:
MutexLock mutex; /*互斥锁*/
/*写操作*/
void onConnection(const Connection & conn){
MutexLockGuard lock(mutex) /*上锁*/
connectionList.insert(conn);
} /*解锁*/
/*读操作*/
void onStringMessage(const string& message){
MutexLockGuard lock(mutex) /*上锁*/
for(auto c : connectionList){
c.send(message);
}
} /*解锁*/
connectionList会被多个线程访问,所以对其的读操作和写操作,都需要上锁。
当竞争非常激烈时,多个线程并发的访问connectionList,最后变成了串行访问。
如果读操作需要花费10ms,写操作需要花费5ms,此时时间轴如下图: 为了方便,假设按照获取锁的顺序执行。
总共需要花费50ms。
并发的访问,因为使用锁,最后变成了串行访问。
这是典型的读者-写者模型,读-读不互斥
,读-写互斥
,写-写互斥
。既然读-读不互斥,所以当第一个读者在临界区时,第二个读者不用等到第一个读者出了临界区后在进入,可以直接进入。如下图:
因为读-读可以并发访问临界区,所以将减少20ms的时间。
借shared_ptr实现copy-on-write
这里提高并发的方法是:用空间换取时间
陈硕老师并不推荐使用读写锁,他通过shared_ptr实现了对共享数据的管理。
用shared_ptr管理共享数据,原理如下:
- shared_ptr是引用计数型智能指针,如果当前只有一个观察者,那么引用计数的值为1。
- 对于write端,如果发现引用计数为1,这时可以安全的修改共享对象,不必担心有人正在读它。
- 对于read端,在读之前把引用计数加1,读完之后减1,这样保证在读的期间其引用计数大于1,可以阻止并发写。
typedef set<Connection> ConnectionList;
shared_prt<ConnectionList> connectionListPtr(new ConnectionList);
MutexLock mutex;
void onConnection(const Connection & conn){
MutexLock lock(mutex);
if(!connectionListPtr.unique()){ /*如果有读者在使用*/
/*拷贝一份*/
connectionListPtr.reset(new set<Connection>(*connectionListPtr))
}
connectionListPtr->insert(conn);
}
void onStringMessage(const string& message){
shared_ptr<ConnectionList> tmp;
{
MutexLockGuard lock(mutex); /*shared_ptr线程不安全*/
tmp = connectionListPtr; /*connectionListPtr的引用计数会加一*/
} /*mutex锁释放*/
for(auto i : *tmp{
i.send();
}
}
onStringMessage中在对shared_ptr进行赋值操作时,使用了锁,是因为shared_ptr对象不是线程安全的,对其访问和修改需要上锁。注意这里使用锁是因为shared_ptr对象本身线程不安全,和ConnectionList对象没有关系。
onConnection中使用锁,不仅仅是因为shared_ptr线程不安全,这把锁还保证了写操作时,不会有其他的读操作和其他的写操作。后面会进一步分析。
对shared_ptr的操作会非常的快,所以以下假设对shared_ptr的相关操作不好时,写操作需要5ms,读操作需要10ms。锁并不保证按顺序获取锁,以下分析为了简单,假设可以根据拿到锁的顺序依次执行。
此时有8个线程需要访问connectionListPtr,并且此时connectionListPtr中已经有了三个连接。
第一个读线程进入访问,此时它生成一个属于自己的shared_ptr,让其指向ConnectionList。此时connectionListPtr的引用计数为2
。
第二个读线程进入后,它也会生成一个属于自己的shared_ptr,此时connectionListPtr的引用计数为3
。
第一个写线程进入后,先上锁,让后面的读线程和写线程都阻塞在锁上,进入后发现connectionListPtr的引用计数为3
,它明白此时还有其他读线程正在使用connectionList,此时它不能修改,但是可以读取。此时写线程通过connectionListPtr.reset(new set<Connection>(connectionListPtr))
语句,将connectionList拷贝了一份,同时修改了connectionListPtr,让其指向新的connectionList。此时拷贝的这个connectionList是写线程独有的了,可以修改了。
此时正在读的两个线程可以正常执行,而写线程也可以进行它的操作,此时这三个线程是并发执行的。后面的线程全部阻塞在写操作中的锁中。
写线程快要完成它的操作了。
第一个写线程完成操作释放锁,之后的两个读线程和写线程进入临界区。此时的两个读线程获取的是最新的connectionList,也就是connectionListPtr指向的connectionListPtr。
此时有4个读线程和一个写线程同时执行。
最开始的两个读线程操作完成时,因为它们的tmp是一个局部变量,在离开作用域时,会将tmp指向的connectionList引用计数减一,当这两个读线程中的最后一个退出时,connectionList的引用计数变为了0,connectionList会被释放。
第二个写线程操作完成后,其后的写线程和读线程会进入临界区。
第三个写线程将connectionList拷贝一份,之后添加一个新的Connection。
两个读线程和一个写线程操作完成,读线程对应的connectionList也会被释放。
最后一个读线程退出后,也会释放它锁对应的connectionList。
实际的执行情况要复杂一些,以上分析是简化的过程。
几种错误的写法
陈硕老师在其书中指出了几种错误的写法,现在分析一下错误的原因。
直接修改connectionListPtr所指的ConnectionList
void onConnection(const Connection & conn){
MutexLock lock(mutex);
connectionListPtr->insert(conn);
}
不满足读-写互斥
一旦写线程执行写操作之前,有读线程已经进入了临界区,此时写线程执行写操作时,可能读线程正在执行读操作,发生了竞态。
试图缩小临界区,把copying移出临界区。
void onConnection(const Connection & conn){
shared_ptr<set<Connection>> tmp(new set<Connection>(*connectionListPtr)); /*shared_ptr不是线程安全的*/
tmp->insert(conn);
MutexLock lock(mutex);
connectionListPtr = tmp;
}
不满足写-写互斥
,同时对connectionListPtr的操作也不是线程安全的,存在一个写线程读shared_ptr,另外写线程在修改shared_ptr。
如果有两个写线程同时并发,同时执行onConnection函数。
在执行到MutexLock lock(mutex);
之前是并发的,两个写线程将新的conn添加到了自己的tmp中。
执行到MutexLock lock(mutex);
时,只有一个写线程可以获取,另一个写线程阻塞。
最后执行的结果是上面两种情况中的一种,不管哪种情况,都会丢失一个conn。
把临界区拆成两个小的,把copying放在临界区之外。
void onConnection(const Connection & conn){
shared_ptr<set<Connection>> old;
{
MutexLock lock(mutex);
old = connectionListPtr;
}
shared_ptr<set<Connection>> tmp(new set<Connection>(*old));
tmp->insert(conn);
MutexLock lock(mutex);
connectionListPtr = tmp;
}
依然存在写-写不互斥
,这里仅解决了shared_ptr的线程安全,此时对connectionListPtr的操作都是线程安全的了。
参考
《Linux多线程服务端编程》https://book.douban.com/subject/20471211/