拨开荷叶行,寻梦已然成。仙女莲花里,翩翩白鹭情。
IMG-LOGO
主页 文章列表 C++高并发场景下读多写少的优化方案

C++高并发场景下读多写少的优化方案

白鹭 - 2022-02-10 2157 0 0

C++高并发场景下读多写少的优化方案

概述

一谈到高并发的优化方案,往往能想到模块水平拆分、数据库读写分离、分库分表,加快取、加mq等,这些都是从系统架构上解决,单模块作为系统的组成单元,其性能好坏也能很大的影响整体性能,本文从单模块下读多写少的场景出发,探讨其解决方案,以其更好的实作高并发,
不同的业务场景,读和写的频率各有侧重,有两种常见的业务场景:

  1. 读多写少:典型场景如广告检索端、白名单更新维护、loadbalancer
  2. 读少写多:典型场景如qps统计

本文针对读多写少(也称一写多读)场景下遇到的问题进行分析,并探讨一种合适的解决方案,

分析

读多写少的场景,服务大部分情况下都是处于读,而且要求读的耗时不能太长,一般是毫秒或者更低的级别;更新的频率就不是那么频繁,如几秒钟更新一次,通过简单的加互斥锁,腾出一片临界区,往往能到达预期的效果,保证资料更新正确,
加锁的模型

但是,只要加了锁,就会带来竞争,即使加的是读写锁,虽然读之间不互斥,但写一样会影响读,而且读写同时争夺锁的时候,锁优先分配给写(读写锁的特性),例如,写的时候,要求所有的读请求阻塞住,等到写执行绪或协程释放锁之后才能读,如果写的临界区耗时比较大,则所有的读请求都会受影响,从监控图上看,这时候会有一根很尖的耗时毛刺,所有的读请求都在队列中等待处理,如果在下个更新周期来之前,服务能处理完这些读请求,可能情况没那么糟糕,但极端情况下,如果下个更新周期来了,读请求还没处理完,就会形成一个恶性回圈,不断的有读请求在队列中等待,最终导致队列被挤满,服务出现假死,情况再恶劣一点的话,上游服务发现某个节点假死后,由于负载均衡策略,一般会重试请求其他节点,这时候其他节点的压力跟着增加了,最终导致整个系统出现雪崩,
因此,加锁在高并发场景下要尽量避免,如果避免不了,需要让锁的粒度尽量小,接近无锁(lock-free)更好,简单的对一大片临界区加锁,在高并发场景下不是一种合适的解决方案

双缓冲

有一种资料结构叫双缓冲,其这种资料结构很常见,例如显示屏的显示原理,显示屏显示的当前帧,下一帧已经在后台的buffer准备好,等时间周期一到,就直接替换前台帧,这样能做到无卡顿的重绘,其实作的指导思想是空间换时间,这种资料结构的作业原理如下:

  1. 资料分为前台和后台
  2. 所有读执行绪读前台资料,不用加锁,通过一个指标来指向当前读的前台资料
  3. 只有一个执行绪负责更新,更新的时候,先准备好后台资料,接着直接切指标,这之后所有新进来的读请求都看到了新的前台资料
  4. 有部分读还落在老的前台那里处理,因为更新还不算完成,也就不能退出写执行绪,写执行绪需要等待所有落在老前台的执行绪读完成后,才能退出,在退出之前,顺便再更新一遍老前台资料(也就当前的新后台),可以保证前后台资料一致,这点在做增量更新的时候有用

工程实作上需要攻克的难点

  1. 写执行绪要怎么知道所有的读执行绪在老前台中的读完成了呢?
    一种做法是让各个读执行绪都维护一把锁,读的时候锁住,这时候不会影响其他执行绪的读,但会影响写,读完后释放锁(某些时候可能会有通知写执行绪的开销,但由于写的频率很低,所以这点开销还能接受),写执行绪只需要确认锁有没有释放了,确认完了后马上释放,确认这个动作非常快(小于25ns,1s=1000ms=1000000us=1000000000ns),读执行绪几乎不会感觉到锁的存在,
  2. 每个执行绪都有一把自己的锁,需要用全域的map来做执行绪id和锁的映射吗?
    不需要,而且这样做全域map就要加全域锁了,又回到了刚开始分析中遇到的问题了,其实,每个执行绪可以有私有存盘(thread local storage,简称TLS),如果是协程,就对应这协程的TLS(但对于go语言,官方是不支持TLS的,想实作类似功能,要么就想办法获取到TLS,要么就不要基于协程锁,而是用全域锁,但尽量让锁粒度小,本文主要针对C++语言,暂时不深入讨论其他语言的实作),这样每个读执行绪锁的是自己的锁,不会影响到其他的读执行绪,锁的目的仅仅是为了保证读优先
    对于执行绪私有存盘,可以使用pthread_key_create, pthread_setspecific,pthread_getspecific系列函式

核心代码实作

template <typename T, typename TLS>
int DoublyBufferedData<T, TLS>::Read(
    typename DoublyBufferedData<T, TLS>::ScopedPtr* ptr) { // ScopedPtr析构的时候,会释放锁
    Wrapper* w = static_cast<Wrapper*>(pthread_getspecific(_wrapper_key)); //非首次读,获取pthread local lock
    if (BAIDU_LIKELY(w != NULL)) {
        w->BeginRead();	// 锁住
        ptr->_data = https://www.cnblogs.com/longbozhan/p/UnsafeRead();
        ptr->_w = w;
        return 0;
    }
    w = AddWrapper();
    if (BAIDU_LIKELY(w != NULL)) {
        const int rc = pthread_setspecific(_wrapper_key, w); // 首次读,设定pthread local lock
        if (rc == 0) {
            w->BeginRead();
            ptr->_data = UnsafeRead();
            ptr->_w = w;
            return 0;
        }
    }
    return -1;
}

template <typename T, typename TLS>
template <typename Fn>
size_t DoublyBufferedData<T, TLS>::Modify(Fn& fn) {
    BAIDU_SCOPED_LOCK(_modify_mutex); // 加锁,保证只有一个写
    int bg_index = !_index.load(butil::memory_order_relaxed); // 指向后台buffer
    const size_t ret = fn(_data[bg_index]); // 修改后台buffer
    if (!ret) {
        return 0;
    }
    // 切指标
    _index.store(bg_index, butil::memory_order_release);	
    bg_index = !bg_index;
    // 等所有读老前台的执行绪读结束
    {
        BAIDU_SCOPED_LOCK(_wrappers_mutex);
        for (size_t i = 0; i < _wrappers.size(); ++i) {
            _wrappers[i]->WaitReadDone();
        }
    }
    // 确认没有读了,直接修改新后台资料,对其新前台
    const size_t ret2 = fn(_data[bg_index]);
    return ret2;
}

完整实作请参考brpc的DoublyBufferData

扩展实作

基于计数器的实作

基于计数器,用atomic,保证原子性,读进入临界区,计数器+1,退出-1,写判断计数器为0则切换(但在计数器为0和切换中间,不能保证没有新的读进来,这时候也要锁),而且计数器是全域锁,这种方案C++也可以采取,只是计数器毕竟也是全域锁,性能会差那么一丢丢,即使用智能指标shared_ptr,也会面临切换之前计数器有变成非0的问题,之所以用计数器,而不用TLS,是因为有些语言,如golang,不支持TLS,对比TLS版本和计数器版本,TLS性能更优,因为没有抢计数器的互斥问题,大概耗费700ns,很多吗?抢计数器本身很快,性能没测验过,可以试试,

golang中sync.Map的实作

也是基于计数器,只是计数器是为了让读前台快取失效的概率不要太高,有抑制和收敛的作用,实作了读的无锁,少部分情况下,前台快取读不到资料的时候,会去读后台快取,这时候也要加锁,同时计数器+1,计数器数值达到一定程度(超过后台快取的元素个数),就执行切换

是否适用于读少写多的场景

不合适,双缓冲优先保证读的性能,写多读少的场景需要优先保证写的性能,

相关文献

作者:longbozhan
出处: https://www.cnblogs.com/longbozhan/p/15780194.html
如果您觉得本文对您有帮助,请点击一下右下方的推荐按钮, 如果您对本文有任何疑问并想和作者探讨,请在本文下方评论,我看到后将第一时间回复!
著作权宣告:本文为博主原创或转载文章,欢迎转载,但转载文章之后必须在文章页面明显位置注明出处,否则保留追究法律责任的权利,
标签:

0 评论

发表评论

您的电子邮件地址不会被公开。 必填的字段已做标记 *