互斥量mutex是如何工作的

并发编程要求同步操作。同一时刻不能有超过一个线程处理数据,否则我们就会遇到数据竞争。最常见的做法就是将关键数据的访问权限放置到mutex中。mutex当然不是免费的。mutex可以严重的影响我们编写的代码的性能。当正确使用的时候我们很难注意到mutex引入的性能支出。但是错误的使用的时候,可能多线程的性能还比不过单线程。
互斥量mutex,最基本的形式就是内存中的一个整数。这个整数基于mutex的不同状态可以有不同的值。当我们讨论mutexes的时候,我们也会需要讨论锁机制。存储在存储器的中的整数并不有趣,但是围绕它的操作却很有趣。

互斥量需要支持两种操作:上锁和解锁。
如果一个线程想要使用互斥量,必须先要调用上锁,然后在释放的时候解锁。在任何时候,互斥量上只能有一个锁。持锁的线程就是当前互斥量的拥有者。另一个线程如果想要获得控制权限,就一定要等待第一个线程解锁。mutual exclusion就是mutex的本意。
上锁操作多种多样。大多数的情况下,我们会持续等待,直到我们可以上锁。因此大多数的上锁操作就是这样的。其他的情况下我们可能想要等待一段时间,或者是压根儿不想等待。
与此相反,开锁通常只是一个函数。开锁后,另一个线程可以上锁。

竞争
尝试上锁一个已经上锁的互斥量就叫做竞争。在良好规划的程序中,竞争应当发生的次数应当很少。你应当设计你的代码以是的大多数的尝试上锁互斥量的操作不会被阻塞。
有两个原因导致你想避免竞争。第一个是任何线程如果等待锁释放就不会做其他的事,这就会浪费CPU。另一个原因更加有趣,尤其是对高性能代码。上锁一个当前解锁的互斥量比竞争的情况的代价消耗要小。如果想要明白为什么就一定要理解互斥量工作的机制。

它是怎么工作的
如上文所述,互斥量的数据在内存中是一个整数。它开始时是0,意味着它没有被上锁。如果你想要上锁互斥量,就需要检查它是否是0,然后赋值为1。然后这个互斥量就被锁住,然后你就成为了锁的拥有者。
这个技巧在于test-and-set指令一定要是原子操作。如果两个线程同时读到了0,并且都写1,那么它们都认为自己拿到了互斥量。如果没有CPU的支持,将没有办法在用户空间实现互斥量。这个操作一定要相对另一个线程是原子的。幸运的是,CPU有叫做“compare-and-set” 或者是“test-and-set”的功能。这个功能需要整数的地址,已经两个值:比较值和设定值。如果比较值与当前整数的值相同,就替换为新值。否则仍是用旧值。C风格的代码可能如下:

int compare_set( int * to_compare, int compare, int set );

int mutex_value;int result = compare_set( &mutex_value, 0, 1 );if( !result ) { /* we got the lock */ }

这部分需要详细再介绍一下。调用者通过观察返回值来判断发生了什么。如果这个值与比较值相同,调用者设置成功了,如果不同,调用者失败了。当这个部分的代码释放锁时,它会把值再设置回0。这就是我们的互斥量机制。

原子的增减功能也可以被使用,如果使用的是linux futex的话,推荐使用这个。

What about waiting
接下来就到了棘手的部分。从一个角度看,这个简单,另一个角度看有比较棘手。上述的test-and-set机制不提供线程等待值的功能。CPU不会理解上层的线程和进程,因此它不是实现等待功能的地方。操作系统一定要提供这个等待功能。

为了让CPU正确的等待,调用者需要经过系统调用。操作系统是唯一一个可以同步不同的线程并且提供等待功能的地方。如果我们必须要等待一个互斥量,或者是释放一个正在等待的互斥量,我们除了操作系统的系统调用没有别的选择。大多数的操作系统都有内置的互斥量原语。在一些情况下,它们提供完全成熟可用的互斥量。如果系统调用可以提供一个完备的互斥量,为什么我们还需要在用户空间设置写test-and-set呢?原因在于系统调用代价较大,应当尽量避免。

不同的操作系统的处理方式不同,并且可能这种不同的趋势还会继续下去。linux中,系统调用futex提供类似mutex的语义。如果没有任何的竞争,这会在用户空间解决掉调用。如果存在竞争,那么就会将调用传递到操作系统,进入更加安全,代价也更大的模式。操作系统作为进程调度者的一部分负责处理等待操作。

这里再展开解释一下, 只要是没有竞争,那么就不会进行系统调用。如果存在竞争,那么系统调用就会用于将争取锁的线程放入sleep queue。如果mutex被释放,那么操作系统也会取从睡眠队列中取出第一个线程进行唤醒。此时,也不是通过简单地将互斥量恢复为初始值来进行锁的释放。而是通过系统调用来来传递锁的ownership。一言一概之,一旦发生了锁的争抢,就需要进行颇为繁琐的系统调用,这是我们编写并发程序所不想看到的。

futex是一个相当灵活的操作,允许除了mutex创建各种上锁机制,比如旗语,屏障,读写锁和事件信号。

The Costs
当涉及到mutex的代价的时候就会发现有趣的事情。最重要的一点就是等待的时间。你的线程应当只花费一小部分时间来等待互斥锁。如果它们等待的过于频繁,你就会失去并行性。在最坏的情况下,很多线程都试图锁住互斥量,导致性能比单独的一个线程还要差。这个并不是mutex自身的代价,但是却是并行编程时需要考虑的问题。
mutex过载相关的问题包括test-and-set操作以及系统调用,也正是它们实现了互斥量。test-and-set一般都是很小的开销,对并行处理十分的关键。CPU的厂家都会尽力使test-and-set指令执行的高效。我们忽视了另外一种重要的指令,fence屏障。这个在各种高层次的互斥量中被大量的使用。并且比test-and-set指令的代价要高,然而大部分的代价仍然是系统调用。系统调用不仅会带来上下文切换的代价,内核也需要时间来调度代码。

欢迎关注我的公众号《处理器与AI芯片》

你可能感兴趣的:(C++)