文章目录
- 19 - Go 并发限制:限流与控制并发数(从原理到工程实践)
- 核心概念
- 控制“同时做多少事”(并发数)
- 控制“单位时间做多少事”(限流)
- 本质是什么?
- 基础使用示例
- 用 channel 控制最大并发数(最经典方式)
- 进阶使用示例
- Worker Pool(生产级常见模型)
- context + 并发控制(超时/取消)
- Token Bucket 简化限流模型
- 常见错误与坑(重点)
- 坑一:goroutine 泄漏(最隐蔽)
- 错误代码
- 为什么错
- 正确写法
- 坑二:channel 无缓冲导致死锁
- 错误代码
- 原因
- 正确写法
- 坑三:忘记释放信号量
- 错误代码
- 原因
- 正确写法
- 底层原理解析(核心)
- channel 本质
- 行为机制:
- semaphore(信号量)
- context 取消机制
- 为什么 Go 用 channel 做并发控制?
- 对比与扩展
- channel 缓冲控制 vs 无缓冲 channel 控制
- 无缓冲 channel:同步阻塞模型
- 带缓冲 channel:并发控制模型
- ticker 限流 vs token bucket 思想
- ticker:固定节奏触发
- token bucket:可突发限流模型(思想层面)
- worker pool vs goroutine 直接并发
- 直接 goroutine 并发(风险模型)
- worker pool(稳定模型)
- 一句话升级理解
- 思考与升华(加分项)
- 本质总结
- 点睛总结
19 - Go 并发限制:限流与控制并发数(从原理到工程实践)
在 Go 并发编程中,一个非常现实的问题是:goroutine 很轻量,但不是无限资源。
当你随手go func()起上成千上万的协程时,系统可能不会立刻崩,但会在某个瞬间出现:
- CPU 被打满
- 内存暴涨
- 下游服务被压垮
- 连接数耗尽
所以一个核心能力就是:如何控制并发数量与访问速率(限流)。
这篇文章我们从工程视角,把 Go 的并发控制体系讲透。
核心概念
并发控制本质解决两个问题:
控制“同时做多少事”(并发数)
典型场景:
- 批量请求 API
- 批量处理文件
- worker pool 模型
目标:避免 goroutine 无限增长
控制“单位时间做多少事”(限流)
典型场景:
- 防止打爆下游服务
- API QPS 控制
- 爬虫访问频率控制
目标:避免瞬时流量过大
本质是什么?
从设计角度看,Go 的并发控制本质是三种思想:
- 通信控制并发(channel)
- 计数控制并发(semaphore)
- 时间控制流量(ticker/token bucket)
一句话总结:
并发控制 = 用“阻塞或排队”换取“系统稳定性”
基础使用示例
用 channel 控制最大并发数(最经典方式)
packagemainimport("fmt""time")// 信号量控制并发数funcworker(idint,semchanstruct{}){// 等待信号量 获取令牌(没有空间会阻塞)sem<-struct{}{}// 执行任务fmt.Printf("worker %d start\n",id)time.Sleep(time.Second)fmt.Printf("worker %d done\n",id)// 释放信号量 释放令牌(有空间会唤醒等待的goroutine)<-sem}funcmain(){// 信号量大小为3,表示最多只能有3个goroutine同时执行sem:=make(chanstruct{},3)// 启动10个goroutinefori:=0;i<10;i++{// 每个goroutine都会等待信号量,直到有可用资源goworker(i,sem)}// 主goroutine等待5秒,以便观察输出time.Sleep(time.Second*5)}小结
sem <- struct{}{}:获取“执行资格”buffer size = 并发上限- 本质是信号量(semaphore)模型
进阶使用示例
Worker Pool(生产级常见模型)
packagemainimport("fmt""time")// 定义任务结构体typeTaskstruct{IDint// 任务ID}// 定义 worker 函数funcworker(idint,tasks<-chanTask,resultschan<-int){// 循环处理任务fortask:=rangetasks{// 处理任务逻辑fmt.Printf("worker %d 进程 task %d\n",id,task.ID)time.Sleep(time.Second)// 返回结果results<-task.ID*2}}// 主函数funcmain(){// 创建任务和结果通道, 缓冲区大小为10taskChan:=make(chanTask,10)resultChan:=make(chanint,10)// 启动固定数量 workerfori:=0;i<3;i++{goworker(i,taskChan,resultChan)}// 投递任务fori:=0;i<10;i++{taskChan<-Task{ID:i}}// 关闭任务通道,让 worker 知道没有更多的任务了close(taskChan)// 收集结果fori:=0;i<10;i++{fmt.Println("result:",<-resultChan)}}留个思考:
如何撑爆缓冲区???
输出:
worker 2 进程 task 0 worker 0 进程 task 1 worker 1 进程 task 2 worker 1 进程 task 3 result: 4 worker 0 进程 task 4 result: 2 result: 0 worker 2 进程 task 5 worker 2 进程 task 6 result: 10 result: 8 worker 1 进程 task 8 result: 6 worker 0 进程 task 7 worker 1 进程 task 9 result: 16 result: 14 result: 12 result: 18小结
- worker 数量固定 => 控制并发
- task channel => 任务队列
- result channel => 输出流
context + 并发控制(超时/取消)
packagemainimport("context""fmt""time")// worker 协程执行函数funcworker(ctx context.Context,idint,semchanstruct{}){// 等待信号量可用// 等待信号量可用,或者被取消select{casesem<-struct{}{}:case<-ctx.Done():fmt.Println("cancel worker",id)return}// 执行任务deferfunc(){<-sem}()fmt.Println("start worker",id)// 模拟执行任务select{case<-time.After(2*time.Second):fmt.Println("done worker",id)case<-ctx.Done():fmt.Println("timeout worker",id)}}funcmain(){// 创建带超时的上下文ctx,cancel:=context.WithTimeout(context.Background(),3*time.Second)// 延迟取消,等待所有协程执行完毕defercancel()sem:=make(chanstruct{},2)fori:=0;i<5;i++{goworker(ctx,i,sem)}time.Sleep(5*time.Second)}输出:
start worker 4 start worker 0 done worker 4 start worker 1 done worker 0 start worker 2 cancel worker 3 timeout worker 1 timeout worker 2小结
context控制生命周期channel控制并发数量- 二者组合 = 工程级并发控制标准方案
Token Bucket 简化限流模型
packagemainimport("fmt""time")// rateLimiter 返回一个通道,每隔500毫秒向该通道发送时间戳。funcrateLimiter()<-chantime.Time{returntime.Tick(500*time.Millisecond)}funcmain(){// 创建一个速率限制器,每隔500毫秒允许一个请求。limiter:=rateLimiter()// 模拟10个请求,每个请求等待速率限制器允许。fori:=0;i<10;i++{<-limiter// 拿令牌fmt.Println("request",i,time.Now())}}输出:
request 0 2026-04-22 22:10:09.784786939 +0800 CST m=+0.500643508 request 1 2026-04-22 22:10:10.284295056 +0800 CST m=+1.000151673 request 2 2026-04-22 22:10:10.784965989 +0800 CST m=+1.500822557 request 3 2026-04-22 22:10:11.284293288 +0800 CST m=+2.000149905 request 4 2026-04-22 22:10:11.784967432 +0800 CST m=+2.500823999 request 5 2026-04-22 22:10:12.284286155 +0800 CST m=+3.000142774 request 6 2026-04-22 22:10:12.784972686 +0800 CST m=+3.500829260 request 7 2026-04-22 22:10:13.284287308 +0800 CST m=+4.000143926 request 8 2026-04-22 22:10:13.78496704 +0800 CST m=+4.500823608 request 9 2026-04-22 22:10:14.28429124 +0800 CST m=+5.000147855小结
- ticker = 固定节奏发放令牌
- 控制的是“时间维度流量”
常见错误与坑(重点)
坑一:goroutine 泄漏(最隐蔽)
错误代码
funcworker(donechanbool){for{// 永久阻塞}}为什么错
- goroutine 没有退出条件
- channel 没关闭 / 没 context 控制
正确写法
funcworker(ctx context.Context){for{select{case<-ctx.Done():returndefault:// do work}}}坑二:channel 无缓冲导致死锁
错误代码
sem:=make(chanstruct{})sem<-struct{}{}// 直接阻塞死锁原因
- 无缓冲 channel = 同步通信
- 没有 receiver
正确写法
sem:=make(chanstruct{},3)sem<-struct{}{}坑三:忘记释放信号量
错误代码
sem<-struct{}{}iferr!=nil{return// 没释放}<-sem原因
- goroutine 提前 return
- semaphore 永久占用
正确写法
sem<-struct{}{}deferfunc(){<-sem}()底层原理解析(核心)
Go 并发控制核心依赖三类机制:
channel 本质
channel 是一个:
- 环形队列(buffer)
- mutex(互斥锁)
- goroutine wait queue(等待队列)
行为机制:
- buffer 未满 → 直接写入
- buffer 满 → sender 阻塞
- buffer 空 → receiver 阻塞
👉 本质:生产者-消费者队列 + 调度器唤醒
semaphore(信号量)
chan struct{}{}等价于:
- 计数器 + 阻塞队列
行为:
- 申请:计数+1(或进入阻塞队列)
- 释放:计数-1 + 唤醒 goroutine
👉 本质:资源计数器
context 取消机制
内部结构:
- done channel
- error 状态
- parent 链式传播
触发机制:
- close(done)
- 所有监听 goroutine 被唤醒
👉 本质:广播式取消信号
为什么 Go 用 channel 做并发控制?
Go 的设计哲学:
Do not communicate by sharing memory; share memory by communicating.
因此:
- 锁(共享内存) → 传统思维
- channel(通信) → Go 思维
👉 并发控制被抽象为“通信问题”
对比与扩展
在 Go 的并发控制中,有几个看起来很像,但本质差异很大的实现方式,很容易在工程中用错。
channel 缓冲控制 vs 无缓冲 channel 控制
这两种写法经常被混用,但行为完全不同。
无缓冲 channel:同步阻塞模型
sem:=make(chanstruct{})gofunc(){sem<-struct{}{}// 发送必须等待接收}()特点:
- 发送和接收必须同时发生
- 本质是“握手”
- 不适合做并发限制
👉 更像“同步点”,而不是限流工具
带缓冲 channel:并发控制模型
sem:=make(chanstruct{},3)sem<-struct{}{}// 超过3会阻塞特点:
- buffer size = 并发上限
- 控制的是“同时执行数量”
- 是最常见的 semaphore 实现方式
👉 工程中标准并发控制方案
ticker 限流 vs token bucket 思想
很多人会把time.Ticker当限流工具,但它和真正限流模型有差异。
ticker:固定节奏触发
forrangetime.Tick(time.Second){fmt.Println("do request")}特点:
- 固定时间间隔执行
- 不关心“突发流量”
- 不能累积令牌
👉 更像“节拍器”
token bucket:可突发限流模型(思想层面)
核心思想:
- 令牌按速率生成
- 请求消耗令牌
- 令牌可以积累(允许突发)
特点:
- 支持突发流量
- 更贴近真实网关限流
- 工程中常用(如 API Gateway)
👉 ticker 是“定时器”,token bucket 是“资源池”
worker pool vs goroutine 直接并发
这是最容易写错的一点。
直接 goroutine 并发(风险模型)
fori:=0;i<10000;i++{godoTask(i)}问题:
- goroutine 数量不可控
- 内存压力不可控
- 下游可能被打爆
👉 适合“低频 + 小规模任务”
worker pool(稳定模型)
fori:=0;i<10;i++{goworker(tasks)}特点:
- 固定 worker 数量
- 任务排队执行
- 系统行为可预测
👉 适合“生产级任务处理”
小结
这三组对比的核心差异可以归纳为一句话:
- channel buffer:控制“并发数量”
- ticker:控制“执行节奏”
- worker pool:控制“执行能力边界”
一句话升级理解
并发控制的本质不是“限制 goroutine”,而是:
在系统可承受范围内,把“执行权”变成一种可调度资源
思考与升华(加分项)
如果抽象 Go 并发控制,本质只有三件事:
- 生产什么(数据)
- 多少人处理(并发)
- 多久处理一次(速率)
可以用一个极简模型表示:
producer → queue → worker pool → limiter → consumer甚至可以自己实现一个简化版本:
packagemainimport"fmt"// 模拟并发处理任务,限制同时处理的数量为5funchandle(taskint){fmt.Println(task)}funcmain(){// 限制并发数量为5sem:=make(chanstruct{},5)// 任务队列tasks:=make(chanint,10)// 启动任务生产者gofunc(){// 生产任务fori:=0;i<cap(tasks);i++{tasks<-i}// 关闭任务队列close(tasks)}()// 启动任务消费者fortask:=rangetasks{sem<-struct{}{}gofunc(){deferfunc(){<-sem}()handle(task)}()}}本质总结
Go 的并发控制,不是“控制 goroutine”,而是:
控制资源流动的节奏与边界
点睛总结
真正的并发能力,不是“能起多少 goroutine”,而是“能稳住多少流量”。