futex:线程怎么在共享内存上高效同步
clone 造出的线程共享同一块地址空间——这是线程比进程轻的根本,但也带来一个甩不掉的问题:好几个执行流同时读写同一片内存,不协调就会踩对方的脚。同步(锁、条件变量)是刚需。可同步这事,做笨了能笨到把线程的轻全吃光:每次拿锁放锁都陷进内核,系统调用的开销比锁保护的活儿还大。Linux 的 futex(fast userspace mutex,快用户态互斥)就是为破这个局设计的同步原语,POSIX 线程库的 mutex、条件变量全建在它上面。这一篇咱们讲 futex 的设计思想——尤其是它那个"键"的讲究,看一个同步原语怎么在"快"和"通用"之间找平衡。下一篇再看 Cinux 怎么把它实现出来。
先垫底:共享内存为什么逼出同步问题
线程共享地址空间,好处是数据不用拷来拷去,一个线程写的,另一个线程立刻能看见。坏处也正是这点:两个线程同时碰同一块内存,没有任何人替你排队。最经典的翻车是"读-改-写":线程 A 读到一个值、打算改成值加 1,可它刚读完还没写回去,线程 B 也读到同一个旧值、也改成值加 1——两次"加 1"最后只加了一次,丢了一次更新。这类问题统称竞争,根治的办法是同步:给这块内存配一把锁,同一时刻只让一个线程进去做"读-改-写",其余的在外面排队等。
锁要能用,得有一套"谁等、谁走"的协调机制——线程拿不到锁时要能乖乖睡着(别空转烧 CPU),拿到锁的人放手时要能叫醒一个等的人。这就是同步原语要干的活。
笨办法的问题:每次锁操作都进内核
最直白的同步原语,是把"排队、睡觉、叫醒"全交给内核:拿不到锁就 syscall 进内核,内核把你挂到这张锁的等待队列上睡着;放锁的人再 syscall,内核叫醒队首。这套能用,但对线程来说太贵了。线程之所以轻,就是图它共享地址空间、切换便宜;可如果每次拿锁放锁都陷进一次系统调用(用户态切到内核态、保存寄存器、跑内核代码、再切回来),这点轻量全被系统调用的固定开销吃掉了——尤其锁大多数时候根本没竞争(就一个线程在用),为这种无竞争的常见情形每次都付系统调用的税,亏得厉害。
futex 的"fast"就是冲着这个来的。
futex 的核心巧思:用户态快速路径 + 内核慢路径
futex 的全名是 fast userspace mutex——快的、用户态的互斥锁。它的巧思一句话能说完:把无竞争的情形留在用户态,只在真冲突时才进内核。 锁这个整数本身住在共享内存里(一个用户态地址上的字),线程拿锁放锁,先在用户态用原子指令(比如 compare-and-swap)直接戳这个字:戳成功了,锁就是你的,一次系统调用都不用——这是绝大多数时候的情形,因为锁常常没人和你抢。只有当你戳发现"别人正占着、抢不到"时,才退化到慢路径:发一次 futex 系统调用进内核,让内核把你挂在这把锁的等待队列上睡着。放锁的人也一样:用户态原子指令放掉锁,如果当时没人等,完事;如果有人等,才发一次 futex 系统调用叫醒一个。
这么一分,贵的系统调用只发生在真有竞争的时候,而无竞争的常见情形全程在用户态几条原子指令搞定——线程的轻量保住了。pthread 的 mutex、条件变量,都是在这套"用户态快路径 + 内核慢路径"之上搭出来的。
内核怎么认得出"等在哪":键
futex 的字住在用户态一个地址上,可内核要维护等待队列,得有个办法认出"这个等待者等的是哪个 futex"。这个用来识别的东西,叫 futex 的键(key)。wait 的人按键登记、wake 的人按键找等待队列,键对得上才算同一把锁。
键怎么选,是 futex 设计里最有讲究的地方,也是"快"和"通用"拔河的核心。
最朴素的键,就是那个 futex 字的虚拟地址——直接拿 uaddr 当键,简单、算起来几乎免费。可虚拟地址是进程局部的:同一个虚拟地址,在两个不同的进程里指向的是两块不同的物理内存。所以拿虚拟地址当键,futex 只能管住同一个地址空间里的同步——也就是线程之间(线程共享地址空间,同一个 uaddr 指向同一块内存,键对得上)。这够 pthread 的常规用途(一个进程里的线程同步),但管不了跨进程:两个进程想通过一段共享内存上的同一个字做 futex,它们各自的 uaddr 可能完全不同,按虚拟地址当键就对不上。
要支持跨进程 futex,键就不能停在虚拟地址,得落到底层的物理对象上:这个 futex 字背后到底是哪段共享内存。Linux 的做法是按这个字所在的映射类型分两种键——私有、匿名映射(就这个进程自己用的),键用"地址空间 + 虚拟地址";文件映射或共享匿名映射(可能被多个进程映射的),键用"文件 inode + 文件内偏移",这样两个进程只要映射的是同一个文件的同一个位置,内核就认它们命的是同一个 futex。这套更通用,代价是内核每次都得去解析这个地址背后的映射(VMA、inode),比直接拿虚拟地址贵。
private flag:一份"我用不到跨进程"的承诺
可大多数 futex 压根不跨进程——就是同一进程里的线程锁。如果每次都老老实实走"解析映射"那一套通用路径,又白白贵了。于是有个 FUTEX_PRIVATE_FLAG:它是用户态给内核的一份承诺——"这个 futex 永远不会跨进程共享"。内核收到这份承诺,就放心跳过映射解析,直接用便宜的"地址空间 + 虚拟地址"键。一份 flag,让通用而稍贵的机制,在不需要通用的时候退回便宜的那档。pthread 库通常默认给进程内的锁打上 private flag,因为它们八成不跨进程。
这一篇留下了什么
futex 的骨架就这些:一个住在用户态共享内存里的整数锁,无竞争时用户态原子指令直接搞定(快路径),真冲突才进内核挂等待队列(慢路径);内核靠"键"认出每把锁,键选得越贴近物理对象越能跨进程、但也越贵,于是用 private flag 让不需要跨进程的锁退回便宜的键。它在"快"和"通用"之间架了座桥,是现代用户态同步的底座。下一篇咱们看 Cinux 怎么实现它——它选了键的哪一档、代价是什么。