为什么在Visual C++中,std::mutex比std::shared_mutex差很多?

13
在 Visual Studio 2022 中以 Release 模式运行了以下命令:
#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更快。


1
我在我的 Windows 1.2 Ghz 笔记本电脑上编写了类似于你的测量代码,并且简单的自旋锁在循环中严格工作 24 ns,std::mutex 75-85 ns,std::shared_mutex 42-45 ns - Arty
1
@Arty,比自旋锁慢两倍--这是预期的互斥量性能。你的自旋锁在退出时使用storememory_order_release,你只需要释放它。但是互斥量将执行一个交换操作(很可能是exchange),以查看是否需要通知一些等待线程。 (x86有带有memory_order_release的廉价存储器,但任何exchange都不便宜,即使是_relaxed - Alex Guteniev
你看过代码了吗?你的问题具体是什么?你是否比较了两者支持的功能以解释它们之间的时间差异?还是大小差异,如果你有一个句柄/主体分离,这并不奇怪。 - Ulrich Eckhardt
1个回答

25

TL;DR:不幸的是,向后兼容性和ABI兼容性问题的组合使得std::mutex在下一次ABI中断之前都不太好。另一方面,std::shared_mutex则很好。


一个体面的std::mutex实现会尝试使用原子操作来获取锁,如果忙,则可能会在读循环中旋转(在x86上进行一些pause),最终将诉诸于OS等待。
有几种实现这样的std::mutex的方法:
  1. 直接委托给执行所有上述操作的相应OS APIs。
  2. 自行进行旋转和原子操作,在需要OS等待时才调用OS APIs。
当然,第一种方式更容易实现,更易于调试,更健壮。因此,它似乎是可行的方式。候选API如下:
  • CRITICAL_SECTION APIs。递归互斥锁,缺乏静态初始化程序并且需要显式销毁。
  • SRWLOCK。非递归共享互斥锁,具有静态初始化程序并且不需要显式销毁。
  • WaitOnAddress。等待特定变量被更改的API,类似于Linux futex
这些基元具有OS版本要求:
  • CRITICAL_SECTION从我认为Windows 95开始存在,尽管Windows 9x中不存在TryEnterCriticalSection,但是自Windows Vista以来,可以使用CRITICAL_SECTIONCONDITION_VARIABLE一起使用,其中包括CONDITION_VARIABLE本身。
  • SRWLOCK从Windows Vista开始存在,但TryAcquireSRWLockExclusive仅在Windows 7中存在,因此只能直接在Windows 7中实现std::mutex
  • WaitOnAddress自Windows 8以来添加。
在添加std::mutex时,需要使用Visual Studio C++库支持Windows XP,因此采用了自己的实现。实际上,std::mutex和其他同步内容被委托给ConCRT(并行运行时)。
对于Visual Studio 2015,实现已切换为使用最佳可用机制,即从Windows 7开始使用SRWLOCK,从Windows Vista开始使用CRITICAL_SECTION。ConCRT不是最佳机制,但仍用于Windows XP和2003。通过将带有虚函数的类的放置new到由std::mutex和其他原语提供的缓冲区中来实现多态性。
请注意,由于运行时检测、放置new以及无法仅具有静态初始化程序的pre-Window 7实现,此实现违反了std::mutex必须是constexpr的要求。
随着时间的推移,对Windows XP的支持终于在VS 2019中停止,对Windows Vista的支持在VS 2022中停止,更改是为了避免使用ConCRT,计划避免甚至运行时检测SRWLOCK(披露:我贡献了这些PR)。尽管如此,由于VS 2015到VS 2022的ABI兼容性,不可能简化std::mutex实现以避免将具有虚函数的类放置在其中。
更令人遗憾的是,尽管SRWLOCK具有静态初始化程序,但所述兼容性防止了具有constexpr互斥量:我们必须在那里放置新的实现。不可能避免放置new,并使实现直接构造在std::mutex内部,因为std::mutex必须是标准布局类(请参见为什么std::mutex是标准布局类?)。
因此,大小开销来自ConCRT互斥量的大小。
运行时开销来自调用链:
  • 库函数调用以到达标准库实现
  • 虚函数调用以到达基于SRWLOCK的实现
  • 最后是Windows API调用。
由于标准库DLL使用了/guard:cf,虚函数调用比通常更昂贵。

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


std::shared_mutex旨在支持从Windows 7开始的系统。因此,它直接使用SRWLOCK

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

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

std::shared_mutex构造函数可以是constexpr的,因为SRWLOCK不需要动态初始化程序,但标准禁止自愿将constexpr添加到标准类中。


1
因此,在Windows上,使用共享互斥量替换std互斥量是有意义的,并且相对未来的兼容性较好。 - Yakk - Adam Nevraumont
3
@Yakk-AdamNevraumont,是的。它有可能在未来变得无用,但不太可能会造成伤害。然而,如果您将其与condition_variable一起使用,则需要使用condition_variable_any来与shared_mutex配对,因为没有专门针对shared_mutexcondition_variable - Alex Guteniev
1
这是一个非常有启发性的问答,Alex,谢谢!我注意到你在问题中打印了两种互斥类型的 sizeof。我猜测,shared_mutex 的大小是否为 8,因为它只包含指向共享控制块的指针? - Ted Lyngmo
1
@TedLyngmo,我已经编辑了答案来涵盖这一点。没有共享控制块。SRWLOCK本身的大小与指针相同(尽管在内部它不是指针)。shared_mutex只是按值包含SRWLOCK - Alex Guteniev
1
TryEnterCriticalSection不仅适用于W2K,它只适用于NT - Cody Gray

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接