仅花200行代码,如何将60万行的RocksDB改造成协程

简介: 采用少量手动修改+自动代码转换的方式,将大型多线程程序改造成协程。在某些重IO、高并发的场景中,帮助业务取得了性能翻倍的效果。
本文摘要:
 采用少量手动修改+自动代码转换的方式,将大型多线程程序改造成协程。在某些重IO、高并发的场景中,帮助业务取得了性能翻倍的效果。

背景

 RocksDB是业界知名的可嵌入的、持久化的KV数据库,它使用一套日志结构的存储引擎,为快速而又低延迟的存储设备做了特殊优化处理。RocksDB使用C++编写,2013年开源,其代码风格成熟稳定,测试覆盖率高,项目中还附带了丰富的性能测试工具。可以说,研究RocksDB原理,并学习其工程实践,是每个做存储和底层系统优化的工程师都绕不开的话题。

 RocksDB本身是多线程模型,支持并发读写。众所周知,协程相比较于线程,在IO繁重或者并发量大时,有着更轻量且更高效的特性。据测试,在系统负载较高时,一次线程切换的时间最高可达30μs;而使用协程,最低仅需十几个ns,相差了几个数量级。

PhotonLibOS(以下简称Photon)是阿里云存储DADI团队开源的一款高性能协程库和IO引擎,我们曾经拿自己用协程实现的IO程序与fio比较过,以及用协程实现的网络程序与Nginx比较过,都取得了更好的性能。恰逢存储内部的某个业务团队正在使用RocksDB,且网络+存储的整体方案遇到了一些性能瓶颈,于是,我们便开始调研用协程改造RocksDB,这是Photon第一次在大规模的成熟软件上进行嫁接尝试。

协程化改造

 先说结论:改造的过程出奇地顺利,没有变更RocksDB的主逻辑,只是手动修改了200多行代码,然后利用一个能够扫描代码并自动转换成协程版本的小脚本,就顺利地完成了编译和运行。

 按照业务需求,我们使用的是2019年的RocksDB 6.1.2版本,总共3175个test case。经过测试,Photon协程版本的RocksDB通过了3170个case,成功率达到99.87% 。经过初步分析,失败的5个都是因为涉及到了线程自身的特性,或者test case里显式地认为自身运行在线程环境里,协程版本无法满足因此失败。且这些失败的case不会影响RocksDB的正常运行。

 在性能方面,利用自带的db_bench工具,在四种典型的KV读写场景下测试对比了Photon版本RocksDB与原版RocksDB的OPS,两者达到了相近的数据;在某些重IO、高并发的场景下,会比原版的性能更好(见后文)。

Photon库介绍

1. 并发模型

 常见的并发模型有:多线程异步回调有栈协程无栈协程。Photon基于有栈协程实现并发。

 如下图,Photon没有依据传统惯例将协程命名为coroutine或者fiber,而是仍然将其命名为thread。多个thread运行在一个vcpu里,而这里的vcpu即是大家熟知的OS线程。每个vcpu同一时间只会运行在一个core上,即使vcpu可能发生跨core迁移,但是这些对于协程(thread)来说是感知不到的。

 之所以这样命名,是因为Photon一直以来都将协程看做是一种轻量级线程,并且在给协程设计API时,也尽量去兼容了POSIX标准和C++ std的语法,以至于如果不是特别提醒,开发者都会很难判断这是一个多线程程序还是协程程序。这些工作也为后面的RocksDB改造埋下了伏笔,使得我们可以轻易地只用少量代码就实现对一个超大型项目的重构。

 在语法上,由于有栈协程不依赖编译器特性(如C++20的asyncawait),协程的切换点被封装到了IO操作或者事件内部,因此对旧代码侵入性较小。

88980f911a30472f88284f80e20a98bd.png

2. 异步事件引擎

 Photon的每个vcpu都包含一个异步事件引擎。所谓事件,其来源可能有以下几个方面:

  • 用户代码的显式调用下,某个协程需要让出处理器(yield),并切换到下一个协程执行单元
  • 跨vcpu迁移(migrate)或唤醒(interrupt)事件
  • 关注的一些fd发生了IO事件
  • 定时器到期,等等

 由于需要确定协程的调用顺序以及IO的执行时机,因此Photon自身除了是一个协程库,也是一个高性能的事件调度器。它支持多种异步引擎,如epoll、io_uring、kqueue等。在5.x以上的高版本Linux内核上,我们推荐使用io_uring引擎。在适当的时机,调度器会通过io_uring的一次系统调用进行批量的IO提交和收割,这种方式减少了系统调用的数量,从而提升了系统的整体性能。

 除了性能以外,普通用户能够感知到的io_uring与epoll的最大变化是,io_uring引擎天然支持异步文件IO。而且经过封装后,它写出来的代码却是同步调用的,不再需要类似libaio的注册与回调,也不需要内存对齐。因此,我们在使用这套IO接口改造RocksDB原先的同步psync IO时没有遇到任何麻烦,只是简单地替换了一下函数名。

3. 同步、锁、和原子操作

 在一个并发的系统中,一般有多种方式实现同步。除了POSIX规定的那些经典的互斥锁、信号量之外,有些语言框架会提出自己的同步语义。比如Golang的channel,实际上是贯彻了它的一种哲学,即“不要通过共享内存来通信,而是通过通信来共享内存”。Photon的互斥锁和信号量基本上延续了POSIX的设计,只不过针对协程场景稍稍进行了改造。我们知道,多线程的同步原语一般都是依赖内核提供的Futex功能,Futex最核心的两个syscall分别是FUTEX_WAKE和FUTEX_WAIT。同理,Photon的mutex实现的很像一个用户态的Futex,也需要利用协程的唤醒和睡眠功能,并且通过链表的方式管理任务。

 关于原子操作的使用,在线程和协程上基本相同。唯一的不同是,如果业务能够确定某个变量只会被单vcpu内的协程使用,则不需要使用原子变量。因为单vcpu本身就是线程安全的.

4. 改造步骤

 下面开始介绍RocksDB改造步骤,主要分为三个方面:

 1. 首先将所有的线程、同步原语等标准C++元素查找替换成Photon的协程版本。这里举一个经典的使用条件变量同步的例子:

bool condition = false;
std::mutex mu;
std::condition_variable cv;

new std::thread([&] {
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::lock_guard<std::mutex> lock(mu);
    condition = true;
    cv.notify_one();
});

std::unique_lock<std::mutex> lock(mu);
while (!condition) {
    cv.wait(lock);
}

 经过替换后,代码变成如下:

bool condition = false;
photon::std::mutex mu;
photon::std::condition_variable cv;

new photon::std::thread([&] {
    photon::std::this_thread::sleep_for(std::chrono::seconds(1));
    photon::std::lock_guard<photon::std::mutex> lock(mu);
    condition = true;
    cv.notify_one();
});

photon::std::unique_lock<photon::std::mutex> lock(mu);
while (!condition) {
    cv.wait(lock);
}

 不难看出,规则很简单,即是在所有的std前面添加了photon::前缀。

 我们之所以这么设计,是为了最大程度地兼容标准,降低用户对新库的学习成本。研究photon::std::thread的代码可以发现,它其实是一个模板类,支持传入普通全局函数、类的成员函数、lambda等。每次new一个thread,就会产生一个协程在后台运行。我们知道,RocksDB本身内置了一个线程池,用于在后台执行compactionflush等任务。经过替换后,它也自然变成了一个协程池。

 此外,在协程场景下,原先的sleep_forwait函数都不再会阻塞调用线程,而是会让出CPU,由调度器决定下一个执行的协程,并执行栈切换。

 2. 第二步,删除所有线程专属的函数调用,比如类似pthread_setname_np这种给线程改名的函数,或者那些用于变更当前线程在内核的IO优先级的syscall。

 3. 最后,将thread_local关键字替换成photon::thread_local_ptr。众所周知,C++11开始引入这个关键字来表示线程局部变量,以便替换原先旧版本编译器的__thread,或者是pthread库提供的specific_key功能。RocksDB重度依赖线程局部变量,每次IO都会查找本线程内缓存的Version数值并进行比较,如果失效了,才会考虑去抢锁或者原子变量,以便获取最新的Version。同理,Photon程序也需要这种局部性的缓存机制,从而每个协程都可以保留一份独立数据。

 代码样例:

// 编译器支持的thread_local关键字
thread_local Value value = "123";

// 替换成新的thread_local_ptr模板类
static photon::thread_local_ptr<Value, std::string> value("123");

db_bench单机性能测试

 为了方便大家验证,我们在github上fork了一份RocksDB的代码,并且往它的6.1.2分支上提了一个Pull Request,包含了上述所说的Photon相关的200多行改动。

 详细执行步骤请查阅photon-bench.md文件,值得注意的是,当前协程程序需要显式指定vcpu的数量,默认设置为8。为了公平起见,测试使用了taskset命令,限制多线程程序最大可以使用的core数量也为8。在并发数上,RocksDB的默认值是64,这个数值对于协程和线程来说会保持一致。

 测试机器为高配云主机,使用6.x内核,gcc 8编译器。1000万个Key,冷加载。测试时间1分钟,最终数据如下(单位:OPS/s)。

9f6e5a92-e07c-49b4-8520-47878137a933.png

 分析可知,当读或者开启同步写时,Photon版与原版的性能基本一致;当关闭同步写时,数据不用即时落盘。由于RocksDB基于LSM的存储引擎可以高效地将随机写转换为顺序写,因此在page cache的参与下,顺序写性能得到极大优化,整个过程转变为CPU密集型任务,协程的特性无法被发挥出来,故性能降低。

 此外,CPU密集型场景下新版性能不如原版还有一个重要的原因就是,新版代码只做了语法替换,而没有进行针对性的调优。举个例子来说,原版多线程在某些情况下会使用asm volatile("pause")进行CPU忙等,那么可否在协程场景下修改为协程的sleep?原版中包含一个core_local模块,它在协程场景下应该如何改造,等等。这里受限于篇幅,不一一列举。

杀手锏:协程化的网络数据库

 看到这里有人可能会问,既然做单机测试时,协程版本的RocksDB貌似并没有很出彩,那么为什么还要做这些改造工作。其实,协程化的最大价值在于发掘一个基于网络的数据库的最大性能,特别是多连接、高并发的场景下

 长久以来,epoll循环一直是实现一个高性能网络服务器的不二之选。不管是类似Java netty、boost asio的异步回调方案,还是类似Golang的协程方案,留给开发者的问题一直是如何在少量的线程里实现高并发的IO。回到RocksDB的场景下,它本身对多线程很友好,但嵌入到网络服务器之后,就不得不引入线程池技术来分发和维护数据读写请求。一边是异步多路复用系统,一边是同步系统,中间的连接器反而容易成为性能瓶颈。

 另一方面,由于RocksDB默认开启了group commit技术,在并发场景下会将多个请求合并成一次IO,因此并发数越高,性能将会越好。协程可轻松支持百万并发,而线程的高并发往往会伴随着严重的竞争。

 我们针对业务团队的需求,将RocksDB嵌入在一个RPC server内,使用较小的KV size,较小的key的总数,同时增大客户端的并发数到1000。准备了两个方案用于测试,分别是:

  • RPC server + 线程池 + 原版RocksDB
  • RPC server + 协程版RocksDB

 结果如下(单位:OPS/s)

ef8345a1-fa5c-4a13-a20d-b140a539c0eb.png

 在这个测试中,为了对多线程更友好一些,我们甚至放开了taskset的限制,让多线程程序可以最多使用到64个物理CPU core。然而随着线程数的增长,线程池方案逐渐遇到了瓶颈。反观协程方案,仅仅使用了8个线程(vcpu),就达到了前者两倍的性能。

总结

 我们通过引入Photon库,花费少量代码,成功地将一个大规模的数据库软件改造成协程。一方面验证了协程在重IO、高并发场景下的理论优势,另一方面也检验了Photon自身的成熟度,输出了DADI技术在存储加速领域的最佳实践。

 需要声明的是,由于我们本身并不是RocksDB的专家,对其的改造仅停留在语法层面。我们相信运行在协程上之后,RocksDB的内部逻辑和调优手段还会存在一些需要调整的地方,以便适应协程、线程、CPU core的三级模型,最大化地增加cache命中率,降低资源争抢概率,解决目前协程版本RocksDB在CPU密集型任务下性能没有完全发挥出来的问题。关于这些工作,就需要后续再慢慢研究了。

 最后补充一句,PhotonLibOS的开源地址是 https://github.com/alibaba/PhotonLibOS 。如果大家对C++协程及高性能IO感兴趣,欢迎前来试用。

相关文章
|
1月前
|
中间件 数据库连接 API
C#数据分表核心代码
C#数据分表核心代码
38 0
|
4月前
|
存储 canal
分库分表优化:引入中间表
【7月更文挑战第12天】
58 10
|
6月前
|
存储 运维 监控
一场67万行代码的应用重构
本文分享了作者对一个架构模糊,拥有67万行代码、46个module的超级应用重构过程中面临的一些问题,还探讨了重构的价值以及技术方案,最后对综合效果进行了分析。
|
11月前
|
NoSQL Cloud Native Redis
【性能优化下】组织结构同步优化二,全量同步/增量同步,断点续传实现方式
【性能优化下】组织结构同步优化二,全量同步/增量同步,断点续传实现方式
|
存储 Oracle JavaScript
300万数据导入导出优化方案,从80s优化到8s(实测)
300万数据导入导出优化方案,从80s优化到8s(实测)
300万数据导入导出优化方案,从80s优化到8s(实测)
|
JavaScript 前端开发 API
【项目数据优化三】长列表数据优化
【项目数据优化三】长列表数据优化
131 0
|
存储 SQL canal
业务单表 读写缓慢 如何优化?
业务单表 读写缓慢 如何优化?
|
SQL 存储 Oracle
Seata 1.6.0 正式发布,大幅度提升存储性能
主要新增特性:支持 oracle 和 pgsql 多主键;支持 seata-server 服务注册多注册中心;支持 dubbo3;支持 jdk17;扩展 update join 语法支持;扩展多种 oracle timestamp 类型;支持 ARM64 镜像。大幅度提升基于 DB 的存储性能。
Seata 1.6.0 正式发布,大幅度提升存储性能
|
存储 消息中间件 SQL
|
关系型数据库 PostgreSQL 移动开发
PostgreSQL 9.6 聚合运算180倍性能提升如何做到? 聚合代码优化OP复用浅析
PostgreSQL 9.6 内核优化之 聚合代码优化OP复用浅析 作者 digoal 日期 2016-10-08 标签 PostgreSQL , 9.6 , 内核优化 , 聚合代码优化 , OP复用 背景 聚合操作指将分组的数据聚合为一个结果输出。 聚合通常用在统
5436 0