Go Worker Pool:饱和控制与降级策略

背景 很多 Go 服务都会做 worker pool,但只做“固定 worker 数量”还不够。 真正决定稳定性的,是池子打满后怎么办。 基础模型 type Job func(context.Context) error type Pool struct { jobs chan Job } func NewPool(workerN, queueN int) *Pool { p := &Pool{jobs: make(chan Job, queueN)} for i := 0; i < workerN; i++ { go func() { for job := range p.jobs { _ = job(context.Background()) } }() } return p } 关键点:拒绝策略 func (p *Pool) TrySubmit(job Job) error { select { case p.jobs <- job: return nil default: return errors.New("worker pool saturated") } } 当队列已满时,快速失败通常比无限排队更可控。 总结 worker pool 的目标不是“永不拒绝”,而是让系统在高峰时有可预测行为。 配合监控和限流,才能构成完整保护链路。 可用系统的关键是边界清晰,而不是无限扛压。

2026年4月27日 · 1 分钟 · BvBeJ

Go Worker Pool 与背压:不靠拍脑袋定并发数

为什么会积压 很多服务写了 worker pool,但上线后仍然: 高峰时队列暴涨 平均延迟还行,P99 很差 CPU 没打满却开始超时 这通常是“入队速率 > 出队速率”的背压问题。 基础模型 type Job struct { ID string } func startPool(ctx context.Context, n int, jobs <-chan Job) { for i := 0; i < n; i++ { go func() { for { select { case <-ctx.Done(): return case job, ok := <-jobs: if !ok { return } handle(job) } } }() } } 结构不复杂,难点在参数选择。 三个观测指标 吞吐:每秒处理多少请求 延迟:P50/P95/P99 队列深度:channel 长度趋势 如果队列长期接近上限,说明处理能力不足或外部依赖抖动。 调优顺序 固定业务流量,先找单 worker 处理能力 逐步增加 worker,观察 P99 与 CPU 变化 达到拐点后停止扩容,避免锁竞争和上下文切换过量 设置拒绝策略,不让队列无限增长 一个实用策略 快速失败:队列满时直接返回可重试错误 分级队列:高优先级任务单独通道 限时执行:每个任务绑定 context 超时 总结 worker pool 不是“越大越好”。并发数本质是资源预算,必须和外部依赖能力、延迟目标一起设计。

2026年4月20日 · 1 分钟 · BvBeJ

Go Context 取消链路:别让 goroutine 泄漏

为什么这个问题总会出现 线上服务一旦有重试、聚合查询、异步回调,就很容易出现 goroutine 泄漏: 上游请求已经结束,下游协程还在跑 超时只控制了入口,没有传到内部依赖 背景任务没有退出信号 context.Context 不是万能药,但它是 Go 服务里最基础的生命周期约束。 三条硬规则 请求入口创建 context,内部只传递不重建 外部依赖调用必须接收 context 子协程要么监听 ctx.Done(),要么有明确退出条件 一个最小可用结构 func handler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond) defer cancel() user, err := userSvc.Get(ctx, "u-1001") if err != nil { http.Error(w, err.Error(), http.StatusGatewayTimeout) return } _ = json.NewEncoder(w).Encode(user) } 关键点在于:超时预算从入口建立,并传给每一层。 扇出场景的取消联动 func aggregate(ctx context.Context, id string) (Profile, error) { g, ctx := errgroup.WithContext(ctx) var base BaseInfo var score CreditScore g.Go(func() error { v, err := queryBase(ctx, id) if err != nil { return err } base = v return nil }) g.Go(func() error { v, err := queryScore(ctx, id) if err != nil { return err } score = v return nil }) if err := g.Wait(); err != nil { return Profile{}, err } return Profile{Base: base, Score: score}, nil } errgroup.WithContext 的价值是:任一分支失败,其他分支自动收到取消信号。 ...

2026年4月19日 · 1 分钟 · BvBeJ

C++ 无锁队列:从 CAS 到内存序

背景 只要做过高并发服务、游戏引擎或者低延迟组件,迟早会碰到一个问题:锁太重了。 典型场景包括: 生产者线程持续推消息 消费者线程高频拉取任务 临界区很短,但锁竞争很激烈 延迟指标对尾部抖动非常敏感 这时候很多人第一反应是“上无锁队列”。 方向没错,但无锁代码最危险的地方在于:看起来能跑,不代表一定正确。 尤其在 C++ 里,只会用 compare_exchange_weak 还不够,真正决定正确性的往往是内存序。 无锁不等于没有同步 先澄清一个常见误区: mutex 是同步 原子变量也是同步 无锁结构只是把同步方式从“阻塞锁”换成了“原子操作 + 内存可见性约束”。 也就是说,你不是不需要同步了,而是需要更精确地控制同步。 一个最简单的 SPSC 环形队列 先从单生产者、单消费者模型说起。这个模型更适合作为理解内存序的起点。 #include <atomic> #include <array> #include <cstddef> template <typename T, std::size_t N> class SpscQueue { public: bool push(const T& value) { const auto tail = tail_.load(std::memory_order_relaxed); const auto next = (tail + 1) % N; if (next == head_.load(std::memory_order_acquire)) { return false; } buffer_[tail] = value; tail_.store(next, std::memory_order_release); return true; } bool pop(T& value) { const auto head = head_.load(std::memory_order_relaxed); if (head == tail_.load(std::memory_order_acquire)) { return false; } value = buffer_[head]; head_.store((head + 1) % N, std::memory_order_release); return true; } private: std::array<T, N> buffer_{}; std::atomic<std::size_t> head_{0}; std::atomic<std::size_t> tail_{0}; }; 这个实现不复杂,但已经体现了两个关键点: ...

2026年4月16日 · 2 分钟 · BvBeJ

Rust Tokio 背压控制:异步系统别只会拼命 spawn

背景 刚开始写 Tokio 程序时,很多人都会觉得异步特别轻: 一个请求一个 task 来一个任务就 tokio::spawn channel 一接就处理 代码看起来很流畅,吞吐也不错。 但一到高负载场景,问题很快就出来了: 任务堆积越来越多 内存不断上涨 下游数据库或 HTTP 依赖被打爆 延迟从毫秒飙到秒级 这时候根问题通常不是 Tokio 不够快,而是系统没有背压。 什么是背压 背压的本质是:当下游处理不过来时,上游必须感知并减速。 如果没有这层机制,异步系统就很容易变成“把问题排队排到内存里”。 一个最典型的错误写法: loop { let job = accept_job().await; tokio::spawn(async move { process_job(job).await; }); } 这段代码的意思其实是: 来多少任务都收 能不能处理完以后再说 如果生产速度持续高于消费速度,系统一定会失控。 最简单的背压:有界 channel 相比无脑 spawn,更稳妥的起点通常是 bounded channel。 use tokio::sync::mpsc; #[tokio::main] async fn main() { let (tx, mut rx) = mpsc::channel::<Job>(1024); tokio::spawn(async move { while let Some(job) = rx.recv().await { process_job(job).await; } }); loop { let job = accept_job().await; if tx.send(job).await.is_err() { break; } } } struct Job; async fn accept_job() -> Job { Job } async fn process_job(_job: Job) {} mpsc::channel(1024) 的关键不是“1024 这个数字”,而是它有上限。 ...

2026年4月16日 · 2 分钟 · BvBeJ