一道题考你对__autoreleasing和__block的理解

考虑下面的代码,有哪些问题,如何把他改成正确的形式?

@interface TestObj : NSObject
@end
@implementation TestObj

- (void)methodWillSetError:(NSError **)error group:(dispatch_group_t)group {
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        *error = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
        dispatch_group_leave(group);

    });
}
@end
void testBlockAndAutoReleasePool() {
    NSLog(@"Hello, World!");
    NSError *error;
    TestObj *testObj = [TestObj new];
    dispatch_group_t group = dispatch_group_create();
    dispatch_group_enter(group);
    [testObj methodWillSetError:&error group:group];
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
        NSLog(@"error is %@", error);
    });
}
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        testBlockAndAutoReleasePool();
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop run];
    }
    return 0;
}

methodWillSetError会去异步设置error的值,然后另外一个地方在error设置后去访问error的值。

实际上现在新版的Xcode已经会对
*error = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
进行警告
Block captures an autoreleasing out-parameter, which may result in use-after-free bugs
那么这个警告是什么意思呢?
实际上这个方法
- (void)methodWillSetError:(NSError * *)error group:(dispatch_group_t)group
的error,虽然没有明确指定内存的修饰符(strong, weak, autoreleasing,但是如果你直接定义NSError **error的临时变量,在arc下xcode会编译失败,要求你明确指定内存关系)但是编译器会默认转成NSError * __autoreleasing*,而在block中捕获一个__autoreleasing的out-parameter是很容易造成错误的。
为什么这么说呢?

void testAutoReleaseError(NSError **error) {
    [@[@1, @2] enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if (idx == 0) {
            *error = [[NSError alloc] initWithDomain:@"domain" code:1 userInfo:nil];
        }
    }];
    NSLog(@"error:%@" , *error);

}

我们用个简化例子来看一下,这个是很容易随手写下的代码。打开Xcode的Zombie,会发现在
NSLog(@"error:%@" , *error);
那一行crash掉,访问了个已经释放的对象,error已经被dealloc掉了。
为什么呢?前面说了error默认是NSError *__autoreleasing *,也就是说*error指向的对象是个__autoreleasing对象,所以
*error = [[NSError alloc] initWithDomain:@"domain" code:1 userInfo:nil];
的赋值在arc下会加个autorelease的调用变成
*error = [[[NSError alloc] initWithDomain:@"domain" code:1 userInfo:nil] autorelease];

而eumerateXXX这一系列的容器接口,里面的实现是包了一层Autorelease Pool的,所以在block运行完后Autorelease Pool被释放了附带着把*error指向的对象给释放了,*error就指向了个野指针,考虑到block运行时候外层存在是否包裹着一层Autorelease Pool的不确定性,所以clang直接把在block里捕获__autoreleasing的out-parameter给警告了。解决这个问题有两种方案,一种是指定error类型为NSError *__strong *。

把一开始的案例中的__autoreleasing修改成__strong后,会发现error打出来还是空的,这是为什么呢?
因为block捕获变量的时候是捕获变量的当前值,你对变量之后的重新赋值对block里的变量不会有影响。而在block里面也不能对变量做修改(block里的error实际上是个拷贝了当前block定义时候error的值的和block绑定的同名变量)
实际上除了error打出来是空的问题,这里还有个严重的可能会导致各种异常情况的bug,普通debug可能看不出来,但是打开Xcode的Address Sanitizer 的 Detect use of stack after return,就会发现在
*error = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
赋值这里会提示说Use of stack memory after return,因为在

void testBlockAndAutoReleasePool() {
    NSLog(@"Hello, World!");
    NSError *error;
    TestObj *testObj = [TestObj new];
    dispatch_group_t group = dispatch_group_create();
    dispatch_group_enter(group);
    [testObj methodWillSetError:&error group:group];
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
        NSLog(@"error is %@", error);
    });
}

这一层的error是个栈变量,对其取地址得到是栈上的空间,等到dispatch_after去设置error的值的时候,栈空间由于函数已经返回了已经被销毁了,这里对error的写入会导致栈的破坏(可能某个栈变量的值就被覆盖了),导致各种奇怪crash你还无从定位。

这里就需要__block,__block的修饰可以将变量从栈空间的作用域提升到堆上。这里还有个小知识点,如果你直接加__block会发现还是在同样的地方会报出Use of stack memory after return,因为虽然__block可以将变量从栈空间的作用域提升到堆上,但它这个时机是在block被copy的时候才发生的,在我们的代码里,是先调用了methodWillSetError再调用dispatch_async,在methodWillSetError对error取地址的时候变量还在栈上,所以需要将async和methodWillSetError交换一下顺序才能保证代码正常运行。

回过头来再来看下,__autoreleasing到底是什么,为什么clang要把__autoreleasing作为默认选项,它和__strong的区别是什么?起初我们定义NSError *error的时候这里arc下不是默认是NSError * __strong error吗,把它的地址传递给个NSError * __autoreleasing *会发生什么?
https://clang.llvm.org/docs/AutomaticReferenceCounting.html
虽然clang的这篇文章是最权威也最全的文档,但是里面介绍还是很绕口的。所以这里就再说明一下。

把一个NSError *__strong*传递给一个接收NSError *__autoreleasing*参数的方法的时候,clang采用了一个pass-by-writeback的策略。
具体说来就是,在这一步骤
[testObj methodWillSetError:&error group:group];
clang 会改写成

NSError *__autoreleasing temp_error = error;
[testObj methodWillSetError:&temp_error group:group];
error = temp_error;

所以即使error的作用域已经被提升到了堆空间,但是如果error的修饰符是`NSError *__autoreleasing*,就会被转成一个在栈上的临时变量,传递到方法里异步去设置error的时候还是会造成栈的地址破坏。
那么为什么默认是__autoreleasing呢?在大部分的代码中其实往往就是一个局部变量(默认__strong类型),传递给一个out-parameter变量,这样就要经历这个__autoreleasing的转来转去的过程。
其实没啥特殊的原因,主要就是惯例,就和alloc,copy,mutableCopy和new家族的方法默认返回的是Retained return values,而其他函数通常返回的就是个Unretained return values一样。在Cocoa编程中,out-parameter返回的就是个autoreleasing的对象。(所以如果你在mrc下写一个传出out-parameter的方法,要确保这个out-parameter在离开这个方法的时候是个autoreleasing的状态,如果是个+1所有权的对象,那么就会有内存泄漏风险)。对比如果要把所有这种out-parameter的方法的参数加上个__autoreleasing的修饰,还不如直接所有的out-parameter默认就是__autoreleasing。所以这个只是一个最不坏的方案。

最后,给读者出个小问题,前面说了解决Block captures an autoreleasing out-parameter有两个办法,在我们的方法中,由于是要去异步设置error的值,所以语义上就应该是个__strong的修饰符,这是方法一,那么如果只是同步方法,又想要在block里设置这个out-parameter,应该要怎么做呢,这个就留给读者思考了。

posted on 2019-10-11 14:07  天天波波  阅读(875)  评论(0编辑  收藏  举报

导航