为什么在当前队列上不能使用dispatch_sync?

61

我遇到了这样一种情况:我有一个委托回调,可以在主线程或其他线程上发生,直到运行时我都不知道(使用StoreKit.framework)。

我还有一些UI代码需要在该回调中更新,在函数执行之前完成,因此我的初始想法是有一个像这样的函数:

-(void) someDelegateCallback:(id) sender
{
    dispatch_sync(dispatch_get_main_queue(), ^{
        // ui update code here
    });

    // code here that depends upon the UI getting updated
}

当在后台线程上执行时,这个方法非常好用。但是,当在主线程上执行时,程序就会陷入死锁。

对我来说,仅仅这一点就很有趣了。如果我正确地阅读了dispatch_sync的文档,那么我期望它会直接执行代码块,而不必担心将其调度到 runloop 中,如此处所述:

作为优化,此函数在可能的情况下在当前线程上调用块。

但没有关系,这只意味着需要多打几个字母,这让我想到了这种方法:

-(void) someDelegateCallBack:(id) sender
{
    dispatch_block_t onMain = ^{
        // update UI code here
    };

    if (dispatch_get_current_queue() == dispatch_get_main_queue())
       onMain();
    else
       dispatch_sync(dispatch_get_main_queue(), onMain);
}

不过,这似乎有些倒置。这是 GCD 制作中的一个错误吗,还是我在文档中漏掉了什么?


5
dispatch_get_current_queue()现已被弃用。检测主队列的方法是使用NSThread.isMainThread()(Swift)或[NSThread isMainThread](Objective-C)。 - udondan
NSThread.isMainThread() 不可靠,因为在极少数情况下,主队列会阻塞,并且 GCD 会重用主线程来执行其他队列。请参见 12 - Jano
@jtbandes请在标记重复问题时小心。这个问题显然比你链接的那个问题更旧,活动也更多,也许应该反向关闭它们。 - Richard J. Ross III
1
@RichardJ.RossIII:我确实考虑过这个问题;在我看来,我将其重复的那个问题更容易理解,并且有一个更全面的答案。该主题在http://meta.stackoverflow.com/questions/315472/old-question-marked-as-duplicate-of-a-new-question上进行了讨论。 - jtbandes
6个回答

77

dispatch_sync 有两个作用:

  1. 将一个 block 放入队列中。
  2. 阻塞当前线程,直到该 block 运行完成。

由于主线程是串行队列(即只使用一个线程),如果你在主队列上运行以下语句:

dispatch_sync(dispatch_get_main_queue(), ^(){/*...*/});
以下事件将会发生:
  1. dispatch_sync 将块队列到主队列。
  2. dispatch_sync 阻塞主队列的线程,直到块执行完成。
  3. dispatch_sync 会一直等待,因为块应该运行的线程被阻塞了。
理解此问题的关键在于,dispatch_sync 不会执行块,它只是将它们排队。执行将会在未来的运行循环迭代中发生。 下面是一个相关方法的示例:
if (queueA == dispatch_get_current_queue()){
    block();
} else {
    dispatch_sync(queueA, block);
}

这样做没问题,但要注意它不能保护你免受涉及队列层级的复杂情况的影响。在这种情况下,当前队列可能与您尝试发送阻塞时先前被阻塞的队列不同。例如:

dispatch_sync(queueA, ^{
    dispatch_sync(queueB, ^{
        // dispatch_get_current_queue() is B, but A is blocked, 
        // so a dispatch_sync(A,b) will deadlock.
        dispatch_sync(queueA, ^{
            // some task
        });
    });
});

对于复杂情况,要在分派队列中读写键值数据:

dispatch_queue_t workerQ = dispatch_queue_create("com.meh.sometask", NULL);
dispatch_queue_t funnelQ = dispatch_queue_create("com.meh.funnel", NULL);
dispatch_set_target_queue(workerQ,funnelQ);

static int kKey;
 
// saves string "funnel" in funnelQ
CFStringRef tag = CFSTR("funnel");
dispatch_queue_set_specific(funnelQ, 
                            &kKey,
                            (void*)tag,
                            (dispatch_function_t)CFRelease);

dispatch_sync(workerQ, ^{
    // is funnelQ in the hierarchy of workerQ?
    CFStringRef tag = dispatch_get_specific(&kKey);
    if (tag){
        dispatch_sync(funnelQ, ^{
            // some task
        });
    } else {
        // some task
    }
});

解释:

  • 我创建了一个指向funnelQ队列的workerQ队列。在真实代码中,如果您有几个“worker”队列,并且您想要一次性恢复/挂起所有队列(通过恢复/更新它们的目标funnelQ队列来实现),那么这非常有用。
  • 我可以在任何时候对我的工作队列进行汇聚,因此为了知道它们是否被汇聚,我用单词“漏斗”标记funnelQ
  • 在程序执行中,我使用dispatch_sync将一些任务分派到workerQ队列,由于某种原因,我想要将任务分派到funnelQ队列,但避免分派到当前队列,所以我检查标记并根据情况采取行动。由于获取值是向上遍历层次结构,因此该值不会在workerQ中找到,但会在funnelQ中找到。这是一种找出任何队列是否为我们存储值的队列的方法。因此,可以防止分派到当前队列。

如果您想知道读写上下文数据的函数,有三个:

  • dispatch_queue_set_specific: 写入队列。
  • dispatch_queue_get_specific: 从队列中读取。
  • dispatch_get_specific: 用于从当前队列中读取的方便函数。

键通过指针进行比较,永远不会被解除引用。设置器中的最后一个参数是释放键的析构函数。

如果您想知道“指向另一个队列”的含义,它的确就是这个意思。例如,我可以将队列A指向主队列,这将导致队列A中的所有块在主队列上运行(通常用于UI更新)。


2
很明显那是对的。dispatch_sync 几乎从来不是最佳选择,我只在少数情况下需要使用它来更新和获取应用程序UI部分的结果,但超出此范畴,你需要选择其他方法。你对检查队列层次结构的疯狂技巧可能只会带来未来的痛苦。 - Richard J. Ross III
1
这很复杂,我更愿意有一个内置的 amIChildOfQueue:,但对于复杂情况,使用特定于队列的上下文是苹果推荐的解决方案。请参见线程中的第6篇文章 dispatch_get_current_queue() 已弃用 - Jano
你能看一下这个问题吗?https://dev59.com/S3jZa4cB1Zd3GeqPi936 - hfossli
@Jano,“阻塞当前队列,直到块运行完成”,你这句话是什么意思?有人说dispatch_sync只阻塞调用它的当前线程。 - onmyway133
1
@entropy 你引用的语句只有在队列只有一个线程时才是正确的,例如:主队列。我编辑了答案以澄清这一点。 - Jano
我尝试了你的代码示例,它在dispatch_sync(funnelQ这一行崩溃了,你知道为什么吗? - gabbler

53
我在文档(最后一章)中找到了以下内容:

不要从正在执行的任务中调用dispatch_sync函数,该任务在传递给函数调用的同一队列上。 这样做会死锁队列。 如果需要分派到当前队列,请使用dispatch_async函数异步完成。

此外,我按照您提供的链接,并在 dispatch_sync 的描述中读到这个:

调用此函数并针对当前队列会导致死锁。

所以我认为这不是GCD的问题,我认为您在发现问题后发明的方法是唯一合理的方法。


12
我必须说我不同意dispatch_sync的行为有什么问题。如果你仔细想想,dispatch_syncasync都会将任务排队,但第一个函数在任务执行完前也不会返回。在你提供的例子中,任务被排队但从未执行,这是死锁的直接原因。所以请记住,该函数的主要功能实际上是将任务排队,而不是调用它。调用是另一回事,但根据你的描述,似乎你期望该函数真正调用你的任务。 - lawicko
9
我不同意。我并不真正关心dispatch_sync在底层是如何工作的,我关心的是从上往下看它做了什么,即“在给定的线程上执行此代码,并在完成后返回”。如果我在目标线程上,那么我就没有必要检查自己是否在目标线程上,因为这个函数应该替我去做。尽管这使我感到惊讶,因为苹果的大多数API都比这更智能,但我猜开发人员可能只是偷懒了一下? :) - Richard J. Ross III
9
@RichardJ.RossIII,你似乎忽略了你正在使用的API是一个串行队列,并且你正在尝试阻塞该队列上的当前项,同时等待其后面的项执行。这个API没有按照你所期望的方式工作并不意味着它实现得不好。它正是按照文档所描述的方式工作的。 - Christopher Pickslay
11
@Richard:我认为你思考的错误在于这里:"从一个自上而下的角度来看,它所做的是在给定的 线程 上执行此代码,并在完成后返回。" dispatch_sync() 不使用线程,而是使用队列。主队列保证在主线程上运行对于 dispatch_sync() 来说是巧合。若要立即执行您尝试排队的块,将打破其含义——在当前任务完成之前执行下一个任务意味着您将不再具有队列行为。 - jscs
2
问题是,99.9%的时间,没有人真正想要真正的串行队列语义。他们不关心顺序;他们只想要没有并发。有些情况下,dispatch_sync语义是有意义的,但我认为它们引起的问题远比它们帮助的多。话虽如此,如果你只想在主线程上运行一些代码,“performSelectorOnMainThread:”具有你所寻找的语义。或者只需编写“#define dispatch_sync_safe(queue, block) {if (queue == dispatch_get_current_queue()) { block(); } else { dispatch_sync(queue, block);}}”,然后调用它即可。 - dgatwood
显示剩余2条评论

16

我知道你的困惑来自哪里:

为了优化,该函数在可能的情况下会在当前线程上调用该块。

注意,它说的是当前线程

线程 != 队列

一个队列没有拥有一个线程,线程也不绑定于队列。有线程和队列两个概念。当一个队列想要运行一个块时,它需要一个线程,但这个线程并不总是相同的线程。 它只需要任何一个线程(每次可能都不一样),当它完成运行块(暂时)后,同一个线程现在可以被不同的队列使用。

这个句子谈到的优化是关于线程的,而不是关于队列的。例如,假设你有两个串行队列QueueAQueueB,现在你做以下操作:

dispatch_async(QueueA, ^{
    someFunctionA(...);
    dispatch_sync(QueueB, ^{
        someFunctionB(...);
    });
});

QueueA运行这个块时,它将临时拥有一个线程,任何线程。someFunctionA(...)将在该线程上执行。现在,在执行同步调度时,QueueA不能做任何其他事情,必须等待调度完成。另一方面,QueueB也需要一个线程来运行其块并执行someFunctionB(...)。因此,要么QueueA暂时挂起其线程,并且QueueB使用其他线程来运行该块,或者QueueA将其线程交给QueueB(毕竟在同步调度完成之前它不需要),并且QueueB直接使用QueueA的当前线程。

不用说,最后一种选择速度更快,因为不需要进行线程切换。而这就是该句话所讨论的优化。因此,对于不同队列的dispatch_sync()可能不会总是导致线程切换(不同队列,可能是同一个线程)。

但是,dispatch_sync()仍然不能发生在相同的队列上(是同一个线程,是的,是同一个队列,不可以)。这是因为队列将一个块接着另一个块执行,当它当前执行一个块时,不会执行另一个块,直到目前执行的块完成。所以它执行BlockA,并且BlockA在同一队列上做了一个dispatch_sync()BlockB。只要它还运行BlockA,队列就不会运行BlockB,但是运行BlockA也不会继续,直到BlockB已经运行完毕。看到问题了吗?这是一个经典的死锁。


6

文档明确指出,传递当前队列将导致死锁。

现在他们没有说为什么设计成这样(除了会实际需要额外的代码来使其工作),但我怀疑这样做的原因是因为在这种特殊情况下,块将“跳过”队列,即在正常情况下,您的块最终在队列中的所有其他块运行后运行,但在这种情况下,它将在其前面运行。

当您尝试将GCD用作互斥机制时,就会出现此问题,而这种特殊情况相当于使用递归互斥体。我不想介入有关是否应该使用GCD或传统的互斥API(例如pthread互斥)甚至是否应该使用递归互斥体的争论;我会让其他人争论这个问题,但肯定存在这样的需求,特别是当您处理主队列时。

个人认为,如果dispatch_sync支持这一点或者提供了另一种函数以提供替代行为,那么它会更有用。我建议其他也这么想的人向Apple提交错误报告(像我一样,ID:12668073)。

您可以编写自己的函数来完成相同的功能,但这有点像一个hack:

// Like dispatch_sync but works on current queue
static inline void dispatch_synchronized (dispatch_queue_t queue,
                                          dispatch_block_t block)
{
  dispatch_queue_set_specific (queue, queue, (void *)1, NULL);
  if (dispatch_get_specific (queue))
    block ();
  else
    dispatch_sync (queue, block);
}

注意:之前我有一个例子使用dispatch_get_current_queue(),但它现在已经被弃用了。


我做过类似的事情,只不过是用宏来做的,这样我编写的其他使用 dispatch_sync 的代码就不会出问题了。点个赞给你! - Richard J. Ross III
1
宏同样可以工作,但一般来说,我建议你仅在无法使用静态内联函数时才使用宏,因为出于许多原因,静态内联函数更可取,并且宏没有任何优势。 - Chris Suter
1
dispatch_get_current_queue 自 iOS 6.x 起已被弃用。 - openfrog
这并不能防止死锁,因为你可能有一个目标队列是queue的队列。然后你就会进入“else”分支,然后出现死锁。这也是由苹果公司记录在案的:“如果代码假定对一个队列的同步执行是免于死锁的,而那个队列不是由dispatch_get_current_queue()返回的队列,那么同样是不安全的。”来自“man 3 dispatch_get_current_queue”下的“注意事项”。 - Johannes Weiss
2
对于主队列,您可以使用if ([NSThread isMainThread]) { block() } else { dispatch_sync(dispatch_get_main_queue(), block); },这是安全的,因为所有以主队列为目标的队列也在主线程上执行(因为主队列是串行队列)。 - Johannes Weiss
新版本在此情况下仍然会死锁:dispatch_queue_t q1 = dispatch_queue_create("Q", DISPATCH_QUEUE_SERIAL); dispatch_queue_t q2 = dispatch_queue_create("Q", DISPATCH_QUEUE_SERIAL); dispatch_set_target_queue(q2, q1); NSLog(@"before"); dispatch_sync(q1, ^{ NSLog(@"on q1"); dispatch_synchronized(q2, ^{ NSLog(@"works"); }); });永远无法到达NSLog("works"); - Johannes Weiss

4
dispatch_asyncdispatch_sync都将它们的操作推入所需队列中,但操作不会立即执行,而是在队列的某个未来迭代中执行。两者之间的区别在于dispatch_sync会阻塞当前队列直到操作完成。
当您在当前队列异步执行某些操作时,请考虑会发生什么。同样,操作不会立即执行,而是放入FIFO队列中,并且必须等待当前运行循环迭代完成(可能还要等待在您将此新操作放入之前排队的其他操作)。
现在您可能会问,在当前队列上异步执行操作时,为什么不总是直接调用函数,而是等待一段时间后再执行呢?答案是两者之间存在很大的差异。很多时候,您需要执行一个操作,但需要在当前运行循环堆栈中的函数执行任何副作用之后才能执行;或者需要在已经计划在运行循环中的某些动画操作之后执行您的操作等等。这就是为什么很多时候您会看到代码[obj performSelector:selector withObject:foo afterDelay:0](是的,它与[obj performSelector:selector withObject:foo]不同)。
正如我们之前所说,dispatch_syncdispatch_async相同,只是它会阻塞直到操作完成。因此,很明显它会死锁--块无法执行,直到至少在当前运行循环迭代完成后;但我们正在等待它完成才能继续。
理论上可以为dispatch_sync的特殊情况(当它是当前线程时)制定一个特殊情况,以立即执行它。(当线程为当前线程且waitUntilDone:为YES时,performSelector:onThread:withObject:waitUntilDone:存在这样的特殊情况,它会立即执行。)但是,我想苹果公司决定无论队列如何,在这里都有一致的行为更好。

但这没有意义。如果出现错误,控制台至少应该输出日志信息,就像其他API(例如递归的 NSLock)一样。 - Richard J. Ross III
@newacct "dispatch_sync阻塞当前线程"?是阻塞当前线程还是当前队列? - onmyway133

2
从以下文档中找到:https://developer.apple.com/library/ios/documentation/Performance/Reference/GCD_libdispatch_Ref/index.html#//apple_ref/c/func/dispatch_syncdispatch_async不同,"dispatch_sync"函数在块完成前不返回。调用此函数并针对当前队列会导致死锁。
dispatch_async不同,目标队列不执行保留操作。因为对此函数的调用是同步的,所以它“借用”了调用者的引用。此外,块上没有执行Block_copy
作为一种优化,这个函数在可能的情况下在当前线程上调用块。

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