你理解的很对。

我投入过一段时间,写 C++ 服务端程序和框架,一旦涉及到协程(暂不考虑 C++20 Coroutine 会如何,主要是我还没来得及学......),生态真的是一个头疼的事情,要么在协程环境调用 Blocking API 阻塞所在线程,性能急剧下降;要么协程环境下调用另一个协程 Runtime 的能力,遇到稀奇古怪的崩溃。

我见到写 C++ 服务端的地方,最终做法大多都是:一旦我造了一个协程轮子,那么为了支持业务,我也免不了要以这个协程为基础,重写 HTTP 客户端,重写 MySQL 客户端,重写 Redis 客户端......

std::mutex


Go 在 IO 相关的生态上是做到了几乎最好。只要功能合适的库,都可直接薅下来用,不会崩溃,不会有稀奇古怪的性能问题。连 Python、C# 都还会区分 async,只有 Go 车门焊死,直接强制有栈协程、底层做异步,把这些破事儿全统一了,也全屏蔽了。

Rust 其实比 C++ 好很多,Tokio 和 async-std 几乎成为了事实标准。基于 Rust 开发的客户端库、SDK,好些的有抽象设计,同时支持 Tokio 和 async-std,一般一些的大多直接基于 Tokio,只要你也用 Tokio,那么在生态问题上,多数场景体验逼近 Go,至少把 C++ 远远甩在身后。


这背后折射的是语言历史(C++)和语言定位(Go、Rust)问题。

C 定位是 Unix/Linux 下使用操作系统能力的接口,就像笔之于纸,功能简单,创造什么全靠你的想象力。C++ 想延续 C 的生态并尝试对抽象和工程化做工作,于是带着上下五千年的包袱,运行着上下五千年的代码,的确繁荣(指用的人相对多,重造的轮子多),也的确恶心人。协程生态,看 C++20 是否会有好转吧。

Go 的定位偏向于 高并发、高 IO、低开发成本,于是实现了强大的 Goroutine,也许是这个世界上综合能力最好的 M:N (协程会跨线程执行)协程 Runtime。那些排期压力最重的业务组,干活最莽的程序员,也很容易用 Go 写出性能合理的服务端程序。

但 Goroutine 有没有不满足的情况呢?也许是 GC 带来性能毛刺,也许是有栈协程高并发时过高的内存占用,也许是 IO 极重,业务极轻的代理转发类服务,导致协程调度的开销已经远大于我的业务本身。总之,也总会有那么 1% 是这样的情况,最终导致技术选型会放弃 Go,现实中多数还是会转向 C++。

Rust 定位是零成本抽象的通用编程语言,但也很重视并发 IO 的需求,于是提供了 async/await,但把 Runtime 和调度器留给外部实现。Rust 的哲学是,如果 Rust 不能确定我提供一个 Runtime/调度器在任何情况下都是最好的,那就不提供,留给大家慢慢研究。

Rust 的异步 Runtime 实现主流是 Tokio,传闻其实现是高仿了 Goroutine,同样地,这满足了也许 99% 的服务端程序,顺带这也说明 Goroutine 确实很好。

然鹅,Rust 把 Runtime 留出来,就会有人投入精力,实现取向不同的 Runtime,因为 TA 们会觉得,相比 M:N 这种通用最优的设计,我的程序可能更在意极端的响应延时,于是 TA 们可能会设计实现一个 M:1 (协程不会跨线程执行)的 Runtime,从而避免了线程间数据同步,也不会加入任务窃取这种对 M:N 看起来很合理,但实际有开销的花活儿。这对于一些网络代理服务、存储服务、金融量化行业,是喜闻乐见的。


曾经看到一个很精髓的观点说,(软件)工程是取舍的艺术。


至于问题中提到的问题,C++/Rust 如何结合使用不同的 Executor,我能力有限,未曾找到通用方法,只能 case by case 的看,一个思路是精心设计两个 Executor 的边界,通过接口交换数据(如果只涉及内存,不涉及 IO),或者在主调一侧的 Executor 做些对阻塞友好的支持,例如设计一组专门用来等待阻塞的线程池/协程池,这部分允许将线程数量开的更多,通过一些系统线程级的同步原语,阻塞等待另一个 Executor 的调用结果,从而不会阻塞主调侧主协程的线程。后者经常是有很多坑,甚至是可能无法实现的,因而也是尽可能避免。

在 C/C++ 上见到过另一种操作是 hook 系统调用,将所有系统调用塞入私货,理论上,那些阻塞调用的代码可以变为契合协程 Runtime 的,开源的 HTTP 库、MySQL/Redis 客户端也许能用,但一方面我还没见过这种做法下真正好用、省心的实现,另一方面,这种方法只能用来做有栈协程,考虑到各种情况下的线程同步问题(包括调用的第三方的阻塞 IO 库内,也可能封装有多线程操作),很难认为这个方法从逻辑上一定可行。最后,这种思路只可能解决协程调同步,不解决两种 Executor 下互调。

如果是我遇到多 Executor 问题,会宁愿优先考虑拆成两个服务或者进程,通过两个 Executor 都能支持的一种方式通信,例如 HTTP 或进程间通信的一些方式。如果这种问题经常遇到,会考虑离职......