第八章 goroutine 与 channel (Goroutines and Channels)
一、导读
1.1 本章学习目标
理解 goroutine 的轻量级特性及其与操作系统线程的区别
掌握 goroutine 的创建、调度和生命周期管理
深入理解 channel 的类型(无缓冲/有缓冲)及其同步语义
掌握 select 语句的多路复用机制
理解 Go 的 CSP 并发模型:"不要通过共享内存来通信,而要通过通信来共享内存"
掌握 sync.WaitGroup、sync.Mutex、sync.Once 等同步原语
理解 goroutine 泄漏的原因和检测方法
能够设计正确的并发程序,避免数据竞争和死锁
1.2 为什么本章如此重要
并发是 Go 最核心的特性,也是 Go 在众多编程语言中脱颖而出的关键。Go 从语言层面内置了并发支持——goroutine 和 channel 不是库,而是语言的一部分。这使得并发编程不再是专家的特权,普通开发者也能编写高效的并发程序。
goroutine 是 Go 的轻量级线程。与操作系统线程(通常需要 1-8MB 栈空间)不同,goroutine 的初始栈仅约 8KB,并且可以动态增长。一个 Go 程序可以轻松创建数十万个 goroutine,而不会耗尽系统资源。Go 的运行时调度器(GMP 模型)负责将 goroutine 调度到操作系统线程上执行,开发者不需要关心线程的创建和销毁。
channel 是 goroutine 之间通信的管道。通过 channel,goroutine 可以安全地传递数据,而不需要使用锁。Go 的并发哲学是"不要通过共享内存来通信,而要通过通信来共享内存"——channel 提供了一种优雅的方式在 goroutine 之间传递所有权。
select 语句是 Go 并发编程的瑞士军刀,它允许同时等待多个 channel 操作,实现了多路复用、超时控制和取消机制。结合 context 包,select 可以构建复杂的并发控制流程。
1.3 核心问题引导
goroutine 与操作系统线程有什么区别?Go 的 GMP 调度模型是如何工作的?
无缓冲 channel 和有缓冲 channel 的同步语义有什么不同?
select 语句在多个 channel 同时就绪时如何选择?如何控制选择顺序?
什么是 goroutine 泄漏?如何检测和预防?
Go 的并发模型与 Java 的线程模型、Node.js 的事件循环有什么异同?
sync.WaitGroup 和 channel 都可以等待多个 goroutine 完成,应该选择哪个?
二、核心概念详解
8.1 goroutine
goroutine 是 Go 的轻量级执行单元,使用 go 关键字启动:
func sayHello(name string) {
fmt.Printf("Hello, %s!\n", name)
}
func main() {
go sayHello("Alice") // 启动 goroutine
go sayHello("Bob")
time.Sleep(time.Second) // 等待 goroutine 完成
}goroutine 的特性:
- 初始栈约 8KB,可动态增长
- 创建和销毁成本极低
- 由 Go 运行时调度,非抢占式协作调度(Go 1.14+ 引入基于信号的抢占)
- 共享进程的内存空间
WaitGroup 等待完成:
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Worker %d running\n", id)
time.Sleep(time.Second)
}(i)
}
wg.Wait()
fmt.Println("All workers done")8.2 Channel
Channel 是 goroutine 之间通信的类型安全管道:
// 创建 channel
ch := make(chan int) // 无缓冲
ch := make(chan int, 10) // 有缓冲,容量 10
// 发送和接收
ch <- 42 // 发送
value := <-ch // 接收
// 关闭 channel
close(ch)无缓冲 channel:发送操作会阻塞直到有接收者就绪,接收操作会阻塞直到有发送者就绪。无缓冲 channel 提供了强同步保证。
func main() {
ch := make(chan string)
go func() {
ch <- "hello" // 阻塞直到有人接收
}()
msg := <-ch // 接收数据,发送方解除阻塞
fmt.Println(msg)
}有缓冲 channel:发送操作在缓冲区未满时不会阻塞,接收操作在缓冲区非空时不会阻塞。
ch := make(chan int, 3)
ch <- 1 // 不阻塞
ch <- 2 // 不阻塞
ch <- 3 // 不阻塞
// ch <- 4 // 阻塞(缓冲区满)方向限制:
func producer(ch chan<- int) { // 只能发送
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}
func consumer(ch <-chan int) { // 只能接收
for v := range ch {
fmt.Println(v)
}
}8.3 select 语句
select 同时监听多个 channel 操作:
select {
case msg := <-ch1:
fmt.Println("从 ch1 接收:", msg)
case ch2 <- data:
fmt.Println("发送到 ch2")
case <-time.After(time.Second):
fmt.Println("超时")
default:
fmt.Println("没有就绪的 channel")
}超时控制:
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
ch := make(chan string, 1)
go func() {
// 模拟 HTTP 请求
ch <- "response data"
}()
select {
case result := <-ch:
return result, nil
case <-time.After(timeout):
return "", fmt.Errorf("请求超时")
}
}8.4 并发模式
扇出(Fan-out)/扇入(Fan-in):
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// 扇出:启动多个 worker
for w := 0; w < 3; w++ {
go worker(w, jobs, results)
}
// 发送任务
go func() {
for j := 1; j <= 10; j++ {
jobs <- j
}
close(jobs)
}()
// 扇入:收集结果
for r := 1; r <= 10; r++ {
fmt.Println(<-results)
}
}管道(Pipeline):
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
c := gen(2, 3, 4, 5)
out := sq(c)
for v := range out {
fmt.Println(v) // 4, 9, 16, 25
}
}8.5 sync 包
// Mutex
var mu sync.Mutex
var count int
func increment() {
mu.Lock()
defer mu.Unlock()
count++
}
// RWMutex
var rwMu sync.RWMutex
func read() { rwMu.RLock(); defer rwMu.RUnlock() }
func write() { rwMu.Lock(); defer rwMu.Unlock() }
// Once
var once sync.Once
var config *Config
func initConfig() {
once.Do(func() {
config = loadConfig()
})
}
// sync.Map(并发安全的 Map)
var safeMap sync.Map
safeMap.Store("key", "value")
value, ok := safeMap.Load("key")三、深入分析与讨论
3.1 GMP 调度模型
Go 的运行时使用 GMP 模型调度 goroutine:
- G(Goroutine):用户态的轻量级线程
- M(Machine):操作系统线程
- P(Processor):逻辑处理器,维护一个本地 goroutine 队列
每个 P 对应一个 M,P 的数量默认等于 CPU 核心数(GOMAXPROCS)。当 M 因系统调用阻塞时,运行时会将 M 与 P 分离,创建或唤醒新的 M 来接管 P。工作窃取(Work Stealing)机制允许空闲的 P 从其他 P 的队列中窃取 goroutine,实现负载均衡。
3.2 goroutine 泄漏
goroutine 泄漏是指 goroutine 永远无法退出,通常因为 channel 操作永久阻塞。常见原因:
- 发送数据到无人接收的 channel
- 从无人发送的 channel 接收数据
- 死锁(多个 goroutine 互相等待)
检测工具:goleak(Uber 开源)可以在测试中检测 goroutine 泄漏。
3.3 并发安全的设计原则
Go 提供两种并发安全策略:
共享内存通过锁保护:使用 sync.Mutex 或 sync.RWMutex
通过通信共享数据:使用 channel 传递数据的所有权
优先选择 channel——它使得数据流在代码中清晰可见,更容易推理正确性。只有在性能敏感或状态复杂的场景下才使用锁。
四、实践案例
4.1 并发 Web 爬虫
package main
import (
"fmt"
"sync"
)
func crawl(url string, results chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
// 模拟爬取
results <- fmt.Sprintf("爬取完成: %s", url)
}
func main() {
urls := []string{
"https://example.com",
"https://golang.org",
"https://github.com",
}
results := make(chan string, len(urls))
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go crawl(url, results, &wg)
}
go func() {
wg.Wait()
close(results)
}()
for result := range results {
fmt.Println(result)
}
}4.2 带取消的工作池
package main
import (
"context"
"fmt"
"sync"
"time"
)
func workerPool(ctx context.Context, numWorkers int, jobs <-chan int, results chan<- int) {
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
select {
case results <- job * 2:
case <-ctx.Done():
return
}
}
}
}(i)
}
wg.Wait()
close(results)
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
jobs := make(chan int, 100)
results := make(chan int, 100)
go workerPool(ctx, 4, jobs, results)
for i := 1; i <= 20; i++ {
jobs <- i
}
close(jobs)
for r := range results {
fmt.Printf("结果: %d\n", r)
}
}五、常见误区与注意事项
goroutine 泄漏:确保每个 goroutine 都有退出路径。使用 context 控制 goroutine 的生命周期。
向已关闭的 channel 发送数据:向关闭的 channel 发送数据会 panic。只有发送方应该关闭 channel,接收方不应关闭。
无缓冲 channel 的死锁:在无缓冲 channel 中,发送和接收必须同时就绪。在同一个 goroutine 中对无缓冲 channel 先发送后接收会导致死锁。
忽略 `sync.WaitGroup.Add` 的时机:wg.Add(1) 必须在 go 语句之前调用,否则 wg.Wait() 可能在 Add 之前就返回。
数据竞争:多个 goroutine 同时读写同一变量需要锁保护。使用 go run -race 检测数据竞争。
六、本章小结
本章系统讲解了 Go 的并发编程模型。goroutine 是轻量级的执行单元,由 Go 运行时通过 GMP 模型调度。channel 提供了类型安全的 goroutine 间通信机制。select 语句实现了多路复用和超时控制。Go 的并发哲学是通过通信共享数据,而非通过共享内存通信。sync 包提供了 Mutex、WaitGroup、Once 等同步原语。正确使用并发原语是编写正确并发程序的关键。
七、思考题与练习
编写程序验证 goroutine 的轻量级特性:创建 10 万个 goroutine,观察内存使用。
使用 channel 实现生产者-消费者模型,支持多个生产者和多个消费者。
使用 select 实现一个带超时的 channel 读取操作。
解释以下代码为什么会死锁,并提供修复方案。
使用 sync.WaitGroup 和 channel 分别实现等待多个 goroutine 完成的功能。比较两种方式的优劣。
实现一个并发安全的计数器,分别使用 sync.Mutex 和 sync/atomic。
使用 goroutine 和 channel 实现一个异步任务队列,支持优先级。
研究 context 包的使用。实现一个可取消的长时间运行任务。