操作系统 / 系统编程复习目录
一、进程概念
1. 冯诺依曼
我们常见的计算机、笔记本、服务器,大部分都遵守冯诺依曼体系。
1.1 外设
- 输入单元:话筒,摄像头,键盘,鼠标,磁盘,网卡,…
- 中央处理器(CPU):运算器 && 控制器;
- 输出单元:声卡,显卡,网卡,磁盘,显示器,打印机,…
1.2 IO
程序在运行的时候,必须把程序先加载到内存,为什么?
- 冯诺依曼体系结构是这么规定的!
- 程序 -> 文件 -> 磁盘 -> 外设 -> 内存 -> CPU;
- 程序 -> 指令和数据 -> CPU;
- 在数据层面,CPU 只和 内存 打交道,外设 只和 内存 打交道。
1.3 数据流
在冯诺依曼体系中,数据流是指数据在计算机系统中的流动过程。以用户通过键盘输入数据并显示在显示器上为例,数据流的过程大致如下:
- 用户通过键盘输入数据,键盘作为输入设备将数据捕获并转换为电信号。
- 电信号通过接口电路传输到计算机内部,并被存储在内存中。
- CPU从内存中读取数据,并进行处理(如加密、编码等)。
- 处理后的数据再次被写入内存,并准备输出。
- 显示器作为输出设备从内存中读取数据,并将其转换为可视的图像显示在屏幕上。
1.4 存储分级 && IO效率
冯诺依曼体系中的存储系统通常被划分为多个层次,以满足不同速度和容量的需求:
- 距离 CPU 越近的存储单元,效率越高,造价贵,单体容量越小;
- 距离 CPU 越远的存储单元,效率越低,造价便宜,单体容量大;
- 内存看作一个非常大的缓存,介于 设备 和 CPU 之间,利用内存,把效率问题,转化成为了软件问题!
- 计算机的效率最终就变成了以内存效率为主;
- 利用高速缓存来减少对主存的访问次数,提高数据访问速度。
2. OS
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。操作系统包括:
- 内核(内存管理、进程管理、文件系统、驱动管理);
- 其他程序(函数库、shell 程序、…)。
2.1 作用:管理
- 与硬件交互,管理所有的软硬件资源;
- 为用户程序(应用程序)提供一个良好(稳定、高效、安全)的执行环境。
2.2 管理:先描述,再组织
- 描述起来,用 struct 结构体;
- 组织起来,用链表或其他高效的数据结构。
- 把你对数据的管理场景转化成为:对特定数据结构的增删查改!
2.3 内存管理、进程管理、文件系统、驱动管理
内存管理:
- 内存分配:为新进程或进程中的新数据块分配内存空间。
- 内存回收:当进程结束或数据不再需要时,回收其占用的内存空间。
- 内存保护:确保每个进程只能访问自己被分配的内存区域,防止内存越界和非法访问。
- 内存映射:将磁盘上的文件或数据块映射到进程的地址空间中,实现快速访问。
- 内存交换(Swapping):当物理内存不足时,将部分不常用的内存数据交换到磁盘上,以释放内存空间。
进程管理:
- 进程创建:根据系统调用或程序启动请求创建新进程。
- 进程调度:按照一定的调度算法(如时间片轮转、优先级调度等)为进程分配CPU资源。
- 进程同步与通信:确保多个进程在并发执行时能够正确、有序地共享数据和资源。
- 进程终止:正常或异常地结束进程的执行,回收其占用的资源。
文件系统:
- 文件存储:将用户数据以文件的形式存储在磁盘上。
- 文件检索:根据文件名、路径等信息快速找到并访问文件。
- 文件保护:通过权限控制、加密等手段保护文件数据的安全性和完整性。
- 文件共享:允许多个用户或进程同时访问同一个文件。
- 文件系统的恢复与备份:在发生数据丢失或损坏时,能够恢复或备份文件系统中的数据。
驱动管理:
- 驱动加载与卸载:在系统启动时加载必要的驱动程序,并在不需要时卸载它们。
- 设备识别与配置:识别连接到系统的硬件设备,并根据配置信息设置其工作参数。
- 设备通信:通过驱动程序与硬件设备进行通信,实现数据的读写和控制。
- 错误处理:当硬件设备发生错误时,通过驱动程序向操作系统报告错误信息,并协助进行错误恢复。
3. 进程
3.1 什么是进程
- 课本概念:程序的一个执行实例,正在执行的程序…
- 内核观点:担当分配系统资源(CPU 时间,内存)的实体;
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合;
- 课本上称之为 PCB(process control block),Linux 操作系统下的 PCB 是
task_struct
;struct PCB { // 状态 // 优先级 // 内存指针字段 // 标识符 // ...包含进程几乎所有的属性字段 struct PCB* next; }
3.2 为什么要有 PCB(task_struct)
Linux 中的 PCB(task_struct)
- 在 Linux 中描述进程的结构体叫做
task_struct
; - task_struct 是 Linux 内核的一种数据结构,它会被装载到 RAM(内存)里,并包含进程的信息;
PCB 有什么用?
- 操作系统需要管理加载到内存的程序(进程),怎么管理?先描述,再组织!
- 进程 = 内核 PCB 对象 + 可执行程序!
- 未来,所有对进程的控制和操作,都只和进程的 PCB 有关,和进程的可执行程序没有关系!!
- 对进程的管理,转化为对 PCB 对象的管理,也就是对链表的增删查改!
3.3 task_struct 内容
- 标识符:PID(进程标识符)、TGID(线程组标识符)、UID/GID(用户/组标识符);
- 状态信息:状态标志、退出代码和信号;
- 优先级和调度信息:优先级、调度策略、调度实体;
- 链接信息:进程亲缘关系、双向循环链表;
- 内存和地址空间信息:内存管理信息、程序代码和数据指针;
- 上下文数据:寄存器状态、堆栈信息;
- I/O 状态信息:I/O 请求,包括分配给进程的 I/O 设备和正在被进程使用的文件列表;
- 文件和文件系统信息:文件描述符、文件系统状态;
- 其他信息…
3.4 PID / PPID,getpid() / getppid()
- PID:进程 ID;
- PPID:父进程 ID;
getpid()
/getppid()
:系统调用,获取进程标识符;fork()
:系统调用,创建子进程。
3.5 状态:R / S / Z / D / X / T
- R (Running):进程正在运行或者准备运行(即处于就绪队列中等待CPU);
- S (Sleeping):进程正在睡眠中,等待某个事件发生;
- Z (Zombie):僵尸进程。这是一个已经结束(terminated)的进程,但是其父进程还没有通过
wait()
或waitpid()
系统调用来读取它的结束状态。僵尸进程仍然保留在进程表中,但已经不再占用系统资源(除了进程表中的一个条目); - D (Disk Sleep):不可中断睡眠状态。这通常表示进程正在等待 I/O 操作,而且这个等待不能被信号中断。这种状态通常用于等待磁盘 I/O 操作的进程;
- X (Dead):死亡状态。这个状态只是一个返回状态,你不会在任务列表里看到这个状态;
- T (Stopped):进程被停止或追踪。一般通过发送 SIGSTOP 信号来停止进程,并且可以发送 SIGCONT 信号让进程继续运行。
3.6 优先级
ps -l # 查看进程信息
F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
0 S 1000 1670858 1670857 0 80 0 - 2069 do_wai pts/0 00:00:00 bash
0 R 1000 1670910 1670858 0 80 0 - 2202 - pts/0 00:00:00 ps
- UID:代表执行者的身份;
- PID:这个进程的代号;
- PPID:这个进程的父进程代号;
- PRI:优先级,值越小越早被执行;
- NI:进程的 nice 值;
PRI(Priority)and NI(Nice value)
- PRI 代表进程的优先级(Priority),是 Linux 内核用于决定先处理哪些进程的一个指标;
- 一般来说,PRI 的值越小,表示进程的优先级越高,越有可能被优先执行。
- NI 代表进程的 nice 值,是一个用于动态调整进程优先级的数值;
- nice 值的取值范围是 -20 到 19。其中,-20 表示最高优先级(即最低 nice 值),而 19 表示最低优先级(即最高 nice 值);
- 作用:nice 值通过影响 PRI 值来间接影响进程的优先级。具体来说,PRI 值等于某个基准值(如 120)加上 nice 值;
- 所以,在 Linux 下调整进程优先级,就是调整进程 nice 值!
nice # 用于在启动新进程时设置其nice值
renice # 用于更改已运行进程的nice值
3.7 进程地址空间
mm_struct
- mm_struct 是 Linux 内核中用于管理进程内存空间的一个关键数据结构,也被称为内存描述符(memory descriptor);
- 用于描述一个进程的虚拟地址空间,包括进程的内存映射情况、内存区域的属性、内存使用情况、页表等信息;
- 进程间共享内存空间,实际就是在共享 mm_struct!结构内部会使用引用计数,来记录当前有几个进程共享此空间,引用计数为 0 则销毁该结构。
3.7.1 是什么?
- 核心:以软件方式模拟内存!
- 进程地址空间是进程在运行时所拥有的一个独立的虚拟内存区域,它包含了进程运行所需的代码、数据、堆栈等;
3.7.2 为什么?
- 内存独占:每个进程都有自己独立的地址空间,这保证了进程间的内存隔离,防止了一个进程对另一个进程内存的非法访问;
- 保护内存:Linux为 每个内存区域(如代码段、数据段、栈等)设置了访问权限,进程只能按照设定的权限访问这些内存区域;而虚拟内存的设计防止了进程直接访问物理内存,增加了系统的安全性和稳定性;
- 统一布局:Linux 进程地址空间具有统一的结构和布局(代码段、数据段、堆、栈、…),这种设计简化了内存管理,提高了系统的运行效率。
3.7.3 怎么办?
- 虚拟地址:是程序运行时所使用的地址,对程序来说是透明的,每个进程都认为自己拥有完整的地址空间。虚拟地址不是直接映射到物理内存上的,而是通过一系列的机制(如页表)来间接访问物理内存;
- 物理地址:是真实存在的内存地址,用于 CPU 直接访问物理内存。物理地址由内存管理单元(MMU)将虚拟地址转换而来;
- 页表:是虚拟地址到物理地址的映射表,存储了虚拟页号(VPN)到物理页号(PPN)的映射关系。每个进程都有自己独立的页表,确保了进程的独立性和隔离性;
- 页表项(PTE):页表中的每一个条目称为页表项,包含了有效位、物理页号等信息。有效位用于标识该虚拟页是否已在物理内存中,物理页号则指向实际的物理内存地址;
- r(Read):读属性。在页表中,每个页表项(PTE)可以包含读权限标志位。如果设置了读权限(r=1),则允许对该页进行读取操作;如果未设置(r=0),则尝试读取该页将引发异常;
- w(Write):写属性。与读属性类似;
- readonly:只读属性。这不是页表项中的一个直接属性,但可以通过设置页表项的读权限(r=1)和清除写权限(w=0)来实现;
- 脏页:当页的内容在内存中被修改后,该页被视为“脏”的,直到其内容被写回磁盘;
- 多级页表:当虚拟地址空间非常大时,使用单级页表会导致页表过大,难以管理。因此,引入了多级页表结构。多级页表将虚拟地址空间划分为多个层次,每一级页表都指向下一级页表的地址或物理页的地址;
- 页帧/页框(4KB):在物理内存中,页帧(或页框)是指用于存储页内容的连续内存块。页表项中存储的是页帧的物理地址,用于将虚拟地址映射到物理地址。
- MMU(内存管理单元):是负责虚拟地址到物理地址转换的硬件单元。当 CPU 执行指令时,会产生一个虚拟地址,这个地址被传递给 MMU。MMU 通过查询页表,找到对应的物理地址,并将其返回给 CPU 进行内存访问。
- 正常转化:CPU 生成虚拟地址 -> MMU 查询页表 -> 构建物理地址 -> CPU 访问物理内存;
- 错误转化:缺页异常,权限检查;
- 映射机制:通过页表和 MMU 的协作,实现了虚拟地址到物理地址的映射。这种映射机制确保了进程的独立性和隔离性,同时也提高了内存的使用效率和安全性;
- 动态映射:在程序运行过程中,系统可以根据需要动态地加载和卸载内存页,实现了对内存资源的有效管理。这种动态映射机制也支持了虚拟内存的实现,使得程序可以访问比物理内存更大的内存空间。
虚拟地址空间布局问题
- 虚拟地址空间通常被划分为用户空间(User Space)和内核空间(Kernel Space)两部分;
- 用户空间通常包含:代码段、数据段、BSS段、堆、栈、件映射和匿名映射段;
- 采用多级页表结构来减少内存的使用并提高页表查找的效率;
- 页表项中存储的是页帧的物理地址,用于将虚拟地址映射到物理地址。
二、进程控制
1. 进程创建
1.1 fork
#include <unistd.h>
pid_t fork(void);
fork()
是 Linux 中很重要的一个函数,它可以从已存在的进程中创建一个新进程,新进程为子进程,而原进程为父进程;
进程调用 fork(),当控制权转移到内核中的 fork 代码后,内核会:
- 分配新的内存块和内核数据结构给子进程;
- 将父进程部分数据结构内容拷贝给子进程;
- 添加子进程到系统进程列表当中;
- fork 返回,开始调度器调度…
1.2 返回值
- 子进程中返回 0;
- 父进程中返回 子进程的 pid;
- 出错返回 -1。
1.3 写时拷贝
- 通常,父子代码是共享的;
- 父子在不写入时,数据就是共享的,当任意一方试图写入,就会以写时拷贝的方式,各自持有一份副本;
1.4 fork 目的
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如:父进程等待客户端请求,生成子进程来处理请求;
- 一个进程要执行一个不同的程序。例如:子进程从 fork 返回后,调用 exec(进程替换)函数;
- 守护进程也会用到 fork。
1.5 系统产生新进程的方式
- 操作系统直接创建,如:系统进程;
- 由父进程创建:
fork()
:通过复制当前进程(父进程)来创建一个新的子进程。子进程会继承父进程的大部分属性和数据,但拥有独立的进程ID(PID)和内存空间;vfork()
:与 fork() 类似,但子进程会共享父进程的内存空间,并且父进程会被阻塞,直到子进程调用 exec() 或 exit();clone()
:这是一个更灵活的系统调用,允许在创建新进程时选择性地共享父进程的资源;exec()
系列函数:虽然 exec() 本身不直接创建新进程,但它可以将当前进程替换为新的可执行文件,从而间接地创建了一个新的执行环境;- …
1.6 创建进程的过程
- 系统调用;
- 分配进程标识符(PID);
- 创建进程控制块(PCB);
- 复制或共享资源;
- 初始化新进程;
- 调度新进程;
- 执行新程序(可选);
- 父进程和子进程的交互。
2. 进程终止
2.1 进程退出场景
- 正常:程序执行完毕,或遇到特定的退出指令(return),进程会正常结束;
- 代码结束结果不对;
- 异常:程序执行过程中遇到无法处理的错误或异常,导致进程非正常终止。例如,访问非法内存地址、除零错误等;
- exit code(退出码):是进程终止时返回给操作系统的一个整数值,用于表示进程的执行结果或状态。退出码为
0
通常表示成功或正常退出,而非零值则表示某种形式的错误或异常情况。
2.2 操作:_exit / exit / main return
_exit
#include <unistd.h>
void _exit(int status);
参数: status定义了进程的终止状态, 父进程通过wait来获取该值
- _exit 直接通过系统调用进入内核,终止进程;
- 立即终止进程,不执行任何清理操作;
- 调用 _exit 后,进程占用的资源将被操作系统回收。
exit
#include <unistd.h>
void exit(int status);
- exit 是一个 C 库函数,用于终止当前进程;
- 执行清理操作,包括调用退出处理程序和刷新 I/O 缓冲区;
- 最后调用 _exit 终止进程。
return
- return 是一种更常见的退出进程方法;
- 执行
return 0
等价于exit(0)
,因为调用 main 的运行时函数会将 main 的返回值当作 exit 的参数。
2.3 进程终止系统做了什么
- 终止遗留线程,如“孤儿”线程;
- 释放资源:系统会释放进程所分配的所有资源,包括内存、文件描述符、内核对象等;
- 执行清理操作:例如,刷新标准 I/O 流的缓冲区,确保所有待输出的数据都被写出;
- 设置进程状态:系统会将终止的进程设置为僵死状态,直到其父进程通过某种方式(wait、waitpid)回收其资源并获取其退出状态;
- 进行 CPU 再分配:将 CPU 分配给其他等待运行的进程。
3. 进程等待
3.1 为什么要等待:内存泄漏
- 子进程退出,父进程如果不管不顾,就可能造成僵尸进程的问题,进而造成内存泄漏;
- 并且我们无法再去杀死一个僵尸进程;
- 父进程也需要通过子进程的退出码,了解子进程的执行情况;
- 父进程就通过进程等待的方式,回收子进程资源,获取子进程退出信息。
3.2 如何等待?- wait / waitpid
wait
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);
返回值:
成功返回被等待进程pid, 失败返回-1
参数:
输出型参数, 获取子进程退出状态, 不关心则可以设置为NULL
wait pid
pid_t waitpid(pid_t pid, int* status, int options);
返回值:
正常返回时, waitpid返回收集到的子进程的进程ID
如果设置了选项WNOHANG, 而调用中waitpid发现没有已退出的子进程可收集, 返回0
如果调用中出错, 则返回-1, 这时errno会被设置为对应的错误值
参数:
pid:
pid = -1, 等待任意一个子进程, 类似wait
pid > 0, 等待进程ID与pid相等的子进程
status:
WIFEXITED(status): 若子进程为正常终止, 则为真。(查看进程是否正常退出)
WEXITSTATUS(status): 若WIFEXITED为非零, 提取子进程退出码。(查看进程的退出码)
options:
WNOHANG: 若pid指定的子进程没有结束, 则waitpid()函数返回0, 不予以等待。 若正常结束, 则返回该子进程的ID
注意事项:
- 如果子进程已经退出,调用 wait / waitpid 时,会立即返回并释放资源,获得子进程的退出信息;
- 如果调用 wait / waitpid 时,子进程存在且正常运行,则进程可能阻塞;
- 如果不存在该子进程,则立刻出错返回。
3.3 signal / exit code
- Signal 是一种进程间通信机制,用于通知进程发生了某个事件;
- Exit Code 是进程结束执行时返回给操作系统的一个整数值,用于表示进程的退出状态;
- Signal 用于进程间的异步通信,通知进程发生了某个事件;而 Exit Code 用于表示进程的退出状态,供父进程判断子进程的执行结果;
- Signal 可以在进程执行的任何时刻由系统或其他进程产生;而 Exit Code 只在进程结束执行时产生;
- …
3.4 阻塞等待 vs 非阻塞等待
阻塞等待
- 挂起线程:在等待期间,当前线程无法执行其他操作,必须等待条件满足;
- 资源占用:阻塞的线程会占用系统资源,包括 CPU 调度时间片等,直到它被唤醒;
- 性能影响:在高并发场景下,大量的阻塞等待可能导致系统资源耗尽,影响程序的性能和响应速度。
非阻塞等待
- 线程继续执行:在等待期间,当前线程可以执行其他操作,提高了程序的并发性和响应速度;
- 轮询机制:非阻塞模式通常需要程序自己实现轮询机制来检查条件是否满足;
- 复杂度高:与阻塞模式相比,非阻塞模式的编程复杂度更高,需要处理更多的逻辑和状态管理。
4. 进程替换
4.1 替换原理
- 父进程用
fork()
创建子进程后,子进程可以通过调用exec()
系列函数以执行另外一个程序; - 当子进程调用 exec() 函数时,该进程的代码和数据完全被新程序替换,替换完成后,新程序将在当前进程的上下文中开始执行;
- 调用 exec() 并不创建新进程,所以调用 exec() 前后该进程的 id 并未改变。
会不会创建新进程
- 不会!
- 创建一个进程,是先创建PCB、地址空间、页表等,再把程序加载到内存;
- 而程序替换所做的本质工作,就是加载!
后续代码如何处理
- 程序替换一旦成功,原进程的后续代码将不再执行,代码和数据都会被丢弃!
- 如果 exec 调用失败(即返回 -1),那么原程序将继续执行 exec 之后的代码。
4.2 execl、execle、execlp、execv、execve、execvp
#include <unistd.h>
int execl(const char* path, const char* arg, ...);
int execlp(const char* file, const char* arg, ...);
int execle(const char* path, const char* arg, ..., char* const envp[]);
int execv(const char* path, char* const argv[]);
int execvp(const char* file, char* const argv[]);
int execve(const char* path, char* const argv[], char* const envp[]);
函数解释
- 这些函数如果调用成功,则执行新的程序,不再返回;
- 如果调用出错,返回 -1,继续执行原程序;
- exec() 只有出错的返回值,没有成功的返回值。
命名理解
- l(list):表示参数采用列表;
- v(vector):参数采用数组;
- p(path):自动搜索环境变量PATH;
- e(env):表示自己维护环境变量。
4.3 my shell
实现一个简易 shell …
三、进程通信
1. IPC
进程间通信(IPC,Inter-Process Communication)是计算机科学中的一个重要概念,它描述了不同进程之间进行数据交换和通信的机制。
IPC 的发展
- 管道
- 匿名管道
- 命名管道
- System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
- POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
2. System V IPC
System V IPC(Inter-Process Communication,进程间通信)是在Unix操作系统上实现进程间通信的一种机制;
System V IPC提供了三种主要的进程间通信工具:信号量(Semaphores)、共享内存(Shared Memory)和消息队列(Message Queues)。
3. 管道
- 管道是 Unix 中最古老的进程间通信的形式;
- 从一个进程连接到另一个进程的数据流称为“管道”;
3.1 匿名管道
#include <unistd.h>
int pipe(int fd[2]); // 创建匿名管道
参数
fd: 文件描述符数组, fd[0]表示读端, fd[1]表示写端
返回值
成功返回0, 失败返回错误代码
-
血缘关系:匿名管道只能对有血缘关系的进程 进行进程间通信,常用于父子(fork);
-
单向通信:管道只能被设计为单向通信(半双工);
-
面向字节流:管道提供流式传输服务;
-
生命周期随进程:一旦所有使用它的进程都终止了,管道也会被自动销毁;
-
自带同步互斥:内核会对管道操作进行同步与互斥;
3.2 命名管道
与匿名管道最大的区别是:命名管道是一种特殊的文件类型,因此允许不相关的进程之间进行通信;
# 使用命令创建命名管道
$ mkfifo filename
// 使用C函数创建命名管道
int mkfifo(const char* filename, mode_t mode);
由于命名管道是一个文件,只要不删除这个文件,命名管道就会一直存在;在程序中对命名管道的访问,就跟普通文件一样(read()
、write()
、…);
- 非血缘关系;
- 单向通信;
- 面向字节流;
- 生命周期随进程;
- 自带同步互斥;
3.3 四种情况
- 管道内没有数据 && 子进程不关闭自己的写端文件 fd,读端(父)就要阻塞等待,直到 pipe 有数据;
- 管道内部被写满 && 父进程(读端)不关闭自己的 fd,写端(子)写满之后,就要阻塞等待;
- 对于写端而言:不写了 && 关闭了 pipe,读端会将 pipe 中的数据读完,最后就会读到返回值为 0,表示读结束;
- 读端不读 && 关闭,写端再写,OS 会直接终止写入的进程(子进程),通过信号 13)SIGPIPE 杀掉进程;
3.4 如何理解进程阻塞
进程阻塞(Process Blocking)是操作系统中进程管理的一个重要概念,它指的是进程在执行过程中由于某种原因暂时停止执行,进入等待状态,直到某种外部事件(如 I/O操作完成、接收到信号、资源可用等)发生后,进程才会被重新唤醒并继续执行。
阻塞可能的原因:
- I/O 阻塞;
- 同步阻塞;
- 信号量阻塞;
- 等待事件;
3.5 pipe VS mkfifo
pipe | mkfifo | |
---|---|---|
使用范围和限制 | 亲缘关系(父子/兄弟) | 任意进程 |
存储位置 | 在内存中,不占用磁盘空间 | 是在文件系统中的一个特殊文件 |
阻塞行为 | 默认阻塞 | 默认阻塞 |
使用方式 | 通常与 fork() 结合使用 | 创建后使用 open() 打开管道文件 |
匿名管道与命名管道唯一的区别在于创建和打开的方式不同,一旦管道被创建出来了,它们具有相同的语义。
4. 共享内存
共享内存区是最快的 IPC 形式。一旦这样的内存映射到共享它的进程地址空间,这些进程间数据传递将不再涉及到内核。
4.1 原理
- 内存映射
- 通过改变内存映射关系,使不同进程的虚拟内存(mm_struct 中的共享区)映射(页表+MMU)到同一块物理内存,从而实现内存共享;
- 在 Linux 中,通常先使用
shmget
创建一个共享内存区,并为其分配物理内存,再使用shmat
将这块物理内存映射到进程地址空间中;
- 数据共享
- 如果共享内存区被映射到多个进程地址空间中,这些进程就可以直接读写这块内存区域(就像访问本地内存一样),因此它的效率很高;
- 同步与互斥
- 多个进程同时访问一块区域,容易出现冲突,必须采用同步和互斥机制来避免冲突(如:信号量、互斥锁);
4.2 key VS shmid
- 共享内存在内核中一定会存在多个,如何让两个或多个进程看到同一个共享内存?给共享内存提供一个唯一标识符
key
! - 而
shmid
类似于文件描述符fd
,在用户层面,想对共享内存进行操作,就得先拿着 shmid 找到这块共享内存!
key | shmid | |
---|---|---|
定义 | 用于标识共享内存区域的整数值 | 创建共享内存后由操作系统返回的唯一标识符 |
生成方式 | 由操作系统分配或开发人员指定,需保证唯一性 | 由 shmget 系统调用成功创建共享内存后自动生成 |
用途 | 标识和访问共享内存区域,用于进程间通信 | 标识特定的共享内存区域,用于后续的共享内存操作 |
作用范围 | 内核层面,作为共享内存的内核标识符 | 用户层面,作为执行共享内存操作的依据 |
- key:在内核角度,区分 shm 的唯一性!
- shmid:不管是指令级,还是代码级,最后对共享内存进行控制,用的都是 shmid;
4.3 shmget、shmat、shmdt、shmctl
shmget
功能:创建一个共享内存区
原型
int shmget(key_t key, size_t size, int shmflg);
参数
key:共享内存的唯一标识符
size:共享内存大小
shmflg:由九个权限标志构成,用法类似于创建文件时使用的mode
返回值:成功返回一个非负整数(shmid);失败返回-1
shmat
功能:将共享内存区映射到进程地址空间
原型
void* shmat(int shmid, const void* shmaddr, int shmflg);
参数
shmid:共享内存标识
shmaddr:指定映射的地址
shmflg:SHM_RND或SHM_RDONLY
返回值:成功返回一个指向共享内存的指针;失败返回-1
- shmaddr 为 NULL,核心自动选择一个地址;
- shmaddr 不为 NULL 且 shmflg 无 SHM_RND 标记,以 shmaddr 为映射地址;
- shmaddr 不为 NULL 且 shmflg 有 SHM_RND 标记,则映射的地址会自动向下调整为 SHMLBA 的整数倍;
- SHM_RDONLY 表示映射后只读共享内存。
shmdt
功能:取消当前进程与共享内存区的映射
原型
int shmdt(const void* shmaddr);
参数
shmaddr:由shmat返回的指针
返回值:成功返回0;失败返回-1
注意:shmdt并不会删除共享内存区
shmctl
功能:控制共享内存
原型
int shmctl(int shmid, int cmd, struct shmid_ds* buf);
参数
shmid:由shmget返回的共享内存标识
cmd:将要采取的动作(有三个可取值)
buf:指向一个保存了共享内存的模式状态和访问权限的数据结构
返回值:成功返回0;失败返回-1
- IPC_STAT:把 shmid_ds 结构中的数据设置为共享内存的当前关联值;
- IPC_SET:在进程有足够权限的前提下,把共享内存的当前关联值设置为 shmid_ds 数据结构中给出的值;
- IPC_RMID:删除共享内存区。
4.4 特征
- 效率高
- 减少数据复制;
- 高容量:共享内存可以承载大量数据;
- 并发性:多个进程可以同时对共享内存读写;
- 为什么
- 共享内存减少了数据在用户空间和内核空间之间的拷贝次数!
- 允许进程直接访问同一块物理内存;
- 生命周期
- 共享内存生命周期随内核,除非显式删除(shmctl - IPC_RMID)或系统重启!
- 如果共享内存正在被进程所映射,那么它不会被立刻删除,而是在所有进程都解除映射后才被真正删除。
4.5 ipcs -m、-s、-q
# 功能:显示系统中所有的共享内存段的信息
$ ipcs -m
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00000000 0 root 644 80 2
0x00000000 1 root 644 16384 2
0x00000000 2 root 644 280 2
# 功能:显示系统中所有的信号量集的信息
$ ipcs -s
------ Semaphore Arrays --------
key semid owner perms nsems
0x000000a7 0 root 600 1
# 功能:显示系统中所有的消息队列的信息
$ ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
4.6 ipcrm -m shmid
- 在 Linux 系统中删除一个共享内存段;
- 仅对 System V IPC 有效;
# 可以先使用 ipcs -m 命令查看共享内存信息
$ ipcs -m
# 再用 ipcrm -m shmid 删除指定共享内存
$ ipcrm -m 12345
5. 消息队列
消息队列是一种用于在不同组件或系统之间异步传递消息的中间件技术。它将消息存储在队列中,由生产者(发送者)将消息发送到队列,消费者(接收者)从队列中拉取或订阅消息进行处理。这种方式实现了消息的异步传递和系统的解耦。
消息队列特点
- 异步性;
- 解耦性;
- 可靠性;
- 缓冲能力;
- 顺序性;
- 扩展性;
6. 信号量
6.1 本质是什么
- 信号量本质是一个计数器,是描述临界资源数量的计数器(电影院有几张票);
- 所有的进程,想访问临界资源,都必须先申请信号量(先买票)!
6.2 临界资源
临界资源指一次只能被一个进程(或线程)所占用的资源,通常需要采用同步机制确保任一时刻只有一个进程能够访问临界资源;
6.3 临界区
临界区指访问临界资源的代码段。在临界区内,进程(或线程)对临界资源进行操作,必须确保操作的原子性和互斥性;
6.4 同步
同步指在多个进程/线程的执行过程中,需要按照某种特定的顺序或时序关系来访问共享资源或执行相关操作。
6.5 互斥
互斥指多个进程/线程在访问临界资源时,必须保证任一时刻只有一个进程/线程能够访问该资源。
6.6 原子性
原子性指一个/一系列操作在执行过程中,要么全部完成,要么全部不执行,不会被其他操作或进程/线程所打断。比如信号量的 P 操作和 V 操作就是原子性的。
6.7 操作:PV
- P(Wait)操作
- 当进程/线程需要访问临界资源时,会执行 P 操作;
- 如果信号量的值大于 0 ,表示有可用资源,进程/线程可以进入临界区,并将信号量的值 -1 ;
- 如果信号量的值为 0 ,表示没有可用资源,进程/线程将被阻塞,直到其他进程/线程释放资源并增加信号量的值;
- V (Signal)操作:
- 当进程/线程完成临界区的操作后,会执行 V 操作;
- V 操作将信号量的值 +1 ,表示释放了一个资源;
- 如果有其他进程/线程因为信号量的值为 0 而被阻塞,V 操作将唤醒这些进程/线程中的一个,使其能够进入临界区。
四、基础 IO
1. FILE*
- FILE* 是 C 语言中用于文件操作的一个非常重要的指针类型,FILE* 指向一个 FILE 类型的对象,这个对象包含了进行文件操作所需的信息;
- 在 C 语言中,几乎所有的文件操作(打开文件、读取文件、写入文件、定位文件指针、关闭文件等)都是通过 FILE* 类型的指针来进行的;
C 中的文件操作
-
打开文件:使用
fopen()
函数打开文件,该函数返回一个FILE*
类型的指针,指向打开的文件。如果文件打开失败,则返回 NULL;FILE *fp = fopen("example.txt", "r"); // 打开文件以只读模式 if (fp == NULL) { // 错误处理 }
-
读写文件:使用如
fgetc()
、fgets()
、fputc()
、fputs()
、fread()
、fwrite()
等函数进行文件的读写操作;char buffer[100]; if (fgets(buffer, 100, fp) != NULL) { // 成功读取一行 } fputs("Hello, World!", fp); // 写入文件
-
定位文件指针:使用
fseek()
、ftell()
、rewind()
等函数可以移动文件指针到指定位置、获取当前文件指针的位置或重置文件指针到文件开头;fseek(fp, 0, SEEK_END); // 将文件指针移动到文件末尾 long pos = ftell(fp); // 获取当前文件指针的位置 rewind(fp); // 将文件指针重置到文件开头
-
关闭文件:使用
fclose()
函数关闭文件;fclose(fp);
2. 认识 fd
- 文件描述符(File Descriptor,简称 fd)是一个非负整数;
- fd 用于在操作系统中唯一标识一个 打开的文件 或 其他输入 / 输出资源(如管道、套接字等);
- Linux 进程默认会打开三个文件描述符,分别为标准输入 0、标准输出 1、标准错误 2;
3. fd 的本质
3.1 数组下标:fd_array[]
- 文件描述符(fd)的本质就是数组下标!
- 操作系统要管理我们打开的文件,就是创建了相应的数据结构(file 结构体)来描述目标文件,然后把它们组织起来(
files_struct
); - 进程中包含了一个指针(
*files
)指向files_struct
这张表,这张表内部包含了一个指针数组(file* fd_array[]
),其中每个元素都是一个指向已打开文件的指针; - 所以,文件描述符就是该指针数组(
file* fd_array[]
)的下标,只要拿着文件描述符,就可以找到对应的文件。
3.2 fd 分配规则
- 最小的没有被使用的数组下标,会分配给最新打开的文件!
3.3 dup、dup2
- 在 Linux 系统编程中,
dup()
和dup2()
是两个非常有用的系统调用,它们用于复制文件描述符;
dup()
#include <unistd.h>
int dup(int oldfd);
参数:
oldfd: 是想要复制的文件描述符
返回值:
成功时,返回一个新的文件描述符(非负整数),这个新描述符是oldfd的副本
出错时,返回-1,并设置errno以指示错误
- dup() 系统调用用于创建一个新的文件描述符,该描述符是调用进程中某个现有文件描述符的副本。新文件描述符与原始文件描述符指向相同的打开文件,共享相同的文件偏移量、文件状态标志和文件模式;
- dup() 的主要用途是当需要额外的文件描述符来引用同一文件时,或者是在进行文件描述符重定向时。
dup2()
#include <unistd.h>
int dup2(int oldfd, int newfd);
参数:
oldfd:是想要复制的文件描述符
newfd:是新的文件描述符的数值
返回值:
成功时,返回newfd
出错时,返回-1,并设置errno以指示错误
- dup2() 系统调用类似于dup(),但它允许调用者指定新文件描述符的数值。如果指定的新文件描述符 newfd 已经打开,则 dup2() 会先关闭它,然后再创建 oldfd 的副本;
- dup2() 主要用于重定向一个文件描述符到另一个已存在的文件描述符上,这在处理文件描述符时提供了更大的灵活性。例如,在子进程中重定向标准输出(stdout,文件描述符为1)到一个文件或管道。
3.4 输出重定向,输入重定向,追加重定向的本质与操作
- 输出重定向是指将原本应该输出到屏幕(通常是标准输出STDOUT,文件描述符为1)的数据信息写入到指定的文件中;
- 输入重定向是指将原本应该从标准输入(STDIN,文件描述符为0)读取的数据来源改为从指定的文件中读取;
- 追加重定向与输出重定向类似,但它不会覆盖目标文件的内容,而是将新的数据追加到文件的末尾。
重定向类型 | 符号 | 本质 | 操作示例 |
---|---|---|---|
输出重定向 | > | 将标准输出重定向到文件 | command > file.txt |
错误输出重定向 | 2> | 将错误输出重定向到文件 | command 2> error.log |
输入重定向 | < | 将标准输入重定向到文件 | command < file.txt |
追加重定向 | >> | 将输出追加到文件末尾 | command >> file.txt |
4. fd vs FILE*
4.1 包含关系
- 因为 IO 相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过
fd
访问的; - 所以 C 库中的 FILE 结构体内部,必定封装了
fd
!
4.2 缓冲区与刷新方式
- 调用系统调用是有时间成本的,缓冲区设计是一种以空间换时间的方法;
- C 库函数(
printf
、fwrite
)会自带缓冲区,而系统调用(write
)没有缓冲区; - 库函数在系统调用的“上层”,是对系统调用的封装,所以缓冲区就是在封装时被加上的,由 C 标准库提供;
- 我们这里说的缓冲区都是用户级缓冲区。
刷新方式
- 缓冲区会在特定条件下被刷新,例如:缓冲区写满、遇到换行符、显式调用
fflush
函数等; - 进程退出的时候,也会自动刷新缓冲区!
4.3 系统调用 write VS 库函数 fwrite
- write 系统调用:是 Linux 系统中的一个系统调用,用于将数据写入文件描述符指向的文件。它工作在较低级别,不涉及用户级缓冲;
- fwrite 库函数:是 C 标准库中的函数,用于向 FILE* 指定的文件写入数据块。fwrite 操作会利用 FILE* 结构体中的缓冲区,并在内部使用 write 系统调用来将数据从缓冲区刷新到文件中。
4.4 用户级缓冲区
- 我们常说的缓冲区一般都是指用户级缓冲区,它是语言层面的缓冲区,C 语言自带缓冲区;
- 使用
FILE*
时,用户级缓冲区由标准 I/O 库自动管理; - 使用
文件描述符(fd)
时,虽然内核中可能也存在缓冲区,但这些缓冲区对用户透明,用户无法直接控制,所以不在我们讨论范围之内。
5. 文件系统
5.1 磁盘
- 系统中大部分的文件都是没有被打开的,它们保存在磁盘(SSD)中;
- OS 需要管理磁盘上的文件,那么如何在磁盘上快速定位一个文件?通过 CHS 定位法!
- 通过磁头定位:磁道 / 柱面 - Cylinder
- 使用哪一个磁头 - Head
- 哪一个扇区 - Sector
- 那么任何一个文件,不就是多个扇区承载的数据吗?
- 把每个扇区(4KB),简单理解为一个数组的元素,那么操作系统对磁盘的管理,就变成了对数组的增删查改!
5.2 分区 vs 格式化
分区:将硬盘或其他存储设备划分为一个或多个逻辑区域的过程。每个分区都被视为一个独立的存储设备,拥有自己的文件系统和存储空间。
- 方便数据组织和管理;
- 减少文件碎片,提升访问速度;
- 提高系统安全和稳定,某个分区出问题,其他分区不受影响;
- 支持多操作系统,可以在不同分区安装多个操作系统;
- 简化备份和恢复过程。
格式化:指在分区上创建文件系统的过程。它主要是创建文件系统的结构和元数据信息,为分区提供一个可读写的文件系统,使操作系统能够有效地与硬盘交互。
- 创建一个可用于存储数据的文件系统结构;
- 初始化分区的元数据,如文件节点表、目录项等;
- 清理分区上的数据,为新的数据存储做准备。
5.3 Block / Block Group / Super Block / Inode Bitmap / Inode Table / Data Blocks
- Block:是文件存取的最小单位,也是操作系统读取硬盘时的基本单位;
- Block Group:ext2 文件系统会将分区划分为数个 Block Group,方便对其进行管理;
- Super Block(超级块):存放文件系统本身的结构信息,如果 Super Block 的信息被破坏,整个文件系统结构就被破坏了;
- Inode Bitmap:是一个位图,每个 bit 位表示一个 inode 是否空闲可用;
- Inode Table:存放文件属性(文件大小、所有者、最近修改时间等);
- Data Blocks:存放文件内容。
5.4 inode 理解:文件=内容(data)+属性(inode)
inode
就是文件系统中的一个数据结构,其中存储了 除文件名和数据内容之外 的所有文件或目录的信息;- 每个文件或目录都有一个唯一的 inode,通过 inode 可以快速定位和管理文件;
- 可以使用
ls -i
查看文件/目录的 inode;
$ touch fileT
$ ls -i
263466 fileT
解释一下上图中创建新文件所需的 4 个操作
- 存储属性:内核先找到一个空闲的 inode(假设是 263466),并把文件信息记录到其中;
- 存储数据:假设这个文件需要占用三个磁盘块(Block),内核找到三个空闲块:300、500、800,将内核缓冲区中的内容复制到其中;
- 记录分配情况:内核在 inode 中的磁盘分布区记录了文件所占用的块列表;
- 添加文件名到目录:我们刚才创建的文件名为 fileT,Linux 如何在当前目录中记录这个文件?内核将(263466,fileT)这组数据添加到目录文件中;
这样一来,文件名 和 inode 之间的对应关系,就将文件名和文件内容及属性连接起来了;
但是我们上面说道 文件=内容(data)+属性(inode),又该如何理解?
inode
中是不包含 文件名 和 文件内容 的,那么一个文件应该=文件名+内容+属性才合理呀;- 实际上目录也是一个文件(Linux 下一切皆文件)!目录中存储的内容(data)正是该目录下 文件名 与 inode 之间的映射关系!
- 所以我们之前对于文件名的理解是不全面的,文件名 与 inode 的映射关系已经被存储在目录中了,它们是一体的!
5.5 软链接
-
独立文件,有独立的 inode
- 软链接本质是一个独立文件,这个文件中保存了 目标文件的路径!
- 在访问软链接时,系统会解析软链接中存放的路径,并使用这个路径访问被链接的文件;
- 可以对目录创建软链接;
- 软链接主要用于在不同位置快速访问文件或目录。
-
ln -s
$ touch test.txt $ ln -s test.txt link.soft # 创建软链接 $ ls -l total 0 lrwxrwxrwx 1 ubuntu ubuntu 8 Aug 22 23:05 link.soft -> test.txt -rw-rw-r-- 1 ubuntu ubuntu 0 Aug 22 15:27 test.txt
- 使用
ln -s
命令为 test.txt 创建一个软链接;
- 使用
-
快捷方式
- 软链接类似于 Windows 下的快捷方式!
5.6 硬链接
-
非独立文件,没有独立的 inode
- 硬链接本质就是在指定的目录下,插入新的 文件名与目标文件的映射关系,并让 inode 的 引用计数++;
- 在磁盘上,找到文件靠的不是文件名,而是 inode,在 Linux 上允许将多个文件名对应(硬链接)于同一个 inode;
- 不能对目录创建硬链接(因为这会引入循环引用的复杂性和安全问题);
-
ln
$ touch test.txt $ ln test.txt link.hard # 创建硬链接
- 使用
ln
命令为 test.txt 创建一个硬链接; - 现在 test.txt 和 link.hard 是同一个 inode 的文件名,这两个文件名代表同一个文件;
- 一个 inode 对应的所有文件名(硬链接)都被删除,文件内容才会被删除(引用计数思想);
- 使用
-
ls -l
$ ls -l total 0 -rw-rw-r-- 2 ubuntu ubuntu 0 Aug 22 15:27 link.hard -rw-rw-r-- 2 ubuntu ubuntu 0 Aug 22 15:27 test.txt ^ 表示该文件有2个硬链接
- 判断一个目录下有多少个子目录:硬链接数 -2 即可得到!
6. 动静态库
6.1 如何打包静态库?ar rc
-
编译源代码为
.o
文件(使用-c
选项);$ gcc -c sourcefile1.c -o sourcefile1.o $ gcc -c sourcefile2.c -o sourcefile2.o
-
使用
ar
命令将.o
文件打包为静态库;$ ar rc libmystatic.a sourcefile1.o sourcefile2.o
r
:表示替换库中的文件;c
:表示创建库;libmystatic.a
就是静态库文件。
6.2 如何打包动态库?-fPIC、gcc/g++ -shared
-
编译源代码为位置无关代码(
-fPIC
);$ gcc -c -fPIC sourcefile1.c -o sourcefile1.o $ gcc -c -fPIC sourcefile2.c -o sourcefile2.o
-
使用
-shared
选项创建动态库;$ gcc -shared -o libmydynamic.so sourcefile1.o sourcefile2.o
libmydynamic.so
就是动态库文件;- 所有库名要符合规范:
lib<name>.<extension>
;
6.3 如何使用静态库?lib*.a
-
使用静态库,需要在程序编译时指定静态库路径(
-L
)和静态库名称(-l
);$ gcc -o test test.c -L. -lmystatic ^ ^--指定库名称为“mystatic” ^--指定库路径为当前目录(./)
-
链接完成后,即使删除静态库,程序依旧正常执行(库被包含进可执行程序中了);
6.4 如何使用动态库?lib*.so
-
动态库的使用与静态库类似;
$ gcc -o test test.c -L. -lmydynamic
-
但是要确保动态库文件可被操作系统找到,我们在编译时指定的路径是给编译器看的,程序运行时操作系统并不知道这个动态库在哪里,所以无法链接;
-
一般建议设置
LD_LIBRARY_PATH
环境变量(下面讲);
6.5 include lib
- 当我们在程序中包含(
include
)一个头文件时,编译器会在标准路径(/usr/include
)中查找它; - 如果这个头文件不在标准路径中,那么我们需要在编译时使用
-I
选项来告诉编译器在哪里查找这个头文件; - 这样一来,我们就可以不使用
-L
和-l
指定路径和库名称了;$ gcc -o test test.c -I ./mylib/include
6.6 -I / -L / -l
- 这些选项都是在编译时添加的,是给编译器看的;
-I
:指定额外的头文件搜索路径;-L
:指定额外的库搜索路径;-l
:指定要链接的库名(库名不包括前缀lib
和后缀.so/.a
);
6.7 动态库:LD_LIBRARY_PATH
通过设置 LD_LIBRARY_PATH 环境变量,可以很方便的让操作系统在程序运行时链接到我们的动态库;
-
使用
export
命令;$ export LD_LIBRARY_PATH=. ^--设置路径为当前目录(./)
-
可以使用
:
作为分隔符,指定多个路径;$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/newpath ^--表示在当前已有路径下新增一个路径
-
使用
echo
命令查看 LD_LIBRARY_PATH 是否被正确设置;$ echo $LD_LIBRARY_PATH
注意:这种设置仅在当前 shell 会话下有效,如果想设置为永久存在,需要修改配置文件。
五、信号
1. 信号概念
信号是 Linux 系统提供的,让用户(进程)给其他进程发送异步信息的一种方式;
使用 kill -l
命令查看系统定义的信号列表:
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
2. 信号产生的方式
2.1 键盘
- 用户在终端按下某些组合键时,会向当前的前台进程发送信号,如
Ctrl+C
产生SIGINT
(中断信号),Ctrl+\
产生SIGQUIT
(退出信号); - SIGQUIT 与 SIGINT 类似,但是 SIGQUIT 会造成
core dump
(核心转储),下面讲;
2.2 kill
- 用户可以在终端执行
kill
命令来向特定进程发送信号。 - 默认情况下,
kill
命令发送的是SIGTERM
(终止信号),用于请求进程优雅地终止;
2.3 系统调用接口
- 进程可以通过系统调用接口(
kill
、raise
、abort
)向自身或其他进程发送信号; kill()
函数可以向指定的进程发送任意信号;raise()
函数可以给当前进程发送任意信号(自己给自己发信号);#include <signal.h> int kill(pid_t pid, int signo); int raise(int signo); 这两个函数都是成功返回0,失败返回-1
abort()
函数用来终止进程,给自己发送指定的信号;#incluce <stdlib.h> void abort(void);
2.4 软件,alarm,SIGPIPE
- 在某些软件条件下,进程会主动或被动地产生信号;
- 例如
alarm
函数可以设置定时器,当定时器超时时会向当前进程发送SIGALRM
(闹钟信号);#include <unistd.h> unsigned int alarm(unsigned int seconds); 让内核在seconds秒之后向当前发送SIGALRM信号,该信号的默认处理动作是终止当前进程
- 当进程尝试写入一个已关闭的管道或 Socket 时,操作系统会向该进程发送
SIGPIPE
信号,告知写入失败;
2.5 异常
- 若程序执行过程中发生异常,操作系统就会向进程发送相应的信号;
- 如:除数为零导致
SIGFPE
(浮点异常)信号,无效的内存引用(野指针)导致SIGSEGV
(段错误)信号。
3. 信号发送给进程的本质
3.1 OS 向目标进程写 bit 位
- 信号通常以位图(bitmap)形式保存在进程的 PCB 中,每个 bit 位对应一个信号编号,bit 位为 1 表示信号以产生但未被处理;
- 向进程发送信号,要先让 OS 接收到信号,然后 OS 修改目标进程 PCB 中的信号位图,将对应信号位设置为 1 ,表示信号已经产生;
- 目标进程会检查 PCB 中的信号位图,如果信号产生且未被处理(未决状态),进程就会对其进程处理;
3.2 所有的信号,都必须经过 OS 发送给目标进程
- 所以,无论信号的来源如何,最终都需要经过操作系统(OS)处理,才能发送给目标进程;
- 只有 OS 有权力修改进程的 PCB !
4. core dump:核心转储
核心转储(core dump),在汉语中有时戏称为“吐核”,是操作系统在进程收到某些信号而终止运行时,将此时进程地址空间的内容以及有关进程状态的其他信息写出的一个磁盘文件。这种信息主要用于调试目的。
- 默认不允许产生 core 文件,因为 core 文件中可能包含敏感信息(用户名、密码),不安全;
- 可以使用
ulimit
命令更改相关设置。
5. 信号相关概念
5.1 未决
- 信号未决:一个信号已经被发送到某个进程,但该进程尚未处理这个信号;
5.2 递达
- 信号递达:信号被内核送到进程,并且进程已经执行了该信号的处理动作;
- 默认处理动作:终止进程、暂停等
- 若一个信号没有被指定特定的处理函数(通常可以使用
signal
sigaction
等函数进行自定义处理),或者指定处理函数为SIG_DFL
(默认处理)时,信号将执行默认处理动作; - 常见的默认处理动作包括终止进程(SIGSEGV、SIGFPE)、暂停进程(SIGSTOP)、忽略信号(SIGURG、SIGWINCH),以及执行特定的系统调用(SIGCHLD);
- 若一个信号没有被指定特定的处理函数(通常可以使用
- 忽略
- 进程可以选择忽略某些信号,即信号到达时,不执行任何处理动作;
- 通常将信号处理函数设置为
SIG_IGN
(忽略)来忽略信号; - 注意:并非所有的信号都可以被忽略(如果可以,那么这个进程就可以忽略所有信号,变成一个无法被外部控制的“无敌”进程),例如
SIGKILL
、SIGSTOP
就不能被忽略;
- 自定义捕捉
- 进程可以给信号指定一个自定义处理函数(使用
signal
、sigaction
函数),当信号到达时,内核会切换到用户态并执行这个处理函数;
- 进程可以给信号指定一个自定义处理函数(使用
5.3 屏蔽
- 信号屏蔽:进程通过设置信号屏蔽字(Signal Mask)来阻止某些信号被递达给该进程;
- 信号屏蔽字是一个位图,每个 bit 位对应一个信号编号,为 1 表示被屏蔽,为 0 表示未被屏蔽;
- 被屏蔽的信号会一直处于未决状态,不会被递达给进程;
5.4 屏蔽 VS 忽略
屏蔽 | 忽略 | |
---|---|---|
定义 | 系统不将信号传递给进程,直到屏蔽被解除 | 系统将信号传递给进程,但进程不对其执行任何处理动作 |
实现方式 | 使用 sigprocmask 函数修改信号屏蔽字 | 使用 signal 或 sigaction 函数设置信号的处理函数为 SIG_IGN |
效果 | 信号在屏蔽期间保持未决状态,不中断进程执行 | 信号被系统直接丢弃,不中断进程执行 |
注意:并非所有信号都可以被屏蔽或者忽略!
6. 信号相关操作
6.1 signal
#include <signal.h>
void (*signal(int sig, void (*func)(int)))(int);
signal
是一个古老的,用于设置信号处理方式的函数;- 为指定信号
sig
设置一个新的处理函数func
;
6.2 sigaction
#include <signal.h>
int sigaction(int sig, const struct sigaction* act, struct sigaction* oact);
sigaction
函数提供了一种更可靠的方式来设置信号的处理方式;- 根据
act
修改信号的处理方式; - 通过
oact
传出该信号原来的处理方式;
6.3 sigprocmask,sigset_t
#include <signal.h>
int sigprocmask(int how, const sigset_t* set, sigset_t* oset);
sigprocmask
函数用于改变当前进程的信号屏蔽字;how
参数决定了如何改变当前的信号屏蔽字:SIG_BLOCK
添加信号到当前屏蔽字中,SIG_UNBLOCK
从当前屏蔽字中移除信号,SIG_SETMASK
设置新的屏蔽字;- 成功返回 0 ,出错返回 -1 ;
sigset_t
是一个用于表示信号集合的类型,它通常是一个位图,每一位代表一个信号。
6.4 sigpending
#include <signal.h>
int sigpending(sigset_t* set);
sigpending
函数用于获取当前被阻塞且未处理(未决)的信号集合,通过 set 参数传出;- 成功返回 0 ,出错返回 -1 ;
7. 信号的处理
7.1 陷入内核
- 当进程执行系统调用或遇到硬件中断时,它会从用户态(User Mode)切换到内核态(Kernel Mode);
- 内核态是操作系统执行自己代码的状态,有很高的优先级,基本不受任何资源约束;
- 操作系统会在进程陷入内核态时,检测是否有待处理的信号(信号处理要在内核态下进行),操作系统会在进程返回用户态之前处理这些信号;
7.2 返回检测
- 当进程完成内核态的操作并准备返回用户态时,操作系统会再次检查是否有待处理的信号(如果有,就处理);
7.3 自定义动作
- 如果信号的处理方式为用户自定义的处理函数,那么进程会从内核态回到用户态执行信号处理函数;
- 信号处理函数在返回时会执行特殊的系统调用,因此进程会再次进入内核态;
7.4 Handler
- Handler(处理程序):用于处理信号的函数或代码块;
- 当信号发生时,如果程序已经为该信号注册了自定义的 handler,则操作系统会调用该 handler 来处理信号;
7.5 ∞
在信号处理的过程(捕捉)中,一共会有 4 次状态切换(内核态 <=> 用户态)!
8. 竞态条件
- 竞态条件:在多进程/线程同时对共享资源进行访问时,由于操作的顺序或时间上的不确定性,导致产生了不可预见的行为;
- 比如,信号处理函数的重入:当一个信号处理函数正在执行时,如果系统再次向该进程发送了相同的信号,而该信号的处理方式被设置为默认处理或自定义处理函数,那么信号处理函数可能会被中断并重新执行;
- 这种重入可能导致数据竞争、状态不一致或死锁等问题;
- 一般来说,通过设置信号屏蔽字可以防止在信号处理函数执行期间接收到其他信号(内核就是这样做的):在信号处理函数执行前设置屏蔽字,执行完毕后移除屏蔽字;
9. volatile
volatile
是一个关键字,使用该关键字声明的变量,不会被编译器优化!因为在编译器的优化中可能会出现用户未定义的行为;
- 如果编译器认为某个变量的值在程序执行过程中不会发生变化,那么该变量的值可能会被缓存(优化)到寄存器中,以减少对内存的访问次数;
- 在信号处理中,信号随时可能到来(异步),信号可能会在任何时刻改变某个变量的值。如果这个变量在寄存器中,信号处理函数就无法正确获取该变量的最新值,从而导致程序行为不可预测;
10. SIGCHLD,SIG_IGN
SIGCHLD
:这是子进程运行结束时发送给父进程的一个信号,通知父进程该回收了;SIG_IGN
:是一种信号处理方式,表示忽略信号;- 默认情况下,父进程不会对
SIGCHLD
信号做任何处理,使得子进程在结束后变成僵尸进程,父进程需要调用wait()
或wait()
进行回收; - 如果父进程忽略了
SIGCHLD
信号(通过调用signal(SIGCHLD, SIG_IGN);
),那么子进程结束后就会被系统自动回收,这样做可以避免子进程成为僵尸进程。
六、多线程
1. 线程理论
1.1 线程概念
- 线程是进程内部的一个执行分支,线程是 CPU 调度的基本单位;
- 加载到内存中的程序,叫做进程。修正:进程 = 内核数据结构 + 进程代码和数据;
- 之前我们所理解的进程,其实就是内部只有一个线程(PCB / task_struct)的进程;
- 现在我们应该改变认知:进程内部至少要有一个线程,进程中可以有多个线程!
1.2 线程的私有属性
- 独立栈
- 每个线程都有自己独立的栈空间,用于存储局部变量、参数和返回地址等信息;
- 独立栈确保了线程的独立性,防止不同线程间的数据相互干扰;
- 上下文
- 线程上下文:线程在执行过程中所需要的所有状态信息(程序计数器、寄存器状态、栈指针、…);
- 上下文信息会在线程切换时被保存,保证线程在切换后能够继续从上次停止的地方开始执行;
- 优先级、TID、PCB
- 线程的优先级是指线程被 CPU 调度的优先顺序。优先级有助于系统根据线程的重要性进行合理的调度;
- 线程 ID(TID)是系统中唯一标识一个线程的标识符;
- 线程控制块(PCB / TCB)是线程在操作系统内核中的标识,其中包含了线程的所有信息(线程状态、优先级、上下文、栈信息、…);
- 注意:在 Linux 中并没有 PCB 与 TCB 的区分,因为线程的 “TCB” 本质是复用了进程 PCB!
1.3 Linux 系统的线程具体实现原理
- Linux 内核中提供了对轻量级进程(LWP)的支持,这种轻量级进程就是线程在内核中的实现;
- Linux 内核通过复用进程的代码和数据结构来实现线程,因此 Linux 并不严格区分线程和进程,它们都是通过 PCB(进程控制块)来描述的;
- 但是对于用户来说,用户并不知道“轻量级进程”这个概念,所以 Linux 对轻量级进程的系统调用进行了封装,转化为线程相关的接口(
pthread
库)提供给用户;
1.4 线程在进程内部运行的本质
- 线程本质上是在进程地址空间内运行!这意味着线程可以直接访问进程所拥有的内存空间和系统资源;
- 进程是系统资源分配的基本单位!线程是 CPU 调度的基本单位!
- 我们知道 Linux 中不区分 task_struct,实际上在 CPU 调度时,只关心调度执行流(也就是轻量级进程);
1.5 LWP,ps -aL
LWP
(Lightweight Process,轻量级进程):在 Linux 系统中,LWP 用来指代线程;
$ ps -aL
PID LWP TTY TIME CMD
3319244 3319244 pts/1 00:00:00 ps
ps
命令:用于显示当前系统中的进程状态;-a
选项:显示所有用户的进程;-L
选项:显示线程的详细信息,包括线程ID(LWP)和每个线程的状态等信息;- 使用该命令后显示的 LWP 表示线程 ID(也称为 LWP ID)。
1.6 线程安全 VS 可重入
- 联系
- 可重入是线程安全的一种特殊情况:如果一个函数是可重入的,那么它在多线程环境下通常也是线程安全的;
- 目标相同:线程安全与可重入的目标都是确保并发执行时的数据一致性和正确性;
- 区别
- 实现方式不同:线程安全通常需要复杂的同步机制和数据访问控制(比如加锁);而可重入性通常是通过避免使用全局变量和静态变量、不调用不可重入函数等方式来实现;
- 适用场景不同:线程安全适用于任何需要并发访问共享资源的场景;而可重入性更适用于需要被多个执行流同时调用的函数;
1.7 -lpthread
-
POSIX
线程库:想在 Linux 系统中进行多线程编程,我们需要包含 POSIX 线程(pthread)库; -
<pthread.h>
:需要在程序中包含该头文件; -
-lphread
:在编译时使用此参数,告诉编译器要链接 POSIX 线程库,该参数通常放在编译命令的末尾;# 例如 gcc -o example example.c -lpthread
-
所有的 Linux 系统都必须自带原生线程(pthread)库;
1.8 用户级线程的概念
- 用户级线程:是操作系统中的一个概念,指不需要内核支持而在用户程序中实现的线程;
- 这种线程不依赖于操作系统核心,其创建、同步、调度和管理完全由用户程序(通常是线程库)来控制;
- 注意:虽然在概念上用户级线程不依赖于内核,但是一些(硬件中断 / 系统调用)的操作还是需要内核的支持;
- 优势:
- 调度无需内核参与,可以提升效率;
- 灵活性高,用户可自定义调度算法;
- 跨平台性:用户线程可以在不支持多线程的操作系统上运行;
2. 线程控制
2.1 pthread_t
pthread_t
的具体类型取决于系统的实现;- 在 Linux 中,pthread_t 类型的线程 ID,本质是进程地址空间中的一个地址;
2.2 pthread_create
功能:创建一个新的线程
原型
int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*start_routine)(void*), void* arg);
参数
thread:返回线程ID
attr:设置线程的属性,为NULL表示使用默认属性
start_routine:是一个函数指针,指向线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
- 注意:这里 thread 返回的线程 ID 并不是操作系统中的线程 ID;
- 操作系统中的 ID 是为了标识该线程,用于 CPU 调度;
- 而 pthread_create() 函数返回的 ID 其实是一个(虚拟内存的)地址,为了后面继续操作能够找到该线程;
2.3 pthread_join
功能:等待线程结束
原型
int pthread_join(pthread_t thread, void** value_ptr);
参数
thread:线程ID
value_ptr:它指向一个指针,它指向的这个指针指向线程的返回值
返回值:成功返回0;失败返回错误码
- 为什么需要线程等待?已经退出的线程,其空间没有被释放,仍在进程地址空间内;
- 调用该函数的线程将挂起等待,直到 ID 为 thread 的线程终止;
2.4 pthread_detach
int pthread_detach(pthread_t thread);
- 默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源;
- 如果不关心线程的返回值,那么 join 是一种负担,这时我们可以使用
pthread_detach
告诉系统:当线程退出时,自动释放线程资源;
2.5 pthread_cancel
功能:取消一个执行中的线程
原型
int pthread_cancel(pthread_t thread);
参数
thread:线程ID
返回值:成功返回0;失败返回错误码
2.6 pthread_exit
功能:线程终止
原型
void pthread_exit(void* value_ptr);
参数
value_ptr:不能指向一个局部变量
2.7 pthread_self
pthread_t pthread_self(void);
- 用于获得线程的唯一标识符(也就是操作系统中的线程 ID);
3. 线程同步与互斥
3.1 互斥
3.1.1 互斥的概念
- 临界资源:被多线程执行流共享的资源就叫做临界资源;
- 临界区:线程内部访问临界资源的代码,就叫做临界区;
- 互斥:任何时刻,互斥保证了有且只有一个执行流进入临界区,访问临界资源。互斥对临界资源起保护作用;
3.1.2 pthread_mutex_t,初始化
pthread_mutex_t
是 POSIX 线程库中定义的一种数据类型,用于表示一个互斥锁(Mutex);
初始化互斥量有两种方法(静态 / 动态);
-
方法 1 ,静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
3.1.3 pthread_mutex_init
-
方法 2 ,动态分配
原型: int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* attr); 参数: mutex:要初始化的互斥量 attr:NULL
3.1.4 pthread_mutex_lock / pthread_mutex_unlock
互斥量加锁:
int pthread_mutex_lock(pthread_mutex_t* mutex);
互斥量解锁:
int pthread_mutex_unlock(pthread_mutex_t* mutex);
返回值:成功返回0;失败返回错误码
调用 pthread_lock
时,可能会遇到:
- 互斥量处于未锁状态,该函数会将互斥量锁定,并返回 0 表示成功;
- 发起函数调用时,如果互斥量已被锁定,或者没有竞争到互斥量,那么 pthread_lock 会陷入阻塞状态,等待互斥量解锁;
3.1.5 pthread_destroy
功能:销毁互斥量
原型:
int pthread_destroy(pthread_mutex_t* mutex);
- 静态(使用
PTHREAD_MUTEX_INITIALIZER
)初始化的互斥量不需要销毁; - 不要销毁一个已经加锁的互斥量;
- 要确保已被销毁的互斥量不会再次被加锁;
3.1.6 原子性
- 概念
- 原子性:不会被任何调度机制打断的操作,该操作只有两种状态,要么完成,要么未完成;
- 原理,swap,exchange 汇编
- 为了实现互斥锁操作,大多数体系结构都提供了
swap
或exchange
指令,该指令可以把寄存器和内存单元的数据交换; - 由于只有一条汇编指令,该命令保证了原子性;
- 即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期;
- 为了实现互斥锁操作,大多数体系结构都提供了
3.2 同步
3.2.1 什么是同步
- 同步:在保证数据安全的前提下,让线程按照特定顺序访问临界资源,从而有效避免饥饿问题;
- 竞态条件:指多个线程在访问共享资源时没有正确同步,导致程序运行结果不可预测;
3.2.2 cond
3.2.2.1 pthread_cond_t
pthread_cond_t
是 POSIX 线程库中用于表示条件变量的一种数据类型;- 条件变量是线程间同步的一种机制,允许一个或多个线程在某些条件未满足时挂起(等待),并在这些条件被其它线程改变(即条件被满足时),被唤醒以继续执行;
- 该操作在生产者-消费者模型、线程池中非常有用;
3.2.2.2 pthread_cond_init
原型:
int pthread_cond_init(pthread_cond_t* cond, const pthread_condattr_t* attr);
参数:
cond:要初始化的条件变量
attr:NULL
3.2.2.3 pthread_cond_wait
功能:等待条件变量满足
原型:
int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex);
参数:
cond:在该条件变量上等待
mutex:互斥量
3.2.2.4 pthread_cond_signal
功能:唤醒在条件变量上等待的线程之一
原型:
int pthread_cond_signal(pthread_cond_t* cond);
- 唤醒一个
- 使用
pthread_cond_signal
函数即可唤醒在特定条件变量上等待的一个线程; - 还有一个函数
pthread_cond_broadcast
用于唤醒所有在该条件变量上等待的线程;
- 使用
- 唤醒多个:惊群
- 惊群:多个在等待的线程被同时唤醒,但只有一个线程可以成功获取资源,其它线程不得不再次进入等待状态;
- 这种情况会浪费系统资源,还可能导致性能问题;
- 要灵活使用 pthread_cond_signal 和 pthread_cond_broadcast ,确保每次调用时都有明确的目的和预期的结果;
- 通常可以使用更细粒度的锁,来减少等待同一个锁的线程数量,从而降低惊群的可能性;
3.2.2.5 pthread_cond_destroy
功能:销毁条件变量
原型:
int pthread_cond_destroy(pthread_cond_t* cond);
3.2.2.6 pthread_cond_wait(&cond, &lock) 为什么
为什么 pthread_cond_wait
需要互斥锁(mutex)?
- 条件等待是线程同步的一种手段,这种操作往往涉及到多个线程,比如:一个线程阻塞等待,另一个线程修改条件变量,唤醒等待线程;
- 既然条件变量是多个线程共享的,那么它就应该被加锁保护;如果条件不满足,当前线程就应该解锁,让其它线程得到锁去修改条件变量;
3.2.2.7 生产者消费者模型,基于 blockqueue
-
生产者-消费者模型有什么用?
- 解耦
- 支持并发
- 支持忙闲不均
-
生产者与消费者不直接通信,而是依赖于一个中间容器:
-
BlockingQueue
(阻塞队列):是多线程编程中一种常用的、实现生产者-消费者模型是数据结构; -
特性:当队列为空时,不能从队列中获取元素(该操作会被阻塞),直到队列中被插入元素;当队列满时,不能往队列里插入元素(阻塞),直到队列中有元素被取出;
-
使用 C++ 模拟实现一个阻塞队列?
…
3.2.3 sem_t
sem_t
是 POSIX 线程库提供的一个数据类型,用于表示信号量;- 信号量是一种同步机制,常用于线程和进程之间的同步与互斥;
3.2.3.1 信号量本质:计数器
- 信号量本质是一个计数器,是描述临界资源数量的计数器(电影院有几张票);
- 所有的进程,想访问临界资源,都必须先申请信号量(先买票)!
3.2.3.2 sem_init
功能:初始化信号量
原型:
int sem_init(sem_t* sem, int pshared, unsigned int value);
参数:
pshared:为0表示线程间共享,非0表示进程间共享
value:信号量初始值
3.2.3.3 sem_wait P
功能:等待信号量,会将信号量的值-1
原型:
int sem_wait(sem_t* sem); // P()
3.2.3.4 sem_post V
功能:发布信号量,表示资源使用完毕,可以归还资源了,会将信号量的值+1
原型:
int sem_post(sem_t* sem); // V()
3.2.3.5 sem_destroy
功能:销毁信号量
原型:
int sem_destroy(sem_t* sem);
3.2.3.6 PV 操作为何必须是原子的
- 确保操作的完整性;
- 实现进程互斥和同步;
- 避免死锁和饥饿:在并发环境下,多个进程/线程可能会同时尝试修改同一个信号量的值,这样可能会导致死锁或饥饿等问题;
3.2.3.7 生产者消费者模型,基于环形队列 321
-
区别:基于阻塞队列的模型可以动态分配空间,而环形队列的大小是固定的;
-
环形队列采用数组模拟,用模运算(%)来模拟环状特性;
-
环形结构的起始状态和结束状态是一样的,不好判断空和满;
-
可以通过计数器或标记位解决,也可以预留一个空的位置,表示满的状态;
-
具体实现
… -
“321”是什么意思?
- 3 种关系:
- 生产者 vs 生产者 - 互斥;
- 消费者 vs 消费者 - 互斥;
- 生产者 vs 消费者 - 互斥 && 同步;
- 2 种角色 - 生产者和消费者;
- 1 个交易场所;
- 3 种关系:
3.3 线程池
- 线程池:创建一些线程并放在一个容器(线程池)中,有任务来临时,直接拿出一个空闲线程来执行,执行完毕后继续放回线程池。这样可以减少线程的创建与销毁带来的开销;
4. 其他概念
4.1 STL 和线程安全
- C++ STL 中的容器是线程安全的吗?不是!
- 因为加锁会对性能造成巨大影响!
- 调用者要自己保证线程安全!
4.2 单例模式,懒汉和饿汉
-
单例模式是一种 经典的、常用的、常考的 设计模式;
-
饿汉:吃完饭就立刻洗碗(程序运行就立刻生成单例对象),下次吃饭就能直接拿碗吃饭;
template <typename T> class Singleton { static T data; public: static T* GetInstance() { return &data; } };
-
懒汉:吃完饭不洗碗,下次吃饭前再洗(用到这个单例对象的时候才会生成);
template <typename T> class Singleton { static T* inst; public: static T* GetInstance() { if (inst == NULL) { inst = new T(); } return inst; } }; // 有问题,不是线程安全的 // 如果GetInstance被多线程同时调用,可能会创建多个T对象实例
// 懒汉模式-线程安全版本 template <typename T> class Singleton { volatile static T* inst; // 设置volatile关键字,避免被编译器优化 static std::mutex lock; public: static T* GetInstance() { if (inst == NULL) { lock.lock(); if (inst == NULL) // 双重判定空指针,降低锁冲突概率,提高性能 { inst = new T(); } lock.unlock(); } return inst; } };
4.3 读者写者问题
这块内容先不关注,作者要去复习网络相关知识了~