Skip to main content


Rust作为一门新兴语言,主打系统编程。提供了多种编写代码的模式。2019年底正式推出了 async/await 语法,标志着 Rust也进入了协程时代。下面让我们来看一看。Rust协程和 Go协程究竟有什么不同。

有栈协程 vs. 无栈协程

协程的需求来自于 C10K 问题,这里不做更多探讨。早期解决此类问题的办法是依赖于操作系统提供的 I/O 复用操作,也就是 epoll/ IOCP 多路复用加线程池技术来实现的。本质上这类程序会维护一个复杂的状态机,采用异步的方式编码,消息机制或者是回调函数。很多用 C/C++ 实现的框架都是这个套路,缺点在于这样的代码一般比较复杂,特别是异步编码加状态机的模式对于程序员是一个很大的挑战。但是从另外一个角度看,符合人类逻辑思维的操作方式却恰恰是同步的。

考虑一个 web server 的场景:每次一个连接一般是请求下载一些数据,如果可以用一个线程来处理每一次新连接,那么这个内部的代码逻辑就可以用同步的方式一路写下来:首先接收数据,然后完成 HTTP request 解析。根据 HTTP 头部的信息访问数据库,然后将取得的结果封装在 HTTP response 中,返回给用户,最后关闭连接。如果是这样,你会发现这里并不需要状态机,也没有什么回调函数,很可能也不需要定时器,整个的过程就是一个流水账,而这正是人类最容易理解的思维方式。然而,我们不能简单地用多线程来解决 C10K 问题,因为操作系统的线程资源是很有限的,而且是昂贵的。操作系统会限制可以打开的线程数,同时线程之间的切换开销也是比较大的。

Go 有栈协程

Go语言的出现提供了一种新的思路。Go语言的协程则相当于提供了一种很低成本的类似于多线程的执行体。在 Go语言中,协程的实现与操作系统多线程非常相似。操作系统一般使用抢占的方式来调度系统中的多线程,而 Go语言中,依托于操作系统的多线程,在运行时刻库中实现了一个协作式的调度器。这里的调度真正实现了上下文的切换,简单地说, Go系统调用执行时,调度器可能会保存当前执行协程的上下文到堆栈中。然后将当前协程设置为睡眠,转而执行其他的协程。这里需要注意,所谓的 Go系统调用并不是真正的操作系统的系统调用,而是 Go运行时刻库提供的对底层操作系统调用的一个封装。举例说明:Socket recv。我们知道这是一个系统调用, Go的运行时刻库也提供了几乎一模一样的调用方式,但这只是建立在 epoll 之上的模拟层,底层的 socket 是工作在非阻塞的方式,而模拟层提供给我们了看上去是阻塞模式的socket。读写这个模拟的 socket 会进入调度器,最终导致协程切换。目前 Go调度器实现在用户空间,本质上是一种协作式的调度器。这也是为什么如果写了一个死循环在协程里,则协程永远没有机会被换出,一个 Processor 相当于就被浪费掉了。

有栈的协程和操作系统多线程是很相似的。考虑以下伪代码:

func routine() int {
    var a = 5
    sleep(1000)
    a += 1
    return a
}

sleep 调用时,会发生上下文的切换,当前的执行体被挂起,直到约定的时间再被唤醒。局部变量 a 在切换时会被保存在栈中,切换回来后从栈中恢复,从而得以继续运行。所谓有栈就是指执行体本身的栈。每次创建一个协程,需要为它分配栈空间。究竟分配多大的栈的空间是一个技术活。分的多了,浪费,分的少了,可能会溢出。Go在这里实现了一个协程栈扩容的机制,相对比较优雅的解决了这个问题。另外一个问题,关于上下文切换,这一般是跟平台或者 CPU 相关的代码,因为要涉及到寄存器操作。同时上下文切换也是有一点代价的,因为毕竟需要额外执行一些指令(个人觉得这一点可以忽略掉,无栈的协程实现难道不是也需要一些额外的指令来完成程序逻辑的跳转?)。

有栈协程看起来还是比较直观,特别是对于开发人员比较友好。如果对比一下 Rust实现的无栈协程,就会知道因为引入这个栈,保存上下文,从而解决了很多很麻烦的问题。

关于 Go,讲一点题外话。

Go有一个比较庞大的运行时刻库。从上文我们了解到,因为 Go调度器的需要,运行时刻库把所有的系统调用都做了封装,这些所谓系统调用都被引入了调度器的调度点,也就是说,执行这类系统调用会进行协程的上下文切换。所以换一句话说。Go的系统调用,其实都是被包装过的,能够感知协程的系统调用。所以从这个角度也可以理解为什么 Go的运行时刻库是比较庞大的。另外, cgo的执行也是类似的过程。因为调用的C代码非常有可能通过 C库来执行系统调用,这样会使线程进入阻塞,从而影响 Go的调度器的行为。所以我们看到 cgo总会执行 entersyscallexitsyscall ,就是这个原因。

Rust 协程

绿色线程 GreenThread

早期的 Rust支持一个所谓的绿色线程,其实就是有栈协程的实现,与 Go协程实现很相似。在0.7之后,绿色线程就被删除了。其中一个原因是,如果引入这样的机制,那么运行时刻库也必须如 Go语言一样能够支持有栈协程,也就是之前讨论 Go题外话提到的内容。Go没有 Native thread 的概念,语言层面只支持协程,选择封装全部的系统调用很合理。然而,如果 Rust也打算这么做,那么 Native thread 和协程运行库 API 统一的问题将很难解决。

无栈协程

无栈协程顾名思义就是不使用栈和上下文切换来执行异步代码逻辑的机制。这里异步代码虽然是异步的,但执行起来看起来是一个同步的过程。从这一点上来看 Rust协程与 Go协程也没什么两样。举例说明:

async fn routine() -> i32 {
    let mut a = 5;
    sleep(1000).await;
    a = a + 1;
    a
}

几乎是一样的流程。 Sleep 会导致睡眠,当时间已到,重新返回执行,局部变量 a 内容应该还是5。Go协程是有栈的,所以这个局部变量保存在栈中,而 Rust是怎么实现的呢?答案就是 Generator 生成的状态机。 Generator 和闭包类似,能够捕获变量a,放入一个匿名的结构中,在代码中看起来是局部变量的数据 a,会被放入结构,保存在全局(线程)栈中。另外值得一提的是, Generator 生成了一个状态机以保证代码正确的流程。从 sleep.await 返回之后会执行 a=a+1 这行代码。async routine() 会根据内部的 .await 调用生成这样的状态机,驱动代码按照既定的流程去执行。

按照一般的说法。无栈协程有很多好处。首先不用分配栈。因为究竟给协程分配多大的栈是个大问题。特别是在32位的系统下,地址空间是有限的。每个协程都需要专门的栈,很明显会影响到可以创建的协程总数。其次,没有上下文切换,貌似性能也许会好一些?当然,更大的好处是并不需要与 CPU 体系相关代码,也就有了更好的跨平台的能力。当然,无栈问题也不少。例如, Rust著名的 PIN 问题。另外,个人觉得 Rust的无栈协程主要问题是不那么直观,理解起来会稍微吃力一些。

协程解决的问题

Rust语言真正实现 async/await 语法只是去年底的事情。在那之前,有一些其他临时使用宏的替代做法。所以现在去看一些开源的软件项目,真正采用 await 写代码还是很少的,主要是 poll 的方式,这样的代码需要自己维护各种状态。一个经典的例子就是 Sink 发送的三件套: pollready/startsend/poll_flush ,首先需要检查是否缓冲区有待发送的数据,若是,则优先处理这一部分数据。然后检查底层是否就绪,否则无法发送,这时候需要把当前发送的东西转存下来,也就是前面提到的发送缓冲区。如果用 C 语言写过 epoll 相关的代码,那么会发现和这里也没有什么大的区别。因为这就是异步编程大致的模式。而事实上,如果可以用 await 来写代码,直接调用 SinkExtsend().await 方法,一切烦恼都消失了。SinkExt::send 内部实现了包含发送缓冲的 Sink 的三件套,而 await 用一种简洁的方式将这一切优雅地呈现出来。这种利用 .await 写出来的代码,看似是用同步的方式在做异步的编程,比较简洁,易于理解。

总之,个人觉得 Rust异步编程的未来是 await 。早期手动来写各种 poll 方法,实在是太繁琐了。语言实则是一种工具,被发明出来是用来帮助程序员的,而不是造成更多的负担。我相信这也是 Rust .await 最大的意义。

Reference

https://mp.weixin.qq.com/s/knMZJEgHwf7zYzgQvQas4A - 无栈协程 | Rust学习笔记