Goroutine
goroutine 是 Go 运行时自己调度的协程,比 OS 线程轻得多:初始栈只有 2 KB(可按需扩缩),切换不走内核态,单进程拉起几十万个不是问题。go 关键字一加就能并发跑,剩下的调度和栈管理全部由 runtime 接管。
下面这一篇覆盖从最小例子到调度器(GMP)、同步原语(Mutex / RWMutex / atomic / WaitGroup)、Channel 及 context 的常用法和坑。
起一个协程
func sayHello() {
fmt.Println("Hello from goroutine!")
}
func main() {
go sayHello() // 启动一个新的协程
fmt.Println("Hello from main!")
time.Sleep(1 * time.Second) // 等待协程完成
}这段代码末尾的 time.Sleep(1 * time.Second) 是关键。主协程跑完 main 会直接退出,不等其他协程,去掉 Sleep 的话子协程通常根本来不及打印。Sleep 只是 demo 凑合用,正式代码里要等协程跑完,用下面会讲的 sync.WaitGroup 或者 channel 做显式同步。
两条 Println 是并发执行的,谁先打印不固定,下面两种结果都合法:
Hello from main!
Hello from goroutine!Hello from goroutine!
Hello from main!GMP 调度器
Go 的调度模型俗称 GMP,是用户态协程能跑得起来的关键。三个角色:
| 缩写 | 实体 | 角色 |
|---|---|---|
| G | goroutine | 一段要被执行的协程代码,含自己的栈、PC、状态 |
| M | machine | 操作系统线程,真正能被 OS 调度的执行单元 |
| P | processor | 逻辑处理器,G 和 M 之间的中转,持有本地运行队列 |
P 的个数等于 GOMAXPROCS(默认等于 CPU 核数),决定了同一时刻最多并行跑多少个 G。M 是按需创建的,没事干就睡掉。一个 M 想跑 G,必须先绑定一个 P。
调度的几条主线
go f()创建出来的 G 默认放到当前 P 的本地队列末尾(队列满了就部分迁到全局队列);- M 从绑定的 P 的本地队列头部取一个 G 来跑;
- 本地队列空了,M 会去全局队列捞,再不行就走工作窃取,从别的 P 的本地队列后半段偷一半过来;
- 如果系统里所有队列都空,M 解绑 P 进入休眠,等待被唤醒;
- G 跑完不会回到运行队列,而是进 gFree 池等待下次
go时复用栈结构。
阻塞场景:syscall 与 netpoller 走两条路
这是 GMP 里最容易讲错的地方,分开讲清楚:
- G 进入 syscall(文件读写、阻塞式系统调用):当前 M 跟着内核陷入卡住,没法继续跑别的 G。runtime 会把 P 解绑给另一个空闲 M(必要时新建),让 P 上其他 G 继续被调度。syscall 返回后,原 M 想拿回一个 P 继续跑;拿不到就把 G 塞进全局队列,自己去睡。
- G 阻塞在网络 IO / channel / select / mutex:G 被 park 挂起,但 M 不会跟着卡,它接着在本 P 上调度别的 G。事件就绪时,netpoller 把对应 G 放回运行队列,下次被调度到就接着跑。
理解这一区别之后再看那些"几万并发连接为什么 Go 不会爆"的问题,就豁然开朗。
抢占式调度
Go 1.14 起引入了基于信号的异步抢占:runtime 给 M 发 SIGURG,强制让 G 让出 CPU。在此之前是协作式抢占,靠编译器在函数序言里插入检查点,所以一段不调用任何函数的纯计算死循环会把调度器卡死(经典的"for {} 让其他 goroutine 跑不起来")。Go 1.14 之后这种情况也能被抢占了。
几个相关优化
- 工作窃取:保证负载在 P 之间动态均衡;
- 局部性:G 倾向于跟着原 P 跑,提升 L1/L2 命中;
- netpoller:所有网络 IO 都过 epoll/kqueue/IOCP,挂起 G 不挂起 M。
WaitGroup
sync.WaitGroup 是最常用的"等一组协程跑完"的同步原语。三步走:
var wg sync.WaitGroup
wg.Add(n) // 在启动 n 个协程之前
go func() {
defer wg.Done() // 协程退出前一定要标记完成
// ...
}()
wg.Wait() // 阻塞直到计数器归零最小可运行示例:
- 声明一个
WaitGroup:
var wg sync.WaitGroup- 启动协程前
Add(必须先调用):
wg.Add(3) // 表示需要等待 3 个 goroutine 完成- 协程末尾
Done,搭配defer防遗漏:
go func() {
defer wg.Done()
// goroutine 的逻辑代码
}()- 主协程
Wait等齐:
wg.Wait()
fmt.Println("All Done")三个真坑
- 先
go后Add是错的。Add必须在go func()之前调用,否则Wait可能在Add之前就看到 0,提前返回。 Done多调一次会 panic(计数器走负)。Add的参数可以是负数,但实际上几乎没人这么用,正确姿势是循环里wg.Add(1)。
互斥锁 Mutex
sync.Mutex 守护任何不允许并发写入的共享状态。零值即可使用,不需要构造函数:
var mu sync.Mutex加锁、临界区、解锁三件套:
import "sync"var mu sync.Mutexfunc example() {
mu.Lock()
defer mu.Unlock()
// 访问共享资源
}完整示例:
var counter int
var mu sync.Mutex
var wg = sync.WaitGroup{}
func count() {
mu.Lock()
defer mu.Unlock()
defer wg.Done()
counter++
}
func main() {
for i := 0; i < 1000000; i++ {
wg.Add(1)
go count()
}
wg.Wait()
fmt.Println(counter)
}几个易踩的点:
- 不要拷贝带锁的结构体。
Mutex拷一份会得到两个相互独立的锁,等于没锁。go vet会报copylocks警告。 - 不要在已锁的临界区里再次
Lock同一把锁。Go 的 Mutex 不可重入,会直接死锁。 Unlock必须由持锁的 goroutine 调用。把Unlock用defer包好是最稳的写法。
原子操作 sync/atomic
sync/atomic 提供针对 int32 / int64 / uint32 / uint64 / uintptr / unsafe.Pointer 的原子操作,避免锁的开销。Go 1.19 之后还多了 atomic.Int32 / atomic.Int64 / atomic.Bool / atomic.Pointer[T] 等类型化版本,写起来更清爽。
常用函数:
Add:原子地把delta加到*addr,返回新值。
var ops uint64 = 0
atomic.AddUint64(&ops, 1)
fmt.Println("ops:", ops)CompareAndSwap:CAS。比较*addr是否等于old,是就改成new并返回true,否则不改返回false。无锁数据结构的基石。
var value int32 = 3
swapped := atomic.CompareAndSwapInt32(&value, 3, 5)
fmt.Println(swapped, value) // true, 5Load:原子读。
var value int32 = 18
loadedValue := atomic.LoadInt32(&value)
fmt.Println(loadedValue) // 18Store:原子写。
var value int32 = 0
atomic.StoreInt32(&value, 20)
fmt.Println(value) // 20Swap:原子地把val写入*addr并返回旧值。
var value int32 = 4
oldValue := atomic.SwapInt32(&value, 5)
fmt.Println(oldValue, value) // 4, 5适用范围有限:原子操作只能针对单个变量做一次"读 → 改 → 写"。一旦临界区里要改两个变量、或者要先判断再改,就得回到 Mutex。原子操作之间没有任何顺序保证,需要的话要配合 memory barrier,但 Go 已经在 atomic 包里保证了 sequentially consistent 语义,业务代码里通常不用考虑。
读写锁 RWMutex
sync.RWMutex 区分了读锁和写锁:
- 多个 goroutine 可以同时持有读锁;
- 写锁是独占的,写锁持有时其他任何读 / 写都拿不到锁;
- 读锁优先级低于已经在等的写锁(避免写饥饿)。
| 方法 | 说明 |
|---|---|
RLock() | 拿读锁 |
RUnlock() | 释放读锁 |
Lock() | 拿写锁 |
Unlock() | 释放写锁 |
TryLock() / TryRLock() | Go 1.18+,拿不到立刻返回 false |
适用场景:读远多于写的状态(缓存、配置、路由表)。读写比 1:1 上下的话 RWMutex 反而比 Mutex 慢,因为内部簿记开销更大。
Channel
channel 是 Go 最具特色的并发原语,等于一根类型化的管道,传值同时也传"happens-before"语义。Go 圈子里那句口号"Don't communicate by sharing memory; share memory by communicating"指的就是它。
创建
// 创建一个传递 int 类型的无缓冲 Channel
ch := make(chan int)
// 创建一个传递 int 类型的有缓冲 Channel,缓冲区大小为 10
chBuffered := make(chan int, 10)make(chan T) 不带容量是无缓冲的,make(chan T, n) 带容量是有缓冲的,两者语义差别巨大。
无缓冲
发送和接收必须同时配对才能继续,否则任一方阻塞。常用来做严格的同步点。
ch := make(chan string)
go func() {
time.Sleep(2 * time.Second)
ch <- "Zhang"
}()
msg := <-ch
fmt.Println(msg)有缓冲
容量没满时发送不阻塞,容量没空时接收不阻塞。容量满了写、容量空了读,才会阻塞。
ch := make(chan int, 2)
ch <- 1
ch <- 2
// ch <- 3 // 如果取消注释,这里会阻塞,因为缓冲区已满
fmt.Println(<-ch) // 输出 1
fmt.Println(<-ch) // 输出 2关闭
close(ch) 表示生产者宣告再也不会写新值。关闭后的行为要记牢:
| 操作 | 行为 |
|---|---|
| 再次发送 | panic:send on closed channel |
| 再次 close | panic:close of closed channel |
| 接收 | 先把缓冲里剩的值取干净,之后返回零值;v, ok := <-ch 中 ok == false 表示已关闭且空 |
range ch | 取干净后自动结束循环 |
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
for v := range ch {
fmt.Println(v)
}经验法则:只让发送方关闭,不要让接收方关闭,并且只在能确定不再有发送时关闭。多生产者场景下,需要额外用 sync.Once 或者一个独立的"关闭信号 channel"。
for range 是消费 channel 直到关闭的最常见写法:
for v := range ch {
fmt.Println(v)
}
// 退出循环 = ch 已被 close 且取空select 多路复用
select 同时盯多个 channel,谁先就绪就跑哪个 case:
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "ch1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "ch2"
}()
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
}要点:
- 多个 case 同时就绪时,
select随机挑一个,避免某些 case 长期被饿死; - 加
default分支可以让 select 立刻返回,做非阻塞的尝试发送/接收; - 加
time.After做超时:
// 会在 3s 后向 Channel timer.C 写入时间
timer := time.NewTimer(3 * time.Second)
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
case <-timer.C:
fmt.Println("Timed out")
}注意 time.After 每次都会 make 一个新的定时器,长循环里频繁用会泄漏定时器,循环内推荐改用 time.NewTimer + Reset,或者让 ctx.Done() 当超时入口。
单向 channel
只发送(chan<- T)或只接收(<-chan T)。常作为函数参数类型,约束函数对 channel 的使用方向:
// 创建一个双向的整数通道
ch := make(chan int)
// sendOnly 只能用于发送数据
var sendOnly chan<- int = ch
// receiveOnly 只能用于接收数据
var receiveOnly <-chan int = ch// 生产者函数,只发送数据
func producer(sendCh chan<- int) {
for i := 0; i < 5; i++ {
sendCh <- i
fmt.Println("Produced:", i)
}
close(sendCh)
}
// 消费者函数,只接收数据
func consumer(receiveCh <-chan int) {
for num := range receiveCh {
fmt.Println("Consumed:", num)
}
}
func main() {
ch := make(chan int)
go producer(ch)
consumer(ch)
}双向 channel 可以隐式赋值给单向版本,反向不行。
循环变量与协程的经典坑
for i := 0; i < 3; i++ {
go func() { fmt.Println(i) }()
}Go 1.21 及之前:三个协程很可能都打印 3,因为闭包捕获的是同一个 i,循环结束时它的值是 3。修法是在循环里 i := i 创建新变量。
Go 1.22 起:循环变量改为每轮迭代独立作用域,上面这段会按预期打印 0、1、2(顺序仍然不固定)。所以维护老项目升到 1.22+ 后还要顺便复查一遍,行为是真的会变。
context
context.Context 用来在调用链里传取消信号、截止时间和请求级数据。Go 里凡是涉及"可能要中途取消"的 IO、RPC、SQL,第一个参数基本都是 ctx。
核心接口
type Context interface {
// 返回上下文的截止时间
Deadline() (deadline time.Time, ok bool)
// 返回一个通道,当上下文被取消或到达截止时间时关闭
Done() <-chan struct{}
// 返回上下文被取消的原因
Err() error
// 返回与 key 关联的值,如果没有则返回 nil
Value(key interface{}) interface{}
}四个方法:Deadline() 看有没有截止时间、Done() 拿到一个会在取消时被 close 的 channel、Err() 看取消原因(Canceled 或 DeadlineExceeded)、Value(key) 取关联值。
根 context
整个调用链的源头通常是 context.Background():
ctx := context.Background()context.TODO() 与 Background() 等价,专门用在"暂时不知道用什么,先占位"的地方。两者在静态分析工具里会被区别对待,所以语义上有差别。
派生 context
所有 WithXxx 都基于父 context 派一个子 context,子 context 自动继承父的截止时间和取消事件。
- 主动取消:
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 确保在不需要时取消上下文,释放资源- 绝对截止时间:
deadline := time.Now().Add(10 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()- 相对超时(语法糖):
ctx, cancel := context.WithTimeout(context.Background(), 10 * time.Second)
defer cancel()- 带值:
ctx := context.WithValue(context.Background(), "userID", 12345)context 的几条规矩
context.WithCancel/WithTimeout/WithDeadline返回的cancel()必须调用,哪怕 ctx 是因超时被取消的也要defer cancel(),否则会泄漏内部 timer 和 goroutine。- 不要把
Context塞进结构体字段,永远当函数参数显式传递,并放在第一个位置; Value只用来传请求级元数据(trace id、用户身份),别拿它传业务参数;- 自定义 key 要用自定义类型而不是
string,防止跨包冲突:type ctxKey int; const userKey ctxKey = 1。