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

时间:2015-08-31 18:13:25

标签: 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();
    }
}

修改

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

一些澄清:

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

operation_requiring_synchronization()通常包含一些插入全局标准容器的内容。

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

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

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

将显式条件抽象为自定义的lock_guard是一个好主意(并且有助于设计的未来更改,包括如果不认为优化的话,只需简单地恢复到std :: lock_guard)。

7 个答案:

答案 0 :(得分:10)

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

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

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

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

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

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

答案 1 :(得分:9)

是的,it is worth it

在你的问题下,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);
    }
}

我的输出? (Visual C ++)

  

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

答案 2 :(得分:2)

你走在正确的轨道上 - 用同步写功能部分并在外部添加,如果需要

而不是显式的if - 块,我仍然会实例化锁,并隐藏其中的复杂性。

template <class Mutex>
struct faster_lock{
  faster_lock(Mutex& mutex) lock here, possibly with nested RAII {}
  ~faster_lock()noexcept { unlock here, or nested RAII }
};

{
  faster_lock lock(mutex);
  operation_requiring_synchronization();
}

最后一点 - 如果你有原子旗,你可以把它变成一个自旋锁,让你的逻辑变得更简单。

答案 3 :(得分:1)

我不同意锁定互斥文件便宜的广泛想法。如果你真的在表演之后,你不会想要这样做。

Mutexes(甚至是无可争议的)用三个悍马打你:他们惩罚编译器优化(互斥体是优化障碍),它们引入内存栅栏(在非悲观平台上)并且它们是内核调用。因此,如果您在紧密循环中经过纳秒性能,则值得考虑。

分支也不是很好 - 出于多种原因。真正的解决方案是避免在多线程环境中需要同步的操作。就这么简单。

答案 4 :(得分:1)

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

作为函数调用,它们使某些优化失败,例如迫使编译器将变量从寄存器溢出到内存中,包括std::vector控制块的指针成员,从而导致额外的存储/重载延迟。 (实际上,完整的内存屏障将击败存储转发)。

(互不互斥函数实际上是如何防止大多数实现上的编译时重新排序,以及在asm中做任何事情以原子方式获取锁定并防止运行时重新排序。这部分涉及耗尽存储缓冲区。)

根据您要做的工作以及锁定的精细程度,无竞争的互斥体的成本可能很小。但是,如果您在循环中的每个vector::push_back()周围进行操作,您可能会看到该循环的加速因子约为20 。< / p>

(基于平均每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。

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

本质上,系统调用也在序列化;在紧密的用户空间循环中,无序的exec可以留很多东西。内核中至少有一些工作。 (它还会破坏您在循环迭代中可能拥有的任何内存级别的并行性,但是互斥锁的全部障碍已经做到了。)

因此,如果出于某种原因,即使在无人竞争的情况下,您也担心使用非常昂贵的锁的错误实现,则很可能希望这样做。 (并且可能希望多线程的情况不那么细粒度)。但是,这种实现方式希望不会普及。 GNU / Linux绝对不是这样,而AFAIK也不重要。


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

在GNU / Linux(glibc)上,它由checking if you built with g++ -pthread or not工作。 (使用弱别名填充检查(动态)链接程序是否为我们提供了一个libpthread私有函数符号名称的非零地址。由于此条件是链接时间常数,因此甚至不需要为{{1} },以便编译器可以将结果保存在寄存器中。基本上,这只是非原子atomic<>的负载。)其他操作系统(不是glibc)上的libstdc ++还有其他检查策略,请参见其他定义。 在没有void*的情况下构建的

Mehrdad's test-case即使在无条件的情况下也可以快速运行。在Arch GNU / Linux,g ++ 9.1 -pthread,glibc 2.29-4,i7-6700k(Skylake)在约4.2GHz(turbo)和-O3下进行的1000M迭代中,大约需要727ms。这几乎是每个迭代3个时钟周期,这是通过echo performance > energy_performance_preference 1 在3个循环循环进行的依赖链上造成的瓶颈。 (我提高了Mehrdad原始版本的迭代次数,而不是使用更高精度的计时/打印,部分是为了隐藏启动开销和最大涡轮增压。)

但是带有 total ,因此glibc的g++ -O3 -pthreadpthread_mutex_lock确实被调用了,它的速度慢了大约18倍Skylake 。我的机器上大约13000毫秒,大约是54个时钟周期/迭代。

测试用例在关键部分内部不进行任何内存访问,只是
本地unlock上的total = ((total << 1) ^ i) + ((total >> 1) & i),编译器可以在互斥量函数调用中将其保存在寄存器中。因此,unsigned int total(锁定)和lock cmpxchg(解锁)必须从存储缓冲区中耗尽的唯一存储是普通存储到其他互斥字段,以及x86的{将返回地址压入堆栈{1}}指令。这应该有点类似于在std :: vector上执行lock dec的循环。对于Agner Fog's testing,仅凭这些call指令而无其他存储器访问权将占用36个周期的吞吐量成本。实际的54个循环/每次迭代表明,锁定/解锁功能中的其他工作以及等待其他存储刷新的开销很大。 (乱序的exec可能会将实际的.push_back(i)计算与所有这些重叠; we know that locked instructions don't block out-of-order exec of independent ALU instructions on Skylake。尽管mfence这样做是由于微码更新可解决勘误,但使gcc的seq-cst的mov + mfence策略成为可能而不是lock来存储,甚至比其他编译器更糟。)


脚注1 :在total = ...,GCC将xchg吊离循环,形成了两种版本的循环。 (这比在循环内的 中拥有3个分支(包括循环分支本身)要快得多。)

“有条件的”版本包括-O3到寄存器的无用加载,该寄存器立即被覆盖,因为基于测试没有任何反应。 (编译器根本不会优化原子 ,就像if(__gthread_active_p ())一样,因此即使未使用的负载也会保留。但是幸运的是x86-64不需要seq_cst负载的任何额外的屏障指令,因此几乎没有仍然需要花费10多个背对背运行:有条件的:728ms相当一致;无条件的:727ms相当一致;相对于在4.19GHz的用户空间周期/秒下测得的平均值,计算出的3个周期/迭代的716ms single_threaded

但是在volatileperf stat -r10 ./a.out上的分支仍在循环内:

  • 有条件的:730到750毫秒(每次运行之间的稳定性比以前差),每个迭代有2个分支。
  • 无条件(无pthread):〜995 ms,每个迭代有3个分支。分支错误率仍为0.00%,但它们确实要为前端付出代价。
  • 无条件(使用pthread):〜13100毫秒(-O2无条件从13000开始)

如果您使用gcc -O2进行编译,或者甚至在-O3进行编译(如果编译器决定不执行循环多版本化或反转或在举起if时调用它),那么您将获得如下所示的asm:

__gthread_active_p

我无法使用g ++或使用libc ++的clang来复制此代码源。 https://godbolt.org/z/kWQ9Rn Godbolt安装的libstdc ++可能没有与正确安装相同的宏def?

-O3不是内联的,因此我们看不到# 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) 检查的效果。


如果执行此操作,可使检查效率提高

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

您可以使变量为非原子变量。在启动任何线程之前,先将其设置为 ,然后再也无需编写它。然后,所有线程都可以跨循环迭代将其读入寄存器。编译器甚至可以为您提升检查循环。 (就像call __gthrw_pthread_mutex_lock(pthread_mutex_t*)一样,如上所述,它在GCC互斥锁实现内完成了分支,但在if (!__gthread_active_p ())上却没有实现)。

您可以手动将其提升到循环之外,而不是让编译器在提升非原子变量的负载之后分支到循环不变的寄存器值上。如果手动提升可帮助您的编译器更快地完成循环,则不妨全力以赴进行此优化:

gcc -O3

将循环体放到一个函数中,以防止重复(如果不重要的话)。

-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;
    }
}

您甚至可以为不同的数据结构使用多线程变量,以跟踪是否有多个线程可能查看特定的数据结构。到那时,您可以考虑将它们设置为// starting threads multi_threaded = true; std::thread t(stuff); 。然后,您需要t.join(); multi_threaded = false; // all threads that could be reading this are now done // so again it can be safely non-atomic 并在整个循环中使用相同的本地变量。

我还没有仔细考虑,但是我认为只要没有 other 线程会设置atomic并启动另一个访问它的线程,该方法就可以工作。无论如何都不是安全的,因为该线程可能正在修改数据结构而没有持有锁。

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

bool nolocks = some_container.skip_locking.load(std::memory_order_relaxed);

这很容易让人毛茸茸,这只是在集思广益可能,而不是什么好主意!

答案 5 :(得分:0)

一般情况下,它可能足够便宜,在你完成之前不用担心它

完成后,您可以双向分析并查看影响。

请记住,您必须分析单线程和多线程的效果。它也可能影响多线程。

#ifdef USE_CONDITIONAL_GUARDED_MUTEX
std::atomic<bool> more_than_one_thread_active{false};
#else
static const bool more_than_one_thread_active{true}; // always use mutex
#endif

您可能需要考虑将其设置为编译时选项,并且具有二进制的单线程和多线程版本,这样就不需要if

#ifdef SINGLE_THREADED_WITHOUT_MUTEX
static const bool more_than_one_thread_active{false}; // never use mutex
#else
static const bool more_than_one_thread_active{true}; // always use mutex
#endif

几乎每个优化器都会根据其值{/ p>删除const bool所包围的代码

答案 6 :(得分:0)

是的,通常避免使用条件进行不必要的锁定会提高性能,因为互斥锁通常依赖于RMW或进入内核,这两者对于简单的分支来说都相对昂贵。有关避免锁定可能有益的其他方案的示例,请参阅double-checked locking idiom

但是,您总是希望考虑获益的成本。当您为单线程和多线程代码启动特殊外壳时,多线程错误可能会进入,这可能会导致跟踪。另一件需要考虑的事情是,尽管在锁定与否之间可能存在可测量的差异,但它可能不会对整个软件产生可测量的影响。所以测量,但智能测量。