Go 并发编程完全指南:goroutine、channel 与 select 的正确打开方式

Go 的并发模型常被一句话概括:"不要通过共享内存来通信,而要通过通信来共享内存。"这句话很漂亮,但新手听完往往还是不会用。这篇文章不停留在口号,而是把 goroutine、channel、select 三件套讲透 —— 它们各自解决什么问题,怎么配合,以及最容易踩的几个坑。

goroutine:几乎免费的并发单元

goroutine 是 Go 运行时管理的轻量级线程。它"轻"在哪里?一个操作系统线程初始栈通常是 1~8MB,而一个 goroutine 初始栈只有 2KB,且能按需增长收缩。所以一个程序同时跑几十万个 goroutine 是完全正常的。

package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 3; i++ {
        fmt.Println(s, i)
        time.Sleep(10 * time.Millisecond)
    }
}

func main() {
    go say("world")   // 启动一个 goroutine,go 关键字就这么简单
    say("hello")      // 主 goroutine 继续跑
}

但这段代码有个隐藏问题:main 函数返回时,整个程序退出,不会等待其他 goroutine。如果 say("hello")say("world") 先结束,"world" 可能只打印了一部分。这引出第一个核心问题:如何等待 goroutine 完成?

用 sync.WaitGroup 等待一组 goroutine

import "sync"

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)              // 计数 +1,必须在 go 之前调用
        go func(id int) {
            defer wg.Done()    // 完成时计数 -1,defer 保证一定执行
            fmt.Printf("worker %d done\n", id)
        }(i)                   // 注意:把 i 作为参数传进去
    }
    wg.Wait()                  // 阻塞,直到计数归零
    fmt.Println("all done")
}

这里有个经典坑:循环变量捕获。在 Go 1.22 之前,如果直接在闭包里用 i 而不是作为参数传入,所有 goroutine 会共享同一个 i,打印出来可能全是 5。Go 1.22 起循环变量每轮迭代是新的,但显式传参依然是更清晰、更不易错的写法,推荐保留这个习惯。

channel:goroutine 之间的安全管道

channel 是 Go 并发的核心。它是一个有类型的、并发安全的队列,goroutine 通过它传递数据,而不需要自己加锁。

ch := make(chan int)        // 无缓冲 channel
ch := make(chan int, 10)    // 容量为 10 的有缓冲 channel

ch <- 42                    // 发送:把 42 放进 channel
v := <-ch                   // 接收:从 channel 取出一个值
close(ch)                   // 关闭 channel

无缓冲 vs 有缓冲

这个区别决定了 channel 的行为,务必分清:

  • 无缓冲 channel:发送和接收必须同时就绪才能完成,否则先到的一方阻塞等待。它本质是一次"同步握手"—— 发送成功意味着对方一定收到了。
  • 有缓冲 channel:缓冲区没满,发送就不阻塞;缓冲区不空,接收就不阻塞。它更像一个有容量的队列,起到削峰、解耦的作用。
// 无缓冲:用作同步信号
done := make(chan struct{})
go func() {
    doWork()
    done <- struct{}{}      // 发送完成信号
}()
<-done                      // 主 goroutine 在此等待,直到上面发信号

// 有缓冲:用作任务队列
jobs := make(chan int, 100)
for i := 0; i < 100; i++ {
    jobs <- i               // 前 100 个不会阻塞
}

range 和 close 配合遍历 channel

func producer(ch chan<- int) {     // chan<- int 表示只能发送
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)                       // 生产完毕,关闭 channel
}

func main() {
    ch := make(chan int)
    go producer(ch)
    for v := range ch {             // range 会一直收,直到 channel 被关闭
        fmt.Println(v)
    }
}

关于 close 的三条铁律:只有发送方应该 close,接收方不要 close;向已关闭的 channel 发送会 panic;从已关闭的 channel 接收会立刻返回零值,用 v, ok := <-chok 判断 channel 是否还开着。

select:同时等待多个 channel

当一个 goroutine 需要同时和多个 channel 打交道时,select 登场。它会阻塞,直到其中某个 case 可以执行;如果多个同时就绪,随机选一个。

select {
case v := <-ch1:
    fmt.Println("从 ch1 收到", v)
case ch2 <- 100:
    fmt.Println("向 ch2 发送了 100")
case <-time.After(time.Second):
    fmt.Println("超时了")           // 超时控制的经典写法
default:
    fmt.Println("没有 channel 就绪") // 加了 default,select 就不阻塞了
}

用 select 做超时控制

func fetchWithTimeout() (string, error) {
    result := make(chan string, 1)  // 缓冲为 1,防止 goroutine 泄漏(见后文)
    go func() {
        result <- slowQuery()
    }()
    select {
    case r := <-result:
        return r, nil
    case <-time.After(2 * time.Second):
        return "", fmt.Errorf("query timeout")
    }
}

context:取消信号的标准方式

time.After 能做单点超时,但真实服务里需要把"取消"信号一层层往下传 —— 一个 HTTP 请求被取消,它派生出的所有下游 goroutine 都应该停下来。这是 context 的职责。

import "context"

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():           // 收到取消信号
            fmt.Printf("worker %d 退出:%v\n", id, ctx.Err())
            return
        default:
            doSomething()
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()                   // 务必调用,释放 context 关联的资源

    for i := 0; i < 3; i++ {
        go worker(ctx, i)
    }
    <-ctx.Done()
    time.Sleep(50 * time.Millisecond) // 给 worker 一点退出时间
}

规约:context 作为函数第一个参数,命名为 ctx;不要把 context 存进结构体;WithCancel / WithTimeout 返回的 cancel 函数一定要调用(用 defer cancel()),否则会泄漏。

worker pool:最实用的并发模式

把上面的知识组合起来,就能写出生产级的 worker pool —— 用固定数量的 goroutine 消费一个任务队列。这能限制并发度,避免瞬间起几万个 goroutine 把下游打垮。

func workerPool(numWorkers int, jobs []int) []int {
    jobCh := make(chan int, len(jobs))
    resultCh := make(chan int, len(jobs))
    var wg sync.WaitGroup

    // 启动固定数量的 worker
    for w := 0; w < numWorkers; w++ {
        wg.Add(1)
        go func(workerID int) {
            defer wg.Done()
            for job := range jobCh {        // 从队列里取任务,直到 jobCh 关闭
                resultCh <- job * job       // 处理并把结果写回
            }
        }(w)
    }

    // 投递所有任务,然后关闭任务队列
    for _, j := range jobs {
        jobCh <- j
    }
    close(jobCh)

    // 单独起一个 goroutine,等所有 worker 完成后关闭结果队列
    go func() {
        wg.Wait()
        close(resultCh)
    }()

    // 收集结果
    var results []int
    for r := range resultCh {
        results = append(results, r)
    }
    return results
}

这个模式里每一处设计都有道理:jobCh 关闭后 range 自动退出,worker 自然结束;用 WaitGroup 确认所有 worker 退出后才关 resultCh,否则向已关闭的 channel 发送会 panic;结果 channel 有缓冲,worker 不会因为没人收结果而卡住。

三个最常见的并发坑

坑一:goroutine 泄漏

goroutine 不会被 GC"杀掉",只要它还阻塞在某个 channel 上,它就永远占着内存。最常见的泄漏:超时后主流程走了,但子 goroutine 还卡在向无人接收的 channel 发送。

// 泄漏版本:超时返回后,goroutine 永远阻塞在 result <- ...
func leaky() string {
    result := make(chan string)      // 无缓冲!
    go func() {
        result <- slowQuery()        // 主流程超时走了,这里永远发不出去
    }()
    select {
    case r := <-result:
        return r
    case <-time.After(time.Second):
        return "timeout"             // 返回了,但上面的 goroutine 泄漏了
    }
}

// 修复:把 channel 改成有缓冲(容量 1),发送方就不会阻塞
result := make(chan string, 1)

坑二:对共享变量的数据竞争

// 有数据竞争:多个 goroutine 同时写 counter
var counter int
for i := 0; i < 1000; i++ {
    go func() { counter++ }()        // counter++ 不是原子操作
}

// 修复 1:用 sync.Mutex
var mu sync.Mutex
mu.Lock(); counter++; mu.Unlock()

// 修复 2:用原子操作
import "sync/atomic"
var counter int64
atomic.AddInt64(&counter, 1)

// 修复 3:更 Go 的方式 —— 用 channel,让一个 goroutine 独占这个变量

排查数据竞争有神器:运行时加 -race 标志,go run -race main.gogo test -race,它会在运行时检测并精确报告竞争发生的位置。上线前的代码都应该跑一遍。

坑三:死锁

// 死锁:无缓冲 channel,没有任何 goroutine 接收
func main() {
    ch := make(chan int)
    ch <- 1                           // 阻塞,永远等不到接收方
    fmt.Println(<-ch)                 // 到不了这里
}
// 运行报:fatal error: all goroutines are asleep - deadlock!

Go 运行时能检测到"所有 goroutine 都睡死了"这种全局死锁并直接报错。但局部死锁(部分 goroutine 卡死)它检测不到,需要靠 -race、pprof 或者超时机制来兜底。

什么时候用 channel,什么时候用锁

"通过通信共享内存"不是说锁是错的。经验上的分工是:需要在 goroutine 之间传递数据所有权、做任务分发、做事件通知,用 channel;只是保护一小段临界区、一个计数器、一个 map,用 sync.Mutexatomic 反而更简单直接、性能更好。不要为了"显得 Go"而把所有同步都硬塞进 channel。

errgroup:带错误传播的 WaitGroup

sync.WaitGroup 能等一组 goroutine,但它不管错误 —— 如果某个 goroutine 失败了,你得自己想办法把错误传出来。golang.org/x/sync/errgroup 解决了这个痛点:任意一个 goroutine 返回错误,整组的 Wait() 就返回那个错误,还能配合 context 让其他 goroutine 提前取消。

import (
    "context"
    "golang.org/x/sync/errgroup"
)

func fetchAll(ctx context.Context, urls []string) ([]string, error) {
    g, ctx := errgroup.WithContext(ctx)
    results := make([]string, len(urls))

    for i, url := range urls {
        i, url := i, url             // 经典:为每个 goroutine 复制循环变量
        g.Go(func() error {
            data, err := fetch(ctx, url)
            if err != nil {
                return err           // 返回错误,会触发整组的 ctx 取消
            }
            results[i] = data        // 每个 goroutine 写自己的下标,无竞争
            return nil
        })
    }

    if err := g.Wait(); err != nil { // 任意一个失败,这里就拿到第一个错误
        return nil, err
    }
    return results, nil
}

注意 results[i] = data 这里没有数据竞争 —— 虽然多个 goroutine 同时写同一个 slice,但每个只写自己专属的下标,互不重叠。这是一个常用且安全的模式,比给整个 slice 加锁高效得多。

sync.Once 与 sync.Pool:两个被低估的工具

sync.Once 保证某段代码在并发环境下"只执行一次",是实现并发安全的懒加载/单例的标准方式:

var (
    instance *Config
    once     sync.Once
)

func GetConfig() *Config {
    once.Do(func() {
        // 即使 1000 个 goroutine 同时调 GetConfig,这里也只跑一次
        instance = loadConfigFromDisk()
    })
    return instance
}

sync.Pool 是一个临时对象池,用来复用那些"创建成本高、生命周期短、会被频繁分配"的对象,从而减轻 GC 压力。典型场景是复用大的 buffer:

var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)     // 池子空时,用 New 造一个
    },
}

func handleRequest(data []byte) string {
    buf := bufPool.Get().(*bytes.Buffer) // 取一个,可能是复用的,也可能是新的
    buf.Reset()                          // 复用前务必重置状态
    defer bufPool.Put(buf)               // 用完放回池子

    buf.Write(data)
    buf.WriteString(" processed")
    return buf.String()
}

关于 sync.Pool 有两个必须知道的点:一是池里的对象随时可能被 GC 回收,所以它只适合放"丢了也无所谓、重新造一个就行"的临时对象,不能用来做连接池这种需要保证存活的场景;二是 Get 出来的对象状态是未知的(可能是别人用过放回来的),用之前一定要 Reset。用对了,在高 QPS 服务里它能显著降低内存分配次数和 GC 停顿。

竞争检测的实战流程

最后补一个工程习惯。并发 bug 的特点是"偶现",靠肉眼 review 很难抓全。把下面这条加进你的 CI:

# 测试时开启竞争检测器,它会插桩监控所有内存访问
go test -race ./...

# 压测时也可以开,更容易触发竞争
go test -race -run=TestConcurrent -count=100 ./...

# 注意:-race 会让程序慢 5~10 倍、内存涨 5~10 倍,
# 所以它用于测试和预发环境,不要带到生产构建里

-race 不是"可能有竞争"的猜测,它报告的是确实发生了的竞争访问,并精确到两个冲突访问各自的代码行和 goroutine 创建位置。把"提交前 go test -race"变成肌肉记忆,能帮你拦掉绝大多数并发隐患。

写在最后

把这套模型收拢一下:goroutine 提供廉价的并发执行单元,channel 提供它们之间安全传值的管道,select 让一个 goroutine 能同时应对多个 channel,context 把取消信号沿调用链往下传,WaitGroup / Mutex / atomic 补上"等待"和"轻量同步"这两块。worker pool 是这些零件最典型的组合。

而真正的功力,体现在你对那三个坑的警觉上:每起一个 goroutine,就问自己"它一定会退出吗";每碰一个共享变量,就问"有没有竞争";每用一个无缓冲 channel,就问"对面一定有人收吗"。把这三个问题变成本能,你写的 Go 并发代码就稳了一大半。上线前别忘了 -race

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

SQL 索引优化实战:为什么你的查询慢,以及怎么修

2026-5-15 10:47:08

技术教程

Rust 所有权与借用完全指南:从内存安全到生命周期标注

2026-5-15 10:55:54

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索