Swift 6 驱魔实录:揭开 Combine 与 @Sendable 的“血色契约”

"凡哥,代码又崩在 _dispatch_assert_queue_fail 了!"

2025 年的深夜,面对 Swift 6 严格的并发检查,曾经无所不能的 Combine 竟成了最大的"雷区"。明明切换了线程,编译器却为何视而不见?

这不仅是一次技术排查,更是一场新旧时代的对话。本文将带你穿透 receive(on:) 的迷雾,直面那个缺失的 @Sendable 符咒,在不抛弃 Combine 的前提下,寻找那条唯一的生路。

🌏 引子

这是一个发生在这个充斥着 caffeine 和 commit log 的深夜里的故事。

窗外,张江高科园区的灯火依旧通明,像是无数行代码在夜色中闪烁。高级架构师李凡 推了推鼻梁上的防蓝光眼镜,看着屏幕上的一行行红色报错,嘴角勾起一丝玩味的笑意。坐在他对面的,是刚入职不久、满眼血丝的实习生小雅

"凡哥,我明明什么都没动,升级到 Swift 6 模式后,这祖传的 Combine 代码就像中了邪一样,跑起来就崩!"小雅的声音带着一丝哭腔,指着屏幕上那个令人绝望的 _dispatch_assert_queue_fail

在本篇博文中,您将学到如下内容:

    • [🌏 引子](#🌏 引子)
    • [🕵️‍♂️ 幽灵般的 Combine 与并发陷阱](#🕵️‍♂️ 幽灵般的 Combine 与并发陷阱)
    • [🧩 消失的符咒:缺失的标注](#🧩 消失的符咒:缺失的标注)
    • [🛠️ 手动档:强行贴符](#🛠️ 手动档:强行贴符)
    • [🕸️ @Sendable,无处不在的幽灵](#🕸️ @Sendable,无处不在的幽灵)
    • [⚖️ 进退维谷的抉择](#⚖️ 进退维谷的抉择)
    • [🚫 没有完美的解药](#🚫 没有完美的解药)
    • [🔚 尾声:黎明前的忠告](#🔚 尾声:黎明前的忠告)

李凡轻轻转动着手中的紫砂壶,那是他为了镇压代码邪灵特意求来的。"小雅啊,这不是中邪。这是因为 Swift 6 的'并发大法'照妖镜,照出了 Combine 隐藏多年的'身世之谜'。"

他放下茶壶,手指在机械键盘上敲击出清脆的声响。"今天,我就带你看看这隐藏在 @Sendable 符咒背后的真相。"


🕵️‍♂️ 幽灵般的 Combine 与并发陷阱

Combine 这玩意儿,在无数项目中可谓是"如影随形"。尽管 Apple 那帮老神仙已经好几年没给它传授新内功了,但我感觉它几乎无处不在。甚至在那些从零开始的崭新项目中,它依然像个顽强的钉子户一样存在。

在深入研究 Swift Concurrency 之前,我甚至没意识到 Combine 的群众基础有多深厚。这导致了一个有趣的现象:每当大家讨论如何修炼 Swift Concurrency 这门新武学时,最常被提及的竟然是如何安顿好 Combine 这个"老前辈"。

今天,我不打算讲什么宏大的架构,只单挑一个最让开发者头秃的痛点:@Sendable 标注。

(如果你对 @Sendable 感到陌生,不妨把它想象成一张"通行证",只有拿了这张证的数据和闭包,才能安全地在不同线程间穿梭。)

🧩 消失的符咒:缺失的标注

影响 Combine 的核心心魔其实并不稀奇。事实上,这问题太普遍了,以至于 Swift 6.2 为了减少这种乱象,不得不修改了 Objective-C completion handlers 的引入规则。

来,小雅,看着这段代码,这里面藏着玄机。这关乎函数参数的命门。

swift 复制代码
/// 干活的函数。
///
/// 完事儿后会在后台队列调用这个 block。
public func doWork(block: @escaping () -> Void) {
    // ... 这里是一顿猛如虎的操作
}

乍一看,这就是个平平无奇、带 completion handler 的函数。但请注意那些注释------它们就像是武功秘籍的注解,非常重要。函数声称 block 参数会在后台队列执行。

问题在于,按照现在的写法,这函数是在"吹牛",编译器根本不允许它这么做。

(这看起来很眼熟对吧?因为这是我从之前关于 @preconcurrency 的文章里搬来的例子。)

听好了,Swift 有一条铁律:在一个特定的线程/队列/Actor(我们称之为隔离域 )中创建的值,除非编译器能证明它是安全的,否则绝不允许 离开这个域。要想证明安全,这个函数参数必须被打上 @Sendable 或者 sending 的烙印。

既然 block 啥也没标,编译器就会断定:这玩意儿绝对跑不出创建它的那个上下文。

用术语来说就是:一个非 Sendable 的闭包,会继承它诞生之地的隔离属性。

swift 复制代码
@MainActor
func scheduleWork() {
    // 咱们现在是在 MainActor (主线程) 的地盘上...
    doWork {
        // ... 因为 doWork 的函数签名暗示了这个闭包
        // 不可能离开当前的隔离域,
        // 这里的编译器就会拍脑袋认为:
        //
        // "这肯定也是在 MainActor 上执行的!"
    }
}

编译器这老实孩子,毫无保留地信任类型系统。既然 doWork 没标记 @Sendable ,编译器就当它是真的。既然 block 不能离开当前隔离域,那它肯定也是在 MainActor 上跑的。

然而,这个函数签名是错的,是个彻头彻尾的谎言。

🛠️ 手动档:强行贴符

Swift 6 语言模式下,如果不作弊,你根本没法按上面那个样子实现 doWork。这后果很严重:如果你在 Swift 6 模式开启的情况下,试图在一个 MainActor 上下文中执行这个 block,它会当场崩溃

对于这种"后台访问主线程数据"的行为,有人觉得是恐怖故事,有人觉得是安全保障,取决于你的立场。

(这种崩溃很容易识别,因为崩掉的函数通常是 _dispatch_assert_queue_fail。往堆栈深处多看几眼,你就能揪出那个缺了标注的罪魁祸首。)

那我们该咋办?

如果你属于"被吓坏了"的那一类,你可以选择当鸵鸟。使用 -disable-dynamic-actor-isolation 编译器标志来禁用这些断言,允许数据竞争的发生。或者退回到 Swift 5 模式,那里默认不开启这个检查。

但作为一名有追求的工程师,我们也可以手动加上缺失的标注

swift 复制代码
@MainActor
func scheduleWork() {
    // 我们在 MainActor...
    doWork { @Sendable in // 👈 看这里!手动加符咒!
        // 哎哟,这下它变成了 @Sendable 闭包,
        // 我们就不知道它到底会在哪个隔离域上跑了。
        //
        // 所以,在这里面访问 MainActor 的东西是不安全的!
        // (编译器会阻止你犯错)
    }
}

能自己修补这漏洞固然好,但如果有人能更新 doWork 的签名让它诚实一点,那才是正道。可惜,很多时候那个 doWork 是别人写的库,我们掌控不了。

最棘手的是,你得先发现这些隐蔽的坑。

🕸️ @Sendable,无处不在的幽灵

搞懂了原理,李凡喝了一口茶,把目光投向了小雅最关心的 Combine。下面这段代码能编译通过,但跑起来就像踩了地雷。

swift 复制代码
@MainActor
class UsesCombine {
    private var cancellables = Set<AnyCancellable>()

    // 别忘了,这里被推断为 MainActor
    func backgroundChain() {
        Just(1)
            .receive(on: DispatchQueue.global()) // 👈 万恶之源
            .sink { value in
                // 编译器在这里偷偷插入了一个检查代码
                print(value)
            }
            .store(in: &cancellables)
    }
}

我尽量用最简单的例子来演示这个惨案。问题的根源就在那个 receive(on:)。从这一行开始,链条后续的所有闭包实际上都在 全局队列(后台) 上运行。但是,sink 接收的那个闭包参数,并没有被标记为 @Sendable

绝大多数 Combine 的操作符都是以函数参数的形式存在的。这其中的每一个,根据用法的不同,都可能需要变成 @Sendable 。Combine 的并发行为完全依赖于运行时的状态。GCD 也有这毛病,但 GCD 的解决方案是简单粗暴地把函数参数都标记为 @Sendable

但 Combine 的参数,一个 @Sendable 都没有。

好吧,GCD 大部分都有...

⚖️ 进退维谷的抉择

李凡叹了口气,眼神变得深邃。

Combine 在这里真的是处于一个极其尴尬 的境地,简直是病理学的典型案例。我甚至无法决定 Combine 的函数到底该不该标记为 @Sendable

语言兼容性 的角度看,没得选,必须标。语言规则就是王法。

但从 人体工程学(好用程度) 的角度看,标了就是灾难。因为很多 Combine 链条根本不做后台操作。如果强制加上 @Sendable,会让那些简单的代码变得异常难写。

看下面这个版本。注意它没用 receive(on:)。但我加了个包装器,模拟如果 sink 真的无条件接受 @Sendable 闭包会发生什么。

swift 复制代码
extension Publisher where Self.Failure == Never {
    // 假设我们有一个强制要求 Sendable 的 sink
    func sendableSink(receiveValue: @escaping @Sendable (Self.Output) -> Void) -> AnyCancellable {
        sink(receiveValue: receiveValue)
    }
}

@MainActor
class UsesCombine {
    private var cancellables = Set<AnyCancellable>()

    func foregroundChain() {
        Just(1)
            .sendableSink { value in
                // ❌ 错误:在同步非隔离上下文中调用了主线程隔离的实例方法 'processValue'
                self.processValue(value)
            }
            .store(in: &cancellables)
    }

    func processValue(_ value: Int) {
    }
}

看吧,虽然我们获得了编译时的数据竞争安全保证,但付出了极大的易用性代价 。当使用 sendableSink 时,我们再也不能随手访问任何隔离状态了(不管是 MainActor 还是其他的)。

更糟糕的是,这纯属误伤 (False-positive)。明明是在前台跑,却非要当成跨线程处理。要想绕过这个新问题,还得用 MainActor.assumeIsolated 这种动态隔离技术,或者其他不安全的逃生通道。简直是糟糕透顶

🚫 没有完美的解药

在这个问题上,我的内心反复横跳。说实话,这里真的没有完美的解决方案

我觉得维持 Combine 现状不动,可能反而是最合理的。虽然从技术上讲,它的类型定义是不正确的。但如果去修正它,收益微乎其微,却会重创那些最常见的普通用例。

现实一点说,你只需要对那些真正进行后台工作的 Publisher 保持高度警惕。

哪怕只是快速扫描一下代码库里的 receive(on:) 都能救命。因为只要出现这玩意儿,就意味着你正在切换 Scheduler,这就是问题的栖息之地。不幸的是,你仍然需要结合上下文,仔细推敲每个闭包到底在干嘛。但至少,这能帮你缩小排查范围。

在这个 Combine 与 Swift 互操作性的烂摊子里,任何一点帮助都是宝贵的。毕竟,这还只是众多问题中的一个......其他的以后再说吧。

🔚 尾声:黎明前的忠告

故事讲到这里,李凡合上了电脑,窗外的天空已泛起鱼肚白。

很多人觉得,既然如此,是不是必须立刻抛弃 Combine?现在的风向似乎都在吹 AsyncSequence

但请记住:Combine 并没有被废弃。 它近期内也不会消失。

根据我的经验,从 Combine 迁移到 AsyncSequence 的难度简直是地狱级的。如果你还没把 Swift 语言的基础打得坚如磐石,千万别轻易尝试这次迁徙,否则你会死得很惨。

"所以,小雅,"李凡站起身,拍了拍一脸恍然大悟的实习生的肩膀,"别急着重写。先去把你代码里的那些 receive(on:) 找出来,给后面的闭包手动贴上 @Sendable 的符咒。这才是现在的生存之道。"

看着小雅重新投入战斗的背影,李凡知道,这场与编译器的战争,才刚刚开始。

相关推荐
初级代码游戏4 小时前
iOS开发 SwiftUI 15:手势 拖动 缩放 旋转
ios·swiftui·swift
ujainu6 小时前
Flutter + OpenHarmony 游戏开发进阶:虚拟摄像机系统——平滑跟随与坐标偏移
开发语言·flutter·游戏·swift·openharmony
初级代码游戏3 天前
iOS开发 SwiftUI 14:ScrollView 滚动视图
ios·swiftui·swift
初级代码游戏3 天前
iOS开发 SwitftUI 13:提示、弹窗、上下文菜单
ios·swiftui·swift·弹窗·消息框
zhyongrui3 天前
托盘删除手势与引导体验修复:滚动冲突、画布消失动画、气泡边框
ios·性能优化·swiftui·swift
zhangfeng11333 天前
CSDN星图 支持大模型微调 trl axolotl Unsloth 趋动云 LLaMA-Factory Unsloth ms-swift 模型训练
服务器·人工智能·swift
zhyongrui4 天前
SnipTrip 发热优化实战:从 60Hz 到 30Hz 的性能之旅
ios·swiftui·swift
大熊猫侯佩5 天前
Neo-Cupertino 档案:撕开 Actor 的伪装,回归 Non-Sendable 的暴力美学
swift·observable·actor·concurrency·sendable·nonsendable·data race
2501_915921436 天前
在没有源码的前提下,怎么对 Swift 做混淆,IPA 混淆
android·开发语言·ios·小程序·uni-app·iphone·swift