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 兜底,就能写出既快又稳的高并发服务。今天就能做的三件事:
- 把一段串行的 for 循环改成
go+WaitGroup并发执行; - 给所有跨 goroutine 的 HTTP 调用加上
select + time.After超时; - 在 CI 里加上
go test -race,把竞态挡在合并之前。




