從FMDB執行緒安全問題說起

CRMO發表於2019-01-28

本文討論的 FMDB 版本為2.7.5,測試環境是 Xcode 10.1 & iOS 12.1

一、問題記錄

最近在分析崩潰日誌的時候發現一個 FMDB 的 crash 頻繁出現,crash 堆疊如下:

從FMDB執行緒安全問題說起

在控制檯能看到報錯:

[logging] BUG IN CLIENT OF sqlite3.dylib: illegal multi-threaded access to database connection
Warning: there is at least one open result set around after performing [FMDatabaseQueue inDatabase:]
複製程式碼

從日誌中能大概猜到,這是多執行緒訪問資料庫導致的 crash。FMDB 提供了 FMDatabaseQueue 在多執行緒環境下運算元據庫,它內部維護了一個序列佇列來保證執行緒安全。我檢查了所有運算元據庫的程式碼,都是在 FMDatabaseQueue 佇列裡執行的,為啥還是會報多執行緒問題(一臉懵逼?)?

在網上找了一圈,發現 github 上有人遇到了同樣的問題, Issue 724Issue 711,Stack Overflow上有相關的討論

專案裡業務太複雜,很難排查問題,於是寫了一個簡化版的 Demo 來複現問題:

    NSString *dbPath = [docPath stringByAppendingPathComponent:@"test.sqlite"];
    _queue = [FMDatabaseQueue databaseQueueWithPath:dbPath];
    
    // 構建測試資料,新建一個表test,inert一些資料
    [_queue inDatabase:^(FMDatabase * _Nonnull db) {
        [db executeUpdate:@"create table if not exists test (a text, b text, c text, d text, e text, f text, g text, h text, i text)"];
        for (int i = 0; i < 10000; i++) {
            [db executeUpdate:@"insert into test (a, b, c, d, e, f, g, h, i) values ('1', '1', '1','1', '1', '1','1', '1', '1')"];
        }
    }];
    
    // 多執行緒查詢資料庫
    for (int i = 0; i < 10; i++) {
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            [_queue inDatabase:^(FMDatabase * _Nonnull db) {
                FMResultSet *result = [db executeQuery:@"select * from test where a = '1'"];
                // 這裡要用if,改成while就沒問題了
                if ([result next]) {
                }
                // 這裡不呼叫close
//                [result close];
            }];
        });
    }
複製程式碼

問題完美復現,接下來就可以排查問題了,有兩個問題亟待解決:

  1. iOS 系統自帶的 SQLite 究竟是不是執行緒安全的?
  2. 為什麼使用了執行緒安全佇列 FMDatabaseQueue, 還是出現了執行緒安全問題?

二、SQLite 執行緒安全

我們先來看第一個問題,iOS 系統自帶的 SQLite 究竟是不是執行緒安全的?

Google 了一下,發現了關於SQLite的官方文件 - Using SQLite In Multi-Threaded Applications。文件寫的很清晰,有時間最好認真讀讀,這裡簡單總結一下。

SQLite 有3種執行緒模式:

  1. Single-thread,單執行緒模式,編譯時所有互斥鎖程式碼會被刪除掉,多執行緒環境下不安全。
  2. Multi-thread,在大部分情況下多執行緒環境安全,比如同一個資料庫,開多個執行緒,每個執行緒都開一個連線同時訪問這個庫,這種情況是安全的。但是也有不安全情況:多個執行緒同時使用同一個資料庫連線(或從該連線派生的任何預準備語句)
  3. Serialized,完全執行緒安全。

有3個時間點可以配置 threading mode,編譯時(compile-time)、初始化時(start-time)、執行時(run-time)。配置生效規則是 run-time 覆蓋 start-time 覆蓋 compile-time,有一些特殊情況:

  1. 編譯時設定 Single-thread,使用者就不能再開啟多執行緒模式,因為執行緒安全程式碼被優化了。
  2. 如果編譯時設定的多執行緒模式,在執行時不能降級為單執行緒模式,只能在Multi-threadSerialized間切換。

threading mode 編譯選項

SQLite threading mode 編譯選項的官方文件

從FMDB執行緒安全問題說起

編譯時,通過配置項SQLITE_THREADSAFE可以配置 SQLite 在多執行緒環境下是否安全。有三個可選項:

  1. 0,對應 Single-thread ,編譯時所有互斥鎖程式碼會被刪除掉,SQLite 在多執行緒環境下不安全。
  2. 1,對應 Serialized,在多執行緒環境下安全,如果不手動指定,這是預設選項。
  3. 2,對應 Multi-thread ,在大部分情況下多執行緒環境安全,不安全情況:有兩個執行緒同時嘗試使用相同資料庫連線(或從該資料庫連線派生的任何預處理語句 Prepared Statements)

除了編譯時可以指定 threading mode ,還可以通過函式 sqlite3_config() (start-time )改變全域性的 threading mode 或者通過sqlite3_open_v2() (run-time)改變某個資料庫連線的 threading mode。

但是如果編譯時配置了SQLITE_THREADSAFE = 0,編譯時所有執行緒安全程式碼都被優化掉了,就不能再切換到多執行緒模式了。

有了前面的知識,我們就可以分析問題一了。呼叫函式 sqlite3_threadsafe() 可以獲取編譯時的配置項,我們可以用這個函式獲取系統自帶的 SQLite 在編譯時的配置,結論是2(Multi-thread)。

也就是說,系統自帶的 SQLite 在不做任何配置的情況下不是完全執行緒安全的。當然可以手動將模式切換到 Serialized 就可以實現完全執行緒安全了。

// 方案一:全域性設定模式
sqlite3_config(SQLITE_CONFIG_SERIALIZED);

// 方案二:設定 connecting 模式,呼叫 sqlite3_open_v2 時 flag 加上 SQLITE_OPEN_FULLMUTEX
sqlite3_open_v2(path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil)

複製程式碼

經過測試,通過上面兩種方案改造之後,Demo 中的 crash 問題完美解決。但是我認為這不是最優的解決方案,蘋果為啥不直接將編譯選項設定為 Serialized,這篇文章就永遠不會出現了?,勞民傷財讓大家折騰半天,去手動設定模式。我認為效能是一個重要因素,Multi-thread 效能優於 Serialized, 使用者只要保證一個連線不在多執行緒同時訪問就沒問題了,其實能滿足大部分需求。

比如 FMDB 的 FMDatabaseQueue 就是為了解決該問題。

三、FMDatabaseQueue 其實並不安全

FMDB 的官方文件寫到:

FMDatabaseQueue will run the blocks on a serialized queue (hence the name of the class). So if you call FMDatabaseQueue's methods from multiple threads at the same time, they will be executed in the order they are received. This way queries and updates won't step on each other's toes, and every one is happy.

在多執行緒使用 FMDatabaseQueue 的確很安全,通過 GCD 的序列佇列來保證所有讀寫操作都是序列執行的。它的核心程式碼如下:

_queue = dispatch_queue_create([[NSString stringWithFormat:@"fmdb.%@", self] UTF8String], NULL);

- (void)inDatabase:(__attribute__((noescape)) void (^)(FMDatabase *db))block {
    // ...省略部分程式碼
    
    dispatch_sync(_queue, ^() {
        FMDatabase *db = [self database];
        block(db);
    });
    
    // ...省略部分程式碼
}
複製程式碼

但是分析第一節 Demo 的 crash 堆疊,可以看到崩潰發生線上程3的函式 [FMResultSet reset],函式定義如下:

- (void)reset {
    if (_statement) {
        // 釋放預處理語句(Reset A Prepared Statement Object)
        sqlite3_reset(_statement);
    }
    _inUse = NO;
}
複製程式碼

這個函式的呼叫棧如下:

- [FMStatement reset]
- [FMResultSet close]
- [FMResultSet dealloc]
複製程式碼

順著呼叫堆疊,我們來看看 FMResultSetdeallocclose 方法:

- (void)dealloc {
    [self close];
    FMDBRelease(_query);
    _query = nil;
    FMDBRelease(_columnNameToIndexMap);
    _columnNameToIndexMap = nil;
}

- (void)close {
    [_statement reset];
    FMDBRelease(_statement);
    _statement = nil;
    [_parentDB resultSetDidClose:self];
    [self setParentDB:nil];
}
複製程式碼

這裡可以得出結論,在 FMResultSet dealloc 時會呼叫 close 方法,來關閉預處理語句。再回到第一節的 crash 堆疊,不難發現執行緒7在用同一個資料庫連線讀資料庫,結合官方文件中的一段話,我們就可以得出結論了。

When compiled with SQLITE_THREADSAFE=2, SQLite can be used in a multithreaded program so long as no two threads attempt to use the same database connection (or any prepared statements derived from that database connection) at the same time.

使用 FMDatabaseQueue 還是發生了多執行緒使用同一個資料庫連線、預處理語句的情況,於是就崩潰了。

解決方案

問題找到了,接下來聊聊怎麼避免問題。

FMDB的正確開啟方式

如果用 while 迴圈遍歷 FMResultSet 就不存在該問題,因為 [FMResultSet next] 遍歷到最後會呼叫 [FMResultSet close]

[_queue inDatabase:^(FMDatabase * _Nonnull db) {
    FMResultSet *result = [db executeQuery:@"select * from test where a = '1'"];
    // 安全
    while ([result next]) {
    }
    
    // 安全
    if ([result next]) {
    }
    [result close];
}];
複製程式碼

如果一定要用 if ([result next]) ,手動加上 [FMResultSet close] 也沒有問題。

寫在最後

寫在最後

我遇到這個問題,是被官方文件的一句話誤導了。

Typically, there's no need to -close an FMResultSet yourself, since that happens when either the result set is deallocated, or the parent database is closed.

於是我提了一個 Pull requests ,我提出了兩種解決方案:

  1. 修改文件,在文件中強調,使用者需要手動呼叫 close。
  2. [FMDatabaseQueue inDatabase:] 函式的最後,呼叫 [FMDatabase closeOpenResultSets] 幫助呼叫者關閉所有 FMResultSet。

FMDB 的作者 ccgus 採用了第一種方案,在最新的一次 commit 修改了文件,加上了相關說明。

Typically, there's no need to -close an FMResultSet yourself, since that happens when either the result set is exhausted. However, if you only pull out a single request or any other number of requests which don't exhaust the result set, you will need to call the -close method on the FMResultSet.


參考

  1. Using SQLite In Multi-Threaded Applications
  2. sqlite3.dylib: illegal multi-threaded access to database connection
  3. FMDB
  4. SQLite編譯選項官方文件

相關文章