关于 dispatch_queue、重入和死锁的说明

Clarifications on dispatch_queue, reentrancy and deadlocks

我需要澄清 dispatch_queues 与重入和死锁的关系。

读这篇博文postThread Safety Basics on iOS/OS X,遇到这句话:

All dispatch queues are non-reentrant, meaning you will deadlock if you attempt to dispatch_sync on the current queue.

那么,重入和死锁是什么关系呢?为什么,如果 dispatch_queue 是不可重入的,那么当您使用 dispatch_sync 调用时会出现死锁吗?

据我所知,只有当您运行所在的线程与分派块的线程相同时,您才能使用 dispatch_sync 造成死锁。

下面是一个简单的例子。如果我 运行 主线程中的代码,因为 dispatch_get_main_queue() 也会抢占主线程,我将以死锁结束。

dispatch_sync(dispatch_get_main_queue(), ^{

    NSLog(@"Deadlock!!!");

});

有任何说明吗?

All dispatch queues are non-reentrant, meaning you will deadlock if you attempt to dispatch_sync on the current queue.

那么,重入和死锁是什么关系呢?为什么,如果 a dispatch_queue 是不可重入的,当你是 使用 dispatch_sync 调用?

在没有阅读那篇文章的情况下,我认为该声明是针对串行队列的,因为它在其他方面是错误的。

现在,让我们考虑一个关于调度队列如何工作的简化概念视图(在一些虚构的伪语言中)。我们还假设一个串行队列,不考虑目标队列。

调度队列

当您创建一个调度队列时,基本上您会得到一个 FIFO 队列,这是一个简单的数据结构,您可以在其中将对象推到末端,并从前面取出对象。

您还会获得一些复杂的机制来管理线程池和进行同步,但其中大部分是为了提高性能。让我们简单地假设您还有一个线程,它只是 运行 一个无限循环,处理来自队列的消息。

void processQueue(queue) {
    for (;;) {
        waitUntilQueueIsNotEmptyInAThreadSaveManner(queue)
        block = removeFirstObject(queue);
        block();
    }
}

dispatch_async

dispatch_async 采取同样简单的观点会产生类似这样的结果...

void dispatch_async(queue, block) {
    appendToEndInAThreadSafeManner(queue, block);
}

它真正做的就是取出块,并将其添加到队列中。这就是为什么它 returns 立即,它只是将块添加到数据结构的末尾。在某个时候,另一个线程会将这个块从队列中拉出并执行它。

请注意,这就是 FIFO 保证发挥作用的地方。将块从队列中拉出并执行的线程总是按照它们在队列中的顺序执行。然后它会等到该块完全执行后再从队列中取出下一个块

dispatch_sync

现在,dispatch_sync 的另一个简单视图。在这种情况下,API 保证它将等到块 运行 在它 returns 之前完成。特别是,调用此函数不会违反 FIFO 保证。

void dispatch_sync(queue, block) {
    bool done = false;
    dispatch_async(queue, { block(); done = true; });
    while (!done) { }
}

现在,这实际上是用信号量完成的,因此没有 cpu 循环和布尔标志,并且它不使用单独的块,但我们试图保持简单。你应该明白了。

块放在队列中,然后函数等待,直到它确定 "the other thread" 有 运行 块完成。

重入

现在,我们可以通过多种不同的方式获得可重入调用。让我们考虑最明显的。

block1 = {
    dispatch_sync(queue, block2);
}
dispatch_sync(queue, block1);

这会将 block1 放入队列中,并等待它到达 运行。最终处理队列的线程将弹出 block1,并开始执行它。当block1执行时,会把block2放入队列,然后等待它执行完。

这是重入的一个含义:当您从另一个对 dispatch_sync

的调用重新进入对 dispatch_sync 的调用时

重新进入的死锁dispatch_sync

但是,block1 现在 运行ning 在队列的 for 循环中。该代码正在执行块 1,并且在块 1 完成之前不会处理队列中的任何内容。

但是,块 1 已将块 2 放入队列中,正在等待它完成。 Block2确实已经放入了队列,但是永远不会执行。块 1 "waiting" 块 2 完成,但块 2 位于队列中,将其从队列中拉出并执行它的代码不会 运行 直到块 1 完成。

无法重新进入的死锁dispatch_sync

现在,如果我们将代码更改为这样...

block1 = {
    dispatch_sync(queue, block2);
}
dispatch_async(queue, block1);

从技术上讲,我们并没有重新进入 dispatch_sync。但是,我们仍然有相同的情况,只是启动 block1 的线程没有等待它完成。

我们仍在运行宁block1,等待block2完成,但运行block2的线程必须先完成block1。这永远不会发生,因为处理 block1 的代码正在等待 block2 从队列中取出并执行。

因此调度队列的重入在技术上不是重新进入同一个函数,而是重新进入同一个队列处理。

完全不重新进入队列的死锁

在最简单的情况下(也是最常见的),我们假设 [self foo] 在主线程上被调用,这对于 UI 回调很常见。

-(void) foo {
    dispatch_sync(dispatch_get_main_queue(), ^{
        // Never gets here
    });
}

这不是 "reenter" 调度队列 API,但它具有相同的效果。我们在主线程上 运行ning。主线程是从主队列中取出块并进行处理的地方。主线程当前正在执行 foo 并且一个块被放置在主队列中,然后 foo 等待该块被执行。但是,它只能在主线程完成当前工作后才能从队列中取出并执行。

这永远不会发生,因为主线程在 `foo 完成之前不会继续,但它永远不会完成,直到它正在等待 运行s 的块...这不会发生。

In my understanding, you can have a deadlock using dispatch_sync only if the thread you are running on is the same thread where the block is dispatch into.

如上述示例所示,情况并非如此。

此外,还有其他类似的场景,但不是很明显,尤其是当sync访问隐藏在层层方法调用中时。

避免死锁

避免死锁的唯一可靠方法是永远不要调用 dispatch_sync(这不完全正确,但足够接近)。如果您向用户公开您的队列,则尤其如此。

如果您使用自包含队列,并控制其使用和目标队列,则在使用 dispatch_sync 时可以保持一些控制。

确实,dispatch_sync 在串行队列上有一些有效的用途,但大多数可能是不明智的,只有当您确定自己不会 'sync' 时才应该这样做访问相同或其他资源(后者称为致命拥抱)。

编辑

Jody, Thanks a lot for your answer. I really understood all of your stuff. I would like to put more points...but right now I cannot. Do you have any good tips in order to learn this under the hood stuff? – Lorenzo B.

遗憾的是,我看过的关于 GCD 的书籍都不是很高级。他们讨论了如何将它用于简单的一般用例的简单表面级别的东西(我猜这是大众市场书籍应该做的)。

但是,GCD 是开源的。 Here is the webpage for it,其中包括指向其 svn 和 git 存储库的链接。但是,该网页看起来很旧(2010 年),而且我不确定代码的最新情况。 git 存储库的最新提交日期为 2012 年 8 月 9 日。

我确定有更多最新更新;但不确定他们会在哪里。

无论如何,我怀疑这些年来代码的概念框架是否发生了很大变化。

此外,调度队列的一般概念并不新鲜,并且已经以多种形式存在了很长时间。

许多个月前,我花了很多时间(和夜晚)编写内核代码(致力于我们认为是 SVR4 的第一个对称多处理实现),然后当我最终攻破内核时,我花了我大部分时间都在编写 SVR4 STREAMS 驱动程序(由用户 space 库包装)。最终,我将它完全变成了用户 space,并构建了一些最早的 HFT 系统(尽管当时不叫它)。

调度队列的概念在其中很普遍。它作为一个普遍可用的用户出现 space 库只是最近才开发的。

编辑#2

Jody, thanks for your edit. So, to recap a serial dispatch queue is not reentrant since it could produce an invalid state (a deadlock). On the contrary, an reentrant function will not produce it. Am I right? – Lorenzo B.

我想你可以这么说,因为它不支持重入调用。

但是,我想我更愿意说死锁是防止无效状态的结果。如果发生任何其他情况,则状态将受到损害,或者队列的定义将被违反。

核心数据的performBlockAndWait

考虑 -[NSManagedObjectContext performBlockAndWait]。它是非异步的,并且 可重入的。它在队列访问周围撒了一些精灵粉,因此当从 "the queue." 调用时,第二个块 运行 立即出现,因此,它具有我上面描述的特征。

[moc performBlock:^{
    [moc performBlockAndWait:^{
        // This block runs immediately, and to completion before returning
        // However, `dispatch_async`/`dispatch_sync` would deadlock
    }];
}];

以上代码没有"produce a deadlock"重入(但是API不能完全避免死锁)。

但是,根据您与谁交谈,这样做可能会产生无效(或 unpredictable/unexpected)状态。在这个简单的例子中,很清楚发生了什么,但在更复杂的部分,它可能更阴险。

最起码,你在performBlockAndWait.

里面做的事情一定要非常小心

现在,实际上,这只是主队列 MOC 的一个真正问题,因为主 运行 循环在主队列上 运行ning,所以 performBlockAndWait 识别那并立即执行该块。但是,大多数应用程序都有一个附加到主队列的 MOC,并响应主队列上的用户保存事件。

如果你想观察调度队列如何与主运行循环交互,你可以在主运行循环上安装一个CFRunLoopObserver,观察它如何处理各种主 运行 循环中的输入源。

如果您从未这样做过,那么这是一个有趣且具有教育意义的实验(尽管您不能假设您观察到的总是那样)。

无论如何,我通常尽量避免dispatch_syncperformBlockAndWait