C高级:关于互斥锁(Mutex)与信号量(Semaphore)的学习记录

多线程编程中,多个线程同时访问共享资源时会产生数据竞争(Data Race),导致程序行为不可预测。互斥锁和信号量是 Linux 下最常用的两种同步机制,用于保护共享资源、协调线程执行顺序。本文将从概念原理到实际代码,系统讲解互斥锁和信号量的含义、作用,以及如何创建和使用它们。以下是我在职坐标的学习记录:

一、互斥锁(Mutex)

1.1 什么是互斥锁

互斥锁(Mutual Exclusion Lock,简称 Mutex)是一种用于保护共享资源的同步机制。它的核心思想是:同一时刻只允许一个线程持有锁,其他试图获取同一把锁的线程会被阻塞(挂起等待),直到锁被释放。

可以这样理解:互斥锁就像公共厕所的门锁——一个人进去后锁上门,其他人只能在外面排队等待;当前使用者出来并解锁后,下一个人才能进去。

1.2 互斥锁的作用

  • 防止数据竞争:当多个线程同时读写同一个全局变量或共享资源时,使用互斥锁可以保证同一时刻只有一个线程在操作,避免数据不一致。
  • 保护临界区(Critical Section):临界区是指访问共享资源的那段代码。互斥锁确保临界区内的代码被原子地执行,不会被其他线程打断。
  • 保证操作的原子性:某些操作(如 count++)在 CPU 层面其实不是原子的(包含读-改-写三步),互斥锁可以让这类操作变得"逻辑上原子"。

1.3 互斥锁的工作原理

线程 A                          线程 B
   │                               │
   ├─ pthread_mutex_lock() ──→ 获取锁成功
   │  (进入临界区)                 │
   │                               ├─ pthread_mutex_lock() ──→ 阻塞等待...
   │                               │
   ├─ pthread_mutex_unlock() ──→ 释放锁
   │                               │
   │                               └─ 被唤醒,获取锁成功
   │                                  (进入临界区)

1.4 互斥锁的类型

POSIX 标准定义了四种互斥锁类型:

类型 说明
PTHREAD_MUTEX_NORMAL 普通锁(默认)。一个线程重复加锁会导致死锁。
PTHREAD_MUTEX_ERRORCHECK 检错锁。重复加锁会返回 EDEADLK 错误,不会死锁。
PTHREAD_MUTEX_RECURSIVE 递归锁。允许同一线程多次加锁,需要相同次数的解锁。
PTHREAD_MUTEX_DEFAULT 默认类型,行为取决于系统实现(通常是 NORMAL)。

二、互斥锁的创建与使用

2.1 相关头文件

#include <pthread.h>

2.2 初始化互斥锁

互斥锁可以通过两种方式初始化:静态初始化动态初始化

方式一:静态初始化(最简单)

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

适用于全局变量或静态变量,使用默认属性(PTHREAD_MUTEX_DEFAULT 类型)。

方式二:动态初始化(可自定义属性)

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数 类型 说明
mutex pthread_mutex_t * 指向互斥锁变量的指针。
attr const pthread_mutexattr_t * 互斥锁属性对象。传 NULL 使用默认属性。如果需要设置递归锁等特殊类型,需要先初始化属性对象。

动态初始化示例:

pthread_mutex_t mutex;

// 使用默认属性
pthread_mutex_init(&mutex, NULL);

// 或者设置递归锁
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&mutex, &attr);
pthread_mutexattr_destroy(&attr);

2.3 加锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
参数 类型 说明
mutex pthread_mutex_t * 要加锁的互斥锁指针。
  • 成功:返回 0,当前线程持有锁,可以进入临界区。
  • 失败:返回错误码。如果锁已被其他线程持有,调用线程会阻塞,直到锁被释放。

非阻塞加锁版本:

int pthread_mutex_trylock(pthread_mutex_t *mutex);

如果锁已被占用,不会阻塞,而是立即返回 EBUSY。适用于不想等待的场景。

2.4 解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数 类型 说明
mutex pthread_mutex_t * 要解锁的互斥锁指针。必须是当前线程持有的锁。
  • 成功:返回 0,释放锁,唤醒一个等待该锁的线程。
  • 失败:返回错误码。常见错误包括对未持有的锁调用 unlock(EPERM)。

2.5 销毁互斥锁

int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数 类型 说明
mutex pthread_mutex_t * 要销毁的互斥锁指针。销毁前必须确保没有线程持有或等待该锁。

静态初始化的互斥锁不需要显式销毁(程序结束时自动释放)。动态初始化的互斥锁使用完毕后应该调用 destroy 释放资源。

2.6 完整示例:多线程计数器

以下示例展示不使用互斥锁和使用互斥锁的区别:

不加锁(有数据竞争):

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

#define NUM_THREADS 4
#define INCREMENT_PER_THREAD 1000000

int counter = 0;  // 共享全局变量

void *increment(void *arg) {
    for (int i = 0; i < INCREMENT_PER_THREAD; i++) {
        counter++;  // 非原子操作!多个线程同时执行会丢失更新
    }
    return NULL;
}

int main() {
    pthread_t threads[NUM_THREADS];
    
    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_create(&threads[i], NULL, increment, NULL);
    }
    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }
    
    // 期望值:4,000,000  实际值:通常小于期望值(数据竞争)
    printf("counter = %d (期望: %d)\n", counter, NUM_THREADS * INCREMENT_PER_THREAD);
    return 0;
}

加锁(正确):

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

#define NUM_THREADS 4
#define INCREMENT_PER_THREAD 1000000

int counter = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;  // 静态初始化

void *increment(void *arg) {
    for (int i = 0; i < INCREMENT_PER_THREAD; 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, increment, NULL);
    }
    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }
    
    printf("counter = %d (期望: %d)\n", counter, NUM_THREADS * INCREMENT_PER_THREAD);
    
    pthread_mutex_destroy(&mutex);  // 销毁互斥锁
    return 0;
}

三、信号量(Semaphore)

3.1 什么是信号量

信号量(Semaphore)是一种更通用的同步机制,它维护一个整型计数器,用于控制同时访问某个资源的线程数量。与互斥锁不同,信号量允许多个线程同时持有(只要计数器大于 0),因此它不仅可以用于互斥,还可以用于协调线程的执行顺序(如生产者-消费者模型)。

可以这样理解:信号量就像一个停车场的剩余车位计数器——每进来一辆车,计数器减 1(P 操作);每出去一辆车,计数器加 1(V 操作)。当计数器为 0 时,新的车只能在外面等待。

3.2 信号量的作用

  • 互斥访问:当信号量的初始值为 1 时,它退化为一个互斥锁(二值信号量),用于保护共享资源。
  • 资源计数:当信号量的初始值为 N 时,它表示有 N 个可用资源。线程获取资源时计数器减 1,释放资源时计数器加 1。
  • 同步协调:信号量可以用于协调线程之间的执行顺序。例如,生产者生产一个物品后对信号量加 1,消费者在信号量大于 0 时才能消费。

3.3 信号量的两种操作

信号量只有两种原子操作:

操作 名称 效果 说明
P 操作(Proberen,荷兰语"尝试") sem_wait() 信号量值减 1 如果减 1 后值小于 0,则线程阻塞等待
V 操作(Verhogen,荷兰语"增加") sem_post() 信号量值加 1 如果有线程在等待,唤醒其中一个
初始值 = 3(表示有 3 个资源)

线程 A: sem_wait()  → 值变为 2  → 成功获取资源
线程 B: sem_wait()  → 值变为 1  → 成功获取资源
线程 C: sem_wait()  → 值变为 0  → 成功获取资源
线程 D: sem_wait()  → 值变为 -1 → 阻塞等待...

线程 A: sem_post()  → 值变为 0  → 唤醒线程 D
线程 D: 被唤醒,继续执行

3.4 信号量 vs 互斥锁

对比维度 互斥锁(Mutex) 信号量(Semaphore)
本质 二值状态(锁定/解锁) 整型计数器
同时访问 只允许 1 个线程 允许 N 个线程(N = 信号量初始值)
所有权 有——只有加锁的线程才能解锁 无——任何线程都可以 post
用途 保护共享资源(互斥) 互斥 + 同步协调
性能 通常更快(更简单) 稍慢(涉及计数器操作)
头文件 <pthread.h> <semaphore.h>

四、信号量的创建与使用

4.1 相关头文件

#include <semaphore.h>
// 编译时链接 -lpthread(sem_open 可能需要 -lrt)

4.2 初始化信号量

#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);
参数 类型 说明
sem sem_t * 指向信号量变量的指针。
pshared int 控制信号量的共享范围。0 表示信号量在同一进程的线程间共享(最常用);非 0 表示在多个进程间共享(需要配合共享内存使用)。
value unsigned int 信号量的初始值。这个值决定了同时可以有多少个线程通过 sem_wait() 而不被阻塞。

常见初始值:

sem_t sem;

// 初始值为 1:等价于互斥锁(二值信号量)
sem_init(&sem, 0, 1);

// 初始值为 5:表示有 5 个可用资源
sem_init(&sem, 0, 5);

// 初始值为 0:用于线程同步,初始时阻塞
sem_init(&sem, 0, 0);

4.3 P 操作(等待/获取)

int sem_wait(sem_t *sem);
参数 类型 说明
sem sem_t * 要操作的信号量指针。
  • 成功:返回 0,信号量值减 1。如果减 1 后值仍 ≥ 0,立即返回;如果 < 0,线程阻塞。
  • 失败:返回 -1,并设置 errno(如 EINTR 被信号中断)。

非阻塞版本:

int sem_trywait(sem_t *sem);

如果信号量值为 0,不会阻塞,而是立即返回 -1 并设置 errno = EAGAIN

带超时的版本(Linux):

int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);

最多等待到 abs_timeout 指定的绝对时间。超时后返回 -1 并设置 errno = ETIMEDOUT

4.4 V 操作(释放/增加)

int sem_post(sem_t *sem);
参数 类型 说明
sem sem_t * 要操作的信号量指针。
  • 成功:返回 0,信号量值加 1。如果有线程在等待该信号量,唤醒其中一个。
  • 失败:返回 -1,并设置 errno

4.5 获取信号量当前值

int sem_getvalue(sem_t *sem, int *sval);
参数 类型 说明
sem sem_t * 信号量指针。
sval int * 输出参数,接收信号量的当前值。如果值为负数,其绝对值表示当前等待的线程数。

4.6 销毁信号量

int sem_destroy(sem_t *sem);
参数 类型 说明
sem sem_t * 要销毁的信号量指针。销毁前必须确保没有线程在等待该信号量。

五、信号量的完整示例

5.1 示例一:用信号量实现互斥(二值信号量)

#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

#define NUM_THREADS 4
#define INCREMENT_PER_THREAD 1000000

int counter = 0;
sem_t sem;  // 二值信号量,初始值为 1

void *increment(void *arg) {
    for (int i = 0; i < INCREMENT_PER_THREAD; i++) {
        sem_wait(&sem);    // P 操作(加锁)
        counter++;
        sem_post(&sem);    // V 操作(解锁)
    }
    return NULL;
}

int main() {
    pthread_t threads[NUM_THREADS];
    
    sem_init(&sem, 0, 1);  // 初始值为 1
    
    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_create(&threads[i], NULL, increment, NULL);
    }
    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }
    
    printf("counter = %d (期望: %d)\n", counter, NUM_THREADS * INCREMENT_PER_THREAD);
    
    sem_destroy(&sem);
    return 0;
}

5.2 示例二:生产者-消费者模型

这是一个经典的使用场景:生产者生产物品放入缓冲区,消费者从缓冲区取出物品消费。使用两个信号量:

  • empty:表示缓冲区中空闲位置的数量(初始值为缓冲区大小)。
  • full:表示缓冲区中已有物品的数量(初始值为 0)。
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>

#define BUFFER_SIZE 5
#define NUM_ITEMS 10

int buffer[BUFFER_SIZE];
int in = 0;   // 生产者的写入位置
int out = 0;  // 消费者的读取位置

sem_t empty;  // 空闲槽位数
sem_t full;   // 已填充的槽位数
pthread_mutex_t mutex;  // 保护 buffer 的互斥锁

void *producer(void *arg) {
    int id = *(int *)arg;
    for (int i = 0; i < NUM_ITEMS; i++) {
        int item = id * 100 + i;
        
        sem_wait(&empty);         // 等待空闲槽位
        pthread_mutex_lock(&mutex);
        
        buffer[in] = item;
        printf("[生产者 %d] 生产物品: %d, 放入位置 %d\n", id, item, in);
        in = (in + 1) % BUFFER_SIZE;
        
        pthread_mutex_unlock(&mutex);
        sem_post(&full);          // 通知有新物品
        
        usleep(100000);  // 模拟生产时间
    }
    return NULL;
}

void *consumer(void *arg) {
    int id = *(int *)arg;
    for (int i = 0; i < NUM_ITEMS; i++) {
        sem_wait(&full);          // 等待有物品可消费
        pthread_mutex_lock(&mutex);
        
        int item = buffer[out];
        printf("[消费者 %d] 消费物品: %d, 从位置 %d\n", id, item, out);
        out = (out + 1) % BUFFER_SIZE;
        
        pthread_mutex_unlock(&mutex);
        sem_post(&empty);         // 释放一个槽位
        
        usleep(200000);  // 模拟消费时间(比生产慢)
    }
    return NULL;
}

int main() {
    pthread_t prod[2], cons[2];
    int prod_ids[2] = {1, 2};
    int cons_ids[2] = {1, 2};
    
    sem_init(&empty, 0, BUFFER_SIZE);  // 初始有 5 个空位
    sem_init(&full, 0, 0);             // 初始没有物品
    pthread_mutex_init(&mutex, NULL);
    
    // 创建 2 个生产者线程
    for (int i = 0; i < 2; i++) {
        pthread_create(&prod[i], NULL, producer, &prod_ids[i]);
    }
    // 创建 2 个消费者线程
    for (int i = 0; i < 2; i++) {
        pthread_create(&cons[i], NULL, consumer, &cons_ids[i]);
    }
    
    // 等待所有线程结束
    for (int i = 0; i < 2; i++) {
        pthread_join(prod[i], NULL);
        pthread_join(cons[i], NULL);
    }
    
    sem_destroy(&empty);
    sem_destroy(&full);
    pthread_mutex_destroy(&mutex);
    
    printf("所有生产消费完成!\n");
    return 0;
}

5.3 示例三:用信号量实现线程同步(控制执行顺序)

假设线程 B 必须等待线程 A 完成某项初始化工作后才能开始执行:

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

sem_t sem;

void *thread_a(void *arg) {
    printf("[线程 A] 正在执行初始化工作...\n");
    sleep(2);  // 模拟耗时操作
    printf("[线程 A] 初始化完成,通知线程 B 可以开始\n");
    sem_post(&sem);  // V 操作:通知线程 B
    return NULL;
}

void *thread_b(void *arg) {
    printf("[线程 B] 等待线程 A 完成初始化...\n");
    sem_wait(&sem);  // P 操作:阻塞等待
    printf("[线程 B] 收到通知,开始执行任务\n");
    return NULL;
}

int main() {
    pthread_t tid_a, tid_b;
    
    sem_init(&sem, 0, 0);  // 初始值为 0,线程 B 会阻塞
    
    // 先创建线程 B(它会阻塞等待)
    pthread_create(&tid_b, NULL, thread_b, NULL);
    // 再创建线程 A
    pthread_create(&tid_a, NULL, thread_a, NULL);
    
    pthread_join(tid_a, NULL);
    pthread_join(tid_b, NULL);
    
    sem_destroy(&sem);
    return 0;
}

六、互斥锁与信号量的选型建议

场景 推荐方案
保护共享变量(如计数器、链表) 互斥锁
保护复杂数据结构(如数据库连接池) 互斥锁
控制同时访问某资源的线程数量(如连接池限流) 信号量(初始值 = 最大并发数)
生产者-消费者模型 信号量 + 互斥锁
线程间的执行顺序同步 信号量(初始值为 0)
简单互斥,不需要协调顺序 互斥锁(性能更好)

经验法则:

  • 互斥锁用于"互斥"——保证同一时刻只有一个线程访问共享资源。
  • 信号量用于"同步"——协调多个线程的执行顺序或控制并发数量。
  • 实际项目中,两者经常配合使用:信号量控制并发数量,互斥锁保护共享数据结构。

七、常见陷阱与注意事项

7.1 互斥锁的常见错误

  • 忘记解锁:在临界区内 returnbreak 时忘记 unlock,导致死锁。
  • 重复加锁:对普通互斥锁(NORMAL 类型)重复加锁会导致死锁。如果需要递归加锁,使用 PTHREAD_MUTEX_RECURSIVE 类型。
  • 解锁未持有的锁:对不是当前线程持有的锁调用 unlock 会导致未定义行为。

7.2 信号量的常见错误

  • 初始值设置错误:初始值决定了并发访问的数量,设置不当会导致资源过度竞争或线程永久阻塞。
  • 忘记 destroy:使用完毕后应调用 sem_destroy(),否则可能导致资源泄漏。
  • 在信号量等待时被信号中断sem_wait() 可能被信号中断(返回 EINTR),需要处理这种情况(通常用循环重试)。

7.3 死锁的预防

死锁是指两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行。常见的预防策略包括:

  1. 锁排序:如果多个线程需要获取多把锁,始终按照相同的顺序获取(如先锁 A 再锁 B)。
  2. 超时机制:使用 pthread_mutex_trylock()sem_timedwait(),获取失败时放弃并重试。
  3. 最小化临界区:尽量减少临界区内的代码量,降低锁竞争的概率。

八、总结

机制 核心作用 初始值含义 关键函数
互斥锁 保证同一时刻只有一个线程访问共享资源 无(二值状态) pthread_mutex_init/lock/unlock/destroy
信号量 控制并发数量 + 线程同步 N = 可用资源数 sem_init/wait/post/destroy

互斥锁和信号量是多线程编程的基石。互斥锁简单高效,专注于"互斥";信号量更灵活,既能互斥又能同步。理解它们的原理和使用场景,是写出正确、高效并发程序的前提。

posted @ 2026-07-29 21:10  Solitude0420  阅读(10)  评论(0)    收藏  举报