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

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

今天,借助此文,分享一下去年引擎优化的一个点,最终优化结果就是在多线程环境下访问某个变量,实现了无锁(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. CF1265B Beautiful Numbers 题解

    Content 给定一个 \(1\sim n\) 的排列,请求出对于 \(1\leqslant m\leqslant n\),是否存在一个区间满足这个区间是一个 \(1\sim m\) 的排列. 数据 ...

  2. Spring实现自定义注解并且配置拦截器进行拦截

    有时候我们会自定义注解,并且需要配置拦截器对请求方法含有该自定义注解的方法进行拦截操作 自定义注解类 NeedToken.java import java.lang.annotation.Docume ...

  3. ByteBuddy代码生成技术

    简介 如官网所说Byte Buddy 是一个代码生成和操作库,用于在Java应用程序运行时创建和修改Java类,而无需编译器的帮助.除了Java类库附带的代码生成实用程序外,Byte Buddy还允许 ...

  4. 【LeetCode】528. Random Pick with Weight 解题报告(Python)

    作者: 负雪明烛 id: fuxuemingzhu 个人博客: http://fuxuemingzhu.cn/ 题目地址:https://leetcode.com/problems/random-pi ...

  5. SOFA 通信

    私有通信协议设计: 我们的分布式架构,所需要的内部通信模块,采用了私有协议来设计和研发. 可以有效地利用协议里的各个字段 灵活满足各种通信功能需求:比如 CRC 校验,Server Fail-Fast ...

  6. sofaBoot

    SOFABoot 和 SOFARPC 都是蚂蚁金服开源的 SOFA 技术栈的开源项目,SOFARPC 只是其 SOFA 技术栈体系(SOFAStack)中的一个 RPC 框架. SOFABoot 也是 ...

  7. CapstoneCS5265设计替代CH7211 |Type-C转HDMI2.0方案|替代CH7211

    龙迅Chrontel的CH7211是一款Type-C转HDMI2.0半导体设备,可通过USB Type-C连接器将DisplayPort信号转换为HDMI/DVI.这款创新的基于USB Type-C的 ...

  8. mybatis练习-获取拥有“普通用户”角色的所有用户信息,要求查询结果除了包含用户自身信息,还包括角色名和角色创建时间。

    实现要求: 获取拥有"普通用户"角色的所有用户信息,要求查询结果除了包含用户自身信息,还包括角色名和角色创建时间. 实现思路: 在用户实体类SysUser中新增角色SysRole成 ...

  9. 关于java的Excel导入导出之easypoi

    导入easypoi相关jar包,这里的easypoi-base的包也可以不倒入,因为easypoi-web中有依赖easypoi-base会自动导入的 <!-- https://mvnrepos ...

  10. 『德不孤』Pytest框架 — 1、Pytest测试框架介绍

    目录 1.什么是单元测试框架 2.单元测试框架主要做什么 3.单元测试框架和自动化测试框架有什么关系 4.Pytest测试框架说明 5.Pytest框架和Unittest框架区别 (1)Unittes ...