13-26.【Combine】如何在多线程场景下保证 Publisher 发出的事件顺序和线程安全?

4 阅读3分钟

在多线程场景下处理 Combine,核心挑战在于:Combine 默认不保证线程安全(Thread-safe)且 Publisher 的事件发出(Emission)通常遵循其上游操作符所在的调度器。

如果不加干预,多个并发任务向同一个 Subject 发送事件,可能会导致竞争条件(Race Conditions)或下游收到的事件乱序。

以下是保证顺序与安全的四种核心策略:


1. 使用单一串行调度器(Serial Scheduler)

保证顺序的最简单方式是强制所有的事件处理在同一个串行队列上执行。

  • receive(on:) :控制下游操作符(如 map, sink)在哪个线程运行。
  • subscribe(on:) :控制上游副作用(如开始请求、读取文件)在哪个线程启动。

防御技巧:始终确保最终更新 UI 的操作位于 DispatchQueue.main

Swift

// 即使上游在多个后台线程并发,下游也会在串行队列中按顺序排队处理
publisher
    .receive(on: DispatchQueue(label: "serial.queue")) 
    .map { process($0) }
    .receive(on: DispatchQueue.main) // 最终安全切回主线程
    .sink { ... }

2. 线程安全的事件注入(Subject 的安全访问)

如果你在多个并发线程中调用 subject.send(value),由于 Subject 本身不是线程安全的,这可能导致内部状态损坏。

  • 解决方案:手动同步发送动作。

Swift

private let lock = NSRecursiveLock()
private let subject = PassthroughSubject<String, Never>()

func threadSafeSend(_ value: String) {
    lock.lock()
    subject.send(value) // 保证发送动作的原子性
    lock.unlock()
}

3. 利用操作符维护逻辑顺序

当多个并发任务的结果需要按特定顺序汇聚时,简单的 receive(on:) 可能不够,你需要更高阶的操作符:

A. 顺序拼接:append / prepend

如果你需要“执行完 A 再执行 B”,即使它们是并发发起的,也可以通过操作符链条强制顺序。

B. 限制并发数:flatMap(maxPublishers:)

当你使用 flatMap 处理多个内部 Publisher 时,可以设置 maxPublishers: .max(1)

  • 效果:它会强制上游发出的任务排队执行。只有前一个内部 Publisher 完成后,才会启动下一个。这对于需要严格保序的任务(如按顺序上传一组图片)至关重要。

4. 关键概念:事件的序列性(Serialization)

根据 Combine 的规范,任何订阅者接收到的事件(receive(_:))必须是同步且顺序的。也就是说,同一个订阅者的 sink 不会被两个线程同时进入。

但是,这并不意味着上游可以并发发值。如果上游违反了序列性(即在两个线程同时给一个下游发值),会导致未定义行为。

  • 防御式设计:receive(on:) 的双重作用

    .receive(on: DispatchQueue.main) 不仅仅是切换线程,它还扮演了**“序列化器(Serializer)”**的角色。即使上游极其混乱,通过 receive(on:) 后,所有的事件都会被放入调度器的队列中,强制排队变成单行道。


5. 总结:多线程防御清单

需求推荐方案
防止 UI 卡死.receive(on: DispatchQueue.main)
保证后台处理顺序使用自定义的串行 DispatchQueue 配合 receive(on:)
解决多线程发值竞争send() 处加锁,或统一调度到同一队列执行 send
保证异步任务按序执行.flatMap(maxPublishers: .max(1))
排查线程问题在操作符之间插入 .print(),观察 [Thread] 标记的变化

避坑指南:不要过度同步

过度使用 NSLock 或同步队列会降低响应式框架的并发优势。优先考虑**声明式的调度(Scheduler)**而非命令式的锁。