Go 语言并发编程实战:从 goroutine 到 channel 的深度应用

2026-05-07 23:19 Go 语言并发编程实战:从 goroutine 到 channel 的深度应用已关闭评论

Go 语言并发编程实战:从 goroutinechannel 的深度应用

结论先行:在 Go 并发编程中,理解 goroutine 的生命周期管理和 channel 的阻塞机制是避免死锁和内存泄漏的关键,而 select 和 sync 包则是构建安全并发程序的必备工具。

1. 用 sync.WaitGroup 掌控 goroutine 的生命周期

新手最容易犯的错:启动 goroutine 后主函数直接退出,子 goroutine 还没执行完就戛然而止。我自己也踩过这个坑,后来才学会用 sync.WaitGroup 来优雅地等待所有 goroutine 完成。

package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // 必须 defer,避免遗漏
    fmt.Printf("Worker %d 开始\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Worker %d 结束\n", id)
}

func main() {
    var wg sync.WaitGroup
    for i := 1; i <= 3; i++ {
        wg.Add(1) // 在启动前加 1
        go worker(i, &wg)
    }
    wg.Wait() // 阻塞直到计数器归零
    fmt.Println("所有 worker 已完成")
}

注意wg.Add(1) 必须在 go 关键字之前调用,否则 goroutine 执行太快会导致 wg.Wait() 提前返回。我第一次写的时候就把顺序搞反了,结果程序总是提前结束,排查了半天才发现这个细节。

2. 有缓冲 vs 无缓冲 channel——阻塞是设计不是 bug

无缓冲 channel 要求发送和接收必须同时发生,否则会阻塞。有缓冲 channel 允许发送方填满缓冲区后再阻塞。很多人刚接触时觉得阻塞是烦恼,但实际上这正是 Go 并发模型优雅的地方。

无缓冲:同步通信

ch := make(chan int)
go func() {
    ch <- 42 // 发送,但没人接收时会阻塞
}()
val := <-ch // 接收,同时解除上面 goroutine 的阻塞
fmt.Println(val)

有缓冲:异步队列

ch := make(chan string, 2)
ch <- "A" // 不阻塞
ch <- "B" // 不阻塞
// ch <- "C" // 缓冲区满,会阻塞
fmt.Println(<-ch) // A
fmt.Println(<-ch) // B

实战经验:有缓冲 channel 适合生产-消费速率不匹配的场景,但缓冲区大小要根据流量估算,过大浪费内存,过小导致频繁阻塞。我曾在线上遇到过因为缓冲区设得太小而造成的性能瓶颈,后来调整为动态估算后问题才解决。

3. select:并发编程的瑞士军刀

select 可以同时监听多个 channel 的读写操作,配合 time.After 实现超时控制,配合 default 实现非阻塞尝试。

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() { time.Sleep(2 * time.Second); ch1 <- "来自 ch1" }()
    go func() { time.Sleep(1 * time.Second); ch2 <- "来自 ch2" }()

    select {
    case msg := <-ch1:
        fmt.Println(msg)
    case msg := <-ch2:
        fmt.Println(msg)
    case <-time.After(500 * time.Millisecond):
        fmt.Println("超时,两个都没准备好")
    default:
        fmt.Println("非阻塞:立即执行,无数据")
    }
}

诚实记录:我第一次写 select 时忘记加 default,导致 goroutine 永远阻塞。后来学会了用 for + select 循环监听多个 channel,配合 time.NewTicker 做定时任务,才真正体会到 select 的威力。

4. 实战:一个健壮的工作池(Worker Pool)

下面是一个从任务队列读取、并行处理、错误收集的完整例子,涵盖了所有核心技巧。这个模式在我的很多项目里都用过,非常稳定。

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

type Task struct {
    ID   int
    Data string
}

type Result struct {
    TaskID int
    Output string
    Err    error
}

func worker(id int, tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()
    for task := range tasks { // 当 tasks 被关闭后自动退出循环
        time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
        // 模拟处理,可能出错
        if rand.Float32() < 0.2 {
            results <- Result{TaskID: task.ID, Err: fmt.Errorf("处理失败")}
            continue
        }
        results <- Result{TaskID: task.ID, Output: task.Data + "_processed"}
    }
}

func main() {
    const numWorkers = 3
    const numTasks = 10

    tasks := make(chan Task, numTasks)
    results := make(chan Result, numTasks)
    var wg sync.WaitGroup

    // 启动 workers
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go worker(i, tasks, results, &wg)
    }

    // 发送任务
    for i := 0; i < numTasks; i++ {
        tasks <- Task{ID: i, Data: fmt.Sprintf("数据%d", i)}
    }
    close(tasks) // 关闭输入 channel,通知 workers 没有新任务了

    // 等待所有 worker 完成(并发关闭结果 channel 的 goroutine)
    go func() {
        wg.Wait()
        close(results) // 确保所有结果都已写入后关闭
    }()

    // 收集结果
    for res := range results {
        if res.Err != nil {
            fmt.Printf("任务 %d 失败: %v\n", res.TaskID, res.Err)
        } else {
            fmt.Printf("任务 %d 成功: %s\n", res.TaskID, res.Output)
        }
    }
}

关键点
- 使用 close(tasks) 让 workers 自然退出,避免 goroutine 泄漏。
- 结果 channel 必须由单独的 goroutine 在 wg.Wait() 之后关闭,否则主 goroutine 会死锁。这个顺序我一开始也弄反过,导致程序卡死。
- 带缓冲的 channel 可以减少阻塞,但缓冲区大小要根据任务量设置合理值。

5. 踩坑实录

坑 1:向 nil channel 发送或接收

var ch chan int // nil channel
// ch <- 1 // 永远阻塞
// <-ch    // 永远阻塞

解决:永远不要使用未初始化的 channel,全部用 make 初始化。

坑 2:忘记 close channel 导致死锁

ch := make(chan int)
go func() {
    ch <- 1
    // 忘记 close(ch)
}()
for val := range ch { // 永远不退出,一直等待
    fmt.Println(val)
}

解决:当发送方明确不会再发送数据时,一定要调用 close(ch),并在接收方使用 for range 循环。

坑 3:goroutine 泄漏——只开不关

func leak() {
    ch := make(chan int)
    go func() {
        for {
            select {
            case <-ch:
                // 处理
            }
        }
    }()
    // 函数返回,但 goroutine 还活着
}

解决:使用 context 或一个停止 channel 显式通知 goroutine 退出。

func noLeak(ctx context.Context) {
    ch := make(chan int)
    go func() {
        for {
            select {
            case <-ch:
            case <-ctx.Done():
                return
            }
        }
    }()
    // ...
}

6. 进阶:使用 context 优雅取消

context.WithCancel 可以广播取消信号,特别适合同时终止多个 goroutine。

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go func() {
        time.Sleep(2 * time.Second)
        cancel() // 发送取消信号
    }()
    work(ctx)
}

func work(ctx context.Context) {
    for i := 0; ; i++ {
        select {
        case <-ctx.Done():
            fmt.Println("收到取消信号,退出")
            return
        default:
            fmt.Println("工作...", i)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

与 WaitGroup 的区别WaitGroup 用于等待 goroutine 结束,context 用于通知 goroutine 结束。生产环境中通常组合使用:用 context 发起取消,用 WaitGroup 等待所有 goroutine 清理完毕。这种组合方式我在微服务架构中用得最多,既保证了及时终止,又防止了资源泄漏。

延伸思考

  • errgroupgolang.org/x/sync/errgroup 包将 WaitGroup 与 context 结合,还能收集第一个错误,适合多个并行子任务。我经常用它来并发发起多个 RPC 调用并处理第一个错误。
  • 扇入/扇出模式:多个输入 channel 汇合到一个输出(扇入),或一个任务被多个 worker 并发处理(扇出),它们是对 channel 和 select 更高级的组合。
  • sync.Mutex vs channel:保护共享内存时用 Mutex;传递数据所有权时用 channel。Go 的哲学是“不要通过共享内存来通信,而要通过通信来共享内存”,但 Mutex 在保护复杂数据结构时更直接。选择标准:数据流向明确时用 channel,只是简单变量同步时用 Mutex。

你已经掌握了 goroutine + channel 的核心用法,下一步就是在真实项目中反复练习,并留意 pprof 和 goleak 工具来检测并发问题。相信我,一旦你熟练了这些模式,Go 的并发编程会成为最让你觉得舒服的部分。

你可能感兴趣的文章

来源:每日教程每日一例,深入学习实用技术教程,关注公众号TeachCourse
转载请注明出处: https://teachcourse.cn/4113.html ,谢谢支持!

资源分享

ubuntu中使用virtualenv创建虚拟环境示例 ubuntu中使用virtualenv创建虚拟
014-Windows编写好的.sh脚本在ubuntu运行提示No such file directory异常处理办法 014-Windows编写好的.sh脚本在u
Android开发之UML类图简介 Android开发之UML类图简介
结合FirstComposeApp重构SettingsScreen实战 结合FirstComposeApp重构Set

评论已关闭!