pthread互斥锁的开销是多少?

33

我试图让一个C++ API(用于Linux和Solaris)具有线程安全性,以便其函数可以从不同的线程中调用而不会破坏内部数据结构。在我的当前方法中,我使用pthread mutexes来保护对成员变量的所有访问。这意味着一个简单的getter函数现在会锁定和解锁互斥锁,并且我担心这会带来一定的开销,特别是当API大多数情况下将被用于单线程应用程序时,任何互斥锁定似乎都是纯粹的开销。

因此,我想问:

  • 您是否有使用锁定与不使用锁定的单线程应用程序的性能经验?
  • 相对于例如简单的“return this->isActive”访问布尔成员变量,这些锁定/解锁调用有多昂贵?
  • 您知道更好的保护这种变量访问的方法吗?

1
非常感谢大家的回答!以下是一些澄清:
  • 当前API存在类似OpenGL的限制:对象只能在创建它们的线程中使用/操作。我想要消除这个限制。
  • “访问器”方法的观点很好 - 我会记住这一点,用于未来的API,但当前的API无法以这种方式更改。
  • 只有少数公共方法;因此,我将在所有这些方法中添加锁定(其中大多数都是相当高级的)。
  • 是的,我是新手多线程 :-)
- oliver
9个回答

40

所有现代线程实现都可以完全在用户空间中处理无争用的互斥锁(只需要几条机器指令)- 仅在存在争用时,库才必须调用内核。

另一个要考虑的问题是,如果应用程序没有显式链接到pthread库(因为它是单线程应用程序),则只会获得虚拟的pthread函数(根本不会进行任何锁定)- 仅当应用程序是多线程的(并链接到pthread库)时,才会使用完整的pthread函数。

最后,正如其他人已经指出的那样,对于类似isActive这样的getter方法使用互斥锁保护是没有意义的- 一旦调用者有机会查看返回值,该值可能已经被更改了(因为互斥锁只在getter方法内部被锁定)。


21
最后一段不正确:如果在getter中不使用互斥锁,调用者可能会获取到无效的值,因为第二个线程可能在创建副本时部分地覆盖了源值。 (好吧,也许在布尔类型上可以运行) - nob
8
虽然不是100%可移植的,但我认为任何常规的原始类型(例如int、bool或float)都可以被原子读取,因为大多数平台都已经保证了这一点。glibc手册也支持我的观点,它说:“实际上,您可以假设int和其他不超过int大小的整数类型是原子的。您还可以假设指针类型是原子的;这非常方便。这两个条件在GNU C库支持的所有机器上都成立,在我们所知道的所有POSIX系统上也都成立。” http://www.cs.utah.edu/dept/old/texinfo/glibc-manual-0.02/library_21.html#SEC360 - Gabe
4
确实可以原子地读取布尔值 - 问题在于,如果没有使用互斥锁,如果一个线程更新了布尔值,然后另一个线程稍后读取该值,第二个读取可能会从处理器的数据缓存中获取旧的、过时的值。获取锁将强制处理器使该线程的数据缓存失效。 - Tom Swirly
3
这个问题非常深奥,取决于架构和编译器的许多参数。处理器可以原子地写入哪些类型?什么样的对齐方式可以保证在处理器总线和高速缓存层次结构上具有原子性?什么是高速缓存一致性协议?架构实现了什么样的内存一致性模型?语言/编译器假设了什么样的内存一致性模型?通常只有在非常强的假设下,原始类型的读取和/或写入才可以不加锁-最好交给对硬件和低级软件非常熟悉的人来处理。 - nccc

23

"互斥锁需要进行操作系统上下文切换,这是相当昂贵的。"

  • 在Linux上并非如此,互斥锁使用称为futex的东西实现。获取未被争用(即未被锁定)的互斥锁只需执行几个简单的指令,通常在当前硬件下约为25纳秒,正如cmeerw所指出的那样。

更多信息请参见: Futex

每个人都应该知道的数字


7

这可能有点偏题,但你似乎对线程还不太熟悉——首先,只在线程可能重叠的地方进行锁定。然后,尽量减少这些地方。此外,不要试图锁定每个方法,考虑线程在对象上的整体操作,并将其作为单个调用进行锁定。尽量将锁定置于最高层(这样可以提高效率并/有助于/避免死锁)。但是锁定不能“组合”,您必须至少通过线程所在的位置和重叠来进行代码的跨组织。


1
做得好,找到了问题的根源。当多个线程同时访问时,尽可能地将所有内容封装起来。访问器会削弱封装性。 - deft_code

4
我做过类似的库,并没有遇到锁性能方面的问题。(我无法告诉你它们是如何实现的,因此不能确定这不是一个大问题。)
我建议首先确保正确性(即使用锁),然后再考虑性能。我不知道有更好的方法;这就是互斥锁的用途。
对于单线程客户端,另一种选择是使用预处理器来构建您的库的非锁定版本和锁定版本。例如:
#ifdef BUILD_SINGLE_THREAD
    inline void lock () {}
    inline void unlock () {}
#else
    inline void lock () { doSomethingReal(); }
    inline void unlock () { doSomethingElseReal(); }
#endif

当然,这会增加一个额外的构建版本需要维护,因为您需要同时分发单线程和多线程版本。

1
使用锁并不能保证“做对”。它可能会导致死锁问题。 - jalf
1
如果所涉及的库不会回调到用户,并且不会锁定其他任何内容,则死锁不可能发生。 - caf
1
我强烈建议不要针对你的库创建单独的线程和非线程版本。原因在于,那些不使用线程的应用程序或库的编写者将希望链接非线程版本,但一旦他们依赖于另一个使用线程版本的库,就会出现各种问题。 - R.. GitHub STOP HELPING ICE

3
我可以告诉你在Windows中,互斥对象是内核对象,因此会产生(相对)显著的锁定开销。如果你只需要在线程中使用的更高效的锁定方式,请使用临界区域。这种方式不能跨进程工作,仅适用于单个进程中的线程。
然而,Linux与多进程锁定有着非常不同的实现方式。我知道互斥锁是使用原子CPU指令实现的,并且仅适用于一个进程,因此它们的性能与Win32临界区域相同,即非常快。
当然,最快的锁定方式是根本不要有锁定,或者尽可能少地使用它们(但如果你的库将在高度线程化的环境中使用,则希望尽可能短地锁定:锁定,做某事,解锁,再做其他事情,然后再次锁定比在整个任务期间保持锁定更好-锁定的成本不在于锁定所需的时间,而在于线程等待另一个线程释放其想要的锁定时闲置的时间!)

2

我对使用 pthred_mutex_lock/unlock 的开销很好奇。我遇到了这样一种情况,需要在不使用互斥锁的情况下复制1500-65K字节,或者使用互斥锁并进行单个写入指向所需数据的指针。

我编写了一个简短的循环来测试每个选项。

gettimeofday(&starttime, NULL)
COPY DATA
gettimeofday(&endtime, NULL)
timersub(&endtime, &starttime, &timediff)
print out timediff data

或者
ettimeofday(&starttime, NULL)
pthread_mutex_lock(&mutex);
gettimeofday(&endtime, NULL)
pthread_mutex_unlock(&mutex);
timersub(&endtime, &starttime, &timediff)
print out timediff data

如果我复制的字节数少于4000左右,那么直接复制操作所需时间较短。但是,如果我复制的字节数超过4000,则使用互斥锁的成本更低。
互斥锁/解锁的计时在3到5微秒之间,包括获取当前时间的gettimeofday所需的约2微秒。

你在进行这个测试时使用的操作系统和版本,以及处理器是什么?系统时钟的粒度是多少,你使用了哪个时钟(墙/ CPU / 线程),你重复操作的频率是多少?你是否重复操作,还是只进行了一次计时?因为在Linux 3.2 x64上,我的机器的单个核心每秒最多可以执行约45百万个pthread_mutex_lock()/_unlcok()对。而且这并不是一个高端系统。但这个时间显然包括了缓存和用户空间处理的好处。 - class stacker

2
互斥锁需要进行操作系统上下文切换,这是相当昂贵的。CPU 可以在不太费力的情况下每秒执行数十万次,但与没有互斥锁相比,它要昂贵得多。在每个变量访问上都使用互斥锁可能过度了。这也可能不是你想要的。这种暴力锁定往往会导致死锁。
你知道更好的保护此类变量访问的方法吗?
设计应用程序时尽可能少地共享数据。某些代码段应该同步,可能需要使用互斥锁,但仅限于实际必要的部分。通常不是单个变量访问,而是包含必须原子执行的变量访问组的任务。 (也许您需要将您的 is_active 标志设置为一些其他修改。将该标志设置并不再对对象进行更改是否有意义?)

1
是的,这是一个很好的观点,没有必要锁定未公开的数据。我的策略几乎总是:
  1. 所有数据都是私有的
  2. 所有不是静态的公共/受保护方法在进入时都会进行锁定
  3. 锁定对象位于堆栈上,以便未捕获的异常可以解锁数据。
- Peter Cardona
3
在多线程库中获得正确的行为的唯一方法是期望库的用户正确使用它。无端地在所有可能被滥用的地方添加锁并不能解决问题或确保正确性。让用户知道哪些方法可以,哪些方法不可以从其他线程调用,然后仅对那些方法加锁即可。 - jalf
3
实际上,就我所知,在最近的Linux系统中(这是问题的一半),我认为pthread_mutex_lock大多数情况下不需要进行上下文切换,因为它们默认使用基于futex的实现方式(请参见http://en.wikipedia.org/wiki/Futex)。 - Fredrik
1
如果一个互斥量需要上下文切换,那么你的操作系统在并发编程方面已经无法使用了。Linux绝对不需要上下文切换,无论是进程本地还是进程共享的互斥量,除非存在争用情况(即使存在争用情况,也只是为了避免在用户空间中更昂贵的自旋)。 - R.. GitHub STOP HELPING ICE
1
我在写这个答案时可能没有想清楚。一个互斥锁 可能 意味着上下文切换(如果存在争用)。否则,它只会花费几个原子指令的时间(虽然比不使用互斥锁要更昂贵,但远不及上下文切换那么昂贵。) - jalf
显示剩余5条评论

1
对于成员变量的访问,您应该使用读写锁(read/write locks),它们具有稍微较少的开销,并允许多个并发读取而不阻塞。
在许多情况下,您可以使用原子内置函数(atomic builtins),如果您的编译器提供了它们(如果您使用gcc或icc __sync_fetch*()等),但是正确处理它们非常困难。
如果您可以保证访问是原子性的(例如,在x86上,如果它被对齐,则dword读取或写入始终是原子的,但不是读取-修改-写入),则通常可以完全避免使用锁,并且改用volatile,但这是不可移植的,并需要了解硬件知识。

1
在多核系统中,如果没有处理器的帮助,你永远无法保证读取或写入是原子性的,例如使用 __sync_fetch*。在单核机器上,假定机器字读/写是原子性的是安全的,但是单核正在逐渐消失。 - deft_code
1
@Caspin,读取一个双字是绝对的原子操作,这意味着即使另一个核心在同时写入同一内存位置,你也永远不会得到一些“旧”的位和一些“新”的位的混合(但是,在x86上,一个四字并不具有相同的原子性)。我认为你把“原子”和“有序”混淆了。 - Pavel Minaev
1
@Caspin,你错了。引用自Intel® 64体系结构内存排序白皮书: “ Intel 64 内存排序保证以下每个内存访问指令的组成内存操作看起来像单个内存访问一样执行,无论内存类型: ... 3. 读取或写入其地址在4字节边界上对齐的双字(4字节)的指令。 4. 读取或写入其地址在8字节边界上对齐的四字(8字节)的指令。“ 因此,在众多操作中,这是原子性的。顺便说一句,Intel 64 是指 IA-32 和 64。 - Gunther Piez
我已经比较了RWLocks和MutExes,我完全不同意它们的开销更小。而且,在实践中,它们往往表现得比人们想象的要差。1)现代操作系统至少为同一进程中的线程提供了高效的MutEx支持。这是建立在用户空间原子CPU指令之上的。RWLocks是建立在其之上的,这使它们不太有效率。2)如果写操作经常需要进行,并且比读取操作时间长,那么RWLocks就没有任何优势。 - class stacker

0

一个次优但简单的方法是在您的互斥锁上放置宏,然后有一个编译器/Makefile选项来启用/禁用线程。

例如:

#ifdef THREAD_ENABLED
#define pthread_mutex_lock(x) ... //actual mutex call
#endif

#ifndef THREAD_ENABLED
#define pthread_mutex_lock(x) ... //do nothing
#endif

然后在编译时执行gcc -DTHREAD_ENABLED以启用线程。

再次强调,我不建议在任何大型项目中使用此方法。但如果你只是想要一些相对简单的东西,那么可以考虑使用。


1
出于我告诉另一个人的同样原因,我不建议这样做。如果在包含pthread.h之后使用#define pthread_mutex_lock会导致未定义的行为(如果可能的话,您可以完全跳过pthread.h)。 - R.. GitHub STOP HELPING ICE

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