EXC_BAD_ACCESS 在 enumerateObjectsUsingBlock 中设置通过写回错误

问题描述

以下代码在尝试设置 EXC_BAD_ACCESS 时导致 *error

- (void)triggerEXC_BAD_ACCESS
{
    NSError *error = nil;
    [self doSetErrorInBlock:&error];
}

- (void)doSetErrorInBlock:(NSError * __autoreleasing *)error
{
    [@[@(0)] enumerateObjectsUsingBlock:^(id  _Nonnull obj,NSUInteger idx,BOOL * _Nonnull stop) {
        *error = [NSError errorWithDomain:@"some.domain" code:100 userInfo:nil]; // <--- causes EXC_BAD_ACCESS
    }];
}

但是,我不确定为什么会出现 EXC_BAD_ACCESS

enumerateObjectsUsingBlock: 调用替换为以下函数,该函数试图重现 enumerateObjectsUsingBlock:函数签名,将使函数 triggerEXC_BAD_ACCESS 运行而不会出错:

- (void)doSetErrorInBlock:(NSError * __autoreleasing *)error
{
    [self runABlock:^(id someObject,BOOL *anotherWriteback) {
        *error = [NSError errorWithDomain:@"some.domain" code:100 userInfo:nil]; // <--- No crash here
    }];
}

- (void)runABlock:(void (NS_NOESCAPE ^)(id obj,BOOL *stop))block
{
    BOOL anotherWriteback = NO;
    block(@"Some string",&anotherWriteback);
}

不确定我是否遗漏了 ARC 在这里的工作原理,或者它是否特定于我使用的 Xcode 版本(Xcode 12.2)。

解决方法

我无法在 -doSetErrorInBlock: 中重现崩溃,但我可以在调试节点中使用“-[NSError retain]: message sent to deallocated instance”重现 -triggerEXC_BAD_ACCESS 中的崩溃(我不是确定是由于 NSZombie 还是其他一些调试选项)。

其原因是 *error 中的 -doSetErrorInBlock: 具有类型 NSError * __autoreleasing,以及 -[NSArray enumerateObjectsUsingBlock:] 的实现(它是封闭源代码,但可以检查程序集) 恰好在内部有一个围绕块执行的自动释放池。 __autoreleasing 的对象指针意味着我们不保留它,并且我们假设它是活的,因为它被某个自动释放池保留了。这意味着将某些东西分配给自动释放池中的 __autoreleasing 变量,然后在自动释放池结束后尝试访问它是不好的,因为自动释放池的末尾可能已经释放了它,所以你可以离开带有悬空指针。 ARC 规范的 This section 说:

如果一个非空指针被分配给一个未定义的行为 __autoreleasing 对象,而自动释放池在范围内并且 然后在离开自动释放池的范围后读取该对象。

崩溃消息说它正在尝试保留它的原因是因为当您尝试传递“指向 __strong 的指针”时会发生什么(例如 &error 中的 -triggerEXC_BAD_ACCESS ) 到“指向 __autoreleasing 的指针”类型的参数(例如 -doSetErrorInBlock: 的参数)。正如您从 ARC 规范的 this section 中看到的那样,发生了一个“pass-by-writeback”过程,在该过程中,他们创建了一个 __autoreleasing 类型的临时变量,分配 __strong 的值变量,进行调用,然后将 __autoreleasing 变量的值分配回 __strong 变量,因此您的 triggerEXC_BAD_ACCESS 方法实际上是这样的:

NSError *error = nil;
NSError * __autoreleasing temporary = error;
[self doSetErrorInBlock:&temporary];
error = temporary;

将值重新分配给 __strong 变量的最后一步是执行保留,这就是它遇到已释放实例的时候。

如果我将 -runABlock: 更改为:

,我可以在您的第二个示例中重现相同的崩溃
- (void)runABlock:(void (NS_NOESCAPE ^)(id obj,NSUInteger idx,BOOL *stop))block
{
    BOOL anotherWriteback = NO;
    @autoreleasepool {
        block(@"Some string",&anotherWriteback);
    }
}

您真的不应该在您编写的新方法中使用 __autoreleasing__strong 好得多,因为强引用可确保您不会意外地遇到悬空引用和类似问题。 __autoreleasing 存在的主要原因是因为在手动引用计数时代,没有明确的所有权限定符,并且“约定”是保留计数不会传入或传出方法,因此对象从方法(包括使用 out 参数由指针返回的对象)将被自动释放而不是保留。 (并且这些方法将负责确保在方法返回时对象仍然有效。)并且由于您的程序可以在不同的操作系统版本上使用,因此它们无法更改新操作系统版本中 API 的行为,因此它们被困在这个“指向 __autoreleasing”类型的指针。但是,在您自己用 ARC 编写的方法(它确实具有明确的所有权限定符)中,该方法只能由您自己的 ARC 代码调用,请务必使用 __strong。如果您使用 __strong 编写方法,它不会崩溃(by default 指向对象的指针被解释为 __autoreleasing,因此您必须明确指定 __strong) :

- (void)doSetErrorInBlock:(NSError * __strong *)error
{
    [@[@(0)] enumerateObjectsUsingBlock:^(id  _Nonnull obj,BOOL * _Nonnull stop) {
        *error = [NSError errorWithDomain:@"some.domain" code:100 userInfo:nil];
    }];
}

如果您出于某种原因坚持采用 NSError * __autoreleasing * 类型的参数,并且想要安全地执行与您正在做的相同的事情,则应该为块使用 __strong 变量,并且仅将其分配到最后的 __autoreleasing 中:

- (void)doSetErrorInBlock:(NSError * __autoreleasing *)error
{
    __block NSError *result;
    [@[@(0)] enumerateObjectsUsingBlock:^(id  _Nonnull obj,BOOL * _Nonnull stop) {
        result = [NSError errorWithDomain:@"some.domain" code:100 userInfo:nil];
    }];
    *error = result;
}