避免多线程时std :: mutex的成本?

Fau*_*ase 11 c++ multithreading

假设我有一个可能会或可能不会产生多个线程的应用程序.是否有必要保护需要与std :: mutex有条件同步的操作,如下所示,或者锁是如此便宜以至于单线程时无关紧要?

#include <atomic>
#include <mutex>

std::atomic<bool> more_than_one_thread_active{false};

void operation_requiring_synchronization() {
    //...
}
void call_operation_requiring_synchronization() {
    if (more_than_one_thread_active) {
        static std::mutex mutex;
        std::lock_guard<std::mutex> lock(mutex);
        operation_requiring_synchronization();
    } else {
        operation_requiring_synchronization();
    }
}
Run Code Online (Sandbox Code Playgroud)

编辑

感谢所有回答和评论的人,非常有趣的讨论.

几点澄清:

应用程序处理输入块,并为每个块决定是以单线程还是并行或以其他方式并发方式处理它.不需要多线程是不可能的.

operation_requiring_synchronization()通常由几个插入到全球标准集装箱.

当应用程序独立于平台并且应该在各种平台和编译器(过去,现在和将来)下运行良好时,分析当然是困难的.

根据目前为止的讨论,我倾向于认为优化是值得的.

我也认为std::atomic<bool> more_than_one_thread_active应该将其改为非原子的bool multithreading_has_been_initialized.最初的想法是当除了主要线程以外的所有线程都处于休眠状态时能够再次关闭标志,但我知道这可能是容易出错的.

将显式条件抽象为定制的lock_guard是一个好主意(并且有助于设计的未来变化,包括如果不认为优化值,则简单地恢复到std :: lock_guard).

Dav*_*rtz 12

通常,优化只应在特定用例中没有证明需要的情况下执行,如果它们影响代码的设计或组织.那是因为这些算法优化可能很难在以后执行.点微观优化总是可以在以后添加,应该在需要之前避免,原因如下:

  1. 如果您对典型用例的猜错,实际上可能会使性能变差.

  2. 它们可以使代码更难以调试和维护.

  3. 即使您对用例的猜测正确,它们也会使新平台的性能变差.例如,在过去的八年中,互斥量的收购已经超过了一个数量级.今天有意义的权衡明天可能没有意义.

  4. 你可以浪费时间在不必要的事情上,更糟糕的是你可以浪费时间去进行其他优化.没有大量的经验,很难预测代码中的实际瓶颈在哪里,甚至专家在实际描述时经常会感到惊讶.

这是一个经典的点微优化,所以只有在分析显示出一些可能的好处时才应该这样做.


Meh*_*dad 9

是的,这是值得的.

在你的问题下,David Schwarz评论道:

无争议的互斥体几乎是免费的.这个成本if可能是可比的.

显然错误的(但是一个常见的误解).
试试这个:

#include <time.h>

#include <atomic>
#include <mutex>

static std::atomic<bool> single_threaded(true);

int main(int argc, char *argv[])
{
    (void)argv;
    if (argc == 100001) { single_threaded = !single_threaded; /* to prevent compiler optimization later */ }
    int n = argc == 100000 ? -1 : 10000000;
    {
        std::mutex mutex;
        clock_t const begin = clock();
        unsigned int total = 0;
        for (int i = 0; i < n; ++i)
        {
            if (single_threaded)
            {
                total = ((total << 1) ^ i) + ((total >> 1) & i);
            }
            else
            {
                std::lock_guard<std::mutex> lock(mutex);
                total = ((total << 1) ^ i) + ((total >> 1) & i);
            }
        }
        clock_t const end = clock();
        printf("Conditional: %u ms, total = %u\n", (unsigned int)((end - begin) * 1000U / CLOCKS_PER_SEC), total);
    }
    {
        std::mutex mutex;
        clock_t const begin = clock();
        unsigned int total = 0;
        for (int i = 0; i < n; ++i)
        {
            std::lock_guard<std::mutex> lock(mutex);
            total = ((total << 1) ^ i) + ((total >> 1) & i);
        }
        clock_t const end = clock();
        printf("Unconditional: %u ms, total = %u\n", (unsigned int)((end - begin) * 1000U / CLOCKS_PER_SEC), total);
    }
}
Run Code Online (Sandbox Code Playgroud)

我的输出?(Visual C++)

条件:24毫秒,总计= 3684292139
无条件:845毫秒,总计= 3684292139

  • 这是完全不切实际的测试代码,旨在尽可能地夸大影响,即便如此,它显示出最小的影响(每个小于50ns).更糟糕的是,答案完全是误导,因为它表明人们可以在一个平台上运行的人工测试代码来衡量特定于硬件和用例的优化的价值. (5认同)
  • @DavidScwarz:上帝禁止你承认你错了吧? (4认同)
  • 我使用带有-O3的g ++ 5.0.0运行它并且两者都得到0,这使得测试失败了一点.如果没有优化,我得到了90毫秒而不是350毫秒,但是一个适用于优化的测试会更有价值. (3认同)
  • @DavidSchwartz,是的,这证明了一切。您知道吗-千家万户的苍蝇不会错,而且他们的饮食应确实采用! (3认同)
  • 我能够在 Soalris x86 上重现您的结果,而在 Linux 上我只能在完全关闭优化的情况下复制您的结果。优化结果非常接近,两个平台上的 g++ 4.4.6。 (2认同)
  • @DavidSchwartz:呃,什么?我一开始并没有给出建议。我实际上只是在回答这个问题:*“锁是如此便宜以至于在单线程时无关紧要?”* *你是*在这里否认并不断试图改变主题以适应你的错误的人对原始问题的回答,即“无争议的互斥锁几乎是免费的”。哎呀,为什么*你*不做一个**可证明的**陈述并花时间证明一次?见鬼,与你不同,我可能有礼貌地赞成你的证明而不是反对它! (2认同)

Pet*_*des 7

争用的锁是不是现代系统坏了,不需要进入内核。但是它们仍然涉及完整的内存屏障和(或作为)原子 RMW 操作的一部分。它们比完美预测的比较/分支慢。

作为一个函数调用,它们击败了一些优化,例如强制编译器将变量从寄存器溢出回内存,包括std::vector控制块的指针成员,引入额外的存储/重新加载延迟。(实际上,完整的内存屏障会打败存储转发)。

(不可内联是互斥函数实际上如何防止大多数实现上的编译时重新排序,以及在 asm 中执行任何操作以自动获取锁并防止运行时重新排序。这部分涉及排空存储缓冲区。)

根据您所做的工作量以及锁定的细粒度程度,无竞争互斥锁的成本可能非常小。但是,如果你正在做它周围的每vector::push_back()一个循环中,你可能会看到大约20级的加速比为循环

(基于平均每 2 或 3 个时钟周期一个存储的假设,假设一些内存级并行性和/或缓存命中是合理的。push_back循环甚至可以自动矢量化并且平均每个时钟周期比 1 个元素更好,假设小元素和廉价的值计算。 lock cmpxchg在 Skylake 上每 18 个周期有 1 个吞吐量,中间没有其他内存操作;https://agner.org/optimize/。其他微体系结构,包括非 x86 ISA,将有所不同,但大约一个数量级可能是一个很好的大致估计。)

但是,它可能仍然是整个程序运行时的一个可以忽略不计的部分,并且通过执行额外的加载会稍微损害多线程情况,并且另一个必须在缓存中保持热态以获得良好性能的全局变量。 并且该全局变量可能与其他任何内容位于不同的缓存行中。


如果您有一个糟糕的线程/互斥体库,其中即使是无争议的情况也进入内核,那么您可能会看到加速因子为 400,或者在使用微码辅助 Spectre 缓解的现代 x86 内核上数以万计通过刷新分支- 预测者;每次进入内核都需要数千个周期。我希望没有任何系统具有足够现代的内核来做到这一点,但仍然使用重量级锁。

我认为主流操作系统(Linux / Mac / Windows)都有轻量级锁定,仅作为争用的后备进入内核。请参阅 Jeff Preshing 的Always Use a Lightweight Mutex文章。可能还有 Solaris 和 *BSD。

syscall在 Skylake x86 上进入内核的成本:大约 100 到 150 个周期左右,IIRC。在 x86 上使用 Spectre/Meltdown 缓解措施,然后您在进入和退出时更改页表(昂贵且可能导致 TLB 未命中/页) walks) 并可能使用特殊的 asm 指令来刷新分支预测。

系统调用本质上也是序列化的;在一个紧凑的用户空间循环中,它不会留下太多供乱序执行者查看的内容。内核中至少有一些工作。(它还会破坏您在循环迭代中可能具有的任何内存级并行性,但是来自互斥锁的完整屏障已经做到了这一点。)

因此,如果出于某种原因,即使在无争用的情况下,您也关心使用非常昂贵的锁的糟糕实现,那么您很可能想要这个。(并且可能希望多线程情况不那么细粒度)。但希望这样的实现不会被广泛使用。GNU/Linux 绝对不是这样,AFAIK 也没有什么重要的。


gcc 的 libstdc++ 已经进行了这种优化,检查__gthread_active_p ()内部互斥锁/解锁(例如__gthread_mutex_lockin/usr/include/c++/9.1.0/x86_64-pc-linux-gnu/bits/gthr-default.h),如果为 false 则不执行任何操作。 这是在标题中,以便包装器pthread_mutex_lock可以内联到您的代码中。

在GNU / Linux(glibc的),它的工作原理通过检查,如果你建有g++ -pthread或没有。(使用弱别名检查(动态)链接器是否为我们提供了 libpthread 私有函数符号名称的非零地址。由于此条件是链接时常量,因此它甚至不需要atomic<>这样编译器可以将结果保存在寄存器中。它基本上只是一个非原子的负载void*。)其他操作系统(不是 glibc)上的 libstdc++ 有其他检查策略,请参阅其他定义。

即使在无条件情况下,Mehrdad 的测试用例运行速度也很快,当构建时没有-pthread. 在 Arch GNU/Linux、g++9.1 -O3、glibc 2.29-4、i7-6700k (Skylake) 上,在 ~4.2GHz (turbo) 和echo performance > energy_performance_preference. 这是每次迭代几乎完全相同3个时钟周期,通过瓶颈上的3周期循环搬运依存链total1。(我从 Mehrdad 的原始版本中增加了迭代计数,而不是使用更高精度的计时/打印,部分是为了隐藏启动开销和最大涡轮增压。)

但是有了 g++ -O3 -pthread如此多的 glibcpthread_mutex_lock并且unlock确实被调用,它在 Skylake 上慢了大约 18 倍。在我的机器上大约 13000 毫秒,大约是 54 个时钟周期/迭代。

测试用例不会在关键部分内进行任何内存访问,只是
total = ((total << 1) ^ i) + ((total >> 1) & i)在本地unsigned int total,编译器可以在互斥函数调用中将其保存在寄存器中。因此,lock cmpxchg(lock) 和lock dec(unlock) 必须从存储缓冲区中排出的唯一存储是其他互斥体字段的普通存储,以及 x86call指令压入堆栈的返回地址。这应该有点类似于.push_back(i)在 std::vector 上执行的循环。根据Agner Fog 的测试,那些lock仅使用 ed 指令而没有其他内存访问将占 36 个周期的吞吐量成本。实际 54 个周期/迭代表明,锁定/解锁功能中的其他工作以及等待其他存储刷新是有成本的。(序 exec 可以total = ...与所有这些重叠实际计算;我们知道locked 指令不会阻止 Skylake 上独立 ALU 指令的乱序 exec。尽管 mfence 这样做是因为微码更新以修复错误,使 gcc 的 mov+mfence 策略用于 seq-cst 存储而不是xchg像其他编译器一样更糟。)


脚注 1:在 处-O3,GCCif(__gthread_active_p ())将循环提升,形成循环的两个版本。(这比在循环使用 3 个分支(包括循环分支本身)要快得多。)

“条件”版本包括无用的加载single_threaded到寄存器中,该寄存器会立即被覆盖,因为根据测试没有任何反应。(编译器不优化原子能公司一样volatile,因此,即使未使用的负载住宿。但幸运的x86-64并不需要seq_cst加载任何额外的屏障指令,因此它几乎没有任何成本。不过,在10背到后面运行:有条件:728ms 相当一致。无条件:727ms 相当一致。对比 3 个周期/迭代计算的 716ms,在 4.19GHz 用户空间周期/秒下的测量平均值perf stat -r10 ./a.out

但是在-O2, 上的分支__gthread_active_p留在循环内:

  • 有条件的:730 到 750 毫秒(从运行到运行的稳定性不如以前),每次迭代有 2 个分支。
  • 无条件(无 pthread):约 995 毫秒,每次迭代采用 3 个分支。分支错误率仍然是 0.00%,但它们确实有前端成本。
  • 无条件(使用 pthread):~13100 毫秒(-O3无条件时为 13000 )

如果你使用 gcc -O2 编译,或者甚至在 -O3 编译器决定不进行循环多版本化或反演或在提升 if 时调用的任何东西,你会得到这样的 asm:

# g++ 9.1 -O2 for x86-64 on Arch GNU/Linux

    # early in the function, before any loops: load a symbol address into a 
    10de:       48 8b 2d f3 2e 00 00    mov    rbp,QWORD PTR [rip+0x2ef3]        # 3fd8 <__pthread_key_create@GLIBC_2.2.5>
     ...
# "Unconditional" inner loop
    11b8:       48 85 ed                test   rbp,rbp           # do{
    11bb:       74 10                   je     11cd <main+0x13d>  # if( __gthread_active_p () )
      11bd:       4c 89 ef                mov    rdi,r13   # pass a pointer to the mutex in RDI
      11c0:       e8 bb fe ff ff          call   1080 <pthread_mutex_lock@plt>
      11c5:       85 c0                   test   eax,eax
      11c7:       0f 85 f1 00 00 00       jne    12be <main+0x22e>  # if non-zero retval: jump to a call std::__throw_system_error( eax ) block
    11cd:       43 8d 04 24             lea    eax,[r12+r12*1]    # total<<1 = total+total
    11d1:       41 d1 ec                shr    r12d,1             # shifts in parallel
    11d4:       31 d8                   xor    eax,ebx
    11d6:       41 21 dc                and    r12d,ebx           # xor, and with i
    11d9:       41 01 c4                add    r12d,eax           # add the results: 3 cycle latency from r12 -> r12 assuming perfect scheduling
    11dc:       48 85 ed                test   rbp,rbp
    11df:       74 08                   je     11e9 <main+0x159>  # conditional skip mov/call
      11e1:       4c 89 ef                mov    rdi,r13
      11e4:       e8 77 fe ff ff          call   1060 <pthread_mutex_unlock@plt>
    11e9:       83 c3 01                add    ebx,0x1
    11ec:       81 fb 80 96 98 00       cmp    ebx,0x989680
    11f2:       75 c4                   jne    11b8 <main+0x128>  # }while(i<10000000)
Run Code Online (Sandbox Code Playgroud)

我无法使用 g++ 在 Godbolt 上重现此代码生成器,也无法使用 libc++ 重现此代码。 https://godbolt.org/z/kWQ9Rn Godbolt 安装的 libstdc++ 可能没有与正确安装相同的宏定义?

call __gthrw_pthread_mutex_lock(pthread_mutex_t*)没有内联所以我们看不到if (!__gthread_active_p ())检查的效果。


如果你这样做,让你的支票更有效率

如果您是唯一运行的线程,则除非您的循环启动线程,否则不会改变。

您可以使变量成为非原子变量。开始任何线程之前设置它,然后再也不写它。然后,所有线程都可以跨循环迭代将其读入寄存器。编译器甚至可以为您提升循环的检查。(就像gcc -O3上面描述的 GCC 互斥体实现内部的分支一样,但不是 at -O2)。

您可以手动将它从循环中提升出来,而不是让编译器在提升非原子变量的负载后在循环不变的寄存器值上进行分支。如果手动提升可以帮助您的编译器显着加快循环速度,那么不妨全力以赴进行此优化:

// global scope
bool multi_threaded = false;   // zero init lets this go in the BSS

// in a function
if (!multi_threaded) {
 // optionally take a lock here, outside an inner loop            std::lock_guard<std::mutex> lock(mutex);
    for (int i = 0; i < n; ++i) {
       stuff;
    }
} else {
    for (int i = 0; i < n; ++i) {
       std::lock_guard<std::mutex> lock(mutex);
       stuff;
    }
}
Run Code Online (Sandbox Code Playgroud)

将循环体拉出到一个函数中以避免重复,如果它不仅仅是微不足道的。

// starting threads
multi_threaded = true;
std::thread t(stuff);
Run Code Online (Sandbox Code Playgroud)

如果你想回到单线程模式,当你知道你是唯一的线程时,你可以安全地这样做:

t.join();
multi_threaded = false;    // all threads that could be reading this are now done
                           // so again it can be safely non-atomic
Run Code Online (Sandbox Code Playgroud)

可能甚至有不同的数据结构multi_threaded变量,跟踪是否有多个线程有可能会看到一定的数据结构。那时你可以考虑制作它们atomic。然后你会想要bool nolocks = some_container.skip_locking.load(std::memory_order_relaxed);在整个循环中使用相同的本地。

我没有仔细考虑过这一点,但我认为只要没有其他线程会设置some_container.skip_locking并启动另一个访问它的线程,它就可以工作;这无论如何都不安全,因为该线程可能正在修改数据结构而不持有锁。

您甚至可以将标志视为“粗锁定”而不是“无锁定”,因此如果另一个线程想要开始使用数据结构,它仍然有效;如果我们在大量迭代中持有锁,那么从启动一个新线程到它实际获得该数据结构的锁的时间可能很重要。

 if (!some_container.fine_locking.load(std::memory_order_relaxed)) {
     // take a lock here, outside an inner loop
     std::lock_guard<std::mutex> lock(mutex);
     for (int i = 0; i < n; ++i) {
         some_container.push_back(i);
     }
 } else {
     // lock *inside* the loop.
     for (int i = 0; i < n; ++i) {
         std::lock_guard<std::mutex> lock(mutex);
         some_container.push_back(i);
     }
 }
Run Code Online (Sandbox Code Playgroud)

这很容易变得毛茸茸的,这只是头脑风暴什么是可能的,而不是一个好主意!


Ser*_*eyA 4

我不同意锁定互斥成本低廉的普遍观点。如果你真的追求表演,你就不会愿意这样做。

互斥体(即使是没有争议的)会给你带来三个嗡嗡声:它们会惩罚编译器优化(互斥体是优化障碍),它们会引发内存围栏(在非悲观的平台上),它们是内核调用。因此,如果您追求紧密循环中的纳秒级性能,那么这是值得考虑的。

出于多种原因,分支也不是很好。真正的解决方案是避免在多线程环境中需要同步的操作。就如此容易。

  • @SergeyA 有人问了一个非常普遍的问题,我们的答案应该基于某个地方可能存在的实现可能是什么样子?避免使用常用的标准化类,因为某个地方的某人可能实施得很糟糕?!这不是一个复杂的问题——基本上是,“我应该在没有明显需要的情况下实施一个小的微观优化吗”,答案也很简单——“不”。 (4认同)
  • @SergeyA 在哪些现代平台上获取和释放无竞争的互斥体内核调用? (3认同)