Linux C并发编程基础(线程安全)
·
Linux 中的线程安全(Thread Safety),它是多线程开发的核心痛点与必备知识点。线程安全指的是:当多个线程同时访问共享资源时,无论线程的执行顺序如何、是否被中断,都能保证共享资源的数据一致性和程序执行结果的正确性,且不会出现数据损坏、资源泄露等异常。反之,若多线程访问共享资源时出现数据混乱、结果异常,则称为「线程不安全」。
1.线程不安全的核心根源
Linux 线程共享进程的大部分资源(全局变量、堆、打开的文件描述符、互斥锁等),这是线程高效通信的基础,也是线程不安全的根本原因,具体可归结为 3 点:
- 共享资源的并发访问:多个线程同时对同一共享资源(如全局变量、共享缓冲区)执行「写操作」,或「读 + 写操作」(纯读操作通常安全),导致数据竞争(Data Race)。
- 线程执行的不确定性:线程是内核调度的基本单位,执行顺序、执行时长由内核调度决定(抢占式调度),线程可能在任意指令间隙被挂起,切换到其他线程执行,导致共享资源的操作无法原子化。
- 非原子操作的拆分:看似简单的操作(如 i++),在底层汇编中会被拆分为「读取 - 修改 - 写入」3 步非原子操作,线程若在中间步骤被抢占,会导致数据不一致。
典型示例:线程不安全的 i++ 操作
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int global_count = 0; // 共享全局变量
// 线程入口函数:执行 100000 次 i++
void *thread_inc(void *arg) {
for (int i = 0; i < 100000; i++) {
global_count++; // 非原子操作,存在数据竞争
}
return NULL;
}
int main() {
pthread_t tid1, tid2;
pthread_create(&tid1, NULL, thread_inc, NULL);
pthread_create(&tid2, NULL, thread_inc, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
// 预期结果:200000,实际结果小于 200000(线程不安全)
printf("最终 global_count:%d\n", global_count);
return 0;
}
编译运行后,global_count 最终值远小于 200000,这就是线程不安全的直观体现。
2.线程安全的核心保障:同步与互斥机制
解决线程不安全问题的核心思路是:通过同步与互斥机制,保证共享资源的访问具有「原子性」、「可见性」和「有序性」,Linux 中主要依赖 pthread 库提供的同步互斥工具,最常用的有以下 4 种。
2.1.互斥锁(Mutex):最常用的排他性同步工具
互斥锁(Mutual Exclusion)是一种「排他锁」,核心作用是保证同一时间只有一个线程能访问临界资源(共享资源),其他线程若想访问,必须阻塞等待锁释放,从而避免数据竞争。
- 核心特性:
- 排他性:锁被一个线程持有后,其他线程无法获取,只能阻塞等待。
- 原子性:锁的获取(pthread_mutex_lock())和释放(pthread_mutex_unlock())是原子操作,不会被线程调度打断。
- 可重入性:默认的互斥锁(非递归锁)不可重入,同一线程多次获取同一把锁会导致死锁;递归互斥锁(PTHREAD_MUTEX_RECURSIVE)允许同一线程多次获取,需对应多次释放。
- 核心接口(pthread 库):
// 1. 初始化互斥锁
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
// 2. 获取互斥锁(阻塞式,获取不到则阻塞等待)
int pthread_mutex_lock(pthread_mutex_t *mutex);
// 3. 尝试获取互斥锁(非阻塞式,获取不到立即返回错误,不阻塞)
int pthread_mutex_trylock(pthread_mutex_t *mutex);
// 4. 释放互斥锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 5. 销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 说明:attr 传入 NULL 表示使用默认属性(非递归、非超时);互斥锁必须初始化后使用,销毁前必须确保已释放。
- 示例:用互斥锁解决 i++ 线程不安全问题
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int global_count = 0;
pthread_mutex_t global_mutex; // 定义互斥锁
void *thread_inc(void *arg) {
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&global_mutex); // 加锁,进入临界区
global_count++; // 临界区操作,保证原子性
pthread_mutex_unlock(&global_mutex); // 解锁,退出临界区
}
return NULL;
}
int main() {
pthread_t tid1, tid2;
pthread_mutex_init(&global_mutex, NULL); // 初始化互斥锁
pthread_create(&tid1, NULL, thread_inc, NULL);
pthread_create(&tid2, NULL, thread_inc, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
printf("最终 global_count:%d\n", global_count); // 预期结果:200000
pthread_mutex_destroy(&global_mutex); // 销毁互斥锁
return 0;
}
编译运行(gcc thread_safe_mutex.c -o thread_safe_mutex -lpthread)后,global_count 最终值为 200000,实现线程安全。
2.2.条件变量(Condition Variable):线程间的协作与唤醒
条件变量不具备互斥功能,核心作用是实现线程间的同步协作(如生产者 - 消费者模型),允许一个线程等待某个「条件满足」,当条件满足时,由其他线程唤醒该线程继续执行,避免线程通过轮询消耗 CPU 资源。
- 核心特性:
- 依赖互斥锁:条件变量必须与互斥锁配合使用,防止多个线程同时修改条件、同时等待条件,保证操作的原子性。
- 阻塞与唤醒:线程通过 pthread_cond_wait() 阻塞等待条件满足,其他线程通过 pthread_cond_signal()(唤醒一个线程)或 pthread_cond_broadcast()(唤醒所有等待线程)通知条件满足。
- 虚假唤醒:线程可能在没有被显式唤醒的情况下被唤醒(内核调度原因),因此需要在循环中检查条件是否真的满足。
- 核心接口(pthread 库):
// 1. 初始化条件变量
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
// 2. 阻塞等待条件满足(自动释放互斥锁,被唤醒后重新获取互斥锁)
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
// 3. 唤醒一个等待条件的线程
int pthread_cond_signal(pthread_cond_t *cond);
// 4. 唤醒所有等待条件的线程
int pthread_cond_broadcast(pthread_cond_t *cond);
// 5. 销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
- 典型场景:生产者 - 消费者模型(简单示例)
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int count = 0; // 缓冲区数据计数
pthread_mutex_t mutex;
pthread_cond_t cond_not_full; // 缓冲区未满条件
pthread_cond_t cond_not_empty; // 缓冲区非空条件
// 生产者线程:向缓冲区写入数据
void *producer(void *arg) {
int data = 1;
while (1) {
pthread_mutex_lock(&mutex);
// 循环检查缓冲区是否已满(防止虚假唤醒)
while (count == BUFFER_SIZE) {
pthread_cond_wait(&cond_not_full, &mutex);
}
// 写入缓冲区
buffer[count++] = data++;
printf("【生产者】写入数据:%d,缓冲区剩余空间:%d\n", data-1, BUFFER_SIZE - count);
pthread_cond_signal(&cond_not_empty); // 唤醒消费者线程
pthread_mutex_unlock(&mutex);
sleep(1);
}
return NULL;
}
// 消费者线程:从缓冲区读取数据
void *consumer(void *arg) {
while (1) {
pthread_mutex_lock(&mutex);
// 循环检查缓冲区是否为空(防止虚假唤醒)
while (count == 0) {
pthread_cond_wait(&cond_not_empty, &mutex);
}
// 读取缓冲区
int data = buffer[--count];
printf("【消费者】读取数据:%d,缓冲区当前数据量:%d\n", data, count);
pthread_cond_signal(&cond_not_full); // 唤醒生产者线程
pthread_mutex_unlock(&mutex);
sleep(2);
}
return NULL;
}
int main() {
pthread_t prod_tid, cons_tid;
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond_not_full, NULL);
pthread_cond_init(&cond_not_empty, NULL);
pthread_create(&prod_tid, NULL, producer, NULL);
pthread_create(&cons_tid, NULL, consumer, NULL);
pthread_join(prod_tid, NULL);
pthread_join(cons_tid, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond_not_full);
pthread_cond_destroy(&cond_not_empty);
return 0;
}
2.3.自旋锁(Spin Lock):轻量级的忙等待锁
自旋锁是一种轻量级锁,核心作用与互斥锁一致(排他性访问临界资源),但实现方式不同:当线程获取不到自旋锁时,不会阻塞挂起,而是在用户态循环忙等待,直到锁被释放。
- 核心特性:
- 忙等待:不切换内核态,无线程上下文切换开销,适合临界区执行时间极短的场景。
- 高 CPU 占用:若临界区执行时间较长,自旋锁会持续占用 CPU 资源,导致其他线程无法执行,性能劣于互斥锁。
- 不可重入:同一线程多次获取同一自旋锁会导致死锁(循环等待自身释放锁)。
- 适用场景 vs 不适用场景:
- 适用:临界区执行时间极短(如几纳秒 / 微秒)、多 CPU 核心环境、对性能要求极高的场景(如内核态代码、高频访问的共享变量)。
- 不适用:临界区执行时间较长、单 CPU 核心环境、IO 密集型线程(会导致 CPU 空耗)。
- 核心接口(pthread 库,用户态):
// 1. 初始化自旋锁
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
// 2. 获取自旋锁
int pthread_spin_lock(pthread_spinlock_t *lock);
// 3. 尝试获取自旋锁(非阻塞)
int pthread_spin_trylock(pthread_spinlock_t *lock);
// 4. 释放自旋锁
int pthread_spin_unlock(pthread_spinlock_t *lock);
// 5. 销毁自旋锁
int pthread_spin_destroy(pthread_spinlock_t *lock);
2.4.读写锁(RW Lock):区分读写的高效同步工具
读写锁(Reader-Writer Lock)是一种优化的互斥锁,核心作用是区分「读操作」和「写操作」,提高多读少写场景下的并发性能。
- 核心特性:
- 读共享:多个线程可以同时获取读锁,执行读操作(纯读操作无数据竞争),并发性能高。
- 写排他:写锁是排他锁,同一时间只能有一个线程获取写锁;持有写锁时,其他线程无法获取读锁或写锁,持有读锁时,其他线程无法获取写锁。
- 优先级:默认通常是「写优先」(避免写操作饥饿),也可配置为「读优先」。
- 适用场景:多读少写的场景(如配置文件读取、缓存数据查询、日志查询),例如:服务器中的配置信息,大部分线程是读取配置,只有少数线程在更新配置。
- 核心接口(pthread 库):
// 1. 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
// 2. 获取读锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
// 3. 获取写锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
// 4. 释放读锁/写锁(统一接口)
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
// 5. 销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
2.5.信号量(Semaphore)
信号量(Semaphore)—— 它是 Linux 中实现线程(或进程)间同步与互斥的核心机制之一,相比互斥锁,信号量更灵活,不仅能实现排他性访问(互斥),还能实现多线程间的资源计数与协作(同步),天然支持线程安全。
- 信号量的定义与核心作用:信号量本质是一个计数器 + 等待 / 唤醒机制,用于:
- 资源计数:记录可用共享资源的数量(如有限的缓冲区、连接池)。
- 同步协作:让线程等待某个资源可用(计数器 > 0),或通知其他线程资源已释放。
- 互斥排他:通过计数器 = 1 实现 “二元信号量”,等效于互斥锁,保证临界资源的排他性访问。
- 信号量的分类(针对线程场景):Linux 中信号量分为两类,线程开发优先使用「POSIX 无名信号量(线程信号量)」,更轻量、易用:

- 核心工作原理(以无名信号量为例):信号量的计数器值(sem)决定线程行为,核心逻辑如下:
- 当 sem > 0:表示有可用资源,线程调用 sem_wait()(获取资源)会将计数器原子减 1,直接返回并继续执行。
- 当 sem = 0:表示无可用资源,线程调用 sem_wait() 会阻塞挂起,直到其他线程调用 sem_post()(释放资源)将计数器加 1 后,被唤醒并完成原子减 1。
- sem_post() 始终是原子操作,即使多个线程同时调用,也不会导致计数器数据混乱,保证线程安全。
2.5.1.POSIX有名信号量
POSIX有名信号量的一般使用步骤是:
(1)使用sem_open( )来创建或者打开一个有名信号量。
(2)使用sem_wait( )和sem_post( )来分别进行P操作和V操作。
(3)使用sem_close( )来关闭他。
(4)使用sem_unlink( )来删除他,并释放系统资源。
示例:
接受数据端
#include "head.h"
int main()
{
//获取KEY值
key_t key=ftok("./",1);
//获取共享内存ID
int shm_id=shmget(key,1024,IPC_CREAT | 0777);
//对共享内存进行映射
char *shm_addr=shmat(shm_id,NULL,0);
//创建或者打开有名信号量
sem_t *sem=sem_open("sem_name",O_CREAT,0777,0);
while(1){
sem_wait(sem);
printf("%s\n",shm_addr);
if(strcmp(shm_addr,"exit")==0){
break;
}
}
//关闭并删除信号量
sem_close(sem);
sem_unlink("sem_name");
return 0;
}
发送数据端
#include "head.h"
#define SEM_NAME "sem_name"
int main()
{
//获取KEY值
key_t key=ftok("./",1);
//获取共享内存ID
int shm_id=shmget(key,1024,IPC_CREAT | 0777);
//对共享内存进行映射
char *shm_addr=shmat(shm_id,NULL,0);
//创建或者打开有名信号量
sem_t *s=sem_open(SEM_NAME,O_CREAT,0777,0);
while(1){
scanf("%s",shm_addr);
sem_post(s);
if(strcmp(shm_addr,"exit")==0){
break;
}
}
//关闭并删除信号量
sem_close(s);
sem_unlink(SEM_NAME);
return 0;
}
2.5.2.POSIX无名信号量
POSIX无名信号量的使用步骤是:
(1)在这些线程都能访问到的区域定义这种变量(比如全局变量),类型是sem_t。
(2)在任何线程使用它之前,用sem_init( )初始化他。
(3)使用sem_wait( )/sem_trywait( )和sem_post( )来分别进行P、V操作。
(4)不再需要时,使用sem_destroy()来销毁他。
示例:
接受数据端
#include "head.h"
int main()
{
//获取KEY
key_t key=ftok("./",2);
//获取共享内存ID
int shm_id=shmget(key,1024,IPC_CREAT | 0777);
//映射共享内存
char *shm_addr=shmat(shm_id,NULL,0);
//初始化无名信号量
sem_t *s=sem_open("sem_name",O_CREAT,0777,0);
int ret=sem_init(s,1,0);
while(1){
sem_wait(s);
printf("%s\n",shm_addr);
if(strcmp(shm_addr,"exit")==0){
break;
}
}
//销毁无名信号量
sem_destroy(s);
return 0;
}
发送数据端
#include "head.h"
int main()
{
//获取KEY
key_t key=ftok("./",2);
//获取共享内存ID
int shm_id=shmget(key,1024,IPC_CREAT | 0777);
//映射共享内存
char *shm_addr=shmat(shm_id,NULL,0);
//初始化无名信号量
sem_t *s=sem_open("sem_name",O_CREAT,0777,0);
int ret=sem_init(s,1,0);
while(1){
scanf("%s",shm_addr);
sem_post(s);
if(strcmp(shm_addr,"exit")==0){
break;
}
}
//销毁无名信号量
sem_destory(s);
return 0;
}
2.5.3.线程安全的核心:POSIX 无名信号量(核心接口)
线程开发中,semaphore.h 提供的无名信号量是首选,所有接口均为线程安全,编译时需链接 -lpthread 库(与 pthread 线程库配套)。
(1) 初始化信号量:sem_init()
- 函数原型:
// 初始化无名信号量,成功返回 0,失败返回 -1 并设置 errno
int sem_init(sem_t *sem, int pshared, unsigned int value);
- 参数说明:
- sem:指向要初始化的信号量对象(sem_t 类型)。
- pshared:是否支持跨进程共享(线程场景必须设为 0,表示仅同一进程内线程共享,更轻量);设为 1 表示支持跨进程共享(需配合共享内存)。
- value:信号量初始计数器值(≥0):
- 互斥场景:设为 1(二元信号量,等效互斥锁)。
- 资源计数场景:设为资源总数(如缓冲区大小 5)。
- 关键注意:信号量使用前必须初始化,且只能初始化一次。
(2) 获取资源(阻塞式):sem_wait()
- 函数原型:
// 阻塞式获取信号量(原子减 1),成功返回 0,失败返回 -1 并设置 errno
int sem_wait(sem_t *sem);
- 功能:
- 原子性检查信号量计数器,若 sem > 0,直接将计数器减 1,返回。
- 若 sem = 0,线程阻塞挂起,直到计数器 > 0(其他线程调用 sem_post()),被唤醒后完成减 1 并返回。
- 支持被信号中断,中断后返回 -1,errno 设为 EINTR。
- 线程安全:操作是原子的,多个线程同时调用不会导致计数器数据竞争。
(3) 尝试获取资源(非阻塞式):sem_trywait()
// 非阻塞式获取信号量(原子减 1),成功返回 0,失败返回 -1 并设置 errno int sem_trywait(sem_t *sem);
- 功能:与 sem_wait() 类似,但不阻塞:
- 若 sem > 0,原子减 1 并返回 0。
- 若 sem = 0,立即返回 -1,errno 设为 EAGAIN,不阻塞线程。
- 适用场景:避免线程长时间阻塞,需要轮询获取资源的场景。
(4) 释放资源(唤醒等待线程):sem_post()
- 函数原型:
// 释放信号量(原子加 1),成功返回 0,失败返回 -1 并设置 errno
int sem_post(sem_t *sem);
- 功能:
- 原子性将信号量计数器加 1。
- 若有线程因调用 sem_wait() 阻塞在该信号量上,会唤醒其中一个等待线程(内核调度决定),让其完成 sem_wait() 的减 1 操作。
- 关键注意:
- 仅能释放已获取的资源(或初始化后的值),避免计数器溢出(虽无硬性限制,但会导致逻辑混乱)。
- 操作是原子的,支持多线程同时调用,保证线程安全。
(5) 销毁信号量:sem_destroy()
- 函数原型:
// 销毁已初始化的无名信号量,成功返回 0,失败返回 -1 并设置 errno
int sem_destroy(sem_t *sem);
- 功能:释放信号量占用的资源,销毁后信号量不可再使用。
- 关键注意:
- 必须确保所有线程都已不再使用该信号量(无线程阻塞在 sem_wait() 上),否则会导致未定义行为。
- 仅能销毁 sem_init() 初始化的无名信号量,不可销毁未初始化或已销毁的信号量。
(6)获取信号量当前值:sem_getvalue()
// 获取信号量当前计数器值,成功返回 0,失败返回 -1 并设置 errno
int sem_getvalue(sem_t *sem, int *sval);
- sval:输出参数,存储信号量当前值:
- 若 sem > 0:*sval 为当前计数器值。
- 若 sem = 0 且有线程阻塞等待:*sval 通常为 0(部分系统返回 -阻塞线程数)。
2.5.4.信号量的线程安全实操示例
下面通过两个典型场景演示信号量的使用,均保证线程安全:
- 场景 1:二元信号量(value=1)实现互斥(等效互斥锁,解决 i++ 线程不安全问题)。
- 场景 2:计数信号量(value=N)实现生产者 - 消费者模型(资源计数与线程协作)。
场景 1:二元信号量实现互斥(解决数据竞争)
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
// 全局共享资源
int global_count = 0;
// 信号量(二元信号量,用于互斥)
sem_t global_sem;
// 线程入口函数:执行 100000 次 i++,通过信号量保证原子性
void *thread_inc(void *arg) {
for (int i = 0; i < 100000; i++) {
// 步骤 1:获取信号量(阻塞式,原子减 1,计数器从 1→0,其他线程阻塞)
if (sem_wait(&global_sem) == -1) {
perror("sem_wait failed");
pthread_exit(NULL);
}
// 临界区:共享资源操作(保证同一时间只有一个线程进入)
global_count++;
// 步骤 2:释放信号量(原子加 1,计数器从 0→1,唤醒等待线程)
if (sem_post(&global_sem) == -1) {
perror("sem_post failed");
pthread_exit(NULL);
}
}
printf("【线程 %lu】执行完毕\n", (unsigned long)pthread_self());
return NULL;
}
int main() {
pthread_t tid1, tid2;
// 步骤 1:初始化信号量(二元信号量,pshared=0 线程共享,value=1)
if (sem_init(&global_sem, 0, 1) == -1) {
perror("sem_init failed");
exit(EXIT_FAILURE);
}
// 步骤 2:创建两个线程
if (pthread_create(&tid1, NULL, thread_inc, NULL) != 0) {
perror("pthread_create tid1 failed");
exit(EXIT_FAILURE);
}
if (pthread_create(&tid2, NULL, thread_inc, NULL) != 0) {
perror("pthread_create tid2 failed");
exit(EXIT_FAILURE);
}
// 步骤 3:等待线程执行完毕
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
// 步骤 4:销毁信号量
if (sem_destroy(&global_sem) == -1) {
perror("sem_destroy failed");
exit(EXIT_FAILURE);
}
// 预期结果:200000(线程安全,无数据竞争)
printf("最终 global_count:%d\n", global_count);
return 0;
}
编译与运行
gcc sem_mutex.c -o sem_mutex -lpthread
./sem_mutex
运行结果
【线程 140708329981696】执行完毕
【线程 140708321588992】执行完毕
最终 global_count:200000
场景 2:计数信号量实现生产者 - 消费者模型
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
#define BUFFER_SIZE 5 // 缓冲区大小(资源总数)
int buffer[BUFFER_SIZE];
int prod_index = 0; // 生产者写入索引
int cons_index = 0; // 消费者读取索引
// 信号量定义
sem_t sem_empty; // 空缓冲区数量(初始=BUFFER_SIZE,资源计数)
sem_t sem_full; // 满缓冲区数量(初始=0,同步唤醒)
sem_t sem_mutex; // 互斥信号量(保护缓冲区索引操作,二元信号量)
// 生产者线程:向缓冲区写入数据
void *producer(void *arg) {
int data = 1;
while (1) {
// 步骤 1:获取空缓冲区(计数减 1,无空缓冲区则阻塞)
sem_wait(&sem_empty);
// 步骤 2:获取互斥锁,保护缓冲区索引(原子操作)
sem_wait(&sem_mutex);
// 步骤 3:写入缓冲区
buffer[prod_index] = data++;
printf("【生产者】写入数据:%d,缓冲区索引:%d\n", data-1, prod_index);
prod_index = (prod_index + 1) % BUFFER_SIZE;
// 步骤 4:释放互斥锁
sem_post(&sem_mutex);
// 步骤 5:释放满缓冲区(计数加 1,唤醒消费者)
sem_post(&sem_full);
// 模拟生产耗时
sleep(1);
}
return NULL;
}
// 消费者线程:从缓冲区读取数据
void *consumer(void *arg) {
while (1) {
// 步骤 1:获取满缓冲区(计数减 1,无数据则阻塞)
sem_wait(&sem_full);
// 步骤 2:获取互斥锁,保护缓冲区索引
sem_wait(&sem_mutex);
// 步骤 3:读取缓冲区
int data = buffer[cons_index];
printf("【消费者】读取数据:%d,缓冲区索引:%d\n", data, cons_index);
cons_index = (cons_index + 1) % BUFFER_SIZE;
// 步骤 4:释放互斥锁
sem_post(&sem_mutex);
// 步骤 5:释放空缓冲区(计数加 1,唤醒生产者)
sem_post(&sem_empty);
// 模拟消费耗时
sleep(2);
}
return NULL;
}
int main() {
pthread_t prod_tid, cons_tid;
// 步骤 1:初始化信号量
sem_init(&sem_empty, 0, BUFFER_SIZE); // 空缓冲区初始=5
sem_init(&sem_full, 0, 0); // 满缓冲区初始=0
sem_init(&sem_mutex, 0, 1); // 互斥信号量初始=1
// 步骤 2:创建线程
pthread_create(&prod_tid, NULL, producer, NULL);
pthread_create(&cons_tid, NULL, consumer, NULL);
// 步骤 3:等待线程(无限循环,可通过信号终止)
pthread_join(prod_tid, NULL);
pthread_join(cons_tid, NULL);
// 步骤 4:销毁信号量(实际不会执行到此处)
sem_destroy(&sem_empty);
sem_destroy(&sem_full);
sem_destroy(&sem_mutex);
return 0;
}
编译与运行
gcc sem_prod_cons.c -o sem_prod_cons -lpthread
./sem_prod_cons
运行结果(核心片段)
【生产者】写入数据:1,缓冲区索引:0
【消费者】读取数据:1,缓冲区索引:0
【生产者】写入数据:2,缓冲区索引:1
【生产者】写入数据:3,缓冲区索引:2
【消费者】读取数据:2,缓冲区索引:1
2.5.5.信号量 vs 互斥锁:核心区别与适用场景
信号量和互斥锁都是线程安全的同步工具,但设计目标和适用场景不同,切勿混淆:
2.5.6.信号量使用的常见误区与最佳实践
- 常见误区
- 误区 1:线程场景中 pshared 设为 1。「错误」:线程间共享信号量应设为 0,1 是跨进程共享,会增加开销,且无需配合共享内存。
- 误区 2:释放未获取的信号量。「错误」:会导致信号量计数器溢出,逻辑混乱(如生产者未写入数据却调用 sem_post(&sem_full))。
- 误区 3:忽略信号量接口的返回值。「错误」:sem_wait()/sem_post() 可能因信号中断、资源不足失败,需检查返回值并处理。
- 误区 4:用互斥锁替代计数信号量。「错误」:互斥锁无资源计数功能,实现多资源协作需手动维护计数器,复杂且易出错。
- 误区 5:信号量初始化后未销毁。「错误」:会导致进程内资源泄露,长期运行可能耗尽系统资源。
- 最佳实践
- 线程场景优先使用无名信号量:轻量、高效,接口简单,无需关心内核资源管理。
- 合理设置初始值:互斥场景设为 1,资源计数场景设为资源总数(如缓冲区大小)。
- 最小化信号量的作用域:仅在需要同步 / 互斥的代码块前后调用 sem_wait()/sem_post(),避免不必要的阻塞。
- 配对使用 sem_wait() 与 sem_post():确保每个获取操作都有对应的释放操作,避免死锁。
- 互斥与同步分离:如生产者 - 消费者模型中,用 sem_mutex 保护临界区,用 sem_empty/sem_full 实现同步,职责清晰。
- 避免信号量嵌套:多个信号量的获取顺序不一致可能导致死锁,若需嵌套,需统一获取顺序。
2.5.7.核心总结
- 信号量是线程安全的计数器 + 等待 / 唤醒机制,支持互斥(二元信号量)和同步(计数信号量),比互斥锁更灵活。
- 线程开发的核心是 POSIX 无名信号量(sem_* 接口),sem_init() 初始化时 pshared=0,编译需链接 -lpthread。
- 核心接口:sem_init()(初始化)、sem_wait()(阻塞获取)、sem_post()(释放唤醒)、sem_destroy()(销毁),操作均为原子性,无数据竞争。
- 二元信号量(value=1)等效于互斥锁,计数信号量(value=N)适用于多资源协作(如生产者 - 消费者模型)。
- 最佳实践:合理设置初始值、配对使用接口、检查返回值、及时销毁信号量,避免逻辑混乱和资源泄露。
- 信号量是多线程开发中实现复杂协作的核心工具,尤其在高并发、有限资源场景中(如连接池、缓冲区管理)至关重要。
3.其他线程安全保障手段
除了上述核心同步互斥机制,还有一些辅助手段可以提升线程安全性,避免潜在问题。
3.1.避免共享资源(最小化共享)
这是最根本、最安全的线程安全方案:尽量减少线程间的共享资源,使用线程私有资源替代共享资源。
- 线程私有数据(TSD):通过 pthread_key_create()、pthread_setspecific()、pthread_getspecific() 为每个线程创建独立的私有数据,避免共享。
- 局部变量:线程内的函数局部变量存储在线程私有栈中,不与其他线程共享,天然线程安全。
3.2.保证操作的原子性(原子操作 API)
对于简单的数值操作(如加减、赋值),可以使用 Linux 提供的原子操作接口(无需锁机制),保证操作的原子性,性能高于互斥锁。
- 示例:__sync_add_and_fetch(&global_count, 1)(原子实现 global_count++),__sync_sub_and_fetch(&global_count, 1)(原子实现 global_count--)。
- 适用场景:简单的整数增减、比较交换等操作,不支持复杂的逻辑操作。
3.3.线程安全的库函数
Linux 中的库函数分为「线程安全函数」和「线程不安全函数」:
- 线程安全函数:内部实现了同步机制(如互斥锁),支持多线程并发调用(如 printf()、pthread_* 系列函数)。
- 线程不安全函数:无内部同步机制,多线程调用可能导致数据混乱(如 strtok()、gethostbyname()),通常有对应的线程安全版本(如 strtok_r()、gethostbyname_r(),_r 表示可重入、线程安全)。
4.线程安全的常见误区与最佳实践
4.1.常见误区
- 误区 1:加锁越多越安全。「错误」:过多的锁会导致「锁竞争」加剧、性能下降,还可能引发「死锁」(多个线程互相等待对方释放锁)。
- 误区 2:纯读操作一定线程安全。「错误」:若有线程同时执行写操作,纯读操作也会读取到不一致的数据(可见性问题),仍需同步机制。
- 误区 3:自旋锁性能一定优于互斥锁。「错误」:自旋锁仅适用于临界区极短的场景,临界区较长时,互斥锁的性能更优。
- 误区 4:忽略死锁的预防。「错误」:多个锁的获取顺序不一致、锁未释放、递归获取非递归锁,都可能导致死锁,一旦发生无法主动解除。
4.2.最佳实践
- 最小化锁的作用域:锁仅包裹临界区代码(共享资源操作),避免锁包裹无关代码,减少锁竞争时间。
- 统一锁的获取顺序:多个线程获取多把锁时,遵循相同的顺序(如先锁 A 后锁 B),预防死锁。
- 优先使用默认互斥锁:无特殊性能要求时,pthread_mutex_t 是最安全、最易用的选择,避免过度优化。
- 避免锁的嵌套:尽量减少锁的嵌套使用,嵌套过多容易导致死锁,且难以排查。
- 使用线程安全的库函数:优先选择带 _r 后缀的可重入函数,避免使用线程不安全的库函数。
- 死锁的预防与排查:避免持有锁时阻塞等待其他线程,使用 pthread_mutex_trylock() 非阻塞获取锁,排查死锁可使用 pstack、gdb 工具。
5.核心总结
- 线程不安全的根本原因是共享资源的并发访问、线程调度的不确定性、非原子操作的拆分。
- 线程安全的核心保障是同步与互斥机制,常用工具包括:互斥锁(通用)、条件变量(线程协作)、自旋锁(轻量级忙等待)、读写锁(多读少写优化)。
- 互斥锁是最常用的同步工具,保证临界资源的排他性访问,适用于大多数场景;条件变量需与互斥锁配合,实现线程间的高效协作。
- 最佳实践:最小化共享资源、最小化锁的作用域、统一锁的获取顺序、优先使用线程安全的接口,避免死锁和性能问题。
- 线程安全是多线程开发的必备技能,直接决定程序的健壮性和稳定性,尤其在后端服务、高并发程序开发中至关重要。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)