Goroutines 101:基础教程
摘要
这篇文章提供了Go语言中goroutines的基础教程,解释了它们如何简化并发以及如何有效地使用它们。
<p><a href="https://lobste.rs/s/ixxtwz/goroutines_101_basic_walkthrough">评论</a></p>
查看缓存全文
缓存时间: 2026/08/20 06:40
# 协程入门:基础概念解析
来源:https://func25.dev/posts/go-goroutines-basics/
Go 语言使得并发编程比大多数其他语言变得简单得多。
- 在 Java 中,你需要在平台线程、虚拟线程和 `ExecutorService` 框架的线程池之间做选择。
- 在 Python 中,你有 `threading`、`asyncio` 和 `multiprocessing`,但选择哪个取决于任务是 I/O 密集型还是 CPU 密集型。
而在 Go 语言中,你只需在函数调用前添加一个关键字(`go`):
```go
go doSomething()
```
这行代码就会启动一个协程,该协程将与应用程序的其余部分同时运行。你无需创建线程对象、配置线程池或安装额外的库。
这种简单的并发处理方式非常重要,这也是我们热爱 Go 语言的重要原因之一。本文将介绍其背后的基础概念:协程、它们如何在操作系统线程上运行,以及 `GOMAXPROCS` 的作用。让我们从执行这行代码时发生的事情开始。
## 1. 启动协程
下面的代码片段启动了一个打印一行内容的协程,然后 `main` 函数打印另一行:
```go
func main() {
go doSomething()
fmt.Println("done")
}
func doSomething() {
fmt.Println("doSomething called")
}
```
我们预期会看到两行输出。但实际运行几次后,通常只会看到一行:
`doSomething` 的消息缺失了。要理解原因,我们需要知道 `go` 关键字究竟做了什么。
`go` 语句并不是直接调用函数。它创建一个新的协程,通知 Go 调度器该协程已准备就绪,然后立即执行下一行代码。这就是第一条规则:`main` 函数永远不会等待它启动的协程。
第二条规则导致了输出的缺失。当 `main` 函数返回时,整个程序就会退出。运行时不会等待其他协程完成,也不会执行它们的延迟调用。
在上面的程序中,`main` 函数在调度器为 `doSomething` 分配任何 CPU 时间之前就结束了,因此当该协程本应打印输出时,进程已经退出了。
```
main go doSomething() doSomething 尚未开始 进程退出 main 返回
```
由于 main 在新的协程获得执行机会之前就返回了,所以进程先退出。
一种常见的初步解决方案是让 `main` 在返回前休眠一段时间:
```go
go doSomething()
time.Sleep(time.Second)
fmt.Println("done")
```
现在两行输出都会显示,因为 1 秒远比 `doSomething` 需要的时间长。但这只是基于时间的猜测,并非真正的同步。如果任务耗时超过休眠时间,输出又会消失;如果任务执行很快,应用程序就会无谓地等待。
“等待此任务完成”的正确工具是 `sync.WaitGroup`:
```go
func main() {
var wg sync.WaitGroup
wg.Go(doSomething)
wg.Wait()
fmt.Println("done")
}
```
`WaitGroup` 维护一个待处理任务的计数器。`WaitGroup.Go`(https://pkg.go.dev/sync#WaitGroup.Go)会将计数器加一并启动协程,当 `doSomething` 返回时计数器减一,而 `Wait` 会阻塞 `main` 函数直到计数器归零。现在每次运行的输出都相同:
## 2. 协程与操作系统线程
协程与操作系统线程的功能相同,都是为了与其他代码并行运行。那么为什么 Go 不直接使用线程,而是构建自己的机制呢?
原因在于:线程是程序向操作系统请求的资源,而协程是由 Go 运行时构建、管理和完全控制的。内核必须保持其线程的通用性,以支持机器上的所有语言,而 Go 运行时只需处理 Go 语言本身。
```
操作系统线程:内核提供 通用型
协程:Go 运行时构建 专为 Go 设计
```
内核提供线程;Go 运行时在其之上构建协程。
正是这种所有权让 Go 能够进行专门优化:
- **栈大小**:新的协程以极小的栈空间启动(最小 2 KB),运行时会根据需要自动扩容。而操作系统线程在创建时就预留了整个栈空间(Linux 上常见的默认值为 8 MB)。大部分预留空间从未使用,但依然被占用。
- **创建成本**:创建新协程只需小栈、一个管理结构体和一个运行队列槽位,所有操作都在进程内完成。创建新线程需要系统调用,每次都需要内核参与。
- **调度器**:Go 运行时在少量操作系统线程上运行大量协程。这被称为 m:n 模型,即 m 个协程共享 n 个线程。内核负责调度线程,但对上层的协程一无所知。
- **上下文切换**:当运行时暂停一个协程并启动另一个时,所有操作都在进程内完成。线程切换需要经过内核,这是其成本较高的主要原因。
Go 并非第一个采用这种设计的语言。Erlang 几十年来一直使用轻量级进程实现并发,Java 也在 JDK 21 中引入了虚拟线程。Java 仍然同时支持两种线程并允许选择,而 Go 则通过一个关键字提供统一的并发单元。
在底层,调度器本身在结构和行为上都很复杂,因为它需要将大量协程分配到有限的线程上,再将它们调度回来。我们将在后续文章中详细讨论。
```
许多协程(G)通过固定处理器(P)传递到少量操作系统线程(M)
内核管理 CPU 核心
```
我们永远不会编写代码来创建处理器 `P` 或线程 `M`,也不会手动在它们之间移动协程。上图中我们唯一能控制的,是运行时允许同时运行 Go 代码的线程数量,Go 将这个限制称为 `GOMAXPROCS`。
## 3. GOMAXPROCS
有两个数字描述了程序能够获得的实际并行度:
```go
func main() {
fmt.Println(runtime.NumCPU())
fmt.Println(runtime.GOMAXPROCS(0))
}
```
在我的机器上,两者都打印 `14`,因为它有 14 个逻辑 CPU:
这两个数字回答不同的问题,但它们是相关的:
- `NumCPU` 是进程可用的逻辑 CPU 数量,Go 在启动时读取这个值,因此在程序运行期间不会改变。
- `GOMAXPROCS` 是运行时本身限制的同时运行 Go 代码的操作系统线程数,这是一个动态值。你可以手动设置它,尽管大多数程序不会这么做。
`runtime.GOMAXPROCS(n)` 稍有特殊,因为它可以根据参数的值进行获取或设置。小于 1 的值表示获取,因此 `GOMAXPROCS(0)` 是读取当前限制的常用方式。1 或更大的值会设置新限制并返回旧限制。
> **注意**
> 这两个数字(`NumCPU` 和 `GOMAXPROCS`)在这里匹配,因为这次运行在裸机上。从 Go 1.25 开始,在设置了 CPU 限制的容器内(例如在 64 核主机上限制为 2 个 CPU),运行时会遵循容器限制而非主机的 CPU 数量,因此 `GOMAXPROCS` 会返回 2,而 `NumCPU` 仍然返回 64。运行时还会在限制发生变化时自动更新这个值。
运行时如何读取容器限制,以及限制在程序运行时发生变化会发生什么,将是下一篇文章的主题。
这个限制在实践中会改变什么?下面的程序启动三个协程,每个协程打印数字 `0` 到 `9`。将限制设为 1:
```go
var wg sync.WaitGroup
func main() {
runtime.GOMAXPROCS(1)
for range 3 {
wg.Go(printDigits)
}
wg.Wait()
}
func printDigits() {
for i := range 10 {
fmt.Print(i)
}
}
```
只有一个执行槽位时,每次只有一个协程运行。由于每个循环都很短,Go 运行时切换到其他协程之前就能完成,因此输出是三个清晰的组:
```
012345678901234567890123456789
```
我的本地机器有 14 个逻辑 CPU,删除 `runtime.GOMAXPROCS(1)` 行后,限制从 1 提升到 14。三个协程可以同时运行,某次运行输出如下:
```
012345678901012345678923456789
```
数字现在混合在一起,因为三个协程同时向输出写入。
```
GOMAXPROCS = 1:012345678901234567890123456789(三个块)
无限制:012345678901012345678923456789(五个块)
```
每种颜色标记一个协程;两次运行分别显示三个块和五个块。
某些运行可能仍然有序,因为这里的样本量很小。每个协程只打印 10 个字符,可能在另一个协程获得执行机会前就完成了。更长的循环会让混合更明显,但原理相同。
第一个结果中有两个细节值得明确指出:
- 这里一个协程在单次调度中完成整个 for 循环是正常的,但并不保证。从 Go 1.14 开始,运行时可以中断一个持有 CPU 约 10 毫秒的协程,即使是在循环中间。
- 三个协程的执行顺序是未定义的,本例隐藏了这一点,因为它们都打印相同的数字 0 到 9。
因此 `GOMAXPROCS(1)` 消除了并行性,但并未消除并发性。所有三个协程可以同时存在且可运行,但在任一时刻只有一个执行 Go 代码。调度器可能让一个协程完成其短循环再运行另一个(如本输出所示),也可能暂停一个协程并稍后恢复。并发允许任务交错执行;并行则要求至少两个协程同时执行。
并行执行只是并发的一部分。下一步是让协程交换值并相互同步,这将在《Go 通道基础原理》(https://func25.dev/posts/go-channels/)中介绍。
## 参考资料
- Go 规范中的 `go` 语句 (https://go.dev/ref/spec#Go_statements)
- `src/runtime/stack.go` (https://go.dev/src/runtime/stack.go) (`stackMin`)
- `sync.WaitGroup.Go` 文档 (https://pkg.go.dev/sync#WaitGroup.Go)
- `runtime.GOMAXPROCS` 文档 (https://pkg.go.dev/runtime#GOMAXPROCS)
- Go 1.25 发布说明:容器感知的 GOMAXPROCS (https://go.dev/doc/go1.25#container-aware-gomaxprocs)
相似文章
并发服务器:第 8 部分 - Go
本文是编写并发网络服务器系列文章的第 8 部分,重点介绍如何在 Go 中使用 goroutines 和 Go 运行时调度来实现并发。
C语言中的Go风格并发
一篇详细的技术文章,探讨如何在C语言中复制Go的并发模型,使用POSIX线程、互斥锁、条件变量和工作池,作为Solod转译器项目的一部分。
深入理解Go运行时:性能分析
深入探讨Go的性能分析机制,解释运行时如何收集CPU、堆、阻塞、互斥锁和协程的性能分析数据,以及这些数据在pprof格式中的表示方式。
Go 中的数据竞争与内存模型
本文解释了数据竞争和 Go 内存模型,说明了在 goroutine 中对共享变量的非同步访问可能导致的问题,并讨论了正确的同步方法。
Go 1.27 交互式导览
Go 1.27 新功能的实践性交互式导览,重点介绍泛型方法、结构体字面量字段选择器等,并提供基于官方发布说明的可运行示例。