返回文章列表

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