如果你還不瞭解什麼是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。
示例程式碼
看這樣一段自定義執行緒的程式碼:
@interface MyThread()
@property (nonatomic, strong) NSThread *currentThread;
@property (nonatomic, assign) CFRunLoopSourceRef socketSource;
@property (nonatomic, assign) CFSocketRef socket;
@property (nonatomic, assign) CFRunLoopRef currentRunloop;
@end
@implementation MyThread
//初始化執行緒
- (instancetype)init {
if (self = [super init]) {
_currentThread = [[NSThread alloc] initWithTarget:self selector:@selector(runThread) object:nil];
}
return self;
}
//開啟執行緒;此方法在使用時沒有多執行緒呼叫
- (void)startThread {
[self.currentThread start];
}
//執行緒入口
- (void)runThread {
@autoreleasepool {
//返回runloop,可以讓其他執行緒停止此執行緒
self.currentRunloop = CFRunLoopGetCurrent();
[self addSocketSource];
CFRunLoopRun();
}
NSLog(@"執行緒退出");
}
//此方法在使用時沒有多執行緒呼叫
- (void)stopThread {
[self removeSocketSource];
@synchronized (_currentRunloop) {
if (_currentRunloop) {
CFRunLoopStop(_currentRunloop);
self.currentRunloop = NULL;
}
}
}
//此方法在使用時沒有多執行緒呼叫
- (void)addSocketSource {
int sock;
sock = socket(AF_INET6, SOCK_STREAM, 0);
CFSocketContext context = {0, (__bridge void *)(self), NULL, NULL, NULL};
self.socket = CFSocketCreateWithNative(NULL, sock, kCFSocketReadCallBack, socketCallBack, &context);
self.socketSource = CFSocketCreateRunLoopSource(NULL, self.socket, 0);
CFRunLoopAddSource(_currentRunloop, _socketSource, kCFRunLoopDefaultMode);
}
- (void)removeSocketSource {
@synchronized (_socket) {
if (_socket) {
//CFSocketInvalidate可能被拋到另一個執行緒去執行,因此 CFSocketInvalidate 和 CFRunLoopStop可能有多執行緒同時呼叫的情況
CFSocketInvalidate(_socket);
CFRelease(_socket);
self.socket = NULL;
}
}
}
複製程式碼
在實踐中,CFSocket是被另一個socket類管理的,所以addSocketSource
和removeSocketSource
都是在另一個類中的,也就有可能出現CFSocketInvalidate
和 CFRunLoopStop
多執行緒同時呼叫的情況。
crash例項分析
看上去並沒有什麼問題,該加鎖的地方都加鎖了,而且CF開頭的那幾個方法都是執行緒安全的。但是這時候,如果出現CFSocketInvalidate
和 CFRunLoopStop
多執行緒同時呼叫的情況,就有crash的可能。例如我們專案裡收到的某個crash:
Thread 0 name: Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0 CoreFoundation 0x000000018e6a9144 CFRunLoopWakeUp + 92
1 CoreFoundation 0x000000018e6a9140 CFRunLoopWakeUp + 88
2 CoreFoundation 0x000000018e6d71e8 CFSocketInvalidate + 712
3 MyApp 0x00000001000fe424 (-[MySocket stop] + 136)
4 MyApp 0x00000001000fcd50 (-[MySocket dealloc] + 56)
5 libsystem_blocks.dylib 0x000000018d6afa28 _Block_release + 144
6 libdispatch.dylib 0x000000018d65a1bc _dispatch_client_callout + 16
7 libdispatch.dylib 0x000000018d65ed68 _dispatch_main_queue_callback_4CF + 1000
8 CoreFoundation 0x000000018e77e810 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 12
9 CoreFoundation 0x000000018e77c3fc __CFRunLoopRun + 1660
10 CoreFoundation 0x000000018e6aa2b8 CFRunLoopRunSpecific + 444
11 GraphicsServices 0x000000019015e198 GSEventRunModal + 180
12 UIKit 0x00000001946f17fc -[UIApplication _run] + 684
13 UIKit 0x00000001946ec534 UIApplicationMain + 208
14 DuoYiIM 0x000000010003ca58 0x100024000 + 100952 (main + 132)
15 libdyld.dylib 0x000000018d68d5b8 start + 4
Thread 0 crashed with ARM-64 Thread State:
cpsr: 0x0000000020000000 fp: 0x000000016fddab30 lr: 0x000000018e6a9140 pc: 0x000000018e6a9144
sp: 0x000000016fddaa00 x0: 0x0000000000000000 x1: 0x0000000000000000 x10: 0x0000000000000000
x11: 0x0000000000000000 x12: 0x0000000000000000 x13: 0x0000000000000000 x14: 0x0000000000000000
x15: 0x0000000000001203 x16: 0x000000000000012d x17: 0x000000018f1eef74 x18: 0x0000000000000000
x19: 0x000000017056cb50 x2: 0x0000000000001000 x20: 0x000000017056cb40 x21: 0x96e73914144e0055
x22: 0x0000000174452990 x23: 0x000000017048bae0 x24: 0x0000000000000000 x25: 0x00000000ffffffff
x26: 0xffffffffffffffff x27: 0x000000017426f1c0 x28: 0x0000000002ffffff x29: 0x000000016fddab30
x3: 0x000000000017e4a6 x4: 0x0000000000012068 x5: 0x0000000000000000 x6: 0x0000000000000036
x7: 0xffffffffffffffec x8: 0x8c8c8c8c8c8c8c8c x9: 0x000000000000000c
複製程式碼
CFSocketInvalidate
在主執行緒被呼叫了。看堆疊,在CFSocketInvalidate
內部呼叫CFRunLoopWakeUp
時,出現了crash。
看不出具體是什麼原因crash,所以需要看看是在CFRunLoopWakeUp
的哪裡掛的。檢視對應版本的CoreFoundation
的彙編程式碼:
_CFRunLoopWakeUp:
0x0000000181521b9c FF0305D1 sub sp, sp, #0x140 ; CODE XREF=_CFRunLoopAddTimer+696, _CFRunLoopTimerSetNextFireDate+592, _CFSocketInvalidate+708, __wakeUpRunLoop+276, __CFXRegistrationPost+344, -[CFPrefsSearchListSource asynchronouslyNotifyOfChangesFromDictionary:toDictionary:]+172, ___CFSocketPerformV0+1408, ___CFSocketManager+2004, ___CFSocketManager+4248, _boundPairRead+604, _boundPairReadClose+124, …
0x0000000181521ba0 FC6F11A9 stp x28, x27, [sp, #0x110]
0x0000000181521ba4 F44F12A9 stp x20, x19, [sp, #0x120]
0x0000000181521ba8 FD7B13A9 stp x29, x30, [sp, #0x130]
0x0000000181521bac FDC30491 add x29, sp, #0x130
0x0000000181521bb0 F40300AA mov x20, x0
0x0000000181521bb4 C80C10F0 adrp x8, #0x1a16bc000
0x0000000181521bb8 084140F9 ldr x8, [x8, #0x80] ; -[_CFXPreferences init]_1a16bc080
0x0000000181521bbc 080140F9 ldr x8, [x8]
0x0000000181521bc0 292013F0 adrp x9, #0x1a7928000
0x0000000181521bc4 29E90791 add x9, x9, #0x1fa ; ___CF120290
0x0000000181521bc8 A8831DF8 stur x8, [x29, #-0x28]
0x0000000181521bcc E8030032 orr w8, wzr, #0x1
0x0000000181521bd0 28010039 strb w8, [x9] ; ___CF120290
0x0000000181521bd4 E8731290 adrp x8, #0x1a639d000
0x0000000181521bd8 08F13F91 add x8, x8, #0xffc ; ___CF120293
0x0000000181521bdc 08014039 ldrb w8, [x8] ; ___CF120293
0x0000000181521be0 48000034 cbz w8, loc_181521be8
0x0000000181521be4 E3560394 bl ___THE_PROCESS_HAS_FORKED_AND_YOU_CANNOT_USE_THIS_COREFOUNDATION_FUNCTIONALITY___YOU_MUST_EXEC__
loc_181521be8:
0x0000000181521be8 93420091 add x19, x20, #0x10 ; CODE XREF=_CFRunLoopWakeUp+68
0x0000000181521bec E00313AA mov x0, x19
0x0000000181521bf0 70300694 bl imp___stubs_-[NSOrderedSet sortedArrayFromRange:options:usingComparator:]//真機的系統庫做了混淆,這裡其實是__CFRunLoopLock
0x0000000181521bf4 882E40F9 ldr x8, [x20, #0x58]
0x0000000181521bf8 080D40B9 ldr w8, [x8, #0xc]
0x0000000181521bfc A8010034 cbz w8, loc_181521c30
複製程式碼
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
原始碼:
void CFRunLoopWakeUp(CFRunLoopRef rl) {
CHECK_FOR_FORK();
__CFRunLoopLock(rl);
if (__CFRunLoopIsIgnoringWakeUps(rl)) {
__CFRunLoopUnlock(rl);
return;
}
kern_return_t ret;
ret = __CFSendTrivialMachMessage(rl->_wakeUpPort, 0, MACH_SEND_TIMEOUT, 0);
if (ret != MACH_MSG_SUCCESS && ret != MACH_SEND_TIMED_OUT) CRASH("*** Unable to send message to wake up port. (%d) ***", ret);
__CFRunLoopUnlock(rl);
}
CF_INLINE Boolean __CFRunLoopIsIgnoringWakeUps(CFRunLoopRef rl) {
return (rl->_perRunData->ignoreWakeUps) ? true : false;
}
複製程式碼
CFRunloop結構體:
struct __CFRunLoop {
CFRuntimeBase _base; //16 byte
pthread_mutex_t _lock; //64 byte
__CFPort _wakeUpPort; //mach_port_t (unsign int), 4 byte
Boolean _unused; //bool變數佔用1 byte,但是需要和4位元組對齊,所以也是4 byte
volatile _per_run_data *_perRunData;
pthread_t _pthread;
uint32_t _winthread;
CFMutableSetRef _commonModes;
CFMutableSetRef _commonModeItems;
CFRunLoopModeRef _currentMode;
CFMutableSetRef _modes;
struct _block_item *_blocks_head;
struct _block_item *_blocks_tail;
CFAbsoluteTime _runTime;
CFAbsoluteTime _sleepTime;
CFTypeRef _counterpart;
};
typedef struct __CFRuntimeBase {
uintptr_t _cfisa; //unsigned long 8 byte
uint8_t _cfinfo[4]; //unsigned char 4 byte
#if __LP64__
uint32_t _rc; //unsigned int 4 byte
#endif
} CFRuntimeBase;
struct pthread_mutex_t {
long __sig; //8 byte
char __opaque[56]; //56 byte
};
複製程式碼
計算結構體size後,得出ldr x8, [x20, #0x58]
就是runloop-> _perRunData
。也就是在呼叫__CFRunLoopIsIgnoringWakeUps
的時候,CFRunLoopRef
已經被釋放了。
分析CFSocket
原始碼
檢視CFSocketInvalidate
原始碼:
void CFSocketInvalidate(CFSocketRef s) {
CHECK_FOR_FORK();
CFRetain(s);
__CFLock(&__CFAllSocketsLock);
__CFSocketLock(s);
if (__CFSocketIsValid(s)) {
//省略部分程式碼...
//取出socket中的runloop陣列
CFArrayRef runLoops = (CFArrayRef)CFRetain(s->_runLoops);
//CFRunloop釋放操作1
CFRelease(s->_runLoops);
s->_runLoops = NULL;
//省略部分程式碼...
__CFSocketUnlock(s);
// Do this after the socket unlock to avoid deadlock (10462525)
for (idx = CFArrayGetCount(runLoops); idx--;) {
CFRunLoopWakeUp((CFRunLoopRef)CFArrayGetValueAtIndex(runLoops, idx));
}
//CFRunloop釋放操作3
CFRelease(runLoops);
//省略部分程式碼...
} else {
__CFSocketUnlock(s);
}
__CFUnlock(&__CFAllSocketsLock);
CFRelease(s);
}
複製程式碼
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
:
void CFRunLoopRemoveSource(CFRunLoopRef rl, CFRunLoopSourceRef rls, CFStringRef modeName) \
CHECK_FOR_FORK();
Boolean doVer0Callout = false, doRLSRelease = false;
__CFRunLoopLock(rl);
if (modeName == kCFRunLoopCommonModes) {
//省略程式碼...
} else {
CFRunLoopModeRef rlm = __CFRunLoopFindMode(rl, modeName, false);
if (NULL != rlm && ((NULL != rlm->_sources0 && CFSetContainsValue(rlm->_sources0, rls)) || (NULL != rlm->_sources1 && CFSetContainsValue(rlm->_sources1, rls)))) {
CFRetain(rls);
//省略程式碼...
if (0 == rls->_context.version0.version) {
if (NULL != rls->_context.version0.cancel) {
doVer0Callout = true;
}
}
doRLSRelease = true;
}
//省略程式碼...
}
}
__CFRunLoopUnlock(rl);
if (doVer0Callout) {
// although it looses some protection for the source, we have no choice but
// to do this after unlocking the run loop and mode locks, to avoid deadlocks
// where the source wants to take a lock which is already held in another
// thread which is itself waiting for a run loop/mode lock
rls->_context.version0.cancel(rls->_context.version0.info, rl, modeName); /* CALLOUT */
}
if (doRLSRelease) CFRelease(rls);
}
複製程式碼
__CFSocketCancel
原始碼:
static void __CFSocketCancel(void *info, CFRunLoopRef rl, CFStringRef mode) {
CFSocketRef s = (CFSocketRef)info;
__CFSocketLock(s);
if (0 == s->_socketSetCount) {
//省略程式碼...
if (NULL != s->_runLoops) {
//從runloops陣列中移除此runloop;對原陣列執行拷貝後,釋放原陣列
CFMutableArrayRef runLoopsOrig = s->_runLoops;
CFMutableArrayRef runLoopsCopy = CFArrayCreateMutableCopy(kCFAllocatorSystemDefault, 0, s->_runLoops);
idx = CFArrayGetFirstIndexOfValue(runLoopsCopy, CFRangeMake(0, CFArrayGetCount(runLoopsCopy)), rl);
if (0 <= idx) CFArrayRemoveValueAtIndex(runLoopsCopy, idx);
s->_runLoops = runLoopsCopy;
//CFRunloop釋放操作2
CFRelease(runLoopsOrig);
}
__CFSocketUnlock(s);
}
複製程式碼
__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程式碼改成這樣,應該就沒問題了吧?
- (void)runThread {
@autoreleasepool {
self.currentRunloop = CFRunLoopGetCurrent();
[self addRunloopSource];
[self addSocketSource];
CFRunLoopRun();
}
NSLog(@"執行緒退出");
}
- (void)stopThread {
if (_currentRunloop) {
//保證removeSocketSource的操作只會在這裡執行,沒有多執行緒的情況
[self removeSocketSource];
CFRunLoopStop(_currentRunloop);
self.currentRunloop = NULL;
}
}
複製程式碼
很遺憾,這樣寫還是不安全的。
原因在於removeSocketSource
之後,runloop裡source就全部為空了,runloop如果檢測到了source為空,就會自動停止runloop迴圈,銷燬執行緒。
因此如果你在另一個執行緒呼叫stopThread
,在removeSocketSource
之後執行緒就會隨時停止,runloop在呼叫CFRunLoopStop
時可能已經被釋放了。
上面的寫法出現crash的概率太低,但是稍微改一下就能必現:
- (void)stopThread {
if (_currentRunloop) {
[self removeSocketSource];
//插入一個耗時操作
sleep(2);
//必定crash
CFRunLoopStop(_currentRunloop);
self.currentRunloop = NULL;
}
}
複製程式碼
這種情況下crash的原因其實是沒做好記憶體管理,只要對runloop增加一次retain操作就沒問題了:
- (void)runThread {
@autoreleasepool {
//做一次retain操作
self.currentRunloop = CFRetain(CFRunLoopGetCurrent());
[self addRunloopSource];
[self addSocketSource];
CFRunLoopRun();
}
NSLog(@"執行緒退出");
}
- (void)stopThread {
if (_currentRunloop) {
[self removeSocketSource];
CFRunLoopStop(_currentRunloop);
CFRelease(_currentRunloop);
self.currentRunloop = NULL;
}
}
複製程式碼
結論
在使用runloop source的時候要謹慎,尤其在處理stop的階段。其他source可能也存在類似的問題。
一個變數有多執行緒操作的時候,在鎖外的操作即使是隻讀也是不安全的,在讀取之前最好再做一次retain操作,防止在讀取的過程中被釋放。