了解 objc 中块内存管理的一种边缘情况

Understand one edge case of block memory management in objc

下面的代码会因为EXC_BAD_ACCESS

而崩溃
typedef void(^myBlock)(void);

- (void)viewDidLoad {
    [super viewDidLoad];
    NSArray *tmp = [self getBlockArray];
    myBlock block = tmp[0];
    block();
}

- (id)getBlockArray {
    int val = 10;
//crash version
    return [[NSArray alloc] initWithObjects:
            ^{NSLog(@"blk0:%d", val);},
            ^{NSLog(@"blk1:%d", val);}, nil];
//won't crash version
//    return @[^{NSLog(@"block0: %d", val);}, ^{NSLog(@"block1: %d", val);}];
}

代码在启用 ARC 的情况下在 iOS 9 中运行。我试图找出导致崩溃的原因。

通过 po tmp 我在 lldb 中找到

(lldb) po tmp
<__NSArrayI 0x7fa0f1546330>(
<__NSMallocBlock__: 0x7fa0f15a0fd0>,
<__NSStackBlock__: 0x7fff524e2b60>
)

而在不会崩溃的版本中

(lldb) po tmp
<__NSArrayI 0x7f9db481e6a0>(
<__NSMallocBlock__: 0x7f9db27e09a0>,
<__NSMallocBlock__: 0x7f9db2718f50>
)

所以我能想到的最可能的原因是当 ARC 释放 NSStackBlock 时崩溃发生了。但为什么会这样呢?

简答:

您发现了一个编译器错误,可能是重新引入的错误,您应该在 http://bugreport.apple.com 上报告它。

更长的答案

这并不总是一个错误,它曾经是一个 功能 ;-) 当 Apple 首次引入块时,他们还引入了 优化 他们是如何实施的;然而,与本质上对代码透明的普通编译器优化不同,它们要求程序员在不同的地方分散调用特殊函数 block_copy() 以使优化工作。

多年来,Apple 不再需要这样做,但仅限于使用 ARC 的程序员(尽管他们也可以为 MRC 用户这样做),而今天的优化应该就是这样,程序员不再需要帮助编译器。

但是您刚刚发现了编译器出错的情况。

从技术上讲,您遇到了 类型丢失 的情况,在这种情况下,已知的块被作为 id 传递 - 减少了已知的类型信息,并且特别是涉及可变参数列表中第二个或后续参数的类型丢失。当你用 po tmp 查看你的数组时,你会看到第一个值是正确的,尽管存在类型丢失,编译器还是正确地得到了那个值,但它在下一个参数上失败了。

数组的文字语法不依赖可变参数函数,生成的代码是正确的。然而 initWithObjects: 确实如此,但它出错了。

解决方法

如果您将 id 的强制转换添加到第二个(和任何后续)块,那么编译器会生成正确的代码:

return [[NSArray alloc] initWithObjects:
        ^{NSLog(@"blk0:%d", val);},
        (id)^{NSLog(@"blk1:%d", val);},
        nil];

这似乎足以唤醒编译器。

HTH

首先,您需要了解,如果您想要存储超出其声明范围的块,则需要复制它并存储副本。

这是因为进行了优化,其中捕获变量的块最初位于堆栈上,而不是像常规对象那样动态分配。 (让我们暂时忽略不捕获变量的块,因为它们可以作为全局实例实现。)因此,当您编写块文字时,如 foo = ^{ ...};,这实际上就像分配给 foo指向在同一范围内声明的隐藏局部变量的指针,例如 some_block_object_t hiddenVariable; foo = &hiddenVariable; 这种优化减少了在许多情况下对象分配的数量,其中块被同步使用并且永远不会超过创建它的范围。

就像指向局部变量的指针一样,如果将指针移到它指向的对象的范围之外,就会有一个悬空指针,取消引用它会导致未定义的行为。如有必要,在块上执行复制会将堆栈移动到堆,在那里它像所有其他 Objective-C 对象一样由内存管理,并且 returns 指向堆复制的指针(如果块已经堆块或全局块,它只是 returns 相同的指针)。

特定编译器是否在特定情况下使用此优化是一个实现细节,但您不能对它的实现方式做出任何假设,因此如果您将块指针存储在一个会过时的地方,则必须始终进行复制当前范围(例如在实例或全局变量中,或在可能比范围长的数据结构中)。即使您知道它是如何实现的,并且知道在特定情况下不需要复制(例如,它是一个不捕获变量的块,或者复制必须已经完成),您也不应该依赖它,并且当你将它存储在一个比当前范围更有效的地方时,你仍然应该总是复制,这是一个很好的做法。

将块作为参数传递给函数或方法有些复杂。如果将块指针作为参数传递给声明的编译时类型为块指针类型的函数参数,那么如果该函数超出其范围,则该函数将反过来负责复制它。所以在这种情况下,你不需要担心复制它,不需要知道函数做了什么。

另一方面,如果您将块指针作为参数传递给声明的编译时类型为非块对象指针类型的函数参数,则该函数将不承担任何责任块复制,因为它只知道它只是一个常规对象,如果存储在超出当前范围的地方,则只需要保留它。在这种情况下,如果您认为该函数可能会在调用结束后存储值,您应该在传递之前复制该块,并传递副本。

顺便说一下,对于将块指针类型分配或转换为常规对象指针类型的任何其他情况也是如此;应该复制块并分配副本,因为任何获得常规对象指针值的人都不会做任何块复制注意事项。


ARC 使情况有些复杂。 ARC 规范 specifies 一些块被隐式复制的情况。例如,当存储到编译时块指针类型的变量(或 ARC 需要保留编译时块指针类型的值的任何其他地方)时,ARC 要求复制而不是保留传入值,因此程序员不必担心在这些情况下显式复制块。

With the exception of retains done as part of initializing a __strong parameter variable or reading a __weak variable, whenever these semantics call for retaining a value of block-pointer type, it has the effect of a Block_copy.

但是,作为一个例外,ARC 规范不保证仅作为参数传递的块被复制。

The optimizer may remove such copies when it sees that the result is used only as an argument to a call.

因此是否显式复制作为参数传递给函数的块仍然是程序员必须考虑的事情。

现在,Apple 的 Clang 编译器最新版本中的 ARC 实现有一个未记录的功能,它将隐式块副本添加到块作为参数传递的某些地方,即使 ARC 规范不需要它. ("undocumented" 因为我找不到任何关于此效果的 Clang 文档。)特别是,在将块指针类型的表达式传递给非块对象指针类型的参数时,它似乎总是防御性地添加隐式副本。事实上,正如 CRD 所证明的那样,当从块指针类型转换为常规对象指针类型时,它还会添加一个隐式副本,因此这是更普遍的行为(因为它包括参数传递情况)。

但是,当前版本的 Clang 编译器似乎在将块指针类型的值作为可变参数传递时不添加隐式副本。 C 可变参数不是类型安全的,调用者不可能知道函数期望的类型。可以说,如果 Apple 想在安全方面犯错误,因为无法知道函数期望什么,他们也应该在这种情况下始终添加隐式副本。但是,由于这整个事情无论如何都是一个未记录的功能,所以我不会说这是一个错误。在我看来,程序员永远不应该依赖仅作为参数传递的块首先被隐式复制。