下面的代码将在NSKVOUnionSetAndNotify里面崩溃,并使用伪造的字典调用CFDictionaryGetValue

这似乎是混乱的addFoos / NSKVOUnionSetAndNotify代码与添加和删除KVO观察者的行为之间的竞赛。

#import <Foundation/Foundation.h>
@interface TestObject : NSObject
@property (readonly) NSSet *foos;
@end

@implementation TestObject {
    NSMutableSet *_internalFoos;
    dispatch_queue_t queue;
    BOOL observed;
}

- (id)init {
    self = [super init];
    _internalFoos = [NSMutableSet set];
    queue = dispatch_queue_create("test", DISPATCH_QUEUE_CONCURRENT);
    return self;
}

- (void)start {
    // Start a bunch of work hitting the unordered collection mutator
    for (int i = 0; i < 10; i++) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            while (YES) {
                @autoreleasepool {
                    [self addFoos:[NSSet setWithObject:@(rand() % 100)]];
                }
            }
        });
    }

    // Start work that will constantly observe and unobserve the unordered collection
    [self observe];
}

- (void)observe {
    dispatch_async(dispatch_get_main_queue(), ^{
        observed = YES;
        [self addObserver:self forKeyPath:@"foos" options:0 context:NULL];
    });
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    dispatch_async(dispatch_get_main_queue(), ^{
        if (observed) {
            observed = NO;
            [self removeObserver:self forKeyPath:@"foos"];
            [self observe];
        }
    });
}

// Public unordered collection property
- (NSSet *)foos {
    __block NSSet *result;
    dispatch_sync(queue, ^{
        result = [_internalFoos copy];
    });
    return result;
}

// KVO compliant mutators for unordered collection
- (void)addFoos:(NSSet *)objects {
    dispatch_barrier_sync(queue, ^{
        [_internalFoos unionSet:objects];
    });
}

- (void)removeFoos:(NSSet *)objects {
    dispatch_barrier_sync(queue, ^{
        [_internalFoos minusSet:objects];
    });
}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        TestObject *t = [[TestObject alloc] init];
        [t start];
        CFRunLoopRunInMode(kCFRunLoopDefaultMode, 10000, false);
    }
    return 0;
}

最佳答案

当访问键值观察字典时,您得到的实际崩溃是EXC_BAD_ACCESS。堆栈跟踪如下:

* thread #2: tid = 0x1ade39, 0x00007fff92f8e097 libobjc.A.dylib`objc_msgSend + 23, queue = 'com.apple.root.default-priority', stop reason = EXC_BAD_ACCESS (code=1, address=0x18)
    frame #0: 0x00007fff92f8e097 libobjc.A.dylib`objc_msgSend + 23
    frame #1: 0x00007fff8ffe2b11 CoreFoundation`CFDictionaryGetValue + 145
    frame #2: 0x00007fff8dc55750 Foundation`NSKVOUnionSetAndNotify + 147
  * frame #3: 0x0000000100000f85 TestApp`__19-[TestObject start]_block_invoke(.block_descriptor=<unavailable>) + 165 at main.m:34
    frame #4: 0x000000010001832d libdispatch.dylib`_dispatch_call_block_and_release + 12
    frame #5: 0x0000000100014925 libdispatch.dylib`_dispatch_client_callout + 8
    frame #6: 0x0000000100016c3d libdispatch.dylib`_dispatch_root_queue_drain + 601
    frame #7: 0x00000001000182e6 libdispatch.dylib`_dispatch_worker_thread2 + 52
    frame #8: 0x00007fff9291eef8 libsystem_pthread.dylib`_pthread_wqthread + 314
    frame #9: 0x00007fff92921fb9 libsystem_pthread.dylib`start_wqthread + 13

如果使用符号NSKVOUnionSetAndNotify设置符号断点,则调试器将在调用此方法的位置停止。
您看到的崩溃是因为调用[addFoos:]方法时从一个线程发送了自动键值通知,但随后又从另一个线程访问了更改字典。调用此方法时,使用全局调度队列会激发这种情况,因为这将在许多不同的线程中执行该块。

有多种方法可以修复此崩溃,我将尝试引导您完成此过程,以使您更加全面地了解正在发生的情况。

在最简单的情况下,可以通过对此键使用键值编码可变代理对象来解决崩溃问题:
NSMutableSet *someSet = [self mutableSetValueForKey:@"foos"];
[someSet unionSet:[NSSet setWithObject:@(rand() % 100)]];

这将停止此特定的崩溃。这里发生了什么事?调用mutableSetValueForKey:时,结果是一个代理对象,该代理对象将消息转发给键“foos”的KVC兼容访问器方法。作者的对象实际上并不完全符合此类型的KVC兼容属性所需的模式。如果为此密钥通知了其他KVC访问器方法,则它们可能会通过Foundation提供的非线程安全访问器,从而可能再次导致此崩溃。稍后我们将介绍如何解决该问题。

崩溃是由跨线程的自动KVO更改通知触发的。自动KVO通知通过在运行时刷新类和方法来工作。您可以阅读更深入的说明herehere。 KVC访问器方法在运行时基本上由KVO提供的方法包装。实际上,这就是原始应用程序崩溃的地方。这是从Foundation中反汇编的KVO插入代码:
int _NSKVOUnionSetAndNotify(int arg0, int arg1, int arg2) {
    r4 = object_getIndexedIvars(object_getClass(arg0));
    OSSpinLockLock(_NSKVONotifyingInfoPropertyKeysSpinLock);
    r6 = CFDictionaryGetValue(*(r4 + 0xc), arg1);
    OSSpinLockUnlock(_NSKVONotifyingInfoPropertyKeysSpinLock);
    var_0 = arg2;
    [arg0 willChangeValueForKey:r6 withSetMutation:0x1 usingObjects:STK-1];
    r0 = *r4;
    r0 = class_getInstanceMethod(r0, arg1);
    method_invoke(arg0, r0);
    var_0 = arg2;
    r0 = [arg0 didChangeValueForKey:r6 withSetMutation:0x1 usingObjects:STK-1];
    Pop();
    Pop();
    Pop();
    return r0;
}

如您所见,这是使用willChangeValueForKey:withSetMutation:usingObjects:didChangeValueForKey: withSetMutation:usingObjects:包装了KVC兼容的访问器方法。这些是发送KVO通知的方法。如果对象已选择加入自动键值观察器通知,则KVO将在运行时插入此包装。在这些调用之间,您可以看到class_getInstanceMethod。这是对被包装的KVC兼容访问器的引用,然后对其进行调用。在原始代码的情况下,这是从NSSet的unionSet:内部触发的,该事件在线程之间发生,并在访问更改字典时导致崩溃。

自动通知由发生更改的线程发送,并打算在同一线程上接收。这是Teh IntarWebs,那里有很多关于KVO的错误或误导性信息。并非所有对象和所有属性都会自动发出KVO通知,并且在您的类中,您可以控制执行和不执行。从Key Value Observing Programming Guide: Automatic Change Notification:

NSObject提供了自动键值更改通知的基本实现。自动键值更改通知将使用键值兼容访问器以及键值编码方法进行的更改通知给观察者。由mutableArrayValueForKey返回的收集代理对象也支持自动通知:

这可能会使人们相信,默认情况下,NSObject的所有后代都会发出自动通知。事实并非如此-框架类可能没有,或实现了特殊行为。核心数据就是一个例子。从Core Data Programming Guide:

NSManagedObject禁用对建模属性的自动键值观察(KVO)更改通知,并且原始访问器方法不调用访问和更改通知方法。对于未建模的属性,在OS X v10.4上,Core Data还会禁用自动KVO。在OS X v10.5和更高版本上,Core Data采用了NSObject的行为。

作为开发人员,您可以通过实现具有正确命名约定+automaticallyNotifiesObserversOf<Key>的方法来确保为特定属性打开或关闭自动键值观察器通知。当此方法返回NO时,不会为此属性发出自动键值通知。禁用自动更改通知后,KVO也不必在运行时处理accessor方法,因为这样做主要是为了支持自动更改通知。例如:
+ (BOOL) automaticallyNotifiesObserversOfFoos {
    return NO;
}

作者在评论中说,他将dispatch_barrier_sync用于其访问器方法的原因是,如果不使用willChangeValueForKey:,则KVO通知将在更改发生之前到达。禁用属性的自动通知后,您仍然可以选择手动发送这些通知。这可以通过使用didChangeValueForKey:countOfFoos方法来完成。这不仅使您可以控制何时发送这些通知(如果有的话),还可以控制在什么线程上。您还记得,自动更改通知是在发生更改的线程上发送和接收的。
例如,如果您希望更改通知仅在主队列上发生,则可以使用递归分解:
- (void)addFoos:(NSSet *)objects {
    dispatch_async(dispatch_get_main_queue(), ^{
        [self willChangeValueForKey:@"foos"];
        dispatch_barrier_sync(queue, ^{
            [_internalFoos unionSet:objects];
            dispatch_async(dispatch_get_main_queue(), ^{
                [self didChangeValueForKey:@"foos"];
            });
        });
    });
}

作者问题的原始类是强制KVO观察在主队列上启动和停止,这似乎是试图在主队列上发出通知。上面的示例演示了一个解决方案,该解决方案不仅可以解决该问题,而且可以确保在数据更改之前和之后正确发送KVO通知。

在上面的示例中,我修改了作者的原始方法作为说明性示例-此类仍不正确地针对键“foos”与KVC兼容。要符合键值观察,对象必须首先符合键值编码。为了解决这个问题,首先创建correct Key-value coding compliant accessors for an unordered mutable collection:

不可变:enumeratorOfFoosmemberOfFoos:addFoosObject:
可变的:removeFoosObject:dispatch_barrier_sync
这些只是最低要求,出于性能或数据完整性的原因,可以实施其他方法。

原始应用程序使用并发队列和dispatch_barrier_sync。由于许多原因,这很危险。 Concurrency Programming Guide建议的方法是改为使用串行队列。这样可以确保一次只能接触到受保护资源的一件事,而且是从一致的上下文中进行的。例如,上述两种方法看起来像这样:
- (NSUInteger)countOfFoos {
    __block NSUInteger  result  = 0;
    dispatch_sync([self serialQueue], ^{
        result = [[self internalFoos] count];
    });
    return result;
}

- (void) addFoosObject:(id)object {
    id addedObject = [object copy];
    dispatch_async([self serialQueue], ^{
        [[self internalFoos] addObject:addedObject];
    });
}

请注意,在本示例以及下一个示例中,为了简洁起见,我不包括手动KVO更改通知。如果要发送手动更改通知,则应将该代码添加到这些方法中,就像在上一个示例中看到的那样。

与在并发队列中使用dispatch_sync不同,这不会导致死锁。

WWDC 2011会话210 Mastering Grand Central Dispatch显示了对调度屏障API的正确使用,该API用于使用并发队列为集合实现读取器/写入器锁。可以这样实现:
- (id) memberOfFoos:(id)object {
    __block id  result  = nil;
    dispatch_sync([self concurrentQueue], ^{
        result = [[self internalFoos] member:object];
    });
    return result;
}

- (void) addFoosObject:(id)object {
    id addedObject = [object copy];
    dispatch_barrier_async([self concurrentQueue], ^{
        [[self internalFoos] addObject:addedObject];
    });
}

注意,对于写操作,调度屏障是异步访问的,而读操作使用dispatch_barrier_sync。原始应用程序使用context进行读取和写入操作,作者声明这样做是为了控制何时发送自动更改通知。使用手动更改通知将解决该问题(同样,为简洁起见,此示例中未显示)。

原始的KVO实施仍然存在问题。它不使用self指针来确定观察的所有权。这是推荐的做法,可以使用指向dispatch_sync的指针作为值。该值应与用于添加和删除观察者的对象的地址相同:
[self addObserver:self forKeyPath:@"foos" options:NSKeyValueObservingOptionNew context:(void *)self];

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if (context == (__bridge void *)self){
        // check the key path, etc.
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

从NSKeyValueObserving.h标头中:

您应尽可能使用-removeObserver:forKeyPath:context:而不是-removeObserver:forKeyPath :,因为它允许您更精确地指定意图。当同一观察者多次注册相同的键路径,但每次都使用不同的上下文指针时,-removeObserver:forKeyPath:在确定要删除的对象时必须猜测上下文指针,并且可能会猜错。

如果您对进一步了解如何应用和实施关键价值观察感兴趣,我建议视频KVO Considered Awesome

综上所述:

•实现所需的key-value coding accessor pattern(无序可变集合)

•使这些访问器线程安全(使用带有dispatch_async / dispatch_sync的串行队列,或带有dispatch_barrier_async / automaticallyNotifiesObserversOfFoos的并发队列)

•决定是否要自动KVO通知,相应地实现mutableSetValueForKey:
•将适当的手动更改通知添加到访问器方法中

•确保通过正确的KVC访问器方法(即ojit_code)来访问您的媒体资源的代码

关于ios - 为什么此KVO代码会100%崩溃?,我们在Stack Overflow上找到一个类似的问题:https://stackoverflow.com/questions/25833322/

10-11 18:37