Rust China Conf 为我国本乡范围的Rust技能大会,致力于成为我国 Rustaceans 交流的盛宴,为国内的 Rust 开发者和企业供给一次充沛的效果展示、技能同享、才能提高、行业资讯交流、企业人才储备建设的时机。
蚂蚁集团开发工程师夏锐航作为同享嘉宾出席大会,为咱们同享CrersDB中的一种并发模型实践,以下是发言实录:
“
咱们好!接下来为咱们带来这次的同享,Rust在CeresDB中的一种并发模型实践。
首要介绍一下我自己,我叫锐航,是蚂蚁集团的开发工程师,这是我的GitHub ID @waynexia。
今天的同享会分为三个部分,首要介绍咱们的产品CeresDB,然后是咱们在实践中的并发模型实践,最终是咱们的社区和团队的介绍。
先经过两个比方来了解一下咱们所处理的面临的场景,这边找了两个图,一张是Wiki上找的股票K线图,其他一个是Grafana的大盘截图,咱们或许对这两个图都比较熟悉,它们有一个很显着的共同点便是这两张图都带有很强的时刻戳特点,便是这两张图的横轴。
其实不只是这儿列出来的两张,咱们接触到的大部分数据其实都显现或许隐示了包括的时刻戳这一列,这些数据的产生一般是随时刻进行,但它便是十分频频并且继续的。并且很少有更新或许删去,所以有的时分时刻戳这一列被隐去,所以咱们的追加被表现成了更新。
这些数据在查询的时分,一般可以分为两种状况,一个是涉及到许多数据核算聚合的,或许是由于长时刻分析,一个长时刻跨度,或许是许多组件,涉及到数据所产生的恳求。其他一个是对最新数据的频频读取,比方获取最新的大盘走势,或许是机器所谓告警等等。
这两个场景,一个是对数据核算功能有十分重的依赖,其他一个是对数据实时性有很高的要求,咱们传统的分析型数据库或许对高频写入或许是数据实时性支撑得不是很好,而专心于时序范畴的产品,或许又无法去很高效地处理许多数据的核算。
那咱们构建了CeresDB期望能处理这个问题,左面这个是咱们项目的介绍,咱们期望将CeresDB打形成一个能一起处理两种负载的数据库。除掉这个最首要的特征,咱们还期望CeresDB可以有更丰厚的特性,比方可以依据数据特征主动地调整一些功能参数,削减运用的负担。其他,支撑完好的SQL查询,一起也可以在此根底之上积极拥抱社区生态,去很好地支撑不同范畴一个细分的查询语言,比方说Metric控范畴盛行的Prometheus PromQL等等。未来也期望可以和更多的产品产生紧密的联系。最终,作为咱们在内部生产上运用的体系,可靠性和水平拓宽才能也是十分重要的一环。
下面介绍一下咱们的Roadmap,现在有两个里程碑,第一个里程碑是最近的release,这是咱们第一次的release,供给了根底的集群布置才能。咱们计划在本年10月达到第二个里程碑,支撑比方方才所说的PromQL的查询才能,现在正在进行POC的混合存储格局,也期望可以完结开发,一起供给更多的WAL生态选项,现在咱们在第一个release中供给的是依据OceanBase的OBKV所实行的WAL。并且期望能在现在静态路由的集群形式下供给更进一步的水平拓宽才能,支撑完备的集群形式。\
除此之外,在未来咱们还期望可以供给依据Python的杂乱UDF才能,支撑大数据生态。一起可以有更灵敏的自适应调度和索引才能,来适应更杂乱的查询场景。并且可以对接更多的云厂商,在更多的云进步行布置。
咱们CeresDB期望参加一个开放的社区,所以咱们假如有想要的feature也欢迎一起来评论,有任何问题也可以在库房提,这是咱们库房的地址:github.com/CeresDB/ceresdb。
介绍一下咱们在生产中的实践。这个一种并发模型,其实便是从Thread Per Core模型中衍生出来的一种办法,去掉了Thread Per Core中与物理中心绑定的特点,这叫在单个作业线程下的编码形式。
首要看一下CeresDB中的一个比方,咱们有一个Write Worker是CeresDB中一个担任处理写恳求的组件,上层的恳求转化为写恳求之后交给Worker履行,比方说刺进数据或许更改表结构等等。一起也有一些后台处分的作业,比方说压实或许刷写,便是后台的数据归档。
咱们以表为单位,将每张表分配到一个Write Worker,一个Write Worker上或许会担任多个表,是一个一对多的联系。这张表的一切写操作都由这个Worker来完结,默许是依照核数来初始化咱们的Worker的,每个Worker之间的负载和资源都是独立的,包括Worker自身也会独占一个线程。以及Worker所担任到的那些表所涉及到的资源,在逻辑上也是不同享的。
具体来说,具体到咱们的场景来说便是每张表或许下面会有文件、WAL,这些在资源在逻辑上是独立的。
经过这样的操作,咱们可以得到两个显着的好处,首要是关于每张表来说,前后台的写入操作它是串行化的,所以做一些简略的逻辑就可以防止写写冲突的问题,可以简化咱们逻辑状况办理的代码。一起Worker将表,便是作业负载,以及表后所隐藏的资源都是独占的,可以去削减资源竞赛的状况。
当然这个完全串行化是一个比较简略的场景,实际上还会有一些杂乱的逻辑,比方说一些不关心写入顺序的操作,咱们就会把它Detach到后台来履行,疏忽它与其他操作之间的写入顺序等等。
刚刚所说在现在咱们大多数状况底下,尽管在逻辑上做了区别,咱们底下仍是同一个物理资源,比方说同一块磁盘、同一块网卡之类的。咱们在逻辑上独占,独占的或许是一个文件集比或许是一个SQL的链接,经过这个笼统可以削减上层资源互斥的逻辑,并且在物理资源拓宽的时分可以很便利的Scale out,比方说简略的加磁盘、加网卡,由于现已做了逻辑资源的隔离,所以可以比较便利地去进行拓宽。
这是在CeresDB中的一个场景,作为一个通用的手法来说,我简略总结了几点可以带来的好处。首要在这种形式下,一切的状况办理只会在一个线程内被拜访,在编码的时分可以简化这个状况办理的逻辑。咱们资源也是相同,各个资源在逻辑层面独立,可以削减资源竞赛的状况。
然后也充沛利用了Rust的协程特性,其实便是async-await的异步编程,削减线程的上下文切换,就或许之前是咱们有若干个线程,这个线程数量是大于核数的,那么操作体系就会把咱们的线程去进行一个调度,调度到不同的核上来履行。咱们现在假如把一切的使命都现已预先分配到线程里面,就可以将线程的切换简化为一个协程的切换。
一起假如咱们可以再进一步将这些作业线程与中心绑定起来,构成一个ThreadPerCore的形式,还可以进一步提高CPU的亲和性等等,以及Cache的有效性。在后面还会说到一些小的地方。
需求留意,这儿所说到的各种东西都是有适用场景,关于这种办法所具体的优势和局限性,最终在后面进行具体评论。
咱们接下来先介绍一下它能带来的几点优势,首要是咱们的一个调度模型,从之前刚刚说的由操作体系完结的抢占式调度,变为了各个协程之间进行的一个协作式调度。
咱们可以来对比一下,左面是一个抢占式调度的一种常见的状况,那写出这种代码,其实便是咱们把若干个使命一起放进一个线程继续履行,或许这些使命之间是会涉及到同一个状况、同一个逻辑资源,那其实这些使命是互斥的。
比方说这儿有两个线程一起在履行两个使命,而这两个使命都是涉及到同一个资源,便是这儿x和y一个笼统。它们或许会被调度到一个核上来履行,在这儿或许先履行第一个线程,履行了两句,然后操作体系把第二个线程调度到这个核上,把第一个线程调度走,第二个线程就履行了两句,这样交替履行。从一个线程来说,它感知到自己是一向在履行的,那么它写完y=1然后去读y的时分,它或许会发现自己y怎样变了。所以在这种形式下其实是需求一个数据同步的,或许是锁或许原始变量之类的手法,来保护好自己的状况和改变。
假如在右边,这两个使命,咱们会把它分配到同一个线程内,便是说涉及到同一个资源的,咱们会按一个线程去分配它。那它假如运行在同一个线程,就算这个线程自身被操作体系调度走,对使命来看,它其实是一向运行的,由于整个线程被调度走,那也没有其他使命可以一起来履行。这时分咱们调度权实际上尽管操作体系仍是可以对咱们线程进行调度,那咱们逻辑上的调度权实际上便是由使命自身或许使命之间来进行一个协作式调度。
比方说这儿假如我的Task1它不想被调度走,那它可以挑选不交出自己的履行权,它把自己四句话悉数履行完,然后再由Task2来履行,这样就可以有一个定式的履行形式,Task1就可以确认自己在写y和读y之间,这个y是不会产生改变的。这样就可以削减简化咱们的状况办理,就不需求去锁上一个临界区来确认这个时分某个变量只会由我来独占拜访之类的,由于它现已在咱们模型层面现已确保。
在这个模型下,更进一步可以想到咱们其实不再需求原子操作了,或许换句话说来说,咱们一切操作都是原子的,可以不受限于硬件的限制,对恣意多的数据进行原子操作,由于只要咱们不交出履行权,那咱们这个操作就可以一向在进行。
这一点带来最直观的改变便是咱们东西结构的改变,比方说常用的Arc或许就变成Rc,一个原子类Atomic Reference Count 变成一个 primitive reference count(RC) 便是咱们可以不需求原子操作。一起咱们有一个许多运用了原子操作的,便是lock free的这种结构,咱们也不需求掉头发去写这种东西了,咱们或许就用普通的单线程结构就可以完结,还使咱们可以确保这个结构、这个状况可以在同一时刻不会有其他人来拜访。
不过也不是说完全可以去掉lock free的结构或许是lock,毕竟咱们状况之间、使命之间仍是需求进行状况同步的,完全抹掉一切的同享状况是十分困难的,咱们一般是在CeresDB中挑选体系的一部分来完结这个模型,是一个工程上的取舍。
除掉不需求原子操作,上面说到的Arc到Rc的改变便是Rc,或许是没有Atomic的东西,它就有一个特征,便是没有Send这个auto trait,可以看到在规范库里面是显现的给Rc完结了一个Send,Send便是一个Negative的implement。
在接下来讲之前,咱们先回想一下Send是什么,Rust中关于Send的界说,简略来说便是用来表明一个结构,用来描绘一个结构能否安全地被多个线程所持有。这个trait十分常见,必定大部分都见到,或许咱们自己界说自己的trail的时分,也给它加上了Send,加上async,或许加上static这种,先加上再说的这些Out-trail,以及咱们又遇事不决,用Arc Mutex来堵住编译器关于生命周期、Send之类的报错的做法。
并且现在许多的根底设施都是在Send这一条件被满足的状况下来完结的,比方或许咱们看看自己的代码,或许大部分都要求了Send,或许是像pub fn spawn这个办法也是对spawn进行了future以及future的成果,也要求了一个Send。
可是在咱们现在所评论的这个模型中,或许Send就不是那么常见了,由于咱们大部分的作业都是在一个线程中完结,没有这样一个结构或许是一个使命发送到多个线程的需求,自然就不需求Send这个束缚,由于咱们在一开始就不会在多个线程中同享它们。
那么少了这个束缚之后,不仅是常用的东西类或许会产生改变,还有他们背面所隐含的编程习气或许也会不同。
最终一点便是咱们可见性的获取,咱们的overhead会削减。比方之前或许常见的是用Mutex 或许 RwLock,这个时分或许是要去处理多个资源在一起拜访的状况。在这儿咱们现已从结构上可以确保咱们只需求去获得语义上的一个内部可变性就行了,咱们理论上是可以把一切额定的运行时检查都去掉,一起还可以确保代码的安全。
其他便是咱们一切的作业负载都在一个Work thread中完结,那这个Work thread就包括作业负载一切的上下文,可以很便利地去依据这一点来完结资源调度和资源控制等等。
好处讲了这么多,最终还有一个but,便是尽管一起只会有一个使命在进行,不存在并发状况的修正,可是咱们仍是不可以完全丢掉锁,由于在不同的使命之间同步状况仍是需求。即便使命之间是协作式调度,为了功能咱们或许仍是会挑选将使命进行交叉进行,比方说或许这个使命它在等IO,那这时分可以先交出一切权,它去后台等IO,让其他使命的核算先开始。这个时分咱们或许有些操作履行到了一半,为了功能便利就要交出一切权,是要经过相似锁,仍是要锁的机制来告诉其他使命,这个资源现在不可以被进行修正。
不过同样是锁,单线程下的锁会略微简略一些,不需求条件变量这种手法就可以完结,比方说最朴素的便是自旋锁,或许是更高功能的话,就可以和Runtime相结合来干与Runtime调度来完结。
其他一点便是这个作业线程中不可以有任何的Blocking行为,这个尽管在往常的异步中也是需求留意的一点,可是同样是产生Blocking操作,咱们这儿只有一个线程,假如把这个线程Block住了,带来的结果是会比往常更麻烦一些,其他线程锁只有一个使命,其他的使命会被调度到其他线程上去履行。可是在这儿,咱们把这个线程锁住之后,这个线程相关的一切使命或许都无法进行。所以同样是一个留意事项,可是在咱们这个场景下或许会形成更严峻的结果。
那是不是这种状况下就完全不能进行堵塞式的IO了呢?
咱们假如是纯异步的IO理论上是没有问题的,是可以恣意履行的。那假如不是,一个常见的办法便是开一个或许多个线程来专门履行这些Blocking的操作,主线程把这些操作移送出去,来确保自己自身仍然是异步的,由于咱们或许有些体系仍是由于特别的原因无法运用异步的IO,只能运用堵塞式的IO,那这种时分这个手法或许就会是必要的。
好处讲了这么多,价值是什么呢?
这儿列了两点我认为比较重要的价值,一个是传染性,其他一个是做强制分片的需求。首要说传染性,这儿指的是Send这个trait的传染性,我刚刚所说整个体系变为单线程的模型改动会比较大,可是假如只改一部分的话也会有问题。
咱们先来回想一下Rust中是怎样处理auto trait的,这儿就以Send为例,假如一个结构一切的field都是Send的,编译器就会主动给这个结构也推导成Send。反过来假如这个结构中有一个或许是多个field,它是Unsend的,就它没有完结Send,那编译器就会把这个Unsend推导到这个结构上。假如一个结构中任何一个地方是Unsend,那这个结构就会被推导为Unsend,这个结构也可以是Rust主动生成的future,经过async语法来生成的future,它自身也是一个匿名的结构体。
假如是这样,Unsend或许就会跟着这种函数调用扩散到整个体系。但这显然是不可承受的,由于这就强制要求咱们把整个体系改形成够接纳Unsend。这种时分为了防止把Unsend扩散得到处都是,咱们可以让两部分经过channel来交流,把之前的显现函数调用包装成一个个task或许是request,就相似于模仿Rpc的感觉,经过这种办法来构建一个Unsend Boundary,把两部分分脱离,从而防止这个问题。
咱们CeresDB中的Write,就方才说的Write,也是经过上层所封装好的Write request来履行操作,上层是把自己所收到的写恳求包装成一个request,然后经过channel发送给底下对应的Worker,然后这个Worker履行完再把成果发送回去,这样就防止了一个显现函数的调用,也就防止了这个类型扩散到其他的部分,而是只把它局限在咱们的Worker中。
其他一个问题便是强制分区,由于咱们期望各个线程之间尽量削减交流来削减咱们的额定开支,所以可以最好便是预先将作业负载和资源都进行区分,比方CeresDB是依照表来进行partition,或许其他体系还依照ID或 Key range之类的。对一些体系来说或许分区是比较简略的,可是关于其他一部分体系,它或许就比较难以找到一个合适的分区办法,那咱们这种单线程的编程模型或许是Threadpoolctl不太合适。
咱们分区也要留意粒度的问题,最好可以确保每个分区之间不会相差太多,或许是每个分区元素的力度也不会相差太大,由于咱们涉及到分区的体系,一般都会遇到分区负载不均的状况,所以咱们为了可以灵敏地调度,依据负荷来动态地调整partition,所以就要求每个分区的单元不会太大。
其他,还要求自身可以履行这个Repartition,这个其实大部分是工程上的问题,关于线程自身来说,它所代表的是一个核算资源,它是无状况的,所以可以很便利地进行区分,很便利地把比方说这条指令,在这个线程或许这个核上履行,把它调度到其他一个线程或许其他一个核上履行,这个是比较便利的。
可是咱们使命一般背面还对应了咱们的状况和资源,咱们状况和资源其实相关于咱们的作业负载,其实是更难以调度的,特别是假如咱们的状况是涉及到耐久化的,比方说咱们会把什么东西写到磁盘上,那咱们这个下在这个磁盘,其他一个partition在其他一个磁盘,这个时分或许会要有一个比较杂乱的逻辑。一起上层的分组路由也要确保路由的正确性。
那么假如咱们的场景也很合适,想体会一下这种编码模型,或许是享受一下它带来的功能提高,那要怎样样开始开发呢?由于咱们这个是要求一个纯异步的编程,那就离不开async-await。
先来看一下最重要的Runtime,现在一些常用的Runtime都有供给不要求Send的接口,比方说tokio和futures,都有spawn local的办法,可以看到和刚刚的spawn的区别便是咱们这儿函数签名上不再要求Send这个bound了。
其他也有一些专门为Threadpoolctl规划的运行时,比方说glommio和monoio,不过这两个除了Threadpoolctl之外还绑定了io_uring作为IO接口,尽管十分合理,由于咱们自身便是最好是可以和异步IO相结合运用,可是也让它没有那么灵敏,由于作为一个还比较新的体系特性,或许许多存量的服务器体系版别还没有晋级,或许就会需求用到咱们刚刚说到的那些手法去做一个适配。
在咱们的实践中,也有遇到这个问题,为了可以兼容非堵塞的IO接口,需求用到前面说到Blocking的线程办法,或许还要再做一些hike来适配,把它迁移到常用的Runtime上。
还有规范库中的一些东西类也可以派上用场,比方说线程部分存储TLS它可以来替代一些全局变量,由于咱们大部分的作业或许核算的整个生命周期内,都会只在一个线程中呈现,所以假如可以把这些状况进行区分,也可以更进一步削减状况同享的开支。
其他,在刚刚说到咱们运用tokio来模仿Threadpoolctl Runtime的时分,也有运用到tos,便是把每个线程拥有一个正常的tokio Runtime,然后把它放在tos里面来模仿一个Threadpoolctl Runtime。
此外还有Cell类,首要便是用来获取内部可变性的,用来替代锁,在无序竞赛的场景下一个比较开支更小的内部可变性的获取。
不过这儿RefCell其实还涉及到一个动态运行时的检查,便是它会检查我这个是否有其他的也一起持有这个可变性,有的话它就会导致panic。事实上在咱们这个模型中,就像方才说到的相同,是可以在规划的时分就完全规避掉这个运行时的检查的,所以咱们理论上RefCell还可以更进一步供给一个确保。
除了这些,还有一些往常的异步编码的留意事项也会变得很重要,比方前面说到的Blocking操作,或许Clippy中的一些Lint。比方Clippy中这个Lint,咱们或许会用RefCell来许多替代锁,所以需求留意await的时机,以这个fn函数为例,比方咱们在第一行获取了x的可变引证,可是在第二行把它await走,这个await相当于交出了咱们这个函数的履行权,这个时分是可以把其他的使命调度过来的。假如有第二个fn函数在这儿进入了,它在尝试去获取x的可变引证,这个时分就相当于一个可变引证被两个函数所持有,这儿就会导致一个panic。所以咱们需求留意await的时机,await其实便是隐式地会交出履行权。
除了这些,还有许多往常异步编码的时分那些留意事项,在这儿或许便是会变得愈加重要,由于违反这些事项,或许会带来一些愈加严峻的结果。就比方说刚刚的Blocking,或许是这儿的await holding refcell。
尽管现在现已有许多根底的办法可以让咱们相对完好地完结这一个特性,或许说这一个模型,可是咱们仍是可以从生态或许是根底库的角度做得更好。比方说在规划的时分就考虑可以确保独占拜访这一条件的根底类,比方说从channel到Runtime等等,还可以更进一步地压榨功能,以及现在或许到处都要求的Send,那是否在规划的时分咱们再加上Send这个trait bound的时分,想一想这儿是否真的需求。
总结来说咱们现在可以很根底的完结,或许说完结一个相对完好的模型,可是在我看来或许仍是有很大的提高空间。
最终介绍一下咱们的社区和团队,咱们的GitHub安排CeresDB,这是咱们主库房的地址,咱们假如有任何的提问或许是主张或许是想要的功能,都可以在这边去开issue和咱们评论,咱们往常像开发相关的交流会在Slack进步行,你也可以参加咱们的微信或许钉钉群来获取信息。
”