互斥锁与条件变量:并发控制的核心武器
在多线程编程的世界里,并发控制是绕不开的核心话题。当多个线程同时访问共享资源时,数据竞争、死锁等问题会像“幽灵”一样出现,破坏程序的正确性。互斥锁(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
,互相等待; - 忘记解锁:线程异常退出,未释放锁,其他线程永久阻塞。
避免死锁的关键:
- 统一锁顺序:所有线程按固定顺序(如
mutex1
→mutex2
)加锁; - 使用带超时的锁(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_GETLK
、F_SETLK
、F_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
中进程是否存在 )。
五、记录上锁的扩展:守护进程的唯一副本启动
记录上锁可用于保障守护进程的唯一副本 :
- 守护进程启动时,对特定文件(如
/var/run/mydaemon.lock
)加写锁; - 若锁已存在(
fcntl
返回EAGAIN
),则退出(已有副本运行 ); - 守护进程退出时,释放锁,允许下次启动。
示例:
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
),遵循文件系统路径规则;oflag
:O_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
的文件系统命名,信号量可被多进程共享:
- 进程 A
sem_open("/my_sem", O_CREAT, 0644, 3)
创建信号量; - 进程 B
sem_open("/my_sem", 0, 0, 0)
打开并使用; - 所有进程
sem_close
后,进程 Asem_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 实现信号量
在嵌入式系统或无文件系统环境中,可通过内存映射文件实现信号量:
- 创建共享内存区域(如
mmap
); - 在共享内存中初始化信号量结构体(模拟
sem_t
); - 手动实现
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 信号量通过“计数器 + 等待/唤醒”模型,实现了从简单互斥到复杂多资源控制的灵活同步:
- 二元信号量等效互斥锁,适配单资源场景;
- 计数信号量控制多资源访问,优化生产者 - 消费者、连接池等模型;
- 进程间共享特性,支持跨进程协作(如容器化应用 )。
尽管存在性能开销、死锁风险等问题,但通过合理设计(如结合互斥锁、优化锁粒度 ),信号量仍是多线程/多进程同步的核心工具。掌握其原理与实践,能大幅提升并发程序的稳定性与效率。