了解 objc 中块内存管理的一种极端情况

Posted

技术标签:

【中文标题】了解 objc 中块内存管理的一种极端情况【英文标题】:Understand one edge case of block memory management in objc 【发布时间】:2016-06-19 18:11:37 【问题描述】:

下面的代码会因为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 时发生崩溃。但为什么会这样呢?

【问题讨论】:

【参考方案1】:

首先,您需要了解,如果您想将块存储在其声明的范围之外,则需要复制它并改为存储副本。

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

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

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

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

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

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


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

除了作为初始化的一部分完成的保留 __strong 参数变量或读取 __weak 变量,无论何时 这些语义要求保留块指针类型的值,它 具有Block_copy 的效果。

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

当优化器看到结果是 仅用作调用的参数。

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

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

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

【讨论】:

感谢您的详细解答。我相信 C 可变参数不是类型安全的,这是对这种情况的更准确的解释。 @dopcn - newacct 和我倾向于不同意规范对块和 ARC 的规定。不幸的是,Apple 的文档并不总是那么清晰和全面,而且公平地说不仅仅是 Apple 的,因此涉及到一定程度的解释。请务必将您的案例作为错误提交给 Apple;他们可能会修复它,说它按预期工作,或者什么也不说;但你会提醒他们。如果他们确实提供了有用的回复,您可以将其添加到您上面的问题中,作为帮助他人的附录。【参考方案2】:

简答

您发现了一个编译器错误,可能是重新引入的错误,您应该在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

【讨论】:

解决方法已验证。感谢您的回答。但我想了解更多关于崩溃原因的信息。如果没有类型转换,NSStackBlock 也可以在调用时运行并运行。为什么释放它会导致崩溃?还是不是其他原因导致崩溃? 一个NSStackBlock 不是一个普通的对象——它是上面提到的优化的结果——它不应该被存储在一个数组(或任何其他对象)中。它的存在只是为了作为参数传递给方法,并且仅在调用者(创建它以传递给另一个方法的方法)在调用堆栈上仍然处于活动状态时才起作用。违反这些规则中的任何一条,所有的赌注都会被取消,编译器会无益地为您违反它们。

以上是关于了解 objc 中块内存管理的一种极端情况的主要内容,如果未能解决你的问题,请参考以下文章

ios的内存管理

iOS内存管理-- iOS中strong,copy,retain,weak,assign的用法

了解自己主动内存管理

轻量级操作系统FreeRTOS的内存管理机制

Netty源码分析(七) PoolChunk

轻量级操作系统FreeRTOS的内存管理机制