首页 > 数据库 > 数据库开发技术 > 【投稿】刀哥:Rust学习笔记 3
@[TOC](Rust 学习心得<3>:无栈协程)
Rust作为一门新兴语言,主打系统编程。提供了多种编写代码的模式。2019年底正式推出了 async/await语法,标志着Rust也进入了协程时代。下面让我们来看一看。Rust协程和Go协程究竟有什么不同。C10K问题,这里不做更多探讨。早期解决此类问题的办法是依赖于操作系统提供的I/O复用操作,也就是 epoll/IOCP 多路复用加线程池技术来实现的。本质上这类程序会维护一个复杂的状态机,采用异步的方式编码,消息机制或者是回调函数。很多用 C/C++ 实现的框架都是这个套路,缺点在于这样的代码一般比较复杂,特别是异步编码加状态机的模式对于程序员是一个很大的挑战。但是从另外一个角度看,符合人类逻辑思维的操作方式却恰恰是同步的。C10K问题,因为操作系统的线程资源是很有限的,而且是昂贵的。操作系统会限制可以打开的线程数,同时线程之间的切换开销也是比较大的。Go语言的出现提供了一种新的思路。Go语言的协程则相当于提供了一种很低成本的类似于多线程的执行体。在Go语言中,协程的实现与操作系统多线程非常相似。操作系统一般使用抢占的方式来调度系统中的多线程,而Go语言中,依托于操作系统的多线程,在运行时刻库中实现了一个协作式的调度器。这里的调度真正实现了上下文的切换,简单地说,Go系统调用执行时,调度器可能会保存当前执行协程的上下文到堆栈中。然后将当前协程设置为睡眠,转而执行其他的协程。这里需要注意,所谓的Go系统调用并不是真正的操作系统的系统调用,而是Go运行时刻库提供的对底层操作系统调用的一个封装。举例说明:Socket recv。我们知道这是一个系统调用,Go的运行时刻库也提供了几乎一模一样的调用方式,但这只是建立在 epoll 之上的模拟层,底层的socket是工作在非阻塞的方式,而模拟层提供给我们了看上去是阻塞模式的socket。读写这个模拟的socket会进入调度器,最终导致协程切换。目前Go调度器实现在用户空间,本质上是一种协作式的调度器。这也是为什么如果写了一个死循环在协程里,则协程永远没有机会被换出,一个Processor相当于就被浪费掉了。func routine() int{var a = 5sleep(1000)a += 1return a}
Go在这里实现了一个协程栈扩容的机制,相对比较优雅的解决了这个问题。另外一个问题,关于上下文切换,这一般是跟平台或者CPU相关的代码,因为要涉及到寄存器操作。同时上下文切换也是有一点代价的,因为毕竟需要额外执行一些指令(个人觉得这一点可以忽略掉,无栈的协程实现难道不是也需要一些额外的指令来完成程序逻辑的跳转?)。Rust实现的无栈协程,就会知道因为引入这个栈,保存上下文,从而解决了很多很麻烦的问题。Go,讲一点题外话。Go有一个比较庞大的运行时刻库。从上文我们了解到,因为Go调度器的需要,运行时刻库把所有的系统调用都做了封装,这些所谓系统调用都被引入了调度器的调度点,也就是说,执行这类系统调用会进行协程的上下文切换。所以换一句话说。Go的系统调用,其实都是被包装过的,能够感知协程的系统调用。所以从这个角度也可以理解为什么Go的运行时刻库是比较庞大的。另外,cgo的执行也是类似的过程。因为调用的C代码非常有可能通过C库来执行系统调用,这样会使线程进入阻塞,从而影响Go的调度器的行为。所以我们看到cgo总会执行entersyscall和exitsyscall,就是这个原因。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}
Go协程是有栈的,所以这个局部变量保存在栈中,而Rust是怎么实现的呢?答案就是
Generator 生成的状态机。Generator 和闭包类似,能够捕获变量a,放入一个匿名的结构中,在代码中看起来是局部变量的数据
a,会被放入结构,保存在全局(线程)栈中。另外值得一提的是,Generator 生成了一个状态机以保证代码正确的流程。从sleep.await
返回之后会执行 a=a+1 这行代码。async routine() 会根据内部的 .await
调用生成这样的状态机,驱动代码按照既定的流程去执行。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 最大的意义。原文作者:Rust语言中文社区;如有侵权,请联系管理员删除。