Appearance
并发陷阱与最佳实践
前面八章我们学习了 goroutine、channel、select、sync、context、atomic 等工具和模式。但掌握了工具不等于能写出正确的并发程序——并发世界充满了陷阱。本篇我们将系统梳理 Go 并发编程中最常见的陷阱:goroutine 泄漏、channel 误用、死锁、循环变量捕获、defer 陷阱等,并给出最佳实践。掌握这些,你才能写出健壮的生产级并发代码。
一、goroutine 泄漏的常见模式与排查
goroutine 泄漏是 Go 程序中最隐蔽的问题之一。goroutine 启动后无法退出,持续占用资源(栈、内存、调度),却没有做有用的事。
1. 泄漏模式一:阻塞在无数据的 channel
go
package main
import (
"fmt"
"time"
)
// ❌ 泄漏:goroutine 永远等不到数据
func leakyFetch() {
ch := make(chan string)
go func() {
ch <- "data" // 无缓冲 channel,没有接收方,永远阻塞
}()
// 函数返回后,ch 无引用,但 goroutine 还在等
}
func main() {
for i := 0; i < 5; i++ {
leakyFetch() // 每次泄漏一个 goroutine
}
time.Sleep(time.Second)
fmt.Println("泄漏了 5 个 goroutine")
}2. 泄漏模式二:发送方阻塞
go
package main
import (
"fmt"
"time"
)
// ❌ 泄漏:向满缓冲发送,没有接收方
func leakyProducer() {
ch := make(chan int, 1)
ch <- 1 // 填满
go func() {
ch <- 2 // 阻塞,没有接收方
}()
}
func main() {
leakyProducer()
time.Sleep(time.Second)
fmt.Println("泄漏了一个 producer goroutine")
}3. 泄漏模式三:没有监听取消信号
go
package main
import (
"fmt"
"time"
)
// ❌ 泄漏:goroutine 死循环,无法被取消
func backgroundWork() {
go func() {
for {
// 永远工作,无法停止
fmt.Println("工作中...")
time.Sleep(time.Second)
}
}()
}
func main() {
backgroundWork()
time.Sleep(3 * time.Second)
// 主 goroutine 退出,backgroundWork 的 goroutine 才跟着结束
// 但如果 main 不退出,它会永远泄漏
}4. 正确做法:用 context 取消
go
package main
import (
"context"
"fmt"
"time"
)
// ✅ 正确:监听 ctx.Done(),可被取消
func safeWork(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
fmt.Println("收到取消,退出")
return
case <-time.After(time.Second):
fmt.Println("工作中...")
}
}
}()
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
safeWork(ctx)
time.Sleep(3 * time.Second)
cancel() // 优雅取消
time.Sleep(500 * time.Millisecond)
}5. 排查泄漏的方法
- runtime.NumGoroutine():监控 goroutine 数量,持续增长说明有泄漏。
- pprof:访问
/debug/pprof/goroutine查看所有 goroutine 的堆栈。 - goleak:go.uber.org/goleak 库,在测试结束时检测残留 goroutine。
go
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
start := runtime.NumGoroutine()
fmt.Println("初始:", start)
for i := 0; i < 10; i++ {
ch := make(chan int)
go func() {
<-ch // 泄漏
}()
}
time.Sleep(time.Second)
now := runtime.NumGoroutine()
fmt.Println("现在:", now, "泄漏:", now-start)
}二、channel 关闭的正确姿势
关于 channel 关闭,Go 社区有一条铁律:只由发送方关闭,不要由接收方关闭。
1. 为什么要发送方关闭
- 接收方关闭后,发送方再发送会 panic——接收方不知道发送方是否还会发。
- 发送方知道自己何时「不再发送」,这是关闭的最佳时机。
- 多个发送方时,需要一个协调者关闭。
2. 单发送方:直接关闭
go
package main
import "fmt"
func main() {
ch := make(chan int)
// 发送方:发完关闭
go func() {
defer close(ch) // ✅ 发送方关闭
for i := 1; i <= 5; i++ {
ch <- i
}
}()
// 接收方:只接收,不关闭
for v := range ch {
fmt.Println(v)
}
}3. 多发送方:用协调 channel
go
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int)
done := make(chan struct{})
// 多个发送方
var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < 3; i++ {
select {
case ch <- id*10 + i:
case <-done:
return
}
}
}(w)
}
// 协调者:等待所有发送方完成后关闭 ch
go func() {
wg.Wait()
close(ch) // ✅ 所有发送方都退出后再关闭
}()
// 接收方
count := 0
for v := range ch {
fmt.Println(v)
count++
}
fmt.Println("共收到:", count)
}4. 不要关闭已关闭的 channel
go
package main
func main() {
ch := make(chan int)
close(ch)
// close(ch) // panic: close of closed channel
}5. 不要向已关闭的 channel 发送
go
package main
func main() {
ch := make(chan int)
close(ch)
// ch <- 1 // panic: send on closed channel
}6. 安全关闭的辅助函数
如果无法保证只关闭一次,可以用 sync.Once:
go
package main
import (
"fmt"
"sync"
)
type SafeChan struct {
ch chan int
once sync.Once
}
func NewSafeChan(buf int) *SafeChan {
return &SafeChan{ch: make(chan int, buf)}
}
func (s *SafeChan) Close() {
s.once.Do(func() {
close(s.ch) // 只关闭一次
})
}
func (s *SafeChan) Send(v int) bool {
select {
case s.ch <- v:
return true
default:
return false
}
}
func main() {
s := NewSafeChan(1)
s.Send(1)
s.Close()
s.Close() // 安全,不会 panic
fmt.Println("ok")
}三、遍历 nil channel 会永久阻塞
对 nil channel 的操作会永久阻塞。for range 一个 nil channel 会让 goroutine 永远卡住。
1. nil channel 的行为
go
package main
import "fmt"
func main() {
var ch chan int // nil
fmt.Println("ch 是 nil:", ch == nil)
// nil channel 的行为:
// - 发送:永久阻塞
// - 接收:永久阻塞
// - close:panic
// - len/cap:返回 0
// <-ch // 永久阻塞(死锁)
fmt.Println("len(nil chan):", len(ch), "cap:", cap(ch))
}2. 利用 nil channel 在 select 中禁用 case
go
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan int)
var ch2 chan int = nil // 初始禁用
go func() {
ch1 <- 1
time.Sleep(500 * time.Millisecond)
ch2 = make(chan int) // 后来启用
ch2 <- 2
}()
for i := 0; i < 2; i++ {
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
}
time.Sleep(100 * time.Millisecond)
}
}nil channel 在 select 中相当于「该 case 永远不会就绪」,可以用来动态启用/禁用监听。但要小心:如果所有 case 都是 nil channel 且没有 default,select 会永久阻塞(死锁)。
四、向已关闭的 channel 发送数据会 panic
这是个常见错误,值得单独强调。
1. 错误示例
go
package main
func main() {
ch := make(chan int, 1)
close(ch)
// ch <- 1 // panic: send on closed channel
}2. 接收方关闭导致发送方 panic
go
package main
import "time"
func main() {
ch := make(chan int)
go func() {
// 接收方关闭,错误!
close(ch)
}()
time.Sleep(100 * time.Millisecond)
// ch <- 1 // 可能 panic,取决于时序
}3. 用 context 协调,避免向关闭 channel 发送
go
package main
import (
"context"
"fmt"
"sync"
)
func producer(ctx context.Context, ch chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; ; i++ {
select {
case <-ctx.Done():
return
case ch <- i:
fmt.Println("发送:", i)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan int, 5)
var wg sync.WaitGroup
wg.Add(1)
go producer(ctx, ch, &wg)
// 接收一些数据
for i := 0; i < 5; i++ {
fmt.Println("收到:", <-ch)
}
// 先取消,让 producer 退出,再关闭 channel
cancel()
wg.Wait()
close(ch)
fmt.Println("完成")
}关键:先让所有发送方通过 context 退出,再关闭 channel,避免发送方在 channel 关闭后还在发送。
五、Mutex 复制导致的死锁
sync.Mutex、sync.WaitGroup、sync.Cond 等不能复制。复制会破坏它们的内部状态,导致死锁或计数错误。
1. 错误示例
go
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
n int
}
// ❌ 错误:接收者是值,Mutex 被复制
func (c Counter) Inc() {
c.mu.Lock() // 锁的是副本
defer c.mu.Unlock()
c.n++
}
func (c Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
func main() {
var c Counter
c.Inc() // 修改的是副本,原 c 不变
fmt.Println(c.Value()) // 0
}2. 正确做法:用指针接收者
go
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
n int
}
// ✅ 正确:接收者是指针,共享同一个 Mutex
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
func main() {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
fmt.Println(c.Value()) // 100
}3. go vet 能检测
bash
go vet ./...go vet 能检测出「锁按值传递」的错误。养成运行 go vet 的习惯。
4. WaitGroup 也要用指针
go
package main
import (
"fmt"
"sync"
)
// ❌ 错误:WaitGroup 按值传递
func badWorker(wg sync.WaitGroup) {
defer wg.Done() // 修改的是副本,原 wg 计数器不减
fmt.Println("done")
}
// ✅ 正确:传指针
func goodWorker(wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("done")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go goodWorker(&wg)
wg.Wait()
}六、for 循环变量捕获问题
这是一个经典的 Go 陷阱,Go 1.22 修复了它,但理解它仍然重要。
1. Go 1.22 之前的行为
go
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i) // Go 1.22 前:可能都输出 3
}()
}
wg.Wait()
}在 Go 1.22 前,循环变量 i 在所有迭代中是同一个变量,goroutine 执行时 i 可能已经是 3。
2. Go 1.22+ 的行为
Go 1.22 起,每次循环迭代创建新的 i,每个 goroutine 捕获的是当次迭代的值,输出 0、1、2。
3. 兼容性写法:显式传参
无论版本如何,显式传参永远安全:
go
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) { // 把 i 作为参数传入
defer wg.Done()
fmt.Println(n) // 总是正确:0, 1, 2
}(i)
}
wg.Wait()
}4. 用局部变量复制
go
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
i := i // 创建新的局部变量
go func() {
defer wg.Done()
fmt.Println(i)
}()
}
wg.Wait()
}i := i 在每次迭代创建一个新变量,goroutine 捕获它。这是 Go 1.22 前的标准修复方式。
5. 建议保持显式传参
虽然 Go 1.22 修复了这个问题,但:
- 显式传参更清晰,表达意图明确。
- 团队可能还在用旧版本。
- 代码审查时不用纠结版本。
所以推荐始终显式传参。
七、defer 在循环中的陷阱
defer 在函数返回时执行,不是在循环结束时。如果在循环里 defer,资源会堆积到函数返回才释放。
1. 错误:循环里 defer 文件
go
package main
import (
"fmt"
"os"
)
// ❌ 错误:所有文件到函数结束才关闭,资源堆积
func badProcessFiles(files []string) {
for _, f := range files {
file, err := os.Open(f)
if err != nil {
continue
}
defer file.Close() // 所有 file 都 defer 到函数结束
// 处理 file...
_ = file
}
}
// ✅ 正确:把循环体抽成函数,让 defer 在每次迭代结束时执行
func goodProcessFiles(files []string) {
for _, f := range files {
processOneFile(f)
}
}
func processOneFile(name string) {
file, err := os.Open(name)
if err != nil {
return
}
defer file.Close() // 在 processOneFile 返回时关闭
fmt.Println("处理:", name)
}
func main() {
goodProcessFiles([]string{"a.txt", "b.txt"})
}2. 错误:循环里 defer 释放锁
go
package main
import (
"fmt"
"sync"
)
// ❌ 错误:锁堆积
func bad() {
var mu sync.Mutex
for i := 0; i < 5; i++ {
mu.Lock()
defer mu.Unlock() // 5 次 Lock,5 次 defer,但都到函数结束才 Unlock
// 第二次 Lock 就死锁了!
fmt.Println(i)
}
}
// ✅ 正确:临界区用完立即 Unlock
func good() {
var mu sync.Mutex
for i := 0; i < 5; i++ {
mu.Lock()
fmt.Println(i)
mu.Unlock() // 立即释放
}
}
func main() {
good()
}3. 抽函数是通用解法
把循环体抽成独立函数,是处理「循环里需要 defer」的通用方法。这样 defer 的作用域是单次迭代,资源及时释放。
八、channel 的 nil 值行为
总结 nil channel 的行为,避免踩坑:
| 操作 | nil channel 的行为 |
|---|---|
发送 ch<-v | 永久阻塞 |
接收 <-ch | 永久阻塞 |
close(ch) | panic |
len(ch) | 0 |
cap(ch) | 0 |
for range | 永久阻塞 |
| select 中的 case | 永远不会就绪 |
1. 小心 nil channel 死锁
go
package main
func main() {
var ch chan int // nil
// for v := range ch { // 永久阻塞
// _ = v
// }
select {
case v := <-ch: // nil,永远不就绪
_ = v
}
// 如果只有这一个 case,永久阻塞(死锁)
}2. 利用 nil channel 做控制
go
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
// 收完之后把 ch 设为 nil,禁用该 case
for {
select {
case v, ok := <-ch:
if !ok {
ch = nil // 收完,禁用
fmt.Println("ch 已关闭")
goto done
}
fmt.Println("收到:", v)
}
}
done:
time.Sleep(100 * time.Millisecond)
}九、并发安全的配置更新
配置热更新是常见需求:一个 goroutine 更新配置,多个 goroutine 读取。必须保证并发安全。
1. 错误:直接读写
go
package main
import "sync"
type Config struct {
Timeout int
Retries int
}
// ❌ 竞态:读写没有同步
var cfg Config
func update(timeout, retries int) {
cfg.Timeout = timeout // 写
cfg.Retries = retries // 写
// 读者可能读到 Timeout 更新但 Retries 没更新的中间状态
}
func read() Config {
return cfg // 读,可能读到中间状态
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); update(5, 3) }()
go func() { defer wg.Done(); _ = read() }()
wg.Wait()
}2. 方案一:atomic.Value
go
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
type Config struct {
Timeout int
Retries int
}
var configVal atomic.Value
func update(timeout, retries int) {
// 整体替换:新建对象,原子替换
configVal.Store(&Config{Timeout: timeout, Retries: retries})
}
func read() *Config {
return configVal.Load().(*Config)
}
func main() {
configVal.Store(&Config{Timeout: 5, Retries: 3})
var wg sync.WaitGroup
// 多个读者
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 5; j++ {
c := read()
fmt.Printf("reader %d: %+v\n", id, c)
time.Sleep(100 * time.Millisecond)
}
}(i)
}
// 一个写者
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(200 * time.Millisecond)
update(10, 5)
fmt.Println(">>> 配置已更新")
}()
wg.Wait()
}3. 方案二:RWMutex
go
package main
import (
"fmt"
"sync"
)
type ConfigStore struct {
mu sync.RWMutex
conf Config
}
func (s *ConfigStore) Get() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return s.conf // 拷贝一份返回,避免外部修改
}
func (s *ConfigStore) Set(timeout, retries int) {
s.mu.Lock()
defer s.mu.Unlock()
s.conf.Timeout = timeout
s.conf.Retries = retries
}
func main() {
store := &ConfigStore{}
store.Set(5, 3)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
c := store.Get()
fmt.Printf("reader %d: %+v\n", id, c)
}(i)
}
wg.Wait()
}选择:读多写少用 atomic.Value(无锁读),写多或需要部分更新用 RWMutex。
十、优雅退出模式
优雅退出是指程序收到终止信号时,能通知所有 goroutine 停止当前工作、清理资源、保存状态,再退出。
1. 基本 pattern
go
package main
import (
"context"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
defer wg.Done()
defer fmt.Printf("worker %d 已退出\n", id)
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d 收到退出信号,清理中...\n", id)
time.Sleep(200 * time.Millisecond) // 模拟清理
return
case <-time.After(500 * time.Millisecond):
fmt.Printf("worker %d 工作中\n", id)
}
}
}
func main() {
// 监听中断信号
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(ctx, i, &wg)
}
// 等待信号
<-ctx.Done()
fmt.Println("收到信号,等待 worker 退出...")
// 给一个超时,避免 worker 卡住导致程序不退出
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
fmt.Println("所有 worker 已退出")
case <-time.After(3 * time.Second):
fmt.Println("超时,强制退出")
os.Exit(1)
}
}signal.NotifyContext(Go 1.16+)是处理信号的标准方式,收到信号自动取消 context。
2. HTTP 服务器的优雅退出
go
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
server := &http.Server{Addr: ":8080"}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second) // 模拟慢请求
fmt.Fprintln(w, "hello")
})
// 启动服务器
go func() {
fmt.Println("服务器启动在 :8080")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Println("服务器错误:", err)
}
}()
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("收到信号,开始优雅关闭...")
// 给 10 秒处理未完成请求
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
fmt.Println("强制关闭:", err)
}
fmt.Println("服务器已退出")
}server.Shutdown(ctx) 会等待所有正在处理的请求完成,再退出,不会中断进行中的请求。
十一、其他常见陷阱
1. 不要在 goroutine 中直接用外部变量返回值
go
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make([]int, 5)
// ❌ 危险:results[idx] 的写入需要同步
// 但这里每个 goroutine 写不同索引,实际上安全(不同内存位置)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
results[idx] = idx * 2
}(i)
}
wg.Wait()
fmt.Println(results) // [0 2 4 6 8]
// ✅ 更清晰:用 channel 收集
out := make(chan int, 5)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
out <- idx * 2
}(i)
}
go func() {
wg.Wait()
close(out)
}()
var collected []int
for v := range out {
collected = append(collected, v)
}
fmt.Println(collected)
}写不同索引的切片元素在内存上是独立的,但要确保在 Wait 之后读,否则有竞态。用 channel 更安全清晰。
2. time.After 在 select 循环中的内存问题
go
package main
import (
"fmt"
"time"
)
// ❌ 每次循环创建新 timer,超时前不释放
func badTimeout() {
ch := make(chan int)
for {
select {
case v := <-ch:
fmt.Println(v)
case <-time.After(5 * time.Second): // 每次新建 timer
return
}
}
}
// ✅ 复用 timer
func goodTimeout() {
ch := make(chan int)
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
timer.Reset(5 * time.Second) // 复用
select {
case v := <-ch:
fmt.Println(v)
case <-timer.C:
return
}
}
}
func main() {
goodTimeout()
}在高频循环中,time.After 会堆积大量未触发的 timer,用 NewTimer + Reset 复用更高效。
3. 不要用共享变量做 goroutine 间同步
go
package main
import "fmt"
var ready bool
// ❌ 错误:用 ready 做同步,无 happens-before
func bad() {
data := 0
go func() {
data = 42
ready = true // 无同步,主 goroutine 可能看不到
}()
for !ready {
// 可能永远循环(编译器优化、内存可见性)
}
fmt.Println(data) // 可能是 0
}
func main() {
_ = bad
}应该用 channel 或 atomic:
go
package main
import "fmt"
func good() {
ch := make(chan int)
go func() {
data := 42
ch <- data // 发送 happens-before 接收
}()
data := <-ch
fmt.Println(data) // 一定是 42
}
func main() {
good()
}十二、小结
本篇我们系统梳理了并发编程的陷阱和最佳实践:
goroutine 泄漏:常见于阻塞在无数据 channel、向满缓冲发送、无取消信号的死循环。用 context + select 监听取消信号预防。用 NumGoroutine、pprof、goleak 排查。
channel 关闭:只由发送方关闭,不要由接收方关闭。多发送方用协调者(等所有发送方退出后关闭)。不要重复关闭、不要向已关闭 channel 发送。用 sync.Once 实现安全关闭。
nil channel:发送/接收/for range 都永久阻塞,close 会 panic。在 select 中可用来禁用 case(动态启用/禁用监听),但全是 nil case 会死锁。
向已关闭 channel 发送会 panic:用 context 协调,先让发送方退出再关闭 channel。
Mutex 复制:Mutex、WaitGroup 等不能复制,必须用指针。接收者用指针、传参传指针。
go vet能检测。循环变量捕获:Go 1.22 前循环变量共享,goroutine 可能都看到最终值。Go 1.22 修复。兼容写法:显式传参或
i := i。推荐始终显式传参。defer 在循环中:defer 在函数返回时执行,循环里 defer 会堆积资源。把循环体抽成函数让 defer 在每次迭代生效。
并发安全配置更新:用 atomic.Value 整体替换(无锁读)或 RWMutex 保护。不要直接读写共享结构体。
优雅退出:用 signal.NotifyContext 监听信号,context 传播取消,WaitGroup 等待 goroutine 退出,加超时兜底。HTTP 用 server.Shutdown。
其他陷阱:time.After 在循环中复用 NewTimer;不要用共享变量做同步(用 channel/atomic);写切片不同索引虽安全但要 Wait 后读。
下一篇是本系列的最后一篇,我们将学习 errgroup、semaphore、singleflight 等扩展库,把并发能力提升到生产级。