但首先,我们为什么需要并发?
今天,大多数程序与需要一定时间才能返回响应的资源进行交互:例如网络或磁盘。如果我们在等待网络响应的同时完全阻塞程序的执行,这将是对硬件的一种相当低效的使用!
这就是为什么Go和Rust在等待I/O(输入/输出)时允许程序执行其他任务的语言特性。
任务
任务是可以并发执行的抽象计算单元:多个函数可以(由程序)同时处理,但它们不一定(由CPU)同时执行(它的并行性需要多个线程)。
可以使用go关键字在Go中生成新任务:
go doSomething()
go doAnotherThing()
在Rust中,需要使用spawn函数:
tokio::spawn(async move {
do_something().await
});
tokio::spawn(async move {
do_another_thing().await
});
在这两种情况下,任务都由语言的运行时同时处理。
运行时
运行时的目的是管理和调度不同的任务,以便有效地使用硬件。
图片
Rust和Go的第一个不同之处。你不能改变Go运行时(除非你使用一个完全不同的编译器,比如tinygo),它是内置在语言中的,而在Rust中,语言没有提供运行时,你必须自己配置。
函数在等待某些东西(例如网络)时将控制权交还给运行时。在Go中,这是由标准库、语言和编译器自动完成的,而在Rust中,它在到达await关键字时发生。
Stackfull协程
Stackfull协程又称绿线程,或M:N线程(M个绿线程运行在N个内核线程上)是Go采用的并发模型。
在这个模型中,运行时管理轻量级(绿色)线程,并将它们调度到可用的硬件线程上。与内核线程一样,每个任务都有自己的栈,如果需要,可以由运行时增加栈。
stackfull协程的第一个问题是,每个任务都有自己的栈,这意味着每个任务使用较少的内存量。从Go 1.22开始,线程程序使用的最小内存量是2 KiB,这意味着如果有10,000个并发任务在运行,程序将使用至少20 MiB的内存。
Stackfull协程的第二个问题是,运行时需要完全控制栈布局,这使得与其他语言(如C的FFI)的互操作性变得困难,因为运行时必须在能够调用C代码之前做一些准备栈的工作。这就是为什么CGO被认为是缓慢的(在现实中,CGO调用在30到75纳秒内完成,在我看来这是相当快的)。
Stackless协程
另一方面,Rust采用了无栈协程方法,其中任务没有自己的栈。在Rust中,Future基本上是实现Future Trait的简单结构,其中每个.await调用链被编译成巨大的状态机。
如果你正在用Python或c#开发,你可能已经知道async/await函数着色的巨大代价,其中同步函数不能调用async函数,反之亦然。
这就导致了许多问题,比如导致了生态系统的碎片化,其中的库是不可互操作的,很难在程序中使用libA,因为你使用的是async而不是这个库,而且还导致了开发人员的许多错误,他们阻塞了运行时的事件循环,降低了系统的性能。
这在Rust中也同样存在,因为标准库不提供与同步函数相同的异步函数(例如read读取整个文件),并且因为不同的运行时甚至不能相互操作,如果你开始为tokio运行时编写程序,你将很难将其移植到另一个运行时。
虽然这些都在Go中得到了解决,在Go中,一切都是同步的,编译器和运行时在调用程序员看不见的异步函数时自动插入等待点,但这是以性能损失(内存和CPU)为代价的。
虽然Rust方法可以最大限度地利用机器,但它带来了一个碎片化的生态系统,这给Rust的采用带来了很大的麻烦。