Futex,Fast Userspace muTEXes,做为linux下的一种快速同步(互斥)机制,已经存在了很长一段时间了(since linux 2.5.7)。它有什么优点?又提供了怎样一些功能,本文就简单探讨一下。node
在futex诞生以前,linux下的同步机制能够归为两类:用户态的同步机制 和 内核同步机制。 用户态的同步机制基本上就是利用原子指令实现的spinlock。最简单的实现就是使用一个整型数,0表示未上锁,1表示已上锁。trylock操做就利用原子指令尝试将0改成1:linux
bool trylock(int lockval) { int old; atomic { old = lockval; lockval = 1; } // 如:x86下的xchg指令 return old == 0; }
不管spinlock事先有没有被上锁,经历trylock以后,它确定是已经上锁了。因此lock变量必定被置1。而trylock是否成功,取决于spinlock是事先就被上了锁的(old==1),仍是此次trylock上锁的(old==0)。而使用原子指令则能够避免多个进程同时看到old==0,而且都认为是本身改它改成1的。服务器
spinlock的lock操做则是一个死循环,不断尝试trylock,直到成功。
对于一些很小的临界区,使用spinlock是很高效的。由于trylock失败时,能够预期持有锁的线程(进程)会很快退出临界区(释放锁)。因此死循环的忙等待极可能要比进程挂起等待更高效。
可是spinlock的应用场景有限,对于大的临界区,忙等待则是件很恐怖的事情,特别是当同步机制运用于等待某一事件时(好比服务器工做线程等待客户端发起请求)。因此不少状况下进程挂起等待是颇有必要的。多线程
内核提供的同步机制,诸如semaphore、等,其实骨子里也是利用原子指令实现的spinlock,内核在此基础上实现了进程的睡眠与唤醒。
使用这样的锁,能很好的支持进程挂起等待。可是最大的缺点是每次lock与unlock都是一次系统调用,即便没有锁冲突,也必需要经过系统调用进入内核以后才能识别。(关于系统调用开销大的问题,能够参阅:《从"read"看系统调用的耗时》。)less
理想的同步机制应该是在没有锁冲突的状况下在用户态利用原子指令就解决问题,而须要挂起等待时再使用内核提供的系统调用进行睡眠与唤醒。换句话说,用户态的spinlock在trylock失败时,能不能让进程挂起,而且由持有锁的线程在unlock时将其唤醒?
若是你没有较深刻地考虑过这个问题,极可能想固然的认为相似于这样就好了:优化
void lock(int lockval) { while (!trylock(lockval)) { wait(); // 如:raise(SIGSTOP) } }
可是若是这样作的话,检测锁的trylock操做和挂起进程的wait操做之间会存在一个窗口,若是其间lock发生变化(好比锁的持有者释放了锁),调用者将进入没必要要的wait,甚至于wait以后再没有人能将它唤醒。(详见《linux线程同步浅析》的讨论。)atom
在futex诞生以前,要实现咱们理想中的锁会很是别扭。好比能够考虑用sigsuspend系统调用来实现进程挂起:spa
class mutex { private: int lockval; spinlocked_set<pid_t> waiters; // 使用spinlock作保护的set public: void lock() { pid_t mypid = getpid(); waiters.insert(mypid); // 先将本身加入mutex的等待队列 while (!trylock(lockval)) { // 再尝试加锁 // 进程初始化时须要将SIGUSER1 mask掉,并在此时开启 sigsuspend(MASK_WITHOUT_SIGUSER1); } waiters.remove(mypid) // 上锁成功以后将本身从等待队列移除 } void unlock() { lockval = 0; // 先释放锁 pid_t waiter = waiters.first(); // 再检查等待队列 if (waiter != 0) { // 若是有人等待,发送SIGUSER1信号将其唤醒 kill(waiter, SIGUSER1); } } }
注意,这里的sigsuspend不一样于简单的raise(SIGSTOP)之类wait操做。若是unlock时用于唤醒的kill操做先于sigsuspend发生,sigsuspend也同样能被唤醒。(详见《linux线程同步浅析》的讨论。)
这样的实现有点相似于老版本的phread_cond,应该仍是能work的。有些不太爽的地方,好比sigsuspend系统调用是全局的,并不仅仅考虑某一把锁。也就是说,lockA的unlock能够将等待lockB的进程唤醒。尽管进程被唤醒以后会继续trylock,并不影响正确性;尽管多数状况下lockA.unlock也并不会试图去唤醒等待lockB的进程(除了一些竞争状况下),由于后者极可能并不在lockA的等待队列中。
另外一方面,用户态实现的等待队列也不太爽。它对进程的生命周期是没法感知的,极可能进程挂了,pid却还留在队列中(甚至于一段时间以后又有另外一个不相干的进程重用了这个pid,以致于它可能会收到莫名其妙的信号)。因此,unlock的时候若是仅仅给队列中的一个进程发信号,极可能唤醒不了任何等待者。保险的作法只能是所有唤醒,从而引起“惊群“现象。不过,若是仅仅用在多线程(同一进程内部)倒也不要紧,毕竟多线程不存在某个线程挂掉的状况(若是线程挂掉,整个进程都会挂掉),而对于线程响应信号而主动退出的状况也是能够在主动退出前注意处理一下等待队列清理的问题。线程
如今看来,要实现咱们想要的锁,对内核就有两点需求:一、支持一种锁粒度的睡眠与唤醒操做;二、管理进程挂起时的等待队列。
因而futex就诞生了。futex主要有futex_wait和futex_wake两个操做:code
// 在uaddr指向的这个锁变量上挂起等待(仅当*uaddr==val时) int futex_wait(int *uaddr, int val); // 唤醒n个在uaddr指向的锁变量上挂起等待的进程 int futex_wake(int *uaddr, int n);
内核会动态维护一个跟uaddr指向的锁变量相关的等待队列。
注意futex_wait的第二个参数,因为用户态trylock与调用futex_wait之间存在一个窗口,其间lockval可能发生变化(好比正好有人unlock了)。因此用户态应该将本身看到的*uaddr的值做为第二个参数传递进去,futex_wait真正将进程挂起以前必定得检查lockval是否发生了变化,而且检查过程跟进程挂起的过程得放在同一个临界区中。(参见《linux线程同步浅析》的讨论。)若是futex_wait发现lockval发生了变化,则会当即返回,由用户态继续trylock。
futex实现了锁粒度的等待队列,而这个锁却并不须要事先向内核申明。任什么时候候,用户态调用futex_wait传入一个uaddr,内核就会维护起与之配对的等待队列。
这件事情听上去好像很复杂,实际上却很简单。其实它并不须要为每个uaddr单独维护一个队列,futex只维护一个总的队列就好了,全部挂起的进程都放在里面。固然,队列中的节点须要能标识出相应进程在等待的是哪个uaddr。这样,当用户态调用futex_wake时,只须要遍历这个等待队列,把带有相同uaddr的节点所对应的进程唤醒就好了。
做为优化,futex维护的这个等待队列由若干个带spinlock的链表构成。调用futex_wait挂起的进程,经过其uaddr hash到某一个具体的链表上去。这样一方面能分散对等待队列的竞争、另外一方面减少单个队列的长度,便于futex_wake时的查找。每一个链表各自持有一把spinlock,将"*uaddr和val的比较操做"与"把进程加入队列的操做"保护在一个临界区中。
另外一个问题是关于uaddr参数的比较。futex支持多进程,须要考虑同一个物理内存单元在不一样进程中的虚拟地址不一样的问题。那么不一样进程传递进来的uaddr如何判断它们是否相等,就不是简单数值比较的事情。相同的uaddr不必定表明同一个内存,反之亦然。
两个进程(线程)要想共享同存,无外乎两种方式:经过文件映射(映射真实的文件或内存文件、ipc shmem,以及有亲缘关系的进程经过带MAP_SHARED标记的匿名映射共享内存)、经过匿名内存映射(好比多线程),这也是进程使用内存的惟二方式。
那么futex就应该支持这两种方式下的uaddr比较。匿名映射下,须要比较uaddr所在的地址空间(mm)和uaddr的值自己;文件映射下,须要比较uaddr所在的文件inode和uaddr在该inode中的偏移。注意,上面提到的内存共享方式中,有一种比较特殊:有亲缘关系的进程经过带MAP_SHARED标记的匿名映射共享内存。这种状况下表面上看使用的是匿名映射,可是内核在暗中却会转成到/dev/zero这个特殊文件的文件映射。若非如此,各个进程的地址空间不一样,匿名映射下的uaddr永远不可能被futex认为相等。
futex_wait和futex_wake就是futex的基本。以后,为了对其余同步方式作各类优化,futex又增长了若干变种。
futex等待系列的调用通常均可以传递timeout参数,支持超时唤醒。这一块逻辑相对较独立,本文中再也不展开。
int futex_wait_bitset(int *uaddr, int val, int bitset); int futex_wake_bitset(int *uaddr, int n, int bitset);
额外传递了一个bitset参数,使用特定bitset进行wait的进程,只能被使用它的bitset超集的wake调用所唤醒。
这个东西给读写锁很好用,进程挂起的时候经过bitset标记本身是在等待读仍是等待写。unlock时决定应该唤醒一个写等待的进程、仍是唤醒所有读等待的进程。
没有bitset这个功能的话,要么只能unlock的时候不区分读等待和写等待,所有唤醒;要么只能搞两个uaddr,读写分别futex_wait其中一个,而后再用spinlock保护一下两个uaddr的同步。
(参阅:http://locklessinc.com/articles/sleeping_rwlocks/)
int futex_requeue(int *uaddr, int n, int *uaddr2, int n2); int futex_cmp_requeue(int *uaddr, int n, int *uaddr2, int n2, int val);
功能跟futex_wake有点类似,但不只仅是唤醒n个等待uaddr的进程,而更进一步,将n2个等待uaddr的进程移到uaddr2的等待队列中(至关于也futex_wake它们,而后强制让它们futex_wait在uaddr2上面)。
在futex_requeue的基础上,futex_cmp_requeue多了一个判断,仅当*uaddr与val相等时才执行操做,不然直接返回,让用户态去重试。
这个东西是为pthread_cond_broadcast准备的。仍是先来回顾一下pthread_cond的逻辑(列一下,后面会屡次用到):
pthread_cond_wait(mutex, cond): value = cond->value; /* 1 */ pthread_mutex_unlock(mutex); /* 2 */ retry: pthread_mutex_lock(cond->mutex); /* 10 */ if (value == cond->value) { /* 11 */ me->next_cond = cond->waiter; cond->waiter = me; pthread_mutex_unlock(cond->mutex); unable_to_run(me); goto retry; } else pthread_mutex_unlock(cond->mutex); /* 12 */ pthread_mutex_lock(mutex); /* 13 */ pthread_cond_signal(cond): pthread_mutex_lock(cond->mutex); /* 3 */ cond->value++; /* 4 */ if (cond->waiter) { /* 5 */ sleeper = cond->waiter; /* 6 */ cond->waiter = sleeper->next_cond; /* 7 */ able_to_run(sleeper); /* 8 */ } pthread_mutex_unlock(cond->mutex); /* 9 */
pthread_cond_broadcast跟pthread_cond_signal相似,不过它会唤醒全部(而不是一个)等待者。注意,pthread_cond_wait在被唤醒以后,第一件事情就是lock(mutex)(第13步)。若是pthread_cond_broadcast一会儿唤醒了N个等待者,它们醒来以后势必会争抢mutex,形成千军万马过独木桥的"惊群"现象。
做为一种优化,pthread_cond_broadcast不该该用futex_wake去唤醒全部等待者,而应该用futex_requeue唤醒一个等待者,而后将其余进程都转移到mutex的等待队列上去(随后再由mutex的unlock来逐个唤醒)。
为何要有futex_cmp_requeue呢?由于futex_requeue实际上是有问题的,它至关于直接把一批进程拖到uaddr2的等待队列里面去了,而没有在临界区里面作状态检查(回想一下futex_wait里面检查*uaddr==val的重要性)。那么,在进入futex_requeue和真正将进程移到uaddr2之间就存在一个窗口,这个间隙内可能有其余线程futex_wake(uaddr2),这将没法唤醒这些正要移动却还没有移动的进程,可能形成这些进程从此再也没法被唤醒了。
不过尽管futex_requeue并不严谨,pthread_cond_broadcast这个case倒是OK的,由于在pthread_cond_broadcast唤醒等待者的时候,不可能有人futex_wake(uaddr2),由于这个锁正在被pthread_cond_broadcast持有,它将在唤醒操做结束后(第9步)才会释放。这也就是为何futex_requeue有问题,却冠冕堂皇的被release了。
int futex_wake_op(int *uaddr1, int *uaddr2, int n1, int n2, int op);
这个系统调用有点像CISC的思路,一个调用中搞了不少动做。它尝试在uaddr1的等待队列中唤醒n1个进程,而后修改uaddr2的值,而且在uaddr2的值知足条件的状况下,唤醒uaddr2队列中的n2个进程。uaddr2的值如何修改?又须要知足什么样的条件才唤醒uaddr2?这些逻辑都pack在op参数中。
int类型的op参数,实际上是一个struct:
struct op { // 修改*uaddr2的方法:SET (*uaddr2=OPARG)、ADD(*uaddr2+=OPARG)、 // OR(*uaddr2|=OPARG)、ANDN(*uaddr2&=~OPARG)、XOR(*uaddr2^=OPARG) int OP : 4; // 判断*uaddr2是否知足条件的方法:EQ(==)、NE(!=)、LT(<)、LE(<=)、GT(>)、GE(>=) int CMP : 4; int OPARG : 12;// 修改*uaddr2的参数 int CMPARG : 12;// 判断*uaddr2是否知足条件的参数 }
futex_wake_op搞这么一套复杂的逻辑,无非是但愿一次系统调用里面处理两把锁,至关于用户态调用两次futex_wake。
假设用户态须要释放uaddr1和uaddr2两把锁(值为0表明未上锁、1表明上锁、2表明上锁且有进程挂起等待),不使用futex_wake_op的话须要这么写:
int old1, old2; atomic { old1 = *uaddr1; *uaddr1 = 0; } if (old1 == 2) { futex_wake(uaddr1, n1); } atomic { old2 = *uaddr2; *uaddr2 = 0; } if (old2 == 2) { futex_wake(uaddr2, n2); }
而使用futex_wake_op的话,只须要这样:
int old1; atomic { old1 = *uaddr1; *uaddr1 = 0; } if (old1 == 2) { futex_wake_op(uaddr1, n1, uaddr2, n2, { // op参数的意思:设置*uaddr2=0,而且若是old2==2,则执行唤醒 OP=SET, OPARG=0, CMP=EQ, CMPARG=2 } ); } else { ... // 单独处理uaddr2 }
搞这么复杂,其实并不只仅是省一次系统调用的问题。由于有可能在unlock(uaddr1)以后,被唤醒的进程立马会去lock(uaddr2)。而这时若是这边还没来得及unlock(uaddr2)的话,被唤醒的进程马上又将被挂起,而后随着这边unlock(uaddr2)又会再度被唤醒。这不折腾么?
这个场景就可能发生在pthread_cond_wait和pthread_cond_signal之间。当pthread_cond_signal在唤醒等待者以后,会释放内部的锁(第9步)。而pthread_cond_wait在被唤醒以后立马又会尝试获取内部的锁,以从新检查状态(第10步)。若不是futex_wake_op将唤醒和释放锁两个动做一笔带过,这中间一定会有强烈的竞争。
固然,使用前面提到的futex_cmp_requeue也能避免过度竞争,pthread_cond_signal不要直接唤醒等待者,而是将其requeue到内部锁的等待队列,等这边释放锁以后才真正将其唤醒。不过既然pthread_cond_signal立马就会释放内部锁,先requeue再wake多少仍是啰嗦了些。
int futex_lock_pi(int *uaddr); int futex_trylock_pi(int *uaddr); int futex_unlock_pi(int *uaddr); int futex_wait_requeue_pi(int *uaddr1, int val1, int *uaddr2); int futex_cmp_requeue_pi(int *uaddr, int n1, int *uaddr2, int n2, int val);
Priority Inheritance,优先级继承,是解决优先级反转的一种办法。 futex_lock_pi/futex_trylock_pi/futex_unlock_pi,是带优先级继承的futex锁操做。 futex_cmp_requeue_pi是带优先级继承版本的futex_cmp_requeue,futex_wait_requeue_pi是与之配套使用的,用于替代普通的futex_wait。 这里面的逻辑很是复杂,稍后能够看阿里七伤的下篇博文……