Rust——Tokio之源神,启动!

3,960 阅读21分钟

Rust异步——Tokio窥探(Tokio是怎么调度你的Future的?)

在阅读本文之前,希望你:了解Rust异步并写过部分依赖Tokio作为运行时的异步代码。

Tokio的名字来自于Tokyo+IO,意思是像东京都市圈那样繁忙却又高效地处理IO,这里我们从源码角度了解Rust运行时的实现,以便更好地使用Rust异步以及基本成为事实标准的运行时框架——Tokio

组织形式

Tokio的组织比较有条理,大致分为:

  • fs:文件处理相关,包括Tokio自己的异步文件处理API和对标准库的适配
  • future:一些和Future相关的方法
  • io:对于异步IO的一些定义,包括Trait,一些方法等
  • loom:测试用
  • macros:宏定义
  • net:网络相关,包括TCP,UDP和UDS(Unix Domain Socket)
  • process:命令行相关
  • runtime:此次的重点,运行时。包含Tokio调度和运行Future的核心
  • signal:信号处理
  • sync:同步机制,包括channel和锁以及各种同步语义
  • task:对于Future的抽象,使之更加符合Tokio的设计
  • time:时间相关,包括定时器等
  • util:一些Tokio的工具包和类型

对于net部分,Tokio依赖另一个自己的库:mio。如果你对epoll/kqueue/IOCP了解的话,可以简单理解成mio就是对这些跨平台IO多路复用框架的封装,屏蔽掉了复杂且不同的系统调用,抽象出统一的接口。

对于time部分,则是通过时间轮算法进行排序,同时和上述mio整合实现定时器。

如果你对Golang的调度策略和实现比较熟悉,那门本文看起来会非常轻松,因为Tokio早期(包括现在)的很多设计都是照搬Golang实现的。你甚至可以理解成,Tokio就是刨去GC同时把有栈routine换成无栈routine的Golang运行时。

task

首先从task模块开始。

Tokio抽象出一个Task类型,并定义Task为绿色线程,或者说轻量级线程,甚至可以说是Golang的goroutine(但是不完全是)。

Tokio对于Task给出了三个关键的点:

  • 轻量:这里指Task的调度和销毁/创建非常轻量,对比系统线程而言。
  • 协同调度:不同的Task之间相互协同运行,不存在操作系统的抢占式调度,即如果一个Task执行复杂的CPU计算,其他Task没法中断它,调度器也没有办法挂起它,所以这可能造成其他Task饥饿,只有当前Task主动让出执行权,比如在资源不满足时,或者等待定时器时,甚至调用yield_now时,否则其他Task不会被执行。
  • 非阻塞:因为没有抢占式机制,所以Task不可以是阻塞的,一旦阻塞,当前线程上所有的Task都会被阻塞,处理的策略有线程池提交阻塞任务,或者剥离当前调度队列到新的线程。

对应的实现实际上在runtime/task下,而task包则是针对这个抽象提供了一些处理的方法,比如如何launch一个Task,如何在本地launch,如何协作式让出资源等。

  • block_in_place:原地阻塞,迫使当前线程让出调度器需要的资源,比如本地调度队列等,然后留着当前线程去执行阻塞调用,同时寻找新的线程去执行剩下的Task。
  • spawn_blocking:提交到线程池处理
  • spawn_local:提交到当前线程的runtime处理
  • yield_now:释放当前Task的执行权,触发再次调度,不过不保证调度顺序

runtime

这是Tokio的核心,也是Tokio运行时部分,对于Future的调度,则是在这里完成。

runtime提供三个核心的组件:

  • io事件循环(event loop):调用驱动类型,驱使io资源的运行,分发就绪的io资源给对应的Task,对应上面提到的mio,这也是Tokio整合mio的地方。
  • 调度器(scheduler):调度执行使用了上述io资源的Task。
  • 计时器(timer):推动计时器的执行和等待。

runtime分为两类,一类是当前线程下驱动的,另一类就是多线程驱动的,当前线程,或者说单线程驱动,适用于内嵌在已存在的线程内使用,比如线程池的某个线程想要驱动异步任务这样的。

我们重点放在多线程模式下。

Tokio对于此模式下任务的调度给予如下保证:

  • 存在一个MAX_TASKS使得整个运行时在任意时间内都不会存在大于这个数量的任务
  • 存在一个MAX_SCHEDULE使得任何任务的调度都不会运行超过这个时间
  • 存在一个MAX_DELAY使得某个任务唤醒之后,一定会在这个时间之内被调度到

第三个参数由运行时自行计算得到。

除此之外没有任何运行时保证。

Tokio还存在一种“额外唤醒”的可能,使得某些任务即使没有调用wake,也会被调度执行。

Tokio保证一定会在某个时间内唤醒至少一次io/定时器任务,当然这个任务是否就绪就另当别论了。

runtime的组成可以分为如下:

  • blocking:提供阻塞调用相关的线程池服务
  • context:链接线程和运行时的桥梁
  • io:负责驱动io资源,链接mio和Task
  • metrics:数据统计,帮助运行时动态调度资源和策略分配
  • scheduler:调度器的核心实现,负责调度Task,整合mio和定时器,唤醒和挂起线程
  • signal:信号驱动,处理系统信号
  • task:Tokio抽象Task的实现
  • time:时间驱动,负责给运行时提供定时功能

流程

从你写下:

#[tokio::main]
async fn main() {
    // ...
}

开始,你的程序就由Tokio托管运行,那么这一切到底是怎么发生的呢?

首先tokio::main是一个标记宏,你的代码会被替换成:

fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(
            async move {
                // ...
          }
        );
}

好,这就是全部了。结束了。

开个玩笑哈!

从Builder开始

build_thread_runtime()首先创建runtime driver和runtime driver handle。

当然在这之前先获取当前可用核心数,这个也是工作线程的默认数量;接着创建线程池,线程池的最大线程数=512 + 核心数,当然这两个参数都是可以更改的。这里需要说一下的是,工作线程也是从线程池选取的,所以才是默认最大线程数+核心数。

之后创建scheduler,scheduler handle和launch。

补充一下:

整个Tokio的设计思路都很类似,就是xx + 对应的xx handle组成一个操作Pair,类似线程和线程句柄的关系;xx是一个具体的存在,而xx handle则是控制xx的手柄。

操作由handle完成,handle指向xx,并把核心的实现转发到xx的实现上去完成。在很多时候都是多个线程持有某一个操作(比如io driver)的handle,当通过handle操作某个资源时,比如poll mio,会把这个操作转发给handle指向的xx实现,当然部分实现handle也可以处理。xx除了实现核心逻辑之外,还负责并发访问控制(比如包含一个锁),保证多个线程持有handle时的一致性和内存安全。

最后通过scheduler handle作为参数设置当前线程Context;launch启动所有的线程和资源,运行时构造完毕。

什么是runtime driver?

runtime driver包含如下:

  • time driver:处理park/unpark

runtime driver handle包含如下:

  • io handle:io driver的handle
  • signal handle:signal driver的handle
  • time handle:time driver的handle
  • clock:Instant::now()的抽象,简单认为当前时间,方便后续计算间隔

runtime/driver/new()细分为创建io_stack,time clock和time driver,Tokio对driver进行如下划分:

  • io_stack:处理io的driver,io又包括net和signal

    • io(net):对mio的包装
    • signal:系统信号处理
  • time_driver:处理定时请求

读到这你可能觉得命名有点乱,我也这么觉得,阅读源码我用了命名替换,不然会被各种xx/driver搞混。

runtime/io/driver/new()创建io driver,返回一个driver和handle,driver有如下结构:

  • signal_ready:是否有信号到达
  • events:存放就绪IO事件的数组,如果你用过epoll,对这就是epoll_wait的events参数,在kqueue里的eventlist
  • poll:一个mio::Poll类型的值,多路复用由它实现

而对应的handle包含如下:

  • registry:注册器,负责注册IO事件,有意思的是,mio::Poll内部也仅有一个registry,二者是同一个;registry包含一个selector和has_waker,前者对应epoll/kqueue/IOCP的fd,后者确保一个registry仅有一个waker,什么是waker?就是手动唤醒selector的一种方式,比如给epoll注册一个eventfd,手动写入值即可把epoll从wait状态唤醒,这在有新的定时器加入或者信号到达时很有用。
  • registrations:追踪所有注册的事件
  • synced:同步锁
  • waker:用来唤醒selector的唤醒器
  • metrics:数据统计,方便运行时动态更改配置

回到new方法,实现非常简单,创建一个mio::Poll实例,设置到driver和handle即可。

接着拿刚刚创建好的io driver和io driver handle创建signal driver和signal driver handle。

runtime/driver/create_signal_driver()的实现很简单,创建一个全局UDS(unix domain socket),拿到它的receiver,然后注册到mio::Poll上。如果是Windows可能会有所不同,但实现上大差不差。对于整个runtime,捕捉到signal,传递到全局UDS的sender,既可以唤醒selector,触发处理。

命令行的信号也转发到这里。

Tokio使用时间轮算法管理时间,在创建了对应的实例之后,使用io stack(其实是io driver)创建time driver。

runtime/time/new()简单地初始化time driver和time driver handle。

time两个很重要的实现:park(包括park_timeout)和unpark,本质都是转发到io driver/handle实现的。signal亦是如此。

park和park_timeout最后调用都会走到同一处,类似于:

park:
    park_timeout(None)
​
park_timeout(duration):
    recent_duration = ...
    if duration == None:
        park_inner(recent_duration)
    else:
        park_inner(min(duration, recent_duration))

所以下面不再区分二者。

那么io driver/handle是怎么实现的呢?

runtime/io/driver/park_timeout()把调用转发到了runtime/io/driver/turn()上,这里便是如何把定时唤醒和IO多路复用集合起来的地方。

这里对应的实现:

  • 调用mio::poll(timeout_duration)进行阻塞等待,等待时间就是下一次定时器间隔时间
  • 当调用返回,要么有IO事件到达,要么定时器时间到了
  • 如果是定时器超时唤醒,则跳过,因为此时线程已经苏醒,会在后面的调度里运行定时器任务
  • 如果是信号时间,简单设置signal_ready即可
  • 如果是IO事件,则触发处理,后面详谈。

调度器的创建

首先创建一个Parker,用于唤醒和挂起,之后在runtime/sheduler/multi_thread/worker/create()进行实际的创建,在正式展开之前,我们先看看一些关键结构。

Worker,工作线程的抽象,调度并运行实际的Task,每一个Worker都运行在一个物理线程上,但是不是每个物理线程都有Worker。

Context,线程独有的上下文抽象,每个线程都有一个Context,同时每一个Worker都通过Context操作调度Task。

Core,Worker的核心,保存着调度需要的数据。

Shared,保存有所有Worker共享的全局状态和数据,内部包含锁实现内存安全。

Synced,Shared保护的数据。

Remote,与Worker绑定的,用来帮助其他Worker访问自己的途径,每个Worker一个Remote。

很迷?那就对了,现在开始介绍整个Tokio runtime的调度规则。

调度规则

首先每个Worker有一个自己的本地队列(数组)用来存放Task,这个本地队列大小是固定的,为256。如果超过了这个数量,会把一半的Tasks移动到全局队列。全局队列(链表)就是所有Workers都可以看到的队列,当本地队列为空时,就会去全局队列找。

除此之外,如果执行了一定次数的本地队列,也会触发一次全局队列查找,避免全局队列的饥饿。这个次数由global_queue_interval指定,同时也会动态计算。

如果全局队列和本地队列都是空的,会触发一次工作窃取,即,去另一个Worker的本地队列“拿”一半的Tasks来运行。

调度器还会每隔61次运行触发一次io或者time的就绪检查,这个61由event_interval指定,来自golang的调度器。

此外,为了本地化和缓存优化,当前Task唤醒了某个Task后,新的Task会优先加入lifo_slot,之后的调度会优先从lifo_slot选择,如果lifo_slot不为空则替换到本地队列去。如果lifo使用了超过三次,触发临时禁用避免饥饿发生。

如果某个Task从非Worker线程唤醒,比如阻塞调用线程池,则会被加入到全局队列等待调度。

以上是调度器大致过程。

之后我们再来分析上述的结构包含哪些字段。

Worker:

  • handle:多线程共享的handle,指向scheduler
  • index:当前的索引,方便对比和查找对应的Remote
  • core:可被移动的Core,后面解释为什么可移动

Core:

  • tick:记录调度次数,触发global_queue_interval的调度切换(简单tick % interval == 0比较即可)
  • lifo_slot:上面提到的优化字段,保存一个Task
  • run_queue:本地队列
  • park:挂起当前Worker的Parker
  • 其他额外字段

Shared:

  • remotes:一个Remote数组
  • inject:全局队列
  • idle:协调空闲Workers
  • owned:todo
  • synced:需要同步的数据,包括空闲队列和全局队列
  • 其他额外字段

Synced:

  • idle:一个Vec,包含所有空闲的Workers的index
  • inject:全局队列

Remote:

  • steal:窃取队列
  • unparker:唤醒当前Remote对应的Worker的唤醒器,由parker得来

Context:

  • worker:当前Context对应的Worker,多个Context可能共享同一个Worker,因为有些Context对应的线程可能是这个Worker分离出去的,后面会提到
  • core:Core
  • defer:一个包含了主动yield的Tasks的Vec,方便在队列为空时调度;有时某些Task可能比较无私,主动让出执行权给别的Task,此时它们就会被放到这里

Launch就是一个Worker数组。

让我们回到create()方法,这里简单创建了三个Vec分别用来存储每个Worker的Core,Remote和metrics。之后在一个循环里(循环次数就是核心线程数),创建一组队列,分别是本地队列和窃取队列,二者底层共享一个数组,通过原子性实现队列的取和写。

之后clone一个parker;我们需要先搞懂一件事,就是上面通过在create()之前创建的Parker,包含了一个io driver,同时这个Parker可以被Clone,cloned之后的Parker共享一个底层,这样做的意义是什么呢?

某个Worker发现队列为空还窃取不到Tasks,接下来它要做的是调用parker.park_timeout()休眠,触发mio::poll上的休眠;之后如果有更多的Worker准备休眠,它们会发现通过io driver休眠在mio::poll的方式已经被第一个Worker使用了,所以它们选择通过Condvar休眠,这样就保证只有一个Worker是挂起在mio::Poll::poll上的,等到就绪到达不会触发所有的Worker。不过在mio::Poll::poll返回时,第一个休眠的Worker会在调度的时候判断Task所属Worker进行唤醒,所以不用担心通过Condvar的线程不会醒来。

每一个Parker <-> Unparker有自己独有的Condvar,Parker <-> Unparker对之间仅共享一个状态,用来判断io driver是否被使用。

继续我们的流程。得到了Parker对,把Unparker放到Remote中方便别的Worker唤醒,同时设置对应的窃取队列,可以让其他Worker通过这里进行工作窃取。

接着构建全局队列和空闲列表,构建scheduler handle,线程池等,最后把Worker添加到launch并设置每个Worker的index,调用完毕。

到这里,我们得到了scheduler(就是一个标记类型),scheduler handle(真正的实现),通过scheduler handle可以构建一个多线程runtime,在这里取runtime handle作为实际的实现,通过runtime handle触发运行时陷入,对当前线程绑定Context,这样调用任何async方法,提交给Tokio时,都有了上下文去处理,不至于panic。

陷入运行时

runtime/handle/enter()负责把运行时和当前线程绑定起来,Context是一个线程本地变量,即ThreadLocal,每个线程都有一个Context,而enter()方法就是设置当前线程的Context。这里的Context和前面提到的不是同一个,但是内部使用到了scheduler的Context,所以可以看成是ThreadLocal包装版,在理解时可以近似为同一个。

稍微展开看看runtime/context/Context:

  • current:一个HandleCell,内部包含一个scheduler handle,就是我们上面创建的那个
  • scheduler:scheduler context,对应上面提到的ThreadLoca的Context,这里需要注意,这个scheduler仅在Context绑定的线程属于Worker时才会设定。哪怕是通过block_in_place()切换到别的线程之后的Worker,依旧只有这个新线程对应的runtime/context/Context会持有这个值,其他线程则是空。

而enter()最终使用shceduler handle设置上面的current字段。到这里运行时构造完毕。

Tokio,启动!

runtime/scheduler/multi_thread/worker/launch()把上面创建的Workers全部启动,spawn_thread指出每个Worker都运行在一个新的thread上。

这里注意到,launch()对每一个调用传递了一个run()方法,正如你所猜到的那样,这个方法是每一个thread一直loop的,我们看到spawn_task()把这个Task放入队列,之后的thread取出来,执行,所以一切回到了run()方法,不过thread这里的loop除了处理run(),还会在run()返回时记录到空闲状态,在这里进行处理。

一切就绪

runtime/scheduler/multi_thread/block_on()runtime的block_on方法调用最终走到这里,之后在一个loop里poll这个Future,直到返回Ready,如果得到了Pending,则park()等待唤醒。

这里的调用是在主线程完成的,也就是如果block_on()的Task返回了Pending,主线程挂起,之后通过注册的Waker唤醒。注意到Context::from_waker()方法就是唤醒主线程的Waker诞生的地方,阻塞主线程通过Condvar实现,而这个Waker内部调用了Condvar的notify_one()方法。

tokio::spawn()

但是真正使用时,我们不会只创建一个Task,而是会在这个Task里创建更多的Task,然后spawn()出去,让Tokio来调度,不然的话这和直接使用线程阻塞没什么区别。那么这里是怎么处理的呢?其实大胆猜测一下,无非就是加入local queue之类的。

追踪调用可以看到调用来到了task/spawn/spawn_inner(),这里找到当前线程的Context,拿到和它对应的shceduler handle,把任务通过handle的spawn创建出去。

runtime/scheduler/multi_thread/worker/schedule_task()为调用目的地,判断当前线程的Context包含的Handle是否和调用的Handle一致,而且当前线程是否持有Core;如果通过,schedule_local会被调用,这里就是前面提到的包含lifo优化的部分:把lifo里面的Task推入队列(本地或者全局),新的Task放到lifo。

否则推到全局队列并唤醒其他Worker,这里需要注意的是,如果调用方来自非Worker线程(通过runtime/context/Context的scheduler是否包含runtime/scheduler/multi_thread/worker/Context判断),则直接推入全局队列并唤醒空闲的Worker。

简单来说spawn()把Task入队,之后在Worker的处理中进行调度,包括调度到别的Worker。

协作运行

在准备park之前,Worker会尝试拿到io driver去试试看有没有新的IO事件(park_timeout(0)),如果有则全部添加到自己的队列,然后唤醒别的Worker,等待窃取。

整个运行时里,不会有超过一半的Worker处于窃取状态。

当Worker运行Task时,会确保当前Worker不处于窃取状态,并且会尝试唤醒一个空闲的Worker来进行工作窃取。

Future::poll()?

runtime/scheduler/multi_thread/worker/run_task()中,可以看到,这里会对lifo做判断,包括是否过多使用,而运行Future之前,会先把它包装成Task,之后调用会在runtime/task/raw/poll()完成,但是按照理解,这里应该要有Waker(Future里的那个,不是上面那些)传进去才对。

不妨来看看作为poll方法来源的vtable提供了哪些实现。跟踪调用到runtime/task/harness/poll_inner(),注意到,对于Waker的构建来自runtime/task/waker/raw_waker,其中对于wake()的包装来到了runtime/task/raw/shcedule()。所以每次Future::poll()内部调用waker.wake()时,都等于调用了runtime/scheduler/multi_thread/worker/schedule_task()。而这个方法我们上面分析过,约等于重新入队。

TCP/UDP/UDS:那我呢?

目光转到runtime/io。关注ScheduledIo类型:

  • readiness:当前就绪状态,指出是可读还是可写还是both
  • waiters:等待在此实例上的waker列表,互斥访问。

再跳到io handle,runtime/io/driver/Handle类型,有一个add_source()方法,这里首先构建一个默认的ScheduledIo,然后使用构建后的实例的值的地址作为epoll/kqueue绑定的event的user_data域,这样当mio::Poll::poll()返回时,可以访问就绪事件绑定的user_data转换成ScheduledIo值。

再次查看调用,发现传入的需要被注册的Source类型的socket,以ScheduledIo地址为user_data的形式绑定到了io driver handle对应的mio::Poll实例上。

此时多路复用注册完成。

Source是什么?转到定义可以看到它是一个Trait,包含三个方法:

  • register:以interest(读/写/both)为期待的事件类型注册自己到registry实例上,同时绑定传入的token,一个usize类型的值
  • reregister:重新注册
  • deregister:把自己从registry取消注册

那么只要一个类型实现了Source,就可以随便注册给mio::Poll了不是吗?以mio::net::tcp::TcpStream为例可以看到确实如此。

不过tokio::net::tcp::TcpStream稍微封装了一点就是,其中便是使用PollEvented封装一些桥接操作,把异步的poll转换成注册waker,并在就绪事件触发时通过注册的waker调用wake()唤醒。

这也是我们关心的地方,因为通过这里可以看到Tokio是怎么把Reactor模型转成异步下的Proactor模型。

ShceduledIo:露头就秒

跟踪调用链,poll_read()/poll_write()最终都来到了runtime/io/scheduled_io/poll_readiness(),这里会把Future::poll(waker)传递给ScheduledIo对应的waiters,而runtime/io/driver/turn()会在得到就绪事件时,拿到user_data转换成ScheduledIo类型,调用其wake()方法并传入此时的就绪类型。

追踪ScheduledIo::wake(readiness)可以看到,这里取出waiters保存的所有waker进行wake()调用,唤醒完成。而std::task::waker::Waker::wake()的实现我们前面提到了,会转成runtime/scheduler/multi_thread/worker/schedule_task()调用。

后记

其实还有很多包没有分析,比如time里面的时间轮,还有io相关的,比如文件操作,还有tokio很重要的两个接口:AsyncRead/AsyncWrite,由于时间关系,打算后续再说。但最后,我们确实知道了调度细节和方式,还有Reactor到Proactor的转换。

此外,调度里有很多细节我直接跳过了,一方面觉得太繁琐,另一方面觉得对理解整体逻辑影响不大。

当时写这篇文章的目的是想知道怎么在Tokio更好地进行文件操作,我之前自己做了个很简单但是性能出色的线程池,阅读完源码之后基本验证了我的猜测,Tokio也是和Golang一样直接开辟线程去做这件事,对比之下我的方案增加了一些额外的策略,除此之外,也让自己对异步框架了解了更多。

Tokio一开始就在注释里提到,设计一个异步运行时不是很难的事,难得是保证稳健,Tokio内部有大量的异常和错误处理还有各种潜在情况的兜底,这才是更应该学习的不是吗?