从一个crash理解weak的延迟释放

crash

最近在项目内部碰到一个野指针crash, 说起来这个crash也很简单, 就是一个在声明一个对象的时候用的是assign, 然后对象释放了还继续调用改对象的方法就crash了. 先来看一个最简单的野指针示意图
野指针1
A对C对像是强持有, B对C对象的申明是assign, 当A释放了C以后, B在调用C的xxx方法就会野指针crash. 对于开发者来说, 很少会把对象申明成assign, 所以这种写法引发的crash是比较好排查的, 下面看另一种示意图.
野指针2
这里B对D是弱引用, C对D是assign引用, 笔者这里示意图里想表明的是C其实是通过B的某些调用才触发对assign对象D的方法调用, 同样的原因唯一对D强引用的A如果在这个过程中释放了D就会造成crash, 说到这里可能会问, 什么场景需要这么费劲的调用, 看上去B也可以直接对D进行调用而且weak不会造成野指针不是吗. 还原一下当时的crash场景.
野指针3
简单说就是A对象通过一个manager(或者viewModel之类的),发出一个网络请求, 图2中的B在这个场景中其实是一个网络库, manager是网络请求的delegate, 负责网络请求之后的回调, 这个网络库在设计的时候为了扩展性考虑在触发回调的时候使用了一个NSInvocation. 关键代码:

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
@interface SSHttpOperation()
...
//网络回调的target
@property(nonatomic, weak)id target;
//外部在发网络请求的时候可以随意设置回调方法, 达到扩展性
@property(nonatomic, assign)SEL didFinishSelector;
...
@end

@implementation SSHttpOperation
- (void)notifyWithResult:(NSDictionary*)result error:(NSError*)error continueWhenCancel:(BOOL)continueWhenCancel {
//网络请求回来, 进行外部业务回调
...
if(target && [target respondsToSelector:didFinishSelector]) {
NSMethodSignature *signature = [target methodSignatureForSelector:didFinishSelector];
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
[invocation setTarget:target]; // 1
[invocation setSelector:didFinishSelector];
id wself = self;
[invocation setArgument:&wself atIndex:2];
[invocation setArgument:&result atIndex:3];
[invocation setArgument:&logicError atIndex:4];
[invocation setArgument:&userInfo atIndex:5];
[invocation invoke];

}
...
}
@end

网络库Operation的关键代码其实就是这个, 代码1的位置[invocation setTarget:target], 点开NSInvocation.h的头文件可以看到NSInvocationtarget的申明是assign…
再简单看一下外部是怎么操作的引起crash:

1
2
3
4
5
6
7
Object A

[self.pgcActionManager changePGCAccount:mediaID likeStatus:!hasSubscribed extraTrack:extraTrack likeBlock:^(BOOL isLiked, NSError *error, NSArray<TTVUserAccount *> *recommendUsers) {
// 一堆业务操作 再block的最后把这个manager置为nil
...
self.pgcActionManager = nil; // 2
}];

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Manager


- (void)changePGCAccount:(NSString *)mediaID likeStatus:(BOOL)changeToLike likeBlock:(SSPGCActionManagerLikeBlock)likeBlock{
...
self.likeOperation = [SSHttpOperation httpOperationWithURLString:url getParameter:nil postParameter:parameterDict userInfo:userInfo];
[_likeOperation setQueuePriority:NSOperationQueuePriorityHigh];
[_likeOperation setFinishTarget:self selector:@selector(operation:finishedResult:error:userInfo:)];
[SSOperationManager addOperation:_likeOperation];
}

#pragma mark - 网络回调
- (void)operation:(SSHttpOperation*)operation finishedResult:(NSDictionary*)result error:(NSError*)error userInfo:(id)userInfo{
// 业务逻辑代码 xxx
//3
if (_likeBlock) {
_likeBlock(isFollow, followError, recommendUsersArray);
}
// 后续的业务逻辑代码
// 4
[TTVLoginManager showLoginForFollowWithResult:@"你的关注太多,登录保存一下吧" extraTrackDic:@{@"isOver":@(1)} position:self.position];
}

上面摘抄了一些关键代码, 调用顺序大概是:
Object A业务逻辑触发了[Manager changePGCAccount…]方法
-> Manager通过网络库发请求并通过NSInvocation触发回调(代码1)
-> 回调方法执行likeBlock(代码3)
-> block内Object A释放Manager(代码2), Manager dealloc方法也在此时执行
-> likeBlock执行完后由于业务需求调用了self.position(代码4), 崩擦擦.

weak

至此我们找到了crash的原因, 修改的方法有很多, 上面我们有说到Operation对Manager其实是弱引用的, 只是为了扩展性?(大概)才通过NSInvocation调用本该是自己的delagte回调, 其实如果牺牲一点扩展性在现在代码的使用NSInvocation的位置(代码1)替换成[self.target xxxx]就不会出问题了, 事实上现在我们大部分的代理在使用过程中也是这个思路.
事实证明,直接调确实是没有问题的, 并且有一个有意思的现象, 通过这种调用在(代码2)self.pgcActionManager = nil;的时候, 这个Manager没有立即走dealloc, 也就是并没有立即释放, 换句话说, 还有别人在持有这个Manager? 一个常识是weak修饰的对象会在对象释放之后置为nil, 但是这和被修饰对象不立即释放并没有什么关系.
于是想到了《Objective-C高级编程 iOS与OS X多线程和内存管理》一书里提到过的,weak会将被修饰对象加入autoreleasePool达到延迟释放的效果, 笔者在读这一段的时候做过诸多实验, 最后的结论是对这个说法表示怀疑, 但是现在看确实是有延迟释放的效果. 直接看答案吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
id __weak obj1 = obj;
NSLog(@"%@", obj1);
}
/* iOS5及之前编译器做法 */
id obj1;
objc_initWeak(&obj1, obj);
id tmp = objc_loadWeakRetained(&obj1);
objc_autorelease(tmp);//错误!!!mistake
NSLog(@"%@", tmp);
objc_destroyWeak(&obj1);

/* 现在的编译器做法*/
id obj = objc_msgSend(NSObject, "new");
id obj1;
objc_initWeak(&obj1, obj);
id tmp = objc_loadWeakRetained(obj1);//objc_loadWeakRetained would increment the reference count to ensure that tmp is alive in the NSLog statement.
NSLog(@"%@", obj1);
objc_release(tmp);
objc_destroyWeak(&obj1);
objc_storeStrong(&obj, 0);//release

现在的编译器在我们使用weak对象的时候会帮我们插入objc_loadWeakRetained导致weak对象引用计数+1, 达到在使用weak对象的时候改对象永远不可能被释放, 在我们的case中就是[self.target xxxx]这个xxx方法中即使将有代码将target释放了, 表面上看引用计数好像为0了, 应该释放, 事实上只有在这个方法走完了引用计数才有可能清0. 以前知道这个实现, 但是没注意这个特性, 现在看只想说奈斯 兄dei.

最后

最后总结一下这次改bug的收获:

  1. NSInvocation是有可能引起野指针的, 使用的时候要多加小心, 尤其是在基础库中的使用.当然就正常使用规范来说文中的那个likeBlock应该放在方法末尾执行(这样也不会crash), 只是业务需要在block执行后继续执行一些操作.
  2. weak会通过objc_loadWeakRetained函数被修饰对象引用计数+1, 保证对象在正在使用的过程中不被释放, 这也是和assign非常不一样一个点.
  3. 一些代码设计上的收获, 例如在设计基础库的时候对扩展性和稳定性的取舍, 不展开了.

demo

最后的最后

撰写本文的过程中尝试过用clang命令rewrite源码, 期间由于没有运行时环境支持开始一直报错, 现在附上一个iOS系统下的clang命令, 供有兴趣的读者参考:
xcrun -sdk iphonesimulator clang -rewrite-objc -fobjc-arc -stdlib=libc++ -mios-version-min=12.1 -fobjc-runtime=ios-12.1 -Wno-deprecated-declarations ViewController.m

参考:

Objective-C高级编程
Why __weak object will be added to autorelease pool?

一个小广告

字节跳动西瓜视频团队真诚的邀请各路小伙伴的加入, 一起做有挑战, 有意义的事情, 欢迎大家投简历至gaobifan@bytedance.com