操作系统-Futex机制
参考链接:
[linux futex浅析] https://developer.aliyun.com/article/6043?spm=a2c6h.13262185.0.0.575c4070sPb94F
[Linux Futex的设计与实现] https://blog.csdn.net/jianchaolv/article/details/7544316#:~:text=Futex%20%E6%98%AFFast%20Userspace%20muTexes%E7%9A%84%E7%BC%A9%E5%86%99%EF%BC%8C%E7%94%B1Hubertus%20Franke%2C%20Matthew%20Kirkwood%2C%20Ingo,%28inter%20process%20communication%29%EF%BC%8C%E5%A6%82%20semaphores%2C%20msgqueues%2C%20sockets%E8%BF%98%E6%9C%89%E6%96%87%E4%BB%B6%E9%94%81%E6%9C%BA%E5%88%B6%20%28flock%20
futex诞生之前
在futex诞生之前,linux下的同步机制可以归为两类:用户态的同步机制 和 内核同步机制。
用户态的同步机制基本上就是利用原子指令实现的spinlock。
内核提供的同步机制,诸如semaphore、等,其实骨子里也是利用原子指令实现的spinlock,内核在此基础上实现了进程的睡眠与唤醒。
使用这样的锁,能很好的支持进程挂起等待。但是最大的缺点是每次lock与unlock都是一次系统调用,即使没有锁冲突,也必须要通过系统调用进入内核之后才能识别。
理想的同步机制应该是在没有锁冲突的情况下在用户态利用原子指令就解决问题,而需要挂起等待时再使用内核提供的系统调用进行睡眠与唤醒。换句话说,用户态的spinlock在trylock失败时,能不能让进程挂起,并且由持有锁的线程在unlock时将其唤醒?
futex来了
现在看来,要实现我们想要的锁,对内核就有两点需求:1、支持一种锁粒度的睡眠与唤醒操作;2、管理进程挂起时的等待队列。
于是futex就诞生了。futex主要有futex_wait和futex_wake两个操作:
// 在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混合用户态和内核态操作,在低并发情况下,减少了系统调用,提高了性能。
在用户态检测到竞争时,需要进入内核态执行挂起到此进程在此共享变量地址上,注意这一步是要同时传地址和当前地址值过去,才能保证期间如果发生了unlock现象,cas操作就操作不成功。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)