这篇是从 Rust China Conf 2022 的 talk 整理来的(校对听写转录稿好麻烦……
(还没校对完)
介绍一下单线程模式两个在生产中的例子,HelixDB 是一个Thread Per Core模型的KV存储,CeresDB 中也有部分逻辑在单线程模式下工作。
Examples
对以一张表来说,写操作本身就有互斥的属性,所以我们以表为单位,将每张表对应到一个Write Worker,一个Write Worker上可能会负责多个表,是一个一对多的关系,这张表的所有写操作都由对应的Worker来完成。默认是按照核数来初始化Write Worker,每个Write Worker之间的负载和资源都进行了隔离。包括Worker本身会独占一个线程,以及Worker所负责到的那些表涉及到的资源在逻辑上也是不共享的。Write Worker的主逻辑实际上就是不断地从一个channel中接收任务并执行。
这样的操作能带来两个好处,首先对于每张表来说,后台的写入操作是串行化的,所以做一些简单的逻辑就能够避免写写冲突的问题,能够简化状态管理的代码。同时Write Worker将表以及表后所隐藏的资源都预先分配并独占,可以去减少资源竞争的情况。
当然这个完全串行化是一个比较简单的场景,实际上还会有一些复杂的逻辑,比如说一些不关心写入顺序的操作就可以detach到后台来执行,忽略它与其他前台操作之间的顺序等。
刚刚所说在目前我们大多数情况底下,虽然在逻辑上做了区分,但底下还是同一个物理资源,比如说同一块磁盘、同一块网卡之类的。在逻辑上独占,独占的可能是一个文件路径或者是一个SQL的链接,通过这个抽象能够减少上层资源互斥的逻辑,并且在物理资源拓展的时候能够很方便的Scale out,比如说简单的加磁盘、加网卡就行,因为已经做了逻辑资源的隔离,所以能够比较方便地去进行拓展。
Image
这是单线程模型在CeresDB中的一个应用场景,作为一个通用的手段,我简单总结了几点它能够带来的好处:
- 首先在这种模式下,所有的状态只会在一个线程内被访问和修改,在编码的时候可以简化状态管理的逻辑。资源也是一样,各个资源在逻辑层面独立,能够减少资源的竞争开销。
- 充分利用了Rust的协程特性,其实就是利用async/await来减少线程的上下文切换。可能之前是有一堆线程,并且线程数量是大于核数。操作系统就会对我们的线程进行调度,分时调度到不同的核上来执行。我们现在如果把所有的任务都已经预先分配到线程里面,就可以将线程的切换简化为一个协程的切换。并且由更理解具体使用情况的我们自己来代替操作系统进行调度。
- 如果能够再进一步将这些工作线程与核心绑定起来,形成Thread Per Core的模式,还可以进一步提高CPU的亲和性以及Cache的命中率等。
Image
Pros. & Cons.
不过需要注意的是,这里所提到的各种方法都有适用场景,接下来详细讨论一下关于这种方式具体的优势和局限性。
It gives
接下来详细介绍一下它能带来的几点优势,首先是调度模型方面,从之前提的由操作系统进行的抢占式调度,变为了各个协程之间进行的协作式调度。
来对比一下,左边是一个抢占式调度的一种常见的情况。产生这种情况其实就是我们把若干个任务同时放进一个线程去执行,可能这些任务之间是会涉及到同一个状态或者逻辑资源,但是这些任务是互斥的。
比如说这里有两个线程同时在执行两个任务,而这两个任务都是涉及到同一个资源,假设两个任务分别叫x和y。它们可能会被调度到一个核上来执行,在这里可能先执行第一个线程,执行了两句,然后操作系统把第二个线程调度到这个核上,把第一个线程调度走,第二个线程就执行了两句,这样交替执行。从一个线程来说,它感知到的是自己是一直在执行的,但是在写完y=1然后去读y的时候,却会发现y变了。所以在这种模式下其实是需要一个数据同步的,可能是锁或者原子变量之类的手段,来保护好状态和变更。
而协作式调度则是由各个任务主动交出执行权,比如说这里如果Task1不想被调度走,那它可以选择不交出自己的执行权,而把自己四条命令全部执行完,然后再由Task2来执行,这样就能够有一个确定性的执行模式。Task1能够确定自己在写y和读y之间,这个y是不会发生变化的。这样能够减少简化我们的状态管理,不需要去锁上一个临界区来确保变量或者资源地独占访问之类的,因为这一点已经在我们模型层面已经保障。另外也能对系统的延时性能有一定提升。
Image
在这个条件下,更进一步可以想到其实不再需要原子操作了,或者换句话说来说,所有的操作都是原子的,可以不受限于硬件的限制,对任意多的数据进行“原子”操作,因为只要不主动交出执行权,那这个操作就可以一直以一种确定的顺序执行。
这一点带来最直观的变化就是我们工具结构的变化,比如说常用的Arc
(Atomic Reference Count)可能就变成Rc
(primitive Reference Count),就是不需要原子操作的引用计数。同时还有一类大量运用了原子操作的lock free这种结构,也不需要掉头发去写这种东西了,用普通的单线程结构就能够完成。还能够保证这个结构、这个状态在同一时刻不会有其他人来访问。
不过也不是说完全能够去掉lock free的结构或者是lock,毕竟状态之间以及任务之间还是需要进行状态同步的,完全抹掉所有的共享状态是非常困难的,在两个实践中都是选择系统的一部分来实现这个模型,算是一个工程上的取舍。
Image
除了不需要原子操作,还有一个特征就是没有Send
这个auto trait,可以看到在标准库里面是显示的给Rc
实现了一个!Send
。
在接下来讲之前,先回忆一下Rust中关于Send
的定义,简单来说就是用来表示一个结构能否安全地被多个线程所持有。这个trait非常常见,大部分地方都能见到,或者自己定义自己的trait的时候,也给它加上了Send
,加上Sync
,或者加上'static
这种,先加上再说的这些auto trait,以及遇事不决,用Arc<Mutex<T>>
来堵住编译器关于生命周期、Send之类的报错的做法。
而且目前很多的基础设施都是在Send
这一条件被满足的情况下来实现的,比如可能我们看看自己的代码,可能大部分都要求了Send
,或者是像pub fn spawn这个方法也是对spawn进行了future以及future的结果,也要求了一个Send
。
但是在我们现在所讨论的这个模型中,可能Send
就不是那么常见了,因为我们大部分的工作都是在一个线程中完成,没有这样一个结构或者是一个任务发送到多个线程的需求,自然就不需要Send
这个约束,因为我们在一开始就不会在多个线程中共享它们。
那么少了这个约束之后,不仅是常用的工具类可能会发生变化,还有他们背后所隐含的编程习惯可能也会不同。
Image
最后一点就是获取可见性的开销会减少。比如之前可能常见的是用Mutex
或者RwLock
,需要处理多个资源可能被同时访问的情况。但在这里可以从结构上避免这种情况的出现,只需要去获得语义上的一个内部可变性就行了。理论上是可以把所有额外的运行时检查都去掉,同时还能够保证代码的安全。
另外就是所有的工作负载从开始到完成都在同一个worker thread中,这个worker thread包含所有的上下文,从这一点出发来方便地实现任务调度和资源控制等功能。
Image
前面这么多虽然,最后还有一个但是:虽然同时只会有一个任务在进行,不存在并发状态的修改,但是还是不能够完全丢掉锁,因为在不同的任务之间还是需要同步状态。为了性能通常会选择将任务进行穿插进行,比如说任务A在等IO的时候可以交出所有权,它去后台等IO,让任务B的计算先开始。这个时候我们可能有些操作才执行到了一半,所以要通过锁的机制来告诉别的任务,这个资源现在不能够被进行访问/修改。
不过同样是锁,单线程下的锁会稍微简单一些,不需要条件变量之类的手段,比如说最朴素的就是自旋锁(现在你也许是知道你在干什么的)。不过最好还是和Runtime相结合来干涉Runtime的调度(的确能在user-land能做到这些!)。
另外一点需要注意的就是这个工作线程中不能够有任何的blocking行为,虽然在平常的异步中也是需要注意的一点。但是同样是执行blocking操作,在一共只有一个线程的情况下把这个线程block住带来的后果会比平常更麻烦一些。多线程的情况下其他的任务可以被调度到别的线程上去执行,但是在这里这个线程关联的所有任务都将无法进行。
那是不是这种情况下就完全不能进行阻塞式的IO了呢?如果是纯异步的IO当然是没有问题的。如果不是的话,一个常见的办法就是开一个或者多个线程来专门执行这些blocking的操作,主线程把这些操作移交出去(某种程度上和模拟异步IO差不多)来保证自己仍然是异步的,考虑到环境与环境不能一概而论,在只能使用阻塞式IO的时候这个手段就是必要的。
Image
It takes
好处讲了这么多,那么代价是什么呢?
这里列了两点我认为比较重要的代价,一个是传染性,另外一个是做强制分片的需求。首先说传染性,这里指的是Send这个trait的传染性,我刚刚所说整个系统变为单线程的模型改动会比较大,但是如果只改一部分的话也会有问题。(todo: mesos)
我们先来回忆一下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,把两部分分离开,从而避免这个问题。
Image
我们CeresDB中的Write,就刚才说的Write,也是通过上层所封装好的Write request来执行操作,上层是把自己所收到的写请求包装成一个request,然后通过channel发送给底下对应的Worker,然后这个Worker执行完再把结果发送回去,这样就避免了一个显示函数的调用,也就避免了这个类型扩散到其他的部分,而是只把它局限在我们的Worker中。
Image
另外一个问题就是强制分区,因为我们希望各个线程之间尽量减少交流来减少额外开销,所以能够最好就是预先将工作负载和资源都进行划分,比如CeresDB是按照表来进行partition,可能别的系统还按照ID或 Key range之类的。对一些系统来说可能分区是比较简单的,但是对于另外一部分系统,它可能就比较难以找到一个合适的分区方式,那我们这种单线程的编程模型或者是Thread Per Core不太适合。
Image
分区也要注意粒度的问题,最好能够保证每个分区之间不会相差太多,或者是每个分区元素的力度也不会相差太大,涉及到分区的系统通常都会遇到分区负载不均的情况,所以为了能够灵活地调度,能够根据负荷来动态地调整partition,就要求每个分区的单元不会太大。
另外,还要求本身能够执行这个Repartition,这个其实大部分是工程上的问题,对于线程本身来说,它所代表的是一个计算资源,是无状态的,所以可以很方便地进行划分,能很方便地把某条指令在这个线程或者这个核上执行,或者``把它调度到另外一个线程或者另外一个核上执行,这个是比较方便的。
但是一个任务通常背后还对应了一些状态和资源,这些相较于任务本身是更难以调度的,特别是如果涉及到持久化的状态,比如把什么东西写到磁盘上,那我们这个下在这个磁盘,另外一个partition在另外一个磁盘,这个时候可能会要有一个比较复杂的逻辑。同时上层的分组路由也要保证路由的正确性。
How to
那么最后假如场景也很适合,想体验一下它带来船新体验和性能提升,那要怎么样开始开发呢?First of这个要求纯异步的代码,所以离不开async
/.await
。
先看一下最重要的Runtime,目前一些常用的Runtime都有提供不要求Send
的接口,比如说tokio和futures,都有spawn local的方法,可以看到和刚刚的spawn的区别就是我们这里函数签名上不再要求Send这个bound了。
另外也有一些专门为Thread Per Core模型设计的运行时,比如说glommio和monoio,不过这两个除了Thread Per Core之外还绑定了io_uring作为IO接口,虽然非常合理,因为我们本身就是最好是能够和异步IO相结合使用,但是也让它没有那么灵活,因为作为一个还比较新的系统特性,可能很多存量的服务器系统版本还没有升级,可能就会需要用到我们刚刚提到的那些手段去做一个适配。
在我们的实践中,也有遇到这个问题,为了能够兼容非阻塞的IO接口,需要用到前面提到Blocking的线程方式,可能还要再做一些hack来适配,把它迁移到常用的Runtime上。
还有标准库中的一些工具类也能够派上用场,比如用TLS代替一些全局变量,它们也属于被分开的“状态”。在刚刚提到我们使用tokio来模拟Thread Per Core Runtime的时候,也有使用到TLS,就是把每个线程拥有一个正常的tokio Runtime,然后把它放在TLS里面来模拟一个Thread Per Core 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的时候,想一想这里是否真的需要。
如果是一些简单的场景在目前的生态中已经能够基于TPC模型构建一个基本完整的应用了,但是想要轻松上手以及最大地发挥优势还需要一些发展时间。