UNIX网络编程笔记:同步

发布于:2025-08-29 ⋅ 阅读:(15) ⋅ 点赞:(0)

互斥锁与条件变量:并发控制的核心武器

在多线程编程的世界里,并发控制是绕不开的核心话题。当多个线程同时访问共享资源时,数据竞争、死锁等问题会像“幽灵”一样出现,破坏程序的正确性。互斥锁(Mutex)和条件变量(Condition Variable),就是我们应对这些问题的“核心武器”,它们相互配合,为多线程协作提供安全且高效的保障。

一、互斥锁:共享资源的“守门人”

(一)基本原理与操作

互斥锁,本质是一个二元信号量,作用是保护共享资源的原子访问。它就像共享资源的“守门人”,同一时间只允许一个线程进入临界区(访问共享资源的代码段 )。

在 POSIX 线程库(pthread )中,互斥锁的核心操作很简单:

  • 上锁(pthread_mutex_lock):线程尝试获取锁,若锁被占用则阻塞,直到锁可用;
  • 解锁(pthread_mutex_unlock):线程释放锁,唤醒等待的线程。

示例:保护共享变量 counter 的访问:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int counter = 0;

void increment() {
    pthread_mutex_lock(&mutex);
    counter++; // 临界区,安全访问共享资源
    pthread_mutex_unlock(&mutex);
}

通过互斥锁,counter++ 操作变成原子操作,避免多线程同时修改导致数据混乱。

(二)死锁与避免策略

互斥锁虽好,但用不好会引发死锁 :两个线程互相等待对方释放锁,陷入永久阻塞。常见死锁场景:

  • 嵌套锁顺序混乱:线程 A 先锁 mutex1 再锁 mutex2,线程 B 先锁 mutex2 再锁 mutex1,互相等待;
  • 忘记解锁:线程异常退出,未释放锁,其他线程永久阻塞。

避免死锁的关键:

  • 统一锁顺序:所有线程按固定顺序(如 mutex1mutex2 )加锁;
  • 使用带超时的锁(pthread_mutex_timedlock):超时后放弃锁,避免永久阻塞;
  • 减少锁粒度:拆分大临界区为小临界区,缩短持有锁的时间。

合理设计锁的使用逻辑,是保障多线程程序稳定的基础。

二、条件变量:线程协作的“信号灯”

(一)条件变量的作用

互斥锁解决了“共享资源访问冲突”,但多线程协作(如“生产者 - 消费者”模型 )还需要条件变量 。它像“信号灯”,让线程在条件不满足时休眠,条件满足时被唤醒。

条件变量常与互斥锁配合使用,核心操作:

  • 等待(pthread_cond_wait):线程释放互斥锁,进入休眠,等待条件满足;条件满足时,自动重新获取锁;
  • 发送信号(pthread_cond_signal/pthread_cond_broadcast):唤醒一个或所有等待的线程。

(二)生产者 - 消费者模型实践

以“生产者 - 消费者”问题为例,条件变量的作用至关重要:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
Queue queue; // 共享队列

// 生产者线程
void producer() {
    pthread_mutex_lock(&mutex);
    while (queue.is_full()) {
        // 队列满,等待消费者消费
        pthread_cond_wait(&cond, &mutex); 
    }
    queue.push(data); // 生产数据
    pthread_cond_signal(&cond); // 唤醒消费者
    pthread_mutex_unlock(&mutex);
}

// 消费者线程
void consumer() {
    pthread_mutex_lock(&mutex);
    while (queue.is_empty()) {
        // 队列空,等待生产者生产
        pthread_cond_wait(&cond, &mutex); 
    }
    queue.pop(data); // 消费数据
    pthread_cond_signal(&cond); // 唤醒生产者
    pthread_mutex_unlock(&mutex);
}
  • pthread_cond_wait原子性释放锁 + 休眠,避免“释放锁后、休眠前”的竞态条件;
  • 条件判断用 while 而非 if,防止“虚假唤醒”(线程被唤醒但条件仍不满足 )。

条件变量让线程协作更高效,避免了“轮询检查条件”的 CPU 浪费。

(三)定时等待与广播通知

条件变量还支持定时等待(pthread_cond_timedwait)广播通知(pthread_cond_broadcast)

  • 定时等待:线程等待条件满足,超时后继续执行(如等待队列有数据,超时则处理其他任务 );
  • 广播通知:唤醒所有等待的线程(如“队列非空”,需所有消费者线程处理 )。

这些扩展功能,让条件变量适配更复杂的协作场景(如线程池的任务调度 )。

三、互斥锁与条件变量的协同:解决复杂协作问题

(一)对比上锁等待与条件变量等待

在未使用条件变量时,线程可能通过“上锁 + 轮询”等待条件:

// 低效的轮询方式
pthread_mutex_lock(&mutex);
while (!condition_met) {
    pthread_mutex_unlock(&mutex);
    sleep(1); // 轮询,浪费 CPU
    pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);

这种方式会频繁上锁、解锁,浪费 CPU 资源。

而条件变量的 pthread_cond_wait 让线程休眠,不占用 CPU ,条件满足时精准唤醒,大幅提升效率。

(二)属性定制:适应特殊需求

互斥锁和条件变量支持属性定制

  • 互斥锁属性:设置锁类型(如 PTHREAD_MUTEX_RECURSIVE 允许同一线程重复加锁 )、是否进程间共享;
  • 条件变量属性:设置是否进程间共享(如多个进程的线程协作 )。

示例:创建进程间共享的互斥锁:

pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, &attr);

属性定制让锁和条件变量突破线程限制,支持进程间的复杂协作。

四、技术总结与实践建议

互斥锁和条件变量是多线程编程的“基石”,它们的协同使用,覆盖了资源保护线程协作两大核心需求:

  • 互斥锁:保障共享资源的原子访问,避免数据竞争;
  • 条件变量:实现线程间的高效协作,避免轮询浪费 CPU。

实践中,需注意:

  • 最小化临界区:锁的范围越小越好,减少线程阻塞时间;
  • 避免嵌套锁:复杂的锁嵌套易引发死锁,尽量简化锁逻辑;
  • 处理虚假唤醒:条件变量的等待需用 while 而非 if,确保条件真的满足;
  • 资源释放:线程退出前,务必释放持有的锁,避免死锁。

掌握这些机制,能让多线程程序在高并发场景下稳定运行,像精密齿轮一样协同工作,驱动复杂系统高效运转。无论是开发高性能服务器,还是优化多线程工具,互斥锁和条件变量都是必须深耕的底层技术。

读写锁:多线程并发的高效控制工具

在多线程编程中,当面对“读多写少”的场景时,普通互斥锁会因为严格的串行访问导致性能瓶颈。读写锁(Read - Write Lock)则通过区分读操作和写操作,实现读操作的并发执行,提升程序在这类场景下的效率。以下将深入解析读写锁的原理、实现及应用。

一、读写锁的核心思想:区分读写,优化并发

(一)读写锁的基本概念

读写锁本质是一种细粒度的并发控制机制,它将对共享资源的访问分为两种类型:

  • 读操作(共享锁):多个线程可以同时获取读锁,并发读取共享资源,因为读操作不会修改资源内容,不会产生数据竞争。
  • 写操作(排他锁):只有一个线程能获取写锁,在写操作执行期间,其他线程无法获取读锁或写锁,保证写操作的原子性。

例如,在一个新闻资讯系统中,大量线程并发读取新闻内容(读操作 ),而只有少量线程(如编辑线程 )修改新闻(写操作 ),使用读写锁可以显著提高系统的并发性能。

(二)与互斥锁的对比

普通互斥锁在“读多写少”场景下效率低下,因为即使是只读操作也会串行执行。读写锁通过分离读写权限,让读操作并行,仅在写操作时串行,充分利用了 CPU 资源,提升了系统的吞吐量。但读写锁的实现比互斥锁复杂,在写操作频繁的场景下,可能因为写锁竞争导致性能不如互斥锁,需要根据实际场景选择。

二、读写锁的基本操作:获取与释放

(一)读写锁的初始化与销毁

在 POSIX 线程库中,读写锁的类型是 pthread_rwlock_t,可以通过以下函数进行初始化和销毁:

#include <pthread.h>

// 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
// 销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
  • attr 用于设置读写锁的属性,如是否支持进程间共享等,通常设为 NULL 使用默认属性。

示例:

pthread_rwlock_t rwlock;
pthread_rwlock_init(&rwlock, NULL);
// 使用读写锁...
pthread_rwlock_destroy(&rwlock);

(二)获取和释放读写锁

1. 获取读锁(pthread_rwlock_rdlock)
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

多个线程可以同时成功获取读锁,进入读操作临界区。如果有线程持有写锁,则获取读锁的线程会阻塞,直到写锁释放。

2. 获取写锁(pthread_rwlock_wrlock)
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

只有当没有线程持有读锁或写锁时,线程才能获取写锁。如果有线程持有读锁或写锁,获取写锁的线程会阻塞。

3. 尝试获取锁(带非阻塞和超时)
  • 非阻塞获取读锁(pthread_rwlock_tryrdlock):尝试获取读锁,若无法获取(如已有写锁 ),立即返回错误,不会阻塞。
  • 非阻塞获取写锁(pthread_rwlock_trywrlock):类似 pthread_rwlock_tryrdlock,针对写锁。
  • 超时获取锁(pthread_rwlock_timedrdlock、pthread_rwlock_timedwrlock):在指定时间内尝试获取锁,超时则返回错误。
4. 释放锁(pthread_rwlock_unlock)
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

无论是读锁还是写锁,都通过该函数释放,释放后会唤醒等待的线程。

示例:

pthread_rwlock_rdlock(&rwlock);
// 执行读操作,如读取共享数据
pthread_rwlock_unlock(&rwlock);

pthread_rwlock_wrlock(&rwlock);
// 执行写操作,如修改共享数据
pthread_rwlock_unlock(&rwlock);

三、读写锁属性:定制锁的行为

(一)属性的作用与设置

读写锁的属性(pthread_rwlockattr_t )可以定制锁的行为,主要包括:

  • 进程共享属性(PTHREAD_PROCESS_SHARED):设置读写锁是否可以被多个进程的线程共享,默认是进程内共享。
  • 优先级继承属性:影响线程获取锁的优先级策略,避免优先级反转问题。

设置属性的示例:

pthread_rwlockattr_t attr;
pthread_rwlockattr_init(&attr);
// 设置为进程间共享
pthread_rwlockattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
pthread_rwlock_t rwlock;
pthread_rwlock_init(&rwlock, &attr);
pthread_rwlockattr_destroy(&attr);

(二)属性对锁行为的影响

  • 进程共享:当需要多个进程的线程共同访问共享资源时(如通过共享内存通信 ),必须设置该属性,否则其他进程的线程无法使用该读写锁。
  • 优先级继承:在实时系统中,优先级反转可能导致高优先级线程被低优先级线程阻塞。启用优先级继承可以让持有锁的低优先级线程临时提升优先级,尽快释放锁,保障高优先级线程的执行。

四、读写锁的手动实现:基于互斥锁和条件变量

理解读写锁的底层实现,有助于更好地运用它。我们可以使用互斥锁和条件变量手动模拟读写锁的功能:

typedef struct {
    pthread_mutex_t mutex;
    pthread_cond_t cond;
    int readers; // 读锁持有者数量
    int writer; // 写锁持有者(0 或 1)
} my_rwlock_t;

// 初始化自定义读写锁
void my_rwlock_init(my_rwlock_t *rwlock) {
    pthread_mutex_init(&rwlock->mutex, NULL);
    pthread_cond_init(&rwlock->cond, NULL);
    rwlock->readers = 0;
    rwlock->writer = 0;
}

// 获取读锁
void my_rwlock_rdlock(my_rwlock_t *rwlock) {
    pthread_mutex_lock(&rwlock->mutex);
    while (rwlock->writer) {
        pthread_cond_wait(&rwlock->cond, &rwlock->mutex);
    }
    rwlock->readers++;
    pthread_mutex_unlock(&rwlock->mutex);
}

// 获取写锁
void my_rwlock_wrlock(my_rwlock_t *rwlock) {
    pthread_mutex_lock(&rwlock->mutex);
    while (rwlock->writer || rwlock->readers > 0) {
        pthread_cond_wait(&rwlock->cond, &rwlock->mutex);
    }
    rwlock->writer = 1;
    pthread_mutex_unlock(&rwlock->mutex);
}

// 释放锁
void my_rwlock_unlock(my_rwlock_t *rwlock) {
    pthread_mutex_lock(&rwlock->mutex);
    if (rwlock->writer) {
        rwlock->writer = 0;
    } else {
        rwlock->readers--;
    }
    pthread_cond_broadcast(&rwlock->cond);
    pthread_mutex_unlock(&rwlock->mutex);
}
  • 读锁获取:等待写锁释放,然后增加读锁计数。
  • 写锁获取:等待读锁和写锁都释放,然后标记写锁持有。
  • 释放锁:根据是读锁还是写锁,更新计数或标记,并广播唤醒等待的线程。

这种手动实现展示了读写锁的核心逻辑:通过互斥锁保护共享状态,条件变量实现线程的等待和唤醒。

五、线程取消与读写锁:处理异常情况

在多线程编程中,线程可能在持有读写锁时被取消(如调用 pthread_cancel ),如果不妥善处理,会导致锁无法释放,引发死锁。

为了避免这种情况,可以使用清理函数(pthread_cleanup_push/pthread_cleanup_pop)

void read_data(my_rwlock_t *rwlock) {
    pthread_cleanup_push((void (*)(void *))my_rwlock_unlock, rwlock);
    my_rwlock_rdlock(rwlock);
    // 执行读操作...
    my_rwlock_unlock(rwlock);
    pthread_cleanup_pop(0);
}

当线程被取消时,清理函数会自动执行,释放读写锁,避免死锁。

六、总结:读写锁的适用场景与最佳实践

读写锁适用于读操作远多于写操作的场景,如缓存系统、文件系统 metadata 访问等。在使用读写锁时,需要注意以下几点:

  • 控制临界区大小:读操作和写操作的临界区应尽可能小,减少线程持有锁的时间,提升并发性能。
  • 避免写饥饿:如果写操作非常少,可能导致写线程长时间无法获取写锁(被读线程持续占用 )。可以通过设置读写锁的优先级策略(如写优先 ),或在合适的时机让读线程主动释放锁,唤醒写线程。
  • 结合实际场景选择:如果写操作频繁,读写锁的性能可能不如互斥锁,需要通过性能测试选择更合适的同步机制。

通过合理运用读写锁及其属性,结合底层原理的理解,可以在“读多写少”的并发场景中实现高效的共享资源访问控制,提升多线程程序的性能和稳定性。

记录上锁:文件与共享资源的精细管控

在多进程、多线程协作场景中,对共享资源(如文件、数据库记录 )的并发访问需要精准的同步机制。记录上锁(Record Locking )通过“锁粒度细化”,实现对文件特定区域(或逻辑记录 )的独占或共享访问,是保障数据一致性的关键工具。以下从基础原理到实践应用,解析记录上锁的技术逻辑。

一、记录上锁的核心价值:突破文件级锁的局限

(一)文件上锁 vs 记录上锁

传统文件上锁(如 flock )是“全或无”的控制:锁整个文件,阻止其他进程读写。但在多进程协作场景(如多个进程读写同一文件的不同行 ),文件上锁会过度限制并发,降低效率。

记录上锁(也称为“字节范围锁” )则支持精细化控制

  • 锁文件的“部分区域”(如从字节偏移 100 到 200 );
  • 区分共享锁(读锁 )独占锁(写锁 ) ,允许多进程读共享、单进程写独占。

例如,在日志系统中,多个进程可并发追加日志(共享锁写末尾 ),但修改历史日志(独占锁特定区域 )时需排他,保障数据安全。

(二)记录上锁的适用场景

记录上锁广泛应用于:

  • 数据库系统:锁表中某条记录(逻辑记录映射到文件字节范围 ),实现事务的 ACID 特性;
  • 配置文件管理:多进程读写同一配置文件的不同字段(如 nginx.conf 的不同 server 配置 );
  • 共享内存同步:结合内存映射文件(mmap ),锁内存中的特定区域,实现进程间同步。

通过记录上锁,共享资源的访问控制从“粗放”走向“精细”,适配复杂业务需求。

二、Posix fcntl:记录上锁的核心实现

(一)fcntl 的锁操作

在 Unix 系统中,记录上锁通过 fcntl 函数的 F_GETLKF_SETLKF_SETLKW 命令实现:

#include <fcntl.h>
struct flock {
    short l_type;   // F_RDLCK(读锁)、F_WRLCK(写锁)、F_UNLCK(解锁)
    short l_whence; // 偏移起始位置(SEEK_SET、SEEK_CUR、SEEK_END)
    off_t l_start;  // 锁的起始偏移
    off_t l_len;    // 锁的长度(0 表示锁到文件末尾 )
    pid_t l_pid;    // 持有锁的进程 ID(F_GETLK 时返回 )
};

int fcntl(int fd, int cmd, struct flock *lock);
  • F_SETLK:设置锁,无法获取则立即返回错误(非阻塞 );
  • F_SETLKW:设置锁,无法获取则阻塞等待(阻塞 );
  • F_GETLK:查询锁状态(是否与现有锁冲突 )。

(二)锁的冲突规则

记录上锁遵循“读写锁”的冲突规则:

  • 读锁(F_RDLCK):多个进程可同时持有同一区域的读锁;
  • 写锁(F_WRLCK):同一区域只能有一个写锁,且与读锁互斥;
  • 解锁(F_UNLCK):释放锁,允许其他进程获取。

示例:对文件 data.txt 的 100 - 200 字节加写锁:

struct flock lock;
lock.l_type = F_WRLCK;
lock.l_whence = SEEK_SET;
lock.l_start = 100;
lock.l_len = 100; // 锁 100 字节(100-200)
fcntl(fd, F_SETLKW, &lock); // 阻塞等待锁
// 执行写操作...
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &lock); // 解锁

这种精细控制,让多进程协作更高效、安全。

(三)劝告性锁与强制性锁

记录上锁分为劝告性锁(Advisory Lock )强制性锁(Mandatory Lock )

  • 劝告性锁:依赖进程“主动检查锁状态” ,未检查则可绕过锁(如 cat 命令直接读文件 );
  • 强制性锁:内核强制检查锁,任何进程读写锁区域都需遵循锁规则,需文件系统配合(如设置文件 gid 位 )。

劝告性锁是默认模式 ,实现简单(进程通过 fcntl 主动加锁、检查 ),但依赖编程规范;强制性锁更严格,但配置复杂(需修改文件权限、内核参数 )。

三、记录上锁的实践:多进程协作示例

(一)多进程并发读共享

多个进程可同时获取同一区域的读锁,并发读取:

// 进程 A、B 同时执行
struct flock lock;
lock.l_type = F_RDLCK;
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 100; // 锁文件前 100 字节
fcntl(fd, F_SETLK, &lock);
// 安全读取前 100 字节
fcntl(fd, F_SETLK, &(struct flock){F_UNLCK, ...});

读锁不互斥,提升了多进程读的并发效率。

(二)单进程写独占

写进程需获取独占锁,阻止其他进程读写:

struct flock lock;
lock.l_type = F_WRLCK;
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 100;
fcntl(fd, F_SETLKW, &lock); // 阻塞等待,直到获取写锁
// 修改文件前 100 字节
fcntl(fd, F_SETLK, &(struct flock){F_UNLCK, ...});

写锁期间,其他进程的读锁、写锁都会阻塞,保障写操作的原子性。

(三)锁的检测与避让

进程可通过 F_GETLK 检测锁冲突:

struct flock lock = {F_WRLCK, SEEK_SET, 0, 100, 0};
fcntl(fd, F_GETLK, &lock);
if (lock.l_type != F_UNLCK) {
    // 锁已被占用,进程 B(PID: lock.l_pid)持有
    printf("Lock held by PID %d\n", lock.l_pid);
}

根据检测结果,进程可选择等待、重试或执行其他逻辑,避免死锁。

四、记录上锁的局限与应对

(一)文件系统的依赖

记录上锁依赖文件系统的支持

  • 某些文件系统(如 NFS )对记录上锁的支持有限(锁为客户端本地锁,服务端无感知 );
  • 强制性锁需文件系统配合设置 gid 位、内核开启 mand 选项,配置复杂。

应对策略:

  • 优先使用劝告性锁,避免依赖文件系统特性;
  • 跨网络共享文件时,改用分布式锁(如 Redis 锁 )替代记录上锁。

(二)锁粒度与性能的权衡

记录上锁的“字节范围”是物理锁粒度 ,而业务需求常是逻辑记录(如数据库的一行 )。若逻辑记录跨字节范围,锁操作会复杂(需计算逻辑记录对应的字节范围 )。

应对策略:

  • 映射逻辑记录到连续字节范围(如数据库按行存储,每行固定长度 );
  • 结合内存映射(mmap ),将逻辑记录转为内存地址范围,简化锁操作。

(三)进程崩溃与锁残留

若进程持有锁时崩溃,未释放锁,会导致其他进程永久阻塞。

应对策略:

  • 使用锁的超时机制(结合 F_SETLKW 的超时变种,或在应用层实现 );
  • 监控进程状态,崩溃时通过守护进程释放残留锁(如检查 /proc 中进程是否存在 )。

五、记录上锁的扩展:守护进程的唯一副本启动

记录上锁可用于保障守护进程的唯一副本

  1. 守护进程启动时,对特定文件(如 /var/run/mydaemon.lock )加写锁;
  2. 若锁已存在(fcntl 返回 EAGAIN ),则退出(已有副本运行 );
  3. 守护进程退出时,释放锁,允许下次启动。

示例:

int fd = open("/var/run/mydaemon.lock", O_CREAT | O_RDWR, 0644);
struct flock lock = {F_WRLCK, SEEK_SET, 0, 1, 0};
if (fcntl(fd, F_SETLK, &lock) == -1) {
    printf("Daemon already running\n");
    exit(EXIT_FAILURE);
}
// 守护进程逻辑...

这种方式简单可靠,替代传统的 PID 文件检查(PID 文件可能因进程崩溃残留 )。

六、总结:记录上锁的技术价值

记录上锁通过 fcntl 实现“字节范围的共享/独占锁”,让共享资源的访问控制从“文件级”细化到“记录级”:

  • 劝告性锁灵活易用,适配大多数协作场景;
  • 结合逻辑记录映射,可实现数据库、配置文件的精细同步;
  • 扩展应用(如守护进程唯一副本 ),展现其多功能性。

尽管存在文件系统依赖、锁残留等问题,但通过合理设计(如劝告性锁 + 超时机制 ),记录上锁仍是 Unix 系统中共享资源同步的核心工具。掌握其原理与实践,能大幅提升多进程程序的稳定性与效率。

Posix 信号量:进程与线程同步的“流量控制器”

在多进程、多线程编程中,同步与互斥是保障程序正确运行的核心。Posix 信号量(Semaphore)作为一种灵活的同步机制,通过“计数器 + 等待/唤醒”模型,精准控制共享资源的访问权限,适配从简单互斥到复杂生产者 - 消费者的各类场景。以下从基础原理到高级实践,拆解 Posix 信号量的技术逻辑。

一、信号量的核心模型:计数器与同步原语

(一)信号量的基本概念

Posix 信号量是一个整数计数器,结合两种操作:

  • P 操作(sem_wait):计数器减 1,若计数器 < 0 则阻塞,等待计数器 ≥ 0;
  • V 操作(sem_post):计数器加 1,若有线程/进程阻塞,唤醒其中一个。

信号量分为二元信号量(计数器仅 0 或 1,等效互斥锁 )和计数信号量(计数器 ≥ 0,控制多资源访问 )。

例如,控制 3 个线程访问共享资源,可初始化信号量为 3:

  • 每个线程 sem_wait(计数器 3→2→1→0 ),第 4 个线程需等待;
  • 线程释放资源时 sem_post(计数器 0→1 ),唤醒等待线程。

通过计数器的增减,信号量实现了对共享资源的“流量控制”。

(二)与互斥锁的区别

特性 互斥锁(Mutex) 信号量(Semaphore)
所有权 持有线程专属,只能由持有者释放 无专属所有权,任意线程/进程可释放
用途 保护临界区(单资源互斥) 控制多资源访问(如连接池、缓冲区 )
阻塞线程 单个线程(等待互斥锁) 多个线程(等待计数资源 )

信号量的“无专属所有权”让其更灵活(如生产者线程生产资源,消费者线程释放信号量 ),适配复杂协作场景。

二、信号量的基础操作:创建、同步与销毁

(一)sem_open:创建与打开信号量

Posix 信号量分为进程内信号量sem_init 初始化 )和进程间信号量sem_open 通过文件系统命名 )。

进程间信号量通过 sem_open 创建/打开:

#include <semaphore.h>
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);
  • name:信号量名(如 /my_sem ),遵循文件系统路径规则;
  • oflagO_CREAT(创建 )、O_EXCL(与 O_CREAT 配合,避免重复创建 );
  • mode:权限(如 0644 );
  • value:初始计数器值(如 3 表示 3 个资源 )。

示例:创建进程间信号量,初始值 1(二元信号量 ):

sem_t *sem = sem_open("/my_sem", O_CREAT | O_RDWR, 0644, 1);

进程间信号量通过文件系统命名,可被不同进程共享(如容器内的进程协作 )。

(二)sem_wait 与 sem_trywait:等待资源

  • sem_wait:阻塞等待信号量计数器 > 0,然后减 1;
  • sem_trywait:非阻塞,计数器 > 0 则减 1 并返回 0,否则返回 EAGAIN

示例:线程安全的资源访问:

sem_wait(sem);
// 访问共享资源(如连接池、缓冲区 )
sem_post(sem);

sem_trywait 适合“尝试访问资源,失败则执行其他任务”的场景(如非阻塞获取数据库连接 )。

(三)sem_post 与 sem_getvalue:释放与查询

  • sem_post:计数器加 1,唤醒等待线程(若有 );
  • sem_getvalue:获取当前计数器值(如监控资源使用情况 )。

示例:生产者 - 消费者模型中,生产者生产资源后 sem_post,消费者 sem_wait 获取:

// 生产者线程
produce_resource();
sem_post(sem); // 计数器 +1,通知消费者

// 消费者线程
sem_wait(sem); // 计数器 -1,获取资源
consume_resource();

通过 sem_getvalue 可查询剩余资源数,动态调整生产者/消费者线程数。

(四)sem_close 与 sem_unlink:销毁与清理

  • sem_close:关闭信号量描述符,释放进程内资源(不销毁信号量 );
  • sem_unlink:销毁信号量(从文件系统命名空间移除 ),需所有进程 sem_close 后调用。

示例:

sem_close(sem); // 关闭描述符
sem_unlink("/my_sem"); // 销毁信号量,释放系统资源

sem_unlink 会导致信号量残留,需注意资源清理。

三、信号量的实践:生产者 - 消费者模型

(一)基础实现:单生产者 - 单消费者

通过信号量控制共享缓冲区(如环形队列 )的读写:

  • 空缓冲区信号量:初始化为缓冲区大小(如 10 ),生产者 sem_wait(有空位则生产 );
  • 满缓冲区信号量:初始化为 0,消费者 sem_wait(有数据则消费 )。

代码示例:

#define BUF_SIZE 10
int buf[BUF_SIZE];
int in = 0, out = 0;

sem_t *empty, *full;

// 生产者线程
void producer() {
    while (1) {
        sem_wait(empty); // 等待空缓冲区
        buf[in] = produce_data();
        in = (in + 1) % BUF_SIZE;
        sem_post(full); // 通知满缓冲区
    }
}

// 消费者线程
void consumer() {
    while (1) {
        sem_wait(full); // 等待满缓冲区
        consume_data(buf[out]);
        out = (out + 1) % BUF_SIZE;
        sem_post(empty); // 通知空缓冲区
    }
}

int main() {
    empty = sem_open("/empty_sem", O_CREAT, 0644, BUF_SIZE);
    full = sem_open("/full_sem", O_CREAT, 0644, 0);
    // 创建生产者、消费者线程...
    sem_close(empty); sem_close(full);
    sem_unlink("/empty_sem"); sem_unlink("/full_sem");
    return 0;
}

信号量让生产者和消费者线程解耦,无需依赖复杂的条件变量逻辑。

(二)扩展:多生产者 - 多消费者

在多生产者 - 多消费者场景中,需额外互斥锁保护索引(in/out)

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// 生产者线程(修改 in 时加锁)
sem_wait(empty);
pthread_mutex_lock(&mutex);
buf[in] = produce_data();
in = (in + 1) % BUF_SIZE;
pthread_mutex_unlock(&mutex);
sem_post(full);

信号量控制资源访问,互斥锁保护共享变量(in/out),两者协同保障多线程安全。

四、信号量的高级应用:进程间共享与内存映射

(一)进程间共享信号量

通过 sem_open 的文件系统命名,信号量可被多进程共享:

  1. 进程 A sem_open("/my_sem", O_CREAT, 0644, 3) 创建信号量;
  2. 进程 B sem_open("/my_sem", 0, 0, 0) 打开并使用;
  3. 所有进程 sem_close 后,进程 A sem_unlink 销毁。

示例:多进程访问共享内存中的资源池,通过信号量控制访问:

// 进程 A、B 共享内存
int *resource_pool = mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
sem_t *sem = sem_open("/pool_sem", O_CREAT, 0644, 5); // 5 个资源

// 进程 A、B 操作资源池
sem_wait(sem);
use_resource(resource_pool);
sem_post(sem);

信号量与共享内存结合,实现了跨进程的资源同步。

(二)内存映射 I/O 实现信号量

在嵌入式系统或无文件系统环境中,可通过内存映射文件实现信号量:

  1. 创建共享内存区域(如 mmap );
  2. 在共享内存中初始化信号量结构体(模拟 sem_t );
  3. 手动实现 sem_wait/sem_post(通过原子操作增减计数器 )。

示例(简化版 ):

struct my_sem {
    int count;
    pthread_cond_t cond;
    pthread_mutex_t mutex;
};

void my_sem_wait(struct my_sem *sem) {
    pthread_mutex_lock(&sem->mutex);
    while (sem->count == 0) {
        pthread_cond_wait(&sem->cond, &sem->mutex);
    }
    sem->count--;
    pthread_mutex_unlock(&sem->mutex);
}

void my_sem_post(struct my_sem *sem) {
    pthread_mutex_lock(&sem->mutex);
    sem->count++;
    pthread_cond_signal(&sem->cond);
    pthread_mutex_unlock(&sem->mutex);
}

这种方式适配无 Posix 信号量支持的环境,但实现复杂(需处理原子操作、条件变量 )。

五、信号量的局限与应对

(一)系统资源限制

信号量受系统最大信号量数最大计数器值限制(如 /proc/sys/kernel/sem ),超出限制会创建失败。

应对策略:

  • 修改内核参数(需管理员权限 );
  • 复用信号量(如合并多个小信号量为大计数信号量 )。

(二)优先级反转与死锁

在实时系统中,信号量可能引发优先级反转(低优先级线程持有信号量,高优先级线程等待 );若信号量使用不当(如循环等待多个信号量 ),会导致死锁。

应对策略:

  • 使用优先级继承(如实时互斥锁 );
  • 避免嵌套信号量请求,统一加锁顺序。

(三)性能开销

信号量的 sem_wait/sem_post 涉及内核态切换,高频操作时性能低于用户态同步原语(如自旋锁 )。

应对策略:

  • 结合用户态信号量(如 pthread_spinlock_t )处理高频场景;
  • 批量操作(如一次性 sem_post 多个资源,减少系统调用 )。

六、总结:信号量的技术价值

Posix 信号量通过“计数器 + 等待/唤醒”模型,实现了从简单互斥到复杂多资源控制的灵活同步:

  • 二元信号量等效互斥锁,适配单资源场景;
  • 计数信号量控制多资源访问,优化生产者 - 消费者、连接池等模型;
  • 进程间共享特性,支持跨进程协作(如容器化应用 )。

尽管存在性能开销、死锁风险等问题,但通过合理设计(如结合互斥锁、优化锁粒度 ),信号量仍是多线程/多进程同步的核心工具。掌握其原理与实践,能大幅提升并发程序的稳定性与效率。


网站公告

今日签到

点亮在社区的每一天
去签到