mutex, 锁 Mutual exclusion (或者锁) 的实现有硬件实现和软件实现, 软件实现是通过一些特别的算法譬如 Peterson’s algorithm,这类软件实现通常比硬件实现需要更多的内存,而且由于现代计算机的乱序执行,需要手动加memory barrier来保证memory ordering,这里暂时不做讨论纯软件实现。CPU如果提供一些用来构建锁的atomic指令,一般会更高效一些。
锁的本质 所谓的锁,在计算机里本质上就是一块内存空间。当这个空间被赋值为1的时候表示加锁了,被赋值为0的时候表示解锁了,仅此而已。多个线程抢一个锁,就是抢着要把这块内存赋值为1。在一个多核环境里,内存空间是共享的。每个核上各跑一个线程,那如何保证一次只有一个线程成功抢到锁呢?你或许已经猜到了,这必须要硬件的某种 guarantee。具体的实现如下。
硬件 CPU如果提供一些用来构建锁的 atomic 指令, 譬如 x86 的 CMPXCHG (加上LOCK prefix), 能够完成 atomic 的 compare-and-swap (CAS), 用这样的硬件指令就能实现 spin lock. 本质上 LOCK 前缀的作用是锁定系统总线 (或者锁定某一块cache line) 来实现atomicity,可以了解下基础的缓存一致协议譬如MSEI。简单来说就是,如果指令前加了LOCK前缀,就是告诉其他核,一旦我开始执行这个指令了,在我结束这个指令之前,谁也不许动。缓存一致协议在这里面扮演了重要角色,这里先不赘述。这样便实现了一次只能有一个核对同一个内存地址赋值。
操作系统 mutex在linux内核中由 futex 系统调用支撑,如果没有竞争不需要陷入内核;内核的主要是 futex_wait/wake 函数配合上层完成业务逻辑;
一个 spin lock 就是让没有抢到锁的线程不断在 while 里面循环进行 compare-and-swap, 燃烧CPU, 浪费青春, 直到前面的线程放手 (对应的内存被赋值0) 。这个过程不需要操作系统的介入,这是运行程序和硬件之间的故事。如果需要长时间的等待,这样反复CAS轮询就比较浪费资源,这个时候程序可以向操作系统申请被挂起,然后持锁的线程解锁了以后再通知它。这样CPU就可以用来做别的事情,而不是反复轮询。 但是OS切换线程也需要一些开销,所以是否选择被挂起,取决于大概是否需要等很长时间,如果需要,则适合挂起切换为别的线程。线程向操作系统请求被挂起是通过一个系统调用,在linux上的实现就是 futex, 宏观来讲, OS需要一些全局的数据结构来记录一个被挂起线程和对应的锁的映射关系,这样一个数据结构天然是全局的,因为多个OS线程可能同时操作它。所以,实现高效的锁本身也需要锁。有没有一环套一环的感觉?futex的巧妙之处就在于,它知道访问这个全局数据结构不会太耗时,于是futex里面的锁就是spin lock。linux上pthread mutex 的实现就是用的 futex 。更多精彩内存参考talk: https://www.infoq.com/presentations/go-locks/
用户态的锁 像Goroutine线程就是go runtime来调度的,而不是OS,所以go routine线程切换的开销要远小于OS线程切换的开销。Goroutine的本质就是一个coroutine,这种轻量的线程在很多语言的runtime里面都有实现。最近Java也有了 (project loom) 。
...