GCDAsyncSocket 在 iOS15 出现 -[_NSThreadPerformInfo dealloc] 崩溃排查笔记

2022-03-14 16:08:04 浏览数 (1)

本文会通过对 NSThread 的原理进行分析,对 iOS 15 开始出现的 [_NSThreadPerformInfo dealloc] 相关崩溃进行定位,并提供相应的解决方案

一、背景

从 iOS 15.0 Beta5 开始,集成开源库 GCDAsyncSocket 的 APP 开始出现 -[_NSThreadPerformInfo dealloc] 相关的崩溃

Crash on iOS 15.0 Beta5 [GCDAsyncSocket cfstreamThread] (GCDAsyncSocket.m:7596) · Issue #775 · robbiehanson/CocoaAsyncSocket · GitHub[1]

代码语言:javascript复制
Thread 32 name: GCDAsyncSocket-CFStream
Thread 32 Crashed:
0 libobjc.A.dylib 0x19b483c50 objc_release   16
1 Foundation 0x184161344 -[_NSThreadPerformInfo dealloc]   56
2 Foundation 0x1842d08dc __NSThreadPerform   160
3 CoreFoundation 0x1829b069c CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION   28
4 CoreFoundation 0x1829c12f0 __CFRunLoopDoSource0   208
5 CoreFoundation 0x1828fabf8 __CFRunLoopDoSources0   268
6 CoreFoundation 0x182900404 __CFRunLoopRun   820
7 CoreFoundation 0x182913fc8 CFRunLoopRunSpecific   600
8 Foundation 0x184134104 -[NSRunLoop  102660 (NSRunLoop) runMode:beforeDate:]   236
9 MyApp 0x104990290 0x1026b0000   36569744
10 Foundation 0x184183950 NSThread__start   764
11 libsystem_pthread.dylib 0x1f2745a60 _pthread_start   148
12 libsystem_pthread.dylib 0x1f2744f5c thread_start   8

因为该堆栈的崩溃发生在 系统库,导致很多 APP 都难以解决此类问题

二、GCDAsyncSocket 简介

GCDAsyncSocket 是一个 TCP 库。它建在 Grand Central Dispatch 之上。

通常情况下,我们可以通过下面的代码创建一个 GCDAsyncSocket 的实例

代码语言:javascript复制
socket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()];

并通过 connect... 方法建立一个连接

代码语言:javascript复制
NSError *err = nil;
if (![socket connectToHost:@"deusty.com" onPort:80 error:&err]) // Asynchronous!
{
    // If there was an error, it's likely something like "already connected" or "no delegate set"
    NSLog(@"I goofed: %@", err);
}

GCDAsyncSocket 建立连接

GCDAsyncSocket 内部会先执行域名解析[2]

代码语言:javascript复制
int gai_error = getaddrinfo([host UTF8String], [portStr UTF8String], &hints, &res0);

随后,通过ip 创建 socket[3] 和配置合适的参数

代码语言:javascript复制
int socketFD = socket(family, SOCK_STREAM, 0);
...
int result = bind(socketFD, interfaceAddr, (socklen_t)[connectInterface length]);

...
int result = connect(socketFD, (const struct sockaddr *)[address bytes], (socklen_t)[address length]);

当 socket 创建后,会创建对应 stream

并通过 CFReadStreamSetClient 函数设置 client

最后通过 CFReadStreamScheduleWithRunLoop 注册回调任务的 runloop

当关闭连接时,我们需要通过 CFReadStreamScheduleWithRunLoop 反注册

After scheduling stream with a run loop, its client (set with CFReadStreamSetClient) is notified when various events happen with the stream, such as when it finishes opening, when it has bytes available, and when an error occurs. A stream can be scheduled with multiple run loops and run loop modes. Use CFReadStreamUnscheduleFromRunLoop to later remove stream from the run loop.

GCDAsyncSocket 通过 unscheduleCFStreams: 函数实现反注册

代码语言:javascript复制
  (void)unscheduleCFStreams:(GCDAsyncSocket *)asyncSocket
{
 LogTrace();
 NSAssert([NSThread currentThread] == cfstreamThread, @"Invoked on wrong thread");

 CFRunLoopRef runLoop = CFRunLoopGetCurrent();

 if (asyncSocket->readStream)
  CFReadStreamUnscheduleFromRunLoop(asyncSocket->readStream, runLoop, kCFRunLoopDefaultMode);

 if (asyncSocket->writeStream)
  CFWriteStreamUnscheduleFromRunLoop(asyncSocket->writeStream, runLoop, kCFRunLoopDefaultMode);
}

GCDAsyncSocket 的实例被释放时,会通过下面的代码[4]将让 类GCDAsyncSocketcfstreamThread 线程执行 (void)unscheduleCFStreams:(GCDAsyncSocket *)asyncSocket{} 方法

⚠️ 注意:withObject 参数是 GCDAsyncSocket 的实例 waitUntilDone 参数的值是 YES,表示会阻塞当前线程

代码语言:javascript复制
[[self class] performSelector:@selector(unscheduleCFStreams:)
                                 onThread:cfstreamThread
                               withObject:self
                            waitUntilDone:YES];

三、 跨线程执行任务

现在,我们重点看一下系统库是如何实现 performSelector:onThread: 方法的。

通过前面的分析,我们可以注意到,系统库必须完成以下两个任务:

1、在另外的线程执行代码

2、阻塞当前线程,直到另一个线程执行完毕时恢复执行

本段内容是建立在iOS 12.4.6 (16G183) 系统版本上面进行分析

ReleadeTrack

为了方便对 GCDAsyncSocket 的引用计数进行追踪,我创建了一个子类 ReleadeTrack,读者可以将本文中出现 ReleadeTrack 的地方理解为 GCDAsyncSocket

代码语言:javascript复制
@interface ReleadeTrack : GCDAsyncSocket

@end

_NSThreadPerformInfo

performSelector:onThread:... 方法执行时,系统库会创建一个私有类 _NSThreadPerformInfo 的实例

_NSThreadPerformInfo 的实例会持有消息相关的信息

⚠️ 注意:_NSThreadPerformInfo 通过 argument 持有了开发者传入的参数 <ReleadeTrack: 0x10160f0a0>通过 waiter 持有了 NSCondition 的实例

代码语言:javascript复制
(lldb) ivars 0x1015b4c90
<_NSThreadPerformInfo: 0x1015b4c90>:
in _NSThreadPerformInfo:
 target (id): <ReleadeTrack: 0x100cabee0>
 selector (SEL): unscheduleCFStreams:
 argument (id): <ReleadeTrack: 0x10160f0a0>
 modes (NSMutableArray*): <__NSArrayM: 0x1015b4b40>
 waiter (NSCondition*): <NSCondition: 0x1015b3c70>
 signalled (char*): Value not representable, *
in NSObject:
 isa (Class): _NSThreadPerformInfo (isa, 0x21a21933b3d1)

(lldb)

并通过调用 -[NSThread _nq:]存到 NSThread 的私有属性 _private

代码语言:javascript复制
(lldb) po $x0
<NSThread: 0x100f1f9a0>{number = 2, name = GCDAsyncSocket-CFStream}

(lldb) po $x2
<_NSThreadPerformInfo: 0x100f058e0>

(lldb) ivars 0x100f1f9a0
<NSThread: 0x100f1f9a0>:
in NSThread:
 _private (id): <_NSThreadData: 0x100f318a0>
 _bytes (unsigned char[44]): Value not representable, [44C]
in NSObject:
 isa (Class): NSThread (isa, 0x41a21933b471)

(lldb) ivars 0x100f318a0
<_NSThreadData: 0x100f318a0>:
in _NSThreadData:
 dict (id): <__NSDictionaryM: 0x100f30130>
 name (id): @"GCDAsyncSocket-CFStream"
 target (id): <ReleadeTrack: 0x100707f58>
 selector (SEL): cfstreamThread:
 argument (id): nil
 seqNum (int): 2
 qstate (unsigned char): Value not representable, C
 qos (char): 0
 cancel (unsigned char): Value not representable, C
 status (unsigned char): Value not representable, C
 performQ (id): nil
 performD (NSMutableDictionary*): nil
 attr (struct _opaque_pthread_attr_t): {
  __sig (long): 1414022209
  __opaque (char[56]): Value not representable, [56c]
 }
 tid (struct _opaque_pthread_t*): 0x100f31928 -> 0x16fa93000
 pri (double): 0.5
 defpri (double): 0.5
in NSObject:
 isa (Class): _NSThreadData (isa, 0x1a21933b449)

(lldb)

该步执行完毕后,NSThread 的私有属性 _private 会指向 _NSThreadData 的实例,_NSThreadData 的私有属性 performQ 会保存 <_NSThreadPerformInfo: 0x100f058e0>

同时,-[NSThread _nq:] 方法会创建 CFRunloopSource 的实例并注册到 GCDAsyncSocket-CFStream 线程

NSCondition

因为 GCDAsyncSocket 销毁时,会将 waitUntilDone:YES 当做参数传入,所以,NSCondition 的实例会被创建并用于阻塞当前线程

NSCondition 的实例初始化时,会分别初始化pthread_mutex_tpthread_cond_init

代码语言:javascript复制
Foundation`-[NSCondition init]:
    0x1e088accc < 0>:   sub    sp, sp, #0x30             ; =0x30
    0x1e088acd0 < 4>:   stp    x20, x19, [sp, #0x10]
    0x1e088acd4 < 8>:   stp    x29, x30, [sp, #0x20]
    0x1e088acd8 < 12>:  add    x29, sp, #0x20            ; =0x20
    0x1e088acdc < 16>:  mov    x19, x0
    0x1e088ace0 < 20>:  bl     0x1df0f6370               ; object_getIndexedIvars
    0x1e088ace4 < 24>:  mov    x20, x0
    0x1e088ace8 < 28>:  adrp   x1, 232117
    0x1e088acec < 32>:  add    x1, x1, #0x868            ; =0x868
->  0x1e088acf0 < 36>:  bl     0x1dfb3d1d0               ; symbol stub for: pthread_mutex_init
    0x1e088acf4 < 40>:  cbz    w0, 0x1e088ad1c           ; < 80>
    0x1e088acf8 < 44>:  adrp   x8, 223297
    0x1e088acfc < 48>:  ldr    x8, [x8, #0x220]
    0x1e088ad00 < 52>:  stp    x19, x8, [sp]
    0x1e088ad04 < 56>:  adrp   x8, 180729
    0x1e088ad08 < 60>:  add    x1, x8, #0x417            ; =0x417
    0x1e088ad0c < 64>:  mov    x0, sp
    0x1e088ad10 < 68>:  bl     0x1df10b720               ; objc_msgSendSuper2
    0x1e088ad14 < 72>:  mov    x19, #0x0
    0x1e088ad18 < 76>:  b      0x1e088ad2c               ; < 96>
    0x1e088ad1c < 80>:  add    x0, x20, #0x40            ; =0x40
    0x1e088ad20 < 84>:  mov    x1, #0x0
    0x1e088ad24 < 88>:  bl     0x1dfb3d11c               ; symbol stub for: pthread_cond_init
    0x1e088ad28 < 92>:  str    xzr, [x20, #0x70]
    0x1e088ad2c < 96>:  mov    x0, x19
    0x1e088ad30 < 100>: ldp    x29, x30, [sp, #0x20]
    0x1e088ad34 < 104>: ldp    x20, x19, [sp, #0x10]
    0x1e088ad38 < 108>: add    sp, sp, #0x30             ; =0x30
    0x1e088ad3c < 112>: ret

随后,通过-[NSCondition lock] 方法间接调用 pthread_mutex_lock 加锁

代码语言:javascript复制
Foundation`-[NSCondition lock]:
->  0x1e088ae1c < 0>:  stp    x29, x30, [sp, #-0x10]!
    0x1e088ae20 < 4>:  mov    x29, sp
    0x1e088ae24 < 8>:  bl     0x1df0f6370               ; object_getIndexedIvars
    0x1e088ae28 < 12>: ldp    x29, x30, [sp], #0x10
    0x1e088ae2c < 16>: b      0x1e0acb720               ; symbol stub for: pthread_mutex_lock

然后,通过 -[NSCondition wait]方法间接调用 pthread_cond_wait 函数,阻塞当前线程

-[NSCondition wait]方法内部调用 pthread_cond_wait 函数实现阻塞

代码语言:javascript复制
Foundation`-[NSCondition wait]:
    0x1e08f249c < 0>:  stp    x29, x30, [sp, #-0x10]!
    0x1e08f24a0 < 4>:  mov    x29, sp
    0x1e08f24a4 < 8>:  bl     0x1df0f6370               ; object_getIndexedIvars
    0x1e08f24a8 < 12>: mov    x1, x0
    0x1e08f24ac < 16>: add    x0, x0, #0x40             ; =0x40
    0x1e08f24b0 < 20>: ldp    x29, x30, [sp], #0x10
->  0x1e08f24b4 < 24>: b      0x1dfb2ee7c               ; pthread_cond_wait

最后,通过 svc 调用阻塞当前线程

GCDAsyncSocket-CFStream 线程

GCDAsyncSocket-CFStream 线程通过 runloop 机制和前面创建的CFRunLoopSource 回调给 __NSThreadPerformPerform 函数

随后,__NSThreadPerformPerform 函数通过performQueueDequeue查找可以被执行的_NSThreadPerformInfo

performQueueDequeue 存在的原因是部分_NSThreadPerformInfo通过下面的方法指定了 mode

代码语言:javascript复制
- (void)performSelector:(SEL)aSelector
               onThread:(NSThread *)thr
             withObject:(nullable id)arg
          waitUntilDone:(BOOL)wait
                  modes:(nullable NSArray<NSString *> *)array

  API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));

找到合适的任务后, __NSThreadPerformPerform 函数会通过调用 performSelector:withObject: 完成指定的任务

现在,我们通过在 [GCDAsyncSocket scheduleCFStreams:] 添加断点并打印一下堆栈

代码语言:javascript复制
(lldb) bt
* thread #4, name = 'GCDAsyncSocket-CFStream', stop reason = breakpoint 16.1
  * frame #0: 0x0000000101111828 CocoaAsyncSocket` [GCDAsyncSocket scheduleCFStreams:](self=ReleadeTrack, _cmd="scheduleCFStreams:", asyncSocket=0x0000000101b0e0b0) at GCDAsyncSocket.m:7700:2
    frame #1: 0x00000001e09a2690 Foundation`__NSThreadPerformPerform   336
    frame #2: 0x00000001dfeacf1c CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__   24
    frame #3: 0x00000001dfeace9c CoreFoundation`__CFRunLoopDoSource0   88
    frame #4: 0x00000001dfeac784 CoreFoundation`__CFRunLoopDoSources0   176
    frame #5: 0x00000001dfea76c0 CoreFoundation`__CFRunLoopRun   1004
    frame #6: 0x00000001dfea6fb4 CoreFoundation`CFRunLoopRunSpecific   436
    frame #7: 0x00000001e087595c Foundation`-[NSRunLoop(NSRunLoop) runMode:beforeDate:]   300
    frame #8: 0x0000000101111780 CocoaAsyncSocket` [GCDAsyncSocket cfstreamThread:](self=ReleadeTrack, _cmd="cfstreamThread:", unused=0x0000000000000000) at GCDAsyncSocket.m:7689:25
    frame #9: 0x00000001e09a24a0 Foundation`__NSThread__start__   984
    frame #10: 0x00000001dfb392c0 libsystem_pthread.dylib`_pthread_body   128
    frame #11: 0x00000001dfb39220 libsystem_pthread.dylib`_pthread_start   44
    frame #12: 0x00000001dfb3ccdc libsystem_pthread.dylib`thread_start   4
(lldb)

通过堆栈,可以看到 __NSThreadPerformPerform 调用了 [GCDAsyncSocket scheduleCFStreams:]

清理 _NSThreadPerformInfo

GCDAsyncSocket-CFStream 线程执行任务结束后,会通过通过releasestr xzr, [x25, x8] 指令销毁 _NSThreadPerformInfo 存储的各种数据

通过反汇编工具,可以反解到以下代码:

代码语言:javascript复制
    r25->target = 0x0;
    r25->argument = 0x0;
    r25->modes = 0x0;

恢复阻塞的线程

随后,__NSThreadPerformPerform 函数会先调用 -[NSCondition lock]加锁,并通过-[NSCondition signal] 间接调用 pthread_cond_signal 函数的方式恢复另外一个被阻塞的线程

-[NSCondition signal] 方法通过 pthread_cond_signal 函数通知

代码语言:javascript复制
Foundation`-[NSCondition signal]:
->  0x1e08f7dc4 < 0>:  stp    x29, x30, [sp, #-0x10]!
    0x1e08f7dc8 < 4>:  mov    x29, sp
    0x1e08f7dcc < 8>:  bl     0x1df0f6370               ; object_getIndexedIvars
    0x1e08f7dd0 < 12>: add    x0, x0, #0x40             ; =0x40
    0x1e08f7dd4 < 16>: ldp    x29, x30, [sp], #0x10
    0x1e08f7dd8 < 20>: b      0x1dfb3d128               ; symbol stub for: pthread_cond_signal

四、iOS 15.x 新版本的跨线程执行任务

从某个版本开始,苹果对 _NSThreadPerformInfo 相关的设计进行了调整。下面以 iOS 15.2 (19C57) 为例进行分析

_NSThreadPerformInfo

因为 arm64e 架构的原因,_NSThreadPerformInfo 新增了一个 _pac_signature 属性

代码语言:javascript复制
(lldb) ivars $x0
<_NSThreadPerformInfo: 0x107824030>:
in _NSThreadPerformInfo:
 _target (id): <ReleadeTrack: 0x105114018>
 _selector (SEL): scheduleCFStreams:
 _argument (id): <ReleadeTrack: 0x1079074d0>
 _pac_signature (AQ): Value not representable, AQ
 _modes (NSArray*): <__NSSingleObjectArrayI: 0x107823f10>
 _waiter (NSCondition*): <NSCondition: 0x107825010>
 _state (int): 2
in NSObject:
 isa (Class): _NSThreadPerformInfo (isa, 0x1dae17a51)

(lldb)

同时,_NSThreadPerformInfo现在有 3 个实例方法

代码语言:javascript复制
22: regex = '_NSThreadPerformInfo', locations = 3, resolved = 3, hit count = 0
  22.1: where = Foundation`-[_NSThreadPerformInfo dealloc], address = 0x0000000182670a1c, resolved, hit count = 0
  22.2: where = Foundation`-[_NSThreadPerformInfo signal:], address = 0x00000001827dff68, resolved, hit count = 0
  22.3: where = Foundation`-[_NSThreadPerformInfo wait], address = 0x00000001827dffcc, resolved, hit count = 0

-[_NSThreadPerformInfo wait]

当需要阻塞当前线程时,会通过-[_NSThreadPerformInfo wait] 间接调用 -[NSCondition lock]-[NSCondition wait] -[NSCondition unlock] 实现

-[_NSThreadPerformInfo signal:]

当需要恢复被阻塞的线程时,会通过-[_NSThreadPerformInfo signal:] 间接调用-[NSCondition lock]-[NSCondition signal] 实现

-[_NSThreadPerformInfo dealloc]

另外一个重要改变是由-[_NSThreadPerformInfo dealloc] 负责释放各种属性,比如_argument就是由下面的代码触发释放的

_NSThreadPerformInfo 生命周期分析

现在,我们先看看 _NSThreadPerformInfo 的引用计数变化情况

正常情况:

非正常情况:

我们可以注意到,系统库销毁 _NSThreadPerformInfo 的时机存在两种情况:

1、触发performSelector:onThread: 的线程销毁

2、GCDAsyncSocket-CFStream 线程销毁

对于第二种情况,我们结合两个线程的执行顺序梳理后如下:

A 代表触发 performSelector:onThread: 的线程 B 代表 GCDAsyncSocket-CFStream 线程

代码语言:javascript复制

经过前面的分析,我们可以发现当 A 线程 通过free释放GCDAsyncSocket 实例的内存所有权后,

GCDAsyncSocket-CFStream 线程仍然会通过_NSThreadPerformInfo 持有悬垂指针,并通过 objc_release 减少引用计数

五、objc 内存管理机制

为了更好的理解崩溃堆栈,我们需要简单的回顾一下objc的内存管理机制

示例代码

代码语言:javascript复制
 Arc *obj = [Arc new];

在 ARC 环境下,上面的代码会变成以下的汇编代码:

tip: xor esi, esi 指令是通过异或操作将 esi 寄存器清零

代码语言:javascript复制
    0x100003a00 < 0>:  push   rbp
    0x100003a01 < 1>:  mov    rbp, rsp
    0x100003a04 < 4>:  sub    rsp, 0x20
    0x100003a08 < 8>:  mov    qword ptr [rbp - 0x18], rdi
    0x100003a0c < 12>: mov    qword ptr [rbp - 0x10], rsi
    0x100003a10 < 16>: mov    rdi, qword ptr [rip   0x49d1] ; (void *)0x0000000100008408: Arc
    0x100003a17 < 23>: call   0x100003d1a               ; symbol stub for: objc_opt_new
    0x100003a1c < 28>: mov    qword ptr [rbp - 0x8], rax
    # $rbp-0x8 内存位置存储  obj 的地址
    0x100003a20 < 32>: lea    rdi, [rbp - 0x8]
    # 通过 lea 让 $rdi 寄存器 存储 $rbp-0x8
    0x100003a24 < 36>: xor    esi, esi
    # 通过 xor 指令是通过异或操作将 esi 寄存器清零
    0x100003a26 < 38>: call   0x100003d32               ; symbol stub for: objc_storeStrong
    # 调用 objc_storeStrong 函数将 实例的地址 和 nil 当做参数传入
->  0x100003a2b < 43>: add    rsp, 0x20
    0x100003a2f < 47>: pop    rbp
    0x100003a30 < 48>: ret

编译器通过添加 objc_storeStrong() 函数将对象进行销毁

objc_storeStrong

objc_storeStrong 的实现逻辑如下

代码语言:javascript复制
void
objc_storeStrong(id *location, id obj)
{
    id prev = *location;
    if (obj == prev) {
        return;
    }
    objc_retain(obj);
    *location = obj;
    objc_release(prev);
}

代码语言:javascript复制
     rdi = 0x00007ffeefbf8868
     rsi = 0x0000000000000000

因为传入的第二个参数是 nil,所以,该步操作后,obj 指向 nil,确保 obj 不会出现悬垂指针。

tip:该设计可以保证 arc 下,可以有效的减少悬垂指针问题

等价于 mrc 代码:

代码语言:javascript复制
id prev = obj;
obj = nil;
objc_release(prev);

objc_release

代码语言:javascript复制
__attribute__((aligned(16), flatten, noinline))
void
objc_release(id obj)
{
    if (obj->isTaggedPointerOrNil()) return;
    return obj->release();
}

objc_release 内部的逻辑是先判断被降低引用计数的对象是否属于 tagged-pointer 或者 nil,如果是可以避免后续的处理

objc_object::release()

随后,开始通过 objc_object::release() 进行再次转发

代码语言:javascript复制
/// A pointer to an instance of a class.
typedef struct objc_object *id;

// Equivalent to calling [this release], with shortcuts if there is no override
inline void
objc_object::release()
{
    ASSERT(!isTaggedPointer());

    rootRelease(true, RRVariant::FastOrMsgSend);
}

objc_object::rootRelease

objc_object::rootRelease 主要进行以下任务:

1、通过 getDecodedClass 获取 class

2、通过 hasCustomRR 判断该类是否存在自定义的 release 方法,如果存在,则通过objc_msgSend 转发

3、执行引用计数减一的操作

4、随后判断是否需要执行 dealloc 的流程

5、如果需要,会通过开始执行 dealloc 方法

代码语言:javascript复制
ALWAYS_INLINE bool
objc_object::rootRelease(bool performDealloc, objc_object::RRVariant variant)
{
    if (slowpath(isTaggedPointer())) return false;

    bool sideTableLocked = false;

    isa_t newisa, oldisa;

    oldisa = LoadExclusive(&isa.bits);

    if (variant == RRVariant::FastOrMsgSend) {
        // These checks are only meaningful for objc_release()
        // They are here so that we avoid a re-load of the isa.
        if (slowpath(oldisa.getDecodedClass(false)->hasCustomRR())) {
            ClearExclusive(&isa.bits);
            if (oldisa.getDecodedClass(false)->canCallSwiftRR()) {
                swiftRelease.load(memory_order_relaxed)((id)this);
                return true;
            }
            ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(release));
            return true;
        }
    }

    if (slowpath(!oldisa.nonpointer)) {
        // a Class is a Class forever, so we can perform this checkonce
        // outside of the CAS loop
        if (oldisa.getDecodedClass(false)->isMetaClass()) {
            ClearExclusive(&isa.bits);
            return false;
        }
    }

retry:
    do {
        newisa = oldisa;
        if (slowpath(!newisa.nonpointer)) {
            ClearExclusive(&isa.bits);
            return sidetable_release(sideTableLocked, performDealloc);
        }
        if (slowpath(newisa.isDeallocating())) {
            ClearExclusive(&isa.bits);
            if (sideTableLocked) {
                ASSERT(variant == RRVariant::Full);
                sidetable_unlock();
            }
            return false;
        }

        // don't check newisa.fast_rr; we already called any RR overrides
        uintptr_t carry;
        newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc--
        if (slowpath(carry)) {
            // don't ClearExclusive()
            goto underflow;
        }
    } while (slowpath(!StoreReleaseExclusive(&isa.bits, &oldisa.bits, newisa.bits)));

    if (slowpath(newisa.isDeallocating()))
        goto deallocate;

    if (variant == RRVariant::Full) {
        if (slowpath(sideTableLocked)) sidetable_unlock();
    } else {
        ASSERT(!sideTableLocked);
    }
    return false;

 underflow:
    // newisa.extra_rc-- underflowed: borrow from side table or deallocate

    // abandon newisa to undo the decrement
    newisa = oldisa;

    if (slowpath(newisa.has_sidetable_rc)) {
        if (variant != RRVariant::Full) {
            ClearExclusive(&isa.bits);
            return rootRelease_underflow(performDealloc);
        }

        // Transfer retain count from side table to inline storage.

        if (!sideTableLocked) {
            ClearExclusive(&isa.bits);
            sidetable_lock();
            sideTableLocked = true;
            // Need to start over to avoid a race against
            // the nonpointer -> raw pointer transition.
            oldisa = LoadExclusive(&isa.bits);
            goto retry;
        }

        // Try to remove some retain counts from the side table.
        auto borrow = sidetable_subExtraRC_nolock(RC_HALF);

        bool emptySideTable = borrow.remaining == 0; // we'll clear the side table if no refcounts remain there

        if (borrow.borrowed > 0) {
            // Side table retain count decreased.
            // Try to add them to the inline count.
            bool didTransitionToDeallocating = false;
            newisa.extra_rc = borrow.borrowed - 1;  // redo the original decrement too
            newisa.has_sidetable_rc = !emptySideTable;

            bool stored = StoreReleaseExclusive(&isa.bits, &oldisa.bits, newisa.bits);

            if (!stored && oldisa.nonpointer) {
                // Inline update failed.
                // Try it again right now. This prevents livelock on LL/SC
                // architectures where the side table access itself may have
                // dropped the reservation.
                uintptr_t overflow;
                newisa.bits =
                    addc(oldisa.bits, RC_ONE * (borrow.borrowed-1), 0, &overflow);
                newisa.has_sidetable_rc = !emptySideTable;
                if (!overflow) {
                    stored = StoreReleaseExclusive(&isa.bits, &oldisa.bits, newisa.bits);
                    if (stored) {
                        didTransitionToDeallocating = newisa.isDeallocating();
                    }
                }
            }

            if (!stored) {
                // Inline update failed.
                // Put the retains back in the side table.
                ClearExclusive(&isa.bits);
                sidetable_addExtraRC_nolock(borrow.borrowed);
                oldisa = LoadExclusive(&isa.bits);
                goto retry;
            }

            // Decrement successful after borrowing from side table.
            if (emptySideTable)
                sidetable_clearExtraRC_nolock();

            if (!didTransitionToDeallocating) {
                if (slowpath(sideTableLocked)) sidetable_unlock();
                return false;
            }
        }
        else {
            // Side table is empty after all. Fall-through to the dealloc path.
        }
    }

deallocate:
    // Really deallocate.

    ASSERT(newisa.isDeallocating());
    ASSERT(isa.isDeallocating());

    if (slowpath(sideTableLocked)) sidetable_unlock();

    __c11_atomic_thread_fence(__ATOMIC_ACQUIRE);

    if (performDealloc) {
        ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
    }
    return true;
}

objc_object::rootRelease(bool performDealloc, objc_object::RRVariant variant) 关键代码整理后如下:

代码语言:javascript复制
    if (variant == RRVariant::FastOrMsgSend) {
        // These checks are only meaningful for objc_release()
        // They are here so that we avoid a re-load of the isa.
        if (slowpath(oldisa.getDecodedClass(false)->hasCustomRR())) {
            ClearExclusive(&isa.bits);
            if (oldisa.getDecodedClass(false)->canCallSwiftRR()) {
                swiftRelease.load(memory_order_relaxed)((id)this);
                return true;
            }
            ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(release));
            return true;
        }
    }
...
    newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry);
...
    if (slowpath(newisa.isDeallocating()))
        goto deallocate;
...
deallocate:
    // Really deallocate.

    if (performDealloc) {
        ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
    }
    return true;
代码语言:javascript复制
    bool isDeallocating() {
        return extra_rc == 0 && has_sidetable_rc == 0;
    }

getDecodedClass

现在,我们重点看一下第 1 步getDecodedClass的实现代码:

代码语言:javascript复制
inline Class
isa_t::getDecodedClass(bool authenticated) {
#if SUPPORT_INDEXED_ISA
    if (nonpointer) {
        return classForIndex(indexcls);
    }
    return (Class)cls;
#else
    return getClass(authenticated);
#endif
}

inline Class
isa_t::getClass(MAYBE_UNUSED_AUTHENTICATED_PARAM bool authenticated) {
#if SUPPORT_INDEXED_ISA
    return cls;
#else

    uintptr_t clsbits = bits;

#   if __has_feature(ptrauth_calls)
#       if ISA_SIGNING_AUTH_MODE == ISA_SIGNING_AUTH
    // Most callers aren't security critical, so skip the
    // authentication unless they ask for it. Message sending and
    // cache filling are protected by the auth code in msgSend.
    if (authenticated) {
        // Mask off all bits besides the class pointer and signature.
        clsbits &= ISA_MASK;
        if (clsbits == 0)
            return Nil;
        clsbits = (uintptr_t)ptrauth_auth_data((void *)clsbits, ISA_SIGNING_KEY, ptrauth_blend_discriminator(this, ISA_SIGNING_DISCRIMINATOR));
    } else {
        // If not authenticating, strip using the precomputed class mask.
        clsbits &= objc_debug_isa_class_mask;
    }
#       else
    // If not authenticating, strip using the precomputed class mask.
    clsbits &= objc_debug_isa_class_mask;
#       endif

#   else
    clsbits &= ISA_MASK;
#   endif

    return (Class)clsbits;
#endif
}


// a better definition is
//     (uintptr_t)ptrauth_strip((void *)ISA_MASK, ISA_SIGNING_KEY)
// however we know that PAC uses bits outside of MACH_VM_MAX_ADDRESS
// so approximate the definition here to be constant
template <typename T>
static constexpr T coveringMask(T n) {
    for (T mask = 0; mask != ~T{0}; mask = (mask << 1) | 1) {
        if ((n & mask) == n) return mask;
    }
    return ~T{0};
}
const uintptr_t objc_debug_isa_class_mask  = ISA_MASK & coveringMask(MACH_VM_MAX_ADDRESS - 1);

#     define ISA_MASK        0x0000000ffffffff8ULL


虽然上面的代码看着比较复杂,但是经过编译器处理后,它就变为了以下代码:

代码语言:javascript复制
0x1996c9b48 < 8>:   ldr    x8, [x0]
0x1996c9b4c < 12>:  and    x9, x8, #0xffffffff8

hasCustomRR

接下来,我们再看看第 2 步的实现代码:

代码语言:javascript复制
// class or superclass has default retain/release/autorelease/retainCount/
//   _tryRetain/_}isDeallocating/retainWeakReference/allowsWeakReference
#define FAST_HAS_DEFAULT_RR     (1UL<<2)

struct objc_object {
private:
    isa_t isa;
}

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

    bool hasCustomRR() const {
        return !bits.getBit(FAST_HAS_DEFAULT_RR);
    }
}

struct class_data_bits_t {
    friend objc_class;

    // Values are the FAST_ flags above.
    uintptr_t bits;
private:
    bool getBit(uintptr_t bit) const
    {
        return bits & bit;
    }
}

第 2 步的逻辑比较简单,编译处理后的汇编如下:

代码语言:javascript复制
->  0x1996c9b50 < 16>:  ldr    x10, [x9, #0x20]
    0x1996c9b54 < 20>:  tbz    w10, #0x2, 0x1996c9bb4    ; < 116>

编译器通过内联优化,最后会变成本次崩溃的汇编代码:

代码语言:javascript复制
libobjc.A.dylib`objc_release:
// 判断是否属于nil或者tag
    0x1996c9b40 < 0>:   cmp    x0, #0x1                  ; =0x1
    0x1996c9b44 < 4>:   b.lt   0x1996c9bb0               ; < 112>

// 读取 isa
    0x1996c9b48 < 8>:   ldr    x8, [x0]

// 读取 class
    0x1996c9b4c < 12>:  and    x9, x8, #0xffffffff8

// 读取 class 的 bits
->  0x1996c9b50 < 16>:  ldr    x10, [x9, #0x20]

// 判断 class 的 bits 是否存在标志信息
    0x1996c9b54 < 20>:  tbz    w10, #0x2, 0x1996c9bb4    ; < 116>

    0x1996c9b58 < 24>:  tbz    w8, #0x0, 0x1996c9bd4     ; < 148>
    0x1996c9b5c < 28>:  mov    x9, #0x100000000000000
    0x1996c9b60 < 32>:  lsr    x10, x8, #55
    0x1996c9b64 < 36>:  cbz    x10, 0x1996c9bb0          ; < 112>
    0x1996c9b68 < 40>:  subs   x10, x8, x9
    0x1996c9b6c < 44>:  b.lo   0x1996c9b94               ; < 84>
    0x1996c9b70 < 48>:  mov    x11, x8当

六、崩溃原理

现在,我们对前面的内容进行一下总结:

当出现悬垂指针并且悬垂指针指向的地址被其它代码重新申请后进行赋值操作,并且新值不符合 isTaggedPointer 规定,随后通过isa--> class-->bits 进行内存读取操作时就会触发崩溃。

小结:

经过前面的分析,我们可以得知,iOS 的新系统中存在一个 bug,该 bug 导致即使我们通过将参数waitUntilDone 设置为YES 的方式阻塞当前线程时,仍然存在触发悬垂指针的可能。

代码语言:javascript复制
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString *> *)array API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));

七、解决方案

因为崩溃的原因是调用performSelector:onThread:时,参数会被系统私有类持有导致崩溃,所以,我们可以通过以下方案解决:

1、通过单例持有 GCDAsyncSocket,避免调用 -[GCDAsyncSocket dealloc]

2、先主动调用-[GCDAsyncSocket disconnect],再释放GCDAsyncSocket的实例

3、通过调整withObject:的参数,避免将 GCDAsyncSocket 的实例进行传递

代码语言:javascript复制
[[self class] performSelector:@selector(unscheduleCFWriteStreams:)
                     onThread:cfstreamThread
                   withObject:(__bridge id _Nullable)self->writeStream

0 人点赞