并发编程基础

这篇是 Go 教程系列的选读内容,介绍 Go 语言最有特色的部分:并发编程。并发是 Go 的核心设计目标之一,语言层面原生支持 goroutine 和 channel,写并发代码比大多数语言都简单。如果你暂时只想刷算法题,可以先跳过,等需要的时候再回来看。

同步、异步、并行、并发

在正式学 Go 的并发之前,先搞清楚几个容易混淆的概念。

假设你现在要做三件事:煮饭、炒菜、煲汤。

最简单的做法是同步执行:你站在灶台前等饭煮熟,熟了之后再去炒菜,炒完菜再煲汤。一件事做完才做下一件,中间全是干等。很安全,但效率很低,因为煮饭的时候你其实可以去干别的。

聪明一点的做法是异步执行:你按下电饭煲的开关,不等饭熟,转身就去炒菜。炒菜的时候锅里在热油,你又顺手切个葱。一个人利用等待的间隙来回切换任务,看起来像是同时在做好几件事,但本质上还是只有你一个人(单线程)。

如果你叫了两个帮手,三个人各守一个灶台,同时开工,这就是并行。三件事真的在同一时刻同时执行,需要多个 CPU 核心才能做到。

并发是什么?并发是一个比较模糊的总称:只要多件事在同时推进,就可以叫并发。不管你是一个人异步切换(单线程),还是多个人并行执行(多线程),都算并发。换句话说,异步和并行都是实现并发的手段。

Go 的并发模型基于两个核心概念:goroutine(轻量级线程)和 channel(通道通信)。goroutine 由 Go 的运行时调度器管理,可以被调度到多个操作系统线程上,既能异步切换,也能真正并行执行。Go 是为并发而生的语言,创建一个 goroutine 的成本极低(初始栈只有几 KB),同时运行成千上万个 goroutine 毫无压力。

goroutine

go 关键字

在 Go 里启动一个并发任务非常简单:在函数调用前面加一个 go 关键字就行了。被 go 修饰的函数调用会在一个新的 goroutine 中执行,不会阻塞当前的执行流。

你可以多运行几次,会发现三个名字的输出顺序可能每次都不一样。这就是并发的特点:多个 goroutine 的执行顺序是不确定的。

注意最后的 time.Sleep:主 goroutine(就是 main 函数所在的 goroutine)一旦执行完毕,整个程序就会退出,不会等其他 goroutine 跑完。用 time.Sleep 等待是一种临时做法,实际项目中我们需要更可靠的方式来等待 goroutine 完成。

sync.WaitGroup

sync.WaitGroup 是专门用来等待一组 goroutine 完成的工具。它的用法很简单:启动前 Add(1) 表示多了一个要等的任务,goroutine 完成后调用 Done() 表示做完了,主 goroutine 调用 Wait() 阻塞等待所有任务完成。

这里用了 defer wg.Done()defer 会保证函数退出时一定会调用 Done(),即使中间发生了 panic 也不会漏掉。注意传给 goroutine 的是 &wg(指针),如果传值的话 goroutine 里操作的是一份副本,主 goroutine 永远等不到。

竞态条件与 sync.Mutex

什么是竞态

当多个 goroutine 同时读写同一个变量时,就会出现竞态条件(race condition)。下面这个例子让 1000 个 goroutine 各自对一个计数器加 1000 次,按道理结果应该是 1000000,但实际运行的结果几乎每次都不对:

为什么结果不对?因为 count++ 其实不是一个原子操作,它分三步:读取 count 的值、加 1、写回。当两个 goroutine 同时读到 count 的旧值(比如都读到了 42),各自加 1 后都写回 43,结果就只加了 1 而不是 2。这就是竞态条件。

用 Mutex 加锁保护

解决竞态最直接的方法是加锁。sync.Mutex 提供了 Lock()Unlock() 方法,同一时刻只有一个 goroutine 能持有锁,其他 goroutine 必须等待锁释放后才能进入:

加了锁之后,count++ 这个操作变成了"排队进入":一个 goroutine 拿到锁、修改 count、释放锁,下一个 goroutine 才能进来。结果就一定是正确的 1000000 了。

Mutex 简单好用,但加锁会让并发变成串行,影响性能。Go 社区更推荐用 channel 来做并发通信,这也是下一节的内容。

channel 通信

基本概念

Go 社区有一句经典格言:"不要通过共享内存来通信,而要通过通信来共享内存"。前面用 Mutex 保护 count 变量就是"共享内存"的做法——多个 goroutine 共享一个变量,靠锁来避免冲突。Go 更推荐的方式是用 channel(通道)来传递数据,从根本上避免多个 goroutine 直接操作同一个变量。

channel 可以理解为一个管道:一端发送数据,另一端接收数据。用 make(chan 类型) 创建,用 <- 操作符发送和接收。

无缓冲 channel

无缓冲 channel 是最基本的形式。发送方执行 ch <- value 会阻塞,直到有接收方执行 <-ch 来取走数据;反过来,接收方也会阻塞,直到有发送方发送数据。两边必须同时就绪,数据才能传递。就像两个人面对面递东西,一个不伸手另一个就得等着。

有缓冲 channel

有缓冲 channel 在创建时指定缓冲区大小:make(chan int, 3) 表示缓冲区可以容纳 3 个值。发送方只有在缓冲区满的时候才会阻塞,接收方只有在缓冲区空的时候才会阻塞:

什么时候用无缓冲,什么时候用有缓冲?简单说:无缓冲 channel 强制发送和接收同步进行,适合需要严格协调的场景;有缓冲 channel 允许发送方先跑一段,适合生产者和消费者速度不一致的场景。刚开始学的时候,先用无缓冲 channel 就够了。

用 range 遍历 channel

发送方用 close(ch) 关闭 channel 后,接收方可以用 for range 循环来遍历 channel,直到所有数据都被取完:

注意:只有发送方才应该关闭 channel,接收方不要关闭。向已关闭的 channel 发送数据会 panic。

select 多路复用

当你需要同时等待多个 channel 时,可以用 select 语句。select 的语法和 switch 很像,但每个 case 都是一个 channel 操作。select 会阻塞,直到其中某个 case 的 channel 就绪,然后执行那个分支:

select 还有一个常见的用法是配合 time.After 实现超时控制:

time.After(d) 返回一个 channel,在指定的时间后会发送一个值。把它放在 select 的一个 case 里,就实现了超时机制:如果正常的 channel 在规定时间内没有数据到达,就走超时分支。

实际例子:并行求和

最后来看一个综合运用 goroutine 和 channel 的实际例子:把一个大数组拆成几段,每段交给一个 goroutine 并行计算,最后汇总结果。

这个例子展示了并发编程的典型模式:把任务拆分、并行执行、通过 channel 收集结果。每个 goroutine 独立计算自己那一段的和,不需要访问共享变量,也不需要加锁,天然就是安全的。

小结

这篇介绍了 Go 并发编程的核心知识。goroutine 用 go 关键字启动,极其轻量,用 sync.WaitGroup 等待一组 goroutine 完成。多个 goroutine 同时读写同一个变量会产生竞态条件,用 sync.Mutex 加锁可以修复。channel 是 goroutine 之间传递数据的管道,无缓冲 channel 强制同步,有缓冲 channel 允许异步,优先用 channel 而不是共享内存。select 同时等待多个 channel,哪个先就绪就处理哪个,还可以配合 time.After 实现超时。

Go 的并发模型设计得非常简洁,goroutine + channel 两个工具就能覆盖绝大部分并发场景。核心思想就一句话:不要通过共享内存来通信,而要通过通信来共享内存