本文讨论的 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 到底是不是线程安全的?markdown
Google 了一下,发现了关于SQLite的官方文档 - Using SQLite In Multi-Threaded Applications。文档写的很清晰,有时间最好认真读读,这里简单总结一下。多线程
SQLite 有3种线程模式:app
有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.