本文討論的 FMDB 版本為
2.7.5
买置,測(cè)試環(huán)境是Xcode 10.1 & iOS 12.1
。
一强霎、問(wèn)題記錄
最近在分析崩潰日志的時(shí)候發(fā)現(xiàn)一個(gè) FMDB 的 crash 頻繁出現(xiàn)忿项,crash 堆棧如下:
在控制臺(tái)能看到報(bào)錯(cuò):
[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:]
從日志中能大概猜到,這是多線程訪問(wèn)數(shù)據(jù)庫(kù)導(dǎo)致的 crash城舞。FMDB 提供了 FMDatabaseQueue
在多線程環(huán)境下操作數(shù)據(jù)庫(kù)轩触,它內(nèi)部維護(hù)了一個(gè)串行隊(duì)列來(lái)保證線程安全。我檢查了所有操作數(shù)據(jù)庫(kù)的代碼家夺,都是在 FMDatabaseQueue
隊(duì)列里執(zhí)行的脱柱,為啥還是會(huì)報(bào)多線程問(wèn)題(一臉懵逼??)?
在網(wǎng)上找了一圈拉馋,發(fā)現(xiàn) github 上有人遇到了同樣的問(wèn)題榨为, Issue 724 和 Issue 711,Stack Overflow上有相關(guān)的討論煌茴。
項(xiàng)目里業(yè)務(wù)太復(fù)雜随闺,很難排查問(wèn)題,于是寫(xiě)了一個(gè)簡(jiǎn)化版的 Demo 來(lái)復(fù)現(xiàn)問(wèn)題:
NSString *dbPath = [docPath stringByAppendingPathComponent:@"test.sqlite"];
_queue = [FMDatabaseQueue databaseQueueWithPath:dbPath];
// 構(gòu)建測(cè)試數(shù)據(jù)蔓腐,新建一個(gè)表test矩乐,inert一些數(shù)據(jù)
[_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')"];
}
}];
// 多線程查詢(xún)數(shù)據(jù)庫(kù)
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就沒(méi)問(wèn)題了
if ([result next]) {
}
// 這里不調(diào)用close
// [result close];
}];
});
}
問(wèn)題完美復(fù)現(xiàn),接下來(lái)就可以排查問(wèn)題了散罕,有兩個(gè)問(wèn)題亟待解決:
- iOS 系統(tǒng)自帶的 SQLite 究竟是不是線程安全的分歇?
- 為什么使用了線程安全隊(duì)列
FMDatabaseQueue
, 還是出現(xiàn)了線程安全問(wèn)題欧漱?
二职抡、SQLite 線程安全
我們先來(lái)看第一個(gè)問(wèn)題,iOS 系統(tǒng)自帶的 SQLite 究竟是不是線程安全的硫椰?
Google 了一下繁调,發(fā)現(xiàn)了關(guān)于SQLite的官方文檔 - Using SQLite In Multi-Threaded Applications。文檔寫(xiě)的很清晰靶草,有時(shí)間最好認(rèn)真讀讀蹄胰,這里簡(jiǎn)單總結(jié)一下。
SQLite 有3種線程模式:
- Single-thread奕翔,單線程模式裕寨,編譯時(shí)所有互斥鎖代碼會(huì)被刪除掉,多線程環(huán)境下不安全派继。
- Multi-thread宾袜,在大部分情況下多線程環(huán)境安全,比如同一個(gè)數(shù)據(jù)庫(kù)驾窟,開(kāi)多個(gè)線程庆猫,每個(gè)線程都開(kāi)一個(gè)連接同時(shí)訪問(wèn)這個(gè)庫(kù),這種情況是安全的绅络。但是也有不安全情況:多個(gè)線程同時(shí)使用同一個(gè)數(shù)據(jù)庫(kù)連接(或從該連接派生的任何預(yù)準(zhǔn)備語(yǔ)句)
- Serialized月培,完全線程安全。
有3個(gè)時(shí)間點(diǎn)可以配置 threading mode恩急,編譯時(shí)(compile-time)杉畜、初始化時(shí)(start-time)、運(yùn)行時(shí)(run-time)衷恭。配置生效規(guī)則是 run-time 覆蓋 start-time 覆蓋 compile-time此叠,有一些特殊情況:
- 編譯時(shí)設(shè)置
Single-thread
,用戶就不能再開(kāi)啟多線程模式随珠,因?yàn)榫€程安全代碼被優(yōu)化了灭袁。 - 如果編譯時(shí)設(shè)置的多線程模式,在運(yùn)行時(shí)不能降級(jí)為單線程模式窗看,只能在
Multi-thread
和Serialized
間切換简卧。
threading mode 編譯選項(xiàng)
SQLite threading mode 編譯選項(xiàng)的官方文檔
編譯時(shí),通過(guò)配置項(xiàng)SQLITE_THREADSAFE
可以配置 SQLite 在多線程環(huán)境下是否安全烤芦。有三個(gè)可選項(xiàng):
- 0举娩,對(duì)應(yīng) Single-thread ,編譯時(shí)所有互斥鎖代碼會(huì)被刪除掉,SQLite 在多線程環(huán)境下不安全铜涉。
- 1智玻,對(duì)應(yīng) Serialized,在多線程環(huán)境下安全芙代,如果不手動(dòng)指定吊奢,這是默認(rèn)選項(xiàng)。
- 2纹烹,對(duì)應(yīng) Multi-thread 页滚,在大部分情況下多線程環(huán)境安全,不安全情況:有兩個(gè)線程同時(shí)嘗試使用相同數(shù)據(jù)庫(kù)連接(或從該數(shù)據(jù)庫(kù)連接派生的任何預(yù)處理語(yǔ)句 Prepared Statements)
除了編譯時(shí)可以指定 threading mode 铺呵,還可以通過(guò)函數(shù) sqlite3_config()
(start-time )改變?nèi)值?threading mode 或者通過(guò)sqlite3_open_v2()
(run-time)改變某個(gè)數(shù)據(jù)庫(kù)連接的 threading mode裹驰。
但是如果編譯時(shí)配置了SQLITE_THREADSAFE = 0
,編譯時(shí)所有線程安全代碼都被優(yōu)化掉了片挂,就不能再切換到多線程模式了幻林。
有了前面的知識(shí),我們就可以分析問(wèn)題一了音念。調(diào)用函數(shù) sqlite3_threadsafe()
可以獲取編譯時(shí)的配置項(xiàng)沪饺,我們可以用這個(gè)函數(shù)獲取系統(tǒng)自帶的 SQLite 在編譯時(shí)的配置瘫析,結(jié)論是2(Multi-thread)团搞。
也就是說(shuō),系統(tǒng)自帶的 SQLite 在不做任何配置的情況下不是完全線程安全的良姆。當(dāng)然可以手動(dòng)將模式切換到 Serialized
就可以實(shí)現(xiàn)完全線程安全了讥脐。
// 方案一:全局設(shè)置模式
sqlite3_config(SQLITE_CONFIG_SERIALIZED);
// 方案二:設(shè)置 connecting 模式遭居,調(diào)用 sqlite3_open_v2 時(shí) flag 加上 SQLITE_OPEN_FULLMUTEX
sqlite3_open_v2(path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil)
經(jīng)過(guò)測(cè)試,通過(guò)上面兩種方案改造之后攘烛,Demo 中的 crash 問(wèn)題完美解決。但是我認(rèn)為這不是最優(yōu)的解決方案镀首,蘋(píng)果為啥不直接將編譯選項(xiàng)設(shè)置為 Serialized
坟漱,這篇文章就永遠(yuǎn)不會(huì)出現(xiàn)了??,勞民傷財(cái)讓大家折騰半天更哄,去手動(dòng)設(shè)置模式芋齿。我認(rèn)為性能是一個(gè)重要因素,Multi-thread
性能優(yōu)于 Serialized
, 用戶只要保證一個(gè)連接不在多線程同時(shí)訪問(wèn)就沒(méi)問(wèn)題了成翩,其實(shí)能滿足大部分需求觅捆。
比如 FMDB 的 FMDatabaseQueue
就是為了解決該問(wèn)題。
三麻敌、FMDatabaseQueue 其實(shí)并不安全
FMDB 的官方文檔寫(xiě)到:
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
的確很安全栅炒,通過(guò) GCD 的串行隊(duì)列來(lái)保證所有讀寫(xiě)操作都是串行執(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ù)處理語(yǔ)句(Reset A Prepared Statement Object)
sqlite3_reset(_statement);
}
_inUse = NO;
}
這個(gè)函數(shù)的調(diào)用棧如下:
- [FMStatement reset]
- [FMResultSet close]
- [FMResultSet dealloc]
順著調(diào)用堆棧乙漓,我們來(lái)看看 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
時(shí)會(huì)調(diào)用 close
方法释移,來(lái)關(guān)閉預(yù)處理語(yǔ)句叭披。再回到第一節(jié)的 crash 堆棧,不難發(fā)現(xiàn)線程7在用同一個(gè)數(shù)據(jù)庫(kù)連接讀數(shù)據(jù)庫(kù)玩讳,結(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ā)生了多線程使用同一個(gè)數(shù)據(jù)庫(kù)連接熏纯、預(yù)處理語(yǔ)句的情況同诫,于是就崩潰了。
解決方案
問(wèn)題找到了豆巨,接下來(lái)聊聊怎么避免問(wèn)題剩辟。
FMDB的正確打開(kāi)方式
如果用 while
循環(huán)遍歷 FMResultSet
就不存在該問(wèn)題,因?yàn)?[FMResultSet next]
遍歷到最后會(huì)調(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])
贩猎,手動(dòng)加上 [FMResultSet close]
也沒(méi)有問(wèn)題。
寫(xiě)在最后
我遇到這個(gè)問(wèn)題萍膛,是被官方文檔的一句話誤導(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.
于是我提了一個(gè) Pull requests ,我提出了兩種解決方案:
- 修改文檔蝗罗,在文檔中強(qiáng)調(diào)艇棕,用戶需要手動(dòng)調(diào)用 close。
- 在
[FMDatabaseQueue inDatabase:]
函數(shù)的最后串塑,調(diào)用[FMDatabase closeOpenResultSets]
幫助調(diào)用者關(guān)閉所有 FMResultSet沼琉。
FMDB 的作者 ccgus
采用了第一種方案,在最新的一次 commit 修改了文檔桩匪,加上了相關(guān)說(shuō)明打瘪。
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.