C# Task/ThreadPool async/await对比Golang GMP
C#和Go的并发模型差异源于不同的设计哲学。C#基于ThreadPool+Task+async/await,强调线程复用和渐进演化,但线程阻塞会严重影响吞吐。Go的GMP模型则将goroutine与OS线程解耦,通过P处理器实现高效调度,支持抢占和工作窃取,天然适合高并发场景。两种模型本质上是"复杂度分配"的不同选择:C#将复杂度交给开发者控制,Go则内置于运行时。Go的轻量级
如果你同时写过 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,更适合大规模并发场景。
更多推荐



所有评论(0)