前幾天與同事討論到Notification在多執行緒下的轉發問題,所以就此整理一下。
先來看看官方的文件,是這樣寫的:
1 2 |
In a multithreaded application, notifications are always delivered in the thread in which the notification was posted, which may not be the same thread in which an observer registered itself. |
翻譯過來是:
1 2 |
在多執行緒應用中,Notification在哪個執行緒中post,就在哪個執行緒中被轉發,而不一定是在註冊觀察者的那個執行緒中。 |
也就是說,Notification的傳送與接收處理都是在同一個執行緒中。為了說明這一點,我們先來看一個示例:
程式碼清單1:Notification的傳送與處理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
@implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; NSLog(@"current thread = %@", [NSThread currentThread]); [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:TEST_NOTIFICATION object:nil]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil userInfo:nil]; }); } - (void)handleNotification:(NSNotification *)notification { NSLog(@"current thread = %@", [NSThread currentThread]); NSLog(@"test notification"); } @end |
其輸出結果如下:
1 2 3 |
2015-03-11 22:05:12.856 test[865:45102] current thread = {number = 1, name = main} 2015-03-11 22:05:12.857 test[865:45174] current thread = {number = 2, name = (null)} 2015-03-11 22:05:12.857 test[865:45174] test notification |
可以看到,雖然我們在主執行緒中註冊了通知的觀察者,但在全域性佇列中post的Notification,並不是在主執行緒處理的。所以,這時候就需要注意,如果我們想在回撥中處理與UI相關的操作,需要確保是在主執行緒中執行回撥。
這時,就有一個問題了,如果我們的Notification是在二級執行緒中post的,如何能在主執行緒中對這個Notification進行處理呢?或者換個提法,如果我們希望一個Notification的post執行緒與轉發執行緒不是同一個執行緒,應該怎麼辦呢?我們看看官方文件是怎麼說的:
1 2 |
For example, if an object running in a background thread is listening for notifications from the user interface, such as a window closing, you would like to receive the notifications in the background thread instead of the main thread. In these cases, you must capture the notifications as they are delivered on the default thread and redirect them to the appropriate thread. |
這裡講到了“重定向”,就是我們在Notification所在的預設執行緒中捕獲這些分發的通知,然後將其重定向到指定的執行緒中。
一種重定向的實現思路是自定義一個通知佇列(注意,不是NSNotificationQueue物件,而是一個陣列),讓這個佇列去維護那些我們需要重定向的Notification。我們仍然是像平常一樣去註冊一個通知的觀察者,當Notification來了時,先看看post這個Notification的執行緒是不是我們所期望的執行緒,如果不是,則將這個Notification儲存到我們的佇列中,併傳送一個訊號(signal)到期望的執行緒中,來告訴這個執行緒需要處理一個Notification。指定的執行緒在收到訊號後,將Notification從佇列中移除,並進行處理。
官方文件已經給出了示例程式碼,在此借用一下,以測試實際結果:
程式碼清單2:在不同執行緒中post和轉發一個Notification
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
@interface ViewController () @property (nonatomic) NSMutableArray *notifications; // 通知佇列 @property (nonatomic) NSThread *notificationThread; // 期望執行緒 @property (nonatomic) NSLock *notificationLock; // 用於對通知佇列加鎖的鎖物件,避免執行緒衝突 @property (nonatomic) NSMachPort *notificationPort; // 用於向期望執行緒傳送訊號的通訊埠 @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; NSLog(@"current thread = %@", [NSThread currentThread]); // 初始化 self.notifications = [[NSMutableArray alloc] init]; self.notificationLock = [[NSLock alloc] init]; self.notificationThread = [NSThread currentThread]; self.notificationPort = [[NSMachPort alloc] init]; self.notificationPort.delegate = self; // 往當前執行緒的run loop新增埠源 // 當Mach訊息到達而接收執行緒的run loop沒有執行時,則核心會儲存這條訊息,直到下一次進入run loop [[NSRunLoop currentRunLoop] addPort:self.notificationPort forMode:(__bridge NSString *)kCFRunLoopCommonModes]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(processNotification:) name:@"TestNotification" object:nil]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil userInfo:nil]; }); } - (void)handleMachMessage:(void *)msg { [self.notificationLock lock]; while ([self.notifications count]) { NSNotification *notification = [self.notifications objectAtIndex:0]; [self.notifications removeObjectAtIndex:0]; [self.notificationLock unlock]; [self processNotification:notification]; [self.notificationLock lock]; }; [self.notificationLock unlock]; } - (void)processNotification:(NSNotification *)notification { if ([NSThread currentThread] != _notificationThread) { // Forward the notification to the correct thread. [self.notificationLock lock]; [self.notifications addObject:notification]; [self.notificationLock unlock]; [self.notificationPort sendBeforeDate:[NSDate date] components:nil from:nil reserved:0]; } else { // Process the notification here; NSLog(@"current thread = %@", [NSThread currentThread]); NSLog(@"process notification"); } } @end |
執行後,其輸出如下:
1 2 3 |
2015-03-11 23:38:31.637 test[1474:92483] current thread = {number = 1, name = main} 2015-03-11 23:38:31.663 test[1474:92483] current thread = {number = 1, name = main} 2015-03-11 23:38:31.663 test[1474:92483] process notification |
可以看到,我們在全域性dispatch佇列中丟擲的Notification,如願地在主執行緒中接收到了。
這種實現方式的具體解析及其侷限性大家可以參考官方文件Delivering Notifications To Particular Threads,在此不多做解釋。當然,更好的方法可能是我們自己去子類化一個NSNotificationCenter,或者單獨寫一個類來處理這種轉發。
NSNotificationCenter的執行緒安全性
蘋果之所以採取通知中心在同一個執行緒中post和轉發同一訊息這一策略,應該是出於執行緒安全的角度來考量的。官方文件告訴我們,NSNotificationCenter是一個執行緒安全類,我們可以在多執行緒環境下使用同一個NSNotificationCenter物件而不需要加鎖。原文在Threading Programming Guide中,具體如下:
1 2 3 4 5 6 7 8 |
The following classes and functions are generally considered to be thread-safe. You can use the same instance from multiple threads without first acquiring a lock. NSArray ... NSNotification NSNotificationCenter ... |
我們可以在任何執行緒中新增/刪除通知的觀察者,也可以在任何執行緒中post一個通知。
NSNotificationCenter線上程安全性方面已經做了不少工作了,那是否意味著我們可以高枕無憂了呢?再回過頭來看看第一個例子,我們稍微改造一下,一點一點來:
程式碼清單3:NSNotificationCenter的通用模式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
@interface Observer : NSObject @end @implementation Observer - (instancetype)init { self = [super init]; if (self) { _poster = [[Poster alloc] init]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:TEST_NOTIFICATION object:nil] } return self; } - (void)handleNotification:(NSNotification *)notification { NSLog(@"handle notification "); } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; } @end // 其它地方 [[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil]; |
上面的程式碼就是我們通常所做的事情:新增一個通知監聽者,定義一個回撥,並在所屬物件釋放時移除監聽者;然後在程式的某個地方post一個通知。簡單明瞭,如果這一切都是發生在一個執行緒裡面,或者至少dealloc方法是在-postNotificationName:的執行緒中執行的(注意:NSNotification的post和轉發是同步的),那麼都OK,沒有執行緒安全問題。但如果dealloc方法和-postNotificationName:方法不在同一個執行緒中執行時,會出現什麼問題呢?
我們再改造一下上面的程式碼:
程式碼清單4:NSNotificationCenter引發的執行緒安全問題
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
#pragma mark - Poster @interface Poster : NSObject @end @implementation Poster - (instancetype)init { self = [super init]; if (self) { [self performSelectorInBackground:@selector(postNotification) withObject:nil]; } return self; } - (void)postNotification { [[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil]; } @end #pragma mark - Observer @interface Observer : NSObject { Poster *_poster; } @property (nonatomic, assign) NSInteger i; @end @implementation Observer - (instancetype)init { self = [super init]; if (self) { _poster = [[Poster alloc] init]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:TEST_NOTIFICATION object:nil]; } return self; } - (void)handleNotification:(NSNotification *)notification { NSLog(@"handle notification begin"); sleep(1); NSLog(@"handle notification end"); self.i = 10; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; NSLog(@"Observer dealloc"); } @end #pragma mark - ViewController @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; __autoreleasing Observer *observer = [[Observer alloc] init]; } @end |
這段程式碼是在主執行緒新增了一個TEST_NOTIFICATION通知的監聽者,並在主執行緒中將其移除,而我們的NSNotification是在後臺執行緒中post的。在通知處理函式中,我們讓回撥所在的執行緒睡眠1秒鐘,然後再去設定屬性i值。這時會發生什麼呢?我們先來看看輸出結果:
1 2 3 4 5 6 |
2015-03-14 00:31:41.286 SKTest[932:88791] handle notification begin 2015-03-14 00:31:41.291 SKTest[932:88713] Observer dealloc 2015-03-14 00:31:42.361 SKTest[932:88791] handle notification end (lldb) // 程式在self.i = 10處丟擲了"Thread 6: EXC_BAD_ACCESS(code=EXC_I386_GPFLT)" |
經典的記憶體錯誤,程式崩潰了。其實從輸出結果中,我們就可以看到到底是發生了什麼事。我們簡要描述一下:
- 當我們註冊一個觀察者是,通知中心會持有觀察者的一個弱引用,來確保觀察者是可用的。
- 主執行緒呼叫dealloc操作會讓Observer物件的引用計數減為0,這時物件會被釋放掉。
- 後臺執行緒傳送一個通知,如果此時Observer還未被釋放,則會向其轉發訊息,並執行回撥方法。而如果在回撥執行的過程中物件被釋放了,就會出現上面的問題。
當然,上面這個例子是故意而為之,但不排除在實際編碼中會遇到類似的問題。雖然NSNotificationCenter是執行緒安全的,但並不意味著我們在使用時就可以保證執行緒安全的,如果稍不注意,還是會出現執行緒問題。
那我們該怎麼做呢?這裡有一些好的建議:
- 儘量在一個執行緒中處理通知相關的操作,大部分情況下,這樣做都能確保通知的正常工作。不過,我們無法確定到底會在哪個執行緒中呼叫dealloc方法,所以這一點還是比較困難。
- 註冊監聽都時,使用基於block的API。這樣我們在block還要繼續呼叫self的屬性或方法,就可以通過weak-strong的方式來處理。具體大家可以改造下上面的程式碼試試是什麼效果。
- 使用帶有安全生命週期的物件,這一點物件單例物件來說再合適不過了,在應用的整個生命週期都不會被釋放。
- 使用代理。
小結
NSNotificationCenter雖然是執行緒安全的,但不要被這個事實所誤導。在涉及到多執行緒時,我們還是需要多加小心,避免出現上面的執行緒問題。想進一步瞭解的話,可以檢視Observers and Thread Safety。