信号量、环形队列与线程池起步:把临界资源拆成很多份(8-16 ~ 8-20)
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
上一篇的锁把临界资源当"整体"保护------一次只许一个人进。但很多资源天生就该拆开用(环形队列的每一个格子、线程池的每一个任务)。这几天笔记的主线就是:用信号量把"预定"变成计数器,用环形队列让生产和消费真正并行,最后用线程池把这套机制变成基础设施。文末再补两篇小笔记(fork 拷贝页表、动态链接最终章)。
一、POSIX 信号量:一个描述资源数目的计数器(8-19)
1. 本质
信号量本质是一个计数器 ,是对资源的预定机制,描述"临界资源的数目"。
和互斥锁的关系一句话说清:
信号量为 1,就是二元信号量 ------功能上完全等价于互斥锁!
区别在于定位:锁把资源当整体(0 或 1),信号量把资源拆成 N 份(计数器 0~N),允许 N 个执行流同时进入,每个人预定一份。
2. P/V 操作与接口
- P 操作(sem_wait) :申请资源,计数器
--;申请失败会阻塞; - V 操作(sem_post) :归还资源,计数器
++。
c
sem_t s;
sem_init(&s, pshared, value); // pshared: 0=线程间共享, 非0=进程间共享; value=初始值
sem_wait(&s); // P
sem_post(&s); // V
sem_destroy(&s);
底层可靠性:信号量的加减本身靠 lock 前缀指令保护 ------和锁用一条交换指令一个思路。但注意笔记的提醒:信号量不保证多并发的完整正确性 (计数器只管数量,不管数据的读写安全),除非配合"资源被拆分、各份独立"的用法------这正是环形队列登场的理由。
二、环形队列:单生产单消费的天生优雅与多生产多消费的改造(8-20)
1. 为什么环形队列配信号量是绝配
普通阻塞队列:整条队列一把锁,放数据、取数据互斥------生产和消费其实串行访问队列。
环形队列:生产者一个下标、消费者一个下标 ,各自往前走,只要"生产者不套圈消费者、消费者不追上生产者",两边就可以同时操作队列的不同格子。这正是信号量表达的场景:
space_sem:剩余空格子数(生产者的资源);data_sem:已有数据格子数(消费者的资源)。
信号量还保证了不会"超越彼此"------生产者没格子就不能放,消费者没数据就不能取。
2. 单生产单消费 → 多生产多消费
单生产单消费下这套逻辑天衣无缝,但有缺陷:现实里生产者消费者都是一群。改造时笔记回答了两个好问题:
为什么是两把锁? ------因为上一个队列模型是"以栈顶(队列口)作为资源",天然互斥;而环形队列以生产者下标和消费者下标为依靠:生产者之间竞争生产者下标(一把锁),消费者之间竞争消费者下标(另一把锁)------两把锁保护两个下标,生产和消费之间靠信号量协调,互不干扰。
先加锁还是先预定资源?
先预定资源(P 操作),再竞争锁!
理由:先 P 一次信号量,就把"进不进得来"的问题在锁外解决了,锁的竞争范围缩小到"已经预定位子的人"------只有最小范围的资源才使用锁。反过来先加锁再 P,所有线程挤在一个锁上,信号量就被架空了。
笔记最后总结的适用边界:当资源不被整体使用时,就上信号量;每个最小单元才用锁。
三、线程池起步:预先创建,空间换时间(8-20)
1. 动机
预先创建一批线程,任务来了直接指派------用预先的时间和空间,换未来的响应时间(空间换时间)。
线程的创建销毁虽然比进程便宜,但高频创建依然是开销;线程池把"创建"和"使用"解耦,开机即有一批待命线程。
2. 顺带的两课
- 设计模式:就是"代码设计的套路"------解耦、效率更好。线程池本身就是一种设计模式的落地(池化);
- 日志 :线程池的运行必须配日志,日志字段是必须齐全的(时间、级别、内容......),否则多线程出了问题根本无从追查。
(笔记里还留了作业:要写好线程池的回调注册,得回去补 C++ 的 lambda、虚函数、右值绑定------这是下一篇的事了。)
四、补遗一:fork 拷贝页表的本质(8-16)
四行笔记,一句话收掉旧悬念:
fork 之后,一级页表是新的,但内容和父进程的一级页表一样,其余(物理页映射)也一样------直到写时拷贝才真正分家。
也就是说:fork 复制的是页表这套"地图" ,不是物理内存本身。地图相同 → 父子看同一批物理页;写时拷贝只改发生写入的那一条页表项。和之前"PCB 以父为模板"完全同构------进程的复制,从头到尾都是结构体和表的复制。
五、补遗二:动态链接与加载的最终章(8-16)
这篇《线程 ELF .o mmap》信息量最大,把整个链接加载的知识做了一次总收束。
1. 链接期:桩、解析函数、.got 的诞生顺序
静态链接器拿到 .o 和动态库后(为什么要看一眼动态库符号表?------因为要拿到函数名对应的偏移),按顺序产出:
- 代码桩 (PLT):依据动态符号表生成,里面是一条
jmp占位; - 解析函数 的生成:它运行期需要两样东西------动态库基地址 + 动态表(相对偏移);
- .got 表的大小确定与占位。
2. 运行期:第一次调用与后续调用的分界
- 第一次调用:走桩 → 进解析函数 → 用动态库虚拟基地址 + 动态表算出真实函数地址 → 写入 .got → call 过去;
- 第二次起:桩里第一段 jmp 直接跳到 .got 里存好的地址,解析只发生一次------这就是之前 PLT/GOT 那篇"按需重定位"的底层细节补全。
3. 两条规则:生成靠规则 1,加载靠规则 1+2
- 规则 1(ELF ↔ 磁盘) :exe/.so 都是 ELF 架构,看 PHT (.o 才看 section 表);数据和代码依据虚拟地址偏移存入块区,块号 = 偏移 / 4096(头指针由链接器决定,内核记住);.so 有自己的 inode,懒加载时按 inode 去磁盘对应位置读;
- 规则 2(磁盘 ↔ 内存):读到的数据按 4KB 存入页帧------用位图找空闲页帧,虚拟地址 32 位按 10/10/12 拆分偏移做映射(和页表那篇完全对上);
- 关键结论:生成需要规则 1,加载需要规则 1+2,且两条规则是解耦的。MMU 实例化的就是"怎么放的,就怎么拿"。
动态库代码并没有在链接期加载进来 ,而是运行期按需(缺页)加载------动态库加载行为结束,才代表该进程的虚拟地址空间真正全部生成完毕。
4. 回到线程:pthread 为什么看中 mmap
线程使用 mmap,就是看中它的两个性质:每一次 mmap 生成一个段;map_private 的空间一个进程独有,但线程之间相互可见(同地址空间)。
线程需要自己的用户栈、管理信息(TCB)、线程局部存储------本来"线程用户栈 + clone 出的 PCB"就够了,再加上管理信息和 TLS,管理更灵活。线程库的全部家当,就是几块 mmap 出来的共享区映射------把前面页表、动态库、线程三条线的知识在 mmap 上拧成一股。
六、小结
| 主题 | 一句话 |
|---|---|
| 信号量 | 描述临界资源数目的计数器;为 1 即互斥锁 |
| P/V | sem_wait 申请(可阻塞)/ sem_post 归还,靠 lock 指令保原子 |
| 环形队列 | 生产者下标、消费者下标各自前进,两个信号量防套圈 |
| 多生产多消费 | 两把锁管两个下标;先 P 再加锁 |
| 线程池 | 预创建 + 任务队列,空间换时间,配日志 |
| fork 页表 | 复制的是地图(页表),不是地(物理页) |
| 动态链接终章 | 桩 → 解析函数 → .got 只解析一次;生成规则 1、加载规则 1+2 |
整个线程章到这里的逻辑链:共享地址空间(红利)→ 数据不一致(事故)→ 锁(安全)→ 忙轮询(浪费)→ 条件变量(有序)→ 信号量(拆分资源)→ 环形队列(真并行)→ 线程池(基础设施)。下一步就是线程池的 C++ 实战了。