如果你同时写过 C#Golang,那你大概率有过这种感受:

在 C# 里写并发,你是在小心翼翼地避免犯错
在 Go 里写并发,你是在顺着语言的本能往前走

这种差异,不是语法糖造成的,也不是谁更“现代”,而是两种完全不同的工程出身和历史路径,最终凝结成了今天的并发模型。

C# 的并发体系,是从 Thread → ThreadPool → Task → async/await 一路演进而来。
它背后承载的是二十多年 Windows / CLR / 企业级系统的历史负担:稳定第一、兼容优先、渐进演化、不轻易推倒重来。

而 Golang 从诞生第一天起,就站在另一个时代节点上。多核已经是常态,服务端天然高并发,线程太重、锁太危险、上下文切换太贵。于是 Go 干脆绕过传统路径,直接把 并发和调度写进了语言运行时,设计了 GMP。

再回头看 C# async/awaitGolang 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

运行过程:

  1. M 绑定一个 P

  2. 从 P 的本地队列取 G

  3. 执行 G

  4. G 执行完成或被挂起

  5. 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,更适合大规模并发场景。

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐