std :: lock()是不明确的,不可实现的还是无用的?

时间:2013-08-29 21:06:56

标签: c++ multithreading c++11 language-lawyer

(注意:对于Massive CPU load using std::lock (c++11)的评论,这大部分是多余的,但我认为这个主题值得自己提出问题和解答。)

我最近遇到了一些示例C ++ 11代码:

std::unique_lock<std::mutex> lock1(from_acct.mutex, std::defer_lock);
std::unique_lock<std::mutex> lock2(to_acct.mutex, std::defer_lock);
std::lock(lock1, lock2); // avoid deadlock
transfer_money(from_acct, to_acct, amount);
哇,我想,std::lock听起来很有意思。我不知道它的标准是什么意思?

C ++ 11第30.4.3节[thread.lock.algorithm],第(4)和(5)段:

  

模板无效锁定(L1&amp;,L2&amp;,L3&amp; ...);

     

4 要求:每个模板参数类型应符合Lockable   要求,[注意:unique_lock类模板符合这些要求   适当实例化时的要求。 - 结束说明]

     

5 效果:所有参数都是通过对lock()的一系列调用锁定的,   每个参数都有try_lock()unlock()。呼叫顺序应该是   不会导致死锁,但在其他方面没有特别说明。 [注:A   必须使用死锁避免算法,例如try-and-back-off,但是   未指定特定算法以避免过度约束   实现。 - 结束注释]如果对lock()try_lock()的调用被抛出   例外情况,unlock()将被调用任何已经存在的参数   通过致电lock()try_lock()锁定。

考虑以下示例。称之为&#34;示例1&#34;:

Thread 1                    Thread 2
std::lock(lock1, lock2);    std::lock(lock2, lock1);

这可能会陷入僵局吗?

标准的简单说明&#34; no&#34;。大!也许编译器可以为我订购我的锁,这将是一种整洁。

现在尝试示例2:

Thread 1                                  Thread 2
std::lock(lock1, lock2, lock3, lock4);    std::lock(lock3, lock4);
                                          std::lock(lock1, lock2);

这可能会陷入僵局吗?

再一次,标准的简单说明&#34; no&#34;。哦,哦。唯一的方法是使用某种后退和重试循环。更多关于以下内容。

最后,例3:

Thread 1                          Thread 2
std::lock(lock1,lock2);           std::lock(lock3,lock4);
std::lock(lock3,lock4);           std::lock(lock1,lock2);

这可能会陷入僵局吗?

再一次,标准的简单说明&#34; no&#34;。 (如果在其中一个调用中对lock()&#34;的&#34;调用序列不是&#34;导致死锁&#34;,究竟是什么?)但是,我很确定这是无法实现的,所以我认为这不是他们的意思。

这似乎是我在C ++标准中遇到过的最糟糕的事情之一。我猜它最初是一个有趣的想法:让编译器分配一个锁定顺序。但是一旦委员会咀嚼起来,结果要么无法实现,要么需要重试循环。是的,这是一个坏主意。

你可以争辩说&#34;退出并重试&#34;有时是有用的。这是事实,但只有当你不知道你试图抓住哪些锁时才会这样。例如,如果第二个锁的标识取决于受第一个锁保护的数据(比如因为您正在遍历某个层次结构),那么您可能必须执行一些抓取 - 释放 - 抓取旋转。但是在这种情况下你不能使用这个小工具,因为你不知道前面的所有锁。另一方面,如果你预先知道你想要哪些锁,那么你(几乎)总是希望简单地强加一个排序,而不是循环。

另请注意,如果实现只是按顺序抓取锁,退出并重试,示例1可以实时锁定。

简而言之,这个小工具充其量只会让我觉得无用。只是一个坏主意。

好的,问题。 (1)我的任何主张或解释是否错误? (2)如果没有,他们在想什么? (3)我们是否应该同意&#34;最佳实践&#34;是完全避免std::lock

[更新]

有些答案说我误解了标准,然后继续以与我相同的方式对其进行解释,然后将规范与实施混淆。

所以,为了清楚起见:

在我阅读标准时,示例1和示例2无法解锁。示例3可以,但仅仅是因为在这种情况下避免死锁是无法实现的。

我的问题的全部意义在于,避免实例2的死锁需要一个后退和重试循环,而这样的循环是非常糟糕的做法。 (是的,对这个简单的例子进行某种静态分析可以避免这种情况,但不是一般情况。)另请注意,GCC将此事件实现为繁忙的循环。

[更新2]

我认为这里的许多脱节是哲学的基本差异。

编写软件有两种方法,尤其是多线程软件。

在一种方法中,你将一堆东西放在一起并运行它以查看它的工作情况。您永远不会相信您的代码存在问题,除非有人能够立即在现实系统中证明该问题。

在另一种方法中,您编写的代码可以进行严格分析,以证明它没有数据竞争,所有循环都以概率1终止,依此类推。您严格按照语言规范保证的机器模型执行此分析,而不是任何特定实现。

后一种方法的倡导者对特定CPU,编译器,编译器次要版本,操作系统,运行时等的任何演示都没有留下深刻印象。这种演示几乎没有意义,完全无关紧要。如果您的算法有数据争用,无论您在运行时发生什么,它都会被破坏。如果您的算法有一个活锁,无论您在运行时发生什么,它都会被破坏。等等。

在我的世界中,第二种方法被称为&#34;工程&#34;。我不确定第一种方法是什么。

据我所知,std::lock界面对于Engineering来说毫无用处。我希望被证明是错的。

4 个答案:

答案 0 :(得分:39)

我认为你误解了避免死锁的范围。这是可以理解的,因为文本似乎在两个不同的上下文中提到lock,“多锁”std::lock以及由“多锁”执行的各个锁(但是可锁定实现它)。 std::lock州的文字:

  

所有参数都会通过对每个参数的lock(),try_lock()或unlock()的一系列调用来锁定。调用序列不会产生陷入僵局

如果你调用std::lock通过十个不同的可锁定项,标准就不会保证该调用没有死锁如果你将可锁定项锁定在{{{{em>的控制范围之外,则无法保证可以避免死锁。 1}}。这意味着线程1锁定A然后B可能会死锁线程2锁定B然后是A.原来的第三个例子就是这种情况,它有(伪代码):

std::lock

因为那不可能是Thread 1 Thread 2 lock A lock B lock B lock A (它只锁定了一个资源),它必须是std::lock

如果两个线程都试图在单个调用unique_lock中锁定A / B和B / A,则会发生 例。您的第二个示例也不会死锁,因为如果已经具有第一个锁的线程2需要第二个锁,则线程1将退回。您更新的第三个示例:

std::lock

仍有可能发生死锁,因为锁的原子性是对Thread 1 Thread 2 std::lock(lock1,lock2); std::lock(lock3,lock4); std::lock(lock3,lock4); std::lock(lock1,lock2); 调用。例如,如果线程1成功锁定std::locklock1,则线程2成功锁定lock2lock3,随后两个线程都会尝试锁定由另一个。

所以,回答你的具体问题:

1 /是的,我认为你误解了标准的含义。它所讨论的序列显然是传递给 lock4的各个可锁定的锁序列。

2 /至于他们在想什么,有时很难说:-)但我认为他们想要给我们能力,否则我们必须自己写。是的,后退和重试可能不是一个理想的策略,但是,如果您需要避免死锁功能,您可能需要付出代价。更好的实现提供它而不是必须由开发人员一遍又一遍地写。

3 /不,没有必要避免它。我不认为我曾经发现自己处于无法进行简单的手动订购锁定的情况,但我并不打算这种可能性。如果您确实发现自己处于这种情况,这可以提供帮助(因此您不必编写自己的死锁避免代码)。


关于回退和重试是一个有问题的策略的评论,是的,这是正确的。但是,如果你不能事先强制执行锁的排序,那么你可能会忽略它可能是必要的

并且它 并不像你想象的那样糟糕。因为锁可以按std::lock的任何顺序完成,所以没有什么能阻止实现在每次退避后重新排序,以使“失败”可锁定到列表的前面。这意味着那些被锁定的人往往会聚集在前面,这样std::lock不太可能不必要地声称资源。

考虑调用std::lock,其中std::lock (a, b, c, d, e, f)是唯一已锁定的可锁定内容。在第一次锁定尝试中,该调用会锁定fa,然后在e上“失败”。

在后退(解锁fa)之后,要锁定的列表将更改为e,以便后续迭代不太可能不必要地锁定。这不是万无一失的,因为其他资源可能在迭代之间被锁定或解锁,但它趋于成功。

实际上,它甚至可以通过检查所有可锁定状态来最初排序列表,以便所有当前锁定的人都在前面。这将在此过程的早期开始“趋向成功”。

这只是一个策略,可能还有其他人,甚至更好。这就是为什么标准没有强制如何完成它的原因,在可能的情况下,可能会有一些天才想出更好的方法。

答案 1 :(得分:21)

如果你认为对std::lock(x, y, ...)的每次调用都是原子的,那么它可能会有所帮助。它将阻塞,直到它可以锁定其所有参数。如果您不知道锁定先验的所有互斥锁,请不要使用此功能。如果您知道,那么您可以安全地使用此功能,而无需订购锁。

但是,如果您喜欢这样做,请务必订购锁。

Thread 1                    Thread 2
std::lock(lock1, lock2);    std::lock(lock2, lock1);

以上不会死锁。其中一个线程将获得两个锁,另一个线程将阻塞,直到第一个线程释放锁。

Thread 1                                  Thread 2
std::lock(lock1, lock2, lock3, lock4);    std::lock(lock3, lock4);
                                          std::lock(lock1, lock2);

以上不会死锁。虽然这很棘手。如果线程2在Thread1执行之前获得lock3和lock4,则线程1将阻塞,直到线程2释放所有4个锁。如果线程1首先获得四个锁,那么线程2将在锁定lock3和lock4时阻塞,直到线程1释放所有4个锁。

Thread 1                          Thread 2
std::lock(lock1,lock2);           std::lock(lock3,lock4);
std::lock(lock3,lock4);           std::lock(lock1,lock2);

是的,以上可能会陷入僵局。您可以将上述内容视为完全等同于:

Thread 1                          Thread 2
lock12.lock();                    lock34.lock();
lock34.lock();                    lock12.lock();

<强>更新

我认为误解是死锁和活锁都是正确性问题。

在实际操作中,死锁是一个正确性问题,因为它会导致进程冻结。而实时锁是一个性能问题,因为它会导致进程变慢,但它仍然可以正确完成其任务。原因是活锁不会(在实践中)无限期地维持下去。

<disclaimer> 可以创建永久锁定形式的活锁,因此等同于死锁。此答案不涉及此类代码,此类代码与此问题无关。 </disclaimer>

this answer中显示的产量是显着的性能优化,可显着降低实时锁定,从而显着提高std::lock(x, y, ...)的性能。

更新2

经过长时间的拖延,我写了一篇关于这个主题的论文初稿。本文比较了完成这项工作的4种不同方式。它包含可以复制并粘贴到您自己的代码中并自行测试的软件:

http://howardhinnant.github.io/dining_philosophers.html

答案 2 :(得分:4)

你对标准的混淆似乎是由于这个陈述

  

5 效果:所有参数都是通过对lock()的一系列调用锁定的,   每个论点都有try_lock()unlock()

这并不意味着std::lock会以原始调用的每个参数递归调用自身。

满足可锁定概念的对象(§30.2.5.4 [thread.req.lockable.req])必须实现所有这三个成员函数。 std::lock将以未指定的顺序在每个参数上调用这些成员函数,以尝试获取对所有对象的锁定,同时执行定义的实现以避免死锁。

您的示例3可能会出现死锁,因为您没有向std::lock发出一个包含您想要锁定的所有对象的单个调用。

示例2将导致死锁,Howard的answer解释了原因。

答案 3 :(得分:1)

C ++ 11是否从Boost采用了​​这个功能?

如果是这样,Boost的描述很有启发性(强调我的):

  

效果:锁定作为参数提供的可锁定对象,未指定和   以避免死锁的方式确定不确定的顺序。 可以安全地打电话   此函数同时来自具有相同互斥锁的多个线程   (或其他可锁定的对象)以不同的顺序没有风险   死锁。如果对任何lock()或try_lock()操作的话   提供的Lockable对象会抛出任何获取的锁定异常   该函数将在函数退出之前释放。