從0開始弄一個面向OC資料庫(五)–多執行緒安全

張書康發表於2019-02-13

前言

通過一步一個腳印的開發,我們實現了資料庫的增刪查改,並支援多種型別資料儲存,如:所有基本資料型別,NSArray,NSMutableArray,NSDictionary,NSMutableDictionary,UIImage,NSURL,UIColor,NSSet,NSRange,NSAttributedString,NSData,自定義模型以及陣列、字典、模型相互巢狀的複雜場景。

然後我們非常完美的將開啟資料庫,建立資料庫表格,解析模型物件,插入資料,更新資料,資料庫升級,資料遷移,關閉資料庫一系列步驟封裝成一個方法,一行程式碼智慧實現複雜模型的資料儲存。如果想了解各個部分是如何實現的,可以前往之前的文章,傳送門:

從0開始弄一個面向OC資料庫(五)–多執行緒安全

本篇主要解決多執行緒安全問題,然後會隨著講一下常用的多執行緒安全技術以及關於在ARC下使用@autorelease的一個必要的場景,最後會分享我們在進行單元測試的時候遇到的一個小坑。

功能實現

實現功能之前,我們先知道多執行緒安全要做什麼?簡單的來說,我們就是要保證在多個執行緒同時對資料庫進行操作的時候是安全的,也可以說我們要保證所有資料庫的操作不管從哪個執行緒過來都要等前面的操作執行完畢再執行本操作,避免資源競爭和衝突。

然後我們要去了解一下OC下保證多執行緒安全的手段,對於OC我們最常見的有原子性atomic,然後有NSLock鎖、@synchronized、GCD的訊號量、序列佇列。

當我們在糾結選擇何種方案的時候,我們可以先去看看前輩們的開源是如何做資料庫執行緒安全的,借鑑一下,最終我們總結出兩個比較優秀的方案:一種方案是FMDB所使用的同步序列佇列:所有的操作都用一個序列的佇列排好,一個個操作排隊進行。另一種是使用GCD訊號量dispatch_semaphore_t。結合我們之前寫的程式碼,根據我們目前的資料庫方案,快速對比一下哪種更適合我們,最終我們選擇了GCD訊號量,使用這個方案,我們的程式碼基本不用變動

在GCD中有三個函式是semaphore的操作,分別是:

  • dispatch_semaphore_create   建立一個semaphore
  • dispatch_semaphore_wait    等待訊號
  • dispatch_semaphore_signal   傳送一個訊號

簡單的介紹一下這三個函式:

dispatch_samaphore_t dispatch_semaphore_create(long value);
這個函式有一個長整形的引數,我們可以理解為訊號的總量;

long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout);
這個函式第一個引數為訊號量,第二個為等待的時間,這個函式的作用是這樣的:
如果dsema訊號量的值大於0,該函式所處執行緒就繼續執行下面的語句,並且將訊號量的值減1;
如果desema的值小於等於0,那麼這個函式就阻塞當前執行緒等待timeout(注意timeout的型別為dispatch_time_t);
如果在等待的期間desema大於0了,則向下執行操作並講訊號量減1.

long dispatch_semaphore_signal(dispatch_semaphore_tdsema)
這個函式會使傳入的訊號量dsema的值加1;

複製程式碼

同時考慮到我們目前的方法都是類方法,我們需要一個例項來記住desema的值,於是我們給CWSqliteModelTool開啟一個單例物件來記錄desema的值,設定訊號總量為1,之後在每一個執行資料庫操作的的方法開始前進行等待訊號量,如果當前訊號量大於0,我們執行運算元據庫,並講訊號量減1,當操作完成之後,我們傳送訊號,使訊號量增1,這樣使其他在等待的執行緒能開始執行操作,以執行查詢操作為例:

- (instancetype)init
{
    self = [super init];
    if (self) {
        // 設定訊號量為1,表示最多同時只有1個執行緒進行操作
        self.dsema = dispatch_semaphore_create(1);
    }
    return self;
}
// 建立一個單例,來記錄訊號量的值
static CWSqliteModelTool * instance = nil;
+ (instancetype)shareInstance {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[CWSqliteModelTool alloc] init];
    });
    return instance;
}

// 查詢表內所有資料
+ (NSArray *)queryAllModels:(Class)cls uid:(NSString *)uid targetId:(NSString *)targetId {
    // 等待訊號量,如果大於0,向下執行操作,否則等待
    dispatch_semaphore_wait([[self shareInstance] dsema], DISPATCH_TIME_FOREVER);
    
    NSString *tableName = [CWModelTool tableName:cls targetId:targetId];
    NSString *sql = [NSString stringWithFormat:@"select * from %@", tableName];
    
    NSArray <NSDictionary *>*results = [CWDatabase querySql:sql uid:uid];
    [CWDatabase closeDB];
    // 傳送訊號量,使訊號量+1
    dispatch_semaphore_signal([[self shareInstance] dsema]);
    
    return [self parseResults:results withClass:cls];
}

複製程式碼

我們在其他的方法內寫上同樣的程式碼,然後我們使用插入資料與刪除資料的方法進行單元測試,測試方案為,開啟3條子執行緒,每條執行緒分別插入1000個複雜的模型,當資料插入結束的時候,再使用3條子執行緒刪除其中的2900條資料,最後剩下100條資料,然後我們來進行單元測試:

在使用單元測試時,分享一個我們發現的坑~我們發現一條資料都沒插入成功或者偶爾插入了一兩條資料,我們反覆檢測我們的程式碼,理論上都是沒問題的,最終我們定位到子執行緒佇列的任務壓根沒執行,思考之後最終我們得出結論:單元測試是在主執行緒執行,我們使用非同步執行緒時並不會阻塞主執行緒的執行,所以這個測試用例順暢無阻的從第一行執行到了最後一行,而單元測試執行完最後一行之後程式就退出了,程式都退出了我們非同步的執行緒的操作當然沒法再執行了~

所以我們不能使用單元測試(或者在單元測試自己開啟一個runloop讓程式不退出),改在程式內進行測試,貼上我們非常長的測試程式碼(viewController內):

#pragma mark - for迴圈未使用autoreleasepool的多執行緒操作
- (void)testMultiThreadingSqliteMore {
    
    dispatch_queue_t queue1 = dispatch_queue_create("CWDBTest1", DISPATCH_QUEUE_CONCURRENT);
    dispatch_queue_t queue2 = dispatch_queue_create("CWDBTest2", DISPATCH_QUEUE_CONCURRENT);
    dispatch_queue_t queue3 = dispatch_queue_create("CWDBTest3", DISPATCH_QUEUE_CONCURRENT);
    dispatch_queue_t queue4 = dispatch_queue_create("CWDBTest4", DISPATCH_QUEUE_CONCURRENT);
    
    dispatch_group_t group = dispatch_group_create();
    dispatch_group_enter(group);
    dispatch_group_enter(group);
    dispatch_group_enter(group);
    
    dispatch_async(queue1, ^{
        for (int i = 1; i < 1000; i++) {
            Student *stu = [self studentWithId:i];
            BOOL result = [CWSqliteModelTool insertOrUpdateModel:stu uid:@"Chavez" targetId:nil];
            NSLog(@"result : %d   %zd",result,stu.stuId);
        }
        NSLog(@"---------------組1結束");
        dispatch_group_leave(group);
    });
    
    dispatch_async(queue2, ^{
        for (int i = 1000; i < 2000; i++) {
            Student *stu = [self studentWithId:i];
            BOOL result = [CWSqliteModelTool insertOrUpdateModel:stu uid:@"Chavez" targetId:nil];
            NSLog(@"result : %d   %zd",result,stu.stuId);
        }
        NSLog(@"---------------組2結束");
        dispatch_group_leave(group);
    });
    
    dispatch_async(queue3, ^{
        for (int i = 2000; i < 3000; i++) {
            Student *stu = [self studentWithId:i];
            BOOL result = [CWSqliteModelTool insertOrUpdateModel:stu uid:@"Chavez" targetId:nil];
            NSLog(@"result : %d   %zd",result,stu.stuId);
        }
        NSLog(@"---------------組3結束");
        dispatch_group_leave(group);
    });
    
    // 當前面3個佇列的任務都完成,則呼叫此通知
    dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        NSLog(@"----------------------插入結束");
        dispatch_async(queue4, ^{
            for (int i = 1; i < 1000; i++) {
                Student *stu = [self studentWithId:i];
                // 刪除資料
                BOOL result = [CWSqliteModelTool deleteModel:stu uid:@"Chavez" targetId:nil];
                NSLog(@"delete result : %d   %zd",result,stu.stuId);
            }
        });
        dispatch_async(queue1, ^{
            for (int i = 2000; i < 3000; i++) {
                Student *stu = [self studentWithId:i];
                // 刪除資料
                BOOL result = [CWSqliteModelTool deleteModel:stu uid:@"Chavez" targetId:nil];
                NSLog(@"delete result : %d   %zd",result,stu.stuId);
            }
        });
        
        dispatch_async(queue2, ^{
            // 刪除資料
            BOOL result = [CWSqliteModelTool deleteModel:[Student class] columnNames:@[@"stuId",@"stuId"] relations:@[@(CWDBRelationTypeMoreEqual),@(CWDBRelationTypeLess)] values:@[@(1000),@(1900)] isAnd:YES uid:@"Chavez" targetId:nil];
            NSLog(@"delete result : %d  1000-1900",result);
        });
    });
}

#pragma mark - 快速獲取一個模型
- (Student *)studentWithId:(int)stuId {
    School *school1 = [[School alloc] init];
    school1.name = @"北京大學";
    school1.schoolId = 2;
    
    School *school = [[School alloc] init];
    school.name = @"清華大學";
    school.schoolId = 1;
    school.school1 = school1;
    
    Student *stu = [[Student alloc] init];
    stu.stuId = stuId;
    stu.name = @"Baidu";
    stu.age = 100;
    stu.height = 190;
    stu.weight = 140;
    stu.dict = @{@"name" : @"chavez"};
    // 字典巢狀模型
    stu.dictM = [@{@"清華大學" : school , @"北京大學" : school1 , @"money" : @(100)} mutableCopy];
    // 陣列巢狀字典,字典巢狀模型
    stu.arrayM = [@[@"chavez",@"cw",@"ccww",@{@"清華大學" : school}] mutableCopy];
    // 陣列巢狀模型
    stu.array = @[@(1),@(2),@(3),school,school1];
    NSAttributedString *attributedStr = [[NSAttributedString alloc] initWithString:@"attributedStr,attributedStr"];
    stu.attributedString = attributedStr;
    // 模型巢狀模型
    stu.school = school;
    UIImage *image = [UIImage imageNamed:@"001"];
    NSData *data = UIImageJPEGRepresentation(image, 1);
    stu.image = image;
    stu.data = data;
    
    return stu;
}
複製程式碼

然後執行,獲取測試結果,但是在反覆側測試的時候,我們發現一個問題,如下圖:

從0開始弄一個面向OC資料庫(五)–多執行緒安全

發現記憶體最高漲到了恐怖的500M!!程式執行起來52M,瞬間翻了10倍。且結束之後記憶體還有90多M,這一定是我們程式的問題!!對於有經驗的人來說,他們一定能馬上定位到問題出現在哪裡,甚至他們在寫程式碼的時候就能知道這樣寫會有問題,而我是沒有經驗的,我思考了一陣,由於有一點理論的知識,最終定位到可能是for迴圈建立了大量的臨時變數沒有被及時釋放導致的,然後根據之前有看到過使用@autoreleasepool釋放臨時變數,蘋果官方文件有(Using Autorelease Pool Blocks)說到:當有大量中間臨時變數產生時,為了避免記憶體使用峰值過高,應到使用@autoreleasepool及時釋放記憶體。最終我們修改程式碼,並進行測試:

從0開始弄一個面向OC資料庫(五)–多執行緒安全


測試時發現,記憶體一直維持在54M左右,效果非常明顯,基本上和程式剛啟動佔用的記憶體差不多,通過這次經驗,我們更加深入的理解,在ARC環境下,如何使用@autoreleasepool來控制程式的記憶體。然後我們開啟資料庫檢測資料是否是剩下對應的100條資料,測試是沒問題的,然後我們再呼叫我們自己寫的查詢資料庫方法進行查詢,查詢的結果也是沒問題的。

在我們解決了多執行緒安全的問題之後我們發現,既然可能有存在需要批量插入資料的情況,我們就多增加一個介面來處理批量插入操作,批量插入實際上就是我們替使用者進行for迴圈插入,但是在插入的過程中,我們用事務控制插入操作,並且插入過程比使用者少的是每次插入資料都要執行開啟和關閉資料庫操作以及每次都去檢測是否需要更新資料庫表。

#pragma mark - 測試批量插入資料
- (void)testGroupInsert {
    NSMutableArray *arr = [NSMutableArray array];
    for (int i = 1; i < 2000; i++) {
        Student *stu = [self studentWithId:i];
        [arr addObject:stu];
    }
    NSLog(@"開始插入資料");
    // 2017-12-23 16:25:46.145023+0800 CWDB[14678:1604328] 開始插入資料
    BOOL result = [CWSqliteModelTool insertOrUpdateModels:arr uid:@"Chavez" targetId:nil];
    NSLog(@"---%zd---插入結束",result);
    // 2017-12-23 16:25:48.466352+0800 CWDB[14678:1604328] ---1---插入結束
    // 使用批量插入的方法 插入2000條資料,總共耗時2.3秒
    
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        NSLog(@"---------------組1開始");
        // 2017-12-23 16:25:48.466587+0800 CWDB[14678:1604407] ---------------組1開始
        for (int i = 2000; i < 4000; i++) {
            @autoreleasepool {
                Student *stu = [self studentWithId:i];
                BOOL result = [CWSqliteModelTool insertOrUpdateModel:stu uid:@"Chavez" targetId:nil];
                NSLog(@"result : %d   %zd",result,stu.stuId);
            }
        }
        NSLog(@"---------------組1結束");
        // 2017-12-23 16:25:56.247631+0800 CWDB[14678:1604407] ---------------組1結束
        // 自行遍歷的方式插入2000條資料,總共耗時8秒(且要自行增加autoreleasepool釋放臨時變數)
    });
    
}
複製程式碼

最終我們通過批量插入以及單個插入2000條資料的時間比較,批量插入消耗的時間遠低於單個分別插入。

在寫完功能之後,我們對專案又進行了一小部分快取優化,在不考慮動態給模型新增屬性的情況下,我們每次去獲取模型成員變數以及型別一定是一樣的,所以,首先我們在這裡使用NSCache來快取了模型的所有成員變數名以及型別,這樣可以不用每次都去解析模型。其次,在判斷資料庫表結構是否需要更新的時候,我們也做了快取,在程式執行期間,只要更新過一次,後面都不用去判斷更新,因為成員變數不變,表結構一定不會變,這都是對效能方面的一些小優化,在做的時候可以適當的考慮一下。

本篇結束

在此,我們封裝的資料庫功能已經開發完了(其實自己封裝一個資料庫也沒想象中那麼難,你也可以的~)回到第一篇文章所立的軍令狀:我們封裝的簡單適用,安全可靠,功能全面,我們說到做到。增刪查改所有操作都只需要一行程式碼。就算你給陣列新增一個物件也需要兩步:先初始化一個陣列,然後再想陣列新增物件,而我們向資料庫插入一個模型,只需要一步,呼叫insertOrUpdateModel:即可。

在接下來,我們會將封裝的程式碼進行少量的重構和優化,去掉一些不必要暴露的方法和對應的單元測試,儘量讓API簡潔明瞭以及去掉一些重複程式碼的封裝,將註釋補全再經過大量的測試場景測試之後,我們將會把我們的CWDB推薦給大家使用,如果你有興趣瞭解或者想自己動手封裝一個資料庫,可以前往本系列文章第一篇開始看一看(文章的連線在本文的開頭),每篇文章對應的程式碼在github的release下都有分別的tag,你可以找到他並且下載下來。。

本篇文章實現的程式碼地址:github:CWDB ——tag為1.4.0——-

(注意:如果要直接執行,必須在CWDatabase.m開頭的位置修改資料庫存放的路徑,開發除錯階段我寫在了我電腦的桌面,不修改會出現路徑錯誤,導致開啟資料庫失敗)

最後覺得有用的同學,希望能給本文點個喜歡,給github點個star以資鼓勵,謝謝大家。歡迎大家向我拋issue,有更好的思路也歡迎大家留言。

給大家安利一個0耦合的仿QQ側滑框架,真正的一行程式碼實現,多了你抽我?:
一行程式碼整合超低耦合的側滑功能

相關文章