Skip to content
The Simple
Go back

Go 并发的标准写法,以及高并发时业界怎么做

Edit page

Table of contents

Open Table of contents

1. 先把三件套拧准

很多人把 Go 并发记成「go 开多进程并发、sync.WaitGroup 等所有工作完成、channel 设置同时开启的时间」。方向是对的,但有两个词会把后面的理解带偏:

所以准确说法只有三个字:

动作标准库里的东西它解决什么
发go f()把「等这一个跑完」换成「不等,丢后台」,循环因此能连着跑下去
等sync.WaitGroup最后收口一次,确认整批任务都结束了才继续
限带缓冲 chan 拼信号量、time.Ticker 拼令牌桶别让同时涌出去的任务把下游压垮

一句话:并发省的是「等待时间」,不是让 CPU 变快。每个任务里那段「等网络回应」的时间,并发时是重叠的。

2. 最小可跑的标准写法

同一份 20 个标的的列表,只改「怎么发」:

// 串行:等这一个跑完,才轮到下一个
for i, s := range symbols {
    results[i] = fetchOne(s)
}

// 并发:go 不等待,丢后台后立刻回到循环
var wg sync.WaitGroup
results := make([]Quote, len(symbols))
for i, s := range symbols {
    wg.Add(1)
    go func(i int, s string) {
        defer wg.Done()
        results[i] = fetchOne(s)
    }(i, s)   // 关键:把当前这轮的值当参数传进去
}
wg.Wait()     // 只在这里等一次

因果链只有一条:因为 go 那行不等待、立刻返回 → 所以循环不被卡住,一口气连跑 20 次 → 所以 20 个后台任务几乎同时开始 → 而每个任务有 200ms 是在干等网络响应,这 20 段等待重叠了 → 所以总耗时从 20×200ms 变成约 200ms。

本机实测(同一批 20 个标的,只改「怎么发」):

跑法耗时成功 / 失败说明
串行4.007 s20 / 0一个个等,等待时间累加
全放开(go 但不设上限)200 ms8 / 12最快,但数据源故意设了并发上限,超了就拒绝
受控(go + 信号量 + 令牌桶)676 ms20 / 0错误归零,仍远快于串行

快不是目的,稳才是:全放开把 4 秒压到 0.2 秒,代价是 20 个里坏了 12 个。

要加「限」,用带缓冲 channel 当通行证就够了,不必引入任何库:

sem := make(chan struct{}, 8) // 同时最多 8 个
go func(i int, s string) {
    sem <- struct{}{}          // 拿一张通行证,满了就等
    defer func() { <-sem }()    // 还回去
    results[i] = fetchOne(s)
}(i, s)

3. 三个不显眼但必须写的细节

少一个就会出 bug,而且这类 bug 在少量任务时往往不出现:

  1. 显式传参 (i, s)。Go 1.22 之前,如果直接写 go func(){ ...i, s... }(),所有 goroutine 会共享同一个循环变量,最后全拿到列表最后一项。go.mod 若写的是 go 1.21,这一步不是代码风格,是必须。
  2. 按索引写回 results[i]。预先 make 好整块结果数组,每个任务只写自己的下标,互不冲突,连互斥锁都不用加。
  3. wg.Add(1) 必须放在 go 之前。若放进 goroutine 内部,主线程可能先执行到 wg.Wait(),计数器还是 0,直接放行,结果一个都没填完。

4. 容易没考虑到的「配合工具」

这些都在标准库或 Go 团队维护的官方扩展库里,写生产代码时几乎一定会用到:

工具归属干什么为什么需要
context标准库超时、取消、传请求范围的值下游卡住时不能无限等,要能一键叫停整批任务
sync.Mutex / RWMutex标准库保护共享的 map、计数器多个 goroutine 同时写同一块内存会出竞态
sync/atomic标准库无锁的整数加减只数个数时比加锁轻得多
sync.Once标准库保证初始化只跑一次配置、连接池这类全局对象别重复建
time.Ticker / time.After标准库定时、退避重试失败后等 50 / 100 / 200ms 再来,避免所有任务同时重试把下游打得更惨
net/http标准库每来一个连接自动起一个 goroutine服务侧高并发不用你手写 go,标准库已经内置
go test -race工具链竞态检测共享数据有没有写错,跑一次就知道
net/http/pprof标准库线上性能画像卡在 CPU、内存还是 goroutine 泄漏,用它看
golang.org/x/sync/errgroup官方扩展一组任务里任一出错,就取消其余纯 WaitGroup 不会传错误,也不会取消兄弟任务
golang.org/x/sync/singleflight官方扩展把「同时来的相同请求」合并成一个防缓存击穿:1000 个人同时查同一个 key,只放 1 个去查库
golang.org/x/time/rate官方扩展现成的令牌桶不用自己拿 Ticker 拼

层次要说清:golang.org/x/... 是 Go 团队自己维护的官方扩展库,需要 go get,但不是社区野库;标准库则连 go get 都不用。这两层都不算「第三方依赖」。

5. 并发 ≠ 高并发,多出来的到底是什么

前面讲的都是并发:几件事穿插着做。而高并发指的是同一瞬间有大量任务涌来,资源不够分——关键变量是请求密度(QPS,每秒请求数),不是用户人数。一个人写个循环打 1000 个请求,也能造出高并发;开 3 个窗口只是并发。

多出来的是一件很具体的事:必须有一个「被压的服务端」。只写并发抓取,你是「制造压力的客户端」,练到的是并发编程,练不到服务侧怎么接、怎么排、怎么在饱和前稳住。所以要验证高并发,得让自己写的服务成为被打的对象。

判断「是不是真高并发」,与语言无关,只看三个数:

本机自压实测:峰值在 100 并发(QPS 30172),拐点约 200 并发(QPS 掉到 7352,P99 从 19ms 涨到 417ms)。超过拐点后「加并发反而更慢」,这才是高并发真正要处理的问题。

6. 高并发下 Go 的业界写法:四层

到这里,写法就从「语言语法」变成「架构分层」了。业界通常按四层来拆,每层都问同一个问题:来不及处理的任务,给它什么去处?

层要解决的问题Go 标准库就够的部分常见外部组件
接得住大量连接同时进来net/http 每连接一个 goroutine;http.Server 的读写超时Nginx / Envoy 做网关;Gin / Echo 只是路由糖;fasthttp 追求极致
排得开来不及处理先放哪带缓冲 channel + 固定 worker 池Kafka / RabbitMQ / NATS 做跨服务队列;Redis Streams 起步够用
控得住别把自己和下游压垮context 超时、x/time/rate 限流、信号量、慢客户端丢旧帧Sentinel-golang / gobreaker 做熔断;Redis 做分布式限流
扩得动加机器能分摊、能看见瓶颈服务无状态、database/sql 自带连接池、pprofRedis 缓存、Prometheus + Grafana、OpenTelemetry、K8s 自动扩容

关键结论:Go 标准库本身已经能撑住相当高的并发。 第三方补的是路由便利性、分布式协调、缓存和可观测性,不是「并发能力」本身。长连接推送也一样:单向推送用标准库的 http.Flusher 写 SSE 就够,双向 WebSocket 才需要 gorilla/websocket、nhooyr.io/websocket 这类库。

7. 其他语言:高并发下是不是不一样?

并发那三件套(发 / 等 / 限)完全一致,跟语言无关;不一致的只有「发」那一步用什么当载体——因为各语言里「一个任务」的成本不同。便宜就一个任务开一个,贵就只能池化,或者干脆不分配载体、改按事件调度。

语言并发标准写法(自带,不需第三方)高并发时额外搭配第三方是必须吗
Gogo / WaitGroup / chan + 信号量Kafka、Redis、Prometheus、Gin(可选)否,标准库就够起步
Java 21+虚拟线程(JVM 管的轻量线程,一个任务一个)、Semaphore、CompletableFutureNetty 做长连接、Spring Boot、Sentinel / Resilience4j 限流熔断、Kafka、Redisson、Micrometer否,JDK 够;Netty 是长连接场景才上
Java 8 及以前固定大小 ThreadPoolExecutor(池子大小本身就是限流)、CountDownLatch同上;长连接必须 Netty否
C / C++pthread_create / std::jthread;C++20 的 std::latch(≈ WaitGroup)、std::counting_semaphore(≈ 信号量)epoll / io_uring 事件循环;Boost.Asio、libuv、libevent 做跨平台;hiredis、librdkafka系统接口不算第三方;要跨平台才用库
Pythonasyncio.create_task() + asyncio.gather()(发和等合成一行)+ asyncio.Semaphoreuvicorn / gunicorn 做 ASGI 服务器、aiohttp、Celery + Redis、FastAPI协程框架是标准库,但异步 HTTP 必须第三方

两个值得记住的例外:

业界标准写法可以这样总结:并发看语言(发 / 等 / 限),高并发看架构(接 / 排 / 控 / 扩)。语言只决定第一层里「载体」长什么样;越往上走,越和语言无关,变成网关、队列、缓存、限流、观测这些组件的组合。第三方库主要出现在「语言外」那一列。

8. 一句话

并发 = 把「等待」叠起来,go 就是那个「不等待」的开关。 高并发 = 当等待叠到资源不够分时,给「来不及处理」找去处、立规矩。 前者是语法,后者是架构。

术语表


Edit page
Share this post:

Previous Post
SSO 一次登录的背后:OAuth2 / OIDC / JWT 是怎么串起来的