Goroutines 101:基础教程

Lobsters Hottest 工具

摘要

这篇文章提供了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

Eli Bendersky

本文是编写并发网络服务器系列文章的第 8 部分,重点介绍如何在 Go 中使用 goroutines 和 Go 运行时调度来实现并发。

C语言中的Go风格并发

Hacker News Top

一篇详细的技术文章,探讨如何在C语言中复制Go的并发模型,使用POSIX线程、互斥锁、条件变量和工作池,作为Solod转译器项目的一部分。

深入理解Go运行时:性能分析

Hacker News Top

深入探讨Go的性能分析机制,解释运行时如何收集CPU、堆、阻塞、互斥锁和协程的性能分析数据,以及这些数据在pprof格式中的表示方式。

Go 中的数据竞争与内存模型

Lobsters Hottest

本文解释了数据竞争和 Go 内存模型,说明了在 goroutine 中对共享变量的非同步访问可能导致的问题,并讨论了正确的同步方法。

Go 1.27 交互式导览

Lobsters Hottest

Go 1.27 新功能的实践性交互式导览,重点介绍泛型方法、结构体字面量字段选择器等,并提供基于官方发布说明的可运行示例。