Go 协程与通道

这是 Go 最核心、最有魅力的部分。Go 把并发编程变得像写普通代码一样简单。你不需要懂线程、锁、信号量这些复杂概念,只需要理解两个东西:协程(goroutine)通道(channel)


1. 并发 vs 并行

先搞清这两个概念:

  • 并发(Concurrency):一个人同时应付多件事。比如边做饭边接电话——你在两件事之间来回切换,看起来像同时在做。
  • 并行(Parallelism):多个人同时各做各的事。比如两个厨师同时炒两道菜——真正的同时。

Go 的协程是并发的——多个协程在同一个 CPU 核心上轮流执行。如果有多核,Go 也会让协程真正并行运行。

并发: |---做菜---|---接电话---|---做菜---| 并行: |---做菜---| |---炒饭---| (另一个核心)

💡 要点速记:并发是"同时处理多件事",并行是"同时做多件事"。Go 协程默认并发,多核时自动并行。


2. 协程(Goroutine)

2.1 启动一个协程

在函数调用前加 go,就变成协程了。就像喊了一声"你去做",自己不等着结果:

gofunc sayHello(name string) {
    fmt.Printf("你好,%s!\n", name)
}

func main() {
    go sayHello("协程")  // 启动协程,不会阻塞
    sayHello("主函数")   // 主函数自己也在跑

    time.Sleep(time.Second) // 等一下,否则主函数结束程序就退了
}

输出可能是(顺序不固定):

你好,主函数!
你好,协程!

2.2 协程的本质

协程比操作系统线程轻量得多:

  • 线程通常占 1MB 栈内存,协程只占 2KB(可动态增长)
  • 创建线程需要系统调用,创建协程只是函数调用
  • 一台机器轻松跑几十万个协程

2.3 匿名函数协程

gogo func(msg string) {
    fmt.Println(msg)
}("我来自协程")

2.4 等协程完成

time.Sleep 不靠谱(你怎么知道要等多久?)。用 sync.WaitGroup 才是正道:

govar wg sync.WaitGroup

func worker(id int) {
    defer wg.Done() // 完成时通知
    fmt.Printf("工人 %d 开始干活\n", id)
    time.Sleep(time.Millisecond * time.Duration(rand.Intn(500)))
    fmt.Printf("工人 %d 干完了\n", id)
}

func main() {
    for i := 1; i <= 5; i++ {
        wg.Add(1)     // 每启动一个协程,计数+1
        go worker(i)
    }
    wg.Wait()         // 等所有协程完成
    fmt.Println("全部完成")
}

💡 要点速记

  • go 函数() 启动协程
  • 协程超轻量,可以创建很多
  • sync.WaitGroup 等待多个协程完成,不要用 time.Sleep
  • 主函数退出 = 程序退出 = 所有协程被杀

3. 通道(Channel)

3.1 为什么需要通道?

多个协程干活,怎么互相传递数据?用共享变量?那得加锁,太麻烦。Go 的方案是:不要通过共享内存来通信,而要通过通信来共享内存。

通道就像一根管子——一头塞东西进去,另一头拿出来。先进先出,线程安全。

3.2 创建和使用通道

go// 创建一个传输 int 的通道
ch := make(chan int)

// 协程1:发送数据
go func() {
    ch <- 42  // 把 42 塞进通道(箭头方向表示数据流向)
}()

// 协程2(主函数):接收数据
value := <-ch  // 从通道取数据
fmt.Println("收到:", value)  // 输出:收到:42

ch <- value 发送,<-ch 接收,value := <-ch 接收并赋值。

3.3 通道的阻塞性

无缓冲通道是同步的——发送方和接收方必须同时就绪:

goch := make(chan string)

go func() {
    ch <- "消息"  // 如果没人接收,这里会一直等待
}()

msg := <-ch  // 如果没人发送,这里也会一直等待

这就像打电话——双方都得接听才能通话。

3.4 带缓冲的通道

goch := make(chan int, 3)  // 缓冲区大小 3

ch <- 1  // 不阻塞,缓冲区有空间
ch <- 2  // 不阻塞
ch <- 3  // 不阻塞
// ch <- 4  // 阻塞!缓冲区满了,必须等有人取走

fmt.Println(<-ch)  // 1
fmt.Println(<-ch)  // 2

带缓冲的通道像邮箱——你可以塞信进去,收信人不用立刻取。满了就塞不下了,空了就取不出来了。

💡 要点速记

  • make(chan T) 无缓冲,make(chan T, n) 缓冲区大小 n
  • 无缓冲通道:发送和接收必须同时就绪,否则阻塞
  • 带缓冲通道:缓冲区满时发送阻塞,缓冲区空时接收阻塞
  • 箭头 <- 指向数据流向:ch <- 发送,<- ch 接收

4. 关闭通道和 range 遍历

4.1 关闭通道

发送方可以关闭通道,告诉接收方"没有更多数据了":

goch := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch) // 发送完毕,关闭通道
}()

// 接收方可以用 range 遍历通道
for num := range ch {
    fmt.Println(num) // 输出 0 1 2 3 4
}

注意

  • 只有发送方应该关闭通道,接收方不要关
  • 关闭后的通道不能再发送数据(会 panic)
  • 关闭后的通道还可以接收(返回零值和 false)
  • 向已关闭的通道发送数据会 panic

4.2 检测通道是否关闭

govalue, ok := <-ch
if !ok {
    fmt.Println("通道已关闭")
    break
}

💡 要点速记

  • 发送方用 close(ch) 关闭通道
  • for x := range ch 自动在通道关闭后退出循环
  • value, ok := <-chok 为 false 表示通道已关闭
  • 不要关闭已经关闭的通道,不要向已关闭的通道发送

5. select 语句

5.1 多路复用

select 就像前台接待员——同时盯着多部电话,哪部响了接哪部:

goch1 := make(chan string)
ch2 := make(chan string)

go func() {
    time.Sleep(1 * time.Second)
    ch1 <- "来自通道1"
}()

go func() {
    time.Sleep(2 * time.Second)
    ch2 <- "来自通道2"
}()

for i := 0; i < 2; i++ {
    select {
    case msg1 := <-ch1:
        fmt.Println("收到:", msg1)
    case msg2 := <-ch2:
        fmt.Println("收到:", msg2)
    }
}

输出(1秒后先收到通道1,2秒后收到通道2):

收到:来自通道1
收到:来自通道2

5.2 select 的规则

  1. 多个 case 同时就绪,随机选一个执行
  2. 没有 case 就绪,阻塞等待
  3. 加了 default,没有 case 就绪时执行 default(不阻塞)

5.3 超时控制

select + time.After 实现超时:

goselect {
case result := <-ch:
    fmt.Println("收到结果:", result)
case <-time.After(3 * time.Second):
    fmt.Println("超时了!3秒内没收到数据")
}

time.After 返回一个通道,在指定时间后会发送当前时间。就像设置一个闹钟——到点了就触发。

5.4 定时器(Ticker)

time.NewTicker 创建周期性触发的定时器:

goticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

for i := 0; i < 5; i++ {
    <-ticker.C  // 每秒收到一次
    fmt.Println("滴答", i+1)
}

💡 要点速记

  • select 同时监听多个通道,哪个先有数据就处理哪个
  • 多个同时就绪时随机选一个
  • time.After(duration) 实现超时控制
  • time.NewTicker(duration) 创建周期定时器

6. 协程中的 panic 恢复

如果协程里的 panic 没人 recover,整个程序会崩溃。所以每个重要协程都应该自己兜底:

6.1 safeGo:安全的协程启动器

gofunc safeGo(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Println("协程 panic 恢复:", r)
            }
        }()
        fn()
    }()
}

safeGo 封装了 go 关键字,自动给每个协程加上 recover 保护。协程里即使 panic,也不会拖垮整个程序。

6.2 必须配合 WaitGroup 使用

光有 safeGo 还不够——main 退出后所有协程会被强制杀死,recover 来不及输出。必须配合 WaitGroup 等 goroutine 跑完:

gofunc main() {
    var wg sync.WaitGroup
    wg.Add(1)

    safeGo(func() {
        defer wg.Done()    // goroutine 里 Done,不是 main 里
        panic("出大事了")   // 不会崩溃整个程序
    })

    wg.Wait()              // 等 goroutine 完成再退出
}

6.3 常见错误

错误 1:wg.Done() 写在 main 里

go// ❌ main 自己把计数器清零了,Wait 立刻返回,goroutine 还没跑就被杀
safeGo(func() {
    panic("出大事了")
})
wg.Done()   // 包工头替工人划考勤,以为没人了就下班
wg.Wait()

错误 2:不写 wg.Done()

go// ❌ 计数器永远不归零,Wait 永远阻塞 → 死锁
safeGo(func() {
    panic("出大事了")
    // 没有 defer wg.Done()
})
wg.Wait()   // 计数器还是 1,天荒地老地等

错误 3:不用 WaitGroup

go// ❌ main 退出太快,goroutine 来不及执行就被杀
safeGo(func() {
    panic("出大事了")
})
// 没有 wg.Wait(),main 直接结束,程序退出

6.4 完整执行流程

gofunc safeGo(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Println("协程 panic 恢复:", r)
            }
        }()
        fn()
    }()
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)

    safeGo(func() {
        defer wg.Done()
        panic("出大事了")
    })

    wg.Wait()
}

执行时序:

main goroutine 子 goroutine ────────────── ──────────── wg.Add(1) [计数器=1] safeGo() ──────────────────→ 注册 defer recover wg.Wait() [阻塞中] 注册 defer wg.Done panic("出大事了")! defer 后进先出: ① wg.Done() [计数器=0] ──→ Wait() 解除! ② recover() → 打印恢复信息 main 继续执行 goroutine 退出 程序退出

输出:

2026/06/01 15:30:00 协程 panic 恢复: 出大事了

💡 要点速记

  • 每个协程应该自己 recover panic,否则整个程序会崩
  • safeGo = 带安全网的 go,panic 不会拖垮进程
  • wg.Done() 必须写在 goroutine 里面,用 defer 兜底
  • wg.Add()wg.Done() 必须配对,多一个 Add 死锁,多一个 Done panic
  • defer 执行顺序:后注册先执行,所以 wg.Done()recover() 之前执行

7. 常见并发模式

7.1 Worker Pool(工人池)

固定数量的工人处理任务,避免无限制创建协程:

gofunc worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        fmt.Printf("工人 %d 处理任务 %d\n", id, job)
        time.Sleep(time.Millisecond * 500) // 模拟工作
        results <- job * 2
    }
}

func main() {
    jobs := make(chan int, 10)
    results := make(chan int, 10)

    // 启动 3 个工人
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // 发送 5 个任务
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs) // 任务发完了

    // 收集结果
    for r := 1; r <= 5; r++ {
        fmt.Println("结果:", <-results)
    }
}

7.2 限制并发数

用带缓冲的通道做信号量,限制同时运行的协程数量:

govar sem = make(chan struct{}, 5) // 最多 5 个并发

func process(url string) {
    sem <- struct{}{}        // 占一个位置(满了就等)
    defer func() { <-sem }() // 完成后释放位置

    // 处理请求...
    fmt.Println("处理:", url)
    time.Sleep(time.Second)
}

7.3 通道多路复用(Fan-in)

多个通道的数据汇聚到一个通道:

gofunc fanIn(ch1, ch2 <-chan string) <-chan string {
    merged := make(chan string)
    go func() {
        for msg := range ch1 { merged <- msg }
    }()
    go func() {
        for msg := range ch2 { merged <- msg }
    }()
    return merged
}

7.4 超时模式

给任何操作加超时保护:

gofunc doWithTimeout(timeout time.Duration) (int, error) {
    resultCh := make(chan int)
    go func() {
        // 可能很慢的操作
        time.Sleep(2 * time.Second)
        resultCh <- 42
    }()

    select {
    case result := <-resultCh:
        return result, nil
    case <-time.After(timeout):
        return 0, fmt.Errorf("操作超时(%v)", timeout)
    }
}

7.5 Pipeline(管道)

数据像流水线一样经过多个处理阶段:

go// 阶段1:生成数据
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// 阶段2:平方
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

// 串联:generate → square → 打印
func main() {
    ch := generate(2, 3, 4)
    ch = square(ch)
    for result := range ch {
        fmt.Println(result) // 4, 9, 16
    }
}

💡 要点速记

  • Worker Pool:固定工人处理任务,避免协程爆炸
  • 信号量模式:带缓冲通道限制并发数
  • Fan-in:多个通道汇聚到一个
  • Pipeline:数据经过多个阶段串行处理

8. 高级模式概览

以下模式初学时了解概念即可,遇到实际需求再深入研究。

8.1 Futures 模式

"我先去干活,结果回头来拿"——启动协程计算,返回一个"未来结果"通道:

gofunc asyncCompute() <-chan int {
    future := make(chan int)
    go func() {
        result := heavyComputation() // 很慢的计算
        future <- result
    }()
    return future // 立刻返回,不等计算完
}

// 使用
f := asyncCompute()
// 可以做别的事...
result := <-f // 需要结果时再取

8.2 多核并行计算

gofunc parallelSum(data []int) int {
    n := runtime.NumCPU()
    size := (len(data) + n - 1) / n
    results := make(chan int, n)

    for i := 0; i < n; i++ {
        go func(start int) {
            sum := 0
            end := start + size
            if end > len(data) { end = len(data) }
            for j := start; j < end; j++ {
                sum += data[j]
            }
            results <- sum
        }(i * size)
    }

    total := 0
    for i := 0; i < n; i++ {
        total += <-results
    }
    return total
}

8.3 限流(Leaky Bucket 漏桶)

控制请求速率,防止系统被冲垮:

gofunc rateLimiter(interval time.Duration) chan struct{} {
    bucket := make(chan struct{}, 1)
    go func() {
        for {
            bucket <- struct{}{}
            time.Sleep(interval)
        }
    }()
    return bucket
}

// 每 100ms 允许一个请求
limiter := rateLimiter(100 * time.Millisecond)
for i := 0; i < 10; i++ {
    <-limiter // 等待令牌
    fmt.Println("处理请求", i)
}

💡 要点速记

  • Futures:异步计算,需要时再取结果
  • 多核并行:把数据分片,每个核心算一片,最后合并
  • 限流:用定时器控制处理速率

9. 新手常见坑

  1. 协程泄漏:协程启动后永远阻塞在通道上,没人发送/接收,协程就永远挂着。确保通道最终会被关闭或发送数据。
  2. 死锁:两个协程互相等待对方发送/接收,谁也动不了。常见于无缓冲通道的"双方都没准备好"的情况。
  3. 向已关闭的通道发送:这会 panic。记住只有发送方关闭,且关闭后不要再发。
  4. range 遍历通道时没关闭for x := range ch 会一直等数据,如果没人 close(ch),就永远阻塞。
  5. 闭包捕获循环变量:经典的坑——for 循环里启动协程,协程里用了循环变量,结果所有协程拿到的是同一个值:
go// 错误!所有协程都打印 3
for i := 0; i < 3; i++ {
    go func() { fmt.Println(i) }()
}

// 正确:把 i 作为参数传入
for i := 0; i < 3; i++ {
    go func(n int) { fmt.Println(n) }(i)
}
  1. 主函数退出导致协程被杀:主函数 return 或 os.Exit 后,所有协程立刻终止,不管它们是否完成。用 WaitGroup 或通道同步来等待。
  2. 无缓冲通道在同一个协程发收ch <- 1; x := <-ch 在同一个协程会死锁——发送在等接收,但接收代码在发送后面,永远执行不到。必须分开到不同协程。
  3. select 空 caseselect {} 会永远阻塞当前协程。有时候是有意为之(让主协程不退出),但别写错了。
目录