文章目录
- 前言
- [一、线程分离属性 pthread_attr_t](#一、线程分离属性 pthread_attr_t)
- [1. 核心 API](#1. 核心 API)
- [2. 使用流程](#2. 使用流程)
- [3. 易错点](#3. 易错点)
- [二、多进程 VS 多线程 全方位对比](#二、多进程 VS 多线程 全方位对比)
- 三、线程间通信与临界区概念
- [1. 线程通信方式](#1. 线程通信方式)
- [2. 带来的问题:资源竞争](#2. 带来的问题:资源竞争)
- [3. 核心概念](#3. 核心概念)
- [4. 经典误区:一个加锁一个不加,锁有用吗?](#4. 经典误区:一个加锁一个不加,锁有用吗?)
- [四、互斥锁 pthread_mutex](#四、互斥锁 pthread_mutex)
- [1. 核心 API](#1. 核心 API)
- [2. 标准使用模板](#2. 标准使用模板)
- [3. 补充:非阻塞加锁](#3. 补充:非阻塞加锁)
- 五、死锁
- [1. 什么是死锁](#1. 什么是死锁)
- [2. 死锁产生的四个必要条件](#2. 死锁产生的四个必要条件)
- [3. 死锁规避方案](#3. 死锁规避方案)
- [六、信号量 sem_t](#六、信号量 sem_t)
- [1. 核心 API](#1. 核心 API)
- [2. 信号量的两种经典用法](#2. 信号量的两种经典用法)
- [3. 实战案例:三个线程按序打印 A、B、C](#3. 实战案例:三个线程按序打印 A、B、C)
- 七、实战踩坑合集
- [1. 输入函数坑:scanf ("% s") 缓冲区残留](#1. 输入函数坑:scanf ("% s") 缓冲区残留)
- [2. 信号量初值坑](#2. 信号量初值坑)
- [3. 主线程退出坑](#3. 主线程退出坑)
- [4. 属性销毁坑](#4. 属性销毁坑)
- 学习总结
前言
今天是系统编程学习的第七天,核心围绕多线程同步与通信展开,覆盖线程分离属性、多进程与多线程的优劣对比、互斥锁机制、死锁的产生与规避、信号量的同步 / 互斥用法四大模块。
学习过程中踩了不少经典面试坑:比如信号量初值设置错误导致线程顺序错乱、误以为互斥锁是硬件级变量保护、scanf 输入带空格字符串出现缓冲区残留等。本篇把知识点、API、实战案例、踩坑避坑一次性整理清楚,方便复习。
一、线程分离属性 pthread_attr_t
线程默认是可接合状态(PTHREAD_CREATE_JOINABLE) ,线程退出后系统不会自动回收资源,必须由主线程调用 pthread_join() 阻塞等待并回收;而**分离状态(PTHREAD_CREATE_DETACHED)**的线程,结束后系统自动回收资源,无需也不能调用 join。
1. 核心 API
| 函数 | 功能 |
|---|---|
pthread_attr_init |
初始化线程属性结构体,必须先初始化再使用 |
pthread_attr_setdetachstate |
设置线程分离状态 |
pthread_attr_destroy |
销毁属性对象,释放内部资源 |
函数原型详解
// 初始化属性,设置默认值
int pthread_attr_init(pthread_attr_t *attr);
// 设置分离状态
// detachstate 取值:
// PTHREAD_CREATE_JOINABLE 默认,可接合
// PTHREAD_CREATE_DETACHED 分离态
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
// 销毁属性
int pthread_attr_destroy(pthread_attr_t *attr);
2. 使用流程
pthread_attr_t attr;
pthread_attr_init(&attr); // 1.初始化
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); // 2.设置分离
pthread_create(&tid, &attr, thread_func, NULL); // 3.创建线程,传入属性
pthread_attr_destroy(&attr); // 4.销毁属性(不影响已创建的线程)
3. 易错点
- 属性只是创建模板:线程创建完成后,属性就和线程无关了,此时 destroy 属性不会影响正在运行的线程。
- 分离态线程禁止调用 pthread_join,否则会出现未定义行为。
- 除了创建时设置,也可以在线程函数内部调用
pthread_detach(pthread_self())将自身设为分离态。
二、多进程 VS 多线程 全方位对比
从四个核心维度对比:
| 对比维度 | 多线程 | 多进程 |
|---|---|---|
| 运行效率 | ✅ 更高。同一地址空间,线程切换开销小,调度快 | ❌ 更低。进程切换需要切换页表、刷新缓存,开销大 |
| 安全性 | ❌ 差。一个线程崩溃,整个进程退出,所有线程全部终止 | ✅ 高。进程地址空间独立,单个进程崩溃不影响其他进程 |
| 通信实现 | ✅ 简单。共享全局变量、堆内存,直接读写即可通信 | ❌ 复杂。地址空间隔离,必须通过管道、共享内存、消息队列等 IPC 机制 |
| 编程复杂度 | 中等。共享资源会产生数据竞争,需要加锁保护 | 高。IPC 机制代码量大,调试难度高 |
适用场景总结
- 多线程:计算密集型任务、频繁数据交互、并发量高的场景(如服务器、线程池)
- 多进程:高可靠性要求、多任务隔离、崩溃不能影响整体的场景(如浏览器、守护进程)
三、线程间通信与临界区概念
1. 线程通信方式
同一进程的所有线程共享同一个虚拟地址空间,因此全局变量、静态变量、堆内存都是天然共享的,线程间可以直接通过这些共享变量通信。
2. 带来的问题:资源竞争
多个线程同时读写同一个共享变量时,会发生数据竞争 ,导致结果错乱。比如两个线程同时对全局变量执行 num++,最终结果会小于预期。
3. 核心概念
- 原子操作:不能被 CPU 调度打断的最小执行单元,执行过程不会被切走。
- 临界区(临界代码):被加锁和解锁包裹的代码片段,同一时刻最多只能有一个线程进入执行。
4. 经典误区:一个加锁一个不加,锁有用吗?
完全没用,互斥锁直接失效。 互斥锁本质是软件层面的约定 ,不是硬件级的变量保护。pthread_mutex_lock 只能拦住调用了这个函数的线程;如果有一个线程读写共享变量时不加锁,它会直接无视锁的存在修改内存,数据竞争依然存在。
铁律:所有访问同一共享资源的线程,必须使用同一把锁包裹临界区,只要漏一个,锁机制就作废。
四、互斥锁 pthread_mutex
互斥锁(Mutex)是解决多线程资源竞争最常用的工具,保证临界区代码的互斥执行。
1. 核心 API
| 函数 | 功能 |
|---|---|
pthread_mutex_init |
初始化互斥锁,默认属性传 NULL |
pthread_mutex_destroy |
销毁互斥锁,释放资源 |
pthread_mutex_lock |
加锁,拿不到锁则阻塞休眠 |
pthread_mutex_unlock |
解锁,唤醒等待的线程 |
函数原型
// 初始化锁,attr为NULL使用默认属性
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
// 销毁锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
// 加锁(阻塞)
int pthread_mutex_lock(pthread_mutex_t *mutex);
// 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
2. 标准使用模板
pthread_mutex_t lock;
pthread_mutex_init(&lock, NULL);
// 线程内部
pthread_mutex_lock(&lock);
// ===== 临界区:访问共享资源 =====
num = 100;
printf("%d\n", num);
// =================================
pthread_mutex_unlock(&lock);
3. 补充:非阻塞加锁
pthread_mutex_trylock():尝试加锁,拿不到锁不会阻塞,直接返回错误码,常用于避免死锁。
五、死锁
1. 什么是死锁
多个线程互相持有对方需要的锁,同时又等待对方释放锁,导致所有线程永久阻塞,程序卡死,就是死锁。
2. 死锁产生的四个必要条件
- 互斥条件:资源同一时间只能被一个线程占用
- 不可剥夺条件:已获得的锁不能被其他线程强行抢走
- 请求保持:线程持有已有锁的同时,又去请求新的锁
- 循环等待:线程之间形成环形的锁等待链
四个条件同时满足才会产生死锁,破坏任意一个即可避免死锁。
3. 死锁规避方案
- 所有线程按照相同的顺序加锁(破坏循环等待)
- 使用
pthread_mutex_trylock尝试加锁,拿不到就释放已有锁(破坏请求保持) - 避免锁嵌套,减少锁的持有时间
六、信号量 sem_t
信号量本质是一个内核维护的计数器 + 等待队列,支持两种原子操作:P 操作(申请资源,计数 - 1)、V 操作(释放资源,计数 + 1)。既可以实现互斥,也可以实现线程同步。
1. 核心 API
| 函数 | 功能 |
|---|---|
sem_init |
初始化信号量 |
sem_destroy |
销毁信号量 |
sem_wait |
P 操作:申请资源,计数 - 1,计数为 0 则阻塞 |
sem_post |
V 操作:释放资源,计数 + 1,唤醒等待线程 |
函数原型详解
// 初始化信号量
// pshared: 0 = 本进程内线程间使用;非0 = 多进程间使用(需放在共享内存)
// value: 信号量初始计数值
int sem_init(sem_t *sem, int pshared, unsigned int value);
// 销毁信号量
int sem_destroy(sem_t *sem);
// P操作:申请资源
int sem_wait(sem_t *sem);
// V操作:释放资源
int sem_post(sem_t *sem);
高频考点:
sem_init第二个参数pshared
- 写
0:用于同一进程的多个线程之间同步,信号量定义为全局变量即可- 写非 0:用于多个进程之间同步,信号量必须放在共享内存中
2. 信号量的两种经典用法
(1)二元信号量(初值 = 1):实现互斥
等价于互斥锁,sem_wait 对应加锁,sem_post 对应解锁。
(2)同步信号量(初值 = 0):控制执行顺序
用于保证线程的先后执行顺序,A 线程做完事再让 B 线程执行。
3. 实战案例:三个线程按序打印 A、B、C
需求
三个线程循环打印,严格按照 A → B → C 的顺序输出。
错误写法踩坑
- 坑 1:
sem_init第二个参数错传 1,信号量初始化异常 - 坑 2:信号量初值设置错误,比如第三个信号量初值设为 1,导致 C 线程抢先执行
正确实现
// 信号量初值:sem_w=1, sem_r=0, sem_a=0
sem_init(&sem_w, 0, 1);
sem_init(&sem_r, 0, 0);
sem_init(&sem_a, 0, 0);
sem_w=1:A 线程初始拥有执行许可,最先运行sem_r=0、sem_a=0:B、C 线程初始无许可,阻塞等待前一个线程唤醒
执行时序:
- A 线程:sem_wait (sem_w) → 打印 A → sem_post (sem_r)
- B 线程被唤醒:sem_wait (sem_r) → 打印 B → sem_post (sem_a)
- C 线程被唤醒:sem_wait (sem_a) → 打印 C → sem_post (sem_w)
- 循环往复,严格保持 A→B→C 顺序
口诀:接力顺序打印,第一个线程信号量初值 = 1,后面全部初值 = 0。
七、实战踩坑合集
1. 输入函数坑:scanf ("% s") 缓冲区残留
问题 :密码验证题目中,输入 try again(带空格)时,scanf("%s") 遇到空格截断,只读取 try,剩下的 again 残留在输入缓冲区,程序退出后被 shell 读取,出现乱码输出。
解决 :题目要求按行输入时,使用 fgets() 读取整行,手动去掉换行符:
fgets(str, sizeof(str), stdin);
str[strcspn(str, "\n")] = '\0';
2. 信号量初值坑
同步场景下,后执行的线程对应的信号量初值必须为 0,否则线程会抢先执行,破坏顺序。
3. 主线程退出坑
主线程执行 return 结束进程时,所有子线程会被直接销毁,不管有没有执行完。所以需要用 pthread_join 或者死循环保证主线程不退出。
4. 属性销毁坑
pthread_attr_destroy 只是销毁属性对象,不会杀死已经创建好的线程。
学习总结
今天的核心是多线程同步,重点掌握:
- 线程分离属性的用法和两种状态的区别
- 多进程和多线程的优劣对比(面试必背)
- 互斥锁的作用、临界区概念,以及 "锁是约定不是硬件保护" 的本质
- 死锁的四个必要条件和规避方法
- 信号量的 P/V 操作,以及互斥、同步两种用法,重点掌握顺序打印的初值设置