Go语言的goroutine是协程对吗?他是什么原理?

176 阅读7分钟

在 Go 语言中,goroutine 可以被视作一种 协程(Coroutine) ,也常被称为  “绿色线程(Green Thread)” 。它们由 Go 的 runtime(运行时)进行管理和调度,并不是操作系统层面的线程。相比操作系统线程,goroutine 更加轻量,创建和切换的开销也更低,因此能够同时运行大量并发任务。

下面我们来分几个方面简要说明 goroutine 的原理和特点。

1. goroutine 和协程的关系

• 协程(Coroutine)  是一种用户态线程(或说更轻量的执行单元),它与操作系统内核线程(kernel thread)相比,不需要内核参与上下文切换

• goroutine 就是 Go 为我们实现的协程机制,Go 程序中每创建一个 goroutine,都相当于创建了一个在用户态调度的轻量级线程。

因此,从概念上说,goroutine 就是 Go 语言对协程的一种具体实现。

2. Go 的调度模型(G-P-M 模型)

Go 语言 runtime 使用了一个 G-P-M 的调度模型,这三个字母代表了:

1. G(Goroutine) :代表一个正在执行(或可执行)的 goroutine,包含其栈、程序计数器等执行上下文信息。

2. M(Machine) :代表一个操作系统线程。Go runtime 最终还是要跑在 OS 线程之上,一个 M 就是一个真正的操作系统线程。

3. P(Processor) :表示可执行 Go 代码的“处理器上下文”,包含运行所需的调度信息、队列等。它的数量通常由 GOMAXPROCS 决定(默认等于 CPU 核心数),也可以手动设置。

简化理解:

• 多个 goroutine(G)依附在若干个 P 之上,

• 多个 P 最终由若干个 OS 线程(M)来驱动执行。

• 当一个 goroutine 阻塞或执行完成后,runtime 就会把这个 goroutine 挂起或销毁,把处理器(P)让给其他的 goroutine 继续运行,从而实现并发。

很多人会疑惑:

• 既然 M 就是 OS 线程,那操作系统直接调度这些线程就好了,为什么还需要一个 P

• 反过来也会想,既然有了 P,里面已经包含队列、上下文,那么为什么还需要 M 去执行?

P 里保存了本地队列、调度所需的一些结构、goroutine 列表等

• 一个 P 可以同时让一个 goroutine 在 CPU 上运行;要想并行执行多个 goroutine,就需要多个 P。

• GOMAXPROCS 决定了默认的 P 的数量(通常与 CPU 核数相等),从而决定了程序能同时并行执行多少个 goroutine。

在运行时,一个 P 会绑定到一个 M(OS 线程)  上,形成“一个操作系统线程 + 一个调度器上下文”的组合。这样就可以让调度和执行相对独立:

1. 调度信息放在 P

• P 拥有本地队列,记录可以在这个 CPU 核心上要跑的 goroutine 列表。

• P 还可能有一些性能优化策略,比如 work stealing(从其他 P 抢可执行的 goroutine)。

• 当一个 goroutine 运行完毕或被挂起,P 可以立刻切换到队列里下一个 goroutine 继续执行,不用切换 OS 线程。

2. 真正执行放在 M

• P 负责调度、排队,但总得有 实体 去拿到 CPU 时间片来跑代码,这个实体就是操作系统线程 M。

• M 被操作系统调度上 CPU,执行 goroutine 中的代码。

• 如果 M 阻塞了,Go runtime 可以把这个 P“解绑”出来,再绑定到另一个空闲的 M,继续运行其他 goroutine。这样就不会因为一个阻塞把并发能力浪费掉。

换句话说,P 负责“我该跑哪些 goroutine、怎么排队”,M 负责“我把 CPU 时间拿来干活” 。如果没有 P,所有 goroutine 的管理、排队、调度逻辑都得塞进 M 自己,且难以灵活地切换到别的线程;如果没有 M,P 只是个纯调度结构,没有 OS 线程执行,goroutine 就跑不起来。

要理解这套设计,得先明白 Go runtime 想解决哪些问题,然后看一下 P 存在的意义 和 M 存在的意义 分别是什么。

这样做的好处是:

• 我们只需要关心如何启动 goroutine,不用关心底层如何分配 OS 线程;

• runtime 会根据实际的 CPU 核心数和任务情况进行动态调度,尽可能地让所有核心都被利用起来(并行),并且切换 goroutine 的成本比切换 OS 线程要低得多。

3. goroutine 为何“轻量”?

1. 初始栈很小

每个 goroutine 的初始栈大小只有几 KB(2KB 左右),相比操作系统线程动辄 MB 级别的栈空间要小很多。此外,goroutine 的栈会根据需要 动态扩容 或 收缩,而不需要像 OS 线程一样一次性分配大块内存。

2. 用户态调度

goroutine 的切换由 Go runtime 在用户态完成,不必进入内核态做上下文切换;同时操作系统也不需要为每个 goroutine 分配独立的线程控制块(TCB)。因此上下文切换速度更快、开销更小。

3. 同一个线程可以复用

当一个 goroutine 被阻塞(如 I/O),runtime 可以在同一个 OS 线程上调度另一个 goroutine 执行,而不是让线程空等。

4. goroutine 的运行机制

1. 启动 goroutine

只需要使用 go func() { ... } 语法,就可以创建一个新的 goroutine。Go runtime 会将这个 goroutine 放到待调度队列中,等待被分配到某个 P,再由 P 对应的 M(OS 线程)来执行。

2. 阻塞和切换

• 当 goroutine 遇到阻塞操作(比如文件 I/O 或网络 I/O),如果该操作是由 Go runtime 管理的,runtime 会把这个 goroutine 暂时挂起,把执行机会让给其他 goroutine。

• 如果遇到系统调用等无法被 runtime 直接“劫持”的场景,Go runtime 可能会新建或启用额外的 OS 线程,以保证不会让整个线程卡死。

3. 调度器工作

• 调度器会周期性地检查 goroutine,做出一些抢占或调度决策,比如 goroutine 是否长时间运行、是否阻塞等;

• 如果 goroutine 占用 CPU 太久,调度器也会进行  “抢占调度” (从 Go 1.14 开始更积极地引入抢占点),防止其他 goroutine 饥饿。

5. 对比其他模型

• 与操作系统线程

如果我们直接使用系统线程,每个线程都可能会被内核调度,创建和切换都更昂贵;Go 的 goroutine 允许我们用更低的成本创建海量并发任务。

• 与 Node.js 异步回调(事件驱动)

Node.js 是单线程事件循环,所有回调在同一个线程里调度,依靠事件或回调来实现并发 I/O。一旦有 CPU 密集型任务,会阻塞事件循环。而 Go 提供了多核并行能力(多个 M),对于 CPU 密集型或者大量并发的场景往往更具优势。

• 与协程库 / 协程语言

例如 Python 的 gevent、Lua 协程等,原理上都类似:在用户态维护任务切换,让编程像同步代码一样写异步逻辑。Go 语言将这一切集成在语言层面和 runtime 中,使用体验比较好,生态也成熟。

6. 小结

• goroutine 就是 Go 为我们提供的 协程 实现,通过 G-P-M 调度模型和 用户态线程 的方式,在保证易用性的前提下,极大提高了并发能力。

• 相比 OS 线程,goroutine 的创建和切换都更加轻量;

• Go runtime 会自动将 goroutine 映射到合适数量的 OS 线程上,并在其中进行调度。

也正是因为这套设计,Go 语言在构建 高并发、高可用 服务时有着天然的优势。