递归锁(互斥锁)与非递归锁(互斥锁)

发布于 2024-07-07 13:28:04 字数 823 浏览 9 评论 0原文

POSIX 允许互斥体是递归的。 这意味着同一个线程可以两次锁定同一个互斥锁,并且不会死锁。 当然也需要解锁两次,否则其他线程都无法获取互斥量。 并非所有支持 pthread 的系统也支持递归互斥体,但如果它们想要POSIX 一致,他们必须这样做。

其他 API(更高级的 API)通常也提供互斥体,通常称为锁。 一些系统/语言(例如Cocoa Objective-C)提供递归和非递归互斥体。 有些语言也只提供其中一种。 例如,在Java中,互斥体总是递归的(同一个线程可能在同一个对象上两次“同步”)。 根据它们提供的其他线程功能,没有递归互斥体可能没有问题,因为它们可以轻松地自己编写(我自己已经在更简单的互斥体/条件操作的基础上实现了递归互斥体)。

我不太明白:非递归互斥体有什么用? 如果两次锁定同一个互斥锁,为什么会出现线程死锁? 即使是可以避免这种情况的高级语言(例如,测试这是否会死锁,如果发生则抛出异常)通常也不会这样做。 他们会让线程死锁。

这是否仅适用于我不小心锁定它两次而只解锁一次的情况,并且在递归互斥体的情况下,更难找到问题,所以我立即将其死锁以查看错误锁定出现的位置? 但是,我不能在解锁时返回一个锁计数器吗?在我确信我释放了最后一个锁并且计数器不为零的情况下,我可以抛出异常或记录问题吗? 或者还有其他我看不到的更有用的非递归互斥体用例吗? 或者这可能只是性能问题,因为非递归互斥体可能比递归互斥体稍快一些? 不过我测试了一下,差别确实没有那么大。

POSIX allows mutexes to be recursive. That means the same thread can lock the same mutex twice and won't deadlock. Of course it also needs to unlock it twice, otherwise no other thread can obtain the mutex. Not all systems supporting pthreads also support recursive mutexes, but if they want to be POSIX conform, they have to.

Other APIs (more high level APIs) also usually offer mutexes, often called Locks. Some systems/languages (e.g. Cocoa Objective-C) offer both, recursive and non recursive mutexes. Some languages also only offer one or the other one. E.g. in Java mutexes are always recursive (the same thread may twice "synchronize" on the same object). Depending on what other thread functionality they offer, not having recursive mutexes might be no problem, as they can easily be written yourself (I already implemented recursive mutexes myself on the basis of more simple mutex/condition operations).

What I don't really understand: What are non-recursive mutexes good for? Why would I want to have a thread deadlock if it locks the same mutex twice? Even high level languages that could avoid that (e.g. testing if this will deadlock and throwing an exception if it does) usually don't do that. They will let the thread deadlock instead.

Is this only for cases, where I accidentally lock it twice and only unlock it once and in case of a recursive mutex, it would be harder to find the problem, so instead I have it deadlock immediately to see where the incorrect lock appears? But couldn't I do the same with having a lock counter returned when unlocking and in a situation, where I'm sure I released the last lock and the counter is not zero, I can throw an exception or log the problem? Or is there any other, more useful use-case of non recursive mutexes that I fail to see? Or is it maybe just performance, as a non-recursive mutex can be slightly faster than a recursive one? However, I tested this and the difference is really not that big.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(8

暖伴 2024-07-14 13:28:04

递归互斥体和非递归互斥体之间的区别与所有权有关。 在递归互斥体的情况下,内核必须跟踪第一次实际获得互斥体的线程,以便它可以检测递归与应该阻塞的不同线程之间的差异。 正如另一个答案指出的那样,在存储此上下文的内存以及维护它所需的周期方面都存在额外开销的问题。

但是,这里还有其他考虑因素。

因为递归互斥体具有所有权意识,所以抓取互斥体的线程必须与释放互斥体的线程是同一个线程。 在非递归互斥体的情况下,没有所有权意识,任何线程通常都可以释放互斥体,无论哪个线程最初获取互斥体。 在许多情况下,这种类型的“互斥体”实际上更像是一种信号量操作,您不一定将互斥体用作排除设备,而是将其用作两个或多个线程之间的同步或信号设备。

互斥体中具有所有权感的另一个属性是支持优先级继承的能力。 由于内核可以跟踪拥有互斥锁的线程以及所有阻塞程序的身份,因此在优先级线程系统中,可以将当前拥有互斥锁的线程的优先级升级为最高优先级线程的优先级当前正在互斥体上阻塞。 这种继承可以防止在这种情况下可能发生的优先级反转问题。 (请注意,并非所有系统都支持此类互斥体的优先级继承,但这是通过所有权概念成为可能的另一个功能)。

如果您参考经典的 VxWorks RTOS 内核,它们定义了三种机制:

  • 互斥 - 支持递归和可选的优先级继承。 此机制通常用于以一致的方式保护数据的关键部分。
  • 二进制信号量 - 无递归,无继承,简单排除,接受者和给予者不必是同一线程,可用广播发布。 此机制可用于保护关键部分,但对于线程之间的一致信号或同步也特别有用。
  • 计数信号量 - 无递归或继承,充当任何所需初始计数的连贯资源计数器,线程仅在资源净计数为零的情况下阻塞。

同样,这会因平台而有所不同 - 特别是他们对这些东西的称呼,但这应该代表正在发挥作用的概念和各种机制。

The difference between a recursive and non-recursive mutex has to do with ownership. In the case of a recursive mutex, the kernel has to keep track of the thread who actually obtained the mutex the first time around so that it can detect the difference between recursion vs. a different thread that should block instead. As another answer pointed out, there is a question of the additional overhead of this both in terms of memory to store this context and also the cycles required for maintaining it.

However, there are other considerations at play here too.

Because the recursive mutex has a sense of ownership, the thread that grabs the mutex must be the same thread that releases the mutex. In the case of non-recursive mutexes, there is no sense of ownership and any thread can usually release the mutex no matter which thread originally took the mutex. In many cases, this type of "mutex" is really more of a semaphore action, where you are not necessarily using the mutex as an exclusion device but use it as synchronization or signaling device between two or more threads.

Another property that comes with a sense of ownership in a mutex is the ability to support priority inheritance. Because the kernel can track the thread owning the mutex and also the identity of all the blocker(s), in a priority threaded system it becomes possible to escalate the priority of the thread that currently owns the mutex to the priority of the highest priority thread that is currently blocking on the mutex. This inheritance prevents the problem of priority inversion that can occur in such cases. (Note that not all systems support priority inheritance on such mutexes, but it is another feature that becomes possible via the notion of ownership).

If you refer to classic VxWorks RTOS kernel, they define three mechanisms:

  • mutex - supports recursion, and optionally priority inheritance. This mechanism is commonly used to protect critical sections of data in a coherent manner.
  • binary semaphore - no recursion, no inheritance, simple exclusion, taker and giver does not have to be same thread, broadcast release available. This mechanism can be used to protect critical sections, but is also particularly useful for coherent signalling or synchronization between threads.
  • counting semaphore - no recursion or inheritance, acts as a coherent resource counter from any desired initial count, threads only block where net count against the resource is zero.

Again, this varies somewhat by platform - especially what they call these things, but this should be representative of the concepts and various mechanisms at play.

还不是爱你 2024-07-14 13:28:04

答案是不是效率。 不可重入互斥体可以产生更好的代码。

示例:A::foo() 获取锁。 然后它调用 B::bar()。 当你写的时候这个效果很好。 但过了一段时间,有人将 B::bar() 更改为调用 A::baz(),这也获取了锁。

好吧,如果没有递归互斥锁,就会陷入僵局。 如果你有它们,它会运行,但可能会损坏。 A::foo() 在调用 bar() 之前可能使对象处于不一致的状态,假设 baz() 无法运行,因为它也获取了互斥体。 但它可能不应该运行! 编写 A::foo() 的人假设没有人可以同时调用 A::baz() - 这就是这两个方法获取锁的全部原因。

使用互斥体的正确思维模型:互斥体保护不变量。 当互斥体被持有时,不变量可能会改变,但在释放互斥体之前,不变量会被重新建立。 可重入锁很危险,因为第二次获取锁时,您无法再确定不变式是否正确。

如果您对可重入锁感到满意,那只是因为您以前没有调试过这样的问题。 顺便说一句,Java 现在在 java.util.concurrent.locks 中具有不可重入锁。

The answer is not efficiency. Non-reentrant mutexes lead to better code.

Example: A::foo() acquires the lock. It then calls B::bar(). This worked fine when you wrote it. But sometime later someone changes B::bar() to call A::baz(), which also acquires the lock.

Well, if you don't have recursive mutexes, this deadlocks. If you do have them, it runs, but it may break. A::foo() may have left the object in an inconsistent state before calling bar(), on the assumption that baz() couldn't get run because it also acquires the mutex. But it probably shouldn't run! The person who wrote A::foo() assumed that nobody could call A::baz() at the same time - that's the entire reason that both of those methods acquired the lock.

The right mental model for using mutexes: The mutex protects an invariant. When the mutex is held, the invariant may change, but before releasing the mutex, the invariant is re-established. Reentrant locks are dangerous because the second time you acquire the lock you can't be sure the invariant is true any more.

If you are happy with reentrant locks, it is only because you have not had to debug a problem like this before. Java has non-reentrant locks these days in java.util.concurrent.locks, by the way.

是你 2024-07-14 13:28:04

正如 Dave Butenhof 本人所写

“递归的所有大问题中最大的一个互斥体是
他们鼓励你完全忘记你的锁定方案并且
范围。 这是致命的。 邪恶的。 这就是“吃线者”。 你持有锁的目的是
绝对是最短的时间。 时期。 总是。 如果你打电话
仅仅因为您不知道它已被持有而被锁定的东西,或者
因为你不知道被调用者是否需要互斥锁,那么你就是
持有时间太长。 您将霰弹枪瞄准您的应用程序并且
扣动扳机。 您可能开始使用线程来获取
并发; 但你刚刚阻止了并发。”

As written by Dave Butenhof himself:

"The biggest of all the big problems with recursive mutexes is that
they encourage you to completely lose track of your locking scheme and
scope. This is deadly. Evil. It's the "thread eater". You hold locks for
the absolutely shortest possible time. Period. Always. If you're calling
something with a lock held simply because you don't know it's held, or
because you don't know whether the callee needs the mutex, then you're
holding it too long. You're aiming a shotgun at your application and
pulling the trigger. You presumably started using threads to get
concurrency; but you've just PREVENTED concurrency."

幸福不弃 2024-07-14 13:28:04

正确的使用心理模型
互斥体:互斥体保护
不变。

为什么您确定这确实是使用互斥体的正确思维模型?
我认为正确的模型是保护数据而不是不变量。

即使在单线程应用程序中,保护不变量的问题也存在,并且与多线程和互斥体没有共同之处。

此外,如果您需要保护不变量,您仍然可以使用从不递归的二进制信号量。

The right mental model for using
mutexes: The mutex protects an
invariant.

Why are you sure that this is really right mental model for using mutexes?
I think right model is protecting data but not invariants.

The problem of protecting invariants presents even in single-threaded applications and has nothing common with multi-threading and mutexes.

Furthermore, if you need to protect invariants, you still may use binary semaphore wich is never recursive.

╰ゝ天使的微笑 2024-07-14 13:28:04

递归互斥体有用的一个主要原因是在同一线程多次访问方法的情况下。 例如,如果互斥锁保护银行账户提款,那么如果还存在与该提款相关的费用,则必须使用相同的互斥锁。

One main reason that recursive mutexes are useful is in case of accessing the methods multiple times by the same thread. For example, say if mutex lock is protecting a bank A/c to withdraw, then if there is a fee also associated with that withdrawal, then the same mutex has to be used.

指尖凝香 2024-07-14 13:28:04

递归互斥体唯一好的用例是当一个对象包含多个方法时。 当任何方法修改了对象的内容时,因此必须锁定对象才能使状态再次一致。

如果方法使用其他方法(即:addNewArray()调用addNewPoint(),并用recheckBounds()完成),但任何这些函数本身都需要锁定互斥体,那么递归互斥体是双赢的。

对于任何其他情况(解决只是错误的编码,甚至在不同的对象中使用它)显然是错误的!

The only good use case for recursion mutex is when an object contains multiple methods. When any of the methods modify the content of the object, and therefore must lock the object before the state is consistent again.

If the methods use other methods (ie: addNewArray() calls addNewPoint(), and finalizes with recheckBounds()), but any of those functions by themselves need to lock the mutex, then recursive mutex is a win-win.

For any other case (solving just bad coding, using it even in different objects) is clearly wrong!

汹涌人海 2024-07-14 13:28:04

恕我直言,大多数反对递归锁(我在 20 年的并发编程中 99.9% 的时间都使用递归锁)的争论都将递归锁的好坏与其他软件设计问题混为一谈,而这些问题是完全无关的。 举个例子,“回调”问题,它被详尽地阐述,没有任何多线程相关的观点,例如在书中 组件软件 - 超越面向对象编程

一旦出现一些控制反转(例如事件触发),您就会面临重入问题。 与是否涉及互斥体和线程无关。

class EvilFoo {
  std::vector<std::string> data;
  std::vector<std::function<void(EvilFoo&)> > changedEventHandlers;
public:
  size_t registerChangedHandler( std::function<void(EvilFoo&)> handler) { // ...  
  }
  void unregisterChangedHandler(size_t handlerId) { // ...
  }
  void fireChangedEvent() { 
    // bad bad, even evil idea!
    for( auto& handler : changedEventHandlers ) {
      handler(*this);
    }
  }
  void AddItem(const std::string& item) { 
    data.push_back(item);
    fireChangedEvent();
  }
};

现在,使用上面这样的代码,您可以获得所有错误情况,这些情况通常在递归锁的上下文中命名 - 只是没有任何错误情况。 事件处理程序一旦被调用就可以自行取消注册,这会导致简单编写的 fireChangedEvent() 中出现错误。 或者它可能会调用 EvilFoo 的其他成员函数,从而导致各种问题。 根本原因是重新进入。
最糟糕的是,这甚至可能不是很明显,因为它可能会在整个事件链上触发事件,最终我们又回到了 EvilFoo(非本地)。

所以,重入才是根本问题,而不是递归锁。
现在,如果您觉得使用非递归锁更安全,那么这样的错误会如何表现出来呢? 每当发生意外重新进入时就会陷入僵局。
并带有递归锁? 同样,它会在没有任何锁的代码中体现出来。

因此,EvilFoo 的邪恶部分是事件及其实现方式,而不是递归锁。 对于初学者来说,fireChangedEvent() 需要首先创建 changedEventHandlers 的副本,并将其用于迭代。

经常讨论的另一个方面是锁首先应该做什么的定义:

  • 保护一段代码不被重新进入
  • 保护资源不被同时使用(被多个线程使用) )。

我进行并发编程的方式,我有后者的心理模型(保护资源)。 这是我擅长递归锁的主要原因。 如果某个(成员)函数需要锁定资源,它就会锁定。 如果它在执行其操作时调用另一个(成员)函数,并且该函数也需要锁定 - 它就会锁定。 而且我不需要“替代方法”,因为递归锁的引用计数与每个函数编写的内容完全相同:

void EvilFoo::bar() {
   auto_lock lock(this); // this->lock_holder = this->lock_if_not_already_locked_by_same_thread())
   // do what we gotta do
   
   // ~auto_lock() { if (lock_holder) unlock() }
}

一旦事件或类似的构造(访问者?!)开始发挥作用,我就会这样做不希望通过某种非递归锁来解决所有随之而来的设计问题。

IMHO, most arguments against recursive locks (which are what I use 99.9% of the time over like 20 years of concurrent programming) mix the question if they are good or bad with other software design issues, which are quite unrelated. To name one, the "callback" problem, which is elaborated on exhaustively and without any multithreading related point of view, for example in the book Component software - beyond Object oriented programming.

As soon as you have some inversion of control (e.g. events fired), you face re-entrance problems. Independent of whether there are mutexes and threading involved or not.

class EvilFoo {
  std::vector<std::string> data;
  std::vector<std::function<void(EvilFoo&)> > changedEventHandlers;
public:
  size_t registerChangedHandler( std::function<void(EvilFoo&)> handler) { // ...  
  }
  void unregisterChangedHandler(size_t handlerId) { // ...
  }
  void fireChangedEvent() { 
    // bad bad, even evil idea!
    for( auto& handler : changedEventHandlers ) {
      handler(*this);
    }
  }
  void AddItem(const std::string& item) { 
    data.push_back(item);
    fireChangedEvent();
  }
};

Now, with code like the above you get all error cases, which would usually be named in the context of recursive locks - only without any of them. An event handler can unregister itself once it has been called, which would lead to a bug in a naively written fireChangedEvent(). Or it could call other member functions of EvilFoo which cause all sorts of problems. The root cause is re-entrance.
Worst of all, this could not even be very obvious as it could be over a whole chain of events firing events and eventually we are back at our EvilFoo (non- local).

So, re-entrance is the root problem, not the recursive lock.
Now, if you felt more on the safe side using a non-recursive lock, how would such a bug manifest itself? In a deadlock whenever unexpected re-entrance occurs.
And with a recursive lock? The same way, it would manifest itself in code without any locks.

So the evil part of EvilFoo are the events and how they are implemented, not so much a recursive lock. fireChangedEvent() would need to first create a copy of changedEventHandlers and use that for iteration, for starters.

Another aspect often coming into the discussion is the definition of what a lock is supposed to do in the first place:

  • Protect a piece of code from re-entrance
  • Protect a resource from being used concurrently (by multiple threads).

The way I do my concurrent programming, I have a mental model of the latter (protect a resource). This is the main reason why I am good with recursive locks. If some (member) function needs locking of a resource, it locks. If it calls another (member) function while doing what it does and that function also needs locking - it locks. And I don't need an "alternate approach", because the ref-counting of the recursive lock is quite the same as if each function wrote something like:

void EvilFoo::bar() {
   auto_lock lock(this); // this->lock_holder = this->lock_if_not_already_locked_by_same_thread())
   // do what we gotta do
   
   // ~auto_lock() { if (lock_holder) unlock() }
}

And once events or similar constructs (visitors?!) come into play, I do not hope to get all the ensuing design problems solved by some non-recursive lock.

深海夜未眠 2024-07-14 13:28:04

非递归互斥体有什么用处?

当您在执行某些操作之前必须确保互斥锁已解锁时,它们绝对是很好的选择。 这是因为pthread_mutex_unlock可以保证互斥体只有在非递归的情况下才被解锁。

pthread_mutex_t      g_mutex;

void foo()
{
    pthread_mutex_lock(&g_mutex);
    // Do something.
    pthread_mutex_unlock(&g_mutex);

    bar();
}

如果 g_mutex 是非递归的,则上面的代码保证在互斥体解锁的情况下调用 bar()

因此,如果 bar() 碰巧是一个未知的外部函数,该函数很可能会执行某些操作,从而导致另一个线程尝试获取相同的互斥锁,从而消除了死锁的可能性。 这种情况在基于线程池构建的应用程序和分布式应用程序中并不罕见,其中进程间调用可能会产生一个新线程,而客户端程序员甚至没有意识到这一点。 在所有此类场景中,最好仅在释放锁后才调用所述外部函数。

如果g_mutex是递归的,则根本没有办法在调用之前确保它已解锁。

What are non-recursive mutexes good for?

They are absolutely good when you have to make sure the mutex is unlocked before doing something. This is because pthread_mutex_unlock can guarantee that the mutex is unlocked only if it is non-recursive.

pthread_mutex_t      g_mutex;

void foo()
{
    pthread_mutex_lock(&g_mutex);
    // Do something.
    pthread_mutex_unlock(&g_mutex);

    bar();
}

If g_mutex is non-recursive, the code above is guaranteed to call bar() with the mutex unlocked.

Thus eliminating the possibility of a deadlock in case bar() happens to be an unknown external function which may well do something that may result in another thread trying to acquire the same mutex. Such scenarios are not uncommon in applications built on thread pools, and in distributed applications, where an interprocess call may spawn a new thread without the client programmer even realising that. In all such scenarios it's best to invoke the said external functions only after the lock is released.

If g_mutex was recursive, there would be simply no way to make sure it is unlocked before making a call.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文