本文討論的 FMDB 版本為2.7.5,測試環(huán)境是?Xcode 10.1 & iOS 12.1
一沐批、問題記錄
最近在分析崩潰日志的時候發(fā)現(xiàn)一個 FMDB 的 crash 頻繁出現(xiàn)从铲,crash 堆棧如下:
在控制臺能看到報錯:
[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:]
從日志中能大概猜到瘪校,這是多線程訪問數(shù)據(jù)庫導(dǎo)致的 crash。FMDB 提供了 FMDatabaseQueue 在多線程環(huán)境下操作數(shù)據(jù)庫名段,它內(nèi)部維護了一個串行隊列來保證線程安全阱扬。我檢查了所有操作數(shù)據(jù)庫的代碼,都是在 FMDatabaseQueue 隊列里執(zhí)行的伸辟,為啥還是會報多線程問題(一臉懵逼??)麻惶?
在網(wǎng)上找了一圈,發(fā)現(xiàn) github 上有人遇到了同樣的問題自娩, Issue 724 和 Issue 711用踩,Stack Overflow上有相關(guān)的討論。
項目里業(yè)務(wù)太復(fù)雜忙迁,很難排查問題,于是寫了一個簡化版的 Demo 來復(fù)現(xiàn)問題:
問題完美復(fù)現(xiàn)碎乃,接下來就可以排查問題了姊扔,有兩個問題亟待解決:
iOS 系統(tǒng)自帶的 SQLite 究竟是不是線程安全的?
為什么使用了線程安全隊列 FMDatabaseQueue梅誓, 還是出現(xiàn)了線程安全問題恰梢?
二、SQLite 線程安全
我們先來看第一個問題梗掰,iOS 系統(tǒng)自帶的 SQLite 究竟是不是線程安全的嵌言?
Google 了一下,發(fā)現(xiàn)了關(guān)于SQLite的官方文檔 - Using SQLite In Multi-Threaded Applications及穗。文檔寫的很清晰摧茴,有時間最好認真讀讀,這里簡單總結(jié)一下埂陆。
SQLite 有3種線程模式:
Single-thread:單線程模式苛白,編譯時所有互斥鎖代碼會被刪除掉娃豹,多線程環(huán)境下不安全。
Multi-thread:在大部分情況下多線程環(huán)境安全购裙,比如同一個數(shù)據(jù)庫懂版,開多個線程,每個線程都開一個連接同時訪問這個庫躏率,這種情況是安全的躯畴。但是也有不安全情況:多個線程同時使用同一個數(shù)據(jù)庫連接(或從該連接派生的任何預(yù)準備語句)
Serialized:完全線程安全。
有3個時間點可以配置 threading mode薇芝,編譯時(compile-time)蓬抄、初始化時(start-time)、運行時(run-time)恩掷。配置生效規(guī)則是 run-time 覆蓋 start-time 覆蓋 compile-time倡鲸,有一些特殊情況:
編譯時設(shè)置 Single-thread,用戶就不能再開啟多線程模式黄娘,因為線程安全代碼被優(yōu)化了峭状。
如果編譯時設(shè)置的多線程模式,在運行時不能降級為單線程模式逼争,只能在Multi-thread和Serialized間切換优床。
threading mode 編譯選項
SQLite threading mode 編譯選項的官方文檔
編譯時,通過配置項SQLITE_THREADSAFE可以配置 SQLite 在多線程環(huán)境下是否安全誓焦。有三個可選項:
????????0胆敞,對應(yīng) Single-thread ,編譯時所有互斥鎖代碼會被刪除掉杂伟,SQLite 在多線程環(huán)境下不安全移层。
????????1,對應(yīng) Serialized赫粥,在多線程環(huán)境下安全观话,如果不手動指定,這是默認選項越平。
????????2频蛔,對應(yīng) Multi-thread ,在大部分情況下多線程環(huán)境安全秦叛,不安全情況:有兩個線程同時嘗試使用相同數(shù)據(jù)庫連接(或從該數(shù)據(jù)庫連接派生的任何預(yù)處理語句 Prepared Statements)
除了編譯時可以指定 threading mode 晦溪,還可以通過函數(shù) sqlite3_config() (start-time )改變?nèi)值?threading mode 或者通過sqlite3_open_v2() (run-time)改變某個數(shù)據(jù)庫連接的 threading mode。
但是如果編譯時配置了SQLITE_THREADSAFE = 0挣跋,編譯時所有線程安全代碼都被優(yōu)化掉了三圆,就不能再切換到多線程模式了。
有了前面的知識,我們就可以分析問題一了嫌术。調(diào)用函數(shù) sqlite3_threadsafe() 可以獲取編譯時的配置項哀澈,我們可以用這個函數(shù)獲取系統(tǒng)自帶的 SQLite 在編譯時的配置,結(jié)論是2(Multi-thread)度气。
也就是說割按,系統(tǒng)自帶的 SQLite 在不做任何配置的情況下不是完全線程安全的。當(dāng)然可以手動將模式切換到 Serialized 就可以實現(xiàn)完全線程安全了磷籍。
// 方案一:全局設(shè)置模式
sqlite3_config(SQLITE_CONFIG_SERIALIZED);
// 方案二:設(shè)置 connecting 模式适荣,調(diào)用 sqlite3_open_v2 時 flag 加上 SQLITE_OPEN_FULLMUTEX
sqlite3_open_v2(path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil)
經(jīng)過測試,通過上面兩種方案改造之后院领,Demo 中的 crash 問題完美解決弛矛。但是我認為這不是最優(yōu)的解決方案,蘋果為啥不直接將編譯選項設(shè)置為 Serialized比然,這篇文章就永遠不會出現(xiàn)了??丈氓,勞民傷財讓大家折騰半天,去手動設(shè)置模式强法。我認為性能是一個重要因素万俗,Multi-thread 性能優(yōu)于 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 的串行隊列來保證所有讀寫操作都是串行執(zhí)行的库倘。它的核心代碼如下:
_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);
? ? });
? ? // ...省略部分代碼
}
但是分析第一節(jié) Demo 的 crash 堆棧,可以看到崩潰發(fā)生在線程3的函數(shù) [FMResultSet reset]论矾,函數(shù)定義如下:
- (void)reset {
? ? if (_statement) {
? ? ? ? // 釋放預(yù)處理語句(Reset A Prepared Statement Object)
? ? ? ? sqlite3_reset(_statement);
? ? }
? ? _inUse = NO;
}
這個函數(shù)的調(diào)用棧如下:
- [FMStatement reset]
- [FMResultSet close]
- [FMResultSet dealloc]
順著調(diào)用堆棧教翩,我們來看看 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];
}
這里可以得出結(jié)論,在 FMResultSet dealloc 時會調(diào)用 close 方法贪壳,來關(guān)閉預(yù)處理語句迂曲。再回到第一節(jié)的 crash 堆棧,不難發(fā)現(xiàn)線程7在用同一個數(shù)據(jù)庫連接讀數(shù)據(jù)庫寥袭,結(jié)合官方文檔中的一段話,我們就可以得出結(jié)論了关霸。
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 還是發(fā)生了多線程使用同一個數(shù)據(jù)庫連接传黄、預(yù)處理語句的情況,于是就崩潰了队寇。
解決方案
問題找到了膘掰,接下來聊聊怎么避免問題。
FMDB的正確打開方式
如果用 while 循環(huán)遍歷 FMResultSet 就不存在該問題,因為 [FMResultSet next] 遍歷到最后會調(diào)用 [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] 也沒有問題。
寫在最后
記得看過一位大牛說遇到這個問題窒舟,是被官方文檔的一句話誤導(dǎo)了系忙。
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 ,和兩種解決方案:
修改文檔惠豺,在文檔中強調(diào)银还,用戶需要手動調(diào)用 close。
在 [FMDatabaseQueue inDatabase:] 函數(shù)的最后洁墙,調(diào)用 [FMDatabase closeOpenResultSets] 幫助調(diào)用者關(guān)閉所有 FMResultSet蛹疯。
FMDB 的作者 ccgus 采用了第一種方案,在最新的一次?commit?修改了文檔热监,加上了相關(guān)說明捺弦。
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