Go 并发实战:goroutine 与 channel

Go 并发靠两个原语撑起:goroutine 与 channel。用 go 关键字就能轻量启动一个协程,成本仅几 KB 栈内存;多个 goroutine 之间用 channel 通信,而非共享内存。这种”以通信来共享”的模型,让 Go 并发编程既简单又不易出错,也是 Docker、Kubernetes、Terraform 这些云原生基石选择 Go 的原因。

一、为什么 Go 天生适合高并发

传统多线程服务里,一个 OS 线程占用几 MB 栈,开几千个就吃不消。Go 在运行时层面做了 M:N 调度:把海量 goroutine 复用到少量 OS 线程上,调度器在用户态完成切换,开销极低。再加上语言层面把 channel 作为一等公民,开发者习惯用”传消息”而不是”加锁抢变量”,并发代码天然更不容易写出竞态。

二、goroutine:轻量到可以海量启动

启动一个 goroutine 只需在函数调用前加 go 关键字。下面这段代码会并发跑 5 个 worker:

package main

import (
    "fmt"
    "time"
)

func worker(id int) {
    fmt.Printf("worker %d start\n", id)
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("worker %d done\n", id)
}

func main() {
    for i := 0; i < 5; i++ {
        go worker(i) // 启动一个 goroutine
    }
    time.Sleep(300 * time.Millisecond) // 等待 goroutine 完成
}

注意:示例里用 time.Sleep 只是为了演示。生产代码不要依赖 Sleep 来”等协程跑完”,正确做法是用 sync.WaitGroup 或 channel 来做同步,否则要么提前退出丢任务,要么等待时间拍脑袋不可靠。

三、channel:用通信代替共享内存

channel 是带类型的管道:make(chan int) 创建无缓冲管道,make(chan int, 3) 创建容量为 3 的有缓冲管道。发送用 ch <- v,接收用 <-ch,关闭用 close(ch),遍历用 for v := range ch。

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)

for v := range ch {
    fmt.Println(v) // 依次打印 1 2 3
}

无缓冲 channel 要求发送和接收”同时就绪”才算完成一次传递(同步点);有缓冲 channel 在缓冲未满时发送不阻塞。二者的取舍如下:

类型发送阻塞条件典型用途
无缓冲 channel接收方未就绪即阻塞严格同步、信号通知
有缓冲 channel缓冲区已满才阻塞解耦生产者/消费者、限流

四、实战:并发抓取并汇总(fan-out / fan-in)

最常见的并发模式是”一组 worker 并发干活,主协程汇总结果”。下面用 WaitGroup 等所有 worker 结束,再关闭 channel,主协程用 range 收完即退出:

func fetch(url string, ch chan<- string) {
    // 真实场景这里用 http.Get 发起请求
    ch <- fmt.Sprintf("%s ok", url)
}

func main() {
    urls := []string{"https://a.com", "https://b.com", "https://c.com"}
    ch := make(chan string, len(urls))

    var wg sync.WaitGroup
    for _, u := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            fetch(u, ch)
        }(u) // 把 u 作为参数传入,避免闭包捕获循环变量
    }

    go func() {
        wg.Wait()
        close(ch)
    }()

    for r := range ch {
        fmt.Println(r)
    }
}

这里有个 Go 老坑:for 循环变量在 Go 1.22 之前是复用的,闭包若直接引用 u 可能拿到错误值。把 u 作为参数传进匿名函数是最稳的写法(Go 1.22+ 已默认每轮新建循环变量,但显式传参依然推荐)。

五、select 与超时控制

当需要从多个 channel 收发,或想给操作加超时,用 select。它和 switch 类似,哪个 case 就绪就走哪个;配合 time.After 可以优雅做超时:

select {
case v := <-ch:
    fmt.Println("recv:", v)
case <-time.After(2 * time.Second):
    fmt.Println("timeout: no data in 2s")
default:
    fmt.Println("non-blocking: channel not ready")
}

default 让 select 不阻塞(非阻塞探测),没有 default 且没有 case 就绪时则会阻塞等待,直到超时分支触发。

六、常见陷阱:数据竞态与 sync

多个 goroutine 同时读写同一变量会触发数据竞态(data race)。首选方案仍是 channel,但如果只是保护一个计数器,用 sync.Mutex 或 sync/atomic 更轻量:

var (
    mu      sync.Mutex
    counter int
)

func inc() {
    mu.Lock()
    counter++
    mu.Unlock()
}

竞态最难的是”本地没问题、上线才炸”。务必用 race 检测器在测试阶段抓出来:

go run -race main.go
# 或
go test -race ./...

不同同步原语的适用场景:

原语适用场景
channel传递数据/信号,表达”流水线”语义
sync.Mutex保护一小段临界区(如修改结构体字段)
sync.RWMutex读多写少(缓存、配置表)
sync/atomic单一整数的无锁自增/标志位

七、把并发能力用进 Web 服务

标准库 net/http 每来一个请求就会起一个 goroutine 处理,天然高并发,这也是用 Go 写 API 网关、中间件特别顺手的原因。真实部署时通常会这样组合:

在 Go 服务前面挂一层 Nginx 反向代理 做负载均衡与 HTTPS 终止;用 Docker Compose 把二进制和依赖打包成镜像;再交给 Kubernetes 做编排与扩缩容。如果要做实时推送,Go 的 goroutine 模型写 WebSocket 服务 也比回调式语言清爽得多。

八、性能调优与可观测

Go 并发并非”开越多 goroutine 越快”。几个关键点:

  • GOMAXPROCS:默认等于 CPU 核数,容器里若限制了核数要注意别被夸大到宿主核数。
  • pprof:引入 net/http/pprof 可采集 CPU、goroutine、heap 火焰图,定位阻塞与泄漏。
  • goroutine 泄漏:channel 没人接收、没设超时,goroutine 会一直挂着。用 runtime.NumGoroutine() 打点监控数量是否只增不减。
  • 限流:对外请求用带缓冲 channel 或 golang.org/x/time/rate 做令牌桶,避免瞬间打爆下游。

九、小结与行动清单

Go 并发的核心就三句话:用 go 起协程、用 channel 传数据、用 select 做多路复用与超时。把共享内存的念头换成”消息传递”,再配合 sync 与 -race 兜底,就能写出既快又稳的高并发服务。今天就能做的三件事:

  1. 把一段串行的 for 循环改成 go + WaitGroup 并发执行;
  2. 给所有跨 goroutine 的 HTTP 调用加上 select + time.After 超时;
  3. 在 CI 里加上 go test -race,把竞态挡在合并之前。
上一篇 WebSocket 频繁断连?Nginx 超时与心跳排查实战
下一篇 Supervisor 实战:进程守护与后台服务保活
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]