这个 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_msgSendobjc_retain, or objc_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] 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 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 是不能通过数组内部的锁来保证线程安全的调用。那为什么不能在数组外部调用的地方加锁的呢?因为数组的外部调用本身就有一把锁,而且外部的函数有相互调用,如果锁加在外部会造成死锁。

后续

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

猜你喜欢

转载自juejin.im/post/7225516440089559095
90