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

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

科技

本文摘要:
采用少量手动修改+自动代码转换的方式,将大型多线程程序改造成协程。在某些重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操作或者事件内部,因此对旧代码侵入性较小。

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)。

分析可知,当读或者开启同步写时,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)

在这个测试中,为了对多线程更友好一些,我们甚至放开了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感兴趣,欢迎前来试用。

微信扫码关注该文公众号作者

戳这里提交新闻线索和高质量文章给我们。
相关阅读
每秒1000000000000000000次运算!Cerebras新超级计算机打造世界最大1350万核心AI集群78岁母亲独居一年仅花2700元,儿子拍下她的账本后才知道……年入超$3亿,在墨尔本坐拥10万平仓储!从自己开叉车敲代码,到跻身澳私企200强!华人创始人讲述创业路:我们想做B2B端亚马逊[评测]ASRock AMD Radeon RX 7900 XTX/XT Phantom Gaming OC大爷投1万炒股忘掉密码,15年后发现已经变成50万!又一股市致富故事是因为忘记密码,长期投资+优质公司才是复利的意义...DBC职梦学员已收到BlackRock (US) 2023校招三面邀请!读懂HikariCP一百行代码,多线程就是个孙子!离婚的亢奋《悠悠岁月》(5)买房 | 香港出租屋大改造!我花20万让房子改头换面【Locker Room】橄榄球、女篮、电竞!这次Locker Room运动上新你会选什么?ROG Desktop(i7-8700k/32g/GTX 1080ti/256gSSD/500g SSD/wifi)秋游河溪--看不够的 Erindale 公园Swagbucks 倒赚:注册 Rocket Money 并连接任意银行账户可获得 $15 奖励【01/05:又有了】BlackRock、苹果冻结招聘! 23秋招补录来袭: 这些岗位正在热招!Quant 上岸 | 学员分享BlackRock上岸秘籍!Social Networks for Soccer in China are Diminishing, Fans Saydocker 初体验:docker 部署 wordpress 博客系统自从用了CheckStyle插件,代码写的越来越规范了....虽多次痛失终轮面试,NYU学员终斩获BlackRock量化实习OFFER!【友情转发】MITCSSA年度巨献|一行代码,告别光棍节Knock~Knock~$20.4亿!恭喜你!中奖后才是悲剧的开始...内核代码量不到一万行、GitHub star超5k,国产开源物联网操作系统TencentOS Tiny的探索与实践DBC职梦学员已收到BlackRock (US) 2023校招二面邀请!进来蹭好运!英国锦鲤仅花£100抽中£250万豪宅+£10万现金!小破本也能炼GPT!300行代码搞定,GitHub获6k星,来自特斯拉前AI总监恭喜Emory学员斩获BlackRock (US) 2023 Full-time Offer!0行代码拿210万年薪,ChatGPT催生新型「程序员」岗:工作纯靠和AI聊天老太太活到122岁的秘诀瞧不上 C++ 和 D 语言,国外程序员将 5.8 万行代码迁移到 Jai 语言,到底图什么?用1个月重构了同事写的烂代码,我总结出了15条重写烂代码的经验!在美国北方,荷花(莲藕)怎么过冬?重写 50 万行代码,从 0 自研的云原生 HSTAP 能否成为数据库的未来?| Q推荐君权与神权 信仰的颠覆(六十)恭喜Boston College学员斩获BlackRock(US)2023 Internship Offer!3行代码建模,训练速度提升200%?这款时序开源神器PaddleTS太强了!
logo
联系我们隐私协议©2024 redian.news
Redian新闻
Redian.news刊载任何文章,不代表同意其说法或描述,仅为提供更多信息,也不构成任何建议。文章信息的合法性及真实性由其作者负责,与Redian.news及其运营公司无关。欢迎投稿,如发现稿件侵权,或作者不愿在本网发表文章,请版权拥有者通知本网处理。