写在前面
记录一下《深入理解计算机系统》中的要点。
一、第一章 计算机系统漫游
1. 程序的编译周期
- 最初编写的源程序
xxx.c
是一个文本文件; - 编译系统把源程序编译到可执行目标程序需要经过如下
4
个阶段;
1.1 预处理阶段
- 由预处理器(cpp)程序执行;
- 根据
.c
文件中的以#
开头的命令修改.c
文件; - 例如:
- 将
#include
包含的头文件内容直接插入到.c
文件中; - 将
#define
后的内容直接在.c
文件中进行替换;
- 将
- 输出被修改的源程序
xxx.i
,也是文本文件;
1.2 编译阶段
- 由编译器(ccl)程序执行;
- 将
.i
中的每条语句翻译成一条条汇编语言指令(低级语言); - 输出汇编程序
xxx.s
,也是文本文件;
1.3 汇编阶段
- 由汇编器(as)程序执行;
- 将
.s
中的汇编语言指令翻译成对应的机器语言指令(低级语言); - 输出可重定位目标程序
xxx.o
,是二进制文件;
1.4 链接阶段
- 由链接器(ld)程序执行;
- 将源程序中调用别的程序的函数所对应的可重定位目标程序
.o
以某种方式并入到当前源程序对应的.o
文件中; - 也就是完成了不同
.o
文件的链接和整合,以完成当前源程序的功能; - 输出可执行目标程序
xxx
(无后缀),也是二进制文件,即可由系统执行;
2. 存储器的层次模型
- 存储器的层次模型如下:
2.1 CPU Cache
- 参考:https://xiaolincoding.com/os/1_hardware/how_to_make_cpu_run_faster.html;
2.1.1 Cache的三级缓存结构
- CPU Cache通常是大小不等的三级缓存,结构如下:
- L1 Cache是各个CPU 核心独占的,包括:
- 数据缓存;
- 指令缓存;
- L2 Cache也是各个CPU 核心独占的;
- L3 Cache则是各个CPU 核心共享的;
- CPU只会从L1 Cache中读写数据;
- L1 Cache是各个CPU 核心独占的,包括:
2.1.2 Cache的内部结构
- Cache是由多个缓存块(Cache Line)组成的,包括:
- 头标志部分:
- 有效位:表明Cache Line中的数据是否还有效;
- 组标记:表明Cache Line中的数据对应内存的哪个组的内存块;
- 数据块部分:
- 对应的内存数据:是内存数据在Cache中的映射;
- 头标志部分:
- 主流的Cache Line都是64字节;
2.1.3 内存地址和Cache数据
- 通过内存地址访问内存数据,实际上最终都是通过内存地址访问Cache数据,因为任何内存数据都需要加载到Cache中才能被CPU读取;
- 内存地址和Cache数据的对应关系如下:
- 根据内存地址访问Cache数据的步骤如下:
- 根据内存地址中的索引信息,计算该地址在L1 Cache中对应的Cache Line索引地址;
- 分别验证是否有效且组标记是否对应:
- 如果对应,则使用偏移量访问L1 Cache中对应的数据;
- 如果不对应,则按照L1 Cache -> L2 Cache -> L3 Cache -> 内存的次序依次查找和更新数据,再使用偏移量在L1 Cache中访问对应数据;
2.1.4 Cache的两种写入策略
-
(1) 直写模式(WriteThrough):
- 在数据更新时,将数据同时写入Cache和内存中;
- 速度较慢;
-
(2) 回写模式(WriteBack):
- 在数据更新时,只将数据写入到Cache中;
- 等到数据换出 Cache时,才将数据写入到内存中;
- 速度较快,但会引起Cache一致性问题,需要额外设计策略保证各个核心的Cache数据一致:
-
回写模式解决Cache一致性问题:
- 使用MESI协议,包括四种状态:
- 已修改(Modified):当前Cache Line的数据是最新,但还没有写入到内存(脏数据);
- 独占(Exclusive):仅当前Cache Line读取了内存对应的数据,数据和内存一致;
- 共享(Shared):不止当前Cache Line读取了内存对应的数据,但各个Cache Line和内存中的数据都一致;
- 已失效(Invalid):当前Cache Line的数据非最新,已失效;
- 当某个CPU核心A更新了Cache Line中的数据时,设置该Cache Line为已修改状态(脏数据),同时通知其他CPU核心,它们对应的Cache Line会被设置为已失效状态(在内存更新后就成为脏数据);
- 这样在其他CPU访问的时候,A会立即将Cache Line中的数据写入到内存中,而其他CPU也会立即从内存中获取已更新的数据;
- 使用MESI协议,包括四种状态:
2.1.5 Cache的三种内存映射关系
- (1) 直接映射:
- 内存中的每个块只能被映射到某一个Cache Line中;
- 一种简单的映射方式是用求模映射;
- 实现简单,地址转换速度快,但Cache利用率不高,容易发生映射冲突,适合大容量Cache;
- 内存中的每个块只能被映射到某一个Cache Line中;
- (2) 全相联映射:
- 内存中的每个块都能被映射到任何一个Cache Line中;
- 对Cache的利用率高,但地址转换需要逐个比对Cache Line,实现复杂,适合小容量Cache;
- Cache替换策略是最近最少使用算法(LRU),即换出最久未使用的Cache Line;
- (3) 组相联映射:
- 内存中的每个块能被映射到一组(若干个)Cache Line中;
- 是直接映射和全相联映射的折中方案,兼顾两者的优点,因而被普遍采用;
- Cache替换策略是最近最少使用算法(LRU),即换出最久未使用的Cache Line;
2.1.6 Cache的编程技巧
- (1) 尽量按照内存的存储顺序来连续访问数据;
- (2) 尽量减少if-else等分支语句的使用;
3. 操作系统的硬件管理
3.1 操作系统的两个基本功能
- (1) 防止硬件被失控的应用程序滥用;
- (2) 在控制复杂而又通常广泛不同的低级硬件设备方面,为应用程序提供简单一致的方法;
3.2 操作系统的三种硬件抽象表示
- 操作系统提供
3
种硬件资源抽象表示; - 每种抽象表示都向外提供独占该抽象所代表的硬件资源的假象;
- 不同的抽象所代表的硬件资源划分如下:
- (1) 文件:对I/O设备的抽象表示;
- 是字节的序列;
- 所有的I/O设备均可以被视为文件,包括磁盘、键盘、显示器和网络等;
- (2) 虚拟存储器:对主存和I/O设备的抽象表示;
- 向进程提供虚拟地址空间;
- 最小的可寻址单位是字节
Byte
; - 最大地址由计算机字长决定,
n
位计算机的地址范围是0-2^n-1
; - 多字节对象的序列排序规则:
- 大端法(big endian):
- 最高有效字节在最前面(低地址);
- 网络字节顺序是大端法,也就是说网络传输时先发最高有效字节;
- 小端法(little endian):
- 最低有效字节在最前面(低地址);
- 在Intel兼容机、Android和IOS机器上,主机字节顺序是小端法,也就是说存取内存时先存最低有效字节;
- 大端法(big endian):
- (3) 进程:对处理器、主存和I/O设备的抽象表示;
- 一些概念如下:
- 上下文切换:指交错执行一个进程和另一个进程的指令;
- 上下文:指进程运行所需的所有状态信息,包括虚拟地址空间中的内容、通用目的寄存器中的内容、程序计数器、环境变量和打开文件描述符的集合;
- 可以由多个线程作为执行单元组成,它们都运行在进程的上下文中;
- 一些概念如下:
4. 进程的虚拟地址空间结构
- 由虚拟存储器提供给进程的虚拟地址空间结构如下:
-
更加详细的版本如下:
-
地址从下往上增大,最底下的地址为0;
-
顶部的四分之一的地址空间:预留给操作系统中的代码和数据;
-
余下的四分之三的地址空间:用来存放用户进程定义的代码和数据;
-
以下是对虚拟地址空间分区的介绍,主要分为
6
个分区;
4.1 内核虚拟存储器
- 操作系统用,也就是顶部的四分之一的地址空间;
- 用于存放操作系统的内核;
4.2 用户栈
- 应用程序用,从顶部四分之一以下的位置往下生长;
- 用于给编译器实现函数调用:
- 调用函数时,栈生长;
- 从函数返回时,栈收缩;
4.3 共享库
- 应用程序用,在用户栈和运行时堆之间;
- 用于存放:
- (1) 进程间共享库的代码和数据,如C标准库和数学库等;
- (2) 用
mmap()
映射的磁盘文件数据;
4.4 运行时堆
- 应用程序用,从程序代码和数据以上的位置往上生长;
- 用于运行时的动态分配数据空间:
- 申请空间时,堆生长;
- 释放空间时,堆收缩;
- 注意,这里的堆并不是一般意义上的堆结构(数组实现的维持最值的结构),而是类似于由若干个空闲块(连续空间的数组)组成的链表的结构;
4.5 程序数据和代码
- 应用程序用,位于底部,占用的空间由可执行文件决定;
- 用于存放应用程序基础数据,包括:
- (1) 读/写数据:
.bss
:存放未编译期初始化的全局变量和静态局部变量;.data
:存放已编译期初始化的全局变量和静态局部变量,仅包括POD类型;
- (2) 只读数据:
.rodata
:存放只读变量和虚函数表;
- (3) 代码(只读的机器指令);
.text
:存放所有代码和字面量;
- (4) 启动代码;
.init
;
- (1) 读/写数据:
4.6 未用区域
- 作为约定的空闲区域,位于最底部;
5. 远程过程调用
- 一个远程过程调用的简单例子如下:
三、第三章 程序的机器级表示
1. 过程调用的栈帧结构
- 这里介绍一下用户栈的使用过程;
1.1 用户栈详细结构
- 用户栈的作用如下:
- (1) 传递过程参数;
- (2) 存储返回信息;
- (3) 保存寄存器以供恢复当前过程后使用;
- 帧:为一个过程分配的部分栈;
- 用户栈中包括了若干帧,每一次调用对应一个帧;
- 栈帧的详细结构如下:
- 其中:
- (1) 帧指针:指向当前帧的开始地址,该指向的空间(即被保存的%ebp)中保存着上一帧的开始地址,便于过程返回;
- (2) 栈指针:指向用户栈的栈顶地址,也是当前帧的结束位置;
- (3) 返回地址:是当前过程调用下一过程返回后的继续执行的第一条指令的地址;
- (4) 被保存的寄存器、本地变量和临时变量:
- 寄存器的值有可能是上一帧遗留下来的值,也有可能是当前帧新覆盖的值,这没有什么关系,因为寄存器是在不同帧之间共享的,并不用区分帧和帧的不同;
- 本地变量和临时变量都是当前帧的值,因为寄存器有可能放不下所有的变量(寄存器的数量有限或者放不下某些类型的变量,如地址类变量、数组类变量和结构类变量),所以要压入到用户栈中;
- (5) 参数构造区域:如果当前帧要调用别的过程,则别的过程的传入参数也是放在当前帧里面的,所以最后一部分就是参数构造区域,因为再往下生长就要进入新的帧上了;
1.2 过程调用中的栈帧变化
- 一个示例的程序如下:
- 这个程序对应的过程调用栈帧变化如下:
- (1) 调用
swap_add()
过程时:- 将返回地址压入
caller()
过程的栈帧,返回地址是int diff = arg1 - arg2
的第一条指令; - 将当前帧指针的值压入用户栈,然后令帧指针指向当前栈顶,也就是
swap_add()
过程的栈帧的开始地址,相当于是将栈指针移动到新的一帧上;
- 将返回地址压入
- (2) 从
swap_add()
过程返回时:- 令栈指针指向当前的帧指针,也就是让栈顶回退到当前帧的刚开始的地方;
- 从用户栈中弹出保存的%ebp,这是上一帧的开始地址,把它赋给当前帧指针,也就是令帧指针回退到上一帧的开始地址;
- 再从用户栈中弹出返回地址,接着从
caller()
调用完swap_add()
后的第一条指令开始执行;
1.3 栈帧的缓冲区溢出
- 如果过程调用中使用了一个缓冲区类型变量,例如数组等,则有可能发生缓冲区溢出(buffer overflow);
- 缓冲区溢出即写入到缓冲区中的数据超过了缓冲区的已分配大小,进而覆盖掉其他地址空间中的数据,它可以发生在栈空间,也可以发生在堆空间;
- 一个栈空间的缓冲区溢出示例程序如下:
echo()
的栈帧结构如下:
- 一些说明如下:
- 对于
echo()
过程而言,它在保存的%ebp下面立刻的就压入了临时变量buf
,因为buf
是echo()
过程中第一个使用的临时变量; - 临时变量
buf
是一个字符串数组,为缓冲区类型,需要提前申请若干无数据空间,这里申请的栈空间是一个字长(4Bytes); - 如果写入
buf
的时候发生了数组越界,则buf[3]
之后的数据将覆盖保存的%ebp中的数据,从而导致帧指针回跳上一帧开始地址的失败,这样就发生了缓冲区溢出导致的错误; - 利用栈的缓冲区溢出可以迫使系统执行未经允许的指令,因为帧指针可能跳转到一个未经授权的指令继续执行,例如蠕虫病毒就是利用栈的缓冲区溢出执行攻击,攻击的手段通常是向进程传入一个溢出缓冲区的字符串;
- 对于
七、第七章 链接
1. 用GCC编译源程序到目标程序
- 假设有两个源程序文件,如下:
- 直接编译源程序到目标程序:
gcc -02 -g -o p main.c swap.c
- 如果分步骤编译
main.c
,则分为4
步骤,如下: - (1) 预处理器
cpp [可选参数] main.c /tmp/main.i
- (2) 编译器
ccl /tmp/main.i main.c -02 [可选参数] -o /tmp/main.s
- (3) 汇编器
as [可选参数] -o /tmp/main.o /tmp/main.s
- (4) 链接器
- 在此之前,
swap.c
也同样执行了上述三个步骤; - 输出可执行文件p;
ld -o p [系统目标文件和参数] /tmp/main.o /tmp/swap.o
- 整个过程是静态链接,如下:
2. 目标文件类型
- 目标文件本质上是字节块的集合,字节块的类型包括:
- 程序代码字节块;
- 程序数据字节块;
- 指导链接器和加载器的数据结构字节块;
- 目标文件有以下
3
种类型:- (1) 可重定位目标文件:
.o
文件,由汇编器输出;- 为二进制文件,包含程序机器语言;
- 可在编译时和别的可重定位目标文件静态链接起来,形成一个可执行目标文件;
- (2) 共享目标文件:
- 特殊的可重定位目标文件,由汇编器输出;
- 为二进制文件,包含程序机器语言;
- 可在可执行文件加载或者运行时,被动态地加载到存储器并链接;
- (3) 可执行目标文件:
- 可执行文件,由链接器输出;
- 为二进制文件,包含程序机器语言;
- 可直接被拷贝到存储器中执行;
- (1) 可重定位目标文件:
3. 可重定位目标文件结构
- Unix ELF类型的可重定位目标文件结构如下:
- 各个节的描述如下:
- ELF头:描述了字的大小和生成该文件的系统的字节顺序;
- .text:已编译程序的机器代码和表达式中的只读常量数据;
- .rodata:其他的只读常量数据;
- .data:已初始化的全局变量和静态局部变量,仅包括POD类型变量;
- .bss:未初始化的全局变量和静态局部变量,包括任何需要调用构造函数的非POD类型对象、调用函数计算得到初始值的变量和初始化为0值的变量;
- (1) 所有需要调用函数进行初始化的变量都需要在运行时才能被初始化;
- (2) .bss段变量会自动在main函数执行前被赋初值,所以如果初始化为0值,则不需要放在.data中,可以减少可执行文件的空间开销;
- .symtab:符号表,存放在程序中被定义和引用的函数和全局变量的信息,但不包含局部变量的信息;
- .rel.text:在链接过程中.text需要修改的指令位置;
- 所有调用外部函数或者引用全局变量的指令都需要修改;
- 调用本地函数的指令则不需要修改;
- .rel.data:在链接过程中.text需要修改的信息;
- .debug:调试符号表;
- 只有以
-g
进行编译时,才会得到这个表;
- 只有以
- .line:源代码
.c
文件的行号和.text中机器指令之间的映射;- 只有以
-g
进行编译时,才会得到这个表;
- 只有以
- .strtab:字符串表,记录.symtab和.debug中所用到的符号名字、每个节的名字、源文件的名字等字符串;
- 节头部表:描述不同节的位置和大小;
4. 符号表
4.1 符号类型
- 有三种类型的链接器符号:
- (1) 可以被其他目标文件调用的全局符号,包括:
- 非
static
的函数; - 非
static
的全局变量;
- 非
- (2) 在别的目标文件中定义且被当前目标文件调用的全局符号,包括:
- 在其他目标文件中定义的非
static
的函数; - 在其他目标文件中定义的非
static
的全局变量;
- 在其他目标文件中定义的非
- (3) 本地符号,包括:
- 带
static
的函数; - 带
static
的全局变量; - 不包括所有的局部变量;
- 带
- (1) 可以被其他目标文件调用的全局符号,包括:
4.2 符号表的使用方式
- 符号表中仅记录以上三种链接器符号,实际上,也就是只记录所定义或者所使用的函数和全局变量,因为只有这两种数据会被其他目标文件使用;
- 符号表的使用过程如下:
- 在链接时,不同目标文件的符号表会合并成一个完整的符号表;
- 在运行时,可执行文件(包括所有源代码对应的可重定位目标文件的机器指令)会加载到虚拟地址空间的程序代码和数据部分中;
- 这样,不同的可重定位目标文件都能够通过查这个完整的符号表找到调用的外部函数或者全局变量所在的地址,完成调用的过程;
4.3 符号表的结构
- Unix ELF类型的符号表包含多个条目结构,每个条目结构如下:
-
包括的内容有:
name
:全局变量或者函数的名字保存在字符串表 .strtab中的字节偏移;value
:全局变量的值或者函数的指令开始地址在保存的节(如 .data、 .bss和 .text)中的字节偏移;size
:全局变量或者函数的大小(Byte);type
:是全局变量还是函数;binding
:是非static
(全局)还是static
(本地);section
:全局变量的值或者函数是在哪个节中出现,包括:- .data;
- .bss;
- .text;
- ABS:该符号不应该被重定位;
- UNDEF:该符号不是在本目标文件而是在别的目标文件中定义的,也就是第二类符号;
- COMMON:该符号是未被初始化的全局变量;
-
示例程序对应的两个符号表如下:
4.4 合并符号表的规则
-
链接器在合并符号表的时候需要处理在多个目标文件中同名的函数或者变量而引起的冲突,所有的同名只能选择一个;
-
这里先给出强弱符号的定义:
- 强符号:函数和已初始化的全局变量;
- 弱符号:未初始化的全局变量;
-
则合并符号表的规则如下:
- (1) 不允许有多个强符号;
- (2) 如果有一个强符号和多个弱符号,则选择强符号;
- (3) 如果有多个弱符号,则从这些弱符号中任意选择一个;
-
注意:
- 合并符号表的时候并不会区分同名全局变量的类型,也就是说,两个不同类型的同名全局变量将在各自的目标文件中,按照当前文件定义的类型写入和读取数据;
- 如果不同类型的存储字节不同,这样的写入和读取会发生意想不到的数据错误和内存溢出:
- 如果选择的符号是较小的存储字节,则在较大存储字节类型的程序中写入会导致覆盖掉别的虚拟内存空间,读取会导致读取到别的虚拟内存空间的内容;
- 如果选择的符号是较大的存储字节,则虽然不会破坏别的虚拟内存空间,但交错读写时读取的数据仍然是错误的;
- 因此是不建议在多个文件中使用同名全局变量的,如果一定要使用,则需要检查同名全局变量是否是同一类型,而且使用的意图是否符合符号表合并的规则;
5. 库
5.1 静态库
-
静态库(static library)可以作为链接器的输入,在Unix中是
.a
文件; -
静态库的目的是:
- 避免整合过于庞大的
.o
到最终的可执行目标文件中,从而缩减可执行文件的空间; - 避免在链接时输入繁琐的可重定位目标文件的名字列表;
- 避免整合过于庞大的
-
为了实现这个目的,首先将源代码拆分,分别生成小的可重定位目标文件模块,每个目标模块仅包含若干函数或者全局变量,然后将这些目标模块整合在一起,就形成了一个静态库,这样:
- 静态库中的每个目标模块均可以从静态库中分离,而单独作为链接器的输入;
- 在调用静态库时,直接使用静态库的名字即可,链接器会自动从中找到所需要的目标模块,然后整合到可执行文件中;
-
用gcc创建和调用包含
addvec.c
和multvec.c
源程序的静态库过程如下:
# 以下是创建静态库的一个例子
# 先将这些源程序分别转换为可重定位目标文件
gcc -c addvec.c multvec.c
# 将这些可重定位目标文件整合到libvector.a静态库中
ar rcs libvector.a addvec.o mulvec.o
# 以下是使用静态库的一个例子
# 将main2.c转换为可重定位目标文件
gcc -02 c main2.c
# 生成可执行文件p2时使用libvector.a作为链接器的输入
gcc -static -o p2 main2.o ./libvector.a
- 过程示意图如下:
- 注意:
- 使用多个静态库作为链接器的输入参数时,必须先出现使用某个符号的库,再出现定义该符号的库,否则该符号将不能被链接器解析;
- 为了满足以上条件,静态库在参数列表中可以重复出现;
5.2 动态链接共享库
- 静态库虽然已经尽量拆分了需要链接的可重定位目标文件,但这种静态整合仍然会有以下问题:
- (1) 有一些常用的函数会被大量复制到不同的可执行文件中,运行时也同样会被大量重复加载到不同进程的虚拟地址空间中,造成存储器的浪费;
- (2) 如果修改了静态库中的内容,所有的可执行文件都需要重新链接更新为新的可执行文件;
- 针对以上静态库的问题,需要提出新的链接方案以满足:
- (1) 仅在文件系统中保留一份库文件;
- (2) 仅加载一次库文件副本到内存中,其他可执行文件在运行时均共享调用该副本;
- 动态链接共享库(dynamic linking shared library)无需在链接阶段就整合到可执行文件中,而是等加载到内存中之后,再由动态链接器执行动态链接,在Unix中是
.so
文件; - 用gcc创建和调用包含addvec.c和multvec.c源程序的动态库过程如下:
# 生成可重定位目标文件并整合到libvector.so动态链接共享库中
gcc -shared -fPIC -o libvector.so addvec.c multvec.c
# 用动态链接共享库生成可执行文件p2
gcc -o p2 main2.c ./libvector.so
- 动态链接过程的示意图如下:
- 一些注意的点如下:
- 生成的动态库是一个与位置无关的代码(PIC),实现的依据是代码段(外部函数)和数据段(全局变量) 加载到虚拟地址空间后,它们相对于0地址的位置总是固定的(这是在设计虚拟地址空间的时候就决定了的),因此可以通过一个全局偏移量表(GOT)获得相对位置,再加上实际加载后0地址所在的内存地址获得动态库的指令和数据的实际地址;
- 可执行文件中并没有整合动态库中的代码和数据,仅拷贝了一些重定位和符号表信息;
- 程序加载时,如果加载器发现程序需要调用动态链接器,则加载并调用动态链接器;
- 由动态链接器负责加载动态库到内存中,并为程序完成动态链接,最后再开始执行程序;
6. 可执行目标文件结构
- 经过链接器后,多个可重定位目标文件被整合到一个可执行目标文件中,整合的内容有
2
个:- (1) 将各个可重定位目标文件的相同命名节整合到可执行目标文件同名节中;
- (2) 根据合并的符号表,修改代码节( .text)中的外部函数和数据节( .data和 .bss)中的全局变量符号的引用,令它们能够指向唯一的正确的地址;
- Unix ELF类型的可执行目标文件结构如下:
-
相比可重定位目标文件,增加了:
- 段头表:描述从可执行文件的连续组块到虚拟地址空间中的连续段中的映射关系;
- .init:定义了
_init
函数,供程序的初始化代码调用;
-
相比可重定位目标文件,减少了:
- .rel,因为可执行文件已经是完全链接(被重定位)的了,所以不需要再提供链接所需要的数据;
-
将可执行文件加载到进程的虚拟地址空间的示意图如下:
- 从上图中可以看出,加载时只加载可执行文件的:
- 只读段:.init,.text,.rodata;
- 读写段:.data,.bss;
八、第八章 异常控制流
1. 异常的类别
- 异常的类别有
4
种,如下:
1.1 中断
- 异步发生;
- 由硬件引起的。如外部I/O设备引起的中断、周期性定时器引起的中断、信号引起的中断等;
- 跳转到中断处理程序中处理中断异常;
- 异常结束后执行下一条指令;
1.2 陷阱
- 同步发生;
- 陷到更深层次(内核)的指令中执行,一种常见的陷阱是执行系统调用,向内核请求服务;
- 跳转到到内核服务中执行系统调用指令;
- 异常结束后执行下一条指令;
1.3 故障
- 同步发生;
- 由程序的错误情况引起,例如缺页异常,运算错误等;
- 跳转到故障处理程序;
- 故障结束后再次执行当前指令;
1.4 终止
- 同步发生;
- 由不可恢复的致命错误引起,如硬件损坏错误;
- 跳转到终止处理程序;
- 终止后即终止当前应用程序,不会继续执行;
2. 进程
- 进程是一个执行中程序的实例;
2.1 进程独占的虚拟地址空间
- 每个进程都有独占整个系统地址空间的假象;
- 它所能获得虚拟地址空间大小由计算机字长决定,而不是由实际的内存硬件大小决定,
n
位字长的虚拟地址空间大小是2^n
;
2.2 进程上下文
- 上下文切换的过程:
- (1) 保存当前进程的上下文;
- (2) 恢复某个先前被抢占进程所保存的上下文;
- (3) 将控制传递给该恢复的进程;
2.3 进程的状态
- 进程的状态包括:
- 运行:执行或者等待执行;
- 暂停:执行被挂起,如收到SIGSTOP、SIGTSTP、SIDTTIN或者SIGTTOU信号时,进程就暂停,直到收到SIGCONT信号;
- 终止:进程永久停止,包括:
- 收到终止进程的信号;
- 从主程序返回;
- 调用exit函数;
2.4 控制进程的相关函数
- 这些函数均为系统调用函数;
(1) 获取进程ID
#include <unistd.h>
#include <sys/types.h>
// 返回调用进程的PID
pid_t getpid(void);
// 返回调用进程的父进程的PID
pid_t getppid(void);
(2) 创建进程
#include <unistd.h>
#include <sys/type.h>
// 创建子进程
// 子进程返回0,父进程返回子进程的PID,错误返回-1
pid_t fork(void);
- 创建的子进程将获得和父进程相同但独立的一份用户级虚拟地址空间拷贝,包括代码、数据、堆和用户栈;
- 子进程和父进程共享内核虚拟地址空间的部分内容:
- (1) 已经打开了的文件描述符;
- (2) 共享内存区域;
- 子进程和父进程最大的区别是它们有不同的PID,通过PID和
fork()
函数的返回值,可以在代码中分辨当前进程是父进程还是子进程; - 每次调用
fork()
时,当前进程(无论是父进程还是已经创建了的子进程)都会创建一个新的子进程,一个例子如下:
(3) 终止进程
#include <stdlib.h>
void exit(int status);
(4) 回收子进程
- 进程终止时,并不会立即被内核清除,而是需要被父进程或者init进程回收才会不占用内存资源;
- 僵尸进程:已经终止但还未被回收的进程;
#include <sys/types.h>
#include <sys/wait.h>
// 成功返回子进程PID,WNOHANG返回0,错误返回-1
pid_t waitpid(pid_t pid, int *status, int options);
- 一些传入参数的说明如下:
(5) 让进程挂起
sleep()
可以挂起进程一段预设的时间:
#include <unistd.h>
// 挂起当前进程一段时间
// 返回剩下的要挂起的时间
unsigned int sleep(unsigned int secs);
pause()
可以挂起进程,直到它被信号中断唤醒;
#include <unistd.h>
// 挂起当前进程直到被信号唤醒
int pause(void);
(6) 加载并运行程序
- 在当前进程中加载并运行一个新的程序如下:
#include <unistd.h>
// 成功则不返回,错误返回-1
// filename:可执行目标文件名,argv:参数列表,envp:环境变量列表
int execve(char *filename, char *argv[], char *envp);
- 参数列表的结构如下,
argv[0]
约定就是filename
:
- 环境列表的结构如下,每个字符串都是一个
NAME=value
的形式:
- 加载程序的过程如下:
execvc()
加载可执行目标文件到当前进程的虚拟地址空间中(并没有创建新进程,而是覆盖原进程的除内核虚拟存储器外的用户级虚拟地址空间);- 跳到程序开始处,执行在ctrl.o中定义的启动代码;
- 启动代码准备好用户栈,然后调用程序的主函数;
- 主函数的形式如下:
int main(int argc, char *argv[], char *envp[]);
- 调用主函数时的用户栈内容:
-
从main的栈帧往上分别是:
argc
:argv[]中非空指针的数量,也就是参数的数量;argv
:指向argv[]的指针;envp
:指向envp[]的指针;- 动态链接器变量;
argv[]
:存放指向可执行文件名+参数列表字符串指针的数组;envp[]
:存放指向环境列表字符串指针的数组;- 参数列表字符串:由命令行输入的参数列表字符串,该字符串已被用
\0
标记每组参数字符串的结尾; - 环境列表字符串:由命令行输入的环境列表字符串,该字符串已被用
\0
标记每组环境字符串NAME=value
的结尾;
-
一些操作环境数组的函数和说明如下:
(7) 设置进程组
- 每个进程都只属于一个进程组,有一个进程组ID(PGID);
- 默认地,一个子进程和它的父进程同属于一个进程组;
- 返回进程组ID如下:
#include <unistd.h>
// 返回进程组ID
pid_t getpgrp(void);
- 修改进程组ID如下:
#include <unistd.h>
// 设置进程pid的进程组ID为pgid
// 成功返回0,错误返回-1
pid_t setpgid(pid_t pid, pid_t pgid);
- 一些参数的说明如下:
- 如果
pid = 0
,则pid
是当前进程的PID; - 如果
pgid = 0
,则是用pid
的值来作为pgid
设置进程的组ID;
- 如果
2.5 控制进程的工具
- Unix提供的控制进程的工具如下:
2.6 三种特殊的进程
2.6.1 僵尸进程
- 已经终止但父进程未回收其资源的进程;
- 特点:
- 当前进程已经终止;
- 在内核的进程表中仍然保留了PCB的信息,占用一定的资源;
- 危害:
- PCB信息会占用内核内存资源;
- 当前进程的PID会被一直占用,无法重新分配;
2.6.2 孤儿进程
- 当前进程未终止但父进程已经退出的进程;
- 特点:
- 当前进程仍未终止;
- 父进程通常是因为某些意外原因并未等待子进程退出就退出了;
- 当然也可以主动退出,此时的子进程通常是想要作为守护进程的;
- 当前进程被init进程(PID=1)接收,作为init的子进程,此时当前进程就不再是孤儿进程了,而且执行完成后会由init进程回收资源;
- 并无危害;
2.6.3 守护进程
- 父进程是init进程的进程;
- 特点:
- (1) 可以手动产生孤儿进程来作为守护进程;
- 因为孤儿进程会被init进程接收;
- (2) 也可以直接由init进程在系统启动时自动创建守护进程;
- 此时该守护进程不是孤儿进程,因为它的父进程就是init进程;
- 一些常见的由init进程在系统启动时创建的守护进程如下:
- syslogd:负责记录系统日志;
- crond:负责周期性执行预定的任务,如备份等;
- sshd:提供ssh远程登录服务;
- httpd:Web服务器程序,如Apache、Nginx等;
- mysqld:MySQL数据库服务进程;
- named:DNS解析服务进程;
- ntpd:网络时间协议服务进程;
- udevd:设备管理服务进程。
- 目的是脱离控制终端,在后台提供一种长期稳定的服务;
- (1) 可以手动产生孤儿进程来作为守护进程;
- 并无危害;
3. 信号
- 本节除了参考《深入理解计算机系统》的第八章外,主要参考了:
- 《Linux高性能服务器编程》第十章;
3.1 信号类型
- 信号是一种由用户、系统或者进程发送的,允许内核向用户进程通知底层的硬件异常事件的发生的机制;
- 产生信号的条件如下:
- 对于终端交互进程,用户可以输入某些特殊的终端字符给进程发送信号,如
Ctrl+C
引起SIGINT; - 系统异常,如浮点异常和非法内存段访问;
- 系统状态变化,如alarm定时器到期将引起SIGALRM信号;
- 显式运行
kill()
函数给进程发送信号;
- 对于终端交互进程,用户可以输入某些特殊的终端字符给进程发送信号,如
- Linux系统支持的信号如下:
- 一种常见的信号是SIGKILL,可以由kill程序向其他进程发送该信号;
# 向进程发送SIGKILL
kill .9 进程PID
# 向进程组发送SIGKILL
kill .9 -进程PGID
- 也可以用
kill()
函数向其他进程发送任意信号,如下:
#include <sys/types.h>
#include <signal.h>
// pid > 0,仅发送给PID进程
// pid < 0,发送给GPID进程组
int kill(pid_t pid, int sig);
3.2 信号相关函数
3.2.1 signal函数
- 作用:
- 系统调用;
- 为一个信号设置处理函数;
- 使用:
sig
:要捕获的信号类型;_handler
:指定信号的处理函数;
#include <signal.h>
// 为信号sig设置处理函数指针_handler
// 成功返回之前处理sig的函数指针,错误返回SIG_ERR,并设置errno
_sighandler_t signal(int sig, _sighandler_t _handler);
_sighandler_t
是一个函数指针别名,定义如下:- 仅一个
int
参数; - 返回类型是
void
;
- 仅一个
3.2.2 sigaction函数
- 作用:
- 系统调用;
- 比
signal()
更加健壮地为一个信号设置处理函数;
- 使用:
sig
:要捕获的信号类型;act
:为信号指定新的信号处理方式;oact
:输出信号之前的处理方式;
#include <signal.h>
// 为sig设置处理方式act,将之前的处理方式记录在oact中
// 成功返回0,失败返回-1,并设置errno
int sigaction(int sig, const struct sigaction *act, struct sigaction *oact);
sigaction
结构体如下:sa_handler
:指定信号处理函数,必须是全局函数或者静态成员函数;sa_mask
:在调用由sa_handler
所定义的信号处理函数时将阻塞该组掩码信号,不允许它们中断信号处理函数的执行,默认处理的信号也是掩码信号;sa_flags
:设置程序在收到信号时的行为;- 可以参考博文:sigaction的使用;
sa_flags
的参数可选如下:
3.3 信号集相关函数
- 信号集是一组信号的集合,实际上是一个长整型数组,结构如下:
3.2.1 基本操作函数
- 使用的方式和文件描述符集(见十二、7.5)类似,相关的函数如下:
#include <signal.h>
// 清空信号集_set
int sigemptyset(sigset_t *_set);
// 在信号集_set中设置所有信号(Linux是64种)
int sigfillset(sigset_t *_set);
// 将_signo信号添加到信号集_set
int sigaddset(sigset_t *_set, int _signo);
// 将_signo信号从信号集_set中删除
int sigdelset(sigset_t *_set, int _signo);
// 验证_signo是否在信号集_set中
int sigismember(_const sigset_t *_set, int _signo);
3.2.2 sigprocmask函数
-
作用:
- 设置或者查看进程的信号掩码集;
-
使用:
- 若
_set
是NULL,则原来的掩码不改变,可以用来获取当前的掩码集; - 掩码集会被子进程继承;
- 若
#include <signal.h>
// 以_how的方式,将_set中的信号设置为信号掩码,将原来的信号掩码记录在_oset中
// 成功返回0,错误返回-1
int sigprocmask(int _how, _const sigset_t *_set, sigset_t *_oset);
_how
参数如下:
3.2.3 sigpending函数
-
作用:
- 获得进程当前被挂起的信号集,即被进程设置为掩码但被进程接收到的信号集;
-
使用:
- 挂起信号集不会被子进程继承;
#include <signal.h>
// 将当前被挂起的信号集记录到set中
// 成功返回0,错误返回-1
int sigpending(sigset_t *set);
3.4 统一事件源
- 进程处理信号的过程如下:
- 在当前进程使用
signal()
或者sigaction()
将信号和信号处理函数绑定之后,信号处理函数的调用就交给了内核,控制权不在当前进程; - 如果当前进程接收到信号,会由内核中断当前进程(中断的三种情况之一)并触发执行对应的信号处理函数;
- 等执行完信号处理函数后,再回到进程的主循环中继续执行;
- 在当前进程使用
- 也就是说,必须要在信号处理函数和当前进程的主循环之间建立通信才行,因为信号处理函数无函数返回值(
void
类型)到主循环,实际上是在另一个进程(内核进程)中执行; - 一般可以用管道进行通信,相当于是进程间通信,这样可以将管道的文件描述符加入到多路复用中(监听来自内核进程的信号),类似于监听socket和其他进程的管道,也就是统一了进程主循环监听的事件源;
3.5 网络编程相关信号
- 在网络网络编程中,服务器必须处理(或者至少忽略)一些常见的信号,以免异常终止;
- 这里介绍一些常见的服务器需要处理的信号;
3.5.1 SIGHUP
-
触发条件:
- 进程的控制终端被挂起;
-
作用:
- 强制服务器重读配置文件;
3.5.2 SIGPIPE
- 触发条件:
- 往一个读端关闭的管道或者socket连接中写数据;
- 会将
errno
设置为EPIPE; - 可以用
send()
的MSG_NOSIGNAL标志禁止写操作触发该信号;
3.5.3 SIGURG
- 作用:
- 内核通知应用程序由带外数据到达;
3.5.4 SIGALRM
- 触发条件:
- 由
alarm()
和setitimer()
设置的实时闹钟超时;
- 由
3.6 进程相关信号
3.6.1 SIGINT
-
触发条件:
- 由键盘输入引起的中断,如输入
Ctrl+C
;
- 由键盘输入引起的中断,如输入
-
作用:
- 终止进程;
3.6.2 SIGTERM
-
触发条件:
- 由软件发送的终止信号;
-
作用:
- 终止进程;
3.6.3 SIGCHLD
-
触发条件:
- 子进程暂停或者终止;
-
作用:
- 通知父进程回收子进程;
十、第十章 虚拟存储器
1. 页表
- 页表是用于提供虚拟地址和物理地址之间映射的数据结构;
- 每个虚拟地址空间(或者说是为进程服务的虚拟存储器)都对应一个页表;
- 页表的结构如下:
-
一些说明如下:
- 页表是一个页表条目(PTE)数组;
- 每个PTE中包括
1
位有效位和n
位物理地址组成,物理地址包括内存中的物理地址和磁盘中的物理地址; - 注意这里的磁盘是用作虚拟内存的一部分空间,由内核自动控制,用户无法控制;
-
查找页表时可能发生以下情况:
- 页命中:页表中的PTE指向的物理地址是内存中的物理地址,可以直接使用;
- 缺页:页表中的PTE指向的物理地址是磁盘中的物理地址,需要将磁盘中的页换到内存中才能使用;
2. Linux中的虚拟存储器形式
- Linux是使用一个
task_struct
维护进程运行时所需要的所有信息; task_struct
中的mm_struct
维护了进程的虚拟存储器的所有信息,其中:pgd
:指向页表的首地址;mmap
:指向虚拟地址区域链表,每个虚拟地址区域记录了虚拟地址空间中的一段地址(通常是表示同一功能的连续组块)的相关信息;
- 它们的结构如下:
- 使用虚拟地址的时候:
- 首先在
mmap
中检查虚拟地址是否合法,也就是在不在某个虚拟地址区域中; - 如果虚拟地址合法,则查找
pgd
指向的页表,找到虚拟地址对应的物理地址; - 读取或者修改物理地址指向的数据;
- 首先在
3. 虚拟地址空间的物理映射
-
虚拟地址空间的结构如下:
-
虚拟地址空间中的地址可以被物理地址映射;
-
根据物理地址指向的数据的使用方式,可以分成
2
种对象:- 私有对象;
- 共享对象;
3.1 私有对象
- 私有对象的物理地址指向的数据只能给一个进程的虚拟存储器使用,其物理地址只能映射到一个虚拟地址空间中;
- 私有对象的映射使用的是写时拷贝技术,即:
- 多个进程一开始都将私有对象映射到同一块物理地址上;
- 直到发生写入冲突(也就是某个进程要写入的地方被其他进程先写入了),这才在其他地方分配(或者说拷贝)空闲的物理地址给当前进程使用;
- 写时拷贝不需要考虑读取冲突,因为进程首次正确访问某个物理空间一定是写入操作,访问未经写入的空间必定会导致错误的结果;
- 多个进程使用私有对象的示意图如下:
3.2 共享对象
- 物理地址指向的数据可以给多个进程的虚拟存储器使用,其物理地址可以映射到多个虚拟地址空间中;
- 共享对象可以节省物理存储器的空间;
- 常见的共享对象是虚拟地址空间中对多个进程相同的内容,包括:
- 内核代码和数据;
- 动态链接共享库;
- 示意图如下:
4. 申请虚拟地址空间
- 虚拟地址空间(或者说是虚拟内存空间)的使用很多都是由内核相关的函数自动实现的,即很多区域无需程序员用代码手动申请空间就会自动申请和填充;
- 程序员能够在代码中手动申请虚拟地址空间的方式有两种:
- (1) 使用
mmap()
函数,在用户栈和运行时堆之间的空间申请一块虚拟地址空间; - (2) 使用
sbrk()
函数,在运行时堆空间之上申请一块虚拟地址空间;
- (1) 使用
- 注意,申请的虚拟地址空间之和不能超过由计算机字长决定的最大虚拟地址空间;
4.1 使用mmap函数
mmap()
函数的作用是将磁盘的文件直接映射到进程的虚拟地址空间中,作用是:- 提高文件读取效率:
- 普通磁盘文件拷贝到内存中需经过:磁盘 -> 虚拟内存的内核态的页缓存区 -> 虚拟内存的用户态的虚拟地址空间;
mmap()
函数映射到内存中只需经过:磁盘 -> 虚拟内存的内核态的页缓存区,然后虚拟内存的用户态的虚拟地址空间直接映射内核的页缓存区,不需要进行拷贝;
- 可以直接修改文件达到修改进程的运行时数据的目的;
- 因为对文件的修改也是对内核页缓存区的修改;
- 由于
mmap()
直接将用户态的虚拟内存空间映射到内核页缓存区,所以应用程序也可以立即知晓修改;
- 可以通过共享磁盘文件作为进程间通信的方式;
- 可以利用磁盘空间实现大数据操作;
- 提高文件读取效率:
mmap()
函数的定义如下:
#include <unistd.h>
#include <sys/mman.h>
// start = NULL:由内核选定一个空闲且够大的开始位置
// 如果是用自己给定的start,则有可能导致空间申请失败
// 申请成功则返回start地址,错误则返回-1
void* mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
- 一些参数的设置如下:
mmap()
会在start
的位置申请一个大小为length
的虚拟地址空间,然后将fd
指定的磁盘文件中从offset
开始映射length
大小的地址到虚拟地址空间中,示意图如下:
-
mmap()
的映射示意图如下:- 首先将磁盘中的文件拷贝到内核的页缓冲区;
- 然后用户进程直接映射到内核缓冲区的页表上,而不需要再拷贝到用户缓存区中;
- 最终看上去的效果就和将文件直接映射到用户缓冲区一样;
- 参考:
- https://juejin.cn/post/6844903949359644680#heading-11 ;
- https://juejin.cn/post/6844903949359644680#heading-11 ;
-
删除申请的虚拟地址空间可以用
munmap()
函数,如下:
#include <unistd.h>
#include <sys/mman.h>
// 成功返回0,错误返回-1
int munmap(void *start, size_t length);
- 关于
mmap()
函数的详细介绍可以参考博文:认真分析mmap:是什么 为什么 怎么用 ;
4.2 使用sbrk函数
sbrk()
函数的作用是增加运行时堆的大小,具体实现就是将 %brk 堆顶指针往上(高地址方向)增长;
sbrk()
函数定义如下:
#include <unistd.h>
// 申请成功则返回旧brk指针地址,错误则返回-1
void* sbrk(int incr);
- 删除申请的虚拟地址空间仍然是用
sbrk()
,只需要将incr
设置为负数即可;
5. 动态存储器分配器
- 动态存储器分配器负责管理虚拟地址空间中的空间分配申请;
- 动态存储器分配器基于
mmap()
和sbrk()
实现; - 有两种类型的分配器:
- 显式分配器:显式地释放已分配的块;
- C:
malloc()
和free()
; - C++:
new
和delete
;
- C:
- 隐式分配器:自动释放未使用但已分配的块;
- Java等;
- 显式分配器:显式地释放已分配的块;
- 动态存储器分配器分配空间后返回的都是指针,因此等号的左值必须是指针;
5.1 malloc函数
malloc()
用于分配空间,定义如下:
#include <stdlib.h>
// 返回指向申请空间的指针
void* malloc(size_t size);
-
其中,
size_t
是unsigned int
类型; -
调用
malloc()
分配虚拟地址空间的规则如下:- 申请的空间自动双字对齐(向上取整为8字节的倍数,假设字长是32bits = 4Bytes);
- (1) 申请小于128K空间:
- 如果堆中没有连续足够大的空间,则调用
sbrk()
扩展运行时堆空间(将堆顶指针往高地址移动),且返回旧 %brk地址; - 如果堆中有足够大的连续空间(通过遍历隐式空闲链表可以得到匹配的空闲块),则分割该连续空间(余下的加入隐式空闲链表)并直接返回匹配空间的首地址;
- 如果堆中没有连续足够大的空间,则调用
- (2) 申请大于128K空间,则调用
mmap()
申请一块虚拟地址空间;
-
注意:
- 调用
malloc()
仅分配了虚拟地址空间,这些虚拟地址空间并未在页表中和物理地址空间进行映射,物理地址为NULL; - 直到第一次读写该虚拟地址时,才为它分配物理地址;
- 调用
-
堆维护的隐式空闲链表的空闲块结构如下:
- 隐式空闲链表的一个例子如下:
- 可以参考博客:malloc 底层实现及原理;
补充1. calloc函数
calloc()
在可以分配多个同样大小的空间而无需手动计算总大小,而且可以为空间中的每个字节初始化为0;- 是
malloc()
的封装函数;
#include <stdlib.h>
// 申请num个size大小的空间,并把空间中的每个字节初始化为0
void* calloc(size_t num, size_t size);
- 可以参考博客:动态内存分配malloc, calloc, realloc函数解析;
补充2. realloc函数
realloc()
用于扩充已申请的虚拟地址空间;- 扩容的规则如下:
- 如果在已申请空间后面有足够大的堆连续空间,则直接追加空间;
- 如果在已申请空间后面没有足够的空间,则:
- 另外找一个足够大的连续空间,可以在隐式空闲链表中找或者用
sbrk()
和mmap()
增加堆空间; - 然后把之前的已申请空间的数据迁移到新空间中;
- 最后返回新空间的起始地址;
- 另外找一个足够大的连续空间,可以在隐式空闲链表中找或者用
#include <stdlib.h>
// 将ptr开始的已申请空间扩容到size大小
// 返回新空间的起始地址
void* realloc(void* ptr, size_t size);
- 可以参考博客:动态内存分配malloc, calloc, realloc函数解析;
5.2 free函数
free()
用于释放空间,定义如下:
#include <stdlib.h>
void free(void *ptr);
-
调用
free()
释放虚拟地址空间的规则如下:- (1) 释放堆空间,则将对应的堆空间放入隐式空闲链表等待下次分配;
- 如果有两个地址连续的空闲块,则合并这两个空闲块,合并的时间可以推迟到堆内匹配空闲块失败以减少开销;
- 如果堆顶的连续空闲块大小超过128K,则调用
sbrk()
缩减运行时堆空间(将堆顶指针往低地址移动);
- (2) 释放
mmap()
申请的空间,则调用munmap()
释放空间即可;
- (1) 释放堆空间,则将对应的堆空间放入隐式空闲链表等待下次分配;
-
可以参考博客:
- malloc 底层实现及原理;
- 【C++】C/C++ 内存管理 —— new和delete底层实现原理;
5.3 new操作符
-
实现原理:
- 调用
malloc()
函数申请空间,如果申请失败,则抛出异常; - 自动调用构造函数,完成对象的初始化;
- 调用
-
但并不是所有情况下都会调用构造函数,不同的写法与构造函数的调用关系如下:
- (1) 未定义默认构造函数,也没有虚函数,则:
class_type *c = new class
不使用自动生成的默认构造函数;class_type *c = new class()
使用自动生成的默认构造函数;- 如果
class
是POD类型,则为基本类型赋初值; - POD类型是指仅由基本类型组成的
class
,且无显式定义的任何函数; - 一旦定义了函数,如构造函数、析构函数等,则不是POD类型;
- 其他类型即使调用了默认构造函数也不会为基本类型赋初值;
- 如果
class_type c = class()
使用自动生成的默认构造函数;- 如果
class
是POD类型,则为基本类型赋初值,同上;
- 如果
- (2) 定义了默认构造函数,或者有虚函数,则:
- 无论如何都会调用(自动生成或者自定义的)默认构造函数;
- (1) 未定义默认构造函数,也没有虚函数,则:
-
关于
new class_type[N]
:- N次调用
malloc()
申请空间; - 在申请的空间上执行N次构造函数,完成N个对象的初始化;
- N次调用
-
可以参考博客:C++:带你理解new和delete的实现原理;
5.4 delete操作符
-
实现原理:
- 执行析构函数,清理动态申请的资源;
- 调用
free()
释放空间;
-
关于
delete []p
:- 在要释放的空间上执行N次析构函数,清理动态申请的资源;
- N次调用
free()
释放空间;
-
可以参考博客:
- malloc 底层实现及原理;
- 【C++】C/C++ 内存管理 —— new和delete底层实现原理;
十一、第十一章 系统级I/O
1. 打开和关闭文件
1.1 open函数
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
// 成功打开返回新文件描述符,错误返回-1
int open(char *filename, int flags, mode_t mode);
flags
说明如下:
mode
说明如下,在sys/stat.h
中定义:
-
参数说明还可参考:open 函数的 flag 参数和错误代码;
-
关于描述符的一些补充:
- Unix进程生命周期开始时,会预先赋三个描述符:
- 0:
stdin
; - 1:
stdout
; - 2:
stderr
;
- 0:
- 后序的描述符在
open
时申请,在close
时释放;
- Unix进程生命周期开始时,会预先赋三个描述符:
1.2 close函数
#include <unistd.h>
// 成功关闭返回0,错误返回-1
int close(int fd);
2. 读写文件
2.1 read函数
#include <unistd.h>
// 从fd文件的当前文件位置拷贝至多n个字节到虚拟内存位置buf
// 成功返回读的字节数,若EOF则为0,错误返回-1
ssize_t read(int fd, void *buf, size_t n);
2.2 write函数
#include <unistd.h>
// 从虚拟内存位置buf拷贝至多n个字节到fd文件的当前文件位置
// 成功返回写的字节数,错误返回-1
ssize_t write(int fd, const void *buf, size_t n);
- 一个从输入流到输出流的使用的示例如下:
3. rio封装下的读写文件
read()
和write()
都是系统调用的函数,因此通常不直接使用;- rio是csapp封装的鲁棒性较强的读写文件函数;
3.1 rio_readn函数
- 作用:至多拷贝n个字节到缓冲区,自动处理EOF值,仅返回成功的字节数;
- 无缓冲的输入输出函数,每次调用都直接使用
read()
函数执行系统调用; - 自动处理由中断引起的读取失败情况;
- 适用于网络和存储器之间的读写;
// 从fd文件的当前文件位置拷贝至多n个字节到虚拟内存位置usrbuf
ssize_t rio_readn(int fd, void *usrbuf, size_t n)
{
size_t nleft = n;
ssize_t nread;
char *bufp = usrbuf;
while (nleft > 0) {
if ((nread = read(fd, bufp, nleft)) < 0) {
// 如果是由中断引起的-1,则重新执行read()
if (errno == EINTR) /* Interrupted by sig handler return */
nread = 0; /* and call read() again */
else
return -1; /* errno set by read() */
}
else if (nread == 0)
break; /* EOF */
nleft -= nread;
bufp += nread;
}
return (n - nleft); /* return >= 0 */
}
3.2 rio_writen函数
- 作用:至多从缓冲区拷贝n个字节到文件,仅返回成功的字节数;
- 无缓冲的输入输出函数,每次调用都直接使用
write()
函数执行系统调用; - 自动处理由中断引起的读取失败情况;
- 适用于网络和存储器之间的读写;
// 从虚拟内存位置usrbuf拷贝至多n个字节到fd文件的当前文件位置
ssize_t rio_writen(int fd, void *usrbuf, size_t n)
{
size_t nleft = n;
ssize_t nwritten;
char *bufp = usrbuf;
while (nleft > 0) {
if ((nwritten = write(fd, bufp, nleft)) <= 0) {
// 如果是由中断引起的-1,则重新执行write()
if (errno == EINTR) /* Interrupted by sig handler return */
nwritten = 0; /* and call write() again */
else
return -1; /* errno set by write() */
}
nleft -= nwritten;
bufp += nwritten;
}
return n;
}
3.3 rio_read函数
- 带缓冲区的
read()
函数,并不是每次调用都直接使用read()
执行系统调用; - 自动处理由中断引起的读取失败情况;
- 需要借助一个外部的缓冲区结构
rio_t
,如下:
#define RIO_BUFSIZE 8192
typedef struct {
int rio_fd; /* Descriptor for this internal buf */
int rio_cnt; /* Unread bytes in internal buf */
char *rio_bufptr; /* Next unread byte in internal buf */
char rio_buf[RIO_BUFSIZE]; /* Internal buffer */
} rio_t;
// 将rio_t结构和fd绑定,并初始化rio_t的缓冲区
void rio_readinitb(rio_t *rp, int fd)
{
rp->rio_fd = fd;
rp->rio_cnt = 0;
rp->rio_bufptr = rp->rio_buf;
}
- 则
rio_read
函数的逻辑是:- 如果缓冲区为空,则从文件中读取一块至多为缓冲区大小的字节到缓冲区中,而不是仅读取字节n;
- 如果缓冲区不为空,则直接从缓冲区中往虚拟内存填入n字节的内容,而不再调用read();
- 实现如下:
// 从fd文件的当前文件位置拷贝至多n个字节到虚拟内存位置buf
// 成功返回读的字节数,若EOF则为0,错误返回-1
static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n)
{
int cnt;
while (rp->rio_cnt <= 0) { /* Refill if buf is empty */
// 从文件中读取一块至多为缓冲区大小的字节到缓冲区中,而不是仅读取字节n
rp->rio_cnt = read(rp->rio_fd, rp->rio_buf,
sizeof(rp->rio_buf));
if (rp->rio_cnt < 0) {
if (errno != EINTR) /* Interrupted by sig handler return */
return -1;
}
else if (rp->rio_cnt == 0) /* EOF */
return 0;
else
rp->rio_bufptr = rp->rio_buf; /* Reset buffer ptr */
}
/* Copy min(n, rp->rio_cnt) bytes from internal buf to user buf */
cnt = n;
if (rp->rio_cnt < n)
cnt = rp->rio_cnt;
// 直接从缓冲区中往虚拟内存填入内容,而不调用read()
memcpy(usrbuf, rp->rio_bufptr, cnt);
// 移动缓冲区指针
rp->rio_bufptr += cnt;
rp->rio_cnt -= cnt;
return cnt;
}
3.4 rio_readnb函数
- 带缓冲的
rio_readn()
函数; - 仅将系统调用的
read()
换成是带缓冲区的rio_read()
; - 可以处理缓冲区读空的清空:
- 如果缓冲区读空,则只会返回缓冲区剩下的内容到内存中;
- 但可能这些内容不足用户申请的
n
,而文件中仍然有未读的数据; - 所以
rio_read()
其实是不能达到原本read()
的效果的;
- 由于不用调用
read()
,因此也不用手动处理由中断引起的读取失败的情况;
// 从fd文件的当前文件位置拷贝至多n个字节到虚拟内存位置usrbuf
// 成功返回读的字节数,错误返回-1
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n)
{
size_t nleft = n;
ssize_t nread;
char *bufp = usrbuf;
// 用循环可以处理缓冲区读空的情况
while (nleft > 0) {
if ((nread = rio_read(rp, bufp, nleft)) < 0)
return -1; /* errno set by read() */
else if (nread == 0)
break; /* EOF */
// 移动内存指针
nleft -= nread;
bufp += nread;
}
return (n - nleft); /* return >= 0 */
}
3.5 rio_readlineb函数
- 作用:带缓冲区的逐行读取文件;
- 调用
rio_read()
函数,不直接调用系统调用的read()
; - 由于不用调用
read()
,因此也不用手动处理由中断引起的读取失败的情况;
// 从fd文件的当前文件位置拷贝至多n个字节到虚拟内存位置usrbuf,如果有换行符则提前返回
// 成功返回读的字节数,错误返回-1
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen)
{
int n, rc;
char c, *bufp = usrbuf;
for (n = 1; n < maxlen; n++) {
// 每次仅读取1字节
if ((rc = rio_read(rp, &c, 1)) == 1) {
*bufp++ = c;
if (c == '\n') {
n++;
break;
}
}
else if (rc == 0) {
if (n == 1)
return 0; /* EOF, no data read */
else
break; /* EOF, some data was read */
}
else
return -1; /* Error */
}
*bufp = 0;
return n - 1;
}
4. 读取文件元数据
4.1 stat函数
- 作用:用文件名字符串获取文件的元数据信息;
#include <unistd.h>
#include <sys/stat.h>
// 成功返回0,错误返回-1
int stat(const char *filename, struct stat *buf);
4.2 fstat函数
- 作用:用文件描述符串获取文件的元数据信息;
#include <unistd.h>
#include <sys/stat.h>
// 成功返回0,错误返回-1
int fstat(int fd, struct stat *buf);
struct stat
的定义如下:
- 其中:
st_size
:文件的字节数;st_mode
:文件的访问许可位和文件类型,如下:- 普通文件:二进制或文本文件,可用
S_ISREG(stat.st_mode)
查询; - 目录文件:包含其他文件的信息的文件,可用
S_ISDIR(stat.st_mode)
查询; - 套接字文件:用来通过网络与其他进程通信的文件,可用
S_ISSOCK(stat.st_mode)
查询;
- 普通文件:二进制或文本文件,可用
5. 共享文件
5.1 打开文件的内核数据结构
- 打开文件的内核数据结构包括:
- (1) 描述符表:记录每个进程打开的文件表,并以文件描述符的形式记录;
- (2) 文件表:进程每打开一次文件(调用
open()
),就会在所有进程共享的文件表中插入一个项,记录当前打开文件的:- 读写文件位置;
- 引用计数(一般是父子进程才会出现重复计数);
- 指向V-node表的指针;
- (3) V-node表:在所有进程共享的V-node表中,记录文件的
stat
结构信息;
5.2 共享文件的情况
-
(1) 在同一进程中多次打开同名文件,则:
- 会创建多个文件表表项,分别记录不同的文件读写位置;
- 但V-node表是共享的;
-
(2) 在父进程中创建子进程,则:
- 子进程也共享父进程所有已打开的文件,文件表中对应表项引用计数+1;
- 仅当所有的引用计数为0时,文件表中的表项才会被内核删除;
6. I/O重定向
6.1 dup和dup2函数
- 作用:提供I/O重定向功能;
- 总是返回可用最小的文件描述符;
#include <unistd.h>
// 返回一个和oldfd指向相同的文件描述符
int dup(int oldfd);
// 将newfd重定向到oldfd,则它们均表示同一个打开的文件
// 成功重定向则返回文件描述符,错误返回-1
int dup2(int oldfd, int newfd);
- 重定向的过程示意图如下:
7. 标准I/O库
- 提供标准的高级I/O封装,相当于是更加标准化和鲁棒化的rio封装实现;
- 适用于本地(包括磁盘和终端)的I/O操作,但不适用于网络应用的I/O操作;
#include <stdio.h>
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
-
一些常用的函数如下:
fopen()
;fclose()
;fread()
;fwrite()
;fgets()
;fputs()
;scanf()
;printf()
;
-
一些总结:
十二、第十二章 网络编程
- 本章除了参考《深入理解计算机系统》的第十二章外,还参考了:
- 《Linux高性能服务器编程》;
1. IP
1.1 IP地址结构
struct in_addr {
unsigned int s_addr; // 网络字节序(大端法)
};
1.2 相关函数
1.2.1 网络字节顺序和主机字节顺序的转换
#include <netinet/in.h>
// 返回网络字节顺序的值
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostshort);
// 返回主机字节顺序的值
unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);
htonl()
和ntohl()
的一种C++实现如下:- 其实它们的实现是相同的;
- 作用都是将每个字节(8bit)从后到前翻转;
- 由于是翻转
uint32_t
,所以只需要处理四段字节; - 使用的是移位运算、与运算和或运算;
0xff
是无符号16进制数,对应的二进制是11111111
;
uint32_t htonl(uint32_t hostlong) {
return ((hostlong & 0xffu) << 24) |
((hostlong >> 8) & 0xffu) << 16) |
((hostlong >> 16) & 0xffu) << 8) |
((hostlong >> 24) & 0xffu)
}
uint32_t ntohl(uint32_t netlong) {
return ((netlong & 0xffu) << 24) |
((netlong >> 8) & 0xffu) << 16) |
((netlong >> 16) & 0xffu) << 8) |
((netlong >> 24) & 0xffu)
}
1.2.2 IP地址和点分十进制串之间的转换
#include <arpa/inet.h>
// 将IPv4点分十进制字符串转到in_addr,成功返回1,错误返回0
int inet_aton(const char *cp, struct in_addr *inp);
// 返回in_addr的IPv4点分十进制字符串指针
char* inet_ntoa(struct in_addr in);
/**
/* 下面的两个函数既可以转换IPv4又可以转换IPv6
/* 所以传入的地址结构指针为void*类型,还要加上family和len
*/
// 将IPv4或者IPv6点分十进制字符串转到in_addr,成功返回1,错误返回0或者-1
int inet_pton(int family, const char *strptr, void *addrptr);
// 返回in_addr的IPv4或者IPv6点分十进制字符串指针
const char * inet_ntop(int family, const void *addrptr, char *strptr, size_t len);
inet_aton()
和inet_ntoa()
的一种C++实现如下:- 由于ipv4地址只有32bit,因此也是分成4段来处理;
- 点分十进制转
uint32_t
需要检查它的有效性,即:- 是否在[0, 255]的范围内;
- 是不是有且仅有4段;
uint32_t
转点分十进制不需要检查它的有效性;
int inet_aton(const char* cp, struct in_addr* inp) {
unsigned int a, b, c, d;
char e;
if (sscanf(cp, "%u.%u.%u.%u%c", &a, &b, &c, &d, &e) != 4) {
return 0; // 格式错误,返回0
}
if (a > 255 || b > 255 || c > 255 || d > 255) {
return 0; // 十进制数值范围超出8位二进制数值表示范围,返回0
}
// 1. 使用网络字节顺序的大端法
// inp->s_addr = htonl((a << 24) | (b << 16) | (c << 8) | d);
// 2. 仍使用主机字节顺序的小端法
inp->s_addr = (a << 24) | (b << 16) | (c << 8) | d;
return 1; // 成功返回1
}
char *inet_ntoa(struct in_addr in) {
static char buf[INET_ADDRSTRLEN];
// 1. 使用主机字节顺序的小端法
// uint32_t addr = ntohl(in.s_addr);
// 2. 仍使用网络字节顺序的大端法
uint32_t addr = in.s_addr;
sprintf(buf, "%d.%d.%d.%d", (addr >> 24) & 0xff,
(addr >> 16) & 0xff,
(addr >> 8) & 0xff,
addr & 0xff);
return buf;
}
2. 套接字
- 套接字(socket)是网络连接的两端点之一;
2.1 套接字地址结构
2.1.1 通用套接字地址
- 通用套接字地址用于套接字接口函数的参数,如
connect()
、bind()
和accept()
; - 结构如下:
- 一些说明如下:
sa_family_t
是unsigned short
类型,为地址簇,它和协议簇的对应关系如下:
sa_data
用于存放套接字地址,但14字节一般是只能放下TCP/IPv4的地址,如下:
- 为了解决地址放不下的情况,Linux定义了新的通用套接字地址结构,如下:
2.1.2 专用套接字地址
-
专用的套接字地址在使用套接字接口时直接被强制转换成通用套接字地址
sockaddr
类型; -
转换成通用套接字地址后,实际上只保留了协议簇类型和端口号的信息;
-
(1) 本地域协议簇套接字结构:
-
(2) TCP/IPv4协议簇套接字结构:
-
每个TCP/IPv4协议簇的套接字都有一个地址,包括:
- 32位的IP地址;
- 16位的端口号;
- 如
128.2.194.242:51213
;
-
结构如下:
-
(3) TCP/IPv6协议簇套接字结构:
2.2 套接字接口
- 即和套接字相关的一组函数;
- 可以用于创建网络引用;
- 基本的关系图如下:
2.2.1 socket函数
- 用于创建一个套接字描述符;
- 套接字描述符是一种类似于文件描述符的整型变量,这里可以将套接字也看作是一种文件;
#include <sys/types.h>
#include <sys/socket.h>
// 成功则返回套接字描述符,错误返回-1
int socket(int domain, int type, int protocol);
// 一个通常使用的例子如下
// AF_INET:使用因特网
// SOCK_STREAM:使用TCP协议传输
// SOCK_DGRAM:使用UDP协议传输
clientfd = socket(AF_INET, SOCK_STREAM, 0);
2.2.2 connect函数
- 客户端用于和服务器建立连接的函数;
#include <sys/socket.h>
// 尝试用sockfd和serv_addr套接字地址的服务器建立一个连接
// 一直阻塞,直到连接建立成功或者报错,成功返回0,错误返回-1
// addrlen = sizeof(sockaddr_in)
int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);
2.2.3 bind函数
- 服务器端用于绑定套接字描述符和套接字地址的函数;
#include <sys/socket.h>
// 将my_addr地址绑定到sockfd上,成功返回0,错误返回-1
int bind(int sockfd, struct sockaddr *my_addr, int addrlen);
2.2.4 listen函数
- 服务器端用于将一个主动套接字(默认)转换为一个监听套接字的函数;
- 同时也告诉内核当前的机器是作为服务器而不是客户端;
#include <sys/socket.h>
// 将sockfd转换为监听套接字描述符
// 允许的最大等待监听请求是backlog(通常设为LISTENQ = 1024)个
int listen(int sockfd, int backlog);
2.2.5 accept函数
- 服务器端用于监听来自客户端连接请求的函数;
- 监听描述符:用于监听,生命周期是服务器的整个生命周期;
- 已连接描述符:用于连接,生命周期仅为连接过程;
- 两者分开有利于并发服务器的实现;
- 两种描述符的作用如下:
- 两种描述符均可以作为参数传入
read()
和write()
函数中,实现类文件的读写操作,从而完成网络信息的传输;
#include <sys/socket.h>
// 成功返回已连接描述符
int accept(int listenfd, struct sockaddr *addr, int *addrlen);
2.2.6 shutdown函数
- 建立连接后的描述符实际上可以看作是文件类型;
- 因此可以调用文件I/O中的
close()
函数来关闭连接; close()
是一个系统调用函数;close()
不是套接字专有的函数;close()
将socket的引用计数减一,如果为0,则关闭连接;
#include <unistd.h>
int close(int fd);
shutdown()
函数是专用的套接字接口;- 用于强行关闭连接,无论它们的引用计数是多少;
#include <sys/socket.h>
// 以howto的方式关闭sockfd上的连接
// 成功返回0,错误返回-1
int shutdown(int sockfd, int howto);
- 其中,
howto
的方式包括:
3. HTTP
3.1 URL
- 即通用资源定位符;
- 结构:
http://
;- 前缀:服务器主机域名或者IP地址及端口(可选);
- 后缀:文件在服务器上的相对位置;
?
:(可选)后面跟传入文件的参数;&
:(可选)用于分隔多个参数;
- 一个例子为:
3.2 HTTP请求
- 结构:
method URI version
; - 其中:
mothod
:包括GET
、POST
、OPTIONS
、PUT
、DELETE
、HEAD
和TRACE
;- URI:统一资源标识符(注意和URL不同,仅为URL的后缀部分);
version
:HTTP/1.0
或者HTTP/1.1
;
3.3 HTTP响应
- 结构:
version status_code status_message
; - 其中:
version
:HTTP/1.0
或者HTTP/1.1
;status_code
:三位整数状态码,表明对请求的处理状态;status_message
:状态信息,给出状态对应的描述;
4. 数据读写函数
4.1 TCP数据读写
- 系统调用,代替
read()
和write()
; - 也可以用于管道的读写,因为管道的读写也是流式读写的;
#include <sys/types.h>
#include <sys/socket.h>
// 从sockfd中读取数据到buf中,最多读len长度
// 成功返回读取的字节数,失败返回-1
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
// 将buf中至多len长度数据写入到sockfd中
// 成功返回写入的字节数,失败返回-1
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
flags
参数说明如下:
4.2 UDP数据读写
- 系统调用,代替
read()
和write()
; - 由于是不可靠的连接,所以相比于TCP需要在读时增加读的发送方socket地址,在写时增加写的接收方socket地址;
#include <sys/types.h>
#include <sys/socket.h>
// 从sockfd中读取数据到buf中,最多读len长度
// 成功返回读取的字节数,失败返回-1
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
// 将buf中至多len长度数据写入到sockfd中
// 成功返回写入的字节数,失败返回-1
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
4.3 通用数据读写
- 既可以读写TCP数据,也可以读写UDP数据;
#include <sys/socket.h>
// 从sockfd中接收信息
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
// 向sockfd中发送信息
ssize_t sendmsg(int sockfd, struct msghdr *msg, int flags);
- 其中,
msghdr
结构如下:
5. 网络信息函数
5.1 获取DNS信息
- 获得主机条目信息:
#include <netdb.h>
// 成功返回指针,错误返回NULL
struct hostent* gethostbyname(const char *name);
// 成功返回指针,错误返回NULL
struct hostent* gethostbyaddr(const char *addr, int len, 0);
- 注意:
localhost
作为本地域名,映射得到的地址是本地环回地址127.0.0.1
;
5.2 获取服务信息
#include <netdb.h>
struct servent* getservbyname(const char* name, const char* proto);
struct servent* getservbyport(int port, const char* proto);
- 其中,返回的
servent
结构体如下:
5.3 获取多个信息
-
getaddrinfo()
内部调用:gethostbyname()
;getservbyname()
;
-
getnameinfo()
内部调用:gethostbyaddr()
;getservbyport()
;
#include <netdb.h>
// 可以通过hostname和service获取IP地址和端口号
int getaddrinfo(const char *hostname, const char *service, const struct addrinfo *hints, struct addrinfo **result);
// 通过套接字地址同时获得主机名和服务名字符串
int getnameinfo(const struct sockaddr *sockaddr, socklen_t addrlen, char *host, socklen_t hostlen, char *serv, socklen_t servlen, int flags);
addrinfo
结构体如下:
6. 高级I/O函数
6.1 pipe函数
-
作用:
- 创建一个管道,以实现进程间通信;
-
使用:
- 创建由两个文件描述符作为两端点的管道;
- 管道大小默认是65536(2^16)字节;
#include <unistd.h>
// 创建以fd[0]和fd[1]作为两端的管道
// f[0]从管道读出数据,f[1]从管道写入数据
int pipe(int fd[2]);
- 也可以直接用
socketpair()
直接创建双向管道:
#include <sys/types.h>
#include <sys/socket.h>
// 成功返回0,失败返回-1
// domain = AF_UNIX
int socketpair(int domain, int type, int protocol, int fd[2]);
6.2 readv和writev函数
- 作用:
readv()
将数据从文件描述符中读到分散的内存块内,即分散读;writev()
将多块分散的内存数据一并写入文件描述符中,即分散写;
#include <sys/uio.h>
// 从长度为count的分散内存块vector中读或者写
ssize_t readv(int fd, const struct iovec* vector, int count);
ssize_t writev(int fd, const struct iovec *vector, int count);
iovec
结构体如下:
6.3 sendfile函数
- 作用:
- 直接在两个文件描述符之间传递数据;
- 完全在内核中操作,可以避免在内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,也称零拷贝;
- 但仍然需要在内核缓冲区和socket缓冲区之间进行一次拷贝(发送在内核空间),只是减少了一次拷贝和内核态到用户态之间的上下文切换过程;
#include <sys/sendfile.h>
// 从in_fd文件中的offset位置读count字节写到out_fd中
// out_fd必须是一个socket,in_fd必须是真实的文件
ssize_t sendfile (int out_fd, int in_fd, off_t *offset, size_t count);
- 传统的I/O过程,如
read
和write
函数的示意图如下:- (1) 从磁盘拷贝到内核缓冲区:DMA拷贝;
- (2) 从内核缓冲区拷贝到用户缓冲区:CPU拷贝;
- (3) 从用户缓冲区拷贝到socket缓冲区:CPU拷贝;
- (4) 从socket缓冲区拷贝到网卡:DMA拷贝;
- 而且要经过四次内核态和用户态的切换(
read
和write
函数各两次);
sendfile
函数零拷贝的过程的示意图如下:- (1) 从磁盘拷贝到内核缓冲区:DMA拷贝;
- (2) 从内核缓冲区拷贝到socket缓冲区:CPU拷贝;
- (3) 从socket缓冲区拷贝到网卡:DMA拷贝;
- 只需要经过两次内核态和用户态的切换(
sendfile
函数两次);
6.4 splice函数
- 作用:
- 在两个文件描述符之间移动数据;
- 也是零拷贝操作;
#include <fcntl.h>
// 从fd_in的off_in处读取len字节数据到fd_out的off_out处
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
flag
参数如下:
6.5 tee函数
- 作用:
- 在两个文件描述符之间复制数据;
- 也是零拷贝操作;
#include <fcntl.h>
// 从fd_in读复制len字节数据到fd_out
// fd_in和fd_out必须是管道文件描述符
ssize_t splice(int fd_in,int fd_out, size_t len, unsigned int flags);
6.6 fcntl函数
- 作用:
- 提供对文件描述符的各种操作;
#include <fcntl.h>
int fcntl(int fd, int cmd, ...);
- 一些常用操作及其参数如下:
7. I/O复用
- 最常使用的I/O通知机制;
- 过程:
- 应用程序通过I/O复用函数向内核注册一组事件;
- 内核通过I/O复用函数将其中就绪的事件通知给应用程序;
- 最后应用程序再使用这些事件(也就是文件描述符)来完成对应的功能;
- I/O复用函数本身是阻塞的,但它们有监听多个I/O事件的能力;
- 也就是说应用程序不会在等待某一socket的过程中阻塞,而是同时等待多个socket,哪个可用就调用哪个socket对应的函数功能,从而达到复用的效果;
- 比如说程序既想
accept()
一个新的连接,又想send()
数据到多个已连接的socket,又想从多个已连接的socket中recv()
数据,又想与本地的文件read()
和write()
数据,就看哪个文件描述符已经就绪,就相对应地调用该文件描述符执行功能;
7.1 I/O模型
- 本节除了参考《深入理解计算机系统》的第十二章外,还参考了:
- 《深入理解RPC框架原理与实现》;
- 《UNIX网络编程:卷1》;
7.1.1 阻塞和非阻塞
- 阻塞:用户进程发起I/O操作后,需要等待I/O操作完成才能继续运行;
- 既需要等待内核将数据准备好,又要等待内核将数据从内核空间拷贝到用户空间;
- 非阻塞:用户进程发起I/O操作后,不需要等待I/O操作完成就可以继续运行;
- 不需要等待内核将数据准备好的这个过程,但如果内核已经准备好了,则需要等待内核将数据从内核空间拷贝到用户空间;
7.1.2 同步和异步
- 同步:用户进程需要等待内核从内核空间拷贝数据到用户空间;
- 只有同步I/O才会有阻塞和非阻塞的问题,因为用户进程最后还需要等待内核拷贝数据回来;
- 异步:用户进程不需要等待内核从内核空间拷贝数据到用户空间;
- 由内核直接将数据拷贝到用户空间,再通知用户进程I/O已经完成;
7.1.3 五种I/O模型
- (1) 阻塞I/O:
- 即阻塞的文件描述符;
- 用户进程发起I/O操作后,内核进入数据准备阶段,如果不能立即完成,也就是I/O事件还没有发生,则会被操作系统阻塞挂起,等待I/O事件发生再使用该阻塞I/O;
- 如默认的socket;
- (2) 非阻塞I/O:
- 即非阻塞的文件描述符;
- 用户进程发起I/O操作后,不管I/O事件是否发生,都不会等待立即返回;
- 如果I/O事件没有发生(也就是目标读写功能未完成),则返回-1,和出错的情况一样;
- 因此,只有在非阻塞I/O的 I/O事件发生之后再使用非阻塞I/O,才能使它成功执行;
- 这个通知的机制可以由I/O复用或者SIGIO信号完成;
- 可以通过以下设置非阻塞的文件描述符,使用的是
O_NONBLOCK
选项:
// 获得旧选项
int old_option = fcntl(fd, F_GETFL);
// 设置新选项,将文件描述符设置为非阻塞
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
- (3) 多路复用I/O:
- 由一个非阻塞的epoll(或者select和poll)负责轮询各个文件描述符,轮询的整个过程是阻塞的,直到有事件就绪;
- 如果有文件描述符就绪,也就是内核已完成数据准备,则通知对应的用户进程执行,这样用户进程虽然本质上仍然是阻塞的,但实际上并不会被阻塞和挂起;
- (4) 信号驱动I/O:
- 用户进程先向内核注册信号处理函数,表明需要读写的文件描述符,然后可以继续执行;
- 内核检测到事件就绪后,向用户进程发送信号,并调用信号处理函数完成I/O执行;
- (5) 异步I/O:
- 用户进程向内核发起I/O操作,内核准备好数据后,直接将数据拷贝到用户空间中,再通知用户进程;
- 用户进程无需等待内核拷贝数据;
7.1.4 I/O模型对比
-
(1) 同步I/O模型:导致请求进程阻塞,直到I/O操作完成;
- 包括:
- 阻塞式I/O;
- 非阻塞式I/O;
- I/O复用;
- 信号驱动I/O;
- 有相同的第二阶段,即:
- 在数据从内核复制到调用者的缓冲区期间,进程阻塞于recvfrom调用;
- 包括:
-
(2) 异步I/O模型:不导致请求进程阻塞;
7.2 套接字就绪条件
- 确定在网络编程的什么情况下,文件描述符就被认为是可读、可写和出现异常;
- 这样I/O复用函数才能作出对应通知;
7.2.1 套接字可读
7.2.2 套接字可写
7.2.3 套接字异常
- socket上接收到带外数据;
- 带外数据:
- 具有更高传输优先级的数据;
- 即使传输队列中已经有数据,带外数据也可以先行传输;
- TCP才支持,UDP不支持;
- 但已经弃用了;
- 参考:TCP-带外数据(紧急数据);
7.3 select系统调用
-
作用:
- 在一段指定的时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件;
-
使用:
nfds
:指定被监听的文件描述符总数,通常设置为select监听的所有文件描述符中的最大值加1;readfds
:可读事件对应的文件描述符集合;writefds
:可写事件对应的文件描述符集合;exceptfds
:异常事件对应的文件描述符集合;timeout
:设定的超时返回时间,但不能完全信任该值,因为出错就会立即返回;- 系统调用不是线程安全的:
- 函数定义如下:
#include <sys/select.h>
// 成功返回就绪的文件描述符总数,超时返回0,错误返回-1
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
fd_set
结构体如下:- 其实就是一个
long int
类型的__fds_bits[]
数组;- 但实际上是一个bitmap,每个文件描述符只占1 bit;
NFDBITS
是计算一个long int
类型占多少bit;__FD_SETSIZE
就是最大文件描述符数量;- 实际的
__fds_bits[]
数组刚好为__FD_SETSIZE
个bits,也就是说一个bit对应一个文件描述符;
- 其实就是一个
fd_set
结构示意图如下:
- 可以用下面的宏来设置
fd_set
中的__fds_bits[]
数组的值:
#include <sys/select.h>
// 将fd_set所有位设置为0
FD_ZERO(fd_set *fdset);
// 设置fd_set的第fd位
FD_SET(int fd, fd_set *fdset);
// 清除fd_set的第fd位
FD_CLR(int fd, fd_set *fdset);
// 测试fd_set的第fd位是否被设置为1
int FD_ISSET(int fd, fd_set *fdset);
timeval
结构体如下:
- 一个例子如下:
- 先定义一个
fd_set
结构; - 在该
fd_set
结构中设置要监听的文件描述符; - 把该结构作为参数传入
select()
; - 在经过内核修改的
fd_set
结构中看当前文件描述符是否已经准备好; - 在调用套接字接口读写该文件描述符;
- 先定义一个
7.4 poll系统调用
-
作用:
- 在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者;
- 相当于是以文件描述符为单位注册监听,可以节省监听函数传入的参数空间;
- 而且不需要每次监听都重新设置监听的事件,因为内核不直接修改事件注册信息;
-
使用:
fds
:一个pollfd
结构体数组,指定注册的文件描述符和我们关心的发生在文件描述符上的事件;nfds
:注册的监听事件数量;timeout
:超时值,单位是毫秒,-1则永不超时;- 系统调用不是线程安全的;
#include <poll.h>
// 成功返回就绪的文件描述符总数,超时返回0,错误返回-1
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
pollfd
结构体如下:
events
可选事件如下,这些事件可以按位与运算进行组合:
7.5 epoll系统调用
-
作用:
- 专门针对Linux的I/O复用函数;
- 使用一系列函数实现I/O复用;
- 省去了显式定义和操作文件操作符集合(如
fd_set
和pollfd[]
)的过程; - 相比select和poll,I/O查询效率更高;
-
使用:
- (1)
epoll_create()
:创建一个放在内核的事件表,记录应用程序需要监听的文件描述符; - (2)
epoll_ctl()
:操作epoll的内核事件表,例如增加或者修改在事件表上注册的监听事件;epfd
:内核事件表文件描述符;op
:指定操作;fd
:操作的文件描述符;event
:监听事件的就绪条件;
- (3)
epoll_wait()
:在一段时间内等待一组文件描述符上的事件;events
:返回的就绪事件数组;maxevents
:最多监听的事件数;timeout
:超时时间;
- 所有系统调用都是线程安全的:
- 通过自旋锁保护就绪的队列;
- 通过互斥锁保护红黑树;
- (1)
#include <sys/epoll.h>
// 创建一个size大小的事件表(size不起作用)
// 返回事件表的文件描述符,供其他函数调用,错误返回-1
int epoll_create(int size);
// 在事件表上以op操作fd,为它设置就绪条件event
// 成功返回0,失败返回-1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 监听maxevents个事件,超时timeout,返回就绪事件数组events
// 成功返回就绪的文件描述符数量,错误返回-1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_ctl()
的op
参数类型如下:
-
epoll_ctl()
的event
结构体如下:events
:描述事件类型,和poll的事件类型基本相同,需要增加E前缀;- 默认:LT(电平触发)模式:
- 监听到就绪事件并通知给应用程序后,应用程序可以不立即处理,下一次会再通知尚未处理完成的就绪事件;
- 如果事件一直就绪,比如文件描述符一直可读或者可写,则会一直通知给应用程序,直到文件描述符不可写或者不可读;
- EPOLLET事件:ET(边缘触发)模式:
- 监听到就绪事件并通知给应用程序后,应用程序应该立即处理,下一次不再通知;
- 仅发生文件描述符的状态变化时才通知应用程序,比如从不可读状态转为可读状态,或者从不可写状态转为可写状态,也就是状态发生变化且变为就绪时仅通知一次;
- EPOLLONESHOT事件:最多触发注册的可读、可写和异常事件之一,且只触发一次,这样可以保证无论何时均仅一个线程处理该socket;
- 默认:LT(电平触发)模式:
data
:存储用户数据;
-
常用的事件及其对应的常量如下:
EPOLLIN : 1。表示文件描述符可以进行读操作。
EPOLLOUT : 4。表示文件描述符可以进行写操作。
EPOLLRDHUP : 8192。表示连接的远程端已经关闭或半关闭了连接。
EPOLLPRI : 2。表示带外数据可读取(仅限于socket)。
EPOLLERR : 8。表示文件描述符发生错误,建议关闭它。例如,连接被重置。
EPOLLHUP : 16。表示文件描述符挂起(hangup)事件。通常表示对端主动关闭连接或发生了异常错误。
EPOLLET : -2147483648。表示以边缘触发模式工作,即只有在状态变化时才发出通知。
EPOLLONESHOT : 1073741824。表示在完整的事件处理序列中仅处理一次事件,如果想要再次关注这个事件,则需要使用epoll_ctl()重新注册该事件。
EPOLLHUP
触发的条件:- 当一个已经连接的套接字收到一个 RST 数据包(重置连接,用于异常时强制关闭连接)时,会触发
EPOLLERR
和EPOLLHUP
事件; - 当对端关闭连接时,会触发
EPOLLIN
和EPOLLHUP
事件; - 当套接字本身关闭时,会触发
EPOLLHUP
事件;
- 当一个已经连接的套接字收到一个 RST 数据包(重置连接,用于异常时强制关闭连接)时,会触发
EPOLLRDHUP
触发的条件:- 当收到
FIN
数据包时,此时仍可以继续接收余下的数据,不用立刻关闭socket;
- 当收到
EPOLLERR
触发的条件:- 对于一个监听套接字而言(即调用
listen()
函数后返回的套接字),当其上存在连接请求丢失/终止、连接已经到达队列的底部,以及插入一个与已知客户端地址相同的客户端等错误时,就会触发EPOLLERR
事件; - 对于一个普通套接字而言(即调用
connect()
或accept()
后返回的套接字),当其发生错误时,就会触发EPOLLERR
事件。常见的错误包括连接被重置、连接超时、连接被拒等; - 对于一个管道、FIFO 或者字符设备文件而言,当其上发生任何错误时,都会触发
EPOLLERR
事件; - 对于一个块设备文件而言,只有在出现硬件错误时才会触发
EPOLLERR
事件;
- 对于一个监听套接字而言(即调用
7.6 三种I/O复用函数的比较
- select、poll和epoll之间的比较如下,参考博客:
- Select、Poll、Epoll详解;
- 彻底理解 IO 多路复用实现机制;
- 从IO模型到协程(三) 多路复用之select、poll和epoll;
- 注意:
- 底层数据结构:
- select是位图,每个文件描述符占1bit;
- poll是数组,每个文件描述符是一个
pollfd
结构; - epoll是红黑树,每个文件描述符是红黑树的一个节点;
- 有文件描述符就绪时,把它放到双向链表中,待
epoll_wait
轮询;
- 有文件描述符就绪时,把它放到双向链表中,待
- 最大连接数是由最大支持的文件描述符数量决定的;
- select默认是
1024
; - poll受限于系统资源;
- epoll受限于系统资源,在Ubuntu上默认是
110592
;
- select默认是
- 底层数据结构:
十三、第十三章 并发编程
- 本章除了参考《深入理解计算机系统》的第十三章外,主要参考了:
- 《Linux高性能服务器编程》;
1. 基于进程的并发编程
1.1 多进程相关的系统调用
-
这里主要简单回顾一下和进程相关的系统调用,详细可以参看八.2 进程;
-
(1) 创建新进程:
fork()
- 每次调用返回两次,一次在父进程中,一次在子进程中;
- 父进程中打开的文件描述符默认在子进程中也是打开的,引用计数+1;
- 父进程的用户根目录、当前工作目录的引用计数+1;
-
(2) 重新加载程序:
execve()
- 用新程序替换当前进程映像;
- 不会关闭原程序打开的文件描述符;
- 有一系列不同参数的函数重载:
- (3) 处理僵尸进程:
waitpid()
- 父进程阻塞进程,直到子进程运行结束为止;
- 可以避免子进程成为僵尸进程;
1.2 匿名管道通信:父子进程
-
作用:
- 系统调用;
- 建立父进程和子进程之间的通信管道;
-
使用:
- 在父进程用
pipe()
建立两个管道; - 然后用
fork()
创建子进程,继承两个管道; - 最后在父子进程中各对应关闭管道的一端;
- 由于一个管道只能单向通信,所以要建立两个管道;
- 建立的其中一个单向管道如下:
- 在父进程用
- 当然也可以用
socketpair
直接建立一个全双工管道;
1.3 命名管道通信:同一用户下的任意两进程
-
作用:
- 系统调用;
- 在任意两个进程之间进行管道通信;
- 但仅适用于同一用户下的进程通信,因为它们的文件权限相同;
-
使用:
mkfifo()
:使用路径创建一个命名管道;open()
:通过路径打开一个命名管道,并返回对应的fd;
#include <sys/types.h>
#include <sys/stat.h>
// 指定命名管道路径pathname,文件权限为mode
int mkfifo(const char *pathname, mode_t mode);
#include <fcntl.h>
// 打开pathname对应的命名管道,返回fd
int open(const char *pathname, int flags);
#include <unistd.h>
// 通过fd读写管道内容
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, void *buf, size_t count);
- 一个例子如下:
char *fifo = "/tmp/myfifo";
/* 创建命名管道 */
if (mkfifo(fifo, 0666) == -1)
{
perror("mkfifo");
exit(1);
}
int fd;
/* 打开命名管道 */
if ((fd = open(fifo, O_WRONLY)) == -1)
{
perror("open");
exit(1);
}
/* 写入数据到管道 */
write(fd, "Hello, world!", 13);
char buf[256];
/* 从管道中读取数据 */
read(fd, buf, sizeof(buf));
1.4 信号量通信:多进程的临界区控制
-
作用:
- 系统调用;
- 唤醒某个进程被挂起的临界区代码继续执行;
-
使用:
semget()
:创建一个新的信号量集合,或者获取一个已有的信号量集合;semop()
:改变信号量的值;semctl()
:允许调用者对信号量进行直接控制;
#include <sys/sem.h>
// 通过key唯一标识创建含nums_sems个信号量的信号量集合
// 成功返回信号量集标识符,错误返回-1
int semget(key_t key, int num_sems, int sem_flags);
// 通过信号量集标识符sem_id,执行num_sem_ops个修改操作sem_ops
// 成功返回0,失败返回-1
int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);
// 在sem_id信号量集中,以command命令操作编号为sem_num的信号量
int semctl(int sem_id, int sem_num, int command, ...);
- 在内核中创建的信号量集合结构体
semid_ds
如下:
sembuf
结构体如下:sem_num
:信号量集中信号量的编号;sem_op
:指定操作类型;sem_flag
:- IPC_NOWAIT:无论信号量操作是否成功,
semop()
都立即返回; - SEM_UNDO:当进程退出时取消正在进行的
semop()
操作;
- IPC_NOWAIT:无论信号量操作是否成功,
command
参数可选值和对应返回如下:
- 一个例子如下:
1.5 共享内存通信:最高效的进程通信
-
作用:
- 系统调用;
- 提供了一种最高效的进程间通信方法;
- 因为它不涉及进程间的数据传输;
-
使用:
shmget()
:创建一段新的共享内存,或者获取一段已经存在的共享内存;shmat()
:将共享内存关联到进程的虚拟地址空间中;shmdt()
:将共享内存从进程的虚拟地址空间中剥离;shmctl()
:控制共享内存的某些属性;shm_open()
:利用mmap()
创建一个进程无关的POSIX共享内存对象,使用方法和open()
完全相同;shm_unlink()
:关闭POSIX共享内存对象;
#include <sys/shm.h>
// 获取用key唯一表示的一个共享内存,大小是size
// 成功返回共享内存的标识符,失败返回-1
int shmget(key_t key, size_t size, int shmflg);
// 将shm_id标识的共享内存绑定到shm_addr地址空间
// 成功返回绑定到的地址,失败返回-1
void* shmat(int shm_id, const void *shm_addr, int shmflg);
// 将绑定到shm_addr上的共享内存从地址空间中剥离
// 成功返回0,错误返回-1
int shmdt(const void *shm_addr);
// 以command命令控制shm_id标识的共享内存
int shmctl(int shm_id, int command, struct shmid_ds *buf);
/*以下是共享内存的POSIX方法*/
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
// 以name打开一个共享内存对象
int shm_open(const char *name, int oflag, mode_t mode);
// 关闭name共享内存对象
int shm_unlink(const char *name);
command
参数可选值和返回值如下:
1.6 消息队列通信:两进程传二进制数据
-
作用:
- 系统调用;
- 是一种在两个进程之间传递二进制块数据的简单有效方式;
-
使用:
msgget()
:创建一个消息队列,或者获取一个已有的消息队列;msgsnd()
:向消息队列发送消息;msgrcv()
:从消息队列从接收消息;msgctl()
:控制消息队列的某些属性;
#include <sys/msg.h>
// 以唯一标识key创建或者获取消息队列
// 成功返回消息队列标识符,错误返回-1
int msgget(key_t key, int msgflg);
// 往msqid消息队列上发送msg_ptr指向的消息
// 成功返回0,错误返回-1
int msgsnd(int msqid, const void *msg_ptr, size_t msg_sz, int msgflg);
// 从msqid消息队列上接收消息到msg_ptr指向的结构
int msgrcv(int msqid, void *msg_ptr, size_t msg_sz, long int msgtype, int msgflg);
//以command控制msqid消息队列
int msgctl(int msqid, int command, struct msqid_ds *buf);
- 创建消息队列后,关联的内核数据结构
msqid_ds
结构体如下:
msg_ptr
指向的消息类型结构体如下:
command
参数设置和返回值如下:
2. 进程池
- 预先创建的一组子进程;
- 进程池的数量应该和CPU数量差不多;
- 进程池的一般模型包括:
- 选择算法:
- 主动选择:如随机选择或者轮流选择;
- 工作队列:通过工作队列被动选择;
- 通知机制:
- 管道通信;
- 一般结构如下:
- 选择算法:
2.1 两种高效的事件处理模式
- 可以参考:
- 9.3 高性能网络模式:Reactor 和 Proactor;
2.1.1 同步Reactor模式
-
同步Reactor模式:
- 由主线程发起I/O请求到I/O处理单元;
- 负责I/O处理的线程(I/O处理单元) 仅负责监听文件描述符上是否有事件发生;
- 如果有事件发生,将数据在内核缓冲区中准备好,并立即将该事件通知I/O逻辑单元;
- 数据读写和处理的过程由处理逻辑的线程(I/O逻辑单元) 负责处理(会阻塞),这里仍然是主线程负责;
-
Unix文件I/O函数均用的是同步模式,这里的I/O处理单元由内核充当,I/O逻辑单元由应用程序进程充当,将数据在内核缓冲区和进程的虚拟地址空间之间的拷贝过程是由内核负责的;
-
(1)
accept()
、read()
、write()
、recv()
和send()
等函数就是阻塞读写的同步Reactor模式;
-
使用Linux上的
epoll()
可以实现阻塞监听但(似乎是)非阻塞读写的同步Reactor模式; -
实际上,读写的过程仍然是阻塞的,只不过一切都已经让
epoll()
准备好了,所以马上就可以完成,因此未被阻塞;
-
-
处理网络I/O的时候可以利用I/O处理单元和内核分离的特性,用多路复用和进程池/线程池进一步封装和分离 主线程和I/O处理单元所负责的功能,从而降低主线程的负载,一个例子如下:
- 主线程/主进程仅调用
epoll()
监听和处理accept()
事件; read()
、write()
、recv()
和send()
等函数均交由子进程/子线程处理,之后再处理业务逻辑;
- 主线程/主进程仅调用
2.1.1.1 单Reactor单线程
- 模型的架构如下:
- 特点:
- 只有一个线程,
epoll
的监听就绪事件和所有业务逻辑的处理均在一个线程上执行;
- 只有一个线程,
- 不足:
- 不能处理高并发;
2.1.1.2 单Reactor多线程
- 模型的架构如下:
- 特点:
- Reactor线程负责监听
epoll
就绪事件和处理accept()
; - 线程池用于处理业务逻辑,如
read()
、write()
、recv()
和send()
等;
- Reactor线程负责监听
- 不足:
- 主线程既要监听连接(
listen_fd
)的就绪事件,又要监听读写(connect_fd
)的就绪事件; - 如果读写事件很多,则可能会阻塞了连接事件导致客户端请求连接超时;
- 主线程既要监听连接(
2.1.1.3 主从Reactor多线程模型
- 模型的架构如下:
-
特点:
- 主Reactor线程负责监听
epoll
的连接就绪事件和处理accept()
,也处理信号等; - 副Reactor线程负责监听
epoll
的读写就绪事件,可以有多个; - 线程池用于处理业务逻辑,如
read()
、write()
、recv()
和send()
等;
- 主Reactor线程负责监听
-
优势:
- 既可以及时响应连接请求,又可以处理大量的读写请求;
- 充分利用了线程池的资源,因为副Reactor线程也在线程池中;
- 可以动态扩容,因为副Reactor线程可以视情况增加;
-
因此,该模型是支持高并发的,推荐使用;
2.1.2 异步Proactor模式
- 异步Proactor模式:
- 由主线程发起I/O请求到I/O处理单元;
- I/O操作均由负责I/O处理的线程(I/O处理单元) 负责处理,处理完成后再通知I/O逻辑单元;
- 处理逻辑的线程(I/O逻辑单元) 仅负责处理业务逻辑(不会阻塞),这里仍然是主线程负责;
- Window下的I/O操作可以实现异步模式,由内核负责将数据拷贝到应用程序进程空间;
- 一个异步模式的网络服务器例子如下,这个例子在Unix上无法真正实现,因为Unix内核不支持真正的异步读写,但可以模拟实现:
- 在主线程/主进程中调用
epoll()
实现监听,然后在主线程中完成accept()
、read()
、write()
、recv()
和send()
等函数的调用,然后才通知子进程/子线程; - 子进程/子线程仅处理业务逻辑;
- 在主线程/主进程中调用
2.2 两种高效的并发模式
2.2.1 半同步/半异步模式
- 同步:程序完全按照代码的顺序执行;
- 异步:程序的执行需要由系统事件来驱动;
-
半同步/半异步模式:同时使用同步线程和异步线程;
- 异步线程用于处理I/O事件;
- 同步线程用于处理客户逻辑;
-
半同步/半反应堆模式:同时使用同步线程和异步线程;
- 仅增加了主线程和工作线程共享的请求队列;
- 事件处理方式是Reactor模式;
- 是半同步/半异步模式的变体;
2.2.2 领导者/追随者模式
- 多个工作线程轮流获得领导权;
- 获得领导权的线程负责监听I/O事件;
- 当检测到I/O事件时,领导者需要选出下一个领导者,然后成为追随者处理I/O事件;
3. 基于多线程的并发编程
3.1 线程存储器模型
-
一组并发线程运行在一个进程的上下文中;
-
每个线程都有它自己独立的线程上下文,包括:
- 线程ID、栈、栈指针、程序计数器、条件代码和通用目的寄存器值;
-
每个线程和其他线程共享整个虚拟存储器空间(也就是读写的位置相同),但不意味着它们共享各自的局部变量(不能看到别的线程读写的局部变量,即寄存器值);
-
因此,多线程默认共享的变量有:
- (1) 全局变量;
- (2) 静态局部变量;
- 即所有放在
.bss
和.data
段中的变量; - 这些变量最好是设置成原子变量,或者在每次使用时加互斥锁保护,否则极易出现问题;
- (3) 打开的文件描述符,因为是放在内核区中的文件描述符表;
-
其他的局部变量可以通过传参数到各个线程达到共享的目的;
-
以下特指在
std::thread
中的参数传递:- (1) 值传递,则在各个线程中有一个独立的变量拷贝;
- (2) 引用传递,实参不加
std::ref()
则是值传递,加std::ref()
则是引用传递; - (3) 指针传递,则在各个线程中有一个独立的指针拷贝,但它们均指向一个相同地址的变量;
- 特别注意,引用和指针传递时要考虑线程生命周期的问题,因为极有可能发生线程结束后资源销毁而另一个线程仍要求访问该线程资源的情况;
-
一个关于变量是否共享的例子如下:
- 全局变量
ptr
和静态局部变量cnt
在各个线程中是共享的,使用时唯一要考虑的是它们的作用域问题; - 主线程和子线程以及子线程之间的局部变量是不共享的,但可以通过传指针和引用的方式实现共享;
- 全局变量
3.2 线程安全的函数
- 线程安全即被多个并发线程反复调用时始终能产生正确的结果;
- 一些简单的介绍如下:
3.3 线程崩溃对其他线程的影响
-
线程拥有独立的栈空间,但不拥有独立的堆空间;
-
如果线程崩溃不会导致整个进程崩溃,则不会对其他线程有影响;
- (1) 发生运算错误,如除零;
- (2) 触发带有回调处理函数的信号;
-
导致整个进程崩溃的情况:
- (1) 调用了
std::exit(0)
函数; - (2) 触发了未处理的信号:
- 触发
SIGABRT
信号:抛出了未被捕获的异常; - 触发
SIGSEGV
信号:访问了内核空间的内存,访问了不存在的内存,向只读内存写入数据; - 以及触发其他类型的信号;
- 触发
- (3) 污染了其他线程可能访问的内存空间:
- 污染了各个线程的共享变量;
- 污染了静态变量或者全局变量;
- (1) 调用了
-
参考:
- https://www.jianshu.com/p/c2f983bf7586;
- https://www.zhihu.com/question/22397613;
- https://www.xianwaizhiyin.net/?p=1650;
3.4 基于std::thread的多线程编程
- 参考现代C++教程 笔记中的五、并行与并发部分,这里不再赘述;