iOS 26 你的 property 崩了吗?

本文首次发表在快手大前端公众号

背景

iOS 26 Runtime 新增特性,对 nonatomic (非原子) 属性的并发修改更加容易产生崩溃。系统合成的 setter 方法会短暂地存入一个哨兵值 0x400000000000bad0 ,而该值可能会被另一个并发访问此属性的线程所读取。如果程序因访问这个哨兵值而崩溃,则表明正在访问的属性存在线程安全问题。

崩溃示例:

核心改动

对于 nonatomic strong 属性的赋值操作,编译时会自动生成对 objc_storeStrong 函数的调用。

示例:

objectivec 复制代码
@property (nonatomic, strong) NSObject *obj1;

系统生成的 setter 方法:

csharp 复制代码
Example`-[ViewController setObj1:]:
    0x1046298c4 <+0>:  sub    sp, sp, #0x30
    0x1046298c8 <+4>:  stp    x29, x30, [sp, #0x20]
    0x1046298cc <+8>:  add    x29, sp, #0x20
    0x1046298d0 <+12>: stur   x0, [x29, #-0x8]
    0x1046298d4 <+16>: str    x1, [sp, #0x10]
    0x1046298d8 <+20>: str    x2, [sp, #0x8]
    0x1046298dc <+24>: ldr    x1, [sp, #0x8]
    0x1046298e0 <+28>: ldur   x8, [x29, #-0x8]
    0x1046298e4 <+32>: adrp   x9, 5159
    0x1046298e8 <+36>: ldrsw  x9, [x9, #0xba4]
    0x1046298ec <+40>: add    x0, x8, x9
--> 0x1046298f0 <+44>: bl     0x105600a10               ; symbol stub for: objc_storeStrong
    0x1046298f4 <+48>: ldp    x29, x30, [sp, #0x20]
    0x1046298f8 <+52>: add    sp, sp, #0x30
    0x1046298fc <+56>: ret    

objc_storeStrong 在旧版本的的实现:

scss 复制代码
void objc_storeStrong(id *location, id obj) {
    // 1. 先用一个临时变量 prev 持有旧值
    id prev = *location;
    
    // 2. 如果新旧值相同,直接返回,避免不必要的内存操作
    if (obj == prev) {
        return;
    }
    
    // 3. 对新值执行 retain,使其引用计数+1
    objc_retain(obj);
    
    // 4. 将指针指向新值
    *location = obj;
    
    // 5. 对旧值执行 release,使其引用计数-1
    objc_release(prev);
}

反汇编 objc_storeStrong 在 iOS 26 新版本的实现:

ini 复制代码
void objc_storeStrong_iOS_26(id *location, id obj) {
    // 1. 读取旧值
    // ldr x20, [x0]
    id prev = *location;

    // 2. 检查新旧值是否相同,相同则直接返回
    // cmp x20, x1
    // b.eq ... (跳转到函数末尾)
    if (prev == obj) {
        return;
    }

    // 为了后续操作,保存新值 obj 和地址 location
    // mov x19, x1  (x19 = obj)
    // mov x21, x0  (x21 = location)
    id new_obj_saved = obj;
    id* location_saved = location;

    // 3. 【核心改动】向属性地址写入哨兵值
    // mov  x8, #0xbad0
    // movk x8, #0x4000, lsl #48  --> x8 = 0x400000000000bad0
    // str  x8, [x0]
    *location = (id)0x400000000000bad0; // 调试陷阱

    // 4. 对新值执行 retain
    // mov x0, x1
    // bl objc_retain
    objc_retain(obj);

    // 5. 将真正的新值写入属性地址,覆盖哨兵值
    // str x19, [x21]
    *location_saved = new_obj_saved;

    // 6. 释放旧值(通过尾调用优化)
    // mov x0, x20
    // b objc_release
    // 这相当于 return objc_release(prev);
    objc_release(prev);
}

为了更主动地暴露 nonatomic 属性的线程安全问题,objc_storeStrong 函数在 iOS 26 中增加了一个关键步骤。

旧实现 (时序:Retain -> Assign -> Release)

  1. objc_retain(newValue);
  2. *location = newValue;
  3. objc_release(oldValue);

新实现 (时序:写入哨兵值 -> Retain -> Assign -> Release)

  1. *location = 0x4...bad0; // <-- 新增:写入哨兵值

  2. objc_retain(newValue);

  3. *location = newValue;

  4. objc_release(oldValue);

旧实现中数据竞争触发崩溃需要满足的条件:

  1. 对象状态:prev 对象的引用计数 == 1,执行完 objc_release 之后 prev 对象被释放。

  2. 线程时序:读线程获取到了 prev 对象,并未对 prev 对象的引用计数+1,写线程执行完 objc_release(prve),读线程仍在继续使用 prev。

  3. 行为前提:读线程必须对这个已成为悬垂指针的 prev 地址执行解引用操作,但是这是一个必要不充分条件,因为该内存可能已被重用,不一定会触发崩溃。

新实现通过引入哨兵值,将不确定的崩溃条件转变为一个确定的、主动触发的机制:

  1. 定义"危险窗口": 写线程在 objc_storeStrong 内部创建了一个明确的"危险窗口"------从写入哨兵值 (*location = 0x4...bad0) 开始,到写入新值 (*location = obj) 结束。访问哨值触发崩溃与旧值 prev 对象的引用计数无关
  2. 简化触发条件: 只要读线程的读取操作落入这个时间窗口内,它必然会获取到哨兵值。对这个非法的哨兵地址进行任何解引用操作,都将必然、立即触发一个带有明确特征 (0x4...bad0) 的 EXC_BAD_ACCESS 崩溃。

另外新的崩溃机制并非替换了旧的崩溃逻辑,而是与之叠加,因此极大地放大了崩溃的概率。

崩溃场景

当一个线程(线程 A)正在为属性赋值,并已写入哨兵值但尚未写入新值时,*location 处于 "危险窗口"。此时,另一个线程(线程 B)的并发读写操作会导致崩溃。

崩溃场景一:写写并发 → objc_release 崩溃

  1. 线程 A:执行 setter,向属性地址写入哨兵值 0x4...bad0。

  2. 线程 B:并发执行 setter,调用 objc_storeStrong 函数。

  3. 关键点:线程 B 此时读到了 "旧值" 是线程 A 写入的哨兵值 0x4...bad0。

  4. 崩溃:objc_storeStrong 在赋值完成后,尝试调用 objc_release(旧值),实际上执行了 objc_release(0x4...bad0)。由于这是一个无效的对象地址,程序立即崩溃,堆栈栈顶指向 objc_release。

复现代码:

崩溃栈顶:

崩溃场景二:读写并发 → objc_retain 崩溃

  1. 线程 A:执行 setter,写入哨兵值 0x4...bad0。

  2. 线程 B:此时执行 getter 来读取该属性。

  3. 关键点:getter 直接从内存中返回了当前的哨兵值 0x4...bad0。

  4. 崩溃:ARC 为了保证对象生命周期,会对这个值执行 retain 操作。这导致系统调用 objc_retain(0x4...bad0)。同样,由于这是一个无效地址,程序崩溃,堆栈栈顶指向 objc_retain。

复现代码:

崩溃栈顶:

根因修复

iOS 26 Runtime 针对 property 新增的哨兵机制,其目的是主动暴露潜藏的多线程数据竞争问题。因此,修复的根本目标是解决底层的线程冲突。

最直接快速的修复方案是把 nonatomic 修改为 atomic。它能有效地规避 iOS 26 此次更新导致的,访问哨兵 0x400000000000bad0 触发的崩溃问题。

需要注意的是 atomic 也有一些局限性,只保证 setter 或 getter 本身是原子操作。如果有一系列依赖该属性的操作,atomic 无法保护整个操作序列是线程安全的。典型场景比如数组、字典的更新,atomic 可以保证线程安全的获取数组或字典对象,但是无法保证对数组和字典的增删是线程安全的,此时需要用锁或者队列覆盖系列复合操作来保证线程安全。

影响范围

这是一次由操作系统 Runtime 变更引发的、波及全量线上版本的崩溃问题。当用户升级操作系统后,代码库中所有潜藏的 nonatomic 数据竞争问题都将被新的"哨兵"机制主动暴露,导致崩溃呈现高度分散的特点,增加了问题处置的复杂性。如下所示,不仅分布为多个崩溃堆栈,并且每个堆栈的 App 版本跨度非常大。

对于线上 App 版本,如果不做任何止损操作,iOS 26 系统的用户崩溃率将比存量系统激增近两个数量级。

Ekko(安全气垫)

崩溃波及全量的线上 App 版本,对于线上历史版本, 从用户体验的角度出发,我们不能够任由崩溃发生,也不能简单粗暴地强制用户升级 App。那么如何在允许的的规则范围内进行崩溃止损呢?快手的答案是使用 Ekko(安全气垫)。

Ekko 是什么?

Ekko 是快手自研的全新的安全气垫框架,命名源自英雄联盟的艾克,他的 R 技能可以回到数秒前位置并恢复生命值,非常契合快手安全气垫的技术实现。Ekko 核心机制是:在异常发生之后,App 闪退之前,通过修改程序执行流,在代码逻辑上等价于绕过执行发生异常的函数,从而让 App 免于崩溃。Ekko 兜底偶现崩溃的场景下,当目标函数未发生崩溃时,执行逻辑不会受任何影响。

以典型的数组越界为例:

Ekko 兜底 objectAtIndex: 后,上述代码在异常发生后,执行逻辑上等价于:

Ekko 简介:

  • 平台覆盖:iOS & Android

  • 兜底能力:在 iOS 端能处理包括 Mach 异常在内的所有崩溃类型。在 Android 端能处理 Java Exception 和 Native Exception。

  • 稳定可靠:兜底的核心逻辑在异常发生后执行,对正常运行的 App 不发生作用。Ekko 系统上线至今,已在线上稳定运行超过一年,多次在异常退出类型的故障处置中发挥关键作用,为快手 App 的稳定性提供了坚实的保障。

Ekko 兜底实践

iOS 传统的安全气垫会通过 hook Objective-C 可能会抛异常的系统方法,在替换的方法内,添加 try catch 或者校验异常参数,防御已知的、可枚举的风险点,从而避免崩溃发生。

因为访问 0x400000000000bad0 触发了 bad access 类型的 Mach 异常能不能被 try catch 住呢?答案是不可以的。但是 Mach 同 Exception 一样,也是两段式的处理,当异常发生时,内核会挂起出错线程,并向用户态发起"问询",并等待用户态的响应,然后根据用户态的回复决定是否终止进程还。这个问询等待回复后决策的机制是 Ekko 兜底 Mach 异常的关键所在。

针对此次 nonatomic 哨兵值崩溃,快手稳定性组通过 Ekko,对访问哨兵地址 0x400000000000bad0 触发的崩溃类型进行了统一兜底,拦截了用户百万次量级的崩溃。兜底主要处理以下两种系统堆栈触发的崩溃场景:

  • 场景一:objc_release
    • 兜底策略: 检测到参数为哨兵地址时,直接返回,不执行任何操作。
    • 业务影响: 无额外影响。此操作仅跳过了一次无效的 release,避免了崩溃。
  • 场景二:objc_retain
    • 兜底策略: 检测到参数为哨兵地址时,中断原始 retain 流程,并向上层返回 nil。

    • 业务影响:可控降级。上层业务代码在获取该属性值后会得到 nil。需要和相关业务方沟通并确认,业务逻辑能够正确处理 nil 返回值,兜底后的效果可接受。

本文仅是 Ekko 系列的开篇,后续我们将通过公众号,为大家详细介绍 Ekko 的技术实现细节,对相关内容感兴趣的可以关注公众号,敬请期待后续的更新~~

相关推荐
黑化旺仔7 小时前
【OC】KVO
macos·ios·objective-c·cocoa
HouWan9 小时前
SwiftUI 小技巧:如何读取 SwiftUI Font 的详细信息
ios·swiftui·apple
李游Leo10 小时前
HarmonyOS 7 实战开发 05:把页面做到可上线状态
ios·harmonyos
终端安全笔记15 小时前
描述文件、企业证书、MDM 不是三个名字,是三层
android·ios·智能手机
传奇开心果编程15 小时前
【SwiftUI入门练中学】第1课 从零开始
学习·ui·ios·swiftui·swift
丿Wayne21 小时前
iOS Token 刷新实战(Actor 避免并发重复刷新)
ios·swift·token·actor·swift并发
终端安全笔记2 天前
iOS 27 给了租赁一个新工具,但它只认受监督的设备
android·网络·安全·ios
大熊猫侯佩2 天前
独立 App 首发完成 iPhone Duo 展开态适配
ios·swiftui·swift
光影少年2 天前
setImmediate 和 setTimeout(0) 的区别
android·前端·react.js·ios·前端框架
2501_916008892 天前
SSE 和 gRPC 流式接口如何抓包,调试 SSE 与 gRPC 流式接口
网络协议·计算机网络·网络安全·ios·adb·https·udp