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 的技术实现细节,对相关内容感兴趣的可以关注公众号,敬请期待后续的更新~~

相关推荐
jiangmiao20248 小时前
IOS开发 Runloop机制
ios·objective-c
從南走到北8 小时前
JAVA国际版任务悬赏发布接单系统源码支持IOS+Android+H5
android·java·ios·微信·微信小程序·小程序
咕噜签名分发冰淇淋9 小时前
苹果ios安卓apk应用APP文件怎么修改手机APP显示的名称
android·ios·智能手机
游戏开发爱好者89 小时前
iOS 开发推送功能全流程详解 从 APNs 配置到上架发布的完整实践(含跨平台上传方案)
android·macos·ios·小程序·uni-app·cocoa·iphone
Larva11 小时前
iOS - 关于如何在编译时写入文件并在代码内读取文件内容
ios
我有与与症12 小时前
从0使用Kuikly框架写一个小红书Demo-Day6
客户端
胎粉仔1 天前
Objective-C 初阶 —— __bridge & __bridge_retained & __bridge_transfer
ios·objective-c
笑尘pyrotechnic1 天前
【OC】UIKit常用组件适配iOS 26
macos·ios·cocoa