feat(channelbridge): 群聊静默窗口合并与回合中续跑(M23)

主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」
此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答
(批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。

四条不可走样:

- 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器
  工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定,
  配置无权翻转;
- 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点
  (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前;
- 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复;
- 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom),
  被静默的消息进不了批。

实现落点:
- lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记;
- coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数;
- coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车;
- coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch;
- runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键);
- gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 +
  writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批);
- 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。

M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条
按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。

回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build
+ 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
This commit is contained in:
AskaEth committed 2026-10-24 21:11:22 +08:00
1 parent 679c8abf1d
commit ca8577f05b
21 files changed
+3616 -244

No files matched your search

+241 -37
View File
@@ -98,11 +98,43 @@ type laneItem struct {
//
// neverDrop 只对**主人私聊**车道为真:它满了不拒收,条目改落 spill(溢出区,只排队)。
// spill 恒为空的车道因此不占额外内存。
//
// 车道分**两种类型**(M23 的红线级结构约束,见下面两个构造函数):
// - **direct 车道**(主人私聊):到达即投递、一条一条跑。它**没有** batcher 字段值,
// 因此拿不到定时器工厂 —— 「主人私聊没有静默窗口」是**类型上不可达**,
// 不是配置判出来的(配置无权翻转,见 newDirectLane)。
// - **batch 车道**(群聊 / 陌生人私聊):持有一个 batchWindow,由窗口引擎决定何时发车。
type lane struct {
key string
ch chan laneItem
neverDrop bool
spill []laneItem
// front 是「回插队首」区:单批超过上限时,尾部**原样保序**地插回这里,
// 成为下一批(M23 蓝图 §4-3:超量不是丢弃)。front 里的条目一定早于
// ch 与 spill 里的,因此读取顺序恒为 front → ch → spill。
front []laneItem
// batcher 非 nil ⇒ 这是一条 batch 车道(持有静默窗口的定时器工厂)。
// nil ⇒ direct 车道:窗口/定时器对它是**不可达类型**。
batcher *batchWindow
// lastTurnEnd 是该车道**上一轮回合结束**的时刻(只有 worker 读写 ⇒ 无需加锁)。
// 它只服务 continuations_total:批内首条在上一轮结束前就已到达 ⇒ 这批是⑤续跑。
lastTurnEnd time.Time
}
// newDirectLane 构造**主人私聊**的 direct 车道:它没有任何窗口状态,也不持有定时器
// 工厂(不传 batchWindow ⇒ 拿不到)。
//
// 🔴 车道类型由 **actor 元数据**(`IsOwner() && !IsGroupChat()`)决定,配置无权翻转:
// 唯一能选 direct 的地方是 enqueueInbound 的 ownerPrivateLane 判定。主人私聊是
// 「她和我说话」,2s 迟滞在私聊里没有任何收益(只有被拖慢的观感),
// 且 M22 已裁定主人私聊永不丢弃、不受陈旧度闸约束 —— 本函数是同一取向的延续。
func newDirectLane(key string, ch chan laneItem, neverDrop bool) *lane {
return &lane{key: key, ch: ch, neverDrop: neverDrop}
}
// newBatchLane 构造**群聊 / 陌生人私聊**的 batch 车道:窗口引擎只在这里可达。
func newBatchLane(key string, ch chan laneItem, neverDrop bool, batcher *batchWindow) *lane {
return &lane{key: key, ch: ch, neverDrop: neverDrop, batcher: batcher}
}
// laneSet 是全部活跃车道的注册表。车道在**空闲时退休**(worker 见队列空即把自己摘掉),
@@ -111,6 +143,9 @@ type laneSet struct {
depth int
staleAfter time.Duration
log *slog.Logger
// after 是静默窗口的定时器工厂(M23):**只**在构造 batch 车道时被塞进
// batchWindow,direct 车道拿不到它(见 newDirectLane)。
after func(time.Duration) <-chan time.Time
mu sync.Mutex
lanes map[string]*lane
@@ -118,14 +153,30 @@ type laneSet struct {
droppedOverflow int64
}
// newLaneSet 构造车道注册表;depth / staleAfter 为 0 时取上面的默认值。
func newLaneSet(depth int, staleAfter time.Duration, log *slog.Logger) *laneSet {
// laneSpec 描述「这条车道该长什么样」。类型(direct / batch)由**actor 元数据**决定,
// 调用方(enqueueInbound)算好传进来;配置只能影响窗口参数,改不了类型。
type laneSpec struct {
// neverDrop ⇒ 主人私聊:队列满时不拒收,落 spill 只排队。
neverDrop bool
// batch ⇒ 群聊/陌生人私聊:持有静默窗口(direct 车道不持有)。
batch bool
// capacity 是队列深度:batch 车道至少能装下一整批(否则「12 条成 1 轮」在
// 结构上不可达 —— 第 9 条就会在门前被拒收)。
capacity int
}
// newLaneSet 构造车道注册表;depth / staleAfter 为 0 时取上面的默认值,
// after 为 nil 时取 time.After。
func newLaneSet(depth int, staleAfter time.Duration, after func(time.Duration) <-chan time.Time, log *slog.Logger) *laneSet {
if depth <= 0 {
depth = defaultLaneDepth
}
if staleAfter <= 0 {
staleAfter = defaultLaneStaleAfter
}
if after == nil {
after = time.After
}
if log == nil {
log = logger.New(logger.Options{})
}
@@ -133,6 +184,7 @@ func newLaneSet(depth int, staleAfter time.Duration, log *slog.Logger) *laneSet
depth: depth,
staleAfter: staleAfter,
log: logger.WithSource(log, laneLogSource),
after: after,
lanes: make(map[string]*lane),
}
}
@@ -178,14 +230,13 @@ func (b *Bridge) LaneStats() LaneStats {
// enqueueInbound 把一条**已过流控闸**的入站消息投进它所属会话的车道,并立刻返回 ——
// 这是「读与回水解耦」的全部:适配器的读循环调用它之后马上可以继续读 socket。
//
// 🔴 这就是留给下一片(M23 静默窗口合并)的**入队 seam**:入参是一条**已过闸门的
// prepared item**(Validate/ResolveActor/Admit 都已在本函数的调用方 onMessage 里做完,
// 连同解析结果 actor 一起带进来)。车窗车道头部(laneWorker)只做「等待 + 发车」,
// 绝不在这里做准入判断 —— M23 要把 processFrom 的闸门段再往前抽成 prepareInbound 时,
// 只需在调用方一侧重排,车道本身不用动。
// 🔴 这就是 M23 静默窗口合并的**入队 seam**:入参是一条**已过闸门的 prepared item**
// (Validate/ResolveActor/Admit 都已在本函数的调用方 onMessage 里做完,连同解析结果
// actor 一起带进来)。车道头部只做「等(窗口)+ 发车」,准入判断不在这里。
//
// 三件事在这里一次性做完:
// - 认领车道(没有就建一条并起 worker);
// - 认领车道(没有就建一条并起 worker)—— **类型由 actor 元数据决定**:
// 主人私聊 ⇒ direct(无窗口),其余 ⇒ batch(有窗口);
// - 入队(群聊/陌生人满则**在门前拒收这一条**并留痕;主人私聊**永不拒收**);
// - 与 Stop 的 `wg.Wait` 对齐:`b.wg.Add` 与 `closed` 的判定在同一把 `b.mu` 下,
// 因此 Stop 要么看见这个工作项、要么我们看见 closed 而不入队,二者必居其一。
@@ -200,6 +251,7 @@ func (b *Bridge) enqueueInbound(ctx context.Context, msg adapter.UnifiedMessage,
// 主人私聊(私聊 + 主人在场,由 actor 元数据 + 白名单结构化判定)**永不丢弃**:
// 队列满时落到溢出区只排队(主人 2026-10-24 裁定:「宁可晚回,不许不回」)。
neverDrop := ownerPrivateLane(msg, actor)
spec := b.laneSpecFor(key, item, neverDrop)
b.mu.Lock()
if b.closed {
@@ -208,7 +260,7 @@ func (b *Bridge) enqueueInbound(ctx context.Context, msg adapter.UnifiedMessage,
"platform", msg.Platform, "channel", msg.ChannelID)
return
}
l, start, rejected := b.lanes.offer(key, item, neverDrop)
l, start, rejected := b.lanes.offer(key, item, spec)
if start {
b.wg.Add(1)
}
@@ -222,6 +274,24 @@ func (b *Bridge) enqueueInbound(ctx context.Context, msg adapter.UnifiedMessage,
}
}
// laneSpecFor 决定一条车道的形状:**类型只由 actor 元数据决定**
// (`IsOwner() && !IsGroupChat()` ⇒ direct),窗口参数只影响容量。
//
// 容量:batch 车道至少要能装下一整批,否则「一批 N 条」在结构上不可达
// (队列深度 8 时第 9 条就会在门前被拒收,合并就成了摆设)。因此
// capacity = max(默认深度, 生效的单批上限);direct 车道用默认深度。
// 内存口径因此是「活跃会话数 × 批上限」,而批上限本身被 clamp 在 [1,50]。
func (b *Bridge) laneSpecFor(key string, item laneItem, neverDrop bool) laneSpec {
spec := laneSpec{neverDrop: neverDrop, batch: !neverDrop, capacity: b.lanes.depth}
if neverDrop {
return spec
}
if w, ok := b.coalesceWindowFor(key, laneStrangerPrivate(item)); ok && w.maxBatch > spec.capacity {
spec.capacity = w.maxBatch
}
return spec
}
// offer 在**一个临界区**内完成「查/建车道 + 入队」,使 `retireLaneIfIdle` 不可能夹在
// 中间把刚查到、还没写入的车道摘掉(那样条目会落进一条没有 worker 的死车道)。
//
@@ -230,19 +300,31 @@ func (b *Bridge) enqueueInbound(ctx context.Context, msg adapter.UnifiedMessage,
// (ownerLaneBacklogWarn 及其翻倍)留 WARN —— 一个白名单账号的积压只反映「她落后了」,
// 不构成第三方洪泛面;
// - **群聊 / 陌生人**:**在门前拒收这一条**(而不是挤掉队列里已有的某条)。队列里
// 的条目因此一条都不会凭空消失 —— 下一片 M23 的 takeAll 依赖这一点;被拒收的那条
// 的条目因此一条都不会凭空消失 —— 窗口的 takeAll 依赖这一点;被拒收的那条
// 由调用方留痕(WARN + 计数 + 审计)。
//
// 🔴 **FIFO 的关键**:`spill` 一旦非空,后面的条目**一律继续追加到 spill**,不再回写
// `ch`。否则「ch 腾出一个位置 + 此时正好有新条目到达」会让后到者超车(复审实测顺序
// `[m1 m2 m3 m5 m4]`)。`nextLaneItem` 因此可以安全地「先取完 ch、再按序取 spill」:
// spill 里的一定晚于 ch 里的,而 spill 一旦开始就用到底(直到它被取空)。
func (s *laneSet) offer(key string, item laneItem, neverDrop bool) (l *lane, start bool, rejected bool) {
//
// 同理,`front`(回插队首区)里的一定**早于** ch 与 spill 里的(只有 worker 自己
// 在发车前把超量的尾部放进去),所以读取顺序恒为 front → ch → spill。
func (s *laneSet) offer(key string, item laneItem, spec laneSpec) (l *lane, start bool, rejected bool) {
s.mu.Lock()
defer s.mu.Unlock()
l = s.lanes[key]
if l == nil {
l = &lane{key: key, ch: make(chan laneItem, s.depth), neverDrop: neverDrop}
capacity := spec.capacity
if capacity <= 0 {
capacity = s.depth
}
ch := make(chan laneItem, capacity)
if spec.batch {
l = newBatchLane(key, ch, spec.neverDrop, &batchWindow{after: s.after})
} else {
l = newDirectLane(key, ch, spec.neverDrop)
}
s.lanes[key] = l
start = true
}
@@ -284,9 +366,18 @@ func ownerPrivateLane(msg adapter.UnifiedMessage, actor adapter.Actor) bool {
return !msg.IsGroup() && actor.IsOwner()
}
// nextLaneItem 按 FIFO 取下一条:先把缓冲区取完,再取主人私聊的溢出区(溢出区里的条目
// 一定晚于缓冲区里的,所以顺序仍然是到达顺序)。ok=false 表示此刻真的没有待办。
// nextLaneItem 按 FIFO 取下一条:先取回插队首区(front),再把缓冲区取完,最后取
// 主人私聊的溢出区(三者的先后由 offer / requeueFront 的写入口径保证)。
// ok=false 表示此刻真的没有待办。
func (b *Bridge) nextLaneItem(l *lane) (laneItem, bool) {
b.lanes.mu.Lock()
if len(l.front) > 0 {
item := l.front[0]
l.front = l.front[1:]
b.lanes.mu.Unlock()
return item, true
}
b.lanes.mu.Unlock()
select {
case item := <-l.ch:
return item, true
@@ -303,13 +394,68 @@ func (b *Bridge) nextLaneItem(l *lane) (laneItem, bool) {
return item, true
}
// takeAll 把车道上**此刻**排着的条目整片取出(顺序 = 到达顺序),供静默窗口的
// 批组装使用。它是合并阶段**唯一**的取队列入口(M23 蓝图 §4-1):
//
// - 只有车道的单消费者(该车道唯一的 worker)调用它 ⇒ 不存在两个消费者各抓一半;
// - 取出即「这批就是这些」,等待期间新到的继续落进队列,下一轮 takeAll 再取;
// - 它**不**做任何筛选:不丢、不重排、不改内容(窗口内零丢失的结构保证)。
func (b *Bridge) takeAll(l *lane) []laneItem {
b.lanes.mu.Lock()
out := l.front
l.front = nil
b.lanes.mu.Unlock()
for {
select {
case item := <-l.ch:
out = append(out, item)
continue
default:
}
b.lanes.mu.Lock()
if len(l.spill) > 0 {
out = append(out, l.spill...)
l.spill = nil
}
b.lanes.mu.Unlock()
return out
}
}
// requeueFront 把一批**尚未发车**的条目按原顺序插回队首,成为下一批。
//
// 它是「单批上限不是丢弃」的落点(M23 蓝图 §4-3):超量的**尾部**原样回插、保序、
// 在锁内、在发车之前。回插到 front 而不是写 ch:ch 有界且写满即拒收,
// 而 front 无界但只由 worker 在发车前写入,写入量与「刚拆出来的尾部」相等。
func (b *Bridge) requeueFront(l *lane, items []laneItem) {
if len(items) == 0 {
return
}
b.lanes.mu.Lock()
merged := make([]laneItem, 0, len(items)+len(l.front))
merged = append(merged, items...)
merged = append(merged, l.front...)
l.front = merged
b.lanes.mu.Unlock()
}
// laneWorker 是某会话车道的唯一执行者:串行跑完每一条,队列空了就把车道交还注册表
// (空闲车道不占内存,也不会把「这个人再说话」变慢)。
//
// **回合中续跑(⑤)就落在这里**:不存在任何 "turn in flight" 标志 —— 「回合在跑」
// 在结构上就等于「worker 正忙」,跑回合期间新消息落进同一个队列,回合结束回到循环头,
// takeAll 自然把这段期间攒下的消息整片取出 ⇒ **一轮续跑**(不是 N 轮,也不是 0 条)。
func (b *Bridge) laneWorker(ctx context.Context, l *lane) {
defer b.wg.Done()
for {
// 桥已停:队列里还没发车的条目**如实登记**(绝不静默消失),然后退休。
if ctx.Err() != nil {
b.abandonLaneAtStop(l)
b.retireLane(l)
return
}
if item, ok := b.nextLaneItem(l); ok {
b.runLaneItem(ctx, item)
b.dispatchLaneItem(ctx, l, item)
continue
}
// 队列空了才谈退休:摘表与入队用同一把锁,因此「摘下」与「刚有人塞进来」不会
@@ -323,39 +469,60 @@ func (b *Bridge) laneWorker(ctx context.Context, l *lane) {
b.retireLane(l)
return
case item := <-l.ch:
b.runLaneItem(ctx, item)
b.dispatchLaneItem(ctx, l, item)
}
}
}
// runLaneItem 跑车道上的一条:先取全局回合槽(**在线程池里等,不在读循环里等**),
// 再过陈旧度闸,最后交给原样的入站流水线。
// dispatchLaneItem 按**车道类型**分派:direct(主人私聊)一条一条跑;batch 走窗口
// 引擎(见 coalesce_lane.go 的 runBatchLaneItem)。
//
// 陈旧度在**取到槽之后**才判:排队等槽的那段时间同样是「让它等」,必须计入年龄,
// 否则一道繁忙的全局上限会把「等了很久」洗成「刚到」。
func (b *Bridge) runLaneItem(ctx context.Context, item laneItem) {
select {
case b.sem <- struct{}{}:
case <-ctx.Done():
// 分派依据是 `l.batcher == nil` —— 一个**结构**判据,不是配置开关:direct 车道的
// 类型里根本没有 batcher 字段值,窗口代码对它不可达。
func (b *Bridge) dispatchLaneItem(ctx context.Context, l *lane, item laneItem) {
if l.batcher == nil {
b.runDirectLaneItem(ctx, l, item)
return
}
defer func() { <-b.sem }()
b.runBatchLaneItem(ctx, l, item)
}
// runDirectLaneItem 跑 direct 车道(= 主人私聊)的一条:先取全局回合槽(**在线程池里
// 等,不在读循环里等**),再过陈旧度闸,然后走逐条闸门 + 单条回合。**没有任何等待窗口**。
func (b *Bridge) runDirectLaneItem(ctx context.Context, l *lane, item laneItem) {
if !b.acquireTurn(ctx) {
return
}
defer b.releaseTurn()
waited := b.now().Sub(item.arrivedAt)
if b.staleInbound(item, waited) {
return
}
// 「这条等了多久才开跑」是「她为什么回得晚」在生产日志里最直接的那个答案;
// 与它同时出现在日志里的 `msg="inbound frame"`(适配器的入站帧 DEBUG)则说明
// 读循环在等的同时一直在读 —— 两行合起来就是「回合没有堵住心跳」的证据。
b.lanes.log.Debug("lane turn started",
"waited", waited.String(),
"platform", item.msg.Platform, "channel", item.msg.ChannelID,
"sender", item.msg.SenderID, "group", item.msg.GroupID)
// 不变量 ④:上下文在这里(出队之后)才装配,因此一轮看到的是最新历史。
b.processFrom(ctx, item.msg, item.a)
b.logLaneStart(item, waited, 1)
p, ok := b.prepareInbound(ctx, item)
if !ok {
l.lastTurnEnd = b.now()
return
}
b.runBatch(ctx, []preparedInbound{p})
l.lastTurnEnd = b.now()
}
// acquireTurn 取一个全局回合槽(maxConcurrent 个)。ctx 取消时返回 false:桥停了
// 就不再开新回合(carried 条目由 Stop 的诚实登记负责,见 coalesce_lane.go)。
func (b *Bridge) acquireTurn(ctx context.Context) bool {
select {
case b.sem <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
// releaseTurn 归还一个全局回合槽。
func (b *Bridge) releaseTurn() { <-b.sem }
// staleInbound 实现陈旧度闸:轮到时已超龄 ⇒ 丢弃不补答,并留 WARN + 计数 + 审计。
// 返回 true 表示这条已被丢弃。waited 由调用方算好(含等全局回合槽的时间)。
func (b *Bridge) staleInbound(item laneItem, waited time.Duration) bool {
@@ -422,18 +589,55 @@ func (s *laneSet) noteDrop(reason string) (stale, overflow int64) {
// retireLaneIfIdle 在车道已空时把它从注册表摘掉并报告 true(worker 随即退出)。
//
// 它必须与 `offer` 用同一把锁:两者交错的话会出现「offer 拿到车道 → worker 退休并删除 →
// offer 往一条没有 worker 的车道入队」的丢失。入队与「查空」在同一把锁下,就不存在这个窗口。
// 它必须与 `offer` / `requeueFront` 用同一把锁:与 offer 交错的话会出现「offer 拿到
// 车道 → worker 退休并删除 → offer 往一条没有 worker 的车道入队」的丢失。入队与
// 「查空」在同一把锁下,就不存在这个窗口。
func (b *Bridge) retireLaneIfIdle(l *lane) bool {
b.lanes.mu.Lock()
defer b.lanes.mu.Unlock()
if len(l.ch) > 0 || len(l.spill) > 0 || b.lanes.lanes[l.key] != l {
if len(l.front) > 0 || len(l.ch) > 0 || len(l.spill) > 0 || b.lanes.lanes[l.key] != l {
return false
}
delete(b.lanes.lanes, l.key)
return true
}
// abandonLaneAtStop 登记「桥停止时车道里还排着、因此永远不会被回答的条目」。
//
// 它与 coalesce_lane.go 的 abandonAtStop 分工明确:那边管**已经取在手上、正在等窗口**
// 的那一批,这里管**还躺在队列里**的(front/ch/spill)。两处都带 source 落 WARN 并计入
// abandoned_at_stop_total —— 进程要停了没法「晚回」,唯一能做对的是**说出来**。
func (b *Bridge) abandonLaneAtStop(l *lane) {
b.lanes.mu.Lock()
remaining := len(l.front) + len(l.ch) + len(l.spill)
b.lanes.mu.Unlock()
if remaining == 0 {
return
}
total := b.coalesceStats.abandonedAtStop.Add(int64(remaining))
b.lanes.log.Warn("lane queue abandoned at bridge stop",
"lane", l.key, "count", remaining, "abandoned_at_stop_total", total)
}
// logLaneStart 记「这一批等了多久才开始跑」—— 「她为什么回得晚」在生产日志里最直接
// 的那个答案。与它同时出现在日志里的 `msg="inbound frame"`(适配器的入站帧 DEBUG)
// 则说明读循环在等的同时一直在读:两行合起来就是「回合没有堵住心跳」的证据。
func (b *Bridge) logLaneStart(item laneItem, waited time.Duration, messages int) {
b.lanes.log.Debug("lane turn started",
"waited", waited.String(), "messages", messages,
"platform", item.msg.Platform, "channel", item.msg.ChannelID,
"sender", item.msg.SenderID, "group", item.msg.GroupID)
}
// laneStrangerPrivate 报告一条入站是否属于**陌生人私聊**(非群 + 非主人)。
//
// 它与 `isStrangerPrivate(sess)` 同源(同一判据的两面):会话层从 actor 派生
// `sess.Owner`,这里直接用 actor 元数据,因此不需要先查会话就能判定「这条车道
// 要不要受 applyToStrangers 管」。主人私聊走不到这里(那是 direct 车道)。
func laneStrangerPrivate(item laneItem) bool {
return !item.msg.IsGroup() && !item.actor.IsOwner()
}
// retireLane 无条件把车道从注册表摘掉(桥停止时的清理;未处理的条目随取消作废)。
func (b *Bridge) retireLane(l *lane) {
b.lanes.mu.Lock()