C# Task/ThreadPool async/await对比Golang GMP
如果你同时写过 C# 和 Golang,那你大概率有过这种感受:
在 C# 里写并发,你是在小心翼翼地避免犯错;
在 Go 里写并发,你是在顺着语言的本能往前走。
这种差异,不是语法糖造成的,也不是谁更“现代”,而是两种完全不同的工程出身和历史路径,最终凝结成了今天的并发模型。
C# 的并发体系,是从 Thread → ThreadPool → Task → async/await 一路演进而来。
它背后承载的是二十多年 Windows / CLR / 企业级系统的历史负担:稳定第一、兼容优先、渐进演化、不轻易推倒重来。
而 Golang 从诞生第一天起,就站在另一个时代节点上。多核已经是常态,服务端天然高并发,线程太重、锁太危险、上下文切换太贵。于是 Go 干脆绕过传统路径,直接把 并发和调度写进了语言运行时,设计了 GMP。
再回头看 C# async/await 和 Golang GMP,你会发现一个事实:
并发模型不是“怎么写代码”,而是“你把复杂度放在哪里”。
它决定的不是某个方法是否 async,而是整个系统的 吞吐上限、故障形态、团队协作成本。
一、Task / ThreadPool / async / await:C# 并发体系的真实结构
C# 的并发模型,本质是:用 ThreadPool 承载执行,用 Task 表达意图,用 async/await 避免浪费线程。
1.ThreadPool:真正干活的“执行资源”
1.1 ThreadPool 是什么?
ThreadPool 是 CLR 管理的一组 OS 线程:
- 线程来自操作系统
- 创建成本高
- 数量受控(Min / Max)
- 被整个进程共享
ThreadPool.QueueUserWorkItem(_ => { // 实际跑在某个 OS 线程上 }); 1.2 ThreadPool 的核心目标只有一个
最大化线程利用率,避免频繁创建/销毁线程
它不是为“无限并发”设计的,而是为**“合理吞吐”**设计的。
1.3 ThreadPool 最怕什么?
阻塞。
Thread.Sleep(...) Task.Wait() Result WaitOne() 一旦线程池线程被阻塞:
- 这个线程无法干活
- CLR 只能尝试慢慢补线程
- 高并发下会直接拖垮吞吐
2.Task:不是线程,是“调度语义”
这是被误解最多的部分。
2.1 Task 到底是什么?
Task ≠ Thread
Task 是:
- 一个“未来会完成的工作”
- 一个调度描述
- 一个结果容器
Task task = Task.Run(() => DoWork()); 真正发生的事:
- Task 被提交
- 调度器决定什么时候
- 决定用哪个线程池线程
- 执行完成后标记状态
2.2 Task 的本质作用
把“做什么”和“用哪个线程做”解耦
这对架构非常重要:
- 代码层:表达业务逻辑
- 运行时:决定资源分配
2.3 Task 并不保证并发
await Task.Run(A); await Task.Run(B); 这是顺序执行。
3. async / await:非阻塞,不是多线程
这是整个模型的核心,也是最容易被神化的地方。
3.1 async / await 本质是什么?
编译器生成的状态机 + 线程释放机制
public async Task FooAsync() { await BarAsync(); DoSomething(); } 3.2 async 的真实价值
不是并发,而是:
在高并发下,减少线程占用时间
这就是为什么 ASP.NET Core 推荐使用 async。
4. 调度源码
4.1 任务调度判断是从线程池中获取线程还是创建线程

4.2 线程池创建线程

4.3 标记空闲线程

4.4 释放空闲线程

C# 线程池中的线程并不是“随用随建、用完即收”的。整体原则可以概括为:创建谨慎,释放极少。
创建时机:
CLR 启动时,线程池几乎是空的,不会预先创建大量线程。只有当任务进入队列、现有线程全部繁忙、并且出现吞吐压力时,线程池才会考虑创建新线程。扩容采用保守的 Hill Climbing 算法:一次只增加少量线程,并根据吞吐变化决定是否继续扩容。因此,线程池对突发高并发的反应是“慢热”的。
创建上限:
线程池有最大线程数限制,一旦达到上限,就不会再创建新线程,后续任务只能排队等待。
释放策略:
线程池线程极少被释放。线程即使长时间空闲,通常也会保留在池中以备复用,而不是频繁销毁。换句话说,线程池更像“长期资产”,不是临时资源。
关键结论:
阻塞线程池线程是架构级错误:线程不会很快回收,却会长期占用名额,直接压低整个进程的吞吐能力。
二、Golang GMP 运行时调度器(runtime scheduler)
1. G / M / P 各自的职责
1.1 G:Goroutine(用户态执行单元)
G 代表一个 goroutine,本质是:
- 一段待执行的函数
- 一组寄存器上下文
- 一个可增长的栈(初始约 2KB)
特点:
- 创建成本极低
- 切换发生在用户态
- 数量可以轻松达到几十万甚至百万级
G 不直接和 OS 线程绑定,它只是“可被调度的任务”。
1.2 M:Machine(OS 线程)
M 对应一个真实的操作系统线程:
- 负责执行 G
- 执行系统调用
- 与 OS 调度器交互
特点:
- 数量不固定
- 可以被创建、休眠、销毁
- 不是并发度的控制者
M 的存在意义只有一个:让 G 真正跑在 CPU 上。
1.3 P:Processor(调度器资源)
P 是 GMP 的关键,也是最容易被忽略的角色。
P 代表:
- 一组可执行 G 的本地队列
- 调度器所需的上下文
- 执行 Go 代码的“许可证”
没有 P,M 不能执行 Go 代码。
重要规则:
- P 的数量 =
GOMAXPROCS - 默认等于 CPU 核心数
- 真正的并发度由 P 决定
2. G、M、P 如何协作
基本关系可以概括为:M 必须绑定一个 P P 才能调度并执行 G
运行过程:
- M 绑定一个 P
- 从 P 的本地队列取 G
- 执行 G
- G 执行完成或被挂起
- M 继续调度下一个 G
3. 调度队列设计
3.1 本地队列(Local Run Queue)
- 每个 P 都有自己的 G 队列
- 大多数调度发生在本地
- 减少全局锁竞争
这是 Go 高性能调度的关键设计。
3.2 全局队列(Global Run Queue)
- 当本地队列满
- 或新创建的 G
- 或负载不均衡时
G 会被放入全局队列,供其他 P 窃取。
3.3 Work Stealing(工作窃取)
当某个 P 没活可干:
- 会从其他 P 的本地队列“偷一半”
- 或从全局队列获取 G
保证 CPU 不会空转。
4. 阻塞时调度器如何处理(核心优势)
4.1 系统调用阻塞
当 G 执行系统调用(IO、sleep 等):
- G 被标记为阻塞
- 执行它的 M 会 解绑 P
- P 立刻绑定到新的 M
- 继续调度其他 G
结果:阻塞不会拖住并发度
4.2 用户态阻塞(channel / mutex)
当 G 因 channel、mutex 等被阻塞:
- G 被挂起
- M 继续执行其他 G
- 无需 OS 介入
5. 抢占式调度(Go 1.14+)
早期 Go 是协作式调度,存在长时间霸占 CPU 的问题。
从 Go 1.14 起:
- 在函数调用、循环等位置插入安全点
- 运行时可强制抢占 G
- 防止“一个 goroutine 跑死”
这让调度器更加公平和可控。
6. M 的创建与回收策略
- M 会在需要时创建
- 当阻塞过多、P 无法运行时补充
- 空闲 M 会休眠
- 长期不用的 M 会被回收
但 M 的数量通常远小于 G 的数量。
7. 调度源码
7.1 G(goroutine)结构体

7.2 M(Machine / OS Thread)结构体

7.3 P(Processor / 调度器资源)结构体

7.4 设置时最终会创建 / 销毁 P

p的数量等于GOMAXPROCS。
7.5 调度主循环(schedule)


7.6 本地队列 runq

7.7 抢占式调度(Go 1.14+)
7.7.1 suspendG(gp *g)
suspendG(gp *g) 是 Go runtime 用来挂起(暂停)一个正在运行的 goroutine 的核心方法,它是抢占式调度的基础。可以把它理解为在安全点强行暂停一个 goroutine,让 CPU 可以去跑其他 goroutine.

s := readgstatus(gp) 检查g的状态。


7.7.2 resumeG
resumeG用来 恢复被 suspendG 挂起的 G

C# 的 ThreadPool + Task + async/await 是协作式调度,线程由线程池管理,Task 异步依赖 await 驱动让出线程,CPU 密集任务容易占用线程,扩展受限。
Go 的 GMP 模型 是抢占式调度,goroutine 轻量、数量几乎无限,P 管理本地队列,高效调度并支持异步安全点抢占,CPU 利用率高且公平。
相比之下,我更喜欢 Go 的 GMP:调度透明、轻量高效、抢占式设计天然防止长时间占用 CPU,更适合大规模并发场景。