刚开始学 Go 的并发时,最容易产生一种错觉:
只要在函数前面加一个 go,程序就会变快。
这句话只对了一小半。
go 关键字确实可以启动一个 goroutine,让一段代码和当前代码"同时推进"。但真正写出可靠的并发程序,还要回答几个问题:
-
主程序怎么知道 goroutine 已经结束?
-
goroutine 之间怎么传递数据?
-
多个 goroutine 同时改同一个变量会发生什么?
-
任务很多时,能不能限制同时工作的 goroutine 数量?
这篇文章从新手视角讲清楚这些问题。我们会先理解几个重要名词,再逐步写代码,最后实现一个完整的工作池。
这篇文章适合谁
如果你已经会写最基本的 Go 程序,例如:
package main
import "fmt"
func main() {
fmt.Println("hello, go")
}
并且知道 func、for、struct、import 的基本用法,那就可以开始学习并发了。
先分清:并发和并行
很多新手会把"并发"和"并行"混在一起。
并发
并发指的是程序能够同时处理多个任务。
注意,这里的"同时处理"不一定表示同一瞬间真的有多个任务在 CPU 上运行。它更强调任务的组织方式:程序可以在多个任务之间切换、等待、调度。
举个生活例子:
你一边烧水,一边切菜,一边等电饭锅煮饭。
你不是同时拥有三双手,而是在多个任务之间安排时间。
并行
并行指的是多个任务在同一时刻真的一起执行。
例如你的电脑有多个 CPU 核心,两个任务分别跑在不同核心上,这就是并行。
Go 里的关系
Go 让你很容易写并发程序。至于它是否并行运行,要看运行环境、CPU 核心数、调度器和 GOMAXPROCS 等因素。
对新手来说,先记住一句话:
并发是一种程序结构,并行是一种运行状态。
写 Go 并发程序时,我们最先关心的是:怎么把任务拆开,怎么让任务协作,怎么安全地拿到结果。
重要名词速查
先把几个词放在这里,后面看到代码会轻松很多。
goroutine
goroutine 是 Go 里的轻量级并发执行单元。
你可以把它粗略理解成"由 Go 运行时管理的轻量任务"。启动一个 goroutine 很简单:
go doSomething()
这表示:启动一个新的 goroutine 去执行 doSomething(),当前函数继续往下走。
channel
channel 是 goroutine 之间传递数据的管道。
例如:
ch := make(chan string)
这行代码创建了一个传递 string 的 channel。
发送数据:
ch <- "hello"
接收数据:
msg := <-ch
WaitGroup
sync.WaitGroup 用来等待一组 goroutine 结束。
常见流程是:
Add:登记有几个任务
Done:某个任务完成
Wait:等待所有任务完成
Mutex
sync.Mutex 是互斥锁。
当多个 goroutine 要同时访问同一份共享数据时,Mutex 可以保证同一时刻只有一个 goroutine 进入关键区域。
race condition
race condition 通常翻译成竞态条件。
如果多个 goroutine 同时读写同一个变量,而且没有使用 channel、Mutex 等同步手段,程序结果就可能变得不确定。这类问题很难靠肉眼发现,所以 Go 提供了 race detector,可以用 -race 检查。
第一个 goroutine
先看一个最小例子:
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("hello from goroutine")
}
func main() {
go sayHello()
fmt.Println("hello from main")
// 暂停一小会儿,给 goroutine 运行的机会。
// 注意:真实项目里不应该用 Sleep 等待 goroutine 结束。
time.Sleep(100 * time.Millisecond)
}
运行后,你可能看到:
hello from main
hello from goroutine
也可能顺序反过来。
这是第一个要适应的地方:并发程序里的执行顺序不一定固定。
为什么要 Sleep
如果去掉 time.Sleep:
func main() {
go sayHello()
fmt.Println("hello from main")
}
程序可能只打印:
hello from main
原因是:main 函数结束时,整个程序就结束了。新启动的 goroutine 还没来得及执行,程序可能已经退出。
但是 Sleep 不是好办法。它只是"猜一个等待时间"。正确方式是用 sync.WaitGroup。
用 WaitGroup 等 goroutine 结束
下面把上面的例子改成可靠版本:
package main
import (
"fmt"
"sync"
)
func sayHello(wg *sync.WaitGroup) {
// defer 表示函数结束前执行。
// 无论函数中间怎么返回,Done 都会被调用。
defer wg.Done()
fmt.Println("hello from goroutine")
}
func main() {
var wg sync.WaitGroup
// Add(1) 表示接下来有 1 个任务需要等待。
wg.Add(1)
// 把 WaitGroup 指针传给 goroutine。
go sayHello(&wg)
fmt.Println("hello from main")
// Wait 会阻塞,直到所有登记的任务都调用 Done。
wg.Wait()
}
这里有三个细节很重要:
-
wg.Add(1)要在启动 goroutine 之前调用。 -
goroutine 里要调用
wg.Done()。 -
WaitGroup通常传指针,也就是*sync.WaitGroup。
如果你把 WaitGroup 复制给 goroutine,容易出现等待状态不一致的问题。
一次启动多个 goroutine
我们再写一个稍微真实一点的例子:同时处理 5 个任务。
package main
import (
"fmt"
"sync"
"time"
)
func handleTask(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("task %d started\n", id)
// 模拟耗时操作,比如请求接口、读文件、计算数据。
time.Sleep(500 * time.Millisecond)
fmt.Printf("task %d finished\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go handleTask(i, &wg)
}
wg.Wait()
fmt.Println("all tasks done")
}
这段代码里,5 个任务会并发执行。总耗时大约接近 500ms,而不是 5 * 500ms。
新手易错点:循环变量
在并发代码里,循环变量很容易被写错。下面这个写法在旧代码中很常见:
for i := 1; i <= 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i)
}()
}
为了让代码意图更清楚,可以显式把 i 作为参数传进去:
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println(id)
}(i)
}
这个写法的意思是:每次循环都把当前的 i 复制一份,传给 goroutine 里的 id。
channel:让 goroutine 传递数据
WaitGroup 可以等待任务结束,但它不负责传递结果。
如果 goroutine 算出了一个值,想交给 main,该怎么办?
这时就可以用 channel。
package main
import "fmt"
func sendMessage(ch chan string) {
// 把字符串发送到 channel。
ch <- "hello from goroutine"
}
func main() {
// 创建一个传递 string 的 channel。
ch := make(chan string)
go sendMessage(ch)
// 从 channel 接收数据。
msg := <-ch
fmt.Println(msg)
}
这里的执行过程是:
main 创建 channel
main 启动 goroutine
goroutine 往 channel 发送字符串
main 从 channel 接收字符串
main 打印结果
无缓冲 channel
上面的 make(chan string) 创建的是无缓冲 channel。
无缓冲 channel 的特点是:
发送方和接收方必须同时准备好,数据才能交接。
如果只有发送,没有接收,发送方会阻塞。
如果只有接收,没有发送,接收方会阻塞。
这像两个人当面递东西:一个人伸手递,另一个人也要伸手接。
带缓冲 channel
带缓冲 channel 可以临时存放一些数据。
package main
import "fmt"
func main() {
// 创建一个容量为 2 的 channel。
ch := make(chan string, 2)
// 因为 channel 有 2 个缓冲位,所以这两次发送不会阻塞。
ch <- "first"
ch <- "second"
fmt.Println(<-ch)
fmt.Println(<-ch)
}
它像一个容量有限的队列。
但是不要把缓冲当成"万能加速器"。缓冲只是改变阻塞时机,不会自动解决设计问题。
close 和 range:告诉接收方没有更多数据了
如果发送方会发送多个值,接收方怎么知道什么时候结束?
可以用 close 关闭 channel。
package main
import "fmt"
func producer(ch chan int) {
for i := 1; i <= 3; i++ {
ch <- i
}
// 关闭 channel,表示不会再发送新数据。
close(ch)
}
func main() {
ch := make(chan int)
go producer(ch)
// range 会一直接收,直到 channel 被关闭。
for value := range ch {
fmt.Println(value)
}
fmt.Println("done")
}
关于 close 的规则
新手可以先记住三条:
-
通常由发送方关闭 channel。
-
不要在接收方关闭 channel。
-
不要重复关闭同一个 channel,否则会 panic。
为什么通常由发送方关闭?
因为只有发送方最清楚"以后还会不会继续发送数据"。
单向 channel:让函数职责更清楚
Go 支持把 channel 参数声明成"只能发送"或"只能接收"。
func producer(ch chan<- int) {
// chan<- int 表示这个函数只能发送 int。
ch <- 1
}
func consumer(ch <-chan int) {
// <-chan int 表示这个函数只能接收 int。
value := <-ch
fmt.Println(value)
}
这样做的好处是:函数签名直接表达了职责。
在多人协作或项目变大以后,这种写法能减少误用。
select:同时等待多个 channel
select 有点像 channel 专用的 switch。
它可以同时等待多个 channel 操作,哪个先准备好,就执行哪个分支。
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(2 * time.Second)
ch <- "result"
}()
select {
case msg := <-ch:
fmt.Println("got:", msg)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
}
}
这段代码会打印:
timeout
因为 goroutine 要 2 秒才发送结果,而 time.After(1 * time.Second) 1 秒后就会触发。
select 常见用途
select 常用于:
-
设置超时
-
监听取消信号
-
同时处理多个 channel
-
避免 goroutine 永远阻塞
共享变量和竞态条件
很多新手第一次写并发计数器,会这样写:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
count := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 这行不是安全的。
// count++ 本质上包含读取、加一、写回三个步骤。
count++
}()
}
wg.Wait()
fmt.Println(count)
}
你可能期望结果永远是:
1000
但实际上不一定。
因为 count++ 不是一个不可拆分的动作。它大致可以理解成:
读取 count
计算 count + 1
写回 count
如果两个 goroutine 同时读到 count = 10,它们都算出 11,再都写回 11,就丢了一次加法。
这就是竞态条件。
用 Mutex 保护共享变量
如果多个 goroutine 必须共享一个变量,就要用同步手段保护它。
下面用 sync.Mutex 改写计数器:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
count := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 加锁:同一时刻只允许一个 goroutine 进入下面的区域。
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(count)
}
被锁保护的区域通常叫关键区域。
更稳妥的写法是用 defer 解锁:
mu.Lock()
defer mu.Unlock()
count++
在很小的代码块里直接 Unlock 也可以,但一旦中间逻辑变复杂,defer 更不容易漏掉。
channel 和 Mutex 怎么选
新手常问:我应该用 channel,还是用 Mutex?
可以先用这个简单判断:
如果重点是传递数据和组织任务,用 channel。
如果重点是保护共享状态,用 Mutex。
例如:
-
多个 worker 从任务队列里拿任务,适合 channel。
-
多个 goroutine 更新同一个 map,适合 Mutex。
-
一个 goroutine 生产数据,另一个 goroutine 消费数据,适合 channel。
-
统计全局计数器,适合 Mutex 或 atomic。
不要为了"看起来更 Go"而强行用 channel。写并发程序的目标是清楚、正确、可维护。
用 race detector 检查数据竞争
Go 提供了 race detector。
如果你的代码在 main.go 里,可以这样运行:
go run -race main.go
如果是测试:
go test -race ./...
它不能证明程序绝对没有并发问题,但能帮你发现很多真实的数据竞争。
建议你养成习惯:并发代码写完后,跑一遍 -race。
工作池:为什么需要 worker pool
现在进入最后一个例子:工作池。
假设你有 10000 个任务要处理,例如:
-
给 10000 个用户发通知
-
处理 10000 张图片
-
请求 10000 个 URL
-
计算 10000 条数据
一种直接写法是:每个任务启动一个 goroutine。
for _, job := range jobs {
go handle(job)
}
这在任务少时没问题,但任务很多时可能造成:
-
goroutine 数量过多
-
内存压力变大
-
数据库、接口、文件系统被打爆
-
错误处理和退出控制变复杂
工作池的核心思想是:
任务可以很多,但同时工作的 worker 数量固定。
比如有 10000 个任务,但只启动 5 个 worker。每个 worker 不断从任务 channel 里取任务,处理完一个再取下一个。
工作池结构图
可以把工作池想象成这样:
jobs channel
main -----------------> worker 1
-----------------> worker 2
-----------------> worker 3
worker 1 --------------\
worker 2 ---------------> results channel ---> main 收集结果
worker 3 --------------/
里面有三个角色:
-
main:创建任务、启动 worker、收集结果。 -
jobs:任务队列。 -
results:结果队列。
完整工作池例子
下面是完整可运行代码:
package main
import (
"fmt"
"sync"
"time"
)
// Job 表示一个任务。
// 这里为了方便演示,让任务内容变成"计算某个数字的平方"。
type Job struct {
ID int
Number int
}
// Result 表示任务处理结果。
type Result struct {
JobID int
Number int
Square int
}
// worker 是真正干活的函数。
//
// 参数说明:
// id :worker 编号,方便观察是哪一个 worker 在处理任务。
// jobs :只接收的 channel,worker 从这里拿任务。
// results :只发送的 channel,worker 把结果发到这里。
// wg :通知 main:这个 worker 什么时候结束。
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
// range jobs 会不断从 jobs channel 接收任务。
// 当 jobs 被关闭,并且里面的数据被取完后,循环自动结束。
for job := range jobs {
fmt.Printf("worker %d started job %d\n", id, job.ID)
// 模拟耗时操作。
time.Sleep(300 * time.Millisecond)
result := Result{
JobID: job.ID,
Number: job.Number,
Square: job.Number * job.Number,
}
// 把处理结果发送给 results channel。
results <- result
fmt.Printf("worker %d finished job %d\n", id, job.ID)
}
}
func main() {
const workerCount = 3
const jobCount = 8
jobs := make(chan Job)
results := make(chan Result)
var wg sync.WaitGroup
// 1. 启动固定数量的 worker。
for i := 1; i <= workerCount; i++ {
wg.Add(1)
go worker(i, jobs, results, &wg)
}
// 2. 单独启动一个 goroutine 发送任务。
go func() {
for i := 1; i <= jobCount; i++ {
jobs <- Job{
ID: i,
Number: i,
}
}
// 所有任务都发送完后,关闭 jobs。
// worker 收到关闭信号后,会在取完剩余任务后退出。
close(jobs)
}()
// 3. 单独启动一个 goroutine 等待所有 worker 结束。
go func() {
wg.Wait()
// 所有 worker 都结束了,说明不会再产生新结果。
// 这时可以关闭 results,让 main 的 range 循环结束。
close(results)
}()
// 4. main 收集所有结果。
for result := range results {
fmt.Printf(
"result: job=%d number=%d square=%d\n",
result.JobID,
result.Number,
result.Square,
)
}
fmt.Println("all jobs done")
}
可能输出如下:
worker 1 started job 1
worker 2 started job 2
worker 3 started job 3
worker 2 finished job 2
worker 2 started job 4
result: job=2 number=2 square=4
worker 1 finished job 1
worker 1 started job 5
result: job=1 number=1 square=1
...
all jobs done
每次运行的顺序可能不同,这是正常的。并发程序通常不应该依赖日志顺序判断正确性。
工作池代码逐段讲解
为什么 worker 数量固定
const workerCount = 3
这表示最多同时有 3 个 worker 处理任务。
即使有 8 个任务,也不会同时启动 8 个 worker。任务会排队,哪个 worker 空闲了,就从 jobs 里取下一个。
为什么 jobs 由发送方关闭
close(jobs)
jobs 是 main 负责发送的,所以 main 最清楚任务什么时候发送完。
关闭 jobs 后,worker 里的这段循环会自然结束:
for job := range jobs {
// 处理任务
}
为什么 results 不能马上关闭
很多新手会想:
close(jobs)
close(results)
这通常是错的。
因为 jobs 关闭只表示"不再发送新任务",不表示 worker 已经处理完已有任务。worker 可能还在计算,并准备往 results 发送结果。
如果你过早关闭 results,worker 再发送数据就会 panic。
所以正确顺序是:
关闭 jobs
等待 worker 全部结束
关闭 results
这就是为什么代码里有:
go func() {
wg.Wait()
close(results)
}()
为什么 results 用 range 接收
for result := range results {
fmt.Println(result)
}
只要 results 没关闭,range 就会继续等结果。
当所有 worker 结束,results 被关闭后,循环自然结束。
这比手动数"收到了几个结果"更适合很多真实场景。
新手常见问题
goroutine 越多越好吗
不是。
goroutine 很轻量,但不是没有成本。启动过多 goroutine 会带来内存、调度、外部资源压力。
如果任务数量很多,优先考虑工作池、限流、批处理。
channel 需要每次都 close 吗
不是。
只有当接收方需要通过关闭信号判断"不会再有数据"时,才需要 close。
如果只是发送一个值、接收一个值,不一定要 close。
close channel 是不是会清空数据
不是。
关闭 channel 后,里面已经发送但还没接收的数据仍然可以被接收。
range ch 会把剩余数据取完,然后结束。
WaitGroup 能不能重复使用
可以,但要非常小心。
新手阶段建议一个 WaitGroup 对应一批任务,不要在上一批 Wait 还没结束时又开始乱加任务。
Mutex 会不会让并发变慢
锁会让部分代码串行执行,但它换来的是正确性。
错误的并发比慢一点的并发更危险。
如果锁竞争很严重,再考虑调整数据结构、减少共享状态、改用 channel 或分片锁等方案。
学 Go 并发的建议路线
你可以按这个顺序练习:
-
用
go启动一个 goroutine。 -
用
WaitGroup等多个 goroutine 结束。 -
用 channel 从 goroutine 拿结果。
-
用
close和range处理多条数据。 -
用
select做超时控制。 -
用
Mutex保护共享变量。 -
用
go run -race检查数据竞争。 -
写一个工作池限制并发数量。
如果这 8 步都能写顺,Go 并发的入门门槛基本就跨过去了。
总结
Go 的并发模型很适合写清楚的任务协作代码。
你需要记住几件事:
-
goroutine用来并发执行任务。 -
WaitGroup用来等待一组 goroutine 结束。 -
channel用来在 goroutine 之间传递数据。 -
close用来告诉接收方不会再有新数据。 -
select用来同时等待多个 channel 操作。 -
Mutex用来保护共享变量。 -
-race可以帮助发现数据竞争。 -
工作池可以限制并发数量,让大量任务更可控。
最后再强调一句:
并发程序首先要正确,其次才是快。
不要为了"看起来高级"而滥用 goroutine。能用简单同步写清楚,就先写清楚;等真的遇到吞吐量、延迟、资源限制问题,再引入更复杂的并发结构。