如果你还不了解什么是runloop,可以看这里的详解深入理解RunLoop。
苹果官方文档中,声明了CFRunloop是线程安全的:
Thread safety varies depending on which API you are using to manipulate your run loop. The functions in Core Foundation are generally thread-safe and can be called from any thread. If you are performing operations that alter the configuration of the run loop, however, it is still good practice to do so from the thread that owns the run loop whenever possible.
但是需要注意的是,狡猾的苹果使用了generally这个模糊的词。
从实践中来看,CFRunloop在停止runloop的阶段的某些操作是存在多线程隐患的。
不安全的CFRunloopSource
CFRunloop是线程安全的,但是加上CFRunloopSource就不一定了。比如CFSocket。
示例代码
看这样一段自定义线程的代码:
|
|
在实践中,CFSocket是被另一个socket类管理的,所以addSocketSource和removeSocketSource都是在另一个类中的,也就有可能出现CFSocketInvalidate和 CFRunLoopStop多线程同时调用的情况。
crash实例分析
看上去并没有什么问题,该加锁的地方都加锁了,而且CF开头的那几个方法都是线程安全的。但是这时候,如果出现CFSocketInvalidate和 CFRunLoopStop多线程同时调用的情况,就有crash的可能。例如我们项目里收到的某个crash:
|
|
CFSocketInvalidate在主线程被调用了。看堆栈,在CFSocketInvalidate内部调用CFRunLoopWakeUp时,出现了crash。
看不出具体是什么原因crash,所以需要看看是在CFRunLoopWakeUp的哪里挂的。查看对应版本的CoreFoundation的汇编代码:
|
|
crash日志中,崩溃在CFRunLoopWakeUp + 92,对应汇编地址为0x0000000181521b9c + 92=0x0000000181521bf8,在ldr w8, [x8, #0xc]的时候挂了。查看crash时寄存器的值,x8: 0x8c8c8c8c8c8c8c8c,很明显x8指向的内存已经被释放了。x8是从ldr x8, [x20, #0x58]得来的(也就是x20的地址偏移0x58后的值),而x20则是从mov x20, x0得来的,x0就是CFRunloopWakeUp的第一个参数,CFRunLoopRef结构体,所以x8就是CFRunLoopRef偏移0x58后的值。
CoreFoundation的代码是开源的,可以在这里下载:CF-1153.18。
对应CFRunloopWakeUp源码:
|
|
CFRunloop结构体:
|
|
计算结构体size后,得出ldr x8, [x20, #0x58]就是runloop-> _perRunData。也就是在调用__CFRunLoopIsIgnoringWakeUps的时候,CFRunLoopRef已经被释放了。
分析CFSocket源码
查看CFSocketInvalidate源码:
|
|
CFSocketInvalidate中唯一使用到CFRunLoopWakeUp的地方,就是最后遍历runloops的操作。
但是此时CFRunLoopRef还在数组里,正在被数组强引用,到了CFRunLoopWakeUp里怎么就被释放了呢?
注意,CFSocketInvalidate里遍历runloops的操作是在锁外面进行的,说明CFSocket很有可能没有管理好它的runloops数组,导致数组在遍历时被释放了。从Do this after the socket unlock to avoid deadlock (10462525)这一行注释猜测,这部分遍历操作之前应该也是在锁内的,但是会出现死锁,所以放到了锁外。苹果的bug report是不对外公开的,只在这里找到了可能相关的讨论:bug #10462525。
最大的可能是出现在__CFSocketCancel里。在runloop停止的时候,也会执行remove source操作,在CFRunLoopRemoveSource里,会执行source0的cancel函数,也就是__CFSocketCancel:
|
|
__CFSocketCancel源码:
|
|
__CFSocketCancel也有一次对CFRunloopRef的释放操作,加上CFSocketInvalidate里的2个,总共有3个释放操作。
所以,如果__CFSocketCancel和CFSocketInvalidate在多线程同时执行,就有可能出现对CFSocket中的runloops数组过度释放,因此在遍历runloops的时候就会出现CFRunLoopRef被释放的情况。虽然这个crash出现的概率比较低,但是在项目里隔一段时间就会稳定出现。
所以,不是加了锁就万事大吉了,CFSocketInvalidate里在遍历数组前应该再加一个retain才能保证安全。
解决方法
- 既然是CFSocket里的bug,那就只能避免不要出现
CFSocketInvalidate和CFRunloopStop多线程执行的代码。 - 如果你的socket只在这个线程里运行,那直接调用
CFRunloopStop即可,runloop会自动清理所有source。 - 如果这个线程需要重用,那就不需要stop,而是停止socket后,在同一个线程里新建socket。
自动停止的Runloop
那么,如果把stop代码改成这样,应该就没问题了吧?
|
|
很遗憾,这样写还是不安全的。
原因在于removeSocketSource之后,runloop里source就全部为空了,runloop如果检测到了source为空,就会自动停止runloop循环,销毁线程。
因此如果你在另一个线程调用stopThread,在removeSocketSource之后线程就会随时停止,runloop在调用CFRunLoopStop时可能已经被释放了。
上面的写法出现crash的概率太低,但是稍微改一下就能必现:
|
|
这种情况下crash的原因其实是没做好内存管理,只要对runloop增加一次retain操作就没问题了:
|
|
结论
在使用runloop source的时候要谨慎,尤其在处理stop的阶段。其他source可能也存在类似的问题。
一个变量有多线程操作的时候,在锁外的操作即使是只读也是不安全的,在读取之前最好再做一次retain操作,防止在读取的过程中被释放。