同步原语:信号量、互斥锁、条件变量与读写锁

在多核处理器成为主流的当下,并发编程已是必备技能。然而,并发带来的竞态条件(Race Condition)和数据不一致问题要求我们借助同步原语来协调多个执行单元对共享资源的访问。本文从底层实现出发,逐一剖析 Linux 中最常用的同步机制:互斥锁、信号量、条件变量、读写锁、自旋锁和 RCU,并给出选

在多核处理器成为主流的当下,并发编程已是必备技能。然而,并发带来的竞态条件(Race Condition)和数据不一致问题要求我们借助同步原语来协调多个执行单元对共享资源的访问。本文从底层实现出发,逐一剖析 Linux 中最常用的同步机制:互斥锁、信号量、条件变量、读写锁、自旋锁和 RCU,并给出选型决策树。

一、互斥锁:内核态与用户态的分水岭

互斥锁(Mutex)是最基础的同步工具,保证同一时刻只有一个线程进入临界区。理解它的关键在于分析无竞争有竞争两种场景下的不同行为。

1.1 pthread_mutex 的内核实现

Linux 中的 pthread_mutex_t 底层依赖 futex(Fast Userspace muTEX)。futex 的设计原则是:在绝大多数无竞争的情况下,完全避免陷入内核。

  • 无竞争路径:调用者执行一次原子 CAS(Compare-And-Swap)操作,直接在用户态将锁状态从"未锁定"改为"已锁定"。这是几条 CPU 指令的事,耗时通常在 10~30 纳秒级别,比一次系统调用的微秒级开销快了两个数量级。
  • 竞争路径:如果 CAS 失败,说明锁已被其他线程持有,调用者通过 futex_wait 系统调用进入内核,将当前线程挂起到等待队列中;当持锁线程释放锁时,通过 futex_wake 唤醒等待者。
// futex 的简化逻辑示意(非真实源码)
int futex_lock(int *uaddr) {
    int expected = 0;
    // 无竞争:原子交换,用户态完成
    if (__atomic_compare_exchange_n(uaddr, &expected, 1,
                                    0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST))
        return 0;  // 加锁成功

    // 有竞争:陷入内核等待
    while (1) {
        if (*uaddr == 0 &&
            __atomic_compare_exchange_n(uaddr, &expected, 1,
                                        0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST))
            return 0;
        syscall(SYS_futex, uaddr, FUTEX_WAIT, 1, NULL, NULL, 0);
    }
}

这种"先尝试用户态,失败再进内核"的分层设计,使得 pthread_mutex_lock 在现实负载中表现出极低的平均延迟。

1.2 互斥锁与二值信号量的区别

虽然二值信号量初始值为 1 时也能实现互斥,但互斥锁有一个关键语义:只有持有锁的线程才能释放它。信号量没有这个限制,因此互斥锁更适合表达"所有权"概念。

二、信号量:P/V 操作的经典抽象

信号量(Semaphore)由 Dijkstra 提出,是一个带有两个原子操作的计数器:

  • P()(Proberen/Wait):等待信号量值大于 0,然后原子减 1;若为 0 则阻塞。
  • V()(Verhogen/Signal):原子加 1,唤醒等待队列中的一个线程。

2.1 二进制信号量与计数信号量

  • 二进制信号量:值只有 0 和 1,等价于互斥锁(但缺少所有权语义)。
  • 计数信号量:值可以大于 1,适合管理有限资源的池,例如连接池、缓冲区槽位等。
// 生产者-消费者(基于 POSIX 有名信号量)
#include <semaphore.h>

#define BUF_SIZE 10

int buffer[BUF_SIZE];
sem_t empty;  // 空槽位数
sem_t full;   // 已填充槽位数
sem_t mutex;  // 互斥访问缓冲区

void* producer(void* arg) {
    for (int i = 0; ; i++) {
        sem_wait(&empty);  // P(empty):等待空槽位
        sem_wait(&mutex);  // P(mutex):进入临界区
        buffer[in] = i;
        in = (in + 1) % BUF_SIZE;
        sem_post(&mutex);  // V(mutex):离开临界区
        sem_post(&full);   // V(full):增加已填充槽位
    }
}

void* consumer(void* arg) {
    while (1) {
        sem_wait(&full);   // P(full):等待已填充槽位
        sem_wait(&mutex);  // P(mutex)
        int val = buffer[out];
        out = (out + 1) % BUF_SIZE;
        sem_post(&mutex);  // V(mutex)
        sem_post(&empty);  // V(empty):释放空槽位
        printf("Consumed: %d\n", val);
    }
}

2.2 有名信号量与无名信号量

  • 无名信号量(unnamed):基于内存,通过 sem_init 创建,线程间共享需放在共享内存中,进程结束即销毁。
  • 有名信号量(named):基于文件系统命名空间,通过 sem_open 创建,可在不相关的进程间共享,需显式 sem_unlink 删除。

信号量适合需要计数能力的场景,例如限制同时访问某资源的线程数量。如果只是简单的互斥,优先选择互斥锁,因为它的语义更清晰,且 modern libc 的优化更成熟。

三、条件变量:与互斥锁协同使用

条件变量(Condition Variable)解决了"等待某个条件成立"的问题。单独的互斥锁无法优雅地实现:线程 A 需要等待线程 B 完成某项操作后再继续执行。

3.1 核心用法与虚假唤醒

条件变量必须与互斥锁配对使用。典型模式如下:

pthread_mutex_lock(&mutex);
while (!predicate) {          // 必须用 while,不能用 if
    pthread_cond_wait(&cond, &mutex);
}
// 条件满足,执行操作
pthread_mutex_unlock(&mutex);

这里使用 while 而非 if 的原因是 虚假唤醒(Spurious Wakeup)pthread_cond_wait 可能在条件并未真正满足时被唤醒,因此每次唤醒后都需要重新检查条件谓词。

3.2 Signal 与 Broadcast

  • pthread_cond_signal:唤醒等待队列中的一个线程。适用于只需一个消费者处理任务的场景。
  • pthread_cond_broadcast:唤醒所有等待线程。适用于多个线程等待同一事件、且事件可能满足多个等待条件的场景。

3.3 超时等待

POSIX 提供了 pthread_cond_timedwait,允许在等待指定时间后自动返回:

struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 5;  // 等待 5 秒

pthread_mutex_lock(&mutex);
while (!ready && ret != ETIMEDOUT) {
    ret = pthread_cond_timedwait(&cond, &mutex, &ts);
}
pthread_mutex_unlock(&mutex);

四、读写锁:共享读与独占写

读写锁(Reader-Writer Lock)将访问者分为两类:读者(只读)和写者(修改)。多个读者可以同时持有锁,但写者需要独占访问。

4.1 适用场景

当共享数据以读操作为主、写操作极少时,使用读写锁可以大幅提升并发度。例如配置表、路由缓存、元数据字典等。

#include <pthread.h>

pthread_rwlock_t rwlock;
int shared_data = 0;

void* reader(void* arg) {
    pthread_rwlock_rdlock(&rwlock);  // 共享读锁
    printf("Reader sees: %d\n", shared_data);
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

void* writer(void* arg) {
    pthread_rwlock_wrlock(&rwlock);  // 独占写锁
    shared_data++;
    printf("Writer updated to: %d\n", shared_data);
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

4.2 写者饥饿问题

在读者源源不断到达的场景下,写者可能长期无法获得锁,这称为写者饥饿(Writer Starvation)。解决方案包括:

  • 写者优先策略:新读者在有写者等待时阻塞,直到写者完成。pthread_rwlock_t 在多数实现中默认偏向写者或公平调度。
  • 公平队列:按到达顺序排队,不分读者写者,直接 FIFO。
  • 限时降级:某些实现允许写者持有锁时临时降级为读者锁。

4.3 何时读写锁反而有害

读写锁并非银弹。如果临界区很短,读写锁的额外开销(需要维护读者计数)可能使其比普通互斥锁更慢。而且当写操作频繁时,读写锁的并发优势完全丧失,只剩下额外复杂度。

经验法则:只有当读操作占比超过 90% 且临界区执行时间较长时,读写锁才值得使用。

五、自旋锁:忙等待的艺术

自旋锁(Spinlock)是一种忙等待锁:当线程无法获取锁时,它不会休眠,而是在一个循环中反复检查锁是否可用。

5.1 x86 TAS 实现

最简单自旋锁基于 Test-And-Set(TAS)原子指令:

#include <stdatomic.h>

typedef atomic_flag spinlock_t;

#define SPINLOCK_INIT ATOMIC_FLAG_INIT

static inline void spin_lock(spinlock_t *lock) {
    while (atomic_flag_test_and_set_explicit(lock, memory_order_acquire))
        ; // 自旋
}

static inline void spin_unlock(spinlock_t *lock) {
    atomic_flag_clear_explicit(lock, memory_order_release);
}

5.2 适用场景与禁忌

自旋锁只适用于以下场景

  • 多核环境:单核下自旋会导致死锁,因为持锁线程无法释放。
  • 极短临界区:例如增加一个计数器、修改一个标志位,耗时远小于一次上下文切换(通常 < 1 微秒)。
  • 内核态或禁用抢占的环境:Linux 内核大量使用自旋锁,因为在中断上下文不能睡眠。

绝对禁忌

  • 在自旋锁保护的临界区中调用睡眠函数(如 sleepmalloc 可能触发内存分配休眠)。
  • 长时间持有自旋锁:会浪费 CPU 周期,降低整体吞吐量。

5.3 混合策略

现代自旋锁常采用先自旋后阻塞的策略:自旋固定次数或固定时间后,若仍无法获取锁,则退化为阻塞式锁(如 mutex)。这种方式结合了自旋锁低延迟和阻塞锁不浪费 CPU 的优点。Linux 内核中的 mutex 就实现了类似机制。

六、RCU:无锁读的极致

RCU(Read-Copy-Update)是 Linux 内核中广泛使用的同步机制,它的核心哲学是:读操作完全无锁,写操作通过复制和延迟释放保证安全

6.1 RCU 的四个阶段

  1. Copy:写者复制一份旧数据结构的副本。
  2. Modify:在副本上修改内容。
  3. Publish:用原子操作将指针从旧版本切换到新版本,从此以后的新读者看到的是修改后的数据。
  4. Grace Period / Free:等待所有在 Publish 之前开始的读操作结束(经过一个 Grace Period),然后安全释放旧数据。
// RCU 风格链表遍历示意(伪代码)
struct node {
    int val;
    struct node *next;
};

struct node *head;

// 读者(完全无锁)
void rcu_reader(void) {
    rcu_read_lock();           // 标记进入 RCU 读临界区
    struct node *p = rcu_dereference(head);
    while (p) {
        printf("%d ", p->val);
        p = rcu_dereference(p->next);
    }
    rcu_read_unlock();         // 离开读临界区
}

// 写者:删除节点
void rcu_delete(struct node *target) {
    struct node *p, **pp;

    for (pp = &head; (p = rcu_dereference(*pp)) != NULL; pp = &p->next) {
        if (p == target) {
            rcu_assign_pointer(*pp, p->next);  // 原子发布新版本
            break;
        }
    }

    synchronize_rcu();  // 等待所有旧读者退出(Grace Period)
    free(p);            // 安全释放旧节点
}

6.2 何时使用 RCU

RCU 最适合读多写极少的数据结构,例如:

  • 内核中的路由表、文件系统挂载表、模块列表
  • 用户态的无锁哈希表、配置缓存

它的优势在于读者侧零开销:不需要锁、不需要原子操作(仅需要一个内存屏障级别的 rcu_dereference)。

6.3 限制

RCU 并非万能:

  • 写操作代价高(需要复制整个结构或路径)。
  • 数据不能包含指针循环引用,因为要等待 Grace Period。
  • 内存回收延迟:旧数据要等一个 Grace Period 才能释放,可能短暂增加内存占用。
  • 仅适合指针可原子替换的数据结构,对需要原地修改的数值型数据无能为力。

七、同步原语选型指南

面对具体问题时,如何选择合适的同步机制?以下决策树可作为参考:

问题特征推荐原语
无竞争为主、偶尔竞争pthread_mutex(futex 优化)
需要限制并发数量(资源池)计数信号量
需等待特定条件成立条件变量 + 互斥锁
读极多、写极少、临界区长读写锁
读极多、写极少、追求极致性能RCU
极短临界区、内核态/中断环境自旋锁
临界区可能睡眠绝对不能用自旋锁

7.1 综合考量维度

  1. 竞争程度:高竞争下自旋锁最差,阻塞式锁或 RCU 更好。
  2. 读/写比例:读占 95% 以上考虑读写锁或 RCU;读写均衡用互斥锁。
  3. 临界区长度:临界区越短,锁的开销占比越大,越需要轻量化机制。
  4. 上下文环境:用户态线程可阻塞,内核中断上下文不可睡眠。

结语

同步原语的选择不是越复杂越好,而是要在正确性、性能、可维护性之间取得平衡。互斥锁经过 futex 优化后已在绝大多数场景下表现优异;信号量适合计数资源的协调;条件变量处理复杂的等待逻辑;读写锁针对读密集型负载;自旋锁服务于最短的临界区;RCU 则为读多写少的场景提供了无锁读的极致方案。理解每种机制的底层实现和开销模型,才能在并发编程中做出明智的决策。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 进程与线程:从 PCB 到内核调度实体
  2. 虚拟内存与分页机制:从 MMU 到 TLB
  3. 系统性能诊断与调优:strace、perf、bpftrace