前言

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 篇。