承接 Go 语言基础:从变量到指针、Go 语言进阶:方法、接口与错误处理 和 Go 语言工程化:模块管理与项目结构,这一篇覆盖 Go 最核心的特性:并发编程。
并发 vs 并行
先区分两个概念:
- 并行(Parallel):两个人同时吃两碗面
- 并发(Concurrency):一个人交替吃两碗面(这口吃这碗,下口吃那碗)
Go 的并发模型就是让你用很少的资源同时做很多事。
Goroutine
一句话:轻量级线程,用 go 关键字启动。
// 普通调用
fmt.Println("hello")
// 用 go 启动一个 goroutine
go fmt.Println("hello") // 加个 go 就行
加了 go,这个函数就会在后台跑,不阻塞当前代码。
先看个例子
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("hello")
}
func main() {
go sayHello() // 后台跑
fmt.Println("main") // 主线程继续
}
你猜输出什么?
答案:只输出 main。
为什么?因为 main 函数结束了,程序就退出了,goroutine 还没来得及跑。
这就像你叫了个外卖(go sayHello),但你立刻出门了(main 结束),外卖员来了发现没人。
怎么等 goroutine 跑完?
最简单的方式:加个 sleep(但这是错误示范)
go sayHello()
time.Sleep(time.Second) // 等1秒,goroutine 有时间跑了
fmt.Println("main")
能工作,但不准、不优雅——你不知道 goroutine 到底要跑多久。
正确方式:用 channel(后面会详细讲)
done := make(chan bool)
go func() {
fmt.Println("hello")
done <- true // 说一声:我做完了
}()
<-done // 等着,直到收到信号
fmt.Println("main")
现在输出:
hello
main
用比喻理解
- 普通函数调用:你叫同事做事,站在旁边等他做完
go函数调用:你叫同事做事,自己先去忙别的- channel:同事做完了发消息通知你
Channel
为什么需要 Channel?
goroutine 是独立运行的,它们之间怎么传数据?
// 两个 goroutine,一个生产数据,一个消费数据
// 怎么把数据从 A 传到 B?
答案:用 channel,就像一根水管,一头进,一头出。
基本用法
// 创建 channel
ch := make(chan int) // 传 int 类型的 channel
// 发送数据(往管子里塞)
ch <- 42
// 接收数据(从管子里取)
x := <-ch
Channel 的特性:阻塞
这是最关键的:
- 发送方:
ch <- 42→ 没人接收就等着(阻塞) - 接收方:
x := <-ch→ 没人发送就等着(阻塞)
就像一个没有缓冲的传送带:
- 你放东西上去,必须有人在另一头接着,否则你就一直举着
- 你去拿东西,必须有人放了东西,否则你就一直等着
用阻塞特性来同步
这就是为什么 channel 能替代 sleep 来等 goroutine:
done := make(chan bool)
go func() {
fmt.Println("干活中...")
done <- true // 干完了,发信号
}()
<-done // 等着,直到收到信号
fmt.Println("结束")
- goroutine 没发信号 → main 在
<-done那行等着 - goroutine 发了信号 → main 继续往下跑
缓冲 Channel
普通 channel 没有缓冲,一发一收必须配对。可以加缓冲:
ch := make(chan int, 3) // 缓冲区大小为3
ch <- 1 // 塞进去,不等(缓冲区没满)
ch <- 2 // 塞进去,不等
ch <- 3 // 塞进去,不等
ch <- 4 // 缓冲区满了,等着(阻塞)
比喻:
- 没缓冲:打电话,必须对方接了你才能说话
- 有缓冲:发微信,对方没看之前你也能发几条
关闭 Channel
发送方说"我不再发了":
close(ch)
接收方可以检测 channel 是否关闭:
x, ok := <-ch
if !ok {
fmt.Println("channel 已关闭")
}
概念总结
| 操作 | 含义 |
|---|---|
make(chan T) | 创建 channel |
ch <- data | 发送 |
x := <-ch | 接收 |
close(ch) | 关闭 |
make(chan T, n) | 带缓冲的 channel |
Select
Select 是什么?
一句话:同时监听多个 channel,哪个先有数据就处理哪个。
为什么需要 Select?
假设你有两个 channel:
ch1 := make(chan string)
ch2 := make(chan string)
用普通方式只能等一个:
msg := <-ch1 // 只能等 ch1,ch2 来了也不知道
用 select 可以同时等:
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg)
}
哪个先有数据就执行哪个。
完整例子
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
// 1秒后往 ch1 塞数据
go func() {
time.Sleep(1 * time.Second)
ch1 <- "来自 ch1"
}()
// 2秒后往 ch2 塞数据
go func() {
time.Sleep(2 * time.Second)
ch2 <- "来自 ch2"
}()
// 同时等 ch1 和 ch2
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg)
}
}
输出:来自 ch1(因为 ch1 先到)
sync.WaitGroup
为什么需要 WaitGroup?
你启动了多个 goroutine,想等它们全部做完再继续:
go task1()
go task2()
go task3()
// 怎么知道三个都做完了?
之前用 channel 可以,但要创建多个 channel 很麻烦。WaitGroup 就是专门解决这个的。
怎么用?三步
var wg sync.WaitGroup
wg.Add(1) // 第1步:告诉 wg 你要等几个任务
go func() {
// 做事情...
wg.Done() // 第3步:做完了,报告一下
}()
wg.Wait() // 第2步:等着,全部做完才继续
sync.Mutex 互斥锁
解决的问题:多个 goroutine 同时读写同一个变量。
var mu sync.Mutex
mu.Lock()
counter++
mu.Unlock()
sync.Once
var once sync.Once
once.Do(func() {
// 这段代码只会执行一次
})
不管你调多少次 once.Do(),里面的函数只执行第一次。
什么时候用?初始化操作,比如数据库连接、配置加载,只做一次。
Context
Context 是什么?
一句话:控制 goroutine 的生命周期,告诉它"别干了,取消"。
为什么需要 context?
假设你发了一个 HTTP 请求,用户等不及关了页面:
用户 → 请求 → 后端启动 goroutine 处理 → 数据库查询中...
用户关了页面
goroutine 还在跑,浪费资源。
用 context 可以通知 goroutine:“别查了,用户走了”。
先忘掉代码,想一个场景
你叫了一个外卖:
- 你:下单(启动 goroutine)
- 外卖员:开始送餐(goroutine 在跑)
- 情况1:你等不及了,打电话说"别送了"(手动取消)
- 情况2:你下单时就说"30分钟不到就取消"(超时取消)
context 就是那个电话。
代码里的角色
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
ctx→ 外卖员手里的对讲机(能听到"取消"的信号)cancel→ 你手里的电话(主动打过去说"取消")2*time.Second→ 30分钟超时(2秒后自动取消)
goroutine 那边怎么听信号?
select {
case <-ctx.Done(): // 对讲机响了,说"取消"
fmt.Println("收到取消,退出")
return
default: // 没响,继续干活
fmt.Println("干活中...")
}
ctx.Done() 就是对讲机,context 取消时它就响(channel 关闭)。
整个流程
- main:创建 context(设定2秒超时)
- main:启动 worker,把对讲机给它
- worker:干活…干活…
- 2秒后:超时,context 自动取消
- worker:听到对讲机响了,退出
两种取消方式
1. 手动取消:WithCancel
ctx, cancel := context.WithCancel(context.Background())
你自己决定什么时候取消:
go worker(ctx)
// 过了一会儿,你决定取消
cancel() // 打电话说"别干了"
就像:你叫了外卖,等了5分钟不耐烦了,主动打电话取消。
2. 超时取消:WithTimeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
设个时间,到了自动取消:
go worker(ctx)
// 不用管了,2秒后自动取消
// cancel() 也可以提前调,但不是必须的
Race Detector
什么是数据竞争?
之前 Mutex 的例子,不加锁会出错:
counter++ // 多个 goroutine 同时改,结果不对
这就是数据竞争,很难复现,偶尔出错,排查很痛苦。
Race Detector
Go 自带一个工具,帮你检测数据竞争:
go run -race demo.go
go build -race -o app
go test -race ./...
加一个 -race 就行。
看个例子
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println("counter:", counter)
}
go run -race test.go
输出:
WARNING: DATA RACE
...
Found 2 data race(s)
-race 告诉你什么
- 哪个 goroutine 在读(goroutine 12)
- 哪个 goroutine 在写(goroutine 8)
- 在第几行(第16行:
counter++)
一句话总结
go run -race→ 跑代码 + 检测数据竞争go test -race→ 跑测试 + 检测数据竞争(写项目时常用)
Worker Pool 并发模式
一句话:固定几个 goroutine,从队列里取任务执行。
为什么需要?
假设你有1000个任务:
// 方式1:每个任务一个 goroutine
for i := 0; i < 1000; i++ {
go doTask(i)
}
启动1000个 goroutine,资源占用大,不好控制。
// 方式2:Worker Pool,只启动3个 worker
for i := 0; i < 3; i++ {
go worker(tasks)
}
// 3个 worker 从 tasks 里取任务,取完一个再取下一个
就像餐厅只有3个厨师,100个菜排队等他们做,做完一个做下一个。
模型
任务队列(channel):[任务1, 任务2, 任务3, ...任务N]
Worker 1:从队列取任务 → 执行 → 再取下一个
Worker 2:从队列取任务 → 执行 → 再取下一个
Worker 3:从队列取任务 → 执行 → 再取下一个
完整代码
package main
import (
"fmt"
"time"
)
// worker 函数:每个 worker 从 tasks 这个 channel 里不断取任务执行
// id:worker 的编号,用来区分是哪个 worker 在干活
// tasks:任务队列,只读 channel(<-chan 表示只能从里面取,不能塞)
func worker(id int, tasks <-chan int) {
// range tasks:从 channel 里取任务,取到就执行
// channel 关闭后,range 自动结束,for 循环退出
for task := range tasks {
fmt.Printf("worker %d 开始处理任务 %d\n", id, task)
time.Sleep(time.Second) // 模拟干活耗时1秒
fmt.Printf("worker %d 完成任务 %d\n", id, task)
}
}
func main() {
// 创建一个带缓冲的 channel,缓冲区大小10
tasks := make(chan int, 10)
// 启动3个 worker,用 goroutine 并发跑
for i := 1; i <= 3; i++ {
go worker(i, tasks)
}
// 往 tasks 里塞5个任务(编号1到5)
for j := 1; j <= 5; j++ {
tasks <- j
}
// 关闭 channel,告诉 worker:"没有新任务了"
close(tasks)
// 等6秒,让 worker 有时间把任务做完
time.Sleep(6 * time.Second)
fmt.Println("全部完成")
}
执行流程
- main:启动 worker 1、worker 2、worker 3
- main:塞任务 1、2、3、4、5
- worker 1:取到任务1,开始干
- worker 2:取到任务2,开始干
- worker 3:取到任务3,开始干
- worker 1:任务1做完,取任务4
- worker 2:任务2做完,取任务5
- worker 3:任务3做完,队列空了,等着…
- main:
close(tasks)→ worker 们收到关闭信号,全部退出 - main:打印"全部完成"
到底怎么保证"干完了取下一个"还很有序?
关键:channel 的阻塞特性。
for task := range tasks {
// 干活...
}
range tasks 会自动做这件事:
- 有任务 → 取出来,执行
- 没任务 → 阻塞,等着
- channel 关闭 → 退出 for 循环
展开来看真实流程:
任务队列(channel):[1, 2, 3, 4, 5]
worker 1:range tasks → 取到 1 → 开始干
worker 2:range tasks → 取到 2 → 开始干
worker 3:range tasks → 取到 3 → 开始干
// 三个 worker 都在干活,队列里还有 4 和 5
worker 1:干完 → range tasks → 取到 4 → 开始干
worker 2:干完 → range tasks → 取到 5 → 开始干
worker 3:干完 → range tasks → 队列空了 → 阻塞,等着
// 没有新任务了
main:close(tasks)
worker 3:检测到关闭 → 退出
worker 1:干完任务4 → 检测到关闭 → 退出
worker 2:干完任务5 → 检测到关闭 → 退出
为什么不需要锁?
因为 channel 天然安全:
- 同一个任务不会被两个 worker 取到
- channel 保证:取走一个就少一个
一句话总结
range channel → 没任务就阻塞,有任务就取一个,取完再取下一个。channel 关闭 → range 自动结束。
参考源
- raw/Go.md