如何等待异步分派的块完成?

184

我正在测试使用 Grand Central Dispatch 进行异步处理的代码。测试代码如下:

[object runSomeLongOperationAndDo:^{    STAssert…}];

测试必须等待操作完成。我的当前解决方案如下:
__block BOOL finished = NO;
[object runSomeLongOperationAndDo:^{
    STAssert…
    finished = YES;
}];
while (!finished);

这看起来有点粗糙,你知道更好的方法吗? 我可以暴露队列并通过调用dispatch_sync进行阻塞:

[object runSomeLongOperationAndDo:^{
    STAssert…
}];
dispatch_sync(object.queue, ^{});

...但这可能在对象方面暴露了太多。

13个回答

318

尝试使用 dispatch_semaphore。它应该类似于这样:

dispatch_semaphore_t sema = dispatch_semaphore_create(0);

[object runSomeLongOperationAndDo:^{
    STAssert…

    dispatch_semaphore_signal(sema);
}];

if (![NSThread isMainThread]) {
    dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
} else {
    while (dispatch_semaphore_wait(sema, DISPATCH_TIME_NOW)) { 
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0]]; 
    }
}

即使 runSomeLongOperationAndDo: 决定操作实际上不够长而不是使用线程同步运行,这段代码也应该可以正确地运行。


62
这段代码对我没有起作用。我的STAssert永远不会执行。我不得不将dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);替换为while (dispatch_semaphore_wait(semaphore, DISPATCH_TIME_NOW)) { [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:10]]; } - nicktmro
41
这可能是因为您的完成块被分派到主队列了?队列由于等待信号量而被阻塞,因此永远不会执行该块。有关在主队列上进行分派而不阻塞的信息,请参见此问题 - zoul
3
我遵循了@Zoul和@nicktmro的建议。但看起来它将进入死锁状态。测试用例“-[BlockTestTest testAsync]”已启动,但从未结束。 - NSCry
3
在ARC下,您需要释放信号量吗? - Peter Warbo
14
这正是我在寻找的。谢谢! @PeterWarbo 不需要这样做dispatch_release()了,使用ARC即可。 - Hulvej
显示剩余13条评论

32

除了其他答案中详细介绍的信号量技术外,我们现在可以在Xcode 6中使用XCTest通过 XCTestExpectation 执行异步测试。这消除了在测试异步代码时需要使用信号量的需求。例如:

- (void)testDataTask
{
    XCTestExpectation *expectation = [self expectationWithDescription:@"asynchronous request"];

    NSURL *url = [NSURL URLWithString:@"http://www.apple.com"];
    NSURLSessionTask *task = [self.session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
        XCTAssertNil(error, @"dataTaskWithURL error %@", error);

        if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
            NSInteger statusCode = [(NSHTTPURLResponse *) response statusCode];
            XCTAssertEqual(statusCode, 200, @"status code was not 200; was %d", statusCode);
        }

        XCTAssert(data, @"data nil");

        // do additional tests on the contents of the `data` object here, if you want

        // when all done, Fulfill the expectation

        [expectation fulfill];
    }];
    [task resume];

    [self waitForExpectationsWithTimeout:10.0 handler:nil];
}
为了给未来的读者留下参考,调度信号量技术在必要时是一种很棒的技术,但我必须承认,我见过太多不熟悉良好异步编程模式的新开发人员过快地将信号量作为一般机制来使异步方法表现得同步。更糟糕的是,我见过许多人从主队列使用这种信号量技术(在生产应用程序中我们绝不能阻塞主队列)。

我知道这里并非如此情况(当这个问题被发布时,还没有像 XCTestExpectation 这样好用的工具;而且,在这些测试套件中,我们必须确保测试在异步调用完成之前不会结束)。这是那些阻塞主线程的信号量技术可能是必要的罕见情况之一。

因此,向原始问题的作者致以歉意,对于他们来说,信号量技术是可行的,但我写下这个警告,提醒所有看到这个信号量技术并考虑将其应用于代码中作为处理异步方法的一般方法的新开发人员:请注意,十有八九,信号量技术不是处理异步操作的最佳方式。相反,请熟悉完成块/闭包模式,以及委托-协议模式和通知。这些往往是处理异步任务的更好方式,而不是使用信号量来使它们表现得同步。通常异步任务被设计成异步执行有其良好的理由,因此请使用正确的异步模式,而不是试图使它们表现得同步。


1
我认为现在应该接受这个答案。这里也有文档:https://developer.apple.com/library/prerelease/ios/documentation/DeveloperTools/Conceptual/testing_with_xcode/testing_3_writing_test_classes/testing_3_writing_test_classes.html#//apple_ref/doc/uid/TP40014132-CH4-SW6 - hris.to
我有一个关于这个的问题。我有一些异步代码,执行大约十几个AFNetworking下载调用来下载单个文档。我想在NSOperationQueue上安排下载。除非我使用类似信号量的东西,否则文档下载NSOperation将立即全部完成,并且不会有任何真正的下载队列 - 它们基本上会并发进行,这是我不想要的。在这里使用信号量合理吗?还是有更好的方法让NSOperations等待其他操作的异步结束?或者其他什么方法? - Benjohn
不,在这种情况下不要使用信号量。如果您有一个操作队列,向其中添加了 AFHTTPRequestOperation 对象,那么您应该创建一个完成操作(其将依赖于其他操作)。或者使用调度组。顺便说一句,你说你不想让它们并发运行,如果这是你需要的,那很好,但是如果按顺序执行而不是并发执行,则会支付严重的性能代价。我通常使用 4 或 5 的 maxConcurrentOperationCount - Rob

29

最近我又遇到了这个问题,并编写了以下关于NSObject的类别:

@implementation NSObject (Testing)

- (void) performSelector: (SEL) selector
    withBlockingCallback: (dispatch_block_t) block
{
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
    [self performSelector:selector withObject:^{
        if (block) block();
        dispatch_semaphore_signal(semaphore);
    }];
    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
    dispatch_release(semaphore);
}

@end

这种方式可以让我在测试中将带有回调函数的异步调用轻松转换为同步调用:

[testedObject performSelector:@selector(longAsyncOpWithCallback:)    withBlockingCallback:^{    STAssert…}];

25

通常不要使用这些答案,它们往往无法扩展(当然也有例外情况)

这些方法与GCD的预期工作方式不兼容,最终会导致死锁和/或通过不断轮询耗尽电池。

换句话说,重新排列代码,使其不再同步等待结果,而是处理状态变化的通知(例如回调/委托协议,可用性,消失,错误等)。 (如果您不喜欢回调地狱,可以将其重构为块。)因为这样才能将真实行为暴露给应用程序的其他部分,而不是隐藏在虚假的外观后面。

相反,使用NSNotificationCenter,为您的类定义一个带有回调的自定义委托协议。如果您不喜欢在各个地方处理委托回调,可以将它们封装到一个具体的代理类中,该类实现自定义协议并将各种块保存在属性中。可能还提供方便的构造函数。

初始工作量稍微多一些,但从长远来看,它将减少可怕的竞态条件和耗电轮询的数量。

别问我要一个例子,因为它太简单了,而且我们也得花时间学习Objective-C的基础知识。


1
这是一个重要的警告,因为涉及到 obj-C 的设计模式和可测试性。 - BootMaker

8

这里有一个不需要使用信号量的巧妙技巧:

dispatch_queue_t serialQ = dispatch_queue_create("serialQ", DISPATCH_QUEUE_SERIAL);
dispatch_async(serialQ, ^
{
    [object doSomething];
});
dispatch_sync(serialQ, ^{ });

您需要使用dispatch_sync和一个空块等待,以便在A-Synchronous块完成之前同步等待串行调度队列。


这个答案的问题在于它没有解决 OP 的原始问题,即需要使用的 API 以 completionHandler 作为参数并立即返回。在此答案的异步块中调用该 API 将立即返回,即使 completionHandler 还没有运行。然后同步块将在 completionHandler 之前执行。 - BTRUE

6
- (void)performAndWait:(void (^)(dispatch_semaphore_t semaphore))perform;
{
  NSParameterAssert(perform);
  dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
  perform(semaphore);
  dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
  dispatch_release(semaphore);
}

使用示例:

[self performAndWait:^(dispatch_semaphore_t semaphore) {
  [self someLongOperationWithSuccess:^{
    dispatch_semaphore_signal(semaphore);
  }];
}];

2

还有SenTestingKitAsync,可以让你编写像这样的代码:

- (void)testAdditionAsync {
    [Calculator add:2 to:2 block^(int result) {
        STAssertEquals(result, 4, nil);
        STSuccess();
    }];
    STFailAfter(2.0, @"Timeout");
}

(详情请参见objc.io文章。)自从Xcode 6以来,XCTest有一个AsynchronousTesting类别,让你可以编写像这样的代码:

XCTestExpectation *somethingHappened = [self expectationWithDescription:@"something happened"];
[testedObject doSomethigAsyncWithCompletion:^(BOOL succeeded, NSError *error) {
    [somethingHappened fulfill];
}];
[self waitForExpectationsWithTimeout:1 handler:NULL];

1

Swift 4:

在创建远程对象时,请使用synchronousRemoteObjectProxyWithErrorHandler代替remoteObjectProxy。不再需要信号量。

下面的示例将返回从代理接收到的版本。如果没有synchronousRemoteObjectProxyWithErrorHandler,它将崩溃(尝试访问不可访问的内存):

func getVersion(xpc: NSXPCConnection) -> String
{
    var version = ""
    if let helper = xpc.synchronousRemoteObjectProxyWithErrorHandler({ error in NSLog(error.localizedDescription) }) as? HelperProtocol
    {
        helper.getVersion(reply: {
            installedVersion in
            print("Helper: Installed Version => \(installedVersion)")
            version = installedVersion
        })
    }
    return version
}

1

这里是我其中一个测试的替代方案:

__block BOOL success;
NSCondition *completed = NSCondition.new;
[completed lock];

STAssertNoThrow([self.client asyncSomethingWithCompletionHandler:^(id value) {
    success = value != nil;
    [completed lock];
    [completed signal];
    [completed unlock];
}], nil);    
[completed waitUntilDate:[NSDate dateWithTimeIntervalSinceNow:2]];
[completed unlock];
STAssertTrue(success, nil);

1
以上代码存在错误。根据 NSCondition文档,在调用 -waitUntilDate: 方法之前,必须先锁定接收器。因此,-unlock 应该在 -waitUntilDate: 之后执行。 - Patrick
这无法扩展到使用多个线程或运行队列的任何内容。 - user246672

0

解决问题的非常原始的方法:

void (^nextOperationAfterLongOperationBlock)(void) = ^{

};

[object runSomeLongOperationAndDo:^{
    STAssert…
    nextOperationAfterLongOperationBlock();
}];

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接