05

并发

多线程编程

阅读量:4 · 预计 14 分钟读完

线程条件变量信号量
阅读进度4%

第五章 并发

导读

并发(Concurrency)是操作系统和现代软件系统中最重要也最复杂的主题之一。当多个执行流(进程或线程)同时访问共享资源时,如果不加以正确的同步和控制,就会出现竞态条件(Race Condition),导致程序行为不确定甚至错误。

本章将系统介绍并发的基本原理和同步机制。我们将从线程的概念开始,逐步深入锁(Lock)、条件变量(Condition Variable)、信号量(Semaphore)等核心同步原语,讨论经典的并发问题(生产者-消费者、读者-写者等),最后介绍无锁编程和并发编程的最佳实践。理解并发原理,对于编写正确的多线程程序、设计高效的操作系统至关重要。


5.1 并发的基本概念

5.1.1 什么是并发

并发是指多个计算活动在重叠的时间段内执行。在单核处理器上,并发通过时间片轮转实现——多个线程交替执行,宏观上看起来是"同时"运行。在多核处理器上,并发可以通过真正的并行实现——多个线程在不同核心上同时执行。

并发带来了巨大的好处:

  • 提高资源利用率:一个线程等待I/O时,其他线程可以继续执行。
  • 提高响应性:长时间运行的任务可以在后台执行,不阻塞用户交互。
  • 利用多核:将工作分散到多个核心,提高吞吐量。

但并发也带来了严峻的挑战:

  • 竞态条件:多个线程访问共享数据时,执行顺序不确定,可能导致错误结果。
  • 死锁:多个线程互相等待对方持有的资源,导致所有线程都无法继续。
  • 饥饿:某些线程长期得不到资源,无法执行。
  • 调试困难:并发bug通常是非确定性的,难以复现和定位。

5.1.2 原子性与竞态条件

原子性(Atomicity):一个操作是原子的,意味着它要么完全执行,要么完全不执行,中间状态对外不可见。

竞态条件(Race Condition):当多个线程访问共享数据,且最终结果取决于线程的执行顺序时,就存在竞态条件。

经典示例:

c
// 共享变量
int counter = 0;

// 线程A和线程B都执行以下操作
void increment() {
    counter++;  // 这不是原子操作!
}

counter++ 实际上包含三个步骤:

从内存读取 counter 的值到寄存器。

寄存器值加1。

将寄存器的值写回内存。

如果线程A和线程B交错执行,可能出现以下情况:

  • A读取counter=0
  • B读取counter=0
  • A将counter写为1
  • B将counter写为1
  • 最终结果:counter=1(期望是2)

这就是典型的竞态条件。

5.1.3 临界区与互斥

临界区(Critical Section):访问共享资源的代码段。在临界区中,必须保证互斥——同一时刻最多只有一个线程在临界区内执行。

互斥(Mutual Exclusion):保证同一时刻最多只有一个线程进入临界区的机制。

临界区问题的解决方案必须满足以下要求:

互斥:同一时刻最多一个线程在临界区。

进步(Progress):如果没有线程在临界区,且有线程想进入,则必须允许其中一个进入。

有界等待(Bounded Waiting):线程请求进入临界区后,必须在有限步内被允许进入(不能无限等待)。


5.2 线程

5.2.1 线程的概念

线程(Thread)是进程内的执行单元。一个进程可以包含多个线程,它们共享进程的地址空间(代码、数据、堆)和文件描述符,但每个线程有独立的栈和寄存器状态。

线程的优势:

  • 轻量级:创建和销毁线程的开销远小于进程。
  • 共享方便:同一进程的线程天然共享内存,通信方便。
  • 响应性好:长时间操作可以在后台线程执行,不阻塞主线程。

5.2.2 用户级线程与内核级线程

用户级线程(User-Level Thread)

  • 线程管理在用户空间完成,内核不可见。
  • 优点:切换开销小(无需陷入内核)、调度灵活。
  • 缺点:一个线程阻塞会导致整个进程阻塞、无法利用多核。

内核级线程(Kernel-Level Thread)

  • 线程管理由内核完成,内核为每个线程维护TCB(Thread Control Block)。
  • 优点:一个线程阻塞不影响其他线程、可以利用多核。
  • 缺点:切换开销大(需要陷入内核)。

现代操作系统的选择:Linux、Windows等现代操作系统都采用内核级线程。在Linux中,线程通过 clone() 系统调用创建,每个线程对应一个 task_struct

5.2.3 POSIX线程(pthreads)

POSIX线程(pthreads)是UNIX/Linux系统的标准线程API:

c
#include <pthread.h>

// 创建线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void *), void *arg);

// 等待线程结束
int pthread_join(pthread_t thread, void **retval);

// 线程退出
void pthread_exit(void *retval);

5.3 锁(Lock)

5.3.1 锁的基本概念

锁(Lock)是最基本的同步原语,用于实现互斥。锁有两种状态:锁定(Locked)和未锁定(Unlocked)。

  • 加锁(Lock/Acquire):如果锁未锁定,则将其设为锁定状态并继续;如果锁已锁定,则等待直到锁可用。
  • 解锁(Unlock/Release):将锁设为未锁定状态,允许其他线程获取。

使用锁保护临界区:

c
lock_t mutex;

void critical_section() {
    lock(&mutex);
    // 临界区代码
    counter++;
    unlock(&mutex);
}

5.3.2 锁的实现

基于硬件的原子指令

现代处理器提供了一些原子指令,可以用来实现锁:

  • 测试并设置(Test-and-Set, TAS):原子地读取一个内存位置的值并将其设为1,返回旧值。
  • 比较并交换(Compare-and-Swap, CAS):原子地比较内存位置的值与预期值,如果相等则写入新值,返回旧值。
  • 获取并相加(Fetch-and-Add, FAA):原子地将内存位置的值加1,返回旧值。

基于TAS的自旋锁实现:

c
typedef struct {
    int flag;  // 0: unlocked, 1: locked
} lock_t;

void init(lock_t *lock) {
    lock->flag = 0;
}

void lock(lock_t *lock) {
    while (test_and_set(&lock->flag) == 1) {
        // 自旋等待
    }
}

void unlock(lock_t *lock) {
    lock->flag = 0;
}

5.3.3 锁的评价标准

  • 互斥性:锁是否正确地实现了互斥?
  • 公平性:是否每个请求锁的线程最终都能获得锁?
  • 性能

- 无竞争时的开销(单线程获取和释放锁的开销)。

- 有竞争时的开销(多线程竞争锁的开销)。

- 单CPU上的开销。

- 多CPU上的开销。

5.3.4 锁的类型

自旋锁(Spinlock)

  • 获取锁失败的线程不断循环检查(自旋)。
  • 优点:无需上下文切换,延迟低。
  • 缺点:浪费CPU时间。
  • 适用场景:锁持有时间短、多处理器系统。

互斥锁(Mutex)

  • 获取锁失败的线程被阻塞(睡眠),让出CPU。
  • 优点:不浪费CPU时间。
  • 缺点:涉及上下文切换,延迟较高。
  • 适用场景:锁持有时间长、单处理器或多处理器系统。

读写锁(Read-Write Lock)

  • 允许多个读者同时访问,但写者独占。
  • 适用于读多写少的场景。

乐观锁(Optimistic Lock)

  • 不加锁执行操作,提交时检查是否有冲突。
  • 适用于冲突概率低的场景。

5.4 条件变量(Condition Variable)

5.4.1 条件变量的概念

条件变量(Condition Variable)用于线程间的协调——一个线程等待某个条件成立,另一个线程在条件成立时唤醒等待的线程。

条件变量提供两个基本操作:

  • wait(cv, mutex):释放mutex,等待cv被通知。被唤醒后重新获取mutex。
  • signal(cv):唤醒一个等待cv的线程(如果有)。
  • broadcast(cv):唤醒所有等待cv的线程。

5.4.2 生产者-消费者问题

生产者-消费者问题( bounded-buffer problem)是经典的并发问题:

c
// 共享缓冲区
int buffer[MAX];
int count = 0;
lock_t mutex;
cond_t cond_prod, cond_cons;

void producer(int item) {
    lock(&mutex);
    while (count == MAX) {
        wait(&cond_prod, &mutex);
    }
    buffer[count++] = item;
    signal(&cond_cons);  // 通知消费者
    unlock(&mutex);
}

void consumer() {
    lock(&mutex);
    while (count == 0) {
        wait(&cond_cons, &mutex);
    }
    int item = buffer[--count];
    signal(&cond_prod);  // 通知生产者
    unlock(&mutex);
    return item;
}

5.4.3 使用条件变量的注意事项

始终在循环中检查条件:使用 while 而非 if,因为可能存在虚假唤醒(Spurious Wakeup)或多个线程被同时唤醒。

始终配合互斥锁使用:条件变量必须与互斥锁配合使用,以避免竞态条件。

注意信号丢失:如果在 signal() 时没有线程在等待,信号会丢失。

注意死锁:确保在正确的时机获取和释放锁。


5.5 信号量(Semaphore)

5.5.1 信号量的概念

信号量(Semaphore)由Dijkstra提出,是一个更通用的同步原语。信号量是一个整数变量,支持两个原子操作:

  • wait(S) / P(S) / down(S):S = S - 1;如果 S < 0,则阻塞。
  • signal(S) / V(S) / up(S):S = S + 1;如果 S ≤ 0,则唤醒一个等待的线程。

5.5.2 信号量的类型

二值信号量(Binary Semaphore)

  • 信号量值只能是0或1。
  • 等价于互斥锁。
  • 用于实现互斥。

计数信号量(Counting Semaphore)

  • 信号量值可以是任意非负整数。
  • 用于控制对有限资源的访问。
  • 初始值为可用资源的数量。

5.5.3 用信号量解决生产者-消费者问题

c
sem_t empty;   // 空槽位数,初始为MAX
sem_t full;    // 满槽位数,初始为0
sem_t mutex;   // 互斥锁,初始为1

void producer(int item) {
    wait(&empty);      // 等待空槽
    wait(&mutex);      // 进入临界区
    buffer[add(item);
    signal(&mutex);    // 离开临界区
    signal(&full);     // 增加满槽数
}

void consumer() {
    wait(&full);       // 等待满槽
    wait(&mutex);      // 进入临界区
    int item = remove();
    signal(&mutex);    // 离开临界区
    signal(&empty);    // 增加空槽数
    return item;
}

5.5.4 读者-写者问题

读者-写者问题允许多个读者同时读,但写者独占:

c
sem_t rw_mutex;   // 读写互斥,初始为1
sem_t mutex;      // 读者计数互斥,初始为1
int read_count = 0;

void reader() {
    wait(&mutex);
    read_count++;
    if (read_count == 1) {
        wait(&rw_mutex);  // 第一个读者获取读写锁
    }
    signal(&mutex);
    
    // 读操作
    read_data();
    
    wait(&mutex);
    read_count--;
    if (read_count == 0) {
        signal(&rw_mutex);  // 最后一个读者释放读写锁
    }
    signal(&mutex);
}

void writer() {
    wait(&rw_mutex);
    // 写操作
    write_data();
    signal(&rw_mutex);
}

5.6 经典并发问题

5.6.1 哲学家就餐问题

五个哲学家围坐在圆桌旁,每人左右各有一支筷子。哲学家需要同时拿到左右两支筷子才能就餐。如何避免死锁?

解决方案

资源分级:规定哲学家先拿编号小的筷子,再拿编号大的筷子。

限制就餐人数:最多允许四个哲学家同时拿筷子。

Chandy/Misra算法:分布式解决方案,使用消息传递。

5.6.2 吸烟者问题

三个吸烟者共享一张桌子,桌上有烟草、纸和火柴。每个吸烟者需要其中两种材料才能吸烟。一个代理轮流在桌上放两种材料,对应的吸烟者取走材料吸烟。

5.6.3 睡眠理发师问题

一个理发师、一把理发椅、N把等待椅。没有顾客时理发师睡觉。顾客来了如果理发师在睡觉则唤醒他,否则在等待椅等待。如果等待椅满了,顾客离开。


5.7 死锁

5.7.1 死锁的条件

死锁(Deadlock)是指两个或多个线程互相等待对方持有的资源,导致所有线程都无法继续。死锁发生的四个必要条件(Coffman条件):

互斥(Mutual Exclusion):资源一次只能被一个线程使用。

持有并等待(Hold and Wait):线程持有至少一个资源,同时请求另一个被其他线程持有的资源。

不可抢占(No Preemption):资源只能由持有者主动释放,不能被强制剥夺。

循环等待(Circular Wait):存在一个线程等待环路。

5.7.2 死锁的处理策略

预防(Prevention)

  • 破坏四个必要条件之一。
  • 例如:要求线程一次性申请所有资源(破坏"持有并等待")。

避免(Avoidance)

  • 在运行时动态检查资源分配状态,确保系统始终处于安全状态。
  • 经典算法:银行家算法(Banker's Algorithm)。

检测与恢复(Detection and Recovery)

  • 允许死锁发生,定期检测,发现后采取措施恢复。
  • 恢复方法:终止死锁线程、抢占资源等。

忽略(Ignorance)

  • 假装死锁不会发生(如Linux和Windows的默认策略)。
  • 如果发生死锁,手动重启系统。

5.8 无锁编程

5.8.1 无锁数据结构

无锁(Lock-Free)数据结构不依赖传统的锁,而是使用原子操作实现并发访问。无锁数据结构可以保证至少一个线程在有限步内完成操作。

无锁栈示例(基于CAS)

c
typedef struct node {
    int data;
    struct node *next;
} node_t;

typedef struct {
    node_t *top;
} stack_t;

void push(stack_t *s, int data) {
    node_t *new_node = malloc(sizeof(node_t));
    new_node->data = data;
    do {
        new_node->next = s->top;
    } while (!CAS(&s->top, new_node->next, new_node));
}

int pop(stack_t *s) {
    node_t *old_top;
    do {
        old_top = s->top;
        if (old_top == NULL) return -1;  // 栈空
    } while (!CAS(&s->top, old_top, old_top->next));
    return old_top->data;
}

5.8.2 无锁编程的挑战

  • ABA问题:CAS检查值是否变化,但值可能从A变为B再变回A,CAS无法检测到这种变化。
  • 内存回收:无锁数据结构中,节点的内存回收需要特别小心,避免其他线程仍在使用该节点。
  • 复杂性:无锁算法通常比加锁算法复杂得多,正确性证明困难。

5.9 常见误区

误区一:使用volatile可以替代锁

volatile 关键字只保证编译器不会优化掉对变量的访问,但不保证原子性。在多线程环境中,volatile 不能替代锁。

误区二:加锁粒度越细越好

细粒度锁可以减少竞争,但增加了死锁风险和代码复杂度。锁的粒度需要根据具体场景权衡。

误区三:无锁编程总是比加锁快

无锁编程在某些场景下性能更好,但并非总是如此。在高竞争场景下,CAS的重试开销可能超过锁的开销。此外,无锁代码更复杂、更容易出错。

误区四:死锁只发生在多线程程序中

死锁也可以发生在多进程环境中(如两个进程互相等待对方持有的文件锁)。分布式系统中的死锁更加隐蔽和复杂。

误区五:条件变量的signal会立即唤醒等待线程

signal() 只是标记条件可能成立,等待线程被唤醒后需要重新获取互斥锁才能继续执行。如果此时互斥锁被其他线程持有,等待线程会继续阻塞。


5.10 实践应用

5.10.1 使用pthreads编程

c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

#define NUM_THREADS 4
int counter = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *thread_func(void *arg) {
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&mutex);
        counter++;
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

int main() {
    pthread_t threads[NUM_THREADS];
    
    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_create(&threads[i], NULL, thread_func, NULL);
    }
    
    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }
    
    printf("Final counter: %d\n", counter);
    pthread_mutex_destroy(&mutex);
    return 0;
}

5.10.2 使用Thread Sanitizer检测并发bug

bash
# 编译时启用Thread Sanitizer
gcc -fsanitize=thread -g program.c -o program -lpthread

# 运行程序,TSan会报告数据竞争
./program

5.10.3 并发编程最佳实践

最小化共享:尽量减少线程间共享的数据。

不可变数据:如果数据在创建后不再修改,可以安全地共享。

线程局部存储:使用线程局部变量(__threadthread_local)避免共享。

高级抽象:优先使用高级并发抽象(如并发队列、actor模型)而非原始锁。

形式化验证:对关键并发代码使用模型检查工具验证正确性。


5.11 本章小结

本章系统介绍了并发的基本原理和同步机制,主要内容包括:

并发基础:并发带来好处也带来挑战。竞态条件和原子性是理解并发的关键概念。

线程:线程是进程内的执行单元,共享地址空间但有独立的栈和寄存器。现代操作系统使用内核级线程。

:锁是实现互斥的基本机制。自旋锁适合短临界区,互斥锁适合长临界区。锁的实现依赖硬件原子指令。

条件变量:用于线程间协调,配合互斥锁使用。生产者-消费者问题是经典应用。

信号量:更通用的同步原语,可以实现互斥和资源计数。读者-写者问题是典型应用。

经典并发问题:哲学家就餐、吸烟者问题、睡眠理发师等问题展示了并发编程的复杂性。

死锁:四个必要条件(互斥、持有并等待、不可抢占、循环等待)。处理策略包括预防、避免、检测与恢复。

无锁编程:使用原子操作实现并发数据结构,避免锁的开销,但实现复杂。

并发是操作系统和现代软件系统的核心挑战。正确理解和使用同步机制,是编写可靠并发程序的基础。