Go 并发代码测试实战——race detector、并发测试策略、goroutine 泄漏检测
Go 并发代码测试实战——race detector、并发测试策略、goroutine 泄漏检测
适读人群:Go 开发工程师、高并发系统开发者 | 阅读时长:约 15 分钟 | 核心价值:用 race detector 和 goleak 系统性测试 Go 并发代码,告别数据竞争和 goroutine 泄漏
去年我在做代码 review 时,看到一个同事小峰写的缓存实现——逻辑不复杂,就是一个带过期时间的内存 map。我扫了一眼,隐约觉得哪里不对劲,让他加上 race detector 跑一下。
结果一跑,爆出了三处数据竞争。
小峰看着报告一脸懵:「我明明加了锁啊,怎么还有竞争?」
仔细一看,他的 sync.RWMutex 用对了地方——读操作加读锁,写操作加写锁。问题在于他的定时清理 goroutine,在遍历 map 删除过期 key 时,只加了读锁,而删除操作实际上是写操作。
这是并发代码里最典型的"以为加了锁,实际没保护到"的问题。Race detector 在几秒内就揪出来了,而手动 code review 花了我 10 分钟也只是"觉得哪里不对"。
1. Race Detector:Go 并发的第一道防线
Go 内置了 Race Detector,基于 ThreadSanitizer(TSan)实现,能在运行时检测数据竞争。
1.1 基础使用
# 测试时开启 race detector
go test -race ./...
# 构建时开启(用于临时调试生产代码)
go build -race -o myapp ./cmd/myapp
# 运行时开启
go run -race main.go1.2 数据竞争示例与检测
package cache_test
import (
"sync"
"testing"
"time"
"example.com/app/cache"
)
// 模拟并发读写
func TestCache_RaceCondition(t *testing.T) {
c := cache.New()
var wg sync.WaitGroup
// 10 个 goroutine 并发写
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := fmt.Sprintf("key-%d", i)
c.Set(key, i, time.Minute)
}(i)
}
// 10 个 goroutine 并发读
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := fmt.Sprintf("key-%d", i)
c.Get(key)
}(i)
}
wg.Wait()
}当存在数据竞争时,race detector 输出:
==================
WARNING: DATA RACE
Write at 0x00c000018220 by goroutine 8:
example.com/app/cache.(*Cache).Set()
/app/cache/cache.go:45 +0x68
Previous read at 0x00c000018220 by goroutine 12:
example.com/app/cache.(*Cache).Get()
/app/cache/cache.go:32 +0x48
==================报告清楚指出:哪两个 goroutine 在争用,争用的内存地址,每个 goroutine 的调用栈。
2. 线程安全缓存:完整实现与测试
// cache/cache.go
package cache
import (
"sync"
"time"
)
type entry struct {
value interface{}
expiresAt time.Time
}
type Cache struct {
mu sync.RWMutex
items map[string]entry
stopCh chan struct{}
}
func New() *Cache {
c := &Cache{
items: make(map[string]entry),
stopCh: make(chan struct{}),
}
go c.cleanupLoop()
return c
}
func (c *Cache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = entry{
value: value,
expiresAt: time.Now().Add(ttl),
}
}
func (c *Cache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.items[key]
if !ok || time.Now().After(e.expiresAt) {
return nil, false
}
return e.value, true
}
func (c *Cache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
}
func (c *Cache) Stop() {
close(c.stopCh)
}
// 关键:cleanup 必须用写锁
func (c *Cache) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.mu.Lock() // 写锁!不是读锁
now := time.Now()
for k, e := range c.items {
if now.After(e.expiresAt) {
delete(c.items, k)
}
}
c.mu.Unlock()
case <-c.stopCh:
return
}
}
}3. goroutine 泄漏检测:goleak
数据竞争之外,另一个常见的并发 Bug 是 goroutine 泄漏——goroutine 启动后没有正确退出,随着时间推移,内存越来越高。
goleak 是 Uber 开源的 goroutine 泄漏检测库:
go get go.uber.org/goleak3.1 基础用法
package worker_test
import (
"context"
"testing"
"time"
"go.uber.org/goleak"
"example.com/app/worker"
)
func TestWorkerPool_NoLeak(t *testing.T) {
defer goleak.VerifyNone(t) // 测试结束时检查是否有泄漏的 goroutine
pool := worker.NewPool(5)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 提交任务
for i := 0; i < 100; i++ {
pool.Submit(func() {
time.Sleep(10 * time.Millisecond)
})
}
// 等待完成并关闭
pool.Shutdown(ctx)
// goleak.VerifyNone 在这里运行
// 如果 pool 的 worker goroutine 没有退出,会报告泄漏
}3.2 TestMain 全局检测
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}VerifyTestMain 会在所有测试结束后检查所有 goroutine 是否退出,是最彻底的检测方式。
3.3 排除已知后台 goroutine
某些库(如 gRPC、数据库驱动)会启动自己的后台 goroutine,需要排除:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m,
goleak.IgnoreTopFunction("google.golang.org/grpc.(*ccBalancerWrapper).watcher"),
goleak.IgnoreTopFunction("go.opencensus.io/stats/view.(*worker).start"),
goleak.IgnoreAnyFunction("internal/poll.runtime_pollWait"),
)
}4. 并发测试策略
4.1 sync.WaitGroup 同步并发测试
func TestConcurrentCounter(t *testing.T) {
counter := NewAtomicCounter()
const goroutines = 100
const increments = 1000
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < increments; j++ {
counter.Increment()
}
}()
}
wg.Wait()
expected := int64(goroutines * increments)
assert.Equal(t, expected, counter.Value())
}4.2 channel 同步与超时控制
func TestEventBus_ConcurrentPublish(t *testing.T) {
bus := NewEventBus()
received := make(chan string, 100)
// 订阅事件
bus.Subscribe("test-event", func(data string) {
received <- data
})
const publishers = 10
var wg sync.WaitGroup
for i := 0; i < publishers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
bus.Publish("test-event", fmt.Sprintf("msg-%d", i))
}(i)
}
wg.Wait()
// 带超时地收集所有消息
var msgs []string
timeout := time.After(2 * time.Second)
for len(msgs) < publishers {
select {
case msg := <-received:
msgs = append(msgs, msg)
case <-timeout:
t.Fatalf("timeout: only received %d/%d messages", len(msgs), publishers)
}
}
assert.Len(t, msgs, publishers)
}4.3 testing.T.Parallel() 的正确使用
func TestHTTPHandler_Parallel(t *testing.T) {
// 父测试不并行
server := httptest.NewServer(makeHandler())
defer server.Close()
tests := []struct {
name string
path string
wantStatus int
}{
{"GET root", "/", 200},
{"GET user", "/user/1", 200},
{"GET missing", "/not-found", 404},
{"POST create", "/user", 201},
}
for _, tt := range tests {
tt := tt // Go 1.22 以前必须
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 子测试并行
resp, err := http.Get(server.URL + tt.path)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, tt.wantStatus, resp.StatusCode)
})
}
}5. 踩坑实录
踩坑记录 1:-race 检测不到所有竞争
Race detector 是运行时检测,只能检测到实际运行中发生的竞争。如果两个 goroutine 的操作没有在同一次测试运行中发生竞争(比如时序上刚好没撞上),race detector 不会报告。解决方案:在并发测试里用 GOMAXPROCS 增加并发度:
func TestMain(m *testing.M) {
runtime.GOMAXPROCS(runtime.NumCPU()) // 最大并发度,增加竞争概率
os.Exit(m.Run())
}踩坑记录 2:goroutine 泄漏的隐蔽来源
// 泄漏:goroutine 在 ctx 取消前可能永久阻塞
func processWithLeak(ctx context.Context, ch chan int) {
go func() {
result := heavyCompute() // 不受 ctx 控制
ch <- result // 如果没人读 ch,永久阻塞
}()
}
// 修复:使用 select 确保 goroutine 可以退出
func processFixed(ctx context.Context, ch chan int) {
go func() {
result := heavyCompute()
select {
case ch <- result:
case <-ctx.Done(): // ctx 取消时退出
}
}()
}踩坑记录 3:channel 容量导致的死锁
// 死锁:无缓冲 channel + 同步等待
func TestDeadlock(t *testing.T) {
ch := make(chan int) // 无缓冲
// 主 goroutine 发送,但没有接收者,死锁!
ch <- 1 // 阻塞在这里
go func() {
<-ch
}()
}
// 修复:先启动接收者
func TestFixed(t *testing.T) {
ch := make(chan int)
done := make(chan struct{})
go func() {
<-ch
close(done)
}()
ch <- 1
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("timeout")
}
}6. 完整并发安全 Worker Pool 实现与测试
// worker/pool.go
package worker
import (
"context"
"sync"
)
type Pool struct {
workers int
jobCh chan func()
wg sync.WaitGroup
}
func NewPool(workers int) *Pool {
p := &Pool{
workers: workers,
jobCh: make(chan func(), workers*10),
}
for i := 0; i < workers; i++ {
p.wg.Add(1)
go p.worker()
}
return p
}
func (p *Pool) worker() {
defer p.wg.Done()
for job := range p.jobCh {
job()
}
}
func (p *Pool) Submit(job func()) {
p.jobCh <- job
}
func (p *Pool) Shutdown(ctx context.Context) error {
close(p.jobCh)
done := make(chan struct{})
go func() {
p.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// worker/pool_test.go
package worker_test
import (
"context"
"sync/atomic"
"testing"
"time"
"go.uber.org/goleak"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"example.com/app/worker"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func TestPool_ExecutesAllJobs(t *testing.T) {
pool := worker.NewPool(5)
var executed int64
const jobs = 1000
for i := 0; i < jobs; i++ {
pool.Submit(func() {
atomic.AddInt64(&executed, 1)
})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := pool.Shutdown(ctx)
require.NoError(t, err)
assert.Equal(t, int64(jobs), atomic.LoadInt64(&executed))
}7. 高级并发测试模式:压力测试与确定性重现
7.1 压力测试:放大并发问题
并发 Bug 的本质是时序依赖,而时序依赖依赖于 CPU 调度。单次测试可能刚好没触发竞争,但循环运行多次可以大幅提高发现的概率:
// 循环运行测试,放大并发问题出现概率
func TestCache_StressTest(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
for round := 0; round < 100; round++ {
c := NewCache()
var wg sync.WaitGroup
errs := make(chan error, 200)
// 50 个 goroutine 写
for i := 0; i < 50; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := fmt.Sprintf("key-%d", i%10) // 故意制造 key 冲突
c.Set(key, i, time.Minute)
}(i)
}
// 50 个 goroutine 读
for i := 0; i < 50; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := fmt.Sprintf("key-%d", i%10)
val, _ := c.Get(key)
if val != nil {
if _, ok := val.(int); !ok {
errs <- fmt.Errorf("round %d: unexpected value type", round)
}
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
}
}配合 go test -race -count=5 可以多次运行同一个测试,进一步提高竞争被发现的概率。
7.2 使用 sync/atomic 和 channel 验证并发正确性
对于需要验证并发操作最终结果正确性的测试,sync/atomic 是最可靠的工具,因为原子操作本身不需要锁:
func TestPool_AllJobsExecuted(t *testing.T) {
const numJobs = 10000
var executedCount int64
pool := NewPool(runtime.NumCPU())
var wg sync.WaitGroup
for i := 0; i < numJobs; i++ {
wg.Add(1)
pool.Submit(func() {
defer wg.Done()
atomic.AddInt64(&executedCount, 1)
})
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
doneCh := make(chan struct{})
go func() {
wg.Wait()
close(doneCh)
}()
select {
case <-doneCh:
// 成功完成
case <-ctx.Done():
t.Fatalf("timeout: only %d/%d jobs executed", atomic.LoadInt64(&executedCount), numJobs)
}
assert.Equal(t, int64(numJobs), atomic.LoadInt64(&executedCount))
pool.Shutdown(context.Background())
}8. 并发代码的设计原则与可测试性
写出"可测试的并发代码",和写出"正确的并发代码"同样重要。可测试性不好的并发代码,往往也是正确性难以保证的并发代码。
原则一:控制并发的边界
把并发控制逻辑集中在一个组件里,不要让并发扩散到整个代码库。sync.Mutex、sync.RWMutex、channel 应该封装在实现层,对外暴露的接口是普通的函数调用。这样测试时可以聚焦在并发逻辑上,业务逻辑可以单独测试。
// 好的设计:并发逻辑封装在内部,接口清晰
type SafeCounter struct {
mu sync.Mutex
value int64
}
func (c *SafeCounter) Increment() { c.mu.Lock(); c.value++; c.mu.Unlock() }
func (c *SafeCounter) Value() int64 { c.mu.Lock(); defer c.mu.Unlock(); return c.value }原则二:context 传播是取消并发的标准方式
任何启动 goroutine 的代码,都应该接受 context.Context 参数,并在 context 取消时退出。这让测试可以精确控制 goroutine 的生命周期:
// 测试可以通过 cancel() 精确控制 goroutine 结束时机
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
worker := NewBackgroundWorker(db)
go worker.Run(ctx)
// 等待 ctx 超时,worker 应该自动退出
<-ctx.Done()
time.Sleep(50 * time.Millisecond) // 给一点退出时间
// goleak 会验证 worker goroutine 已经退出原则三:不要在测试里用 time.Sleep 等待
time.Sleep 是并发测试里最常见的坏味道。它让测试变慢、不确定性更强,在 CI 机器上可能因为 CPU 繁忙而超时失败。用 channel、sync.WaitGroup 或 goleak 代替 Sleep:
// 坏:用 sleep 等待 goroutine 完成
pool.Submit(task)
time.Sleep(100 * time.Millisecond) // 不可靠
assert.Equal(t, 1, pool.CompletedCount())
// 好:用 channel 精确等待
done := make(chan struct{})
pool.Submit(func() {
defer close(done)
// do work
})
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("task did not complete in time")
}并发代码测试是工程中最需要耐心和纪律的部分。小峰那个缓存 bug 的发现,花了 10 分钟的 code review,但用 -race 只需要几秒。把 race detector 和 goleak 写进 CI,是投入产出比最高的工程实践之一。
9. Go 并发模型的可测试性设计
写并发代码是 Go 的强项,但写出"容易测试的并发代码"需要在设计阶段就考虑可测试性。这一节我总结了几个让并发代码更容易被测试的设计模式。
可注入的时钟与定时器
并发 Bug 有时候与时间强相关——缓存过期、连接超时、重试间隔。在测试里控制时间,比等待真实时间流逝要高效得多。可测试的时钟设计:
// 把时钟抽象成接口
type Clock interface {
Now() time.Time
After(d time.Duration) <-chan time.Time
NewTicker(d time.Duration) *time.Ticker
}
// 生产环境用真实时钟
type RealClock struct{}
func (RealClock) Now() time.Time { return time.Now() }
func (RealClock) After(d time.Duration) <-chan time.Time { return time.After(d) }
func (RealClock) NewTicker(d time.Duration) *time.Ticker { return time.NewTicker(d) }
// 测试环境用可控时钟(可以用 github.com/benbjohnson/clock 等库)
type MockClock struct {
currentTime time.Time
}
func (m *MockClock) Now() time.Time { return m.currentTime }
func (m *MockClock) Advance(d time.Duration) { m.currentTime = m.currentTime.Add(d) }这样,测试缓存过期逻辑时,不需要等待 5 分钟,直接调用 mockClock.Advance(5 * time.Minute) 就能触发过期清理,然后立即验证结果。
可观测的内部状态
并发代码的 Bug 经常藏在内部状态里——goroutine 数量、队列长度、处理中的任务数量。在测试里,如果能观察这些内部状态,就能精确断言系统的行为:
type Pool struct {
workers int
jobCh chan func()
activeJobs int64 // 用 atomic 维护,便于并发安全读取
wg sync.WaitGroup
}
// 暴露可观测的状态(测试用,也可以用于监控指标)
func (p *Pool) ActiveJobs() int64 {
return atomic.LoadInt64(&p.activeJobs)
}
func (p *Pool) QueueLength() int {
return len(p.jobCh)
}测试时就能写出精确的断言:
pool := NewPool(3)
// 提交一个耗时 500ms 的任务
pool.Submit(func() { time.Sleep(500 * time.Millisecond) })
time.Sleep(10 * time.Millisecond) // 等任务开始
assert.Equal(t, int64(1), pool.ActiveJobs(), "should have 1 active job")使用 errgroup 管理并发测试的错误收集
在并发测试里,多个 goroutine 可能同时失败,用 channel 收集错误容易漏掉或者死锁。golang.org/x/sync/errgroup 是更好的工具:
func TestConcurrentOperations(t *testing.T) {
g, ctx := errgroup.WithContext(context.Background())
for i := 0; i < 100; i++ {
i := i
g.Go(func() error {
result, err := doOperation(ctx, i)
if err != nil {
return fmt.Errorf("operation %d failed: %w", i, err)
}
if result != expectedResult(i) {
return fmt.Errorf("operation %d: got %v, want %v", i, result, expectedResult(i))
}
return nil
})
}
if err := g.Wait(); err != nil {
t.Error(err)
}
}errgroup 会在第一个 goroutine 出错时取消 context,让其他 goroutine 优雅退出,同时收集所有的错误信息。
10. 并发测试的工程文化:从规则到本能
我见过很多团队,把 -race 开关用"可选"的态度对待——本地开发不开,CI 偶尔开,出了问题才想起来加。这种态度最终会付出代价。
并发 Bug 的本质是时序非确定性——同样的代码,在不同的机器、不同的负载、不同的 Go 版本下,行为可能不同。Race detector 捕捉到的竞争,可能在生产环境一直没有触发,直到某个流量峰值或新机型部署时突然出现。而那时候的修复成本,是本地几秒 -race 测试成本的几百倍。
推行并发测试最佳实践,有几个实际可行的落地步骤:
第一步,在 CI 里强制 -race。不是建议,是强制。让每个 PR 在 race detector 下通过是基本门槛。这一步通常没有阻力,因为大多数代码天然是安全的,race detector 大多数时候不会报告问题。
第二步,把 goleak.VerifyTestMain 加到所有服务的测试入口。goroutine 泄漏问题大多是隐蔽的——内存缓慢增长,只有在长跑压测或者生产环境几天后才会明显。早发现早治疗。
第三步,建立并发问题的后验文化。每次发现的并发 Bug(无论是 race detector 发现的还是生产发现的),都写一个能复现它的测试用例提交到代码库。随着时间积累,这些测试用例本身就成了并发编程的活文档——新人加入时,阅读这些测试用例,能快速了解"哪些并发模式是危险的"。
小峰那个缓存 Bug,最终被提炼成了一个测试用例:"清理 goroutine 在遍历删除时必须持有写锁,不能只用读锁"。这个测试用例今天还在那个项目里,每次有人改缓存实现,它都会无声地站岗守卫。这就是好的并发测试的真正价值——不只是在写代码的那一刻有用,而是永久有效的工程资产。
11. 并发测试的性能考量与工具选型
并发测试本身也需要关注性能。一个运行时间超过 30 秒的测试,在日常开发中很快会被绕过或跳过,失去它的价值。
控制并发测试的规模
在压力测试里,并发数量要根据被测系统的特性来设定,而不是越大越好。对于 IO 密集型代码(数据库、网络),可以用较高并发(100-1000);对于 CPU 密集型代码,并发超过 CPU 核数就没有意义,反而会因为上下文切换变慢。
func TestWorkerPool_ConcurrentLoad(t *testing.T) {
if testing.Short() {
t.Skip("skipping load test")
}
// 并发数量和被测系统容量匹配
numWorkers := runtime.NumCPU() * 2
pool := NewPool(numWorkers)
// 任务数量是工作者数量的合理倍数
numJobs := numWorkers * 100
// ... 其余逻辑
}选择正确的同步原语
并发测试里的同步原语选择很重要。sync.WaitGroup 适合"等待一批 goroutine 全部完成";channel 适合"等待某个特定事件";sync.Mutex 适合"保护共享数据";sync/atomic 适合"简单计数器和标志位"。
很多并发 Bug 恰恰来自选错了同步原语——比如用 time.Sleep 代替 WaitGroup,用 channel 的长度作为完成信号(channel 还有未读数据不等于没有完成),或者用普通变量加锁代替 atomic(在简单计数场景里,atomic 既高效又安全)。
go-deadlock:检测死锁
除了 race detector 和 goleak,还有一个工具专门检测锁死锁——github.com/sasha-s/go-deadlock,它是 sync.Mutex 的替代品,在检测到锁的获取顺序不一致时报警:
import "github.com/sasha-s/go-deadlock"
type SafeMap struct {
mu deadlock.Mutex // 替代 sync.Mutex
data map[string]int
}go-deadlock 通过追踪锁的获取顺序,发现"A 持有锁1,等待锁2;B 持有锁2,等待锁1"这类循环依赖。这类死锁在测试里可能很少触发(需要特定的执行时序),但用 go-deadlock 在 CI 里跑一段时间,能以极低的运行时开销捕获到潜在的死锁模式。
并发测试工具箱的完整清单:go test -race(数据竞争)+ goleak(goroutine 泄漏)+ go-deadlock(死锁检测)。这三件事组合在一起,覆盖了并发 Bug 的三种主要形态。不是每个项目都需要全部用上,但对于高并发核心系统,三者都值得配置。
12. 小结:建立并发代码的工程纪律
并发代码的正确性不能靠直觉和运气,需要系统性的工程实践。-race、goleak、良好的并发设计模式,这三件事是最基础也是最重要的基础设施。
并发 Bug 不会因为你忽视它而消失,它只会在最不合时宜的时候出现——高流量的大促日、新机型部署后、依赖升级之后。在测试阶段暴露这些问题,远比在生产环境被用户发现要好得多。
把 go test -race 写进 CI,把 goleak.VerifyTestMain 写进 TestMain,这两行代码能以极低的成本为并发代码建立起基础的安全网。从今天开始做,不需要等到"架构稳定了再优化"。
13. 从测试到系统:并发代码的全链路保障
并发代码的测试不能孤立来看。一个完整的并发代码保障体系包括三个层次:
代码设计层:选择正确的并发原语,封装并发逻辑,用 context 控制 goroutine 生命周期,暴露可观测的内部状态。这一层决定了代码天然的可测试性。
测试验证层:race detector 检查数据竞争,goleak 检查 goroutine 泄漏,go-deadlock 检查死锁,并发压力测试验证最终一致性。这一层把潜在问题拦截在开发阶段。
线上监控层:goroutine 数量监控(通过 runtime.NumGoroutine 或 Prometheus 指标),内存增长趋势,请求队列积压告警。这一层在测试没有覆盖到的场景里提供最后一道防线。
三个层次缺一不可。只有代码设计好了,测试才能有效;只有测试充分了,线上监控才能发现真正的异常而不是噪声。小峰那个缓存实现的修复,正是走完了这三个层次——重新设计了锁的使用方式,补充了 race detector 测试,加了 goroutine 数量监控。
并发代码的测试投入,在长期是收益正向的工程实践。
每一个被 race detector 发现的数据竞争,都是一次"在测试里发现而不是在生产里发现"的胜利。坚持在 CI 里开启 -race,是代价最低、收益最高的并发安全实践之一,没有理由不做。
写在最后
并发 Bug 是最难用肉眼发现的 Bug,race detector 和 goleak 是 Go 并发代码的两把利剑。我建议所有 Go 项目都把 go test -race 写入 CI,把 goleak.VerifyTestMain 写进 TestMain——这两件事的成本极低,收益极大。
下一篇我们跳出 Go,进入 CI/CD 视角,聊 GitHub Actions 如何为 Java/Go/Python 多语言项目构建测试流水线。
