本文是GCD多執行緒程式設計中dispatch_semaphore
內容的小結,通過本文,你可以瞭解到:
- 訊號量的基本概念與基本使用
- 訊號量線上程同步與資源加鎖方面的應用
- 訊號量釋放時的小陷阱
今天我來講解一下dispatch_semaphore
在我們平常開發中的一些基本概念與基本使用,dispatch_semaphore
俗稱訊號量,也稱為訊號鎖,在多執行緒程式設計中主要用於控制多執行緒下訪問資源的數量,比如系統有兩個資源可以使用,但同時有三個執行緒要訪問,所以只能允許兩個執行緒訪問,第三個應當等待資源被釋放後再訪問,這時我們就可以使用dispatch_semaphore
。
與dispatch_semaphore
相關的共有3個方法,分別是dispatch_semaphore_create
,dispatch_semaphore_wait
,dispatch_semaphore_signal
下面我們逐一瞭解一下這三個方法。
semaphore的三個方法
dispatch_semaphore_create
/*!
* @function dispatch_semaphore_create
*
* @abstract
* Creates new counting semaphore with an initial value.
*
* @discussion
* Passing zero for the value is useful for when two threads need to reconcile
* the completion of a particular event. Passing a value greater than zero is
* useful for managing a finite pool of resources, where the pool size is equal
* to the value.
*
* @param value
* The starting value for the semaphore. Passing a value less than zero will
* cause NULL to be returned.
*
* @result
* The newly created semaphore, or NULL on failure.
*/
API_AVAILABLE(macos(10.6), ios(4.0))
DISPATCH_EXPORT DISPATCH_MALLOC DISPATCH_RETURNS_RETAINED DISPATCH_WARN_RESULT
DISPATCH_NOTHROW
dispatch_semaphore_t
dispatch_semaphore_create(long value);
複製程式碼
dispatch_semaphore_create
方法用於建立一個帶有初始值的訊號量dispatch_semaphore_t
。
對於這個方法的引數訊號量的初始值,這裡有2種情況:
- 訊號量初始值為0時:這種情況主要用於兩個執行緒需要協調特定事件的完成時,即執行緒同步。
- 訊號量初始值為大於0時:這種情況主要用於管理有限的資源池,其中池大小等於這個值,即資源加鎖。
上面的2種情況(執行緒同步、資源加鎖),我們在後續的使用篇中會詳細講解。
dispatch_semaphore_wait
/*!
* @function dispatch_semaphore_wait
*
* @abstract
* Wait (decrement) for a semaphore.
*
* @discussion
* Decrement the counting semaphore. If the resulting value is less than zero,
* this function waits for a signal to occur before returning.
*
* @param dsema
* The semaphore. The result of passing NULL in this parameter is undefined.
*
* @param timeout
* When to timeout (see dispatch_time). As a convenience, there are the
* DISPATCH_TIME_NOW and DISPATCH_TIME_FOREVER constants.
*
* @result
* Returns zero on success, or non-zero if the timeout occurred.
*/
API_AVAILABLE(macos(10.6), ios(4.0))
DISPATCH_EXPORT DISPATCH_NONNULL_ALL DISPATCH_NOTHROW
long
dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout);
複製程式碼
dispatch_semaphore_wait
這個方法主要用於等待
或減少
訊號量,每次呼叫這個方法,訊號量的值都會減一,然後根據減一後的訊號量的值的大小,來決定這個方法的使用情況,所以這個方法的使用同樣也分為2種情況:
- 當減一後的值小於0時,這個方法會一直等待,即阻塞當前執行緒,直到訊號量+1或者直到超時。
- 當減一後的值大於或等於0時,這個方法會直接返回,不會阻塞當前執行緒。
上面2種方式,放到我們日常的開發中就是下面2種使用情況:
-
當我們只需要
同步執行緒
時,我們可以使用dispatch_semaphore_create(0)
初始化訊號量為0,然後使用dispatch_semaphore_wait
方法讓訊號量減一,這時就屬於第一種減一後小於0的情況,這時就會阻塞當前執行緒,直到另一個執行緒呼叫dispatch_semaphore_signal
這個讓訊號量加1的方法後,當前執行緒才會被喚醒,然後執行當前執行緒中的程式碼,這時就起到一個執行緒同步的作用。 -
當我們需要對
資源加鎖
,控制同時能訪問資源的最大數量(假設為n)時,我們就需要使用dispatch_semaphore_create(n)
方法來初始化訊號量為n,然後使用dispatch_semaphore_wait
方法將訊號量減一,然後訪問我們的資源,然後使用dispatch_semaphore_signal
方法將訊號量加一。如果有n個執行緒來訪問這個資源,當這n個資源訪問都還沒有結束時,就會阻塞當前執行緒,第n+1個執行緒的訪問就必須等待,直到前n個的某一個的資源訪問結束,這就是我們很常見的資源加鎖的情況。
dispatch_semaphore_signal
/*!
* @function dispatch_semaphore_signal
*
* @abstract
* Signal (increment) a semaphore.
*
* @discussion
* Increment the counting semaphore. If the previous value was less than zero,
* this function wakes a waiting thread before returning.
*
* @param dsema The counting semaphore.
* The result of passing NULL in this parameter is undefined.
*
* @result
* This function returns non-zero if a thread is woken. Otherwise, zero is
* returned.
*/
API_AVAILABLE(macos(10.6), ios(4.0))
DISPATCH_EXPORT DISPATCH_NONNULL_ALL DISPATCH_NOTHROW
long
dispatch_semaphore_signal(dispatch_semaphore_t dsema);
複製程式碼
dispatch_semaphore_signal
方法用於讓訊號量的值加一,然後直接返回。如果先前訊號量的值小於0,那麼這個方法還會喚醒先前等待的執行緒。
semaphore使用篇
執行緒同步
這種情況在我們的開發中也是挺常見的,當主執行緒中有一個非同步網路任務,我們需要等這個網路請求成功拿到資料後,才能繼續做後面的處理,這時我們就可以使用訊號量這種方式來進行執行緒同步。
我們首先看看完整測試程式碼:
- (IBAction)threadSyncTask:(UIButton *)sender {
NSLog(@"threadSyncTask start --- thread:%@",[NSThread currentThread]);
//1.建立一個初始值為0的訊號量
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
//2.定製一個非同步任務
//開啟一個非同步網路請求
NSLog(@"開啟一個非同步網路請求");
NSURLSession *session = [NSURLSession sharedSession];
NSURL *url =
[NSURL URLWithString:[@"https://www.baidu.com/" stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
request.HTTPMethod = @"GET";
NSURLSessionDataTask *dataTask = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (error) {
NSLog(@"%@", [error localizedDescription]);
}
if (data) {
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:nil];
NSLog(@"%@", dict);
}
NSLog(@"非同步網路任務完成---%@",[NSThread currentThread]);
//4.呼叫signal方法,讓訊號量+1,然後喚醒先前被阻塞的執行緒
NSLog(@"呼叫dispatch_semaphore_signal方法");
dispatch_semaphore_signal(semaphore);
}];
[dataTask resume];
//3.呼叫wait方法讓訊號量-1,這時訊號量小於0,這個方法會阻塞當前執行緒,直到訊號量等於0時,喚醒當前執行緒
NSLog(@"呼叫dispatch_semaphore_wait方法");
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
NSLog(@"threadSyncTask end --- thread:%@",[NSThread currentThread]);
}
複製程式碼
執行之後的log如下:
2019-04-27 17:24:27.050077+0800 GCD(四) dispatch_semaphore[34482:6102243] threadSyncTask end --- thread:<NSThread: 0x6000028aa7c0>{number = 1, name = main}
2019-04-27 17:24:27.050227+0800 GCD(四) dispatch_semaphore[34482:6102243] 開啟一個非同步網路請求
2019-04-27 17:24:27.050571+0800 GCD(四) dispatch_semaphore[34482:6102243] 呼叫dispatch_semaphore_wait方法
2019-04-27 17:24:27.105069+0800 GCD(四) dispatch_semaphore[34482:6105851] (null)
2019-04-27 17:24:27.105262+0800 GCD(四) dispatch_semaphore[34482:6105851] 非同步網路任務完成---<NSThread: 0x6000028c6ec0>{number = 6, name = (null)}
2019-04-27 17:24:27.105401+0800 GCD(四) dispatch_semaphore[34482:6105851] 呼叫dispatch_semaphore_signal方法
2019-04-27 17:24:27.105550+0800 GCD(四) dispatch_semaphore[34482:6102243] threadSyncTask end --- thread:<NSThread: 0x6000028aa7c0>{number = 1, name = main}
複製程式碼
從log中我們可以看出,wait方法會阻塞主執行緒,直到非同步任務完成呼叫signal方法,才會繼續回到主執行緒執行後面的任務。
資源加鎖
當一個資源可以被多個執行緒讀取修改時,就會很容易出現多執行緒訪問修改資料出現結果不一致甚至崩潰的問題。為了處理這個問題,我們通常使用的辦法,就是使用NSLock
,@synchronized
給這個資源加鎖,讓它在同一時間只允許一個執行緒訪問資源。其實訊號量也可以當做一個鎖來使用,而且比NSLock
還有@synchronized
代價更低一些,接下來我們來看看它的基本使用
第一步,定義2個巨集,將wait
與signal
方法包起來,方便下面的使用
#ifndef ZED_LOCK
#define ZED_LOCK(lock) dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
#endif
#ifndef ZED_UNLOCK
#define ZED_UNLOCK(lock) dispatch_semaphore_signal(lock);
#endif
複製程式碼
第二步,宣告與建立共享資源與訊號鎖
/* 需要加鎖的資源 **/
@property (nonatomic, strong) NSMutableDictionary *dict;
/* 訊號鎖 **/
@property (nonatomic, strong) dispatch_semaphore_t lock;
複製程式碼
//建立共享資源
self.dict = [NSMutableDictionary dictionary];
//初始化訊號量,設定初始值為1
self.lock = dispatch_semaphore_create(1);
複製程式碼
第三步,在即將使用共享資源的地方新增ZED_LOCK
巨集,進行訊號量減一操作,在共享資源使用完成的時候新增ZED_UNLOCK
,進行訊號量加一操作。
- (IBAction)resourceLockTask:(UIButton *)sender {
NSLog(@"resourceLockTask start --- thread:%@",[NSThread currentThread]);
//使用非同步執行併發任務會開闢新的執行緒的特性,來模擬開闢多個執行緒訪問貢獻資源的場景
for (int i = 0; i < 3; i++) {
NSLog(@"非同步新增任務:%d",i);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
ZED_LOCK(self.lock);
//模擬對共享資源處理的耗時
[NSThread sleepForTimeInterval:1];
NSLog(@"i:%d --- thread:%@ --- 將要處理共享資源",i,[NSThread currentThread]);
[self.dict setObject:@"semaphore" forKey:@"key"];
NSLog(@"i:%d --- thread:%@ --- 共享資源處理完成",i,[NSThread currentThread]);
ZED_UNLOCK(self.lock);
});
}
NSLog(@"resourceLockTask end --- thread:%@",[NSThread currentThread]);
}
複製程式碼
在這一步中,我們使用非同步執行併發任務會開闢新的執行緒的特性,來模擬開闢多個執行緒訪問貢獻資源的場景,同時使用了執行緒休眠的API來模擬對共享資源處理的耗時。這裡我們開闢了3個執行緒來併發訪問這個共享資源,程式碼執行的log如下:
2019-04-27 18:36:25.275060+0800 GCD(四) dispatch_semaphore[35944:6315957] resourceLockTask start --- thread:<NSThread: 0x60000130e940>{number = 1, name = main}
2019-04-27 18:36:25.275312+0800 GCD(四) dispatch_semaphore[35944:6315957] 非同步新增任務:0
2019-04-27 18:36:25.275508+0800 GCD(四) dispatch_semaphore[35944:6315957] 非同步新增任務:1
2019-04-27 18:36:25.275680+0800 GCD(四) dispatch_semaphore[35944:6315957] 非同步新增任務:2
2019-04-27 18:36:25.275891+0800 GCD(四) dispatch_semaphore[35944:6315957] resourceLockTask end --- thread:<NSThread: 0x60000130e940>{number = 1, name = main}
2019-04-27 18:36:26.276757+0800 GCD(四) dispatch_semaphore[35944:6316211] i:0 --- thread:<NSThread: 0x6000013575c0>{number = 3, name = (null)} --- 將要處理共享資源
2019-04-27 18:36:26.277004+0800 GCD(四) dispatch_semaphore[35944:6316211] i:0 --- thread:<NSThread: 0x6000013575c0>{number = 3, name = (null)} --- 共享資源處理完成
2019-04-27 18:36:27.282099+0800 GCD(四) dispatch_semaphore[35944:6316212] i:1 --- thread:<NSThread: 0x600001357800>{number = 4, name = (null)} --- 將要處理共享資源
2019-04-27 18:36:27.282357+0800 GCD(四) dispatch_semaphore[35944:6316212] i:1 --- thread:<NSThread: 0x600001357800>{number = 4, name = (null)} --- 共享資源處理完成
2019-04-27 18:36:28.283769+0800 GCD(四) dispatch_semaphore[35944:6316214] i:2 --- thread:<NSThread: 0x600001369280>{number = 5, name = (null)} --- 將要處理共享資源
2019-04-27 18:36:28.284041+0800 GCD(四) dispatch_semaphore[35944:6316214] i:2 --- thread:<NSThread: 0x600001369280>{number = 5, name = (null)} --- 共享資源處理完成
複製程式碼
從多次log中我們可以看出:
新增訊號鎖之後,每個執行緒對於共享資源的操作都是有序的,並不會出現2個執行緒同時訪問鎖中的程式碼區域。
我把上面的實現程式碼簡化一下,方便分析這種鎖的實現原理:
//step_1
ZED_LOCK(self.lock);
//step_2
NSLog(@"執行任務");
//step_3
ZED_UNLOCK(self.lock);
複製程式碼
- 訊號量初始化的值為1,當一個執行緒過來執行step_1的程式碼時,會呼叫訊號量的值減一的方法,這時,訊號量的值為0,它會直接返回,然後執行step_2的程式碼去完成去共享資源的訪問,然後再使用step_3中的signal方法讓訊號量加一,訊號量的值又會迴歸到初始值1。這就是一個執行緒過來訪問的呼叫流程。
- 當執行緒1過來執行到step_2的時候,這時又有一個執行緒2它也從step_1處來呼叫這段程式碼,由於執行緒1已經呼叫過step_1的wait方法將訊號量的值減一,這時訊號量的值為0。同時執行緒2進入然後呼叫了step_1的wait方法又將訊號量的值減一,這時的訊號量的值為-1,由於訊號量的值小於0時會阻塞當前執行緒(執行緒2),所以,執行緒2就會一直等待,直到執行緒1執行完step_3中的方法,將訊號量加一,才會喚醒執行緒2,繼續執行下面的程式碼。這就是為什麼訊號量可以對共享資源加鎖的原因,如果我們可以允許n個執行緒同時訪問,我們就需要在初始化這個訊號量時把訊號量的值設為n,這樣就限制了訪問共享資源的執行緒數。
通過上面的分析,我們可以知道,如果我們使用訊號量來進行執行緒同步時,我們需要把訊號量的初始值設為0,如果要對資源加鎖,限制同時只有n個執行緒可以訪問的時候,我們就需要把訊號量的初始值設為n。
semaphore的釋放
在我們平常的開發過程中,如果對semaphore使用不當,就會在它釋放的時候遇到奔潰問題。
首先我們來看2個例子:
- (IBAction)crashScene1:(UIButton *)sender {
dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
//在使用過程中將semaphore置為nil
semaphore = nil;
}
複製程式碼
- (IBAction)crashScene2:(UIButton *)sender {
dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
//在使用過程中對semaphore進行重新賦值
semaphore = dispatch_semaphore_create(3);
}
複製程式碼
我們開啟測試程式碼,找到semaphore對應的target,然後執行一下程式碼,然後點選後面2個按鈕呼叫一下上面的程式碼,然後我們可以發現,程式碼在執行到semaphore = nil;
與semaphore = dispatch_semaphore_create(3);
時奔潰了。然後我們使用lldb
的bt
命令檢視一下呼叫棧。
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)
frame #0: 0x0000000111c31309 libdispatch.dylib`_dispatch_semaphore_dispose + 59
frame #1: 0x0000000111c2fb06 libdispatch.dylib`_dispatch_dispose + 97
* frame #2: 0x000000010efb113b GCD(四) dispatch_semaphore`-[ZEDDispatchSemaphoreViewController crashScene1:](self=0x00007fdcfdf0add0, _cmd="crashScene1:", sender=0x00007fdcfdd0a3d0) at ZEDDispatchSemaphoreViewController.m:117
frame #3: 0x0000000113198ecb UIKitCore`-[UIApplication sendAction:to:from:forEvent:] + 83
frame #4: 0x0000000112bd40bd UIKitCore`-[UIControl sendAction:to:forEvent:] + 67
frame #5: 0x0000000112bd43da UIKitCore`-[UIControl _sendActionsForEvents:withEvent:] + 450
frame #6: 0x0000000112bd331e UIKitCore`-[UIControl touchesEnded:withEvent:] + 583
frame #7: 0x00000001131d40a4 UIKitCore`-[UIWindow _sendTouchesForEvent:] + 2729
frame #8: 0x00000001131d57a0 UIKitCore`-[UIWindow sendEvent:] + 4080
frame #9: 0x00000001131b3394 UIKitCore`-[UIApplication sendEvent:] + 352
frame #10: 0x00000001132885a9 UIKitCore`__dispatchPreprocessedEventFromEventQueue + 3054
frame #11: 0x000000011328b1cb UIKitCore`__handleEventQueueInternal + 5948
frame #12: 0x0000000110297721 CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17
frame #13: 0x0000000110296f93 CoreFoundation`__CFRunLoopDoSources0 + 243
frame #14: 0x000000011029163f CoreFoundation`__CFRunLoopRun + 1263
frame #15: 0x0000000110290e11 CoreFoundation`CFRunLoopRunSpecific + 625
frame #16: 0x00000001189281dd GraphicsServices`GSEventRunModal + 62
frame #17: 0x000000011319781d UIKitCore`UIApplicationMain + 140
frame #18: 0x000000010efb06a0 GCD(四) dispatch_semaphore`main(argc=1, argv=0x00007ffee0c4efc8) at main.m:14
frame #19: 0x0000000111ca6575 libdyld.dylib`start + 1
frame #20: 0x0000000111ca6575 libdyld.dylib`start + 1
(lldb)
複製程式碼
從上面的呼叫棧我們可以看出,奔潰的地方都處於libdispatch
庫呼叫dispatch_semaphore_dispose
方法釋放訊號量的時候,為什麼在訊號量使用過程中對訊號量進行重新賦值或置空操作會crash呢,這個我們就需要從GCD的原始碼層面來分析了,GCD的原始碼庫libdispatch
在蘋果的開原始碼庫可以下載,我在自己的Github
也放了一份libdispatch-187.10版本的,下面的原始碼分析都是基於這個版本的。
首先我們來看一下dispatch_semaphore_t
的結構體dispatch_semaphore_s
的結構體定義
struct dispatch_semaphore_s {
DISPATCH_STRUCT_HEADER(dispatch_semaphore_s, dispatch_semaphore_vtable_s);
long dsema_value; //當前的訊號值
long dsema_orig; //初始化的訊號值
size_t dsema_sent_ksignals;
#if USE_MACH_SEM && USE_POSIX_SEM
#error "Too many supported semaphore types"
#elif USE_MACH_SEM
semaphore_t dsema_port; //當前mach_port_t訊號
semaphore_t dsema_waiter_port; //休眠時mach_port_t訊號
#elif USE_POSIX_SEM
sem_t dsema_sem;
#else
#error "No supported semaphore type"
#endif
size_t dsema_group_waiters;
struct dispatch_sema_notify_s *dsema_notify_head;//連結串列頭部
struct dispatch_sema_notify_s *dsema_notify_tail;//連結串列尾部
};
複製程式碼
這裡我們需要關注2個值的變化,dsema_value
與dsema_orig
,它們分別代表當前的訊號值與初始化時的訊號值。
當我們呼叫dispatch_semaphore_create
方法建立訊號量時,這個方法內部會把傳入的引數儲存到dsema_value
(當前的value)和dsema_orig
(初始value)中,條件是value的值必須大於或等於0。
dispatch_semaphore_t
dispatch_semaphore_create(long value)
{
dispatch_semaphore_t dsema;
// If the internal value is negative, then the absolute of the value is
// equal to the number of waiting threads. Therefore it is bogus to
// initialize the semaphore with a negative value.
if (value < 0) {//初始值不能小於0
return NULL;
}
dsema = calloc(1, sizeof(struct dispatch_semaphore_s));//申請訊號量的記憶體
if (fastpath(dsema)) {//訊號量初始化賦值
dsema->do_vtable = &_dispatch_semaphore_vtable;
dsema->do_next = DISPATCH_OBJECT_LISTLESS;
dsema->do_ref_cnt = 1;
dsema->do_xref_cnt = 1;
dsema->do_targetq = dispatch_get_global_queue(
DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dsema->dsema_value = value;//當前的值
dsema->dsema_orig = value;//初始值
#if USE_POSIX_SEM
int ret = sem_init(&dsema->dsema_sem, 0, 0);//記憶體空間對映
DISPATCH_SEMAPHORE_VERIFY_RET(ret);
#endif
}
return dsema;
}
複製程式碼
然後呼叫dispatch_semaphore_wait
與dispatch_semaphore_signal
時會對dsema_value
做加一或減一操作。當我們對訊號量置空或者重新賦值操作時,會呼叫dispatch_semaphore_dispose
釋放訊號量,我們來看看對應的原始碼
static void
_dispatch_semaphore_dispose(dispatch_semaphore_t dsema)
{
if (dsema->dsema_value < dsema->dsema_orig) {//當前的訊號值如果小於初始值就會crash
DISPATCH_CLIENT_CRASH(
"Semaphore/group object deallocated while in use");
}
#if USE_MACH_SEM
kern_return_t kr;
if (dsema->dsema_port) {
kr = semaphore_destroy(mach_task_self(), dsema->dsema_port);
DISPATCH_SEMAPHORE_VERIFY_KR(kr);
}
if (dsema->dsema_waiter_port) {
kr = semaphore_destroy(mach_task_self(), dsema->dsema_waiter_port);
DISPATCH_SEMAPHORE_VERIFY_KR(kr);
}
#elif USE_POSIX_SEM
int ret = sem_destroy(&dsema->dsema_sem);
DISPATCH_SEMAPHORE_VERIFY_RET(ret);
#endif
_dispatch_dispose(dsema);
}
複製程式碼
從原始碼中我們可以看出,當dsema_value
小於dsema_orig
時,即訊號量還在使用時,會直接呼叫DISPATCH_CLIENT_CRASH
讓APP奔潰。
所以,我們在使用訊號量的時候,不能在它還在使用的時候,進行賦值或者置空的操作。
如果文中有錯誤的地方,或者與你的想法相悖的地方,請在評論區告知我,我會繼續改進,如果你覺得這個篇文章總結的還不錯,麻煩動動小手,給我的文章與Git程式碼樣例
點個✨