为什么 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_mutex
比std::mutex
更快,严格来说是其特征的一个子集
TL;DR: 向后兼容性和 ABI 兼容性问题的不幸组合使得 std::mutex
在下一次 ABI 中断之前变得糟糕。 OTOH,std::shared_mutex
很好。
std::mutex
的体面实现会尝试使用原子操作来获取锁,如果忙,可能会尝试在读取循环中旋转(在 x86 上有一些 pause
),并且最终还是会求助于OS等等。
有几种方法可以实现 std::mutex
:
- 直接委托给相应的 OS API 执行上述所有操作。
- 自己做自旋和原子操作,只调用 OS APIs OS 等待。
当然,第一种方式更容易实现,调试更友好,更健壮。所以这似乎是要走的路。候选人 API 是:
CRITICAL_SECTION
API秒。一个递归互斥锁,缺少静态初始化器并且需要显式销毁
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_SECTION
与 CONDITION_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.
运行 以下 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_mutex
比std::mutex
更快,严格来说是其特征的一个子集
TL;DR: 向后兼容性和 ABI 兼容性问题的不幸组合使得 std::mutex
在下一次 ABI 中断之前变得糟糕。 OTOH,std::shared_mutex
很好。
std::mutex
的体面实现会尝试使用原子操作来获取锁,如果忙,可能会尝试在读取循环中旋转(在 x86 上有一些 pause
),并且最终还是会求助于OS等等。
有几种方法可以实现 std::mutex
:
- 直接委托给相应的 OS API 执行上述所有操作。
- 自己做自旋和原子操作,只调用 OS APIs OS 等待。
当然,第一种方式更容易实现,调试更友好,更健壮。所以这似乎是要走的路。候选人 API 是:
CRITICAL_SECTION
API秒。一个递归互斥锁,缺少静态初始化器并且需要显式销毁SRWLOCK
。具有静态初始化程序且不需要显式销毁的非递归共享互斥锁WaitOnAddress
. An API to wait on particular variable to be changed, similar to Linuxfutex
.
这些基元有 OS 版本要求:
CRITICAL_SECTION
存在于我认为 Windows 95,虽然TryEnterCriticalSection
在 Windows 9x 中不存在,但是CRITICAL_SECTION
与CONDITION_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
.
部分运行时开销是由于 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.