从0开始弄一个面向OC数据库(五)--多线程安全

1,303 阅读12分钟

前言

通过一步一个脚印的开发,我们实现了数据库的增删查改,并支持多种类型数据存储,如:所有基本数据类型,NSArray,NSMutableArray,NSDictionary,NSMutableDictionary,UIImage,NSURL,UIColor,NSSet,NSRange,NSAttributedString,NSData,自定义模型以及数组、字典、模型相互嵌套的复杂场景。

然后我们非常完美的将打开数据库,创建数据库表格,解析模型对象,插入数据,更新数据,数据库升级,数据迁移,关闭数据库一系列步骤封装成一个方法,一行代码智能实现复杂模型的数据存储。如果想了解各个部分是如何实现的,可以前往之前的文章,传送门:

本篇主要解决多线程安全问题,然后会随着讲一下常用的多线程安全技术以及关于在ARC下使用@autorelease的一个必要的场景,最后会分享我们在进行单元测试的时候遇到的一个小坑。

功能实现

实现功能之前,我们先知道多线程安全要做什么?简单的来说,我们就是要保证在多个线程同时对数据库进行操作的时候是安全的,也可以说我们要保证所有数据库的操作不管从哪个线程过来都要等前面的操作执行完毕再执行本操作,避免资源竞争和冲突。

然后我们要去了解一下OC下保证多线程安全的手段,对于OC我们最常见的有原子性atomic,然后有NSLock锁、@synchronized、GCD的信号量、串行队列。

当我们在纠结选择何种方案的时候,我们可以先去看看前辈们的开源是如何做数据库线程安全的,借鉴一下,最终我们总结出两个比较优秀的方案:一种方案是FMDB所使用的同步串行队列:所有的操作都用一个串行的队列排好,一个个操作排队进行。另一种是使用GCD信号量dispatch_semaphore_t。结合我们之前写的代码,根据我们目前的数据库方案,快速对比一下哪种更适合我们,最终我们选择了GCD信号量,使用这个方案,我们的代码基本不用变动

在GCD中有三个函数是semaphore的操作,分别是:

  • dispatch_semaphore_create   创建一个semaphore
  • dispatch_semaphore_wait    等待信号
  • dispatch_semaphore_signal   发送一个信号

简单的介绍一下这三个函数:

dispatch_samaphore_t dispatch_semaphore_create(long value);
这个函数有一个长整形的参数,我们可以理解为信号的总量;

long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout);
这个函数第一个参数为信号量,第二个为等待的时间,这个函数的作用是这样的:
如果dsema信号量的值大于0,该函数所处线程就继续执行下面的语句,并且将信号量的值减1;
如果desema的值小于等于0,那么这个函数就阻塞当前线程等待timeout(注意timeout的类型为dispatch_time_t);
如果在等待的期间desema大于0了,则向下执行操作并讲信号量减1.

long dispatch_semaphore_signal(dispatch_semaphore_tdsema)
这个函数会使传入的信号量dsema的值加1;

同时考虑到我们目前的方法都是类方法,我们需要一个实例来记住desema的值,于是我们给CWSqliteModelTool开启一个单例对象来记录desema的值,设置信号总量为1,之后在每一个执行数据库操作的的方法开始前进行等待信号量,如果当前信号量大于0,我们执行操作数据库,并讲信号量减1,当操作完成之后,我们发送信号,使信号量增1,这样使其他在等待的线程能开始执行操作,以执行查询操作为例:

- (instancetype)init
{
    self = [super init];
    if (self) {
        // 设置信号量为1,表示最多同时只有1个线程进行操作
        self.dsema = dispatch_semaphore_create(1);
    }
    return self;
}
// 创建一个单例,来记录信号量的值
static CWSqliteModelTool * instance = nil;
+ (instancetype)shareInstance {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[CWSqliteModelTool alloc] init];
    });
    return instance;
}

// 查询表内所有数据
+ (NSArray *)queryAllModels:(Class)cls uid:(NSString *)uid targetId:(NSString *)targetId {
    // 等待信号量,如果大于0,向下执行操作,否则等待
    dispatch_semaphore_wait([[self shareInstance] dsema], DISPATCH_TIME_FOREVER);
    
    NSString *tableName = [CWModelTool tableName:cls targetId:targetId];
    NSString *sql = [NSString stringWithFormat:@"select * from %@", tableName];
    
    NSArray <NSDictionary *>*results = [CWDatabase querySql:sql uid:uid];
    [CWDatabase closeDB];
    // 发送信号量,使信号量+1
    dispatch_semaphore_signal([[self shareInstance] dsema]);
    
    return [self parseResults:results withClass:cls];
}

我们在其他的方法内写上同样的代码,然后我们使用插入数据与删除数据的方法进行单元测试,测试方案为,开启3条子线程,每条线程分别插入1000个复杂的模型,当数据插入结束的时候,再使用3条子线程删除其中的2900条数据,最后剩下100条数据,然后我们来进行单元测试:

在使用单元测试时,分享一个我们发现的坑~我们发现一条数据都没插入成功或者偶尔插入了一两条数据,我们反复检测我们的代码,理论上都是没问题的,最终我们定位到子线程队列的任务压根没执行,思考之后最终我们得出结论:单元测试是在主线程运行,我们使用异步线程时并不会阻塞主线程的运行,所以这个测试用例顺畅无阻的从第一行执行到了最后一行,而单元测试执行完最后一行之后程序就退出了,程序都退出了我们异步的线程的操作当然没法再执行了~

所以我们不能使用单元测试(或者在单元测试自己开启一个runloop让程序不退出),改在程序内进行测试,贴上我们非常长的测试代码(viewController内):

#pragma mark - for循环未使用autoreleasepool的多线程操作
- (void)testMultiThreadingSqliteMore {
    
    dispatch_queue_t queue1 = dispatch_queue_create("CWDBTest1", DISPATCH_QUEUE_CONCURRENT);
    dispatch_queue_t queue2 = dispatch_queue_create("CWDBTest2", DISPATCH_QUEUE_CONCURRENT);
    dispatch_queue_t queue3 = dispatch_queue_create("CWDBTest3", DISPATCH_QUEUE_CONCURRENT);
    dispatch_queue_t queue4 = dispatch_queue_create("CWDBTest4", DISPATCH_QUEUE_CONCURRENT);
    
    dispatch_group_t group = dispatch_group_create();
    dispatch_group_enter(group);
    dispatch_group_enter(group);
    dispatch_group_enter(group);
    
    dispatch_async(queue1, ^{
        for (int i = 1; i < 1000; i++) {
            Student *stu = [self studentWithId:i];
            BOOL result = [CWSqliteModelTool insertOrUpdateModel:stu uid:@"Chavez" targetId:nil];
            NSLog(@"result : %d   %zd",result,stu.stuId);
        }
        NSLog(@"---------------组1结束");
        dispatch_group_leave(group);
    });
    
    dispatch_async(queue2, ^{
        for (int i = 1000; i < 2000; i++) {
            Student *stu = [self studentWithId:i];
            BOOL result = [CWSqliteModelTool insertOrUpdateModel:stu uid:@"Chavez" targetId:nil];
            NSLog(@"result : %d   %zd",result,stu.stuId);
        }
        NSLog(@"---------------组2结束");
        dispatch_group_leave(group);
    });
    
    dispatch_async(queue3, ^{
        for (int i = 2000; i < 3000; i++) {
            Student *stu = [self studentWithId:i];
            BOOL result = [CWSqliteModelTool insertOrUpdateModel:stu uid:@"Chavez" targetId:nil];
            NSLog(@"result : %d   %zd",result,stu.stuId);
        }
        NSLog(@"---------------组3结束");
        dispatch_group_leave(group);
    });
    
    // 当前面3个队列的任务都完成,则调用此通知
    dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        NSLog(@"----------------------插入结束");
        dispatch_async(queue4, ^{
            for (int i = 1; i < 1000; i++) {
                Student *stu = [self studentWithId:i];
                // 删除数据
                BOOL result = [CWSqliteModelTool deleteModel:stu uid:@"Chavez" targetId:nil];
                NSLog(@"delete result : %d   %zd",result,stu.stuId);
            }
        });
        dispatch_async(queue1, ^{
            for (int i = 2000; i < 3000; i++) {
                Student *stu = [self studentWithId:i];
                // 删除数据
                BOOL result = [CWSqliteModelTool deleteModel:stu uid:@"Chavez" targetId:nil];
                NSLog(@"delete result : %d   %zd",result,stu.stuId);
            }
        });
        
        dispatch_async(queue2, ^{
            // 删除数据
            BOOL result = [CWSqliteModelTool deleteModel:[Student class] columnNames:@[@"stuId",@"stuId"] relations:@[@(CWDBRelationTypeMoreEqual),@(CWDBRelationTypeLess)] values:@[@(1000),@(1900)] isAnd:YES uid:@"Chavez" targetId:nil];
            NSLog(@"delete result : %d  1000-1900",result);
        });
    });
}

#pragma mark - 快速获取一个模型
- (Student *)studentWithId:(int)stuId {
    School *school1 = [[School alloc] init];
    school1.name = @"北京大学";
    school1.schoolId = 2;
    
    School *school = [[School alloc] init];
    school.name = @"清华大学";
    school.schoolId = 1;
    school.school1 = school1;
    
    Student *stu = [[Student alloc] init];
    stu.stuId = stuId;
    stu.name = @"Baidu";
    stu.age = 100;
    stu.height = 190;
    stu.weight = 140;
    stu.dict = @{@"name" : @"chavez"};
    // 字典嵌套模型
    stu.dictM = [@{@"清华大学" : school , @"北京大学" : school1 , @"money" : @(100)} mutableCopy];
    // 数组嵌套字典,字典嵌套模型
    stu.arrayM = [@[@"chavez",@"cw",@"ccww",@{@"清华大学" : school}] mutableCopy];
    // 数组嵌套模型
    stu.array = @[@(1),@(2),@(3),school,school1];
    NSAttributedString *attributedStr = [[NSAttributedString alloc] initWithString:@"attributedStr,attributedStr"];
    stu.attributedString = attributedStr;
    // 模型嵌套模型
    stu.school = school;
    UIImage *image = [UIImage imageNamed:@"001"];
    NSData *data = UIImageJPEGRepresentation(image, 1);
    stu.image = image;
    stu.data = data;
    
    return stu;
}

然后执行,获取测试结果,但是在反复侧测试的时候,我们发现一个问题,如下图:

发现内存最高涨到了恐怖的500M!!程序运行起来52M,瞬间翻了10倍。且结束之后内存还有90多M,这一定是我们程序的问题!!对于有经验的人来说,他们一定能马上定位到问题出现在哪里,甚至他们在写代码的时候就能知道这样写会有问题,而我是没有经验的,我思考了一阵,由于有一点理论的知识,最终定位到可能是for循环创建了大量的临时变量没有被及时释放导致的,然后根据之前有看到过使用@autoreleasepool释放临时变量,苹果官方文档有(Using Autorelease Pool Blocks)说到:当有大量中间临时变量产生时,为了避免内存使用峰值过高,应到使用@autoreleasepool及时释放内存。最终我们修改代码,并进行测试:
测试时发现,内存一直维持在54M左右,效果非常明显,基本上和程序刚启动占用的内存差不多,通过这次经验,我们更加深入的理解,在ARC环境下,如何使用@autoreleasepool来控制程序的内存。然后我们打开数据库检测数据是否是剩下对应的100条数据,测试是没问题的,然后我们再调用我们自己写的查询数据库方法进行查询,查询的结果也是没问题的。

在我们解决了多线程安全的问题之后我们发现,既然可能有存在需要批量插入数据的情况,我们就多增加一个接口来处理批量插入操作,批量插入实际上就是我们替用户进行for循环插入,但是在插入的过程中,我们用事务控制插入操作,并且插入过程比用户少的是每次插入数据都要执行打开和关闭数据库操作以及每次都去检测是否需要更新数据库表。

#pragma mark - 测试批量插入数据
- (void)testGroupInsert {
    NSMutableArray *arr = [NSMutableArray array];
    for (int i = 1; i < 2000; i++) {
        Student *stu = [self studentWithId:i];
        [arr addObject:stu];
    }
    NSLog(@"开始插入数据");
    // 2017-12-23 16:25:46.145023+0800 CWDB[14678:1604328] 开始插入数据
    BOOL result = [CWSqliteModelTool insertOrUpdateModels:arr uid:@"Chavez" targetId:nil];
    NSLog(@"---%zd---插入结束",result);
    // 2017-12-23 16:25:48.466352+0800 CWDB[14678:1604328] ---1---插入结束
    // 使用批量插入的方法 插入2000条数据,总共耗时2.3秒
    
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        NSLog(@"---------------组1开始");
        // 2017-12-23 16:25:48.466587+0800 CWDB[14678:1604407] ---------------组1开始
        for (int i = 2000; i < 4000; i++) {
            @autoreleasepool {
                Student *stu = [self studentWithId:i];
                BOOL result = [CWSqliteModelTool insertOrUpdateModel:stu uid:@"Chavez" targetId:nil];
                NSLog(@"result : %d   %zd",result,stu.stuId);
            }
        }
        NSLog(@"---------------组1结束");
        // 2017-12-23 16:25:56.247631+0800 CWDB[14678:1604407] ---------------组1结束
        // 自行遍历的方式插入2000条数据,总共耗时8秒(且要自行增加autoreleasepool释放临时变量)
    });
    
}

最终我们通过批量插入以及单个插入2000条数据的时间比较,批量插入消耗的时间远低于单个分别插入。

在写完功能之后,我们对项目又进行了一小部分缓存优化,在不考虑动态给模型添加属性的情况下,我们每次去获取模型成员变量以及类型一定是一样的,所以,首先我们在这里使用NSCache来缓存了模型的所有成员变量名以及类型,这样可以不用每次都去解析模型。其次,在判断数据库表结构是否需要更新的时候,我们也做了缓存,在程序运行期间,只要更新过一次,后面都不用去判断更新,因为成员变量不变,表结构一定不会变,这都是对性能方面的一些小优化,在做的时候可以适当的考虑一下。

本篇结束

在此,我们封装的数据库功能已经开发完了(其实自己封装一个数据库也没想象中那么难,你也可以的~)回到第一篇文章所立的军令状:我们封装的简单适用,安全可靠,功能全面,我们说到做到。增删查改所有操作都只需要一行代码。就算你给数组添加一个对象也需要两步:先初始化一个数组,然后再想数组添加对象,而我们向数据库插入一个模型,只需要一步,调用insertOrUpdateModel:即可。

在接下来,我们会将封装的代码进行少量的重构和优化,去掉一些不必要暴露的方法和对应的单元测试,尽量让API简洁明了以及去掉一些重复代码的封装,将注释补全再经过大量的测试场景测试之后,我们将会把我们的CWDB推荐给大家使用,如果你有兴趣了解或者想自己动手封装一个数据库,可以前往本系列文章第一篇开始看一看(文章的连接在本文的开头),每篇文章对应的代码在github的release下都有分别的tag,你可以找到他并且下载下来。。

本篇文章实现的代码地址:github:CWDB ------tag为1.4.0-------

(注意:如果要直接运行,必须在CWDatabase.m开头的位置修改数据库存放的路径,开发调试阶段我写在了我电脑的桌面,不修改会出现路径错误,导致打开数据库失败)

最后觉得有用的同学,希望能给本文点个喜欢,给github点个star以资鼓励,谢谢大家。欢迎大家向我抛issue,有更好的思路也欢迎大家留言。

给大家安利一个0耦合的仿QQ侧滑框架,真正的一行代码实现,多了你抽我😁: 一行代码集成超低耦合的侧滑功能