线程的同步与互斥
通过之前的学习,可以知道一个进程中可以存在多条线程,每条线程可以执行一个任务,而线程是并发执行的,这样可以提高程序的运行效率,但是系统分配资源是以进程为单位的,而进程中的所有线程会共享这些资源。
思考:由于进程中创建出来的线程是并发执行的,也就是可能会出现线程之间抢占资源的情况,请问应该如何避免?
回答:可以利用线程间的同步和互斥机制,来达到线程对资源的有序访问,同步指的是控制两个进度使之有先有后,次序可控,而互斥指的是控制两个进度使之互相排斥,不同时运行。
1.读写锁
思考:互斥锁是为了防止多个线程对共享资源访问时破坏资源,但是如果某些线程只是为了获取共享资源的值,而不打算修改共享资源,这样所有的线程对共享资源访问的时候都使用互斥锁,就会导致程序的效率下降,请问如何解决这种问题?
回答:对于互斥锁而言,凡是涉及临界资源的访问一律加锁,这在并发读操作的场景下会大量浪费时间。要想提高访问效率,就必须要将对资源的读写操作加以区分:读操作可以多任务并发执行,只有写操作才进行恰当的互斥。Linux系统就提供了一种读写锁。
1.1初始化读写锁
1.1.1参数
rwlock
:指向要初始化的读写锁变量(pthread_rwlock_t
类型)的指针。
attr
:读写锁的属性设置(通常传 NULL
使用默认属性)。返回值:
1.1.2返回值
成功返回 0
;
失败返回非 0
错误码(如 ENOMEM
表示内存不足,EBUSY
表示锁已初始化)。加锁解锁操作
1.2加锁解锁操作
读写锁最大的特点是对即将要做的读写操作做了区分:读操作可以共享,因此多条线程可以对同一个读写锁加多重读锁。写操作天然互斥,因此多条线程中只能有一条线程拥有写锁。
如果只对数据进行读操作,那么就填加读锁。如果要对数据进行写操作,那么就填加写锁。
注意:读写锁适合多个线程对共享资源进行读操作和写操作,并且写操作没有读操作频繁的情况,另外要注意,如果多条线程对资源进行读取时,可以添加多把读操作锁。
注意:如果有一条线程准备对资源进行修改,则不应该有其他的线程再对资源进行读或者写,就相当于写操作和读操作是互斥的。
另外,如果已经有一条线程获取了写操作锁,那其他线程就无法获取写操作锁,就需要进行阻塞等待,所以就相当与写操作和写操作是互斥的。
设计一个程序,程序中有3个线程,主线程A创建一个文本,每隔5s获取一次系统时间并写入到该文本中,另外两个线程B和C分别从文本中读取当前的时间和日期,子线程B输出系统时间”hh:mm:ss”,子线程C输出系统日期”年,月,日”,要求使用读写锁实现互斥。 提示:主线程A获取写操作的锁,另外的线程分别获取读操作的锁。
#define _XOPEN_SOURCE 700 // 启用strptime和读写锁的完整声明
#include <stdio.h>
#include <pthread.h>
#include <time.h> // 包含strptime的声明(需配合_XOPEN_SOURCE)
#include <unistd.h>
#include <stdlib.h>
#include <string.h>#define FILE_NAME "time_log.txt"
pthread_rwlock_t rwlock;// 线程B:输出时间 "hh:mm:ss"
void *thread_b(void *arg) {char buffer[100];struct tm tm_info; // 正确类型:struct tm,用于存储解析结果while (1) {if (pthread_rwlock_rdlock(&rwlock) != 0) {perror("thread B: 无法获取读锁");pthread_exit(NULL);}FILE *file = fopen(FILE_NAME, "r");if (file == NULL) {perror("thread B: 无法打开文件");pthread_rwlock_unlock(&rwlock);sleep(1);continue;}if (fgets(buffer, sizeof(buffer), file) != NULL) {// 修正:strptime的第三个参数必须是struct tm*char *result = strptime(buffer, "%Y-%m-%d %H:%M:%S", &tm_info);if (result != NULL) { // 正确比较:指针与NULLprintf("线程B (时间): %02d:%02d:%02d\n", tm_info.tm_hour, tm_info.tm_min, tm_info.tm_sec);}}fclose(file);pthread_rwlock_unlock(&rwlock);sleep(2);}
}// 线程C:输出日期 "年,月,日"
void *thread_c(void *arg) {char buffer[100];struct tm tm_info; // 正确类型:struct tmwhile (1) {if (pthread_rwlock_rdlock(&rwlock) != 0) {perror("thread C: 无法获取读锁");pthread_exit(NULL);}FILE *file = fopen(FILE_NAME, "r");if (file == NULL) {perror("thread C: 无法打开文件");pthread_rwlock_unlock(&rwlock);sleep(1);continue;}if (fgets(buffer, sizeof(buffer), file) != NULL) {// 修正:显式接收返回值(char*),与NULL比较char *result = strptime(buffer, "%Y-%m-%d %H:%M:%S", &tm_info);if (result != NULL) {printf("线程C (日期): %d年,%d月,%d日\n", tm_info.tm_year + 1900, // 转换为实际年份tm_info.tm_mon + 1, // 转换为实际月份(0-11→1-12)tm_info.tm_mday);}}fclose(file);pthread_rwlock_unlock(&rwlock);sleep(3);}
}// 主线程A:写入系统时间
int main() {pthread_t tid_b, tid_c;time_t current_time;struct tm *time_info;char time_str[20];if (pthread_rwlock_init(&rwlock, NULL) != 0) {perror("无法初始化读写锁");return 1;}if (pthread_create(&tid_b, NULL, thread_b, NULL) != 0) {perror("无法创建线程B");return 1;}if (pthread_create(&tid_c, NULL, thread_c, NULL) != 0) {perror("无法创建线程C");return 1;}printf("程序运行中,按Ctrl+C退出...\n");while (1) {if (pthread_rwlock_wrlock(&rwlock) != 0) {perror("主线程: 无法获取写锁");break;}time(¤t_time);time_info = localtime(¤t_time);strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S", time_info);FILE *file = fopen(FILE_NAME, "w");if (file == NULL) {perror("主线程: 无法打开文件");pthread_rwlock_unlock(&rwlock);sleep(5);continue;}fprintf(file, "%s\n", time_str);fclose(file);printf("主线程A: 写入时间 - %s\n", time_str);pthread_rwlock_unlock(&rwlock);sleep(5);}pthread_rwlock_destroy(&rwlock);pthread_join(tid_b, NULL);pthread_join(tid_c, NULL);return 0;
}
2.POSIX信号量
信号量可以用于多个不同进程间或者同一个进程中多个不同线程间进行同步的方案,Linux系统提供了POSIX信号量和IPC对象中的信号量集供用户使用。
POSIX信号量一般用于描述一种共享资源的状态,Linux系统把POSIX信号量分为两种:一种是POSIX匿名信号量,另一种则是POSIX具名信号量,两者区别如下所示:
2.1POSIX匿名信号量
Linux系统中的信号量可以用于进程间通信,也可以用于同一个进程中多个线程的通信,而POSIX匿名信号量则是专门在进程中的多个线程间进行使用的一种信号量,也就是只存在于内存中。
2.1.1初始化POSIX匿名信号量
Linux系统中提供了一个名称叫做sem_init()的函数接口,用户利用该接口可以对POSIX匿名信号量进行初始化。
2.1.1.1参数
sem
:指向要初始化的信号量对象(sem_t
类型)的指针。
pshared
:指定信号量的共享范围:
0
:信号量用于同一进程内的线程间同步(最常用)。
非 0
:信号量用于不同进程间同步(需放在共享内存中,如通过 shm_open
或 mmap
创建的共享区域)。
value
:信号量的初始值(>=0),决定了可同时访问共享资源的线程 / 进程数量:
若用于互斥(同一时间仅一个访问者),初始值设为 1
(二进制信号量)。
若用于限流(如最多 N 个访问者),初始值设为 N
(计数信号量)。
2.1.1.1返回值
成功返回 0
;
失败返回 -1
,并设置 errno
指示错误(如 EINVAL
表示参数无效,ENOSYS
表示系统不支持)。
2.1.2对POSIX信号量进行P/V
对于信号量而言,都是对资源的一种数量的表示,所以进程或者线程在有限的资源进行访问时都需要进行申请和释放,所以Linux系统下提供了两个函数来实现POSIX匿名信号量的P/V操作,分别是sem_wait()和sem_post()。
设计一个程序,要求在进程中存在两条线程,主线程获取键盘的字符串并输入到数组中,子线程等待主线程输入完成后,在把数组中的字符串输出到终端。要求使用POSIX信号量实现线程的同步。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <semaphore.h>
#include <strings.h>
#include <stdlib.h>
#include <string.h>
// 临界资源,volatile修饰防止编译器优化
volatile char buf[128] = {0}; // POSIX匿名信号量,用于线程同步
sem_t sem_main; // 主线程信号量:控制输入
sem_t sem_child; // 子线程信号量:控制输出// 子线程任务:等待并输出缓冲区内容
void *task_B(void *arg)
{while (1){// 等待主线程输入完成(P操作)sem_wait(&sem_child);// 输出缓冲区内容printf("子线程输出: [%s]\n", buf);bzero((void*)buf, sizeof(buf)); // 清空缓冲区// 通知主线程可以继续输入(V操作)sem_post(&sem_main);}
}// 主线程:获取键盘输入并通知子线程
int main(int argc, char const *argv[])
{// 初始化信号量// sem_main: 初始值1,允许主线程先输入// sem_child: 初始值0,子线程先等待sem_init(&sem_main, 0, 1);sem_init(&sem_child, 0, 0);// 创建子线程pthread_t thread_B;if (pthread_create(&thread_B, NULL, task_B, NULL) != 0){perror("pthread_create failed");return 1;}// 持续获取键盘输入while (1){// 等待子线程处理完成(P操作)sem_wait(&sem_main);// 提示用户输入printf("请输入字符串(输入quit退出): ");fflush(stdout); // 确保提示信息及时输出// 获取键盘输入if (scanf("%s", (char*)buf) != 1){perror("输入错误");break;}// 检查退出条件if (strcmp((char*)buf, "quit") == 0){printf("程序退出中...\n");break;}// 通知子线程可以输出(V操作)sem_post(&sem_child);}// 清理资源sem_destroy(&sem_main);sem_destroy(&sem_child);pthread_cancel(thread_B); // 终止子线程pthread_join(thread_B, NULL); // 回收子线程资源return 0;
}
2.2POSIX具名信号量
POSIX匿名信号量主要是在进程中的多条线程中使用,而POSIX具名信号量则主要是在多个进程中使用,并且可以存储在根文件系统的 /dev/shm 目录下,可以被系统中任意有权限的进程访问。
2.2.1创建或者打开POSIX具名信号量
由于POSIX具名信号量有自己的文件名称,所以Linux系统提供了一个名称叫做sem_open()的函数接口,用户可以利用该接口创建或者打开POSIX具名信号量。
2.2.1.1参数
name
:信号量的名称,需以 /
开头(如 "/my_sem"
),遵循文件系统命名规则,是跨进程识别的唯一标识。
oflag
:打开方式,常用组合:
O_CREAT
:若信号量不存在则创建(需配合 mode
和 value
)。
O_EXCL
:与 O_CREAT
同时使用,若信号量已存在则返回错误(避免覆盖)。
O_RDWR
:读写方式打开(默认)。
mode
:仅当 O_CREAT
时有效,指定信号量的权限(如 0644
表示所有者读写,组和其他只读)。
value
:仅当 O_CREAT
时有效,指定信号量的初始值(>=0)。
2.2.1.2返回值
成功返回指向 sem_t
类型的信号量指针;
失败返回 SEM_FAILED
,并设置 errno
(如 EEXIST
表示信号量已存在,ENAMETOOLONG
表示名称过长)。
2.2.2POSIX具名信号量进行P/V操作
对于信号量而言,都是对资源的一种数量的表示,所以进程或者线程在有限的资源进行访问时都需要进行申请和释放,所以Linux系统下提供了两个函数来实现POSIX具名信号量的P/V操作,分别是sem_wait()和sem_post()。
设计两个程序A和B,程序A和程序B需要创建一个共享内存,然后要求程序A把自己的PID写入到共享内存中,当程序A写入完成后,程序B从共享内存中读取程序A的PID并输出到终端,要求双方使用POSIX具名信号量实现同步。
#include <fcntl.h> /* For O_* constants */
#include <sys/stat.h> /* For mode constants */
#include <semaphore.h>
#include <sys/shm.h>
#include <stdio.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>#define SHM_SIZE 4int main(int argc, char const *argv[])
{//1.打开一个共享内存,如果不存在则创建int shm_id = shmget(ftok(".",50),SHM_SIZE,IPC_CREAT|IPC_EXCL|0644);if (shm_id == -1){if(errno == EEXIST){//此时可以再次调用该函数打开共享内存shm_id = shmget(ftok(".",50),4,0644);}else{fprintf(stderr, "shmget error,errno:%d,%s\n",errno,strerror(errno));exit(1);}}//2.把打开的共享内存段映射到自己的进程空间中int * pshm = (int *)shmat(shm_id,NULL,0);//3.打开一个POSIX有名信号量,如果不存在则创建sem_t * psem = sem_open("named_sem", O_CREAT|O_EXCL,0644,0);if(psem == SEM_FAILED){if(errno == EEXIST){//此时可以再次调用该函数打开共享内存psem = sem_open("named_sem",0);}else{fprintf(stderr, "sem_open error,errno:%d,%s\n",errno,strerror(errno));exit(2);}}//4.进程A先对临界资源进行访问,然后对POSIX信号量进行V操作*pshm = getpid();sem_post(psem);return 0;
}
#include <fcntl.h> /* For O_* constants */
#include <sys/stat.h> /* For mode constants */
#include <semaphore.h>
#include <sys/shm.h>
#include <stdio.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>#define SHM_SIZE 4int main(int argc, char const *argv[])
{//1.打开一个共享内存,如果不存在则创建int shm_id = shmget(ftok(".",50),SHM_SIZE,IPC_CREAT|IPC_EXCL|0644);if (shm_id == -1){if(errno == EEXIST){//此时可以再次调用该函数打开共享内存shm_id = shmget(ftok(".",50),4,0644);}else{fprintf(stderr, "shmget error,errno:%d,%s\n",errno,strerror(errno));exit(1);}}//2.把打开的共享内存段映射到自己的进程空间中int * pshm = (int *)shmat(shm_id,NULL,0);//3.打开一个POSIX有名信号量,如果不存在则创建sem_t * psem = sem_open("named_sem", O_CREAT|O_EXCL,0644,0);if(psem == SEM_FAILED){if(errno == EEXIST){//此时可以再次调用该函数打开共享内存psem = sem_open("named_sem",0);}else{fprintf(stderr, "sem_open error,errno:%d,%s\n",errno,strerror(errno));exit(2);}}//4.死循环,防止进程终止while(1){//进程B必须等待进程A把PID写入到共享内存中之后才可以输出共享内存段中的数据//进程B需要进行P操作sem_wait(psem);//访问临界资源printf("shared memory data is [%d]\n",*pshm);}return 0;
}
注意:如果POSIX具名信号量使用完成后,应该及时关闭,Linux系统提供了名字叫做sem_close()的函数实现该操作。
另外,POSIX具名信号量创建完成后是存储在文件系统中的,所以并不会因为进程的结束而被删除,如果用户后期不打算继续使用POSIX具名信号量,linux系统提供了名称叫做sem_unlink()的函数接口实现删除POSIX具名信号量。
3.条件量
在许多场合中,程序的执行通常需要满足一定的条件,条件不成熟的时候,任务应该进入睡眠阻塞等待,条件成熟时应该可以被快速唤醒。另外,在并发程序中,会其他任务同时访问该条件,因此任何时候都必须以互斥的方式对条件进行访问。条件量就是专门解决上述场景的逻辑机制。
注意:条件和条件量是两个不同的东西,条件是指程序要继续运行所需要的前提条件,比如文件是否读完、内存是否清空等具体的场景限定。而条件量(即pthread_cond_t)要讨论的是一种同步互斥变量,专用于解决上述逻辑场景。
操作流程:在进行条件判断前,先加锁(防止其他任务并发访问),成功加锁后判断条件是否允许,若条件允许,则直接操作临界资源,然后释放锁。若条件不允许,则进入条件量的等待队列中睡眠,并同时释放锁。
在条件量中睡眠的任务,可以被其他任务唤醒,唤醒时重新判定条件是否允许程序继续执行,当然也是必须先加锁。
条件量一般要跟互斥锁(或二值信号量)配套使用,互斥锁提供锁住临界资源的功能,条件量提供阻塞睡眠和唤醒的功能。
3.1初始化条件量
3.1.1参数
cond
:指向要初始化的条件变量(pthread_cond_t
类型)的指针
attr
:条件变量的属性设置(通常传 NULL
使用默认属性)。
3.1.2返回值
成功返回 0
;
失败返回非 0
错误码(如 ENOMEM
表示内存不足,EBUSY
表示条件变量已初始化)。
3.2进入等待状态
3.2.1参数
cond
:指向条件变量(pthread_cond_t
)的指针,线程将等待该条件变量的通知。
mutex
:指向已加锁的互斥锁(pthread_mutex_t
)的指针,函数会自动释放该锁并在唤醒后重新获取。
3.2.2返回值
成功返回 0
;
失败返回非 0
错误码(如 EINVAL
表示参数无效,EPERM
表示互斥锁未加锁)。
3.3唤醒等待任务
3.4唤醒所有任务
设计一个程序,主线程需要创建2个子线程之后主线程终止,此时进程中有2个子线程A和B,此时进程中有一个临界资源flag,子线程A获取触摸屏坐标并判断坐标值是否在LCD屏的左上角,如果坐标范围满足左上角,则利用条件量和互斥锁来唤醒子线程B,子线程B的任务是判断flag 是否大于0,如果子线程B的条件满足,则让子线程B在终端输出一个字符串即可。要求进程中使用条件量和互斥锁实现线程的同步以及临界资源的互斥访问。
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>// 临界资源:flag变量
int flag = 0;// 互斥锁和条件变量
pthread_mutex_t mutex;
pthread_cond_t cond;// 模拟LCD屏幕分辨率(假设)
#define LCD_WIDTH 800
#define LCD_HEIGHT 600
// 左上角区域定义(左上角100x100的区域)
#define TOP_LEFT_WIDTH 100
#define TOP_LEFT_HEIGHT 100// 模拟获取触摸屏坐标
void get_touch_coordinates(int *x, int *y) {// 随机生成坐标用于模拟,实际中应从硬件获取*x = rand() % LCD_WIDTH;*y = rand() % LCD_HEIGHT;// 每3秒有一次概率触发左上角区域,方便测试if (time(NULL) % 3 == 0) {*x = rand() % TOP_LEFT_WIDTH;*y = rand() % TOP_LEFT_HEIGHT;}
}// 子线程A:检测触摸屏坐标并唤醒线程B
void *thread_A(void *arg) {printf("子线程A启动,开始检测触摸屏坐标...\n");while (1) {int x, y;// 获取触摸屏坐标get_touch_coordinates(&x, &y);printf("子线程A: 触摸屏坐标 (%d, %d)\n", x, y);// 判断是否在LCD左上角区域if (x < TOP_LEFT_WIDTH && y < TOP_LEFT_HEIGHT) {printf("子线程A: 检测到坐标在左上角区域!\n");// 加锁修改临界资源pthread_mutex_lock(&mutex);// 设置flag大于0flag = 1;printf("子线程A: 设置flag = %d,唤醒子线程B\n", flag);// 唤醒等待的子线程Bpthread_cond_signal(&cond);pthread_mutex_unlock(&mutex);}// 短暂休眠,避免过度占用CPUsleep(1);}return NULL;
}// 子线程B:等待条件并输出字符串
void *thread_B(void *arg) {printf("子线程B启动,等待条件满足...\n");while (1) {// 加锁并等待条件变量pthread_mutex_lock(&mutex);// 循环等待条件满足(防止虚假唤醒)while (flag <= 0) {pthread_cond_wait(&cond, &mutex);}// 条件满足,输出字符串printf("子线程B: 条件满足(flag = %d),输出字符串: \"Hello from Thread B!\"\n", flag);// 重置flag,准备下一次检测flag = 0;pthread_mutex_unlock(&mutex);}return NULL;
}int main() {pthread_t tid_A, tid_B;// 初始化互斥锁和条件变量pthread_mutex_init(&mutex, NULL);pthread_cond_init(&cond, NULL);// 创建子线程A和Bif (pthread_create(&tid_A, NULL, thread_A, NULL) != 0) {perror("创建线程A失败");exit(1);}if (pthread_create(&tid_B, NULL, thread_B, NULL) != 0) {perror("创建线程B失败");exit(1);}// 主线程创建完子线程后终止printf("主线程已创建子线程A和B,主线程即将终止...\n");// 主线程退出,但子线程继续运行// 注意:需要设置线程分离属性,否则主线程退出后子线程可能被终止pthread_detach(tid_A);pthread_detach(tid_B);// 主线程终止return 0;
}
4.死锁的产生与解决
4.1死锁的概念
死锁(DeadLock)指的是两个或者两个以上的进程或线程在执行的过程中争抢资源而造成的一种互相等待的现象,如果没有解决方案,则这些进程或者线程就无法继续执行。
此时系统就产生了死锁,这些永远处于等待状态的进程或者线程就被称为死锁进程或者死锁线程。
系统中的进程或者线程都是并发执行的,一旦数据被多个线程或者进程共享,那么就很可能会产生争用和冲突的情况。这种情况也被称为竞态条件(race condition),这往往会破坏共享数据的一致性。
4.2死锁的产生
为了避免出现抢占资源的现象,一般对于共享资源的访问都是采用“互斥”的方案实现。但是这样就可能会出现一个问题:当一个进程或者线程去申请资源,如果正在使用资源的进程或者线程一直都没有释放资源,那申请资源的进程或者线程会处于永远阻塞的状态,无法继续执行,这就产生了“死锁”。
举个例子,一个进程中有多条线程,如果线程A锁住了资源C并等待资源D,而线程B锁住了资源D并等待资源C,这样两个线程就发生了死锁现象。
死锁的产生一般由以下四个条件:资源互斥、循环等待、不可剥夺、请求且保持。区别如下:
4.2.1资源互斥
资源互斥条件指的是一个共享资源每次只能由一个线程或者进程访问,比如互斥锁机制,如果一个资源已经被一条线程或者进程使用,但是如果该线程或者进程一直不释放该资源,此时其他需要访问该资源的线程或者进程就只能处于阻塞等待状态,也就意味着产生了死锁。
4.2.2循环等待
循环等待条件指的是多个进程或者线程形成了一个环路,比如进程A等待进程B的资源,进程B等待进程C的资源,进程C等待进程A的资源。
4.2.3不可剥夺
不可剥夺条件指的是当一个进程或者线程已经获得了资源,在该进程或者线程没有使用完之前不可以强行剥夺。
4.2.4请求保持
请求且保持条件指的是一个进程或者线程已经对资源A上锁,并且想要对资源B上锁,而另一个进程或者线程已经对资源B上锁,并且想要对资源A上锁,也就是双方都没有释放资源的同时又申请资源。
4.3死锁的解决
产生死锁会严重影响程序的执行,所以用户需要尽量避免死锁的产生,一般有以下几种方案:
4.3.1避免持多个锁
可以通过减少程序持有锁的数量来避免死锁的产生,如果程序必须要持有多个锁,则获取锁的顺序应该谨慎一些。
4.3.2采用超时机制
用户在设计程序时如果准备对资源上锁,可以通过设置超时获取锁的方式来避免死锁的产生,比如调用pthread_mutex_timedlock()、pthread_rwlock_timedwrlock()等相关函数来实现,如果在规定时间没有获取到锁,则放弃获取锁。
4.3.3尽快释放资源
如果进程或者线程已经对资源上锁,应该在使用完资源后及时对资源解锁,这样其他进程或者线程就可以继续获取资源。
当然,如果死锁已经发生,那么用户就需要检查哪个进程出现了死锁,一般Linux系统中会把出现死锁的进程记录在 /proc/locks 文件中,用户可以通过 cat /proc/locks 来查看,可以列出当前系统中所有被卡住的进程和它们所持有的锁。这样用户可以通过 kill -9来强制结束进程。