这个 bug 不简单我只修复了 90%
背景
键盘弹出时偶现的崩溃,只出现在 iOS 16 及以上的系统版本。崩溃堆栈如下:
0 libobjc.A.dylib _objc_retain()
1 UIKitCore -[UIKeyboardTaskQueue promoteDeferredTaskIfIdle]()
2 UIKitCore -[UIKeyboardTaskQueue performDeferredTaskIfIdle]()
3 UIKitCore -[UIKeyboardTaskQueue continueExecutionOnMainThread]()
4 Foundation ___NSThreadPerformPerform()
根据苹果的文档 investigating-crashes-for-zombie-objects 崩溃发生在 _objc_retain
,初步原因判定为 zombie。
Determine whether a crash report has signs of a zombie
The Objective-C runtime can’t message objects deallocated from memory, so crashes often occur in the
objc_msgSend
,objc_retain
, orobjc_release
functions.
zombie 问题的解决思路通常是先找到 zombie 的对象,然后根据这个对象的使用场景找到数据竞争的代码路径,如果是业务层的代码,即使没有 zombie 或者 asan 工具,解析出触发 zombie 崩溃的 address 对应的行和列能够确定 zombie 的对象。键盘的崩溃发生在系统堆栈,我们只能通过反汇编 + debug 调试理解崩溃的上下文,找到问题所在。当然即使是系统层面的 zombie 问题,因为是 OC 的调用栈,修复都相对简单,而这个键盘上的崩溃难就难在你即使知道了原因也不能做到完全修复。
崩溃排查
UIKeyboardTaskQueue 类
崩溃发生在 OC 实例方法的调用, 调用栈和 DeferredTask
(延时任务)相关。可以使用 otool 查看一些相关的方法和属性。
关联方法
这里有一个崩溃栈之外的方法 addDeferredTask
翻译过来是添加延时任务,执行延时任务需要取任务,猜测和这里存在数据竞争的概率很大。
imp 0xff63b4ac (0xc1cfe8) -[UIKeyboardTaskQueue promoteDeferredTaskIfIdle]
imp 0xff63b470 (0xc1cfa0) -[UIKeyboardTaskQueue performDeferredTaskIfIdle]
imp 0xff63b644 (0xc1d1a4) -[UIKeyboardTaskQueue addDeferredTask:]
关联属性:
_deferredTasks
猜测上述方法 promoteDeferredTaskIfIdle
addDeferredTask
的调用是在操作这个数组。
offset 0x1e3eec8 _OBJC_IVAR_$_UIKeyboardTaskQueue._deferredTasks 32
name 0x197f0c0 _deferredTasks
type 0x1a5e329 @"NSMutableArray"
分析崩溃堆栈
promoteDeferredTaskIfIdle
zombie 在这个函数内触发,需要分析这个函数找到 zombie 对象。
汇编代码
-[UIKeyboardTaskQueue promoteDeferredTaskIfIdle]:
0000000189b1ebb8 pacibsp
0000000189b1ebbc sub sp, sp, #0x30
0000000189b1ebc0 stp x20, x19, [sp, #0x10]
0000000189b1ebc4 stp fp, lr, [sp, #0x20]
0000000189b1ebc8 add fp, sp, #0x20
0000000189b1ebcc ldr x8, [x0, #0x28]
0000000189b1ebd0 cbz x8, loc_189b1ebe4
loc_189b1ebd4:
0000000189b1ebd4 ldp fp, lr, [sp, #0x20] ; CODE XREF=-[UIKeyboardTaskQueue promoteDeferredTaskIfIdle]+56
0000000189b1ebd8 ldp x20, x19, [sp, #0x10]
0000000189b1ebdc add sp, sp, #0x30
0000000189b1ebe0 retab
; endp
loc_189b1ebe4:
0000000189b1ebe4 mov x19, x0 ; CODE XREF=-[UIKeyboardTaskQueue promoteDeferredTaskIfIdle]+24
0000000189b1ebe8 ldr x0, [x0, #0x20]
0000000189b1ebec bl _objc_msgSend$count ; _objc_msgSend$count
0000000189b1ebf0 cbz x0, loc_189b1ebd4
0000000189b1ebf4 ldr x0, [x19, #0x20]
0000000189b1ebf8 mov x2, #0x0
0000000189b1ebfc bl _objc_msgSend$objectAtIndex: ; _objc_msgSend$objectAtIndex:
0000000189b1ec00 bl 0x18c873b70 <======= 崩溃发生在这里
0000000189b1ec04 mov x2, x0
0000000189b1ec08 str x0, [sp, #0x20 + var_18]
0000000189b1ec0c ldr x0, [x19, #0x18]
0000000189b1ec10 bl _objc_msgSend$addObject: ; _objc_msgSend$addObject:
0000000189b1ec14 ldr x0, [x19, #0x20]
0000000189b1ec18 mov x2, #0x0
0000000189b1ec1c bl _objc_msgSend$removeObjectAtIndex: ; _objc_msgSend$removeObjectAtIndex:
0000000189b1ec20 ldr x0, [sp, #0x20 + var_18]
0000000189b1ec24 ldp fp, lr, [sp, #0x20]
0000000189b1ec28 ldp x20, x19, [sp, #0x10]
0000000189b1ec2c add sp, sp, #0x30
0000000189b1ec30 autibsp
0000000189b1ec34 eor x16, lr, lr, lsl #1
0000000189b1ec38 tbz x16, 0x3e, loc_189b1ec40
崩溃发生在 0000000189b1ec00,此时是在 retain _objc_msgSend$objectAtIndex:
返回的 object。_objc_msgSend$objectAtIndex:
的参数 self
是 [x19, #0x20]
x19
是 UIKeyboardTaskQueue
实例,根据上面 otool 的分析 offset 0x20 的位置是 _deferredTasks
对象。_deferredTasks
这个数组的元素在另一个线程并发 release
导致在当前线程返回了 dangling pointer,触发了 zombie crash。看到这里,既然是数组多线程访问的问题,把数组替换为线程安全的数组,这个问题不就解了吗?这个方案并不完美,崩溃发生在数组取值之后的 _objc_retain
操作,即使是数组内部加锁,锁的范围也不能覆盖到外部的 retain 操作。
伪代码
理解下这个函数的功能,promoteDeferredTaskIfIdle
这个函数实现是把 task
从 _deferredTasks
数组里面转移到了 _tasks
数组里面。
void -[UIKeyboardTaskQueue promoteDeferredTaskIfIdle](int arg0) {
r0 = arg0;
r31 = r31 - 0x30;
var_10 = r20;
stack[-24] = r19;
saved_fp = r29;
stack[-8] = r30;
if (*(r0 + 0x28) == 0x0) {
r19 = r0;
if (_objc_msgSend$count() != 0x0) {
_objc_msgSend$objectAtIndex:(); // 从 _deferredTasks 取出
var_18 = loc_18c873b70();
_objc_msgSend$addObject:(); // 添加到 _tasks 数组
_objc_msgSend$removeObjectAtIndex:(); // 从 _deferredTasks 移除
r0 = var_18;
if (((stack[-8] ^ stack[-8] * 0x2) & 0x40000000) != 0x0) {
asm { brk #0xc471 };
loc_189b1ec40(r0);
}
else {
loc_18c873d00();
}
}
}
return;
}
performDeferredTaskIfIdle
上层调用函数先 加锁 然后调用 promoteDeferredTaskIfIdle
方法,如果其他代码路径下对 _deferredTasks
的操作也加了这把锁,那理论上不会存在数据竞争的点。
int -[UIKeyboardTaskQueue performDeferredTaskIfIdle](int arg0) {
_objc_msgSend$lock();
_objc_msgSend$promoteDeferredTaskIfIdle();
_objc_msgSend$unlock();
r0 = arg0;
if (((r30 ^ r30 * 0x2) & 0x40000000) != 0x0) {
asm { brk #0xc471 };
r0 = loc_189b1ebb4(r0);
}
else {
r0 = _objc_msgSend$continueExecutionOnMainThread();
}
return r0;
}
addDeferredTask
伪代码如下
int -[UIKeyboardTaskQueue addDeferredTask:](int arg0) {
loc_18c873eb0(arg0);
_objc_msgSend$lock(arg0);
loc_18c873ae0(@class(UIKeyboardTaskEntry));
var_18 = _objc_msgSend$initWithTask:();
loc_18c873d50();
_objc_msgSend$addObject:(*(arg0 + 0x20));
_objc_msgSend$unlock(arg0);
_objc_msgSend$continueExecutionOnMainThread(arg0);
r0 = var_18;
if (((r30 ^ r30 * 0x2) & 0x40000000) != 0x0) {
asm { brk #0xc471 };
r0 = loc_189b1ed38(r0);
}
else {
r0 = loc_18c873d00();
}
return r0;
}
这里对数组的操作 _objc_msgSend$addObject:(*(arg0 + 0x20))
会先取数组的 count 然后在 count 的位置插入元素,如果多线程并发访问 addObject
,可能在对同一个 index 插入值,导致先插入的值被释放,同时多线程取值如果访问到之前插入的值,这个值已经是 dangling pointer,会触发 crash。但是 addDeferredTask
对数组的操作也是在 lock 范围内,addObject 之间理论上是线程安全的,addObject 和崩溃堆栈 promoteDeferredTaskIfIdle
里面的 objectAtIndex
理论上也是线程安全的。_deferredTasks
还存在多线程访问的原因可能是有其他的调用在修改数组或者是这把锁失效。
小结
数据竞争的点还没有找到,可以先从 _deferredTasks
入手,保证所有对 _deferredTasks
的操作都是线程安全的。
修复方案
_deferredTasks
替换为线程安全的数组。套用这个方案一定要理解清楚并且加好开关限制系统版本!!!
@interface UIKeyboardDeferredTasksWrapper : NSProxy
@end
@implementation UIKeyboardDeferredTasksWrapper
{
NSMutableArray *_tasks;
}
- (instancetype)initWithTasks:(NSMutableArray *)tasks {
self = [[self class] alloc];
_tasks = task;
return self;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [_tasks methodSignatureForSelector:sel];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
static dispatch_queue_t queue = nil; // 是否要关联 target?
if (queue == nil) {
queue = dispatch_queue_create("com.platform.taskqueue", 0x0);
}
dispatch_sync(queue, ^{
[invocation invokeWithTarget:_tasks];
});
}
@end
替换 UIKeyboardTaskQueue
的 _deferredTasks
成员变量。
static id new_task_queue_init(id self, SEL _cmd) {
id obj = origin_task_queue_init(self, _cmd);
unsigned int count = 0;
Ivar *ivars = class_copyIvarList([self class], &count);
for (int i = 0; i < count; i ++) {
Ivar ivar = ivars[i];
const char *ivar_name = ivar_getName(ivar);
if (strcmp([tasksIvarName cStringUsingEncoding:NSUTF8StringEncoding], ivar_name) == 0) {
id ivar_value = object_getIvar(self, ivar);
if (![ivar_value isKindOfClass:[NSMutableArray class]]) {
break;
}
object_setIvar(self, ivar, [[UIKeyboardDeferredTasksWrapper alloc] initWithTasks:(NSMutableArray *)ivar_value]);
break;
}
}
return obj;
}
这个方案上线之后,线上的崩溃量级减少了 90%,不能完全修复的原因前面也提到过,崩溃发生在数组取值之后的 retain 操作,不在数组内部锁的包含范围内。如果对数组只有 addObject 和 objectAtIndex 这两种操作,因为加锁之后不会对同一个 index 赋值,这个问题也就解了,但是实际上还有 remove 的操作,remove 和 objectAtIndex 是不能通过数组内部的锁来保证线程安全的调用。那为什么不能在数组外部调用的地方加锁的呢?因为数组的外部调用本身就有一把锁,而且外部的函数有相互调用,如果锁加在外部会造成死锁。
后续
线上工具检测到 promoteDeferredTaskIfIdle
和 addDeferredTask
确实存在数据竞争的点。唯一的解释就是这把锁失效了。目前猜测是如下原因导致的,UIKeyboardTaskQueue
持有的锁的类型是 NSConditionLock
,系统对锁调用 tryLock
和 unLock
的逻辑也是成对出现的,如果 tryLock
没有执行, unLock
正常执行了,就相当于只执行了一次 unLock
的操作,这个时候就会影响到其它线程 lock
的逻辑,我们把这个问题反馈给了苹果,有反馈之后再来同步下结论。
转载自:https://juejin.cn/post/7225516440089559095