为什么 std::mutex 比 Visual C++ 中的 std::shared_mutex 差这么多?

Why is std::mutex so much worse than std::shared_mutex in Visual C++?

运行 以下 Visual Studio 2022 年发布模式:

#include <chrono>
#include <mutex>
#include <shared_mutex>
#include <iostream>

std::mutex mx;
std::shared_mutex smx;

constexpr int N = 100'000'000;

int main()
{
    auto t1 = std::chrono::steady_clock::now();
    for (int i = 0; i != N; i++)
    {
        std::unique_lock<std::mutex> l{ mx };
    }
    auto t2 = std::chrono::steady_clock::now();
    for (int i = 0; i != N; i++)
    {
        std::unique_lock<std::shared_mutex> l{ smx };
    }
    auto t3 = std::chrono::steady_clock::now();

    auto d1 = std::chrono::duration_cast<std::chrono::duration<double>>(t2 - t1);
    auto d2 = std::chrono::duration_cast<std::chrono::duration<double>>(t3 - t2);

    std::cout << "mutex " << d1.count() << "s;  shared_mutex " << d2.count() << "s\n";
    std::cout << "mutex " << sizeof(mx) << " bytes;  shared_mutex " << sizeof(smx) << " bytes \n";
}

输出结果如下:

mutex 2.01147s;  shared_mutex 1.32065s
mutex 80 bytes;  shared_mutex 8 bytes

为什么会这样?

意想不到的是,更丰富的特征std::shared_mutexstd::mutex更快,严格来说是其特征的一个子集

TL;DR: 向后兼容性和 ABI 兼容性问题的不幸组合使得 std::mutex 在下一次 ABI 中断之前变得糟糕。 OTOH,std::shared_mutex 很好。


std::mutex 的体面实现会尝试使用原子操作来获取锁,如果忙,可能会尝试在读取循环中旋转(在 x86 上有一些 pause),并且最终还是会求助于OS等等。

有几种方法可以实现 std::mutex:

  1. 直接委托给相应的 OS API 执行上述所有操作。
  2. 自己做自旋和原子操作,只调用 OS APIs OS 等待。

当然,第一种方式更容易实现,调试更友好,更健壮。所以这似乎是要走的路。候选人 API 是:

  • CRITICAL_SECTIONAPI秒。一个递归互斥锁,缺少静态初始化器并且需要显式销毁
  • SRWLOCK。具有静态初始化程序且不需要显式销毁的非递归共享互斥锁
  • WaitOnAddress. An API to wait on particular variable to be changed, similar to Linux futex.

这些基元有 OS 版本要求:

  • CRITICAL_SECTION 存在于我认为 Windows 95,虽然 TryEnterCriticalSection 在 Windows 9x 中不存在,但是 CRITICAL_SECTIONCONDITION_VARIABLE 自 Windows Vista 以来添加,与 CONDITION_VARIABLE 本身。
  • SRWLOCK从WindowsVista开始存在,但是TryAcquireSRWLockExclusive从Windows7开始存在,所以只能直接实现std::mutex从[=133开始=] 7.
  • WaitOnAddress 自 Windows 8.
  • 以来添加

添加std::mutex时,需要Windows XP由Visual Studio C++库支持,所以它是用自己做事来实现的。事实上,std::mutex 和其他同步内容已委托给 ConCRT (Concurrency Runtime)

对于 Visual Studio 2015,实施已切换为使用最佳可用机制,即 SRWLOCK 从 Windows 7 开始,CRITICAL_SECTION 在 Windows远景。 ConCRT 被证明不是最好的机制,但它仍然被用于 Windows XP 和 2003。多态性是通过将具有虚函数的 classes 新放置到 [= 提供的缓冲区中来实现的10=] 和其他原语。

请注意,此实现打破了 std::mutex 成为 constexpr 的要求,因为运行时检测、新放置以及 Window 7 之前的实现无法仅具有静态初始值设定项。

随着时间的推移,对 Windows XP 的支持最终在 VS 2019 中被删除,对 Windows Vista 的支持在 VS 2022 中被删除,所做的更改是为了避免使用 ConCRT,更改是计划避免运行时检测 SRWLOCK(披露:我贡献了这些 PR)。仍然由于 VS 2015 和 VS 2022 的 ABI 兼容性,无法简化 std::mutex 实现来避免所有这些将 classes 与虚拟函数放在一起。

更可悲的是,尽管 SRWLOCK 具有静态初始化程序,但上述兼容性阻止了 constexpr 互斥锁:我们必须在那里放置新的实现。不可能避免放置新的,并在 std::mutex 内部实现构造,因为 std::mutex 必须是标准布局 class(请参阅 )。

因此大小开销来自 ConCRT 互斥体的大小。

并且运行时开销来自调用链:

  • 库函数调用以获取标准库实现
  • 虚拟函数调用以实现基于 SRWLOCK 的实现
  • 终于WindowsAPI打电话了。

由于使用 /guard:cf.

构建标准库 DLL,虚拟函数调用比通常更昂贵

部分运行时开销是由于 std::mutex 填写所有权计数和锁定线程。尽管 SRWLOCK 不需要此信息。这是由于与 recursive_mutex 共享内部结构。额外的信息可能对调试有帮助,但确实需要时间来填写。


std::shared_mutex本来是为了支持Windows7开始的系统,所以直接用了SRWLOCK

std::shared_mutex的大小是SRWLOCK的大小。 SRWLOCK 与指针大小相同(尽管在内部它不是指针)。

它仍然涉及一些可以避免的开销:它调用C++运行时库,只是为了调用Windows API,而不是直接调用Windows API。不过,这看起来可以通过下一个 ABI 修复。

std::shared_mutex 构造函数可以是 constexpr,因为 SRWLOCK 不需要动态初始化器,但标准禁止自愿将 constexpr 添加到标准 classes.