Skip to content

并发陷阱与最佳实践

前面八章我们学习了 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.Mutexsync.WaitGroupsync.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()
}

十二、小结

本篇我们系统梳理了并发编程的陷阱和最佳实践:

  1. goroutine 泄漏:常见于阻塞在无数据 channel、向满缓冲发送、无取消信号的死循环。用 context + select 监听取消信号预防。用 NumGoroutine、pprof、goleak 排查。

  2. channel 关闭:只由发送方关闭,不要由接收方关闭。多发送方用协调者(等所有发送方退出后关闭)。不要重复关闭、不要向已关闭 channel 发送。用 sync.Once 实现安全关闭。

  3. nil channel:发送/接收/for range 都永久阻塞,close 会 panic。在 select 中可用来禁用 case(动态启用/禁用监听),但全是 nil case 会死锁。

  4. 向已关闭 channel 发送会 panic:用 context 协调,先让发送方退出再关闭 channel。

  5. Mutex 复制:Mutex、WaitGroup 等不能复制,必须用指针。接收者用指针、传参传指针。go vet 能检测。

  6. 循环变量捕获:Go 1.22 前循环变量共享,goroutine 可能都看到最终值。Go 1.22 修复。兼容写法:显式传参或 i := i。推荐始终显式传参。

  7. defer 在循环中:defer 在函数返回时执行,循环里 defer 会堆积资源。把循环体抽成函数让 defer 在每次迭代生效。

  8. 并发安全配置更新:用 atomic.Value 整体替换(无锁读)或 RWMutex 保护。不要直接读写共享结构体。

  9. 优雅退出:用 signal.NotifyContext 监听信号,context 传播取消,WaitGroup 等待 goroutine 退出,加超时兜底。HTTP 用 server.Shutdown。

  10. 其他陷阱:time.After 在循环中复用 NewTimer;不要用共享变量做同步(用 channel/atomic);写切片不同索引虽安全但要 Wait 后读。

下一篇是本系列的最后一篇,我们将学习 errgroup、semaphore、singleflight 等扩展库,把并发能力提升到生产级。