目录

并发、并行与锁

并发编程时,经常要用到锁。锁的概念有很多,例如悲观锁、乐观锁、自旋锁等等。在使用锁的过程中,始终有一个疑问:在使用多核处理器的情况下,位于不同 CPU 的线程在修改同一个全局变量的时候,是如何做到加锁的?

最早的操作系统没有线程这个概念,只有进程的概念,此时进程是操作系统分配资源和调度的基本单位。而我们现在正在使用的操作系统通常都是多线程操作系统,线程是操作系统调度的基本单位,进程仍是操作系统分配资源的基本单位。

在不考虑使用超线程技术的 CPU 的时候,CPU 本身没有线程这种概念。以下均不考虑超线程 CPU。

单核处理器

多线程操作系统使用单核处理器,同一时刻只能有一个线程正在执行。

这样在加锁的时候,不会有两个线程同时执行加锁指令,必然是有一个先后顺序。如果两个线程要获取同一个锁,在锁被其中一个线程获取到后,在线程释放该锁前,另一个线程无法获取该锁。

线程在执行过程中会因为中断而切换到其他线程。在程序上观察加锁操作可能只有一条调用的代码,但在执行的时候是多条指令。一旦发生中断,就可能会出现问题。操作系统提供了锁的原子操作。这个操作主要通过开关中断来实现。

关闭中断后,不会被切换到其他线程。此时可以加载内存里的锁状态,然后判断锁是否已被占用。如果被占用则开启中断,让其他线程继续执行。然后再关闭中断,再次判断,直到锁的状态是空闲。此时由于没有开启中断,所以可以将锁在内存的状态更新为已被占用,然后开启中断。这样完成了获取锁的任务。

加锁的方式不只有这一种,还可以用 CPU 提供的 test_and_set 指令。

多核处理器

多线程操作系统使用多核处理器,同一时刻可能有多个线程正在执行。

但是要区分两种情况:

  1. 程序限制只使用单核
    例如 CPython 解释器是单线程的,虽然 threading 封装了操作系统的原生线程,但只有获取到 GIL (Global Interpreter Lock,全局解释器锁)的线程才能执行,因此同一时刻只有一个线程能够执行。
  2. 程序开启多核支持
    例如 C++ 可以用 std::thread;Golang 通过执行 runtime.GOMAXPROCS(runtime.NumCPU()) 开启多核支持。

在使用多核的情况下,一个进程的多个线程可能分布在不同核心上同时执行。如果这些同时执行的线程想要获取同一个锁,就会产生冲突。

操作系统级别的锁原语无法发挥作用,只能由 CPU 去处理。CPU 必须将多个核心对同一个内存区域的访问串行化。

CPU 提供了总线锁和缓存锁两种方式。

CPU 总线是所有核心与芯片组连接的主干道。当一个线程要获取锁(操作共享内存)的时候,其所在核心会发出一个 LOCK 信号,阻止其他处理器访问内存。

由于锁住总线会阻止其他核心访问其他内存地址的数据,所以提供了缓存一致性机制来减少开销。

参考