从FMDB线程安全问题说起

本文讨论的 FMDB 版本为2.7.5,测试环境是 Xcode 10.1 & iOS 12.1

一、问题记录

最近在分析崩溃日志的时候发现一个 FMDB 的 crash 频繁出现,crash 堆栈如下:

15486768292546.jpg

在控制台能看到报错:

[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 编译选项的官方文档

15486559651186.jpg

编译时,通过配置项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编译选项官方文档
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容