本文討論的 FMDB 版本爲
2.7.5
,測試環境是Xcode 10.1 & iOS 12.1
。html
最近在分析崩潰日誌的時候發現一個 FMDB 的 crash 頻繁出現,crash 堆棧以下:git
在控制檯能看到報錯:github
[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
隊列裏執行的,爲啥仍是會報多線程問題(一臉懵逼🤔)?sql
在網上找了一圈,發現 github 上有人遇到了一樣的問題, Issue 724 和 Issue 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];
}];
});
}
複製代碼
問題完美復現,接下來就能夠排查問題了,有兩個問題亟待解決:bash
FMDatabaseQueue
, 仍是出現了線程安全問題?咱們先來看第一個問題,iOS 系統自帶的 SQLite 到底是不是線程安全的?多線程
Google 了一下,發現了關於SQLite的官方文檔 - Using SQLite In Multi-Threaded Applications。文檔寫的很清晰,有時間最好認真讀讀,這裏簡單總結一下。app
SQLite 有3種線程模式:async
有3個時間點能夠配置 threading mode,編譯時(compile-time)、初始化時(start-time)、運行時(run-time)。配置生效規則是 run-time 覆蓋 start-time 覆蓋 compile-time,有一些特殊狀況:
Single-thread
,用戶就不能再開啓多線程模式,由於線程安全代碼被優化了。Multi-thread
和Serialized
間切換。SQLite threading mode 編譯選項的官方文檔
編譯時,經過配置項SQLITE_THREADSAFE
能夠配置 SQLite 在多線程環境下是否安全。有三個可選項:
除了編譯時能夠指定 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
就是爲了解決該問題。
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]
複製代碼
順着調用堆棧,咱們來看看 FMResultSet
的 dealloc
和 close
方法:
- (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
仍是發生了多線程使用同一個數據庫鏈接、預處理語句的狀況,因而就崩潰了。
問題找到了,接下來聊聊怎麼避免問題。
若是用 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 ,我提出了兩種解決方案:
[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.