先说问题:
项目中使用到了跨平台方案的数据库Realm,踩了一些坑,主要是多线程操作数据库导致Crash的问题。
再说结论:
Realm数据库不允许托管的数据在不同线程传递访问,与常识不同:已查数据,不能异步读
Terminating app due to uncaught exception 'RLMException', reason: 'Realm accessed from incorrect thread.'
截屏2024-07-09 18.51.00.png
解决方案:
方案一:开始以为这个问题不难解决,不让用,深拷贝一个出来用总可以,至于数据库的原托管对象拥有的更新自动通知的能力,先不考虑这个,即使拷贝对象没有此能力,但手动通知总是可以的,但是,RLMObject没有继承NSCopying协议,意味着此路不通。
方案二:既然没有继承NSCopying,自己手动实现一个?查出来的对象不能在异步线程访问,那自己造一个总可以?理论可行,实际上,也可行,但是工作量不小,并且,有关增删改查的操作都要重写一遍,且不说工作量,就是一个不小心写个BUG也是100%的概率。
万不能给自己挖坑。
方案三:本着代码少写一点是一点的想法,考虑了一下,实际上,Realm应该考虑到这个问题,不可能在哪里用要关心下线程问题吧(当然这要求并不过分),但是对于一个只读数据,你不让多线程操作,这怎么说都显得很别扭。于是回头仔细看了接口文档。然后看到了下面两个东西。
Returns a frozen (immutable) snapshot of this object.
The frozen copy is an immutable object which contains the same data as this
object currently contains, but will not update when writes are made to the
containing Realm. Unlike live objects, frozen objects can be accessed from any
thread.
- **warning**: Holding onto a frozen object for an extended period while performing write
transaction on the Realm may result in the Realm file growing to large sizes. See
`Realm.Configuration.maximumNumberOfActiveVersions` for more information.
- **warning**: This method can only be called on a managed object.
*/
- (instancetype)freeze NS_RETURNS_RETAINED;```
大概意思就说可以对数据库托管的对象进行冻结,其完全继承了原对象的所有值,但是脱离了数据库托管,也就可以在异步线程访问了,需要注意的是当写操作的时候持有冻结对象将会使数据库文件变大,还有就是这个冻结操作只能用在被数据库托管的对象上(这点它说的不怎么对,后面讨论,算了懒得讨论了,直接上结论:在被冻结的对象上重复使用冻结操作,返回与其一样的指针,也就是浅拷贝,有兴趣的自己验证)。
这里说一句:所谓的数据库托管,在这里的意思是从数据库中查到的对象,而不是你自己手动alloc的对象。
然后对应的还有个解冻操作:
/*
Returns a live (mutable) reference of this object.
This method creates a managed accessor to a live copy of the same frozen object.
Will return self if called on an already live object.
*/
- (instancetype)thaw;
`
意思就是针对冻结的对象返回一个可用的、活动的(可修改的),一毛一样的、数据库托管的对象。
Realm数据库有个特点,例如,查询出来的数据跟数据库本身还存在某种关联,这就是所谓的“托管”,也就是这种托管的存在使得数据在其他线程更新的时候,本线程持有的托管对象能自动收到通知,这点是不需要手动操作的(但是要手动注册)。另外,凡是托管的对象,都不能拿着这个对象的指针在异步线程去读写,这点要特别注意。
这两个方法有没有感觉像mutablecopy/copy?
那么问题就简单了,实际上只需要看下freeze/thaw 操作返回的新对象和原对象有何区别,就可以下定论了。
结论:
对托管对象执行freeze方法,会返回一个不可变的、没有被托管的(意味着可以多线程读了),与原对象地址不同的新对象,称之为 frozen对象,此对象不可写,否则报错,但可以多线程读,下图可见man1和man2地址不同,man1 是托管对象,man2是man1的freeze对象,地址不同,而且man2可以在异步线程读(不能写)
目前解决了不能异步线程读的问题,下面自然而然的根据这个规则,那就是异步写了,man2是个被冻结的对象,那么令man2 执行一个 thaw解冻操作,那岂不是就能在异步线程改写了嘛?
对于man3 = [man2 thaw], 自然而然的认为,man3 已经是一个托管对象,此时可以进行修改,并同步到数据库,但实际上Realm 不允许这么干。其实man3相当于是man1 冻结、解冻后的产物,虽然man3的内存地址和man1 man2 都不同,但是它内部“托管”的逻辑仍然是和man1相同,追本溯源,**man3也只能在和man1相同的线程修改**。
因此:
冻结对象适合作为形参传递,并且可以异步线程传递
解冻对象适合作为实参来增删改查,前提是确保对其操作时所在的线程与其关联的托管对象查询时的线程相同。
比如:数据库查询取得man1 而后man1冻结产生man2, man2解冻产生man3,虽然man1、2、3分别是三个不同的对象,但是他们内部有关联,man1是数据库查询所得,与数据库本身是被托管的关系,man2,由man1 冻结而来,与数据库无托管关系也不能修改但是 man2 解冻之后的man3此时又是一个被托管的对象,并且,man3 的更新改动必须和man1产生时的线程是同一个,有点绕......至于其他传递链也就是这条链路的延伸了,总之,**记录最初产出时候的线程,所有以它为蓝本产生的副本,如果是冻结副本,只可读,并且可多线程可读,如果是解冻的副本,那么他们的读写必须都要处在蓝本产生的线程之下。** 除非根据副本在数据库中重新捞数据,那就是另外一条关系链路了。
因此,对于从数据库查询出来的数据,如果有需要进行传递的话,如果不确定是否会进入到异步线程处理,最好进行freeze操作(深拷贝的副本,只可读,无数据库托管)。以保证其万一进入某个异步线程造成crash。
而对于解冻之后的数据,用其注册通知、监听改变仍然是有效的,但要保证其与蓝本产生时上下文环境相同(线程)。
实现如下:
#import
NS_ASSUME_NONNULL_BEGIN
@interface SafeRLMObject : RLMObject
@property (nonatomic, assign) BOOL enableRead; //只读
@property (nonatomic, assign) BOOL enableRW; //读写
@property (nonatomic, assign) uint64_t origin;
@end
NS_ASSUME_NONNULL_END
#import "SafeRLMObject.h
typedef NS_ENUM(NSInteger) {
SafeTypeNRNW,
SafeTypeR,
SafeTypeRW
}Savetype;
@interface SafeRLMObject ()
@property (nonatomic, assign)Savetype safeType;
@end
@implementation SafeRLMObject
- (id)init{
self= [super init];
if(self) {
self.origin= (int64_t)[NSThread currentThread];
}
return self;
}
+ (NSArray *)ignoredProperties {
return @[@"origin",@"safeType",@"enableRead",@"enableRW"];
}
- (Savetype)safeType {
if(self.isFrozen) {
return SafeTypeR;
}
uint64_tthread = (uint64_t)[NSThreadcurrentThread];
if(self.origin!= thread) {
return SafeTypeNRNW;
}
return SafeTypeRW;
}
- (BOOL)enableRead {
Savetype type = [self safeType];
if(type ==SafeTypeR|| type ==SafeTypeRW) {
return YES;
}
return NO;
}
- (BOOL)isEnableRW {
Savetypetype = [self safeType];
if(type ==SafeTypeRW) {
return YES;
}
return NO;
}
- (instancetype)freeze {
SafeRLMObject*obj = [super freeze];
obj.origin=self.origin;
return obj;
}
- (instancetype)thaw {
SafeRLMObject*obj = [super freeze];
obj.origin=self.origin;
return obj;
}
@end
以上录了数据库对象在创建时候的所在线程的线程号(线程的64位地址)最开始是想由此对象持有这个线程指针,待到执行写操作的时候,可以使用performselector:onThread 方法 异步到持有的origin线程执行更新操作,可以保证在A线程创建也在A线程更新,能绝对遵循Realm的规则,实现线程安全。但是,后续考虑了一下,为了一个数据库对象的安全更新,强持有一个线程不释放,貌似有些得不偿失,因为有些线程不仅仅只是做一个数据库查询这么简单的操作,一些复杂操作占用内存、占用cpu轮询片,资源开销并不小,为盘醋包顿饺子有些过分,因此,改成记录此线程的整型地址,不再影响此线程的生命周期,因此,实际上以上代码并不能实现数据安全更新(如果对此功能有强需求的可以按以上思路自己添加,验证可行,负收益就是异步线程生命周期增加,开销增加)。但能做到在操作之前判断此对象是否能安全读写,如能更好,不能的话仍然需要从数据库中重新捞取并实现更新。但确实也提供了一套规避crash的方法,至少,在读写RLMObject对象之前,有一个明确的方法可以判断当前数据库/数据库对象是否安全了,加上断言,即可以在开发阶段直接杜绝绝大部分由此原因产生的crash。
最后想说的是,不仅是数据库对象RLMObject是这样的规则,就是RLMRealm数据库本身也仍然是这样的规则,不过,一般情况下,我们更关心的是RLMObject的多线程操作,而RLMRealm则可以通过RLMRealm *realm = [RLMRealm defaultRealm];在异步线程很方便的随时获取,因此不再赘述RLMRealm的处理,有需要可以照猫画虎,一毛一样的。
网友评论