一处 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模式中的用法,从而发现这里存在常见的并发问题。