从C++20 std::原子有等待和通知操作。通过is_always_lock_free,我们可以确保实现是无锁的。有了这些砖块,构建一个无锁互斥就不那么困难了。在琐碎的情况下,锁定将是一个比较交换操作,或者如果互斥锁被锁定则是一个等待。这里最大的问题是它是否值得。如果我能创建这样一个实现,那么STL版本很可能会更好更快。然而,我仍然记得,当我在2016年看到QMutex的表现优于std::mutex时,我是多么惊讶。那么,你认为我应该尝试这样的实现吗?还是std::mutex的当前实现已经足够成熟,可以进行远远超出这些技巧的优化?
UPDATE我的措辞不是最好的,我的意思是实现可以在愉快的路径上无锁(从非锁定状态锁定)。当然,如果我们需要等待获取锁,我们应该被阻止并重新安排。最有可能的是,在大多数平台上,原子::wait不是由简单的spinlock实现的(现在让我们忽略角落的情况),所以它基本上实现了与mutex::lock相同的功能。所以基本上,如果我实现这样一个类,它将执行与std::mutex完全相同的操作(同样在大多数流行的平台上)。这意味着STL可以在支持这些技巧的平台上的互斥实现中使用相同的技巧。就像这个自旋锁,但我会使用原子等待而不是旋转。我应该相信我的STL实现是他们做到的吗?
无锁互斥是一个矛盾。
您可以用无锁构建块构建锁,事实上,无论是用asm还是用std::atomic
手工编写,这都是正常的做法。
但是根据定义,整个锁定算法不是无锁定的。(https://en.wikipedia.org/wiki/Non-blocking_algorithm)。整个要点是在一个线程处于关键部分时阻止其他线程向前推进,即使它在关键部分时不幸休眠。
我的意思是,实现可以在愉快的路径上无锁(从未锁定状态锁定)
std::mutex::lock()
也是这样:如果不必阻止,它就不会阻止如果有线程在等待锁,则可能需要进行类似futex(FUTEX_WAIT_PRIVATE)
的系统调用。但是使用std::notify
的实现也是如此。
也许你还不明白";"无锁定";意味着:无论其他线程在做什么,它都不会阻塞。仅此而已。这并不意味着";在简单/容易的情况下更快";。对于复杂的算法(例如队列),如果循环缓冲区已满,则放弃并阻塞通常更快,而不是在简单的情况下增加开销以允许其他线程"阻塞";"辅助";或者取消卡住的部分操作。(循环缓冲队列中的无锁进度保证)
从std::atomic
中推出自己的std::mutex
没有固有的优势无论哪种方式,编译器生成的机器代码都必须做大致相同的事情,我希望快速路径大致相同。唯一的区别是在已经锁定的情况下选择该怎么做。(但是,设计一种方法来避免在没有等待程序的情况下调用notify
可能很棘手,这是std::mutex
在glibc/phreads实现Linux中实际管理的。)
(我假设库函数调用的开销与使用互斥的原子RMW的成本相比可以忽略不计。将其内联到代码中是一个非常小的优势。)
互斥实现可以针对某些用例进行调优,具体取决于它在休眠前的旋转等待时间(使用像futex
这样的操作系统辅助机制,使其他线程能够在释放锁时唤醒它),以及旋转等待部分的指数回退。
如果std::mutex
在您关心的硬件上对您的应用程序表现不佳,那么值得考虑一种替代方案。尽管IDK确切地说明了你将如何衡量它是否有效。也许如果你能弄清楚它决定睡觉
是的,你可以考虑使用std::atomic
,因为现在有了一种便携式机制,有望揭示一种回归操作系统辅助睡眠/唤醒机制(如futex
)的方法。不过,您仍然希望在spin-wait循环中手动使用x86_mm_pause()
等系统特定的东西,因为我认为C++没有任何等效于Rust的std::hint::spin_loop()
的东西,实现可以用来公开x86pause
指令之类的东西,该指令用于spin循环的主体。(请参阅通过内联汇编操作内存的锁定:这些注意事项,以及旋转只读而不是垃圾邮件式的原子RMW尝试。此外,还可以了解x86汇编语言中旋转锁的必要部分,无论是否使用C++编译器为您生成机器代码,这些部分都是相同的。)
另请参阅https://rigtorp.se/spinlock/re:用std::atomic在C++中实现互斥。
Linux/libstdc++通知/等待行为
我在Arch Linux(glibc 2.33)上测试了当系统需要等待很长时间时,std::wait
会产生什么
-
std::mutex
锁定无争用快速路径,解锁无等待程序:零系统调用,纯用户空间原子操作。值得注意的是,它能够检测到解锁时没有等待程序,因此它不会进行FUTEX_WAKE
系统调用(否则,这可能是获取和释放在该内核L1d缓存中仍然很热的未受控制互斥体的一百倍。) -
std::mutex
上的lock()
已锁定:仅为futex(0x55be9461b0c0, FUTEX_WAIT_PRIVATE, 2, NULL)
系统调用。在那之前,用户空间可能有些旋转;我没有用GDB一步到位,但如果是这样的话,可能是用pause
指令。 -
std::mutex
unlock()
与服务员:使用futex(0x55ef4af060c0, FUTEX_WAKE_PRIVATE, 1) = 1
。(在原子RMW之后,IIRC;不确定为什么它不使用发布存储。) -
std::notify_one
:即使没有服务员,也始终是futex(address, FUTEX_WAKE, 1)
,因此在解锁锁时,如果没有服务员,则由您来避免。 -
std::wait
:在用户空间中旋转几次,包括在futex(addr, FUTEX_WAIT, old_val, NULL)
之前进行4次sched_yield()
调用。
注意wait
/notify
函数使用FUTEX_WAIT
而不是FUTEX_WAIT_PRIVATE
:这些函数应该在共享内存上的进程之间工作。futex(2)
手册页说_PRIVATE
版本(仅适用于单个进程的线程)允许一些额外的优化。
我不知道其他系统,尽管我听说Windows/MSVC上的一些类型的锁必须很糟糕(即使在快速路径上也总是一个系统调用)才能与一些ABI选项向后兼容,或者其他什么。就像std::lock_guard
在MSVC上可能很慢,但std::unique_lock
不是吗?
测试代码:
#include <atomic>
#include <thread>
#include <unistd.h> // for quick & dirty sleep and usleep. TODO: port to non-POSIX.
#include <mutex>
#if 1 // test std::atomic
//std::atomic_unsigned_lock_free gvar;
//std::atomic_uint_fast_wait_t gvar;
std::atomic<unsigned> gvar;
void waiter(){
volatile unsigned sink;
while (1) {
sink = gvar;
gvar.wait(sink); // block until it's not the old value.
// on Linux/glibc, 4x sched_yield(), then futex(0x562c3c4881c0, FUTEX_WAIT, 46, NULL ) or whatever the current counter value is
}
}
void notifier(){
while(1){
sleep(1);
gvar.store(gvar.load(std::memory_order_relaxed)+1, std::memory_order_relaxed);
gvar.notify_one();
}
}
#else
std::mutex mut;
void waiter(){
unsigned sink = 0;
while (1) {
mut.lock(); // Linux/glibc2.33 - just a futex system call, maybe some spinning in user-space first. But no sched_yield
mut.unlock();
sink++;
usleep(sink); // give the other thread plenty of time to take the lock, so we don't get it twice.
}
}
void notifier(){
while(1){
mut.lock();
sleep(1);
mut.unlock();
}
}
#endif
int main(){
std::thread t (waiter); // comment this to test the no-contention case
notifier();
// loops forever: kill it with control-C
}
使用g++ -Og -std=gnu++20 notifywait.cpp -pthread
编译,使用strace -f ./a.out
运行以查看系统调用。(每秒几次或几次,因为我睡得很好。)
如果用户空间中有任何旋转等待,与1秒的睡眠间隔相比,它可以忽略不计,因此它需要大约一毫秒的CPU时间(包括启动)来运行19次迭代。(perf stat ./a.out
)
通常,您的时间最好花在减少所涉及的锁定量或争用量上,而不是试图优化锁本身锁定是一件极其重要的事情,并且许多工程人员已经为大多数用例对其进行了调优。
如果你正在使用自己的锁,你可能想用系统特定的东西弄脏你的手,因为这都是调优选择的问题。不同的系统不太可能对std::mutex
和wait
做出与Linux/glibc相同的调优选择。除非std::wait
在您关心的唯一系统上的重试策略恰好非常适合您的用例。
如果不首先调查std::mutex
在系统上的具体作用,例如单步执行已锁定案例的asm,看看它会进行什么重试,那么滚动自己的互斥对象是没有意义的。然后你会更好地知道你是否能做得更好。
您可能需要区分互斥锁(通常是与调度程序交互的睡眠锁)和旋转锁(它不会使当前线程进入睡眠状态,只有当不同CPU上的线程可能持有锁时才有意义)。
使用C++20原子,您肯定可以实现自旋锁,但这与std::mutex
无法直接比较,后者会使当前线程进入睡眠状态。互斥和自旋锁在不同的情况下都很有用。当成功时,自旋锁可能会更快——毕竟互斥实现可能包含一个自旋锁。它也是您可以在中断处理程序中获得的唯一一种锁(尽管这与用户级代码不太相关)。但是,如果长时间持有spinlock并且存在争用,则会浪费大量的CPU时间。