goroutine 多不等于并发能力强:从 ModelGate 讲有界并发与 Semaphore
从 ModelGate 的 Provider 级并发治理出发,解释为什么 goroutine 很轻量仍然需要限制并发、Semaphore 与限流器有什么区别,以及流式请求为什么必须一直占用并发额度。
ModelGate 是我用 Go 实现的 LLM API 网关。
Go 很适合写并发服务。
一个请求进来,net/http 会让不同请求能够并发处理;程序内部也可以很容易启动 goroutine:
go handle()
所以刚开始接触 Go 服务端时,很容易形成一个直觉:
goroutine 很轻量,那多来一些并发请求应该也没什么问题。
但真正把 ModelGate 接到外部大模型 Provider 后,这个想法很快就会遇到边界。
假设某个 Provider 平时可以稳定处理:
50 个同时进行的请求
此时突然来了:
500 个请求
如果网关没有任何限制,就可能同时向 Provider 发出 500 个调用。
问题不只是网关多创建了几百个 goroutine。
每一个正在执行的大模型请求可能还占用:
一个 HTTP 连接
一个响应 Body
上游并发额度
Token 生成资源
本地内存
超时计时器
流式请求的长期连接
而且 LLM 请求通常不像普通数据库查询那样几十毫秒就结束。
一条 SSE 流可能持续:
几秒
几十秒
甚至更久
于是 ModelGate 在每个 Provider 前面都加了一层:
Provider 级 Semaphore。
默认配置为:
PROVIDER_MAX_CONCURRENCY = 50
这篇文章从这层实现出发,说明为什么:
goroutine 可以很多,
和:
应该同时向一个下游发很多请求,
是两件完全不同的事情。
项目地址:theHerta27/ModelGate
先区分“请求来了多少”和“正在执行多少”
假设一秒内来了 100 个请求。
有两个数字很容易混淆。
第一个是:
这一秒来了多少请求
第二个是:
此刻有多少请求还没有执行完
比如每个请求只执行 10ms:
100 请求 / 秒
可能并不意味着:
同时有 100 个请求在运行
因为前面的请求很快就结束了。
反过来,如果每个请求持续 30 秒:
10 请求 / 秒
持续一段时间以后,也可能累积很多:
in-flight requests
所以:
Rate
和:
Concurrency
不是同一个概念。
一个关注:
单位时间进入多少请求。
另一个关注:
同一时刻有多少请求仍然占着资源。
这一点也是理解:
Rate Limiter
和:
Semaphore
区别的基础。
ModelGate 的 Semaphore 很简单
ModelGate 自己实现了一个很小的 Semaphore:
type Semaphore struct {
slots chan struct{}
}
创建时:
func New(maxConcurrent int) (*Semaphore, error) {
if maxConcurrent <= 0 {
return nil, fmt.Errorf(
"max concurrent requests must be positive",
)
}
return &Semaphore{
slots: make(chan struct{}, maxConcurrent),
}, nil
}
本质上就是一个:
有容量的 channel
假设:
maxConcurrent = 3
可以把它想成三个位置:
[ ][ ][ ]
请求 A 获得一个:
[A][ ][ ]
请求 B:
[A][B][ ]
请求 C:
[A][B][C]
现在已经满了。
请求 D 再来,就不能继续进入真实 Provider。
它必须等待。
Acquire 为什么是往 channel 里写
获取 Permit 的实现是:
func (s *Semaphore) Acquire(
ctx context.Context,
) (*Permit, error) {
select {
case s.slots <- struct{}{}:
return &Permit{semaphore: s}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
只要 channel 还没满:
s.slots <- struct{}{}
就能立即成功。
这里放进去的:
struct{}{}
本身没有业务数据。
它只是表示:
一个并发位置已经被占用。
假设 channel 容量是 50:
len(slots) = 当前正在占用的额度
cap(slots) = 最大允许额度
因此项目里的:
func (s *Semaphore) InFlight() int {
return len(s.slots)
}
可以直接得到当前已占用数量。
而:
func (s *Semaphore) Capacity() int {
return cap(s.slots)
}
就是最大并发数。
Release 为什么是从 channel 里取走一个元素
Permit 结束时:
func (p *Permit) Release() {
if p == nil || p.semaphore == nil {
return
}
p.once.Do(func() {
<-p.semaphore.slots
})
}
也就是说:
Acquire
→ channel +1
Release
→ channel -1
假设:
[A][B][C]
A 完成:
[ ][B][C]
等待中的 D 就可以获得这个空位。
所以 Semaphore 并不是在决定:
哪个 goroutine 能存在。
而是在决定:
哪些请求现在可以继续进入被保护的那段代码。
在 ModelGate 中,这段代码就是:
对某个具体 Provider 的调用。
为什么是每个 Provider 一个 Semaphore
ModelGate 支持多个 Provider。
启动时会为每个 Provider 分别创建:
semaphore, err :=
providerconcurrency.New(
cfg.ProviderMaxConcurrency,
)
然后和这个 Provider 的:
Retry Policy
Circuit Breaker
一起包装进 Governance Provider:
Provider A
├── Semaphore A
├── Circuit Breaker A
└── Retry A
Provider B
├── Semaphore B
├── Circuit Breaker B
└── Retry B
这意味着:
并发额度是 Provider 级的。
假设:
Provider A 最大并发 = 50
Provider B 最大并发 = 50
A 已经满了:
A: 50 / 50
并不意味着:
B 也不能处理请求
如果 Router 选择 B:
B: 20 / 50
它仍然有自己的容量。
这比一个全局:
整个 ModelGate 最多 50
更符合多 Provider 网关的结构。
因为真正被保护的是:
各自独立的下游容量。
非流式请求什么时候占用 Permit
ModelGate 的非流式 Chat() 入口先执行:
concurrencyPermit, err :=
p.semaphore.Acquire(ctx)
if err != nil {
return nil, err
}
defer concurrencyPermit.Release()
随后才进入:
Circuit Breaker
↓
Retry
↓
Provider
所以完整路径大致是:
Acquire Semaphore
↓
Circuit Breaker Allow
↓
Retry
↓
Provider Chat
↓
最终成功 / 失败
↓
Release Semaphore
这意味着一个逻辑请求在整个 Provider 调用期间都会占用一个并发额度。
包括内部 Retry。
例如:
Attempt 1 → 503
↓
Backoff
↓
Attempt 2 → success
从外层 Semaphore 的角度看,这仍然是:
一个逻辑请求
Permit 不会在 Attempt 1 和 Attempt 2 之间释放。
为什么 Retry 等待期间也继续占着额度
这里其实存在一种设计取舍。
当前 ModelGate 是:
Semaphore
↓
Retry
├── Attempt
├── Backoff
└── Attempt
而不是:
Retry
↓
每个 Attempt 单独 Acquire Semaphore
所以一次逻辑请求进入 Provider Governance 后,直到 Retry 整体完成才释放额度。
这样做的一个效果是:
已经开始处理的逻辑请求,在恢复期间仍然占用一个 Provider 并发预算。
不会出现很多请求:
Attempt 失败
→ 暂时释放
→ 大量新请求插进来
→ 原请求 Backoff 后再次参与竞争
当前实现更像是把:
一次逻辑 Provider 调用
整体看成一个需要占用容量的工作单元。
但它的代价也很明确:
Backoff 等待期间
实际上没有正在占用真实 Provider 连接,却仍然占着一个 Semaphore Permit。
这是当前实现的实际边界。
如果以后真实流量表明 Retry Backoff 占用额度影响吞吐,也可以重新评估:
Semaphore 应该限制逻辑调用,还是只限制真实 Attempt。
但当前代码选择的是前者。
为什么流式请求更需要有界并发
非流式请求可能:
1 秒
2 秒
5 秒
结束。
但 SSE 请求可能长期保持连接:
Client
↓
ModelGate
↓
Provider
data: ...
data: ...
data: ...
...
如果 Stream 一建立就释放 Semaphore:
ChatStream()
↓
拿到 Stream
↓
Release Permit
↓
继续 Recv 30 秒
那么 Semaphore 统计的就只是:
建立连接那一瞬间。
而真正占用 Provider 的几十秒流式生成过程完全不算在并发里。
结果可能变成:
Semaphore Capacity = 50
已经有 500 条 SSE Stream
仍然认为还有位置
这显然失去了限制意义。
所以 ModelGate 的 Stream 会把:
Concurrency Permit
一起保存到 managedStream:
type managedStream struct {
providerName string
inner provider.Stream
circuit *circuitbreaker.Permit
concurrency *providerconcurrency.Permit
cancel context.CancelFunc
once sync.Once
}
直到整个 Stream 生命周期结束。
Stream 到底什么时候释放额度
managedStream 最终会调用:
func (s *managedStream) finish(
outcome circuitbreaker.Outcome,
) {
s.once.Do(func() {
s.cancel()
s.circuit.Done(outcome)
s.concurrency.Release()
})
}
finish() 会在几个地方触发。
正常 EOF
chunk, err := s.inner.Recv()
if errors.Is(err, io.EOF) {
s.finish(
circuitbreaker.OutcomeSuccess,
)
}
表示流正常结束。
中途错误
if err != nil {
s.finish(
circuitOutcome(err),
)
}
主动 Close
func (s *managedStream) Close() error {
err := s.inner.Close()
// ...
return err
}
最终也会进入 finish()。
所以:
建立 Stream
↓
Acquire Permit
↓
持续几十秒 Recv
↓
EOF / Error / Close
↓
Release Permit
并发额度真正覆盖的是:
完整的流生命周期。
这也是 LLM Gateway 和很多普通短请求服务相比,一个非常明显的并发特点。
测试怎样证明 Stream 一直占着 Permit
ModelGate 有一条测试专门验证这一点。
Semaphore Capacity 设置成:
1
首先创建一条 Stream:
stream, err :=
managed.ChatStream(...)
此时唯一一个 Permit 已经被占用。
随后在另一个带 10ms Timeout 的 Context 中执行:
managed.Chat(ctx, ...)
测试要求结果是:
context.DeadlineExceeded
也就是:
Stream 还活着
↓
Concurrency = 1 / 1
↓
另一个 Chat 等不到 Permit
↓
Context Timeout
然后:
stream.Close()
释放 Permit。
再调用:
managed.Chat(
context.Background(),
...
)
就能成功。
所以测试验证的是:
Stream 从建立直到 Close,确实一直占用 Provider 并发额度。
而不是只测试 Semaphore 自己的 Acquire() 和 Release()。
为什么等待 Semaphore 时必须监听 Context
假设:
ProviderMaxConcurrency = 50
此时 50 个请求都在执行。
第 51 个请求进入:
Acquire
如果实现只是:
s.slots <- struct{}{}
它会一直阻塞,直到有空位。
问题是客户端可能早就已经:
取消请求
或者整个 Gateway 的:
Overall Timeout
已经到期。
如果 Semaphore 仍然无条件等待:
请求已经没用了
↓
goroutine 还在排队
就会浪费资源。
所以当前实现用:
select {
case s.slots <- struct{}{}:
// acquired
case <-ctx.Done():
return nil, ctx.Err()
}
这意味着:
等待 Permit
本身也是请求生命周期的一部分。
如果:
Client Cancel
Overall Timeout
先发生,就放弃排队。
因此 Semaphore 并不是:
请求来了就一定等到有位置为止。
而是:
在请求自己的时间预算仍然有效时等待。
这其实是一种 Backpressure
假设上游同时只能处理:
50
而瞬间来了:
100
如果没有并发控制:
100 个请求
↓
全部立刻压给 Provider
有了 Semaphore:
前 50 个
→ 执行
后 50 个
→ 等待
下游处理完成后:
Permit 逐渐释放
等待的请求再继续进入。
这相当于告诉调用链:
下游已经没有容量了,前面的流量不能无限制继续向后推进。
这种现象可以理解为:
Backpressure,背压。
但这里需要特别说明:
ModelGate 当前的 Semaphore 实现不是一个完整的:
有界请求队列系统
它只是让等待中的 goroutine 阻塞在:
Acquire(ctx)
上。
Semaphore 限制了什么,又没有限制什么
假设:
ProviderMaxConcurrency = 50
它能够保证在单个 ModelGate 实例中:
同时持有该 Provider Permit 的逻辑请求
<= 50
但它不能保证:
整个服务最多只有 50 个请求
因为第 51、52、53……个请求仍然可能已经进入 Gateway。
只是它们停在:
Acquire
等待。
所以可能出现:
50 个正在执行 Provider
1000 个 goroutine
正在等待 Permit
Semaphore 控制的是:
active concurrency
而不是:
total admitted requests
这一点很重要。
如果流量无限增长,仅仅依赖 Semaphore 并不能让整个服务获得无限保护。
等待中的请求仍然可能占用:
goroutine
HTTP 连接
请求对象
Context
内存
当前 ModelGate 依靠:
请求 Overall Timeout
+
Client Cancellation
来限制这些等待不会无限存在。
但它没有单独实现一个固定长度的:
waiting queue
例如:
最多排队 100
超过以后立即 503
所以更准确地说:
当前 Semaphore 为 Provider 调用提供了有界执行并发,并通过等待形成背压,但没有为等待队列本身再设置独立容量。
为什么“goroutine 很轻量”仍然不能无限开
goroutine 相比系统线程确实轻量。
但:
轻量
不等于:
没有成本
更重要的是,ModelGate 的并发成本也不只有 goroutine。
一次外部 HTTP 请求还会涉及:
TCP / TLS 连接
HTTP Transport
Response Body
Buffer
JSON / SSE 解析状态
Context
Timer
Provider 自身并发配额
所以即使 Go Runtime 能调度非常多 goroutine,也不能据此推出:
下游 API 就应该同时接收非常多请求。
这其实是两个层次。
Go 的 goroutine 回答:
我怎样高效地组织大量并发任务?
Semaphore 回答:
我现在允许多少任务同时进入某个受限资源?
前者是:
并发执行模型
后者是:
资源治理策略
为什么这里用 Semaphore,而不是 Rate Limiter
这是我觉得面试特别容易追问的一点。
ModelGate 其实两个都有:
Redis Token Bucket Rate Limiter
和:
Provider Semaphore
看起来都在:
限制请求。
但限制的是完全不同的东西。
Rate Limiter 控制进入速度
ModelGate 的 Redis Token Bucket 默认配置:
RATE_LIMIT_REQUESTS_PER_MINUTE = 60
RATE_LIMIT_BURST = 60
它按:
ClientIdentity
计算限流 Key。
关注的是:
这个客户端在一段时间内发了多少请求?
例如:
第 1 秒:
60 个请求
可能快速消耗 Token。
后续如果没有足够 Token:
429 Too Many Requests
所以 Rate Limiter 更接近:
单位时间的请求频率
Semaphore 控制同时占用量
Semaphore 不关心:
一分钟来了多少请求
它只看:
此刻还有多少请求没有结束?
比如两个场景。
场景 A
每秒 100 个请求
每个请求 1ms
虽然 Rate 很高,但同时执行数量可能不高。
场景 B
每秒 5 个请求
每个请求持续 60 秒
一分钟后理论上可能累积:
大量 in-flight Stream
虽然请求速率没有那么高。
所以:
Rate Limit
→ 控制速度
Semaphore
→ 控制占用
不能互相替代。
用 Token Bucket 代替 Semaphore 会发生什么
假设设置:
60 requests / minute
但这 60 个请求恰好在一分钟开始时同时进入。
Token Bucket 允许 Burst:
60
于是:
60 个请求
仍然可能同时打到 Provider。
如果 Provider 的稳定并发只有:
10
Rate Limit 并没有保护它。
反过来,把 Semaphore 设置成:
10
也不能阻止某个客户端:
不断提交新请求
只要前面的请求很快结束,它就能持续获得新的 Permit。
因此:
Semaphore
也不是频率限制。
这就是为什么 ModelGate 同时需要两层治理。
为什么 Rate Limiter 放在 Gateway,而 Semaphore 放在 Provider
当前链路大致是:
Client
↓
Gateway Rate Limit
↓
Idempotency
↓
Cache
↓
Router
↓
Selected Provider
↓
Provider Semaphore
↓
Circuit Breaker
↓
Retry
↓
Upstream
Rate Limiter 面向的是:
ClientIdentity
它控制:
某个调用方可以多快地访问整个 Gateway。
Semaphore 面向的是:
Provider
它控制:
这个具体下游最多允许多少个逻辑调用同时存在。
一个是:
入口治理
一个是:
依赖治理
位置也自然不一样。
Rate Limit 通常拒绝,Semaphore 当前选择等待
两者还有一个很直观的行为差异。
Rate Limiter 没 Token:
请求
↓
429
调用方需要以后再试。
而 Semaphore 没位置:
请求
↓
等待
只要在 Context 到期之前有 Permit 释放,就继续执行。
所以:
Rate Limit
→ admission rejection
Semaphore
→ admission waiting
至少在 ModelGate 当前实现里是这样。
这也是为什么不能只是看到:
“它们都能让请求变少”
就认为两者作用相同。
为什么 Semaphore 的 Permit 要保证只能 Release 一次
Permit 内部有:
once sync.Once
释放时:
p.once.Do(func() {
<-p.semaphore.slots
})
为什么需要?
假设一个请求意外:
Release
Release
如果两次都真正执行:
channel -2
第二次可能:
阻塞
或者错误地释放属于其他请求的额度。
流式请求尤其容易存在多条结束路径:
Recv → EOF
Recv → Error
Close
所以 managedStream 自己也有:
once sync.Once
Semaphore Permit 又单独有:
once sync.Once
这样即使上层清理路径重复:
一个 Permit 也最多只会真正归还一次。
这是典型的资源生命周期保护。
为什么 Semaphore 应该按“资源所有权”释放
非流式请求:
defer concurrencyPermit.Release()
很自然。
因为函数返回时,请求肯定不再使用 Provider。
但流式请求不能这么写:
func ChatStream(...) (Stream, error) {
permit, _ := Acquire(...)
defer permit.Release()
stream, _ := provider.ChatStream(...)
return stream, nil
}
因为函数一返回:
defer 执行
→ Permit Release
可 Stream 明明还在外面继续:
Recv()
这就是典型的:
创建资源的函数返回了,但资源生命周期还没有结束。
所以 Permit 必须转移给:
managedStream
由真正知道 Stream 什么时候结束的对象负责释放。
这和之前 SSE 文章里提到的:
谁创建 Stream?
谁读取?
谁关闭?
其实是同一个资源所有权问题。
Semaphore 和连接池是不是一回事
它们也不完全一样。
HTTP Transport 可能本身存在:
连接池
例如复用 Keep-Alive 连接。
数据库也常见:
sql.DB.SetMaxOpenConns()
限制连接数。
这些机制控制的是:
底层连接资源
而 Provider Semaphore 控制的是:
逻辑请求并发
一条 HTTP/2 连接甚至可能承载多个并发 Stream。
所以:
连接数
也不等于:
请求并发数
即使 HTTP Client 自己有连接管理,也不能自动表达:
我只允许 50 条模型生成同时进行。
因此应用层仍然需要自己的业务并发预算。
Semaphore 和 Worker Pool 又有什么区别
另一个常见选择是:
固定 50 个 Worker
请求先放进:
Task Queue
然后:
Worker 1
Worker 2
...
Worker 50
不断取任务。
这也可以实现:
最多 50 个同时执行
但系统结构会变成:
请求
↓
显式任务队列
↓
Worker Pool
↓
Provider
ModelGate 的请求本身是同步 HTTP / SSE 调用。
客户端正在等待当前请求结果。
所以当前实现没有额外建立一套:
内部任务队列 + Worker
而是让请求 goroutine 本身:
Acquire Permit
→ 执行
→ Release
Semaphore 更轻量,也更符合当前同步调用模型。
如果场景变成:
异步批处理
长任务排队
请求提交后立即返回 Job ID
Worker Pool / Queue 就可能更合适。
这也说明并发工具不能脱离业务模型来选。
为什么 Capacity 不能拍脑袋越大越好
当前默认:
ProviderMaxConcurrency = 50
配置还限制:
最大不能超过 10000
但这些并不意味着:
50 是生产环境最佳值。
真正应该考虑的是:
Provider 官方并发配额
平均请求耗时
流式请求占比
网关实例数
连接池
CPU / Memory
目标延迟
429 / 503 比例
例如单个 Provider 本身只允许:
20 concurrent requests
网关配置:
500
就没有意义。
反过来,如果上游能够稳定支持:
1000
而网关永远限制在:
10
又可能造成不必要的排队。
所以并发上限本质上应该是:
一个基于实际容量和 SLO 调整的运行参数。
当前项目的默认值只能说明:
系统具备可配置的 Provider 级并发边界
不能说明:
50 已经过真实生产负载验证为最佳值
一个很容易忽略的问题:这是单实例限制
ModelGate 为每个 Provider 创建的 Semaphore:
&Semaphore{
slots: make(chan struct{}, maxConcurrent),
}
只存在于当前 Go 进程内存中。
所以如果只有:
1 个 ModelGate 实例
设置:
ProviderMaxConcurrency = 50
那么这个实例最多同时放 50 个逻辑请求进入该 Provider。
但如果部署:
3 个 ModelGate 实例
每个都是:
50
理论上的总并发上限就可能接近:
3 × 50 = 150
因为三个进程之间:
Semaphore A
Semaphore B
Semaphore C
并不共享状态。
这和 Redis Rate Limiter 不一样。
Rate Limiter 的状态放在:
Redis
多个网关实例可以看到同一份 Token Bucket。
而 Semaphore 是:
Process-local
所以它保证的是:
单实例、单 Provider 的并发上限。
如果未来需要:
整个集群对某个 Provider 的总并发严格不超过 50,
就需要进一步设计:
分布式并发协调
集中式配额
Provider 自身限额
或者按实例分配额度
当前 ModelGate 没有实现集群级分布式 Semaphore。
这一点如果面试官追问,我觉得应该明确说出来。
多实例时最简单的思路是什么
假设 Provider 总共允许:
100 并发
部署:
4 个 ModelGate 实例
一种简单方法是每个实例:
25
大致控制:
4 × 25 = 100
但它有明显限制。
如果某个实例流量很少:
只用了 5 个额度
其他实例也不能借它剩下的 20。
如果实例数量动态扩缩容:
4 → 8
每实例额度还要重新计算。
所以:
静态拆额度
简单,但利用率不一定最佳。
更复杂的系统可以进一步做全局配额协调。
不过 ModelGate 当前没有做到这一步,因此博客里也不应该把本地 Semaphore 描述成:
全局分布式并发限制
Circuit Breaker OPEN 时 Semaphore 发生了什么
当前 Chat() 顺序是:
Acquire Semaphore
↓
Circuit Breaker Allow
也就是说:
请求会先拿 Permit,再检查 Breaker。
如果 Breaker 已经 OPEN:
circuitPermit, err :=
p.breaker.Allow()
if err != nil {
return nil, err
}
因为非流式请求已经:
defer concurrencyPermit.Release()
函数返回后 Permit 会马上归还。
流式路径也会显式:
concurrencyPermit.Release()
所以 OPEN 状态不会把额度泄漏掉。
另一种设计也可以:
先检查 Circuit Breaker
再 Acquire Semaphore
这样已经熔断的请求可以更早退出。
但当前 ModelGate 的源码顺序就是:
Semaphore
→ Circuit Breaker
→ Retry
所以我不会把它描述成另一种实现。
为什么需要 Benchmark,但 Benchmark 不能证明吞吐量
项目还为 Semaphore 写了:
BenchmarkProviderConcurrencyLimiter
以及:
BenchmarkProviderConcurrencyLimiterParallel
测试:
Acquire
→ Release
在串行和并行情况下的基础开销。
这能帮助观察:
这层同步原语本身有没有明显异常开销。
但它不能证明:
ModelGate 支持多少 QPS
因为真实 Gateway 性能还受到:
HTTP
JSON
Redis
PostgreSQL
Provider 延迟
SSE
Retry
网络
大量因素影响。
Semaphore Benchmark 测的只是:
这个小组件
而不是:
完整系统吞吐能力
这和 LabReserve 里:
30 人抢 2 个名额
不能被解释成性能压测,是一样的道理。
如果 10 倍流量来了,Semaphore 能保护什么
假设平时:
10 requests/s
突然变成:
100 requests/s
Semaphore 至少能保证:
单个实例不会因为入口流量上涨,就无限提高某个 Provider 的同时执行数。
例如:
ProviderMaxConcurrency = 50
那么:
最多 50
→ 真正进入 Provider Governance 调用
剩余请求
→ 等待 Permit
这能保护:
上游 Provider
也限制当前实例正在占用的上游逻辑调用数。
但如果 10 倍流量持续很久:
请求进入速度
>
Provider 完成速度
等待数量还是会越来越多。
Eventually:
请求 Overall Timeout
会让一部分等待请求失败。
所以 Semaphore 解决的是:
无限并发执行
而不是:
无限流量本身
真正处理持续过载还需要:
入口 Rate Limit
合理 Timeout
负载均衡
实例扩容
显式 Queue Limit / Overload Rejection
这些机制一起工作。
为什么有界并发本质上是在承认“容量有限”
我觉得这其实是整个问题最重要的地方。
如果代码没有 Semaphore,看起来似乎更“高并发”:
来多少请求
就开多少 goroutine
全部往下发
但这只是没有承认资源边界。
现实里:
CPU 有限
内存有限
连接有限
Provider 并发额度有限
数据库连接有限
第三方 API 容量有限
一个系统能够稳定工作,并不是因为:
它从不限制并发。
而是因为:
它知道哪些资源不能无限竞争,并且提前定义了边界。
所以:
有界并发
并不是在和:
高并发
对着干。
恰恰相反:
想让系统在高并发下仍然可控,往往首先需要把并发变成有界的。
怎样验证 Semaphore 的基本边界
ModelGate 的 Semaphore 单元测试把 Capacity 设置成:
1
第一次:
permit, err :=
semaphore.Acquire(
context.Background(),
)
成功后检查:
InFlight = 1
Capacity = 1
随后第二次 Acquire 使用:
context.WithTimeout(
context.Background(),
10*time.Millisecond,
)
因为没有空位,最终要求:
context.DeadlineExceeded
这验证:
Semaphore 满时不会突破容量,而是等待到 Context 结束。
之后:
permit.Release()
permit.Release()
故意 Release 两次。
最终必须仍然:
InFlight = 0
说明:
sync.Once
确实保证 Permit 只归还一次。
随后新的 Acquire 又能成功。
这条测试验证的主要不变量就是:
InFlight
永远不能因为重复释放而失真
面试时我会怎样回答
如果面试官问:
你这里为什么用 Semaphore,而不是限流器?
我会回答:
因为两者控制的维度不同。ModelGate 的 Redis Token Bucket 控制的是客户端请求速率,比如单位时间最多多少请求;Provider Semaphore 控制的是此刻同时有多少逻辑请求占用某个下游。
LLM 尤其是 SSE 请求持续时间可能很长,即使每秒请求数不高,也可能累积很多 in-flight 请求,所以只做 Rate Limit 不能保护 Provider 的并发容量。
ModelGate 为每个 Provider 建一个本地 Semaphore,默认容量 50。非流式请求从进入 Provider Governance 到 Retry 最终结束一直持有 Permit;流式请求则一直持有到 EOF、错误或 Close,不能在建立 Stream 后马上释放,否则长连接就不会被计入并发。
如果继续问:
Semaphore 满了以后怎么办?
我会回答:
当前实现不是立即拒绝,而是在
Acquire(ctx)等待。有 Permit 释放就继续;如果客户端取消或者请求 Overall Timeout 先到,就返回 Context Error。所以它通过等待形成背压。但当前并没有另外限制等待队列长度,因此 Semaphore 只保证执行中的 Provider 并发有界,不保证进入 Gateway 的总请求数有界。
如果继续问:
这是不是分布式并发限制?
我会回答:
不是。Semaphore 是 Go 进程内基于 buffered channel 实现的,所以限制的是单个 ModelGate 实例对单个 Provider 的并发。如果部署三个实例,每个设置 50,集群理论总并发可能接近 150。要做全局 Provider 并发配额,还需要额外的跨实例协调或者按实例分配额度。
如果继续问:
goroutine 已经很轻量了,为什么还要限制?
我会回答:
限制的不是 goroutine 本身,而是下游资源。一个模型请求还占连接、响应 Body、Provider 并发额度和较长的执行时间。goroutine 能高效承载并发,不代表外部依赖拥有无限容量。
我从这个问题中理解到的事情
刚开始学习 Go 并发时,很容易把:
goroutine
channel
当作高并发本身。
比如:
go func() {
callProvider()
}()
写起来很容易,就会让人觉得:
同时运行得越多越厉害。
但做 ModelGate 之后,我觉得真正的服务端并发问题更像是在问:
哪些事情可以同时发生?
哪些资源只能同时承受有限数量?
多出来的请求是等待、拒绝还是排队?
等待多久以后应该放弃?
请求取消后怎样退出等待?
长连接什么时候才算真正释放资源?
多个实例之间的并发额度是不是共享的?
ModelGate 当前这层关系可以概括成:
goroutine
→ 让多个请求可以并发执行
Provider Semaphore
→ 限制单实例对单 Provider 的 in-flight 调用
Acquire(ctx)
→ 没容量时等待,同时响应取消和超时
Permit
→ 表示一次并发资源所有权
sync.Once
→ 防止重复释放
managedStream
→ 让 SSE 在完整生命周期内持续占用 Permit
Rate Limiter
→ 控制客户端请求速率,而不是同时执行数量
Overall Timeout
→ 防止等待 Permit 的请求无限存活
而我觉得最值得记住的区别是:
Rate Limiter
回答:
“你来得是不是太快?”
Semaphore
回答:
“现在里面是不是已经太多人了?”
一个是:
速度
一个是:
占用
两者都叫“限制请求”,但处理的是两个完全不同的维度。
最后还有一个我之前不太容易意识到的点:
高并发系统并不是尽可能让所有工作同时执行,而是让有限资源在高并发输入下仍然保持可控。
goroutine 让“同时处理很多事情”变得容易。
Semaphore 做的则是另一件同样重要的事:
在真正有限的资源前面,明确告诉系统:到这里为止。
延伸阅读
- Go Documentation:Channels
- Go Documentation:Package context
- Go Blog:Pipelines and cancellation
- Go Documentation:sync.Once