借助本文,实现一种在“读多写一”场景下的无锁实现方式

在我们的工作中,多线程编程是一件太稀松平常的事。在多线程环境下操作一个变量或者一块缓存,如果不对其操作加以限制,轻则变量值或者缓存内容不符合预期,重则会产生异常,导致进程崩溃。为了解决这个问题,操作系统提供了锁、信号量以及条件变量等几种线程同步机制供我们使用。如果每次操作都使用上述机制,在某些条件下(系统调用在很多情况下不会陷入内核),系统调用会陷入内核从而导致上下文切换,这样就会对我们的程序性能造成影响。

今天,借助此文,分享一下去年引擎优化的一个点,最终优化结果就是在多线程环境下访问某个变量,实现了无锁(lock-free)操作。

背景

对于后端开发者来说,服务稳定性第一,性能第二,二者相辅相成,缺一不可。

作为IT开发人员,秉承着一句话:只要程序正常运行,就不要随便动。所以程序优化就一直被搁置,因为没有压力,所以就没有动力嘛。在去年的时候,随着广告订单数量越来越多,导致服务rt上涨,光报警邮件每天都能收到上百封,于是痛定思痛,决定优化一版。

秉承小步快跑的理念,决定从各个角度逐步优化,从简单到困难,逐个击破。所以在分析了代码之后,准备从锁这个角度入手,看看能否进行优化。

在进行具体的问题分析以及优化之前,先看下现有召回引擎的实现方案,后面的方案是针对现有方案的优化。

  • 广告订单以HTTP方式推送给消息系统
  • 消息系统收到广告订单消息后
    • 将广告订单消息格式化后推送给消息队列kafka(第1步)
    • 将广告订单消息持久化到DB(第2步)
  • 召回引擎订阅kafka的topic
    • 从kafka中实时获取广告订单消息,建立并实时更建立维度索引(第3步)
    • 召回引擎接收pv流量,实时计算,并返回满足定向后的广告候选集(第4步)

从上面图中可以看出,召回引擎是一个多线程应用,一方面有个线程专门从kafka中获取最新的广告订单消息建立维度索引(此为写线程),另一方面,接收线上流量,根据流量属性,获取广告候选集(此为读线程)。因为召回引擎涉及到同时读和写同一块变量,因此读写不能同时操作。

概述

在多线程环境下,对同一个变量访问,大致分为以下几种情况:

  • 多个线程同时读
  • 多个线程同时写
  • 一个线程写,一个线程读
  • 一个线程写,多个线程读
  • 多个线程写,一个线程读
  • 多个线程写,多个线程读

在上述几种情况中,多个线程同时读显然是线程安全的,而对于其他几种情况,则需要保证其_互斥排他_性,即读写不能同时进行,管他几个线程读几个线程写,代码走起。

thread1
{
std::lock_guard<std::mutex> lock(mtx);
// do sth(read or write)
} thread2
{
std::lock_guard<std::mutex> lock(mtx);
// do sth(read or write)
} threadN
{
std::lock_guard<std::mutex> lock(mtx);
// do sth(read or write)
}

在上述代码中,每一个线程对共享变量的访问,都会通过mutex来加锁操作,这样完全就避免了共享变量竞争的问题。

如果对于性能要求不是很高的业务,上述实现完全满足需求,但是对于性能要求很高的业务,上述实现就不是很好,所以可以考虑通过其他方式来实现。

我们设想一个场景,假如某个业务,写操作次数远远小于读操作次数,例如我们的召回引擎,那么我们完全可以使用读写锁来实现该功能,换句话说_读写锁适合于读多写少的场景_。

读写锁其实还是一种锁,是给一段临界区代码加锁,但是此加锁是在进行写操作的时候才会互斥,而在进行读的时候是可以共享的进行访问临界区的,其本质上是一种自旋锁。

代码实现也比较简单,如下:

writer thread {
pthread_rwlock_wrlock(&rwlock);
// do write operation
pthread_rwlock_unlock(&rwlock);
} reader thread2 {
pthread_rwlock_rdlock(&rwlock);
// do read operation
pthread_rwlock_unlock(&rwlock);
} reader threadN {
pthread_rwlock_rdlock(&rwlock)
// do read operation
pthread_rwlock_unlock(&rwlock);
}

在此,说下读写锁的特性:

  • 读和读指针没有竞争关系
  • 写和写之间是互斥关系
  • 读和写之间是同步互斥关系(这里的同步指的是写优先,即读写都在竞争锁的时候,写优先获得锁)

那么,对于一写多读的场景,还有没有可能进行再次优化呢?

答案是:有的。

下面,我们将针对一写多读,读多写少的场景,进行优化。

方案

在上一节中,我们提到对于多线程访问,可以使用mutex对共享变量进行加锁访问。对于一写多读的场景,使用读写锁进行优化,使用读写锁,在读的时候,是不进行加锁操作的,但是当有写操作的时候,就需要加锁,这样难免也会产生性能上的影响,在本节,我们提供终极优化版本,目的是在写少读多的场景下实现lock-free。

如何在读写都存在的场景下实现lock-free呢?假设如果有两个共享变量,一个变量用来专供写线程来写,一个共享变量用来专供读线程来读,这样就不存在读写同步的问题了,如下所示:

在上节中,我们有提到,多个线程对一个变量同时进行读操作,是线程安全的。一个线程对一个变量进行写操作也是线程安全的(这不废话么,都没人跟它竞争),那么结合上述两点,上图就是线程安全的(多个线程读一个资源,一个线程写另外一个资源)。

好了,截止到现在,我们lock-free的雏形已经出来了,就是_使用双变量_来实现lock-free的目标。那么reader线程是如何第一时间能够访问writer更新后的数据呢?

假设有两个共享资源A和B,当前情况下,读线程正在读资源A。突然在某一个时刻,写线程需要更新资源,写线程发现资源A正在被访问,那么其更新资源B,更新完资源B后,进行切换,让读线程读资源B,然后写线程继续写资源A,这样就能完全实现了lock-free的目标,此种方案也可以成为双buffer方式。

实现

在上节中,我们提出了使用双buffer来实现lock-free的目标,那么如何实现读写buffer无损切换呢?

指针互换

假设有两个资源,其指针分别为ptrA和ptrB,在某一时刻,ptrA所指向的资源正在被多个线程读,而ptrB所指向的资源则作为备份资源,此时,如果有写线程进行写操作,按照我们之前的思路,写完之后,马上启用ptrA作为读资源,然后写线程继续写ptrB所指向的资源,这样会有什么问题呢?

我们就以std::vector为例,如下图所示:

在上图左半部分,假设ptr指向读对象的指针,也就是说读操作只能访问ptr所指向的对象。

某一时刻,需要对对象进行写操作(删除对象Obj4),因为此时ptr = ptrA,因此写操作只能操作ptrB所指向的对象,在写操作执行完后,将ptr赋值为ptrB(保证后面所有的读操作都是在ptrB上),即保证当前ptr所指向的对象永远为最新操作,然后写操作去删除ptrA中的Obj4,但是此时,有个线程正在访问ptrA的Obj4,自然而然会轻则当前线程获取的数据为非法数据,重则程序崩溃。

此方案不可行,主要是因为在写操作的时候,没有判断当前是否还有读操作。

原子性

在上述方案中,简单的变量交换,最终仍然可能存在读写同一个变量,进而导致崩溃。那么如果保证在写的时候,没有读是不是就能解决上述问题了呢?如果是的话,那么应该如何做呢?

显然,此问题就转换成如何判断一个对象上存在线程读操作。

用过std::shared_ptr的都知道,其内部有个成员函数use_count()来判断当前智能指针所指向变量的访问个数,代码如下:

long
_M_get_use_count() const noexcept
{
// No memory barrier is used here so there is no synchronization
// with other threads.
return __atomic_load_n(&_M_use_count, __ATOMIC_RELAXED);
}

那么,我们可以考虑采用智能指针的方案,代码也比较简单,如下:

std::atomic_size_t curr_idx = 0;

std::vector<std::shared_ptr<Obj>> obj_buffers;
obj_buffers.emplace_back(std::make_shared<Obj>(...));
obj_buffers.emplace_back(std::make_shared<Obj>(...)); // write thread
{
size_t prepare = 1 - curr_idx.load();
while (obj_buffers[prepare].use_count() > 1) {
continue;
} obj_buffers[prepare]->load();
curr_idx = prepare;
} // read thread
{
auto tmp = obj_buffers[curr_idx.load()];
// do sth
}

在上述代码中

  • 首先创建一个vector,其内有两个Obj的智能指针,这俩智能指针所指向的Obj对象一个供读线程进行读操作,一个供写线程进行写操作
  • curr_idx代表当前可供读操作对象在obj_buffers的索引,即obj_buffers[curr_idx.load()]所指对象供读线程进行读操作
  • 那么相应的,obj_buffers[1- curr_idx.load()]所指对象供写线程进行写操作
  • 在读线程中
    • 通过auto tmp = obj_buffers[curr_idx.load()];获取一个拷贝,由于obj_buffers中存储的是shared_ptr那么,该对象的引用计数+1
    • 在tmp上进行读操作
  • 在写线程中
    • prepare = 1 - curr_idx.load();在上面我有提到curr_idx指向可读对象在obj_buffers的索引,换句话说,1 - curr_idx.load()就是另外一个对象即可写对象在obj_buffers中的索引
    • 通过while循环判断另外一个对象的引用计数是否大于1(如果大于1证明还有读线程正在进行读操作)

好了,截止到此,lock-free的实现目标基本已经完成。实现原理也也相对来说比较简单,重点是要保证_写的时候没有读操作_即可。

![image-20211212162535172](/Users/lijun/Library/Application Support/typora-user-images/image-20211212162535172.png)

上图是召回引擎做了lock-free优化后的效果图,从图上来看,效果还是很明显的。

扩展

双buffer方案在“一写多读”的场景下能够实现lock-free的目标,那么对于“多写一读”或者“多写多读”场景,是否也能够满足呢?

答案是不太适合,主要是以下两个原因:

  • 在多写的场景下,多个写之间需要通过锁来进行同步,虽然避免了对读写互斥情况加锁,但是多线程写时通常对数据的实时性要求较高,如果使用双buffer,所有新数据必须要等到索引切换时候才能使用,很可能达不到实时性要求

  • 多线程写时若用双buffer模式,则在索引切换时候也需要给对应的对象加锁,并且也要用类似于上面的while循环保证没有现成在执行写入操作时才能进行指针切换,而且此时也要等待读操作完成才能进行切换,这时候就对备用对象的锁定时间过长,在数据更新频繁的情况下是不合适的。

缺点

通过前面的章节,我们知道通过双buffer方式可以实现在一写多读场景下的lock-free,该方式要求两个对象或者buffer最终持有的数据是完全一致的,也就是说在单buffer情况下,只需要一个buffer持有数据就行,但是双buffer情况下,需要持有两份数据,所以存在内存浪费的情况。

其实说白了,双buffer实现lock-free,就是采用的空间换时间的方式。

结语

双buffer方案在多线程环境下能较好的解决 “一写多读” 时的数据更新问题,特别是适用于数据需要定期更新,且一次更新数据量较大的情形。

性能优化是一个漫长的不断自我提升的过程,项目中的一点点优化往往就可以使得性能得到质的提升。

好了,今天的文章就到这,我们下期见。

作者:高性能架构探索

关注公众号【高性能架构探索】,第一时间获取干货内容;回复【pdf】免费获取计算机必备经典书籍

性能优化-使用双buffer实现无锁队列的更多相关文章

  1. 双buffer实现无锁切换

    大家好,我是雨乐! 在我们的工作中,多线程编程是一件太稀松平常的事.在多线程环境下操作一个变量或者一块缓存,如果不对其操作加以限制,轻则变量值或者缓存内容不符合预期,重则会产生异常,导致进程崩溃.为了 ...

  2. boost 无锁队列

    一哥们翻译的boost的无锁队列的官方文档 原文地址:http://blog.csdn.net/great3779/article/details/8765103 Boost_1_53_0终于迎来了久 ...

  3. 高性能无锁队列 Disruptor 初体验

    原文地址: haifeiWu和他朋友们的博客 博客地址:www.hchstudio.cn 欢迎转载,转载请注明作者及出处,谢谢! 最近一直在研究队列的一些问题,今天楼主要分享一个高性能的队列 Disr ...

  4. java轻松实现无锁队列

    1.什么是无锁(Lock-Free)编程 当谈及 Lock-Free 编程时,我们常将其概念与 Mutex(互斥) 或 Lock(锁) 联系在一起,描述要在编程中尽量少使用这些锁结构,降低线程间互相阻 ...

  5. Erlang运行时中的无锁队列及其在异步线程中的应用

    本文首先介绍 Erlang 运行时中需要使用无锁队列的场合,然后介绍无锁队列的基本原理及会遇到的问题,接下来介绍 Erlang 运行时中如何通过“线程进度”机制解决无锁队列的问题,并介绍 Erlang ...

  6. 【DPDK】【ring】从DPDK的ring来看无锁队列的实现

    [前言] 队列是众多数据结构中最常见的一种之一.曾经有人和我说过这么一句话,叫做“程序等于数据结构+算法”.因此在设计模块.写代码时,队列常常作为一个很常见的结构出现在模块设计中.DPDK不仅是一个加 ...

  7. 无锁队列--基于linuxkfifo实现

    一直想写一个无锁队列,为了提高项目的背景效率. 有机会看到linux核心kfifo.h 原则. 所以这个实现自己仿照,眼下linux我们应该能够提供外部接口. #ifndef _NO_LOCK_QUE ...

  8. folly无锁队列,尝试添加新的函数(续)

    基于上一篇文章,dropHead取出节点后,删除节点,会出现内存访问的问题.按照这个逻辑,如果将移出的节点保存到一个无锁队列中,然后在需要节点的时候,从这个备用的无锁队列中取出节点,那么应该就可以避开 ...

  9. 锁、CAS操作和无锁队列的实现

    https://blog.csdn.net/yishizuofei/article/details/78353722 锁的机制 锁和人很像,有的人乐观,总会想到好的一方面,所以只要越努力,就会越幸运: ...

随机推荐

  1. Java实现HttpGet和HttpPost请求

    maven引入JSON处理jar <dependency> <groupId>com.alibaba</groupId> <artifactId>fas ...

  2. java 多线程 线程组ThreadGroup;多线程的异常处理。interrupt批量停止组内线程;线程组异常处理

    1,线程组定义: 线程组存在的意义,首要原因是安全.java默认创建的线程都是属于系统线程组,而同一个线程组的线程是可以相互修改对方的数据的.但如果在不同的线程组中,那么就不能"跨线程组&q ...

  3. uniapp+nvue实现仿微信App界面+功能 —— uni-app实现聊天+语音+视频+图片消息

    基于uniapp + nvue实现的uniapp仿微信界面功能聊天应用 txim 实例项目,实现了以下功能. 1: 聊天会话管理 2: 好友列表 3: 文字.语音.视频.表情.位置等聊天消息收发 4: ...

  4. TensorFlow.NET机器学习入门【3】采用神经网络实现非线性回归

    上一篇文章我们介绍的线性模型的求解,但有很多模型是非线性的,比如: 这里表示有两个输入,一个输出. 现在我们已经不能采用y=ax+b的形式去定义一个函数了,我们只能知道输入变量的数量,但不知道某个变量 ...

  5. Linux(Centos)安装maven

    下载maven安装包 官网地址:http://maven.apache.org/download.cgi 也可以使用 https://yvioo.lanzous.com/ivNVrfcs6ja 把文件 ...

  6. c++之快速排序改进(随机值)

    数量少(5~25),插入排序很高效 一个影响快排效率的因素就是: 基准值的选择 本文将演示一种随之法的快排 改进前 void quick_sort5(int arr[], int low, int h ...

  7. 第一篇CSDN博客,大家好!

    大家好,我是负雪明烛! 我这昵称的来源是喜欢一句很有意蕴的古诗--苍山负雪,明烛天南. 我喜欢这句诗,很多的账号都用了这个"负雪明烛"的昵称,如果大家在其他地方看到叫这个名字的人, ...

  8. Robot(hdu5673)

    Robot Accepts: 92 Submissions: 188 Time Limit: 12000/6000 MS (Java/Others) Memory Limit: 65536/65536 ...

  9. [Guide]Google Python Style Guide

    扉页 项目主页 Google Style Guide Google 开源项目风格指南 - 中文版 背景 Python 是Google主要的脚本语言.这本风格指南主要包含的是针对python的编程准则. ...

  10. C#WPF数据绑定模板化操作四步走

    前言:WPF数据绑定对于WPF应用程序来说尤为重要,本文将讲述使用MVVM模式进行数据绑定的四步走用法: 具体实例代码如下: 以下代码仅供参考,如有问题请在评论区留言,谢谢 1 第一步:声明一个类用来 ...