goroutine
goroutine 是由 Go 运行时管理的并发执行单元。go f() 让 f 在新的 goroutine 中执行,调用者可以继续做自己的工作;两者仍处于同一进程的地址空间中,可能访问相同的数据。
启动一个 goroutine 很简单,难点在于把任务的关系交代清楚:什么时候可以读取结果,哪些数据由多个任务共享,调用方不再需要结果时,谁负责让任务退出。本文以 Go 1.22 为基线,先解释这些使用规则,调度队列和 GMP 的内部执行过程另行展开。
1. 面试核心
先记住这几条,再读正文- goroutine 由 Go 运行时调度到操作系统线程上执行,不是每创建一个 goroutine 就创建一个内核线程。它使用可增长的栈,适合组织大量并发任务,但仍消耗内存、调度和同步资源。卡 · goroutine 与线程
go f(args)中的函数值和参数在调用方求值,函数体在新的 goroutine 中执行。闭包捕获变量,参数复制传入的值;复制指针或切片并不会复制其关联的全部数据。卡 · 创建、参数与捕获- 启动、完成和结果交付是三件事。
go语句不返回任务句柄,函数的返回值会被丢弃;等待一组任务可以使用sync.WaitGroup,任务结果和错误仍需另外传递。卡 · 等待任务完成 - goroutine 可以通过 channel 传递值,也可以在同步保护下访问共享数据。通信、互斥和完成通知各有职责,不是使用了其中一种就自动解决其他问题。卡 · 通信与共享数据
- 共享数据的正确性取决于访问和同步规则。
WaitGroup可以让调用者等到任务完成,却不会让多个任务之间的count++自动变成原子操作。卡 · 通信与共享数据 - channel、锁和 I/O 等操作都可能让任务等待;goroutine 等待不等于一定占住一个操作系统线程。是否阻塞线程,要看具体操作如何与运行时配合。卡 · goroutine 在什么情况下等待
- 普通函数返回不会自动结束它启动的 goroutine;
main返回会让整个程序退出,也不会等待其他 goroutine。任务使用的资源必须活到任务真正结束。卡 · 调用方返回后,任务由谁负责 - 取消是通知,退出是任务执行后的结果。任务需要观察取消信号,让阻塞操作能够返回,再完成清理;发送取消后仍可能需要等待退出确认。卡 · 取消后还要确认退出
2. goroutine 与线程
goroutine 表达一条可以独立推进的执行流程,操作系统线程则是运行时实际执行 Go 代码所依托的资源。Go 运行时在它们之间安排调度,操作系统仍然负责调度这些线程。
| 对照项 | goroutine | 操作系统线程 |
|---|---|---|
| 谁管理调度 | Go 运行时 | 操作系统内核 |
| 与另一方的关系 | 多个 goroutine 可以复用线程,也可以在多个线程上并行执行 | 线程执行运行时安排的 goroutine,不要求一一绑定 |
| 栈 | 从较小的栈开始,运行时按需要调整 | 栈的预留、增长和限制取决于操作系统及线程实现 |
| 数据隔离 | 同一进程内可以共享数据 | 同一进程内的线程也可以共享数据 |
| 等待与切换 | 某些等待由运行时挂起 goroutine | 系统调用等操作可能阻塞线程 |
所以,不能把 goroutine 说成「与操作系统无关」:它最终仍要在线程上执行,网络、文件和内存等操作也会用到操作系统。也不能把它理解成需要程序员在每个切换点手工 yield 的协程;Go 1.22 的运行时支持抢占调度。
并发是多个任务的执行过程可以交错推进,并行是多个任务在同一时刻执行。单个处理器也能通过交错执行支持并发;要让计算任务真正并行,还需要可用的执行资源以及可以拆开的计算。
大量网络请求可能处于等待状态,goroutine 让我们可以按任务写出相对直接的阻塞式代码。对于已经把 CPU 用满的计算,再增加 goroutine 不会凭空增加算力,还可能增加调度、通信和缓存竞争。这里的轻量是实现和资源使用上的特点,不是对任意程序的性能保证。
3. 创建、参数与捕获
3.1 参数先在调用方求值
go 后面必须是函数或方法调用。执行到这条语句时,调用方先确定函数值和实参,再安排新 goroutine 执行函数体,自己继续向后运行。
下面用一个 channel 传回结果。make(chan int) 创建一个传递整数的无缓冲通道,result <- n 发送,<-result 接收;发送方和接收方在这次交付时配合完成通信。
package main
import "fmt"
func argument() int {
fmt.Println("evaluate")
return 3
}
func main() {
result := make(chan int)
go func(value int) {
result <- value * 2
}(argument())
fmt.Println("launched")
fmt.Println(<-result)
}
输出依次为 evaluate、launched、6。argument() 在 main 所在的 goroutine 中求值;新任务计算结果后,通过 result 交给调用方。launched 先打印,是因为读取并打印结果的语句排在它后面,不是因为新 goroutine 必须等调用方打印后才能执行。
如果被启动的函数声明了返回值,go 调用会丢弃那些返回值。想取得结果或错误,需要明确设计 channel、受保护的结果对象等交付方式,不能把 go f() 当成一个可直接读取结果的 Future。
3.2 捕获变量与传入值不同
闭包可以访问外层变量,传参则复制求值时的值。下面用 start 控制读取时机,让两个任务都在 value 被修改后继续执行,避免靠调度快慢猜输出。
package main
import "fmt"
func main() {
value := 1
start := make(chan struct{})
byParameter := make(chan int)
byCapture := make(chan int)
go func(snapshot int) {
<-start
byParameter <- snapshot
}(value)
go func() {
<-start
byCapture <- value
}()
value = 2
close(start)
fmt.Println(<-byParameter, <-byCapture) // 1 2
}
第一个任务的 snapshot 在启动时已经取得 1;第二个任务读取的是外层的 value。close(start) 使等待关闭信号的接收可以继续,并建立所需的同步关系,所以第二个任务读取到修改后的 2,这里没有让读写无序竞争。
如果传入的是指针,复制的是指针值;如果传入的是切片,复制的是切片值,底层数组仍可能共享。因此,显式传参解决的是变量绑定和取值时机,不能单独证明数据不共享或访问安全。
Go 1.22 语言语义下,循环头通过 := 声明的迭代变量每轮独立;给外部已有变量赋值的 = 循环仍复用那个变量。模块语言版本和更完整的对照见 Go 函数的循环变量。每轮变量独立,也不意味着变量指向的对象不再共享。
4. 等待任务完成
sync.WaitGroup 用计数表示还有多少工作未完成:Add 增加计数,Done 减少计数,Wait 等到计数归零。它适合表达「这批任务结束后再继续」。
package main
import (
"fmt"
"sync"
)
func main() {
values := []int{2, 3, 4}
results := make([]int, len(values))
var wg sync.WaitGroup
for i, value := range values {
wg.Add(1)
go func(index, n int) {
defer wg.Done()
results[index] = n * n
}(i, value)
}
wg.Wait()
fmt.Println(results) // [4 9 16]
}
Add(1) 放在启动语句之前,让任务先被登记,再允许调用方等待。如果放进新 goroutine,调用方可能在它尚未执行 Add 时看到零计数,提前从 Wait 返回。defer wg.Done() 则让正常返回等退出路径都执行完成通知。
这里每个任务只写自己对应的元素,没有修改 results 的切片结构,也没有两个任务写同一个元素。调用方在 Wait 返回后才读取结果;Done 与它解除阻塞的 Wait 之间有同步保证。任务完成的先后可以不同,结果仍放在原来的索引位置。
WaitGroup 不负责收集错误,不会自动取消其他任务,也不会保护任务执行期间的共享修改。它的计数描述的是我们登记的工作;如果某个任务又启动了未登记的后台任务,Wait 不会因此自动等待那条新执行流程。
time.Sleep 只让调用方等待一段时间,不能证明另一任务已经完成。机器负载、I/O 和调度变化后,原来看似够长的等待仍可能过短。
5. 通信与共享数据
goroutine 之间可以传递消息,也可以共享对象。选择机制时,先确定要表达的是数据交付、共享状态保护,还是工作完成:
| 需要表达的关系 | 可选机制 | 仍要明确的边界 |
|---|---|---|
| 把值交给另一个任务 | channel | 谁发送、谁接收、何时停止、是否可能一直等不到对方 |
| 多个任务修改同一状态 | 互斥锁等同步机制 | 哪些访问必须遵守同一把锁,哪些操作必须作为一个整体 |
| 等待一组已登记的工作 | WaitGroup | 每次登记是否对应完成通知,错误和取消如何传递 |
| 通知任务不再需要继续 | context 等取消信号 | 任务在哪里观察信号,阻塞操作怎样退出 |
例如,两个任务都要增加同一个计数,count++ 包含读取、加一和写回。用互斥锁保护整个操作,才能保证它们不会同时读到同一个旧值再各自写回。
package main
import (
"fmt"
"sync"
)
func main() {
var count int
var mu sync.Mutex
var wg sync.WaitGroup
for worker := 0; worker < 2; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
mu.Lock()
count++
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Println(count) // 2000
}
mu 保证任务之间的计数修改互斥,wg 保证最后的读取发生在两个任务完成之后。删掉 Lock 和 Unlock,即使保留 Wait,并发增加仍存在数据竞争;偶尔输出 2000 也不能证明程序正确。
channel 同样不会自动复制发送值关联的全部对象。发送一个切片后,如果发送方继续修改底层数组,接收方又同时读取,仍需要额外同步或约定所有权交接。选择 channel 和锁的依据是数据如何流动、哪些状态必须一起保护,不是认为其中一种天然更安全。
6. 阻塞与退出责任
6.1 goroutine 在什么情况下等待
等待不一定是错误。任务没有可处理的数据,或者必须等另一个操作完成时,暂时停止推进是正常行为。
| 操作 | 等待条件示例 | 退出等待的条件 |
|---|---|---|
| channel 发送或接收 | 无缓冲通道没有对应操作,或缓冲区满、空 | 出现可完成的通信;具体结果还取决于通道状态 |
select | 没有可执行的分支且没有 default | 至少一个分支可以继续 |
互斥锁、WaitGroup | 锁被持有,或工作计数尚未归零 | 锁释放,或工作完成 |
| 网络、文件或外部调用 | 尚未取得结果 | 数据就绪、错误、超时或所支持的取消机制生效 |
time.Sleep | 指定等待时间尚未结束 | 计时结束并获得执行机会 |
channel、锁等等待通常可以由运行时挂起 goroutine,让执行线程继续处理别的任务。网络 I/O 可以与运行时网络轮询配合;某些系统调用或 cgo 调用仍可能阻塞操作系统线程,运行时再安排其他线程推进可运行的工作。不能把所有 I/O 都概括成同一种调度路径。
GOMAXPROCS 限制的是同时执行 Go 代码所用的线程数量,不是 goroutine 总数,也不是整个进程的线程总数。更细的线程与运行时调度关系需要结合具体阻塞路径解释。
6.2 取消后还要确认退出
context 可以向任务传递取消信号。调用 cancel 会通知任务不再需要继续,但不会在任意指令处强制终止那个 goroutine。
下面的 consume 在收到任务时打印,在取消或输入关闭时返回。返回的 done 通道只表达退出确认:任务结束时关闭它,调用方通过接收等待清理完成。
package main
import (
"context"
"fmt"
)
func consume(ctx context.Context, jobs <-chan string) <-chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
fmt.Println(job)
}
}
}()
return done
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
jobs := make(chan string)
done := consume(ctx, jobs)
jobs <- "job-1"
cancel()
<-done
fmt.Println("stopped")
}
输出依次是 job-1 和 stopped。consume 返回时,新任务仍然存在;调用方随后交付一个任务,再取消并等待 done。最后一行能确定工作循环已经走完退出路径,而不是只知道取消请求已经发出。
这个循环的取消发生在观察 ctx.Done() 时。如果 fmt.Println 换成一个长时间阻塞的调用,外围的 select 无法中断已经进入的调用;需要使用支持 context 的 API、设置期限或提供对应的解除阻塞方式。计算量大的循环也需要在合适位置检查取消。
如果取消分支和输入分支同时就绪,select 不保证优先选择取消。因此,这个示例不承诺「调用 cancel 后绝不会再取出一个任务」。需要更严格的停止边界时,要另行设计接收、开始执行和取消之间的协调。
6.3 调用方返回后,任务由谁负责
普通函数的返回不会自动等待或取消它启动的 goroutine。任务仍可能使用传入的连接、文件、切片或其他对象,所以资源所有者必须明确任务的结束条件,并安排释放顺序。
例如,函数启动后台写入后立即执行 defer file.Close(),函数一返回就会关闭文件,后台写入未必已经结束。可以在返回前等待写入,也可以把文件的关闭责任交给持有它的后台任务;选择取决于接口是否承诺后台执行。
main 则有特殊的程序退出语义:main 返回时,程序结束,不等待其他 goroutine,也不会替它们逐个执行尚未运行的 defer。需要完成的后台工作,必须在 main 返回前协调结束。
垃圾回收也不会因为调用者不再关心结果,就自动终止一个一直等待的 goroutine。它的栈及可达对象仍可能占用资源;任务没有正常进展也没有退出路径时,就可能形成 goroutine 泄漏。
此外,调用方的 recover 不能捕获另一个 goroutine 中的 panic。恢复只作用于当前 goroutine 的展开过程,具体条件见 Go 错误处理的 panic 与 recover。等待和取消机制都不能代替错误处理。
7 · 面试题卡片
本页题目与参考回答
7.1 goroutine 和操作系统线程有什么区别?为什么说 goroutine 轻量?
考察重点:执行单元、调度层次、栈与资源成本,以及并发和并行的区别。
相关内容:goroutine 与线程、goroutine 在什么情况下等待。
参考回答
**goroutine 由 Go 运行时管理,并复用操作系统线程执行,不与内核线程一一对应。**它从较小的栈开始,由运行时按需要调整,一部分等待和切换也由运行时处理,因此可以较低成本组织大量并发任务。
它仍依赖操作系统线程,不是无需系统资源。大量 CPU 计算受实际并行资源限制,增加 goroutine 还会增加调度、内存与同步成本,不能据此保证任意程序都会更快。
若与 Java 比较,需要注明所比较的线程类型;Java 21 还提供虚拟线程,不能把 Java 的所有并发任务都概括成与平台线程一一对应。
7.2 go 调用的参数在哪里求值?显式传参能否消除共享数据?
考察重点:调用求值、闭包变量绑定和值复制的范围。
相关内容:创建、参数与捕获。
参考回答
**函数值和参数在调用方求值,函数体在新的 goroutine 中执行。**传入普通整数会复制当时的整数值,闭包则可以继续访问外层变量,后续读写需要明确同步。
显式传参不等于深拷贝。指针仍可指向同一个对象,切片仍可关联同一个底层数组;Go 1.22 的循环变量独立也不会自动消除这些对象的共享。
7.3 怎样等一组 goroutine 完成后再执行下一步?
考察重点:工作登记、完成通知、等待顺序及结果的可见性。
相关内容:等待任务完成。
参考回答
**可以用 sync.WaitGroup 登记工作,在每个任务退出前调用 Done,调用方通过 Wait 等待计数归零。**通常先 Add 再执行 go,避免等待方看到零计数后提前返回;任务里可用 defer wg.Done() 安排完成通知。
结果需要通过明确的位置或 channel 交付。WaitGroup 不会自动传递错误、取消任务,也不会保护各任务之间的共享修改。Sleep 只能等待时间,不能证明任务完成。
7.4 goroutine 之间怎样通信和协调?是否只能使用 channel?
考察重点:消息传递、共享状态保护、完成通知与取消的职责。
参考回答
**可以用 channel 传递值,也可以在锁等同步机制保护下访问共享状态,并不只有 channel 一种方式。**等待一组工作完成可以用 WaitGroup,传递取消信号可以用 context,它们解决不同的协调问题。
channel 适合表达数据交付和所有权流转,锁适合保护多个访问者共同维护的状态。不论选择哪种方式,都要说明谁负责结束任务、处理错误和释放资源;发送指针或切片也不会自动复制关联对象。
7.5 多个 goroutine 访问共享变量,怎样保证安全?WaitGroup 够吗?
考察重点:数据竞争、复合操作、互斥与等待的不同保证。
相关内容:通信与共享数据。
参考回答
**先检查是否存在未同步的冲突访问,再为共享状态建立一致的访问规则;WaitGroup 本身不够。**例如两个任务执行 count++,应使用同一把互斥锁保护完整的读取、修改和写回,或使用适用的原子操作,也可以把计数集中交给一个任务维护。
Wait 让调用者等到工作完成,无法使工作执行期间的相互读写变得互斥。没有出现错误输出,也不能证明没有数据竞争。
7.6 goroutine 什么时候会阻塞?阻塞时是否一定占住线程?
考察重点:等待条件、运行时挂起与系统线程阻塞的区别。
相关内容:goroutine 在什么情况下等待。
参考回答
**没有可完成的 channel 通信、锁尚未释放、任务计数未归零,以及 I/O 等待等情况,都可能让 goroutine 暂停推进。**具体能否继续,要看相应条件何时满足。
运行时可以挂起等待的 goroutine,让线程执行其他工作;网络轮询也可以配合这种机制。但某些系统调用或 cgo 调用会阻塞线程,不能把「goroutine 阻塞」一概理解成「线程一定阻塞」或「线程绝不会阻塞」。
7.7 启动 goroutine 的函数返回,和 main 返回,有什么不同?
考察重点:函数调用范围、后台任务生命周期与程序退出。
相关内容:调用方返回后,任务由谁负责。
参考回答
**普通函数返回不会自动结束它启动的 goroutine;main 返回则让程序退出,不等待其他 goroutine。**因此,普通函数需要说明后台任务由谁取消、等待和清理,不能在任务仍使用资源时提前释放。
程序退出也不会替其他 goroutine 执行剩余的 defer。必须完成的工作,应在 main 返回前通过明确的完成通知协调结束。
7.8 调用 cancel 后,怎样确认 goroutine 已退出?
考察重点:协作式取消、阻塞操作的退出条件与完成确认。
相关内容:取消后还要确认退出。
参考回答
**cancel 只发出取消信号,确认退出还要等待任务的完成通知。**任务应观察 ctx.Done(),让耗时或阻塞操作具备相应的取消、期限或解除阻塞机制,清理资源后再关闭 done 或调用 Done。
外层的 select 无法中断已经进入的任意调用;多个分支同时就绪时,也不保证优先选择取消。任务退出条件必须由具体控制流程保证。
8. 参考资料
- Go 1.22 语言规范:go 语句、闭包、循环变量与程序执行
- Go 内存模型:goroutine 创建、结束与 channel 同步
- Go FAQ:goroutine、线程与并行执行
- Go 1.22:sync.WaitGroup 的用法与同步保证
- Go 1.22:context 的取消契约
- Go 1.22:运行时栈管理
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明