使用shared_ptr实现copy-on-write

本文是《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/

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值