布景
键盘弹出时偶现的溃散,只呈现在 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 ThreadSafeMutableArray : NSProxy
@end
@implementation ThreadSafeMutableArray
{
NSMutableArray *_array;
}
- (instancetype)initWithArray:(NSMutableArray *)array {
self = [[self class] alloc];
_array = array;
return self;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [_array 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:_array];
});
}
@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 methods:methods]);
break;
}
}
return obj;
}
这个计划上线之后,线上的溃散量级减少了 90%,不能彻底修正的原因前面也提到过,溃散产生在数组取值之后的 retain 操作,不在数组内部锁的包括范围内。假如对数组只有 addObject
和 objectAtIndex
这两种操作,由于加锁之后不会对同一个 index 赋值,这个问题也就解了,可是实际上还有 remove
的操作,remove
和 objectAtIndex
是不能通过数组内部的锁来保证线程安全的调用。那为什么不能在数组外部调用的当地加锁的呢?由于数组的外部调用自身就有一把锁,并且外部的函数有相互调用,假如锁加在外部会形成死锁。
后续
线上东西检测到 promoteDeferredTaskIfIdle
和 addDeferredTask
的确存在数据竞赛的点。仅有的解释便是这把锁失效了。现在猜想是如下原因导致的,UIKeyboardTaskQueue
持有的锁的类型是 NSConditionLock
,体系对锁调用 tryLock
和 unLock
的逻辑也是成对呈现的,假如 tryLock
没有履行, unLock
正常履行了,就相当于只履行了一次 unLock
的操作,这个时候就会影响到其它线程 lock
的逻辑,我们把这个问题反馈给了苹果,有反馈之后再来同步下结论。