Rust Async 的 Cancellation Safety:避免半提交状态

关键事实 Future 在 .await 点可能被取消。若状态更新分布在多个 await 之间,就可能出现“写了一半”的业务状态。 设计原则 把副作用集中在单一提交点。 在可取消区间只做纯计算或幂等准备。 对外部系统写入使用幂等键。 反例与修正 // 反例:先扣库存再写订单,两个 await 中间可被取消 reserve_stock().await?; create_order().await?; // 修正:准备阶段无副作用,最后一次性提交 let plan = build_plan().await?; commit(plan).await?; 工程策略 为关键流程增加“中断注入测试”。 对每个 await 标注取消后的状态语义。 引入补偿任务清理孤儿状态。 小结 异步取消是默认行为,不是异常路径。把 cancellation safety 当作接口契约的一部分,才能避免线上出现“偶发且不可复现”的脏状态。

2026年5月5日 · 1 分钟 · BvBeJ

Rust Tokio 取消安全:避免半完成状态

背景 Tokio 里 select! 和超时很常用,但取消发生在任意 await 点,任务可能停在中间状态。 实践建议 把副作用操作放在不可分割阶段 写操作尽量幂等 关键路径加补偿或重试机制 tokio::select! { _ = shutdown.recv() => { tracing::info!("cancelled"); } res = do_commit_work() => { res?; } } 总结 取消安全本质是状态机设计,不是语法问题。 异步代码能停下来不难,停得干净才难。

2026年5月1日 · 1 分钟 · BvBeJ

Rust 无锁结构中的内存回收:Epoch 与 Hazard Pointer 对比

先澄清核心矛盾 无锁链表里,线程 A 可能刚把节点从链表摘下,线程 B 还在读取它。此时直接 drop 会造成悬垂引用。 两类主流方案 Epoch Based Reclamation(EBR) 线程进入临界区时 pin 当前 epoch。 删除节点先放入 retired 列表。 只有当所有线程都跨过该 epoch,节点才可释放。 Hazard Pointer(HP) 读取前先声明“我正在看这个指针”。 回收线程扫描所有 hazard slot。 未被保护的 retired 节点才能释放。 EBR 的工程特性 吞吐通常更高,读路径开销小。 线程暂停会拖慢全局回收进度。 更适合短临界区、线程活跃的服务。 HP 的工程特性 回收更精细,不容易被慢线程拖住。 读路径需要发布 hazard,CPU 开销更高。 更适合线程生命周期不可控的系统。 Rust 落地建议 // 伪代码:删除节点不立即释放,而是 retire fn remove(node: Shared<Node>, guard: &Guard) { if cas_unlink(node, guard) { guard.defer_destroy(node); // 交给回收器延迟释放 } } 使用经过验证的库(如 crossbeam)而不是手搓回收器。 把“最大 retired 数量”做成指标,防止隐性内存膨胀。 压测时加入 SIGSTOP 慢线程场景,验证回收鲁棒性。 小结 锁不一定是瓶颈,错误回收一定是灾难。无锁结构上线前,先证明回收策略在“慢线程、抖动、长尾”下仍可控。

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

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