Channel 的时间相关与选择器:delay、withTimeout/withTimeoutOrNull、ticker

373 阅读4分钟

1)delay(ms):可取消的“睡眠”,不阻塞线程

  • 作用:让当前协程挂起 ms 毫秒,不占用底层线程(调度器会在到时后恢复协程)。

  • 可取消:期间如协程被取消,delay 会立刻结束并抛 CancellationException。

  • 时间基准:使用单调时钟(不受系统时钟跳变影响)。

  • 用法要点

    • 仅在协程内可用;别在 flow {} 里用 withContext 切线程来“睡”,那会破坏 Flow 不变式。
    • 重试/退避心跳速率限制里最常见。
suspend fun fetchWithRetry(max: Int = 5) {
  var backoff = 200L
  repeat(max) { attempt ->
    runCatching { fetch() }
      .onSuccess { return }
    delay(backoff)           // 可取消的退避
    backoff = (backoff * 2).coerceAtMost(10_000L)
  }
  error("still failing")
}

2)withTimeout/withTimeoutOrNull:给一段挂起代码加“总时限”

  • withTimeout(ms) :超时会取消当前协程的子层级并抛 TimeoutCancellationException。
  • withTimeoutOrNull(ms) :超时返回 null,避免抛异常(但内部仍被取消)。
  • 适用:给 receive()、send()、网络/IO、select {} 整段加护栏
  • 注意:异常是取消异常;如果你 catch (e: Exception) 把它吞掉了,可能掩盖上层取消。要么不要捕获,要么只捕获业务异常,或检查 e is CancellationException 直接 throw e 继续向上。
// 等待通道数据最多 500ms
val item = withTimeoutOrNull(500) { ch.receive() }
if (item == null) showStale() else render(item)

// 给“发消息”加超时
withTimeout(300) { ch.send(cmd) } // 满缓冲+SUSPEND 时,send 会挂到超时

3)ticker(...):节拍器通道(ReceiveChannel)

让你以固定节奏收到 Unit“滴答”。非常适合心跳、速率限制、对齐帧率、周期采样

fun ticker(
  delayMillis: Long,            // 相邻 tick 的间隔
  initialDelayMillis: Long = delayMillis,
  mode: TickerMode = FIXED_DELAY // FIXED_DELAY / FIXED_PERIOD
): ReceiveChannel<Unit>
  • 模式

    • FIXED_DELAY:每次消费者“取出”后再等 delay 发下一次(不会追赶)。
    • FIXED_PERIOD:尽量对齐绝对时钟周期(追赶节拍;消费慢时会尽快补齐到期 tick)。
  • 背压“合流/覆盖”语义(类似 CONFLATED) ——不会无限排队;慢消费不会造成大量堆积。

  • 停止:不再需要时 cancel() 之,或让外层协程结束。

val ticks = ticker(delayMillis = 200, initialDelayMillis = 0, mode = TickerMode.FIXED_PERIOD)
launch {
  for (unit in ticks) pullAndRenderLatest()  // 200ms 刷一次
}
// ...
ticks.cancel()

Flow 也有 sample(Period) 可替代“按周期取最新”;但 ticker 适合通道/选择器场景(见下)。


4)select {}:把“时间”与“通道事件”放到一个竞态里

select 是 “谁先可用选谁” 的原语。可监听:

  • ch.onReceive { ... } / onReceiveCatching { ... }

  • ch.onSend(value) { ... }

  • deferred.onAwait { ... }

  • onTimeout(ms) { ... } ← 时间分支

Kotlin 的 select 是“有偏好的” :从上到下注册分支,先匹配到先选。如果你需要“近似公平”,要轮换分支顺序或自己做均衡。

4.1 接收 or 超时,谁先到选谁

import kotlinx.coroutines.selects.select

suspend fun <T> recvOrTimeout(ch: ReceiveChannel<T>, timeoutMs: Long): T? =
  select {
    ch.onReceiveCatching { r ->
      if (r.isClosed) null else r.getOrNull()
    }
    onTimeout(timeoutMs) { null }          // 超时分支
  }

4.2 两个源 + 超时:抢占先到的

val value = select<String?> {
  chA.onReceive { "A:$it" }
  chB.onReceive { "B:$it" }
  onTimeout(1000) { null }
}

4.3 选择“能立刻发出去”的那条通道(发送竞争)

suspend fun sendAny(cmd: Cmd, a: SendChannel<Cmd>, b: SendChannel<Cmd>) =
  select {
    a.onSend(cmd) { "A" }                  // 哪个有空位选哪个
    b.onSend(cmd) { "B" }
    onTimeout(50) { error("all busy") }    // 全忙则报错/重试
  }

4.4 ticker + select:速率控制 + 兜底

val ticks = ticker(100, 0, TickerMode.FIXED_DELAY)
while (isActive) {
  select {
    ch.onReceive { process(it) }           // 有消息就处理
    ticks.onReceive { flushBatch() }       // 没消息也每 100ms 刷一次
    onTimeout(3_000) { markStale() }       // 长时间没消息 → 标记超时
  }
}

5) 典型时间×通道模式

5.1 心跳与看门狗(watchdog)

  • 目标:周期发送 PING,一定时间没收到 PONG → 视为断线。
val pings = ticker(5_000)
while (isActive) {
  select {
    pings.onReceive { socket.send(PING) }
    socket.onReceiveCatching { r ->
      if (r.isClosed) closeConn() else if (r.getOrNull() == PONG) ack()
    }
    onTimeout(15_000) { reconnect() } // 15s 没任何消息
  }
}

5.2 限速消费者(固定吞吐)

val pace = ticker(50)  // 20条/秒
for (item in inbox) {
  pace.receive()       // 按节拍取许可
  handle(item)
}

5.3 批处理(有数据就吃,空闲也按时刷)

val tick = ticker(200)
val buf = mutableListOf<Event>()
while (isActive) {
  select {
    inbox.onReceive { e ->
      buf += e
      if (buf.size >= 100) { flush(buf); buf.clear() }
    }
    tick.onReceive {
      if (buf.isNotEmpty()) { flush(buf); buf.clear() }
    }
  }
}

5.4 “超时回退到旧数据”

val fresh = withTimeoutOrNull(150) { remoteCh.receive() }
val data = fresh ?: cache.loadLastKnownGood()
render(data)

6) 容量/背压与时间原语的配合

  • withTimeout(send) + SUSPEND:当队列满,超过时限就放弃这条(或记录日志)。
  • ticker + DROP_OLDEST:高频输入时滑窗 + 固定刷新节拍,保障 UI 不被顶爆。
  • select { onTimeout } 比整段 withTimeout 更灵活:你可以不取消外层,只是走“超时分支”。

7) 易坑与建议

  1. 吞掉取消异常:withTimeout 抛的是 TimeoutCancellationException(CancellationException 子类)。别用 catch (Exception) 一股脑吞掉;若要处理,检测后 throw 回去。
  2. 用系统时间做节拍:请使用 delay/ticker(单调时钟),不要用 Thread.sleep/System.currentTimeMillis 做判断。
  3. ticker 不会无限积压:它是“覆盖式”的;如果你需要每个 tick 必达,请用 Channel(N,SUSPEND) + 生产者 send。
  4. select 的“偏好” :分支顺序会影响选择结果;多源竞争时可轮转顺序或人为均衡。
  5. 超时单位:所有超时/延时的单位统一用 Long 毫秒 或 Duration,避免单位误判。
  6. 优雅收尾:ticker.cancel();对自建 Channel 用 close() 让消费者把队列读干净。

8) 小抄(如何选)

目标推荐
给某个操作加“总时限”withTimeout / withTimeoutOrNull
周期动作/帧率对齐ticker(period, initialDelay, mode) 或 Flow 的 sample(period)
多事件竞态 + 超时兜底select { ch.onReceive ... ; onTimeout(t) { ... } }
退避重试delay(backoff)(可取消)
队列满时别一直挂起withTimeout { send(...) } 或 trySend + 丢弃策略