是什么让boost :: shared_mutex如此缓慢

时间:2019-06-26 00:41:53

标签: c++ boost boost-thread

我使用Google基准测试进行了以下3次测试,结果使我感到惊讶,因为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获得足够的信息,因此希望在这里有所帮助。

谢谢

1 个答案:

答案 0 :(得分:6)

我不知道标准库/ boost / etc如何的细节。实现方式有所不同,尽管标准库版本似乎更快(,恭喜,无论谁写的)

因此,我将尝试从理论上解释各种互斥锁类型之间的速度差异,这将解释为什么共享互斥锁(应该)变慢的原因。

原子自旋锁

更多-因此,作为一项学术练习,请考虑最简单的线程安全的“类似于互斥体”的实现:简单的原子自旋锁。

从本质上讲,这不过是std::atomic<bool>std::atomic_flag。初始化为false。要“锁定”互斥锁,您只需在循环中执行原子比较和交换操作,直到获得错误的值(即,在将其原子设置为true之前,先前的值为false)。

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();

但是,由于此构造的性质,我们将其称为“不公平”互斥体,因为获取锁的线程顺序不一定是他们开始尝试锁定锁的顺序。也就是说,在争用较高的情况下,线程可能会尝试锁定而永远不会成功,因为其他线程会更幸运。这对时间非常敏感。想象一下音乐椅。

因此,尽管它的功能类似于互斥锁,但并不是我们认为的“互斥锁”。

Mutex

互斥锁可以被认为是建立在原子自旋锁之上的(尽管它通常不是这样实现的,因为它们通常是在操作系统和/或硬件的支持下实现的。)

本质上,互斥锁比原子自旋锁高出一步,因为它有一个等待线程队列。这使它“公平”,因为锁定获取的顺序(或多或少)与锁定尝试的顺序相同。

如果您已经注意到,如果您运行sizeof(std::mutex),它可能会比您预期的要大。在我的平台上是40字节。多余的空间用于保存状态信息,特别是包括一些访问每个互斥锁的锁定队列的方式。

当您尝试锁定互斥锁时,它会执行一些低级线程安全操作以对互斥锁的状态信息进行线程安全访问(例如原子自旋锁),检查互斥锁的状态,然后将线程添加到锁定队列,并且(通常)使线程在等待时进入睡眠状态,这样就不会浪费宝贵的CPU时间。低级线程安全操作(例如,原子自旋锁)在线程进入睡眠的同时原子地释放(通常,这是有效地需要OS或硬件支持的地方)。

通过执行低级线程安全操作(例如原子自旋锁),从队列中弹出下一个等待线程并唤醒它来执行解锁。现在,已唤醒的线程“拥有”该锁。冲洗并重复。

共享的互斥锁

共享互斥锁使这一概念更进一步。它可以由单个线程拥有读/写权限(如普通的互斥锁),也可以由多个线程拥有只读权限(显然,无论如何,这取决于程序员,以确保其安全性)。

因此,除了唯一的所有权队列(如普通互斥锁)之外,它还具有共享的所有权状态。共享所有权状态可以简单地是当前具有共享所有权的线程数的计数。如果检查sizeof(std::shared_mutex),通常会发现它甚至比std::mutex大。例如,在我的系统上,它是56个字节。

因此,当您锁定共享互斥锁时,它必须执行普通互斥锁所做的所有事情,而且还必须验证其他一些内容。例如,如果您尝试唯一锁定,则必须验证没有共享所有者。并且,当您尝试共享锁定时,必须验证没有唯一的所有者。

因为我们通常希望互斥锁是“公平的”,所以一旦队列中有一个唯一的锁,即使将来可能正在共享(即非唯一)锁,将来也要尝试共享锁而不是获取锁通过几个线程。这是为了确保共享所有者不会“欺负”想要唯一所有权的线程。

但是这也是另一回事:排队逻辑必须确保在共享所有权期间共享储物柜永远不要放入空队列中(因为它应该立即成功并成为另一个共享所有者)。

此外,如果有一个唯一的储物柜,然后是一个共享的储物柜,再是一个唯一的储物柜,则它必须(大致)保证获得顺序。因此,锁定队列中的每个条目还需要一个表示其用途的标志(即共享与唯一)。

然后我们考虑唤醒逻辑。解锁共享互斥锁时,逻辑会因互斥锁的当前所有权类型而异。如果解锁线程具有唯一所有权或是最后一个共享所有者,则它可能必须从队列中唤醒某些线程。它将唤醒队列中请求共享所有权的所有线程,或者唤醒队列中请求唯一所有权的单个线程。

您可以想象,所有这些关于谁因什么原因以及如何变化而锁定的额外逻辑不仅取决于当前所有者,而且取决于队列的内容,这可能会使其速度变慢。希望您阅读的频率比编写的频率高得多,因此可以让许多共享所有者同时运行,从而减轻了协调所有这些的性能影响。