Go 的 Goroutine 与协程

Go 的 Goroutine 与协程

•约 5.5 千字

很多文章把协程解释成「比线程轻的东西」,把 async/await 解释成「暂停一下再继续」,再把 go func() 解释成「启动一个异步任务」。这些说法不能算错,但它们没有回答最关键的问题:到底保存了什么上下文,谁负责保存,谁决定什么时候恢复?

如果不回答这个问题,进程、线程、协程、goroutine、异步函数就会变成一串互相替换的术语。更麻烦的是,go func() 和 await 看起来都像是在控制执行权,却其实做着完全不同的事情。

本文的主线只有一句话:go func() 是 spawn,负责生成一个新的并发执行单元;await 是 suspend,负责保存当前异步计算的后续 continuation。Go 的 goroutine 是一种有栈用户态协程,它用 Runtime 保存完整调用栈,因此不需要把每个函数改写成 async。

一、先把几个词放回正确的层次

1. 并发、并行和异步

并发(Concurrency)描述的是多个任务在一段时间内共同推进。它不要求同一时刻真的有多个 CPU 核心执行它们。单核 CPU 也可以交替推进 A 和 B:

text
时间 ───────────────────────────────>
CPU  A A A A | B B | A A | B B B | A

并行(Parallelism)则要求多个执行单元在同一时刻使用多个 CPU 核心。并发是组织方式,并行是硬件执行结果。一个程序可以并发但不并行,也可以为了并行而采用并发结构。

异步(Asynchronous)描述的是等待方式:调用者发起一个可能很慢的操作后,不必停在原地等结果,可以先做别的事情,结果准备好以后再继续。异步任务最终可以由事件循环、线程池、内核线程或 goroutine 执行。

因此:

  • 协程是可以被暂停和恢复的执行单元;
  • 并发是多个任务共同推进的程序结构;
  • 异步是把等待与当前调用路径解耦的方式;
  • 并行是多个任务同时占用多个执行资源。

它们不是同一维度的概念。

2. 进程、线程和协程分别是什么

进程是资源隔离和地址空间的边界。一个进程拥有自己的虚拟地址空间、文件描述符、权限和至少一个执行线程。进程之间默认不能直接读写对方的普通变量,通信需要管道、socket、共享内存或消息队列。

线程是进程内的一条执行路径。多个线程共享进程的代码、堆和大部分资源,却各自拥有寄存器现场和调用栈。线程由操作系统调度,能在多个 CPU 核心上真正并行,但创建、调度、阻塞和同步都有内核与硬件成本。

协程是由用户态 Runtime 或程序自身管理的可暂停执行单元。它通常比内核线程轻,但「轻」不是定义。真正重要的是:协程的上下文由谁保存,挂起和恢复由谁安排。

对象主要边界谁调度是否共享普通地址空间
进程资源和地址空间操作系统否
内核线程执行路径操作系统同一进程内是
用户态协程可暂停的任务Runtime / executor / 程序通常是
异步函数可被改写的计算片段Runtime / 事件循环取决于宿主环境

二、本文的核心:go 是 yield 吗

1. go func() 做的是 spawn

go
go f(x)

这条语句的核心语义是:创建一个新的 goroutine,让它从 f 的入口开始执行。当前 goroutine 不需要停下来等待它,也不需要把「从这里往后的代码」保存成 continuation。

可以把它近似理解为:

text
当前 goroutine:继续执行下一条语句
新 goroutine:从 f 的入口开始,等待 Runtime 调度

新 goroutine 可能很快获得执行机会,也可能被放在运行队列里一段时间;当前 goroutine 甚至可能在它开始运行之前就结束。go 是一个并发边界,不是一个「现在立刻切换过去」的命令。

为什么必须显式写 go?因为普通函数调用默认是顺序的:

go
result := query()
render(result)

如果所有函数调用都自动产生并发任务,程序的基本语义就会被破坏。局部变量、异常传播、资源释放和性能都将无法从代码表面判断。go 让「这里开始出现一个独立并发活动」成为可见的程序事实。

2. await 做的是 suspend

await 关注的不是创建谁,而是当前异步计算已经运行到一个可能尚未完成的操作:

text
user = await loadUser()
render(user)

如果 loadUser() 尚未完成,当前异步函数需要保存 render(user) 这段后续计算,向 Runtime 返回,等结果准备好以后再恢复。这个「结果完成后接下来做什么」就是 continuation(延续)。

所以 await 的近似语义是:

text
当前异步函数:保存 await 之后的 continuation,然后返回
Runtime:执行其他可运行任务
I/O 完成:把 continuation 放回队列
Runtime:恢复 continuation

这和 go func() 的差别可以压缩成一张表:

语法动作当前执行单元是否必须暂停保存的东西
go f()生成新任务否新任务的入口、参数和运行现场
await future等待当前任务的结果未完成时是await 后面的 continuation、局部变量和状态
yield主动让出当前任务是生成器或协程的可恢复现场
runtime.Gosched()建议让出调度机会通常会当前 goroutine 的运行现场

因此,go 不是 yield。如果想表达「当前 goroutine 让别人先运行」,更接近的是 runtime.Gosched();如果想表达「等某个任务完成后继续」,则是 channel、Future、Task 或 await。

3. go 和 await 都是定界,但定界对象不同

可以说两者都在源代码中划定了控制流边界,但不能因此把它们视为同一种机制:

text
go func()
    定界:一个新的并发执行单元
    保存:新 G 的入口和有栈运行现场
    当前函数:默认继续执行

async/await
    定界:一段可以暂停和恢复的异步计算
    保存:await 后的 continuation 和状态
    当前函数:未完成时返回,完成后从状态继续

这也是为什么「go func() 需要显式写出来」并不能反驳「await 不是显式切换上下文」。前者显式声明的是 创建新任务,后者显式标记的是 当前任务可以在哪里挂起。

三、Continuation:async/await 究竟改写了什么

1. 什么是 continuation

Continuation 不必先理解成某种神秘的编程语言黑魔法。它就是:当前计算完成后,剩下要做的全部事情。

text
user = loadUser()
render(user)

在 loadUser() 返回之前,render(user) 就是它的 continuation。如果用回调表达:

text
loadUser(user => render(user))

函数接收的不只是输入,还接收「结果产生以后调用谁」。这就是 Continuation-Passing Style(CPS,延续传递风格)的直观版本。回调嵌套过深时,人们会觉得代码难以维护,本质上是因为程序员被迫手动管理 continuation。

2. 为什么叫 Delimited Continuation

Scheme 的 call/cc 可以捕获非常大的控制上下文,甚至接近整个运行时;这种能力很强,但把它强行接入到语言中会改变异常、资源释放、栈和调试工具的底层架构。

Delimited Continuation(定界 continuation)则只捕获某个明确边界以内的后续计算。async 函数、async block 或 executor 定义的任务边界,都可以提供这样的范围。await 不会把整个 Runtime 的「无限未来」都保存下来,它只要求编译器处理当前异步计算中,从这个挂起点到该计算结束的后续路径。

因此,把 async/await 理解成「借助语法和编译器,把定界 continuation 隐藏起来」是有价值的;但不要把它简单说成 shift/reset 的一对语法别名。它们在语言语义、异常传播、资源管理和 Runtime 设计上仍有差异。

3. 一个异步函数如何变成状态机

考虑:

text
async function loadPage() {
    user = await loadUser()
    posts = await loadPosts(user.id)
    return render(user, posts)
}

编译器可以近似生成:

text
struct LoadPage {
    state: Start | WaitingUser | WaitingPosts | Done
    user: User?
    posts: Posts?

    poll() -> Pending | Ready<Page> {
        switch state {
        case Start:
            future = loadUser()
            if not future.isReady() {
                state = WaitingUser
                future.register(this)
                return Pending
            }
            user = future.result()
            state = WaitingUser
            fallthrough
        case WaitingUser:
            future = loadPosts(user.id)
            if not future.isReady() {
                state = WaitingPosts
                future.register(this)
                return Pending
            }
            posts = future.result()
            state = Done
            return Ready(render(user, posts))
        case WaitingPosts:
            posts = future.result()
            state = Done
            return Ready(render(user, posts))
        case Done:
            return Ready(cached_result)
        }
    }
}

真实编译器还要处理异常、取消、局部变量提升、生命周期和优化,但基本思路不变:每个挂起点切出一个状态,跨越挂起点仍要使用的变量被放进长期存储,Future 完成后由 executor 再次驱动这个状态机。

如果没有状态机,最笨的方案可以是:

text
while not future.isFinished():
    yield_to_scheduler()

这会忙等,浪费 CPU。实际 Runtime 会在 I/O、定时器或其他事件完成时,直接把对应 continuation 放回可运行队列。await 负责提供挂起和恢复的语义边界,Runtime/executor 才负责决定下一个运行谁。

四、有栈与无栈协程:两种保存上下文的办法

1. 有栈协程:保存调用栈

有栈协程拥有自己的调用栈。假设调用链是:

text
main → handle → readRequest → network read

当 network read 等待数据时,Runtime 可以保存这个协程的栈和寄存器现场,让出执行资源;数据到达以后,再恢复同一个栈,从原来的位置继续执行。

这意味着 main、handle 和 readRequest 不需要全部变成特殊的 async 函数。普通函数调用可以自然地嵌套,异常和栈追踪也更接近传统线程。

有栈模型的代价是 Runtime 必须真正理解并维护调用栈:栈从哪里分配,如何增长和收缩,如何与垃圾回收配合,如何抢占,进入阻塞系统调用时如何让出线程,恢复时如何重新获得执行资源。

2. 无栈协程:保存状态机

无栈协程不保存一整条独立调用栈,而是把函数改写成状态机。它只在编译器或 Runtime 定义的挂起点暂停:await、yield 或等价的 poll 边界。

无栈模型通常有三个特点:

  1. 跨越挂起点的局部变量必须被提升到状态对象中;
  2. 函数需要知道自己下次从哪个状态恢复;
  3. 调用异步函数的上层代码通常也必须进入异步上下文,这就是函数染色。

无栈并不等于更差。状态机可能比完整调用栈更紧凑,挂起点也更明确;而且语言只要能生成状态机,就可以在普通线程、事件循环或第三方 executor 上运行,不必提供完整的用户态线程 Runtime。

3. 有栈和无栈的真正分界

维度有栈协程无栈协程
保存内容调用栈、寄存器和运行现场状态、局部变量、后续路径
挂起位置通常可以深入普通函数调用链通常只能在编译器插入的挂起点
函数染色较少明显
Runtime 负担栈管理、抢占、调度、阻塞适配executor、Future、状态机驱动
调试体验更像线程调用栈需要理解状态机和异步栈
典型例子Go goroutine、Java Virtual ThreadRust Future、Python asyncio、JavaScript Promise

这里的「有栈」不是说协程拥有一块永远固定的大内存;Go 的 goroutine 栈可以从很小的空间开始,运行时按需增长和收缩。这里的「无栈」也不是说完全没有栈,而是说异步任务的暂停与恢复不依赖一整条独立的协程调用栈。

五、goroutine:Go 的有栈用户态协程

1. 直接回答:goroutine 是协程吗

是。更精确地说,goroutine 是 Go Runtime 提供的一种有栈、用户态调度的并发执行单元。Go 官方文档有时把它称为「由 Go Runtime 管理的轻量线程」,这是面向使用者的直观说法;从实现模型看,它不是一个操作系统线程,而是由 Runtime 复用多个操作系统线程来执行。

它具备协程的核心特征:有自己的可增长栈,可以被挂起和恢复,由用户态 Runtime 调度,并且可以和大量其他 goroutine 共享有限的操作系统线程。

2. G、M、P 如何协作

Go Runtime 常用 G、M、P 描述调度器:

对象含义作用
Ggoroutine要执行的代码、栈和状态
Mmachine一个操作系统线程
Pprocessor执行 Go 代码所需的 Runtime 资源

M 必须持有 P 才能执行 Go 用户代码。G 被放进 P 的本地运行队列或全局队列,等待某个 M 执行。P 的数量通常由 GOMAXPROCS 决定;M 的数量可以比 P 多,因为某些 M 可能阻塞在系统调用或 cgo 中。

text
大量 G(goroutine)
        │ Go Runtime 调度
        ▼
有限的 P(执行 Go 代码所需资源)
        │ 绑定
        ▼
若干 M(操作系统线程)
        │
        ▼
CPU 核心上的并发或并行

这就是 M:N 模型。大量 G 不需要对应大量内核线程;一个 goroutine 阻塞在网络 I/O 时,网络轮询器可以等待事件,让其他 goroutine 继续运行;一个 goroutine 进入真正的阻塞系统调用时,Runtime 也可以让出 P,让其他 M 执行别的 G。

3. goroutine 不是自动并行

go
go expensiveCalculation()

这条语句创建了并发任务,但是否和当前任务同时执行,取决于 GOMAXPROCS、可用核心、调度状态和任务类型。CPU 密集型任务超过硬件并行度后,更多 goroutine 通常只会增加调度和竞争;I/O 密集型任务则可以通过大量等待中的 goroutine 提高资源利用率。

Go 从 1.14 开始支持异步抢占,长时间不调用函数的循环不再轻易饿死其他 goroutine,也不至于长时间阻塞垃圾回收。但抢占不是性能和生命周期的替代品:忙等、cgo、锁竞争、无限队列和下游限流仍然需要工程设计。

六、不同语言如何选择

「支持协程」仍然不是足够精确的描述。下面按上下文表示方式、调度者和底层执行资源比较常见的模型:

语言或运行时常用抽象主要模型调度者典型写法
Gogoroutine + channel有栈用户态协程Go Runtime,映射到多个 OS 线程普通函数前加 go
JavaScript / Node.jsasync、Promise、await无栈状态机 / Future事件循环和宿主 I/Oawait promise
Python asynciocoroutine + Task无栈异步函数asyncio 事件循环await coroutine
RustFuture + executor无栈状态机executor,通过 poll 驱动future.await
Kotlinsuspend、launch、async无栈挂起函数Coroutine dispatcherdelay() 时挂起
C# / .NETasync、await、Task编译器生成状态机Task scheduler、线程池或同步上下文await task
Java 21+Virtual Thread有栈用户态线程JVM 的 M:N 调度器阻塞式线程风格
Erlang / ElixirBEAM process轻量消息进程BEAM 调度器进程之间传消息

这里有两个容易混淆的地方。

第一,协程运行在线程池上,不代表协程就是线程。Kotlin 协程可以先后复用同一条线程;线程池是它的承载资源,协程是上层的可暂停任务。

第二,async/await 不保证底层一定是协程。它可能被编译成状态机,也可能由线程池和 Future 实现。判断具体模型,要看上下文由谁保存、挂起点在哪里、谁负责恢复。

Go:用有栈换取普通函数的组合性

go
func handle(conn net.Conn) error {
    request, err := readRequest(conn)
    if err != nil {
        return err
    }
    return writeResponse(conn, request)
}

go func() {
    if err := handle(conn); err != nil {
        log.Println(err)
    }
}()

handle 不需要改成 async handle。它调用的网络 API 阻塞时,Runtime 可以暂停当前 G,保存它的栈,再执行其他 G。调用链中的普通函数仍然可以组合,错误也可以通过普通返回值传播。

Rust、Python、JavaScript:用无栈换取轻量状态

Rust 的 Future 需要 executor 调用 poll,未完成时返回 Pending,完成时返回 Ready。Python 的 asyncio 用事件循环管理 coroutine 和 Task,await 会把控制权交还给事件循环。JavaScript 的 Promise 和 event loop 也采用类似的「保存后续计算、事件完成后继续」模型。

这种模型不需要为每个任务维护完整调用栈,但异步边界会进入类型、函数签名和调用方式。调用异步函数的上层通常也必须异步化,这就是所谓函数染色。

Kotlin、C#:把状态机包装得更像普通代码

Kotlin 的 suspend fun 和 C# 的 async/await 都把异步函数改写为可恢复状态机,但它们不等于单线程事件循环。Kotlin 可以使用不同 dispatcher,C# 可以使用线程池、I/O 完成机制或 SynchronizationContext。挂起的是逻辑任务,释放的是承载它的线程。

Java Virtual Thread:重新选择有栈用户态线程

Java 21 的 Virtual Thread 由 JVM 管理,以 M:N 方式映射到平台线程。它保留了 Thread 和阻塞式代码的使用体验,但不让单个 CPU 计算变快。它主要改善大量等待型任务的并发规模和吞吐。

它与 goroutine 的相似点是:有栈、用户态调度、可以大量创建;差异在于 Java 选择尽量保留传统线程的身份、工具和 API 语义。

七、事件循环、I/O 多路复用与协程的关系

事件循环不是协程本身,而是一个调度中心。它可以调度回调、Promise、Future、无栈异步函数,也可以调度用户态线程。

以 I/O 多路复用为例,程序把多个 socket 注册到 epoll、kqueue 或 IOCP,然后等待一批就绪事件。一个线程可以监听很多连接,但同一时刻仍然只能执行一段 CPU 指令。数据到达以后,事件循环取出对应任务,调用回调或恢复状态机。

I/O 多路复用等待的是「哪些 I/O 已经就绪」;异步 I/O 更强调「数据传输完成后通知」。两者都可以作为协程 Runtime 的底层机制,但它们本身不是协程。

Go 的网络 Runtime 把这层复杂性藏在标准库后面。业务代码看起来像顺序执行的阻塞式函数,底层却可以使用网络轮询器和多个操作系统线程承载大量 goroutine。这正是有栈模型与 Runtime 配合后的工程价值。

八、Go 并发代码真正需要负责什么

1. 等待和错误

go f() 不返回一个可以自动 join 的对象。goroutine 结束时,程序不会自动把结果和错误送回调用者。可以使用 channel、sync.WaitGroup 或 errgroup 等待完成,并明确错误传播方式。

2. 取消和生命周期

go
func worker(ctx context.Context, jobs <-chan Job) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case job, ok := <-jobs:
            if !ok {
                return nil
            }
            if err := process(ctx, job); err != nil {
                return err
            }
        }
    }
}

没有关闭的 channel、没有取消信号的网络请求、永远等待接收的 goroutine,都可能造成泄漏。垃圾回收器不会因为一个 goroutine 已经「看起来没用了」就替你终止它。

3. 背压和并发上限

goroutine 很轻,但不是免费。无限制地为每个请求创建 goroutine,仍然可能耗尽内存、连接、文件描述符和下游服务额度。固定 worker 数量、带容量的 channel、信号量、超时和限流,都是并发边界的一部分。

4. 共享内存和数据竞争

channel 不能自动消灭数据竞争。如果多个 goroutine 仍然直接读写同一份 map、切片或结构体,就需要 mutex、原子操作或清晰的数据所有权。使用 go test -race 检查真实竞态,不要把「用了 channel」当成安全证明。

九、最后重新回答四个问题

goroutine 是协程吗? 是。它是 Go Runtime 实现的有栈用户态协程,使用 M:N 调度复用操作系统线程。

协程和并发是什么关系? 协程是组织并发的一种执行单元,并发是多个任务共同推进的结构。多进程、内核线程和事件循环也都可以实现并发。

有栈和无栈差在哪里? 有栈保存调用栈,恢复时可以回到普通函数调用链;无栈保存状态机和 continuation,通常只能在 await/yield 等挂起点恢复。

go func() 和 await 是不是一回事? 不是。go func() 是 spawn,创建新任务;await 是 suspend,保存当前异步计算的后续 continuation。前者不要求当前任务暂停,后者在结果未完成时需要当前异步计算返回并等待恢复。

这组区别看似抽象,却会直接影响 API 设计、错误传播、调试方式和资源生命周期。理解了「谁保存 continuation,谁负责调度」,再去看某种语言把它叫作 goroutine、coroutine、Task、Future 还是 Virtual Thread,就不会被名字牵着走。

作者Aoang
发布于2024-02-04
更新于2024-02-04
许可协议CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议,并注明出处。