i007.cc

i007.cc

优先队列-降维打击

05.价值资料

C#是有栈协程还是无栈协程?为什么?

C# 的 async/await 是无栈协程,而且它是这套模式的鼻祖,C++20、Rust、Python、JavaScript 的 async/await 基本都是跟它学的。


C# 编译器做的事和 C++20 几乎一样

csharp
// 你写的代码
async Task<int> ComputeAsync() {
    int x = await FetchAAsync();
    int y = await FetchBAsync(x);
    return x + y;
}

 

编译器把它变成一个状态机类(可以用 ILSpy 反编译看到):

csharp
// 编译器生成的(简化版)
private struct ComputeAsync_StateMachine : IAsyncStateMachine {
    public int state;       // 当前在哪个暂停点
    public int x;           // await 点之间需要保留的局部变量
    public int y;
    public AsyncTaskMethodBuilder<int> builder;

    public void MoveNext() {
        switch (state) {
            case 0:
                // 启动 FetchAAsync,注册续体,返回
                state = 1;
                var awaiter1 = FetchAAsync().GetAwaiter();
                if (!awaiter1.IsCompleted) {
                    builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this);
                    return;   // ← 真正的"暂停",就是 return
                }
                goto case 1;
            case 1:
                x = awaiter1.GetResult();
                state = 2;
                // 启动 FetchBAsync...
                // ...
            case 2:
                y = awaiter2.GetResult();
                builder.SetResult(x + y);
                return;
        }
    }
}

 

和 C++20 协程帧几乎是同一个设计,都是:堆上的状态机对象 + 只存活跃局部变量 + 靠 return 实现暂停


为什么会有人误以为 C# 是有栈的

可能来自几个混淆点:

Thread 是有栈的(这只是线程,不是协程);Task 背后有线程池(线程有栈,但 async/await 的暂停发生在状态机层面,和线程的栈无关);以及 C# 也有 yield return(同样是无栈,也是编译器生成的状态机,更早在 C# 2.0 就引入了)。


async/await 的传播路径

C# 5.0 (2012)  ← 首创 async/await 关键字
      │
      ├──► Python 3.5 (2015)     async def / await
      ├──► JavaScript ES2017     async function / await  
      ├──► Rust (2019)           async fn / .await
      ├──► Swift 5.5 (2021)      async / await
      └──► C++20 (2020)          co_await(机制更底层,可定制)

 

C# 的设计者 Anders Hejlsberg(也是 TypeScript 之父、Turbo Pascal 作者)在 2012 年把这个模式带进了主流,之后整个行业几乎都跟进了。


C# 有没有有栈协程?

标准库里没有。如果真的需要有栈协程,只能通过 P/Invoke 调用 Windows 的 Fiber API,或者用第三方库,但这在 C# 生态里极其罕见,因为 async/await 已经够用了。

 

c#有GC,如果设计成有栈协程显然对于调用者更友好啊

这个观点很有道理,值得认真分析——GC 确实移除了有栈协程最大的技术障碍,但 C# 团队还是选择了无栈,这背后有几层真实的权衡。


你说的是对的:GC 确实扫除了关键障碍

C++ 和 Rust 不能做有栈协程的可增长栈,核心原因是栈增长时需要复制内存,而栈上的指针就失效了——语言本身无法自动修复这些指针。

Go 能做可增长的 goroutine 栈,正是因为 GC 运行时知道所有指针在哪里,复制栈时可以把所有指针一起更新。C# 同样有 GC,理论上完全可以做同样的事。

所以从技术可行性上讲,C# 做有栈协程没有根本性障碍。


但 C# 仍然选了无栈,原因是多层的

1. 内存规模:差距在量级上

有栈协程(假设最小栈 4KB,类似 Go 的起始大小):
  100 万并发 × 4KB = 4GB

无栈协程(C# 的 async 状态机,典型几百字节):
  100 万并发 × 500B ≈ 500MB

 

ASP.NET Core 是 C# 最重要的使用场景之一,一台服务器承载几十万并发请求是常态需求。在这个量级上,两种方案的内存差距是 8 倍以上,不是可以忽略的工程细节。

2. 切换开销:状态机跳转比寄存器切换快

有栈协程切换时必须保存和恢复完整的寄存器集合(rsp、rbp、rip 等约 15 个),因为 CPU 不知道你要切换协程。

无栈协程的”切换”本质上只是一次普通函数调用加一个 switch 跳转,没有额外的寄存器保存动作,CPU 流水线更友好。

在高频切换场景(每秒切换百万次以上)这个差距是可测量的。

3. 编译器优化空间:无栈协程有一个独特机会

当编译器能证明一个 async 方法一定同步完成(比如 await 的 Task 已经完成),它可以把状态机分配在调用者的栈上而不是堆上,完全消除堆分配。

有栈协程做不到这个,因为栈是预分配的,这个优化机会天然不存在。

4. “传染性”是设计意图,不是缺陷

这一点是争议最大的,但 C# 团队是有意为之的。

csharp
// 有栈协程的世界:
void ProcessRequest() {
    var data = FetchFromDb();   // 这会挂起协程吗?看不出来
    var result = Transform(data); // 这呢?
    Save(result);                 // 这呢?
}

// 无栈协程的世界:
async Task ProcessRequest() {
    var data = await FetchFromDbAsync();   // 明确:这里会挂起
    var result = Transform(data);          // 明确:这里不会
    await SaveAsync(result);               // 明确:这里会挂起
}

 

每个 await 都是一个并发边界——这个点之后,线程可能去干别的事,这个点之后读到的任何共享状态都可能已经被其他操作修改。让这些边界在代码里显式可见,对推理并发正确性非常重要。

有栈协程的”对调用者友好”,另一面是”并发边界对读者不可见”。大型项目里,这会让 code review 和 bug 定位变得困难。


Go 的选择和代价

Go 选了有栈协程(goroutine),并且承受了对应的代价:

go
// Go 代码:
func processRequest() {
    data := fetchFromDb()    // 可能挂起 goroutine,但看不出来
    result := transform(data)
    save(result)
}

 

Go 能做到这一点,是因为它从语言设计上就接受了”任何函数调用都可能挂起”这个前提,并且用 goroutine + channel 作为唯一的并发模型——整个生态没有”同步版”和”异步版”的分裂。

但 Go 也为此付出了代价:没有”这个函数调用会不会切换”的静态可见性,某些微妙的竞态条件更难发现;goroutine 泄漏(忘记退出的 goroutine 会一直持有栈内存)是 Go 程序的常见问题。

C# 面对的生态不同——它继承了大量同步的 .NET 库,如果选有栈协程,就必须解决”同步调用在协程里自动变异步”的问题,这是个比 Go 从零开始更难的问题。


真正的遗憾在哪里

如果说 C# 无栈协程有什么真实的痛点,不是传染性本身,而是传染性导致的生态碎片化

csharp
// 同一件事,存在两个版本
File.ReadAllText(path)       // 同步版
File.ReadAllTextAsync(path)  // 异步版

// 混用时出问题(在 async 上下文里调同步版会死锁):
async Task BadExample() {
    var text = File.ReadAllText(path);  // 能跑,但阻塞了线程
}

 

.NET 生态里”同步 API 和异步 API 并存”的双轨局面,才是开发者日常最头疼的问题,而不是 async 关键字本身要不要写。这个问题有栈协程确实能从根本上避免——Go 就没有这个”同步版/异步版”的分裂。


一句话总结

C# 有能力做有栈协程,但选择了无栈,换来的是更低的内存消耗、更显式的并发边界、更大的编译器优化空间。真正的代价不是写 async 关键字的麻烦,而是整个生态被迫维护同步和异步两套 API——这个分裂成本,在 Go 从零设计成有栈协程的生态里根本不存在。

发表回复