【问题标题】:Is std::lock() ill-defined, unimplementable, or useless?std::lock() 是否定义不明确、无法实现或无用?
【发布时间】:2013-09-02 11:30:21
【问题描述】:

(注意:对于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&, L2&, L3&...);

4 要求: 每个模板参数类型都应满足 Lockable 要求,[注:unique_lock 类模板满足这些 适当实例化时的要求。 ——尾注]

5 效果:所有参数都通过对lock()的一系列调用锁定, try_lock()unlock() 在每个参数上。调用顺序应 不会导致死锁,但未指定。 [注:A 必须使用 try-and-back-off 等死锁避免算法,但 未指定特定算法以避免过度约束 实施。 — 结束注释] 如果调用 lock()try_lock() 抛出 一个例外,unlock() 应为任何已被调用的参数调用 通过调用 lock()try_lock() 锁定。

考虑以下示例。称之为“示例 1”:

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

可以这样死锁吗?

对标准的简单解读说“不”。伟大的!也许编译器可以为我订购我的锁,这会很整洁。

现在试试示例 2:

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

可以这样死锁吗?

这里再一次,标准的简单阅读说“不”。哦哦。做到这一点的唯一方法是使用某种退避和重试循环。更多内容如下。

最后,示例 3:

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

可以这样死锁吗?

再一次,对标准的简单解读说“不”。 (如果这些调用之一中的“对lock() 的调用序列”不是“导致死锁”,究竟是什么?)但是,我很确定这是无法实现的,所以我想这不是他们的意思。

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

您可以争辩说“退出并重试”有时很有用。这是真的,但只有当你不知道你试图抢在前面的锁时。例如,如果第二个锁的身份取决于第一个锁保护的数据(比如因为您正在遍历某些层次结构),那么您可能需要进行一些抓取-释放-抓取旋转。但在那种情况下,你不能使用这个小工具,因为你不知道前面的所有锁。另一方面,如果您确实知道要预先锁定哪些锁,那么您(几乎)总是只想简单地施加排序,而不是循环。

另外,请注意,如果实现只是按顺序获取锁、退出并重试,则示例 1 可以活锁。

简而言之,这个小工具在我看来充其量是没用的。只是一个坏主意。

好的,问题。 (1) 我的任何主张或解释是否错误? (2) 如果不是,他们到底在想什么? (3) 我们是否都同意“最佳实践”是完全避免std::lock

[更新]

一些答案​​说我误解了标准,然后继续以我的方式解释它,然后将规范与实现混淆。

所以,要明确一点:

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

我的问题的全部要点是,避免示例 2 的死锁需要一个回退和重试循环,而这样的循环是非常糟糕的做法。 (是的,对这个微不足道的例子进行某种静态分析可以避免这种情况,但在一般情况下并非如此。)还要注意 GCC 将此事物实现为繁忙循环。

[更新 2]

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

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

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

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

后一种方法的拥护者对特定 CPU、编译器、编译器次要版本、操作系统、运行时等的任何演示都没有印象。这样的演示几乎没有意思,也完全无关紧要。如果您的算法存在数据竞争,那么无论您运行它时会发生什么,它都会被破坏。如果您的算法 有活锁,那么无论您运行它时会发生什么,它都会被破坏。以此类推。

在我的世界里,第二种方法称为“工程”。我不确定第一种方法叫什么。

据我所知,std::lock 接口对工程学毫无用处。我很想被证明是错误的。

【问题讨论】:

  • 对其进行编码并尝试使其处于实时锁定状态。
  • @HowardHinnant:这证明什么都没有。你可以运行一个程序一万亿次并且永远不会触发竞争条件;这并不能证明没有竞争条件。简单想象一下,两个线程同时运行示例 1,逐行遍历您的代码in lockstep;即两个线程始终在同一行代码上。 (当然,在现实生活中,调度抖动最终可能会破坏活锁。这并不意味着您的 算法 不会受到活锁的影响。它显然会...)
  • 活锁状态具有非常正的特征值。如果真的形成了活锁状态,它会很快分崩离析。这不会是一个稳定的状态。
  • -1 因为你的 cmets。您似乎只想咆哮而不是解决问题。
  • @Nemo,我仍然不确定你是否理解序列是什么。 30.4.3 明确指出,对于对std::lock单个 调用:所有参数都通过对每个参数的lock()、try_lock() 或unlock() 的一系列调用来锁定.调用顺序不应导致死锁。多次调用std::lock不会避免死锁,因为这超出了本节讨论的顺序。

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


【解决方案1】:

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

所有参数都通过对每个参数的lock()、try_lock()或unlock()序列的调用锁定。调用序列不应导致陷入僵局

如果您调用 std::lock 传递十个不同的可锁定对象,则标准保证该调用不会出现死锁如果您锁定不受 std::lock 控制的可锁定对象,则不能保证避免死锁。这意味着线程 1 锁定 A 然后 B 可以与线程 2 锁定 B 然后 A 发生死锁。在您原来的第三个示例中就是这种情况,它具有(伪代码):

Thread 1     Thread 2
lock A       lock B
lock B       lock A

因为这不可能是std::lock(它只锁定了一个资源),所以它一定是unique_lock

根据您的第一个示例,如果两个线程都尝试在对std::lock单个调用中锁定 A/B 和 B/A,则会发生死锁避免。您的第二个示例也不会死锁,因为如果已经拥有第一个锁的线程 2 需要第二个锁,线程 1 将退出。您更新的第三个示例:

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

仍然有死锁的可能性,因为锁的原子性是对std::lock 调用。例如,如果线程 1 成功锁定了 lock1lock2,那么线程 2 成功锁定了 lock3lock4,当两个线程都试图锁定对方持有的资源时,就会发生死锁。

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

1/ 是的,我认为您误解了标准的含义。它所谈论的顺序显然是在传递给 single std::lock 的单个可锁定对象上执行的锁定顺序。

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

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


关于回退和重试是一个有问题的策略的 cmets,是的,这是正确的。但是,如果您不能事先强制执行锁的顺序,您可能会错过它可能是必要的。

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

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

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

事实上,它甚至可以通过检查所有可锁定对象的状态来对列表进行排序,以便所有当前锁定的对象都排在最前面。这将在流程的早期开始“趋向成功”操作。

这只是一个策略,可能还有其他策略,甚至更好。这就是为什么该标准没有规定如何它是如何完成的,可能会有一些天才想出更好的方法。

【讨论】:

  • 也就是说,只有back-off-and-retry的实现才能符合所写的标准? (示例 2 是我的问题的本质。如果我们同意示例 2 中的标准禁止死锁,那么我认为我们同意该标准有效地强制执行回退和重试繁忙循环。这就是 GCC 选择做的事情,顺便说一句。)如果是这样,那么我们的协议很激烈,这个东西显然不应该在现实生活中使用。
  • @Nemo,可能还有其他可行的策略,尽管我在漫长的职业生涯中没有遇到过它们 :-) 但这就是标准没有强制要求的原因该怎么做,以防万一某个地方有一些超级天才会发明一种新方法。至于你是否使用它,请参阅我的最后一段。如果您确实需要退避,最好让库来完成,而不是自己编写代码。但是,我从未真正发现过这种情况。
  • 我接受这个答案,因为它似乎是共识。请注意,此小工具的每个 可能实现都会受到活锁的影响。 (当然,每个提议的实现都会这样做;我很确定原则上这是不可避免的。)在我看来,实现可以减少但永远不会消除这种失败的可能性,这是处理并发编程的一种非常糟糕的方式。所以我仍然认为std::lock 最好避免出现在存在更好方法的场景中......这是我(或你)可以想象的每个场景。
  • @Nemo - 我已经开发多线程软件 30 多年了,从来没有轮询过锁,一次都没有。
  • 对于复杂的多资源管理的悲惨情况,我想出的唯一明智的策略是确保除非集合的所有成员都被允许,否则不允许需要一组资源的线程继续同时可用。这意味着阻塞任何无法在 condvar 或信号量上获取其所有资源的线程,直到其他线程释放重叠资源。
【解决方案2】:

如果您认为对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 在线程 1 之前获得了 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();

更新

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

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

&lt;disclaimer&gt; 可以创建一些形式的活锁,它们是永久性的,因此等同于死锁。此答案未解决此类代码,并且此类代码与此问题无关。 &lt;/disclaimer&gt;

this answer 中显示的产量是一项重要的性能优化,它显着降低了活锁,从而显着提高了std::lock(x, y, ...) 的性能。

更新 2

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

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

【讨论】:

  • 在高质量的实现中,例如stackoverflow.com/a/14525010/576911 中显示的,“忙循环”并不忙。在检测到其中一个互斥锁被锁定后,实现应该解锁所有内容,然后在锁定的互斥锁上阻塞。如果它只是盲目地尝试锁定任何东西,那确实会很昂贵,而且实施很差。
  • 我已经用 N == 2、3、4 进行了彻底的测试。我没有用 N == 100、1000、1000000 进行测试。我同意当 N 足够高时你可能是正确的。我不关心 N 如此高的用例,即使它会创建您描述的场景。我还没有见过需要如此高 N 的实际代码。如果将来开发出需要如此高 N 的激励算法,并且如果用高质量的实现进行测试证明了任何大量的相似锁,那么你是正确的,对于这些用例,应避免使用 std::lock
  • 两个锁和很多线程会发生什么?假设 500 个线程最终被阻塞等待 l0 而另外 500 个线程被阻塞等待 l1?您确定这不能是一个稳定的活锁,因为一侧的每个互斥锁释放都会使另一侧的一个线程可运行,而所有 500+500 只是交换位置?为什么您希望这会自行解决,您怎么知道需要多长时间? (请注意,这不是对您的实现的批评,我同意这很聪明。这是对整个“退避并重试”设计的批评。)
  • @Nemo:你自己编码和测试过任何东西吗?或者你只做无测试的评论?不如你编写 500 个线程,对其进行测试,然后报告你的发现。
  • @DavidSchwartz:嗨!巧合的是,这是我今天一直在研究的主题。现在我的答案中有一个链接,可以更完整地处理这个主题。我坦率地承认,我运行的测试仅限于单个操作系统。我邀请所有人,包括你自己,从论文中复制代码,并从他们选择的操作系统中报告结果。
【解决方案3】:

你对标准语的困惑似乎是由于这个说法

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

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

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

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

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

【讨论】:

  • 这似乎与@paxdiablo 下面的答案相同。请在那里查看我的 cmets。
  • @Nemo 评论 1:std::lock 的参数顺序无关紧要,因此两个调用将实现相同的目的。调用不能死锁,因为一旦线程 1 获得锁(保证无死锁),线程 2 将等待直到线程 1 放弃锁(反之亦然)。评论 2:同样适用于这种情况,不管线程 2 尝试锁定一个额外的互斥体。
  • 老实说,我很困惑如何从标准中的语言中获得这一点。我明白这就是您想要它的意思——实际上我也是如此——但这显然不是它所说的
【解决方案4】:

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

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

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

【讨论】:

  • 此文本未出现在 C++ 标准中。我们可以假设 C++ 标准中的文本是预期的规范,它可能与 Boost 所做的不同。
  • 省略这段文字是对规范的重大放宽,让我们回到了最初问题的本质:这种放宽是故意的吗(我们当然应该能够假设),还是标准中的缺陷导致要求不可行?
  • 嗯,Boost 版本是行不通的(根据 OP),而 C++ 版本是可以的(根据 Howard),因为 C++ 版本没有指定如果从多个线程调用没有死锁不同顺序的相同互斥锁。
  • OP 没有提到 Boost,所以我认为我们不能在这方面将太多结论归咎于它。
  • 我的观点是,C++ 版本保证 any 调用 lock(),以及 any 互斥锁组合,保证从不 到死锁,句号。我指出了 Boost 版本,因为它似乎表明 lock() 起源于一个限制性更强的用例。这就是我理解 OP 要质疑的内容,以及 Howard Hinnant(以及其他人)要捍卫的内容:满足 C++ 要求所需的底层实现是否可以在所有(合理的)情况下产生足够良好的结果。跨度>
猜你喜欢
  • 2015-06-19
  • 1970-01-01
  • 2016-04-06
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多