本文首次发表在快手大前端公众号
背景
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)
- objc_retain(newValue);
- *location = newValue;
- objc_release(oldValue);
新实现 (时序:写入哨兵值 -> Retain -> Assign -> Release)
-
*location = 0x4...bad0; // <-- 新增:写入哨兵值
-
objc_retain(newValue);
-
*location = newValue;
-
objc_release(oldValue);
旧实现中数据竞争触发崩溃需要满足的条件:
-
对象状态:prev 对象的引用计数 == 1,执行完 objc_release 之后 prev 对象被释放。
-
线程时序:读线程获取到了 prev 对象,并未对 prev 对象的引用计数+1,写线程执行完 objc_release(prve),读线程仍在继续使用 prev。
-
行为前提:读线程必须对这个已成为悬垂指针的 prev 地址执行解引用操作,但是这是一个必要不充分条件,因为该内存可能已被重用,不一定会触发崩溃。
新实现通过引入哨兵值,将不确定的崩溃条件转变为一个确定的、主动触发的机制:
- 定义"危险窗口": 写线程在 objc_storeStrong 内部创建了一个明确的"危险窗口"------从写入哨兵值 (*location = 0x4...bad0) 开始,到写入新值 (*location = obj) 结束。访问哨值触发崩溃与旧值 prev 对象的引用计数无关
- 简化触发条件: 只要读线程的读取操作落入这个时间窗口内,它必然会获取到哨兵值。对这个非法的哨兵地址进行任何解引用操作,都将必然、立即触发一个带有明确特征 (0x4...bad0) 的 EXC_BAD_ACCESS 崩溃。
另外新的崩溃机制并非替换了旧的崩溃逻辑,而是与之叠加,因此极大地放大了崩溃的概率。
崩溃场景
当一个线程(线程 A)正在为属性赋值,并已写入哨兵值但尚未写入新值时,*location 处于 "危险窗口"。此时,另一个线程(线程 B)的并发读写操作会导致崩溃。
崩溃场景一:写写并发 → objc_release 崩溃
-
线程 A:执行 setter,向属性地址写入哨兵值 0x4...bad0。
-
线程 B:并发执行 setter,调用 objc_storeStrong 函数。
-
关键点:线程 B 此时读到了 "旧值" 是线程 A 写入的哨兵值 0x4...bad0。
-
崩溃:objc_storeStrong 在赋值完成后,尝试调用 objc_release(旧值),实际上执行了 objc_release(0x4...bad0)。由于这是一个无效的对象地址,程序立即崩溃,堆栈栈顶指向 objc_release。
复现代码:

崩溃栈顶:

崩溃场景二:读写并发 → objc_retain 崩溃
-
线程 A:执行 setter,写入哨兵值 0x4...bad0。
-
线程 B:此时执行 getter 来读取该属性。
-
关键点:getter 直接从内存中返回了当前的哨兵值 0x4...bad0。
-
崩溃: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 的技术实现细节,对相关内容感兴趣的可以关注公众号,敬请期待后续的更新~~