如果你还不了解什么是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操作,防止在读取的过程中被释放。