布景

键盘弹出时偶现的溃散,只呈现在 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 theobjc_msgSend,objc_retain, orobjc_releasefunctions.

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] x19UIKeyboardTaskQueue 实例,依据上面 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 操作,不在数组内部锁的包括范围内。假如对数组只有 addObjectobjectAtIndex 这两种操作,由于加锁之后不会对同一个 index 赋值,这个问题也就解了,可是实际上还有 remove 的操作,removeobjectAtIndex 是不能通过数组内部的锁来保证线程安全的调用。那为什么不能在数组外部调用的当地加锁的呢?由于数组的外部调用自身就有一把锁,并且外部的函数有相互调用,假如锁加在外部会形成死锁。

后续

线上东西检测到 promoteDeferredTaskIfIdleaddDeferredTask 的确存在数据竞赛的点。仅有的解释便是这把锁失效了。现在猜想是如下原因导致的,UIKeyboardTaskQueue 持有的锁的类型是 NSConditionLock,体系对锁调用 tryLockunLock 的逻辑也是成对呈现的,假如 tryLock 没有履行, unLock 正常履行了,就相当于只履行了一次 unLock的操作,这个时候就会影响到其它线程 lock 的逻辑,我们把这个问题反馈给了苹果,有反馈之后再来同步下结论。