muduo网络库:06---线程同步精要之(借shared_ptr实现copy-on-write)

  • 本文内容衔接于前一篇文章(线程安全的Singleton实现)https://blog.csdn.net/qq_41453285/article/details/104875213
  • 本节解决前面文章(https://blog.csdn.net/qq_41453285/article/details/104859230)的几个未决问题:
    • 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替换读写锁

一、post()和traverse()死锁问题

  • 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()
    }
}

修正代码如下:

  • 我们首先更改数据结构:
    • 将要操作的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);
}

二、把Request::print()移出Inventory::printAll()临界区

做法①

  • 把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中的每个元素,开销比较大

做法②(copy-on-wirte)

  • 为了避免做法①所带来的的开销,如果遍历期间没有其他人修改requests_,那么我们可以减小开销
  • 例如:
    • 用shared_ptr管理std::set,在遍历的时候先增加引用计数,阻止并发修改
    • 当然Inventory::add()和Inventory::remove()也要相应修改,原理与上面的post()和reaverse()原理相似。可以参阅:recipes/thread/test/Request-Inventory.cc

三、用普通mutex替换读写锁的一个例子

场景

  • 一个多线程的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颇有相似之处,但理解起来容器很多
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

董哥的黑板报

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值