Table of contents
Open Table of contents
1. 先把三件套拧准
很多人把 Go 并发记成「go 开多进程并发、sync.WaitGroup 等所有工作完成、channel 设置同时开启的时间」。方向是对的,但有两个词会把后面的理解带偏:
go不是多进程,也不是多线程。它把一个函数包成 goroutine:Go 运行时管理的后台小任务,初始栈约 2KB;而一条操作系统线程默认栈约 8MB,相差几千倍。正因为便宜,才敢「一个任务开一个」。- channel 不是定时器。它是 goroutine 之间传数据、排队的管道。真正限制「同时最多几个在跑」的是信号量(可以用带缓冲的 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 s | 20 / 0 | 一个个等,等待时间累加 |
全放开(go 但不设上限) | 200 ms | 8 / 12 | 最快,但数据源故意设了并发上限,超了就拒绝 |
受控(go + 信号量 + 令牌桶) | 676 ms | 20 / 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 在少量任务时往往不出现:
- 显式传参
(i, s)。Go 1.22 之前,如果直接写go func(){ ...i, s... }(),所有 goroutine 会共享同一个循环变量,最后全拿到列表最后一项。go.mod若写的是go 1.21,这一步不是代码风格,是必须。 - 按索引写回
results[i]。预先make好整块结果数组,每个任务只写自己的下标,互不冲突,连互斥锁都不用加。 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 个窗口只是并发。
多出来的是一件很具体的事:必须有一个「被压的服务端」。只写并发抓取,你是「制造压力的客户端」,练到的是并发编程,练不到服务侧怎么接、怎么排、怎么在饱和前稳住。所以要验证高并发,得让自己写的服务成为被打的对象。
判断「是不是真高并发」,与语言无关,只看三个数:
- 同时承载的连接 / 任务数
- 单位时间的请求密度(QPS)
- 拐点:并发加到某个值后吞吐反而掉、P99(99% 的请求都比它快的那个延迟)反而涨
本机自压实测:峰值在 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 自带连接池、pprof | Redis 缓存、Prometheus + Grafana、OpenTelemetry、K8s 自动扩容 |
关键结论:Go 标准库本身已经能撑住相当高的并发。 第三方补的是路由便利性、分布式协调、缓存和可观测性,不是「并发能力」本身。长连接推送也一样:单向推送用标准库的 http.Flusher 写 SSE 就够,双向 WebSocket 才需要 gorilla/websocket、nhooyr.io/websocket 这类库。
7. 其他语言:高并发下是不是不一样?
并发那三件套(发 / 等 / 限)完全一致,跟语言无关;不一致的只有「发」那一步用什么当载体——因为各语言里「一个任务」的成本不同。便宜就一个任务开一个,贵就只能池化,或者干脆不分配载体、改按事件调度。
| 语言 | 并发标准写法(自带,不需第三方) | 高并发时额外搭配 | 第三方是必须吗 |
|---|---|---|---|
| Go | go / WaitGroup / chan + 信号量 | Kafka、Redis、Prometheus、Gin(可选) | 否,标准库就够起步 |
| Java 21+ | 虚拟线程(JVM 管的轻量线程,一个任务一个)、Semaphore、CompletableFuture | Netty 做长连接、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 | 系统接口不算第三方;要跨平台才用库 |
| Python | asyncio.create_task() + asyncio.gather()(发和等合成一行)+ asyncio.Semaphore | uvicorn / gunicorn 做 ASGI 服务器、aiohttp、Celery + Redis、FastAPI | 协程框架是标准库,但异步 HTTP 必须第三方 |
两个值得记住的例外:
- Python 有 GIL(全局解释器锁,同一时刻只放行一个线程跑 Python 字节码),所以多线程对 CPU 密集没用;但网络 I/O 等待时会让出,协程照样能并发——和 Go「省的是等待时间」是同一个道理。CPU 密集得靠多进程。
- C/C++ 最不像 Go:一条 OS 线程栈约 8MB,1000 个连接按一连接一线程就是约 8GB 栈内存,活没干就先被内存压垮。所以走 epoll:不给连接分配载体,只登记「哪个连接有数据了」,一个线程照看上万连接。Nginx、Redis 都是这套。
业界标准写法可以这样总结:并发看语言(发 / 等 / 限),高并发看架构(接 / 排 / 控 / 扩)。语言只决定第一层里「载体」长什么样;越往上走,越和语言无关,变成网关、队列、缓存、限流、观测这些组件的组合。第三方库主要出现在「语言外」那一列。
8. 一句话
并发 = 把「等待」叠起来,
go就是那个「不等待」的开关。 高并发 = 当等待叠到资源不够分时,给「来不及处理」找去处、立规矩。 前者是语法,后者是架构。
术语表
- goroutine:Go 运行时管理的后台轻量任务,初始栈约 2KB,不是进程也不是系统线程。
- WaitGroup:等一批 goroutine 全部结束的计数器。
- channel:goroutine 之间传数据的管道,带缓冲时可当队列或通行证。
- 信号量:固定数量的通行证,限制「同时最多几个在跑」。
- 令牌桶:按固定速率放令牌,限制「每秒最多几个」。
- context:Go 标准库里传递「超时 / 取消」信号的对象。
- 竞态(race):多个任务同时读写同一块内存导致结果不确定,用
go test -race查。 - QPS:每秒请求数,衡量单位时间涌来多少活儿。
- P99:99% 的请求都比它快的那个延迟,用来看最慢那一批。
- 拐点:并发加到某值后吞吐反而下降、延迟反而上升的临界点。
- SSE / WebSocket:基于 HTTP 的单向服务器推送 / 双向长连接。
- 虚拟线程:Java 21 起由 JVM 管理的轻量线程,一个任务一个、不用池化。
- GIL:Python 的全局解释器锁,同一时刻只放行一个线程执行字节码。
- epoll:Linux 内核告诉你「哪几个连接现在有数据」的机制,一个线程可照看上万连接。