前言
Go 从语言层面为并发而生:go 关键字一行起"线程",channel 让协程间安全通信。口号是——不要通过共享内存来通信,而要通过通信来共享内存。
一、goroutine:廉价的并发单元
package main
import (
"fmt"
"time"
)
func say(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("world") // go 关键字: 并发执行
say("hello") // 当前协程继续跑
}
goroutine 的"轻":
| 维度 | OS 线程 | goroutine |
|---|---|---|
| 栈初始大小 | 1~8 MB 固定 | 2KB 起步、按需增长 |
| 创建/切换成本 | 系统调用,微秒级 | 用户态调度,纳秒级 |
| 单机数量级 | 数千 | 数十万~百万 |
调度由 Go 运行时的 GMP 模型完成(G=goroutine,M=内核线程,P=逻辑处理器),此处知道存在即可。
⚠️ main 退出 = 所有 goroutine 立即死亡(不管跑没跑完),所以需要等待机制 → WaitGroup。
二、WaitGroup:等所有协程干完
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1) // 计数 +1(必须在 go 之前!)
go func(u string) { // 注意: 参数传值, 避免闭包变量陷阱
defer wg.Done() // 干完 -1
fetch(u)
}(url)
}
wg.Wait() // 阻塞到计数归零
经典闭包陷阱(Go 1.22 前 for 循环变量复用):
for _, url := range urls {
go func() {
fmt.Println(url) // ❌ 老版本: 全打印最后一个!
}()
}
// 解法1: url := url 影子变量
// 解法2: 像上面那样通过参数传入
// Go 1.22+ 循环变量已是每轮独立, 但老代码里这坑遍地
三、channel:协程间的高速公路
// 无缓冲: 发送阻塞直到有人接收(同步, 交接班)
ch := make(chan int)
// 有缓冲: 装满才阻塞(异步, 邮筒)
ch := make(chan int, 10)
ch <- 42 // 发送
v := <-ch // 接收
close(ch) // 关闭(发送方关!)
生产者-消费者:
func producer(ch chan<- int) { // chan<- 只写
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // 没有更多数据了
}
func consumer(ch <-chan int) { // <-chan 只读
for v := range ch { // 循环接收, channel 关闭且排空后自动退出
fmt.Println("got", v)
}
}
func main() {
ch := make(chan int, 3)
go producer(ch)
consumer(ch)
}
关闭规则:
- 由发送方关闭,接收方永远不要 close
- 关闭后:接收先拿完剩余值,然后得到零值
v, ok := <-ch:ok == false表示已关闭且取空- 向已关闭的 channel 发送 → panic
- 关闭 nil channel → panic
四、select:多路复用
select {
case v := <-chA:
fmt.Println("A:", v)
case chB <- 42:
fmt.Println("发送到 B 成功")
case <-time.After(3 * time.Second): // 超时控制(高频用法!)
fmt.Println("超时")
return
}
- 多个 case 同时就绪 → 随机选一个(防饿死)
default分支:都 不就绪时立即走(非阻塞收发)
// 非阻塞发送
select {
case ch <- msg:
default: // 满了就丢弃, 不卡住
log.Println("丢弃", msg)
}
超时控制实战:
func fetchWithTimeout(url string) error {
done := make(chan error, 1) // 缓冲1: goroutine 不泄漏
go func() { done <- realFetch(url) }()
select {
case err := <-done:
return err
case <-time.After(2 * time.Second):
return fmt.Errorf("timeout: %s", url)
}
}
五、sync 包三剑客
// Mutex: 保护共享数据(计数器、map 等)
var (
mu sync.Mutex
counter int
)
func inc() {
mu.Lock()
defer mu.Unlock() // defer 保证解锁, 死锁保险
counter++
}
// RWMutex: 读多写少场景(读锁共享, 写锁独占)
var rwmu sync.RWMutex
func readConfig() string {
rwmu.RLock()
defer rwmu.RUnlock()
return conf
}
// Once: 只执行一次(单例、初始化)
var once sync.Once
var conn *Conn
func getConn() *Conn {
once.Do(func() {
conn = dial() // 无论并发调用多少次, 只 dial 一次
})
return conn
}
原则:能用 channel 表达的用 channel(传递数据所有权),需要保护共享状态时用 Mutex。两者不是对立而是互补。
六、常见死锁与泄漏
死锁现场一:无缓冲 channel 自发自收
ch := make(chan int)
ch <- 1 // ❌ 永久阻塞: 没有接收者, fatal error: all goroutines are asleep
死锁现场二:互斥锁循环等待
// goroutine1: mu1.Lock() -> 要 mu2
// goroutine2: mu2.Lock() -> 要 mu1 💀
// 预防: 多把锁永远按相同顺序加锁
泄漏现场:goroutine 阻塞在发送,无人接收
func leak() {
ch := make(chan int) // ❌ 无缓冲
go func() { ch <- 42 }() // 若没人收, 这个 goroutine 永远卡着
// ... 提前 return 了
}
// ✅ 解法: make(chan int, 1) 或用 context 取消(后续专篇)
排查工具:
go run -race main.go # 竞态检测器, CI 必开
go test -race ./...
七、实战:并发抓取器
func fetchAll(urls []string) map[string]string {
var (
mu sync.Mutex
wg sync.WaitGroup
results = make(map[string]string, len(urls))
)
for _, u := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
body := fetchOne(u) // 返回摘要
mu.Lock()
results[u] = body
mu.Unlock()
}(u)
}
wg.Wait()
return results
}
10 个 URL 并行抓取,总耗时 ≈ 最慢的那个,而不是总和。
小结
| 组件 | 用途 |
|---|---|
go f() |
起协程 |
| WaitGroup | 等一组协程结束 |
| channel | 协程间传数据,关闭由发送方做 |
| select | 多路等待 + 超时/非阻塞 |
| Mutex/RWMutex | 保护共享内存 |
-race |
上线前必跑 |
本文是「Golang」系列第 5 篇。