一处static成员变量初始化不当导致的并发问题(Crashes in RCTImageLoader canHandleRequest)

公司的项目使用了 ReactNative 0.50,最近要抓app稳定性,组里开启了清Bug大行动。行动过程中发现了一处RN的Bug,觉得问题还比较典型,下面我们一起来看看。

1 初步探寻

Bugly上报的日志如下:

Bugly日志
1
2
3
4
5
6
7
8
9
libicucore.A.dylib uregex_setUText + 36
Foundation -[NSRegularExpression(NSMatching) enumerateMatchesInString:options:range:usingBlock:] + 480
Foundation -[NSRegularExpression(NSMatching) enumerateMatchesInString:options:range:usingBlock:] + 480
Foundation -[NSRegularExpression(NSMatching) firstMatchInString:options:range:] + 128
MyApp -[RCTImageLoader canHandleRequest:] + 304
MyApp -[RCTNetworking handlerForRequest:] + 396
MyApp -[RCTNetworking networkTaskWithRequest:completionBlock:] + 72
MyApp -[RCTNetworking sendRequest:responseType:incrementalUpdates:responseSender:] + 676
MyApp __44-[RCTNetworking sendRequest:responseSender:]_block_invoke + 220

这里可以看到,Bug发生在ReactNative源码的RCTImageLoadercanHandleRequest方法中。我们看看这个有问题的方法canHandleRequest

出现问题的RCTImageLoader源码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
- (BOOL)canHandleRequest:(NSURLRequest *)request
{
NSURL *requestURL = request.URL;
// If the data being loaded is a video, return NO
// Even better may be to implement this on the RCTImageURLLoader that would try to load it,
// but we'd have to run the logic both in RCTPhotoLibraryImageLoader and
// RCTAssetsLibraryRequestHandler. Once we drop iOS7 though, we'd drop
// RCTAssetsLibraryRequestHandler and can move it there.
static NSRegularExpression *videoRegex = nil;
if (!videoRegex) {
NSError *error = nil;
videoRegex = [NSRegularExpression regularExpressionWithPattern:@"(?:&|^)ext=MOV(?:&|$)"
options:NSRegularExpressionCaseInsensitive
error:&error];
if (error) {
RCTLogError(@"%@", error);
}
}
NSString *query = requestURL.query;
if (query != nil && [videoRegex firstMatchInString:query
options:0
range:NSMakeRange(0, query.length)]) {
return NO;
}
for (id<RCTImageURLLoader> loader in _loaders) {
// Don't use RCTImageURLLoader protocol for modules that already conform to
// RCTURLRequestHandler as it's inefficient to decode an image and then
// convert it back into data
if (![loader conformsToProtocol:@protocol(RCTURLRequestHandler)] &&
[loader canLoadImageURL:requestURL]) {
return YES;
}
}
return NO;
}

从注释中我们了解到这个方法主要是对request进行过滤,使用了一个正则videoRegex去对传入的request进行匹配。根据(?:&|^)ext=MOV(?:&|$)规则,去判断如果是视频文件就返回NO,从而跳过处理(因为这个是ImageLoader嘛就不处理视频)。

2 分析

我们先看看出问题的一句,我们从Bugly日志中问题发生的下个入口(通常问题发生在从MyApp转到Native处)初步认定问题发生在canHandleRequest的第23行firstMatchInString处。

1
2
3
4
5
6
7
NSString *query = requestURL.query;
if (query != nil && [videoRegex firstMatchInString:query
options:0
range:NSMakeRange(0, query.length)]) {
return NO;
}

对于这个firstMatchInString我们从出错堆栈中看出这个方法里面使用enumerateMatchesInString:options:range:usingBlock:去遍历字符串,在代码示例中的if语句里,query这个字符串不可能为空,因为之前使用query != nil判断过了。query的类型为NSString,是不可变字符串,代入此方法应该也不会出问题。那么我们问题有可能出自videoRegex

2.1 发现问题

videoRegex是一个静态成员变量,我们知道,oc的静态成员变量只会生成一份内存,这里RN之所以使用静态成员变量是因为这个正则的匹配器每次都是一样的,所以只需要初始化一次,后面每次都使用同一个匹配器就好了。但是,这里隐藏了一个常见的并发问题。

我们在学习OC的单例模式时常学习这个,使用静态成员变量时常出现这样的并发问题,我们细看这里的源码:

1
2
3
4
static NSRegularExpression *videoRegex = nil;
if (!videoRegex) {
videoRegex = initMethod;
}

当多线程访问此方法时,线程A跑到 if (!videoRegex) {这一句时,判断videoRegex为空,就进入方法块对它进行初始化。如果这时刚好发生线程切换,此时线程B也跑到这个if判断来,因为videoRegex还没初始化完成,所以其仍然是空,因此线程B也开始进入方法块,对之进行初始化。

这样问题就发生了 – 这个静态变量初始化了2次!

这会导致什么问题呢?如果线程A初始化完之后,进入下面的正则匹配,从firstMatchInString跑到enumerateMatchesInString:options:range:usingBlock:去遍历匹配字符串。但是这期间线程B将匹配器(videoRegex)重新初始化了!我们不希望发生的事情(crash)就这样发生了!

2.2 构造场景复现问题

当我们发现疑似问题时,特别是这种难以复现的问题,有一个重要的步骤就是构造场景复现问题。因为我们的猜测有可能是错的,如果猜错了盲目进行修复,很可能上线了发现问题并没有真正修复,而且就算修复了我们心里也不会太有底,不知道问题是不是正的就修复了。

因为我们怀疑可能是并发问题,于是就构造多个线程并发访问, 从而构造了一个复现场景,如下:

构造场景复现问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
- (void)testCode
{
for (int i = 0; i <= 1000; i++) { // 构造1000个线程并发访问有问题的代码
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
if (i % 4 == 0) { // 这里构造了几查询字符串用以判断Crash和查询字符串有没有关系
[self bugCodeWithInput:nil];
} else if ( i % 4 == 1) {
[self bugCodeWithInput:@"https://xx/xxx.MOV"];
} else if ( i % 4 == 2) {
[self bugCodeWithInput:@""];
} else {
[self bugCodeWithInput:@"https://xx/xx.png"];
}
});
}
}
- (void)bugCodeWithInput:(NSString *)query
{
// 怀疑有问题的代码
static NSRegularExpression *videoRegex = nil;
if (!videoRegex) {
NSError *error = nil;
videoRegex = [NSRegularExpression regularExpressionWithPattern:@"(?:&|^)ext=MOV(?:&|$)"
options:NSRegularExpressionCaseInsensitive
error:&error];
}
if (query != nil && [videoRegex firstMatchInString:query
options:0
range:NSMakeRange(0, query.length)]) {
}
}

运行testCode,发现果不其然,crash发生了!看来问题就是和我们分析的一样。下面我们来尝试解决之。

2.3 解决问题

解决这类问题其实很简单,就像常在单例模式里做的一样,使用dispatch_once_tblock就可以解决(因为dispatch_once会根据传入的token只执行一次,从而避免了静态变量的多次初始化),我们将初始化正则匹配器的方法改成如下:

解决初始化静态变量的并发问题
1
2
3
4
5
6
7
8
9
10
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSError *error = nil;
videoRegex = [NSRegularExpression regularExpressionWithPattern:@"(?:&|^)ext=MOV(?:&|$)"
options:NSRegularExpressionCaseInsensitive
error:&error];
if (error) {
RCTLogError(@"%@", error);
}
});

我们修改上面testCode中怀疑有问题的代码这一段替换成上面解决的代码,再次运行我们构造的复现场景,发现Crash消失了!自此,videoRegex就只会初始化一次,问题完美解决!

3 小结

这其实不是一个很难的Crash,本文旨在提供一个解决问题的常见思路,特别是遇到这种难以复现的问题,构造问题的复现场景是一个非常好的办法,它可以帮我们确定我们的判断是否正解、解决方法是否正确。

还有一个就是平时多看代码多积累,比如这里的静态变量问题,看多了很容易联想到single模式中的用法,从而发现这里存在常见的并发问题。

坚持原创技术分享,您的支持将鼓励我继续创作!