OS-并发-锁

互斥

《从入门到放弃》之中,写到并发编程需要放弃原子性、顺序性、可见性。

现在,我们需要让代码重新获得原子性和顺序性。为此,我们需要互斥,也就是阻止并发的发生,阻止多个线程同时读写数据。

乍一看这似乎有些多此一举,好不容易用多核实现了并行与并发了又要阻止并行与并发。但要清晰的认识到,当锁的粒度足合理时代码中依旧有相当大一部分能够并行执行,起到加速作用。

  • 细粒度锁:将互斥范围限制在最小的临界区(如单个变量或短代码段),允许其他线程在锁外并行执行。
  • 锁分离:为不同资源分配独立锁,避免全局锁导致的串行化。
  • 读写分离:读取操作往往可以正常并行,只有写操作需要互斥执行,这样可以进一步提高并行程度。

硬件为我们实现的互斥

在编程中,“检查一个变量然后确定要不要修改一个变量“这个操作太过常见。以至于硬件提供了原子指令来保证一条指令实现这个功能并且是原子的。

我们只需要保证这个操作是原子的,硬件会确保只有一个线程能“看到”整个操作的完整效果,而其它线程要么看到操作完全未发生,要么看到操作完全发生,从而避免了数据不一致的问题。

1
2
3
4
bool lock;
if(lock){
    lock=false;
}

这样我们就能够实现一个自旋锁,提供 get_lock 和 unlock 来保护临界区。其核心思想是:

抢锁机制:线程通过原子指令尝试获取锁,成功则进入临界区;失败则循环等待(自旋),直到锁可用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
typedef struct {
  atomic_bool lock;
} Lock;
Lock lock = {false};

void get_lock() {
  bool expected = false;
  while (true) {
    expected = false;
    // 原子比较交换:如果 lock == expected (false),则设为 true
    if (atomic_compare_exchange_weak(&lock.lock, &expected, true)) {
      break; // 成功获得锁
    }
    // 如果失败,expected 已被改成 true,循环继续尝试
  }
}

void unlock() { atomic_store(&lock.lock, false); }

操作系统帮我们实现的互斥

自旋锁完成了保护临界区的任务,但问题是这样会存在大量空转的线程:

  1. 若持有锁的线程被抢占(如I/O阻塞),其他线程会持续自旋,导致 CPU 空转。
  2. 多核场景下,多个核心可能同时空转,加剧资源浪费。

自旋锁不公平:自旋锁可能会让某个线程永远自旋,导致线程“饿死”。

为此,计算机设计了一种机制,当线程自己判断要等待很久才能获取锁时,不如主动告诉操作系统把自己挂起,避免浪费 CPU 进行无意义的空转。

队列:休眠代替自旋

之前的方法有一个共同的问题:不公平。如果调度器实现的不合理,可能出现一个线程一直自旋或让出CPU的问题。

一种可行的解决方案是构建一个 FIFO 等待锁的队列:

  • 线程试图获取锁:
    • 成功获取锁
    • 失败,锁已被占用。将自己加入等待队列,然后休眠让出 CPU。
  • 当锁被释放时从等待队列中弹出并唤醒顶部的线程。

futex

Linux 中的 futex (fast userspace mutex) 其设计目标是在绝大多数情况下避免内核态的切换,从而提高性能。

其基本思想是:在用户空间利用原子操作(比如 cmpxchg 等)进行快速的无锁检测和操作;只有在竞争激烈、需要阻塞线程时,才通过系统调用进入内核态进行等待和唤醒。


信号量

信号量有两个核心函数:

sem_wait()

sem_wait() 的主要作用是尝试获取资源,其行为如下:

  • 资源可用时:如果信号量的值大于 0,sem_wait() 会将信号量的值减 1,并立即返回,表示资源已被成功获取。
  • 资源不可用时:如果信号量的值等于 0,sem_wait() 会将调用线程挂起(阻塞),直到信号量的值变为正数(即资源可用)。
  • 多个线程等待时:如果有多个线程调用 sem_wait() 并等待,它们会被放入等待队列,当资源可用时,其中一个线程会被唤醒。

sem_post()

sem_post() 的主要作用是释放资源,其行为如下:

  • 增加信号量值sem_post() 会将信号量的值加 1,表示资源已被释放。
  • 唤醒等待线程:如果有线程正在等待(即信号量的值为负数),sem_post() 会唤醒其中一个等待线程。
  • 无阻塞sem_post() 不会阻塞调用线程,它总是立即返回。

将信号量作为锁:通过将信号量初始化为 1,sem_wait() 和 sem_post() 可以实现互斥锁的功能。

条件同步:通过将信号量初始化为 0,sem_wait() 和 sem_post() 可以实现线程间的等待和通知机制。

信号量和互斥锁的区别

特征维度 互斥锁 信号量
所有权 严格绑定加锁线程 无所有权限制
初始值范围 必须为1(二值信号量) 任意非负整数(≥0)
唤醒机制 仅允许加锁线程唤醒 任意线程可触发唤醒
适用场景 单一临界区保护 资源池管理/同步协调
  1. 拥有权概念

    • 互斥锁具有"拥有权"概念,只有加锁的同一线程才能解锁。
    • 信号量是一个简单的非负计数器,没有线程拥有权的限制,任意线程都可以调用sem_post()或sem_wait()。
  2. 实现机制

    • 互斥锁通常实现为二值状态(持有/未持有)。
    • 信号量是一个计数机制,可以表示可用资源的数量。

信号量特别适合以下并发问题:

  1. 资源管理:当有多份有限资源需要管理时,信号量可以很好地表示可用资源数量。
  2. 生产者/消费者模型:这是信号量的经典应用场景,可以优雅地解决缓冲区同步问题。

信号量的局限性

虽然信号量很强大,但在某些情况下可能不是最佳选择:

  1. 复杂条件判断:当程序执行需要根据复杂条件获取多个资源时,条件变量可能更合适。
  2. 实现难度:用信号量实现条件变量比看起来要困难得多,容易引入缺陷。
  3. 调度复杂性:对于需要全局协调的复杂并发问题,引入专门的调度线程可能更可靠。
updatedupdated2025-04-272025-04-27