作者:谢敬伟,江湖人称“刀哥”,20年IT老兵,数据通信网络专家,电信网络架构师,目前任Netwarps开发总监。刀哥在操作系统、网络编程、高并发、高吞吐、高可用性等领域有多年的实践经验,并对网络及编程等方面的新技术有浓厚的兴趣。

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() 
{
    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发送的三件套:poll_ready/start_send/poll_flush,首先需要检查是否缓冲区有待发送的数据,若是,则优先处理这一部分数据。然后检查底层是否就绪,否则无法发送,这时候需要把当前发送的东西转存下来,也就是前面提到的发送缓冲区。如果用C语言写过epoll 相关的代码,那么会发现和这里也没有什么大的区别。因为这就是异步编程大致的模式。而事实上,如果可以用await来写代码,直接调用SinkExt的send().await方法,一切烦恼都消失了。SinkExt::send 内部实现了包含发送缓冲的Sink的三件套,而await 用一种简洁的方式将这一切优雅地呈现出来。这种利用.await 写出来的代码,看似是用同步的方式在做异步的编程,比较简洁,易于理解。

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

下一篇文章,我们来研究下 async/await 究竟做了什么。


深圳星链网科科技有限公司(Netwarps),专注于互联网安全存储领域技术的研发与应用,是先进的安全存储基础设施提供商,主要产品有去中心化文件系统(DFS)、企业联盟链平台(EAC)、区块链操作系统(BOS)。
微信公众号:Netwarps
无栈协程 | Rust学习笔记