是什么让 boost::shared_mutex 这么慢
What makes the boost::shared_mutex so slow
我在 3 次测试后使用 google 基准测试 运行,结果让我感到惊讶,因为 RW 锁比释放模式下的简单互斥锁慢 ~4 倍。 (并且比调试模式下的简单互斥锁慢 10 倍)
void raw_access() {
(void) (gp->a + gp->b);
}
void mutex_access() {
std::lock_guard<std::mutex> guard(g_mutex);
(void) (gp->a + gp->b);
}
void rw_mutex_access() {
boost::shared_lock<boost::shared_mutex> l(g_rw_mutex);
(void) (gp->a + gp->b);
}
结果是:
2019-06-26 08:30:45
Running ./perf
Run on (4 X 2500 MHz CPU s)
CPU Caches:
L1 Data 32K (x2)
L1 Instruction 32K (x2)
L2 Unified 262K (x2)
L3 Unified 4194K (x1)
Load Average: 5.35, 3.22, 2.57
-----------------------------------------------------------
Benchmark Time CPU Iterations
-----------------------------------------------------------
BM_RawAccess 1.01 ns 1.01 ns 681922241
BM_MutexAccess 18.2 ns 18.2 ns 38479510
BM_RWMutexAccess 92.8 ns 92.8 ns 7561437
我没有通过 google 获得足够的信息,所以希望在这里得到一些帮助。
谢谢
不知道具体的标准如何library/boost/etc。实现有所不同,尽管标准库版本似乎更快 (恭喜,无论是谁写的).
因此,我将尝试在理论层面上解释各种互斥锁类型之间的速度差异,这将解释为什么共享互斥锁 (应该) 更慢。
原子自旋锁
More-so 作为学术练习,考虑最简单的 thread-safety "mutex-like" 实现:一个简单的原子自旋锁。
本质上,这只不过是一个std::atomic<bool>
或一个std::atomic_flag
。它被初始化为假。对于 "lock" 互斥锁,您只需在循环中执行原子 compare-and-exchange 操作,直到获得假值(即,在自动将其设置为真之前,先前的值为假)。
std::atomic_flag flag = ATOMIC_FLAG_INIT;
// lock it by looping until we observe a false value
while (flag.test_and_set()) ;
// do stuff under "mutex" lock
// unlock by setting it back to false state
flag.clear();
但是,由于这种构造的性质,我们称之为 "unfair" 互斥体,因为获取锁的线程顺序不一定是它们开始尝试锁定它的顺序。也就是说,在竞争激烈的情况下,一个线程可能会尝试锁定但永远不会成功,因为其他线程更幸运。很timing-sensitive。想象一下音乐椅。
因此,虽然它的功能类似于互斥锁,但它并不是我们认为的 "mutex"。
互斥
互斥量可以被认为是建立在原子自旋锁之上(尽管它通常不会这样实现,因为它们通常是在操作系统 and/or 硬件的支持下实现的)。
本质上,互斥体比原子自旋锁高出一步,因为它有一个等待线程队列。这允许它是 "fair" 因为锁获取的顺序(或多或少)与锁定尝试的顺序相同。
如果您已经注意到,如果您 运行 sizeof(std::mutex)
它可能比您预期的要大一些。在我的平台上它是 40 个字节。额外的 space 用于保存状态信息,特别是包括一些访问每个单独互斥体的锁定队列的方法。
当您尝试锁定互斥量时,它会执行一些 low-level thread-safety 操作以 thread-safe 访问互斥量的状态信息(例如原子自旋锁),检查状态互斥量,将您的线程添加到锁定队列,并且(通常)在您等待时让您的线程休眠,这样您就不会浪费宝贵的 CPU 时间。 low-level thread-safety 操作(例如原子自旋锁)在线程进入休眠的同时以原子方式释放(这通常是 OS 或需要硬件支持以提高效率的地方) .
解锁是通过执行low-level thread-safe操作(例如原子自旋锁),从队列中弹出下一个等待线程,并将其唤醒。现在已经被唤醒的线程"owns" 锁。漂洗并重复。
共享互斥体
共享互斥使这个概念更进一步。它可以由单个线程拥有 read/write 权限(如普通互斥锁),或由多个线程拥有 read-only 权限(语义上,无论如何 - 由程序员来确保它的安全)。
因此,除了唯一的所有权队列(像普通的互斥体)之外,它还有一个共享的所有权状态。共享所有权状态可以只是当前具有共享所有权的线程数的计数。如果您检查 sizeof(std::shared_mutex)
,您会发现它通常比 std::mutex
还要大。例如,在我的系统上,它是 56 个字节。
因此,当您锁定一个共享互斥量时,它必须执行普通互斥量所做的所有操作,但还必须验证其他一些内容。例如,如果您尝试进行唯一锁定,则必须验证没有共享所有者。当您尝试共享锁定时,它必须验证没有唯一所有者。
因为我们通常希望互斥体是 "fair",一旦队列中有唯一锁,未来的共享锁尝试必须排队而不是获取锁,即使它当前可能正在共享(即 non-unique) 被多个线程锁定。这是为了确保共享所有者不会 "bully" 想要唯一所有权的线程。
但这也适用于其他方式:排队逻辑必须确保共享储物柜在共享所有权期间永远不会被放入空队列(因为它应该立即成功并成为另一个共享所有者)。
此外,如果有一个唯一储物柜,然后是一个共享储物柜,然后是一个唯一储物柜,它必须(大致)保证获取顺序。所以锁队列中的每个条目也需要一个标志来表示它的目的(即共享与唯一)。
然后我们想到了wake-up逻辑。解锁共享互斥锁时,逻辑会因互斥锁的当前所有权类型而异。如果解锁线程具有唯一所有权或者是最后一个共享所有者,它可能必须从队列中唤醒一些线程。它会唤醒队列前面的 all 个线程共享所有权,或请求唯一所有权的队列前端的 单个 线程。
正如您所想象的,所有这些关于谁出于什么原因锁定以及它如何变化的额外逻辑不仅取决于当前所有者而且还取决于队列的内容使得这可能会慢很多。希望您阅读的频率明显高于写入的频率,因此您可以同时拥有多个共享所有者 运行ning,从而减轻协调所有这些的性能损失。
我在 3 次测试后使用 google 基准测试 运行,结果让我感到惊讶,因为 RW 锁比释放模式下的简单互斥锁慢 ~4 倍。 (并且比调试模式下的简单互斥锁慢 10 倍)
void raw_access() {
(void) (gp->a + gp->b);
}
void mutex_access() {
std::lock_guard<std::mutex> guard(g_mutex);
(void) (gp->a + gp->b);
}
void rw_mutex_access() {
boost::shared_lock<boost::shared_mutex> l(g_rw_mutex);
(void) (gp->a + gp->b);
}
结果是:
2019-06-26 08:30:45
Running ./perf
Run on (4 X 2500 MHz CPU s)
CPU Caches:
L1 Data 32K (x2)
L1 Instruction 32K (x2)
L2 Unified 262K (x2)
L3 Unified 4194K (x1)
Load Average: 5.35, 3.22, 2.57
-----------------------------------------------------------
Benchmark Time CPU Iterations
-----------------------------------------------------------
BM_RawAccess 1.01 ns 1.01 ns 681922241
BM_MutexAccess 18.2 ns 18.2 ns 38479510
BM_RWMutexAccess 92.8 ns 92.8 ns 7561437
我没有通过 google 获得足够的信息,所以希望在这里得到一些帮助。
谢谢
不知道具体的标准如何library/boost/etc。实现有所不同,尽管标准库版本似乎更快 (恭喜,无论是谁写的).
因此,我将尝试在理论层面上解释各种互斥锁类型之间的速度差异,这将解释为什么共享互斥锁 (应该) 更慢。
原子自旋锁
More-so 作为学术练习,考虑最简单的 thread-safety "mutex-like" 实现:一个简单的原子自旋锁。
本质上,这只不过是一个std::atomic<bool>
或一个std::atomic_flag
。它被初始化为假。对于 "lock" 互斥锁,您只需在循环中执行原子 compare-and-exchange 操作,直到获得假值(即,在自动将其设置为真之前,先前的值为假)。
std::atomic_flag flag = ATOMIC_FLAG_INIT;
// lock it by looping until we observe a false value
while (flag.test_and_set()) ;
// do stuff under "mutex" lock
// unlock by setting it back to false state
flag.clear();
但是,由于这种构造的性质,我们称之为 "unfair" 互斥体,因为获取锁的线程顺序不一定是它们开始尝试锁定它的顺序。也就是说,在竞争激烈的情况下,一个线程可能会尝试锁定但永远不会成功,因为其他线程更幸运。很timing-sensitive。想象一下音乐椅。
因此,虽然它的功能类似于互斥锁,但它并不是我们认为的 "mutex"。
互斥
互斥量可以被认为是建立在原子自旋锁之上(尽管它通常不会这样实现,因为它们通常是在操作系统 and/or 硬件的支持下实现的)。
本质上,互斥体比原子自旋锁高出一步,因为它有一个等待线程队列。这允许它是 "fair" 因为锁获取的顺序(或多或少)与锁定尝试的顺序相同。
如果您已经注意到,如果您 运行 sizeof(std::mutex)
它可能比您预期的要大一些。在我的平台上它是 40 个字节。额外的 space 用于保存状态信息,特别是包括一些访问每个单独互斥体的锁定队列的方法。
当您尝试锁定互斥量时,它会执行一些 low-level thread-safety 操作以 thread-safe 访问互斥量的状态信息(例如原子自旋锁),检查状态互斥量,将您的线程添加到锁定队列,并且(通常)在您等待时让您的线程休眠,这样您就不会浪费宝贵的 CPU 时间。 low-level thread-safety 操作(例如原子自旋锁)在线程进入休眠的同时以原子方式释放(这通常是 OS 或需要硬件支持以提高效率的地方) .
解锁是通过执行low-level thread-safe操作(例如原子自旋锁),从队列中弹出下一个等待线程,并将其唤醒。现在已经被唤醒的线程"owns" 锁。漂洗并重复。
共享互斥体
共享互斥使这个概念更进一步。它可以由单个线程拥有 read/write 权限(如普通互斥锁),或由多个线程拥有 read-only 权限(语义上,无论如何 - 由程序员来确保它的安全)。
因此,除了唯一的所有权队列(像普通的互斥体)之外,它还有一个共享的所有权状态。共享所有权状态可以只是当前具有共享所有权的线程数的计数。如果您检查 sizeof(std::shared_mutex)
,您会发现它通常比 std::mutex
还要大。例如,在我的系统上,它是 56 个字节。
因此,当您锁定一个共享互斥量时,它必须执行普通互斥量所做的所有操作,但还必须验证其他一些内容。例如,如果您尝试进行唯一锁定,则必须验证没有共享所有者。当您尝试共享锁定时,它必须验证没有唯一所有者。
因为我们通常希望互斥体是 "fair",一旦队列中有唯一锁,未来的共享锁尝试必须排队而不是获取锁,即使它当前可能正在共享(即 non-unique) 被多个线程锁定。这是为了确保共享所有者不会 "bully" 想要唯一所有权的线程。
但这也适用于其他方式:排队逻辑必须确保共享储物柜在共享所有权期间永远不会被放入空队列(因为它应该立即成功并成为另一个共享所有者)。
此外,如果有一个唯一储物柜,然后是一个共享储物柜,然后是一个唯一储物柜,它必须(大致)保证获取顺序。所以锁队列中的每个条目也需要一个标志来表示它的目的(即共享与唯一)。
然后我们想到了wake-up逻辑。解锁共享互斥锁时,逻辑会因互斥锁的当前所有权类型而异。如果解锁线程具有唯一所有权或者是最后一个共享所有者,它可能必须从队列中唤醒一些线程。它会唤醒队列前面的 all 个线程共享所有权,或请求唯一所有权的队列前端的 单个 线程。
正如您所想象的,所有这些关于谁出于什么原因锁定以及它如何变化的额外逻辑不仅取决于当前所有者而且还取决于队列的内容使得这可能会慢很多。希望您阅读的频率明显高于写入的频率,因此您可以同时拥有多个共享所有者 运行ning,从而减轻协调所有这些的性能损失。