深入浅出理解计算机核心知识系列【操作系统合集-进程篇】
本人志在持续更新计算机系统、计算机网络、C++语言的核心知识点的系列合集,以易懂、全面的方式讲解底层知识。对于正在准备面试八股的朋友来说,本系列涵盖了本人面试中遇到的所有考点以及许多相关拓展知识,读完后能帮助你从容面对大部分面试拷打;对于想要深入学习计算机知识的朋友来说,本系列比较系统地介绍了操作系统和网络等重点内容,也举了不少例子,大大有助于你从底层的视角去理解计算机系统。
先说明,本系列恐怕不是计算机小白或是想速通期末的朋友们的目标,它需要一定系统和语言基础,也并不是面向教材和考试要求去讲解,所以更适合那些实操过代码、了解一些计算机系统知识、并且想要深入底层和扎实基础的朋友们去耐心学习。如果你是这样的人,欢迎阅读该系列文章,并分享自己的理解或提出文章中的模糊、错误的地方(不排除有)。
想要阅读系列中其他内容或想要持续关注本系列更新可移步:https://github.com/feiyangyang11/Cpp-Core-CS-Interview-Guide.git。
进程
进程的基本定义
进程的经典定义就是,系统中正在运行的一个程序实例,或者说,操作系统对一个正在运行的程序的抽象
这里面有三个需要注意的关键词:
- 程序:硬盘上存储的静态文件,如main.exe、python.exe等
- 运行:程序被加载进内存,CPU 开始执行它的指令
- 抽象:操作系统给这个运行中的程序包装出一套“独立运行环境”,让它感觉自己独占 CPU、内存等资源
举例:你打开两个浏览器窗口,底层把浏览器程序加载到内存,运行出两个浏览器进程
为什么需要进程
CPU只有有限的核心,不可能让所有程序一直同时跑,所以就需要把每个程序包装成一套单独上下文(包含程序代码、数据、栈、程序计数器……)作为CPU调度的单位,也就是进程
所以进程至少承担了三件事:
- 资源分配单位
- CPU调度单位之一
- 程序的隔离边界
进程的组成部分
一个进程的上下文至少包括:
- 程序代码段
- 数据段(包含bss和data)
- 堆段
- 栈段
- CPU 上下文
- 打开的文件
- 虚拟地址空间
- 进程控制块 PCB
进程的用户态虚拟地址空间分区
低地址
0x00000000
│
│ 代码段 text:存放程序机器指令,只读、可执行
│
├────────────────────
│ 只读数据段 rodata:存放字符串常量、const 全局常量等
│
├────────────────────
│ 已初始化数据段 data:存放已初始化的全局变量、静态变量
│
├────────────────────
│ 未初始化数据段 bss:存放未初始化或初始化为 0 的全局变量、静态变量
│
├────────────────────
│ 堆 heap :malloc / new 申请的动态内存,向高地址增长 ↓
│
│
│ 共享库 / mmap 区域: 动态库、文件映射、匿名映射等
│
│
├────────────────────
│ 栈 stack:局部变量、函数参数、返回地址、保存的寄存器,向低地址增长 ↑
│
├────────────────────
│ 命令行参数、环境变量:argc / argv / envp 等
│
高地址
进程的完整虚拟地址分区
用户态虚拟地址是面试常考的部分,但除了用户态,进程还有内核态的虚拟地址空间,以下列出的是进程的完整虚拟地址空间:
虚拟地址从低到高
────────────────────────────────────────────
0x00000000
│
│ 用户空间 User Space
│ 每个进程相对独立
│
│ ┌────────────────────────────────────┐
│ │ NULL / 保留区 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ text 代码段 │
│ │ 程序机器指令 │
│ │ PC/RIP 的值通常指向这里 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ rodata 只读数据段 │
│ │ 字符串常量、只读常量 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ data 已初始化数据段 │
│ │ 已初始化全局变量、静态变量 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ bss 未初始化数据段 │
│ │ 未初始化或置 0 的全局/静态变量 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ heap 堆 │
│ │ 用 malloc/new 动态申请内存 │
│ │ 向高地址增长 ↓ │
│ └────────────────────────────────────┘
│
│ 空闲区域
│
│ ┌────────────────────────────────────┐
│ │ mmap 映射区 │
│ │ 动态库、文件映射、匿名映射、共享内存 │
│ └────────────────────────────────────┘
│
│ 空闲区域
│
│ ┌────────────────────────────────────┐
│ │ user stack 用户栈 │
│ │ 局部变量、函数参数、返回地址、栈帧 │
│ │ 向低地址增长 ↑ │
│ │ RSP/ESP 用户态时通常指向这里 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ argv / envp / auxv │
│ │ 命令行参数、环境变量、辅助向量 │
│ └────────────────────────────────────┘
│
├────────────────────────────────────────────
│
│ 内核空间 Kernel Space
│ 普通用户程序不能直接访问
│
│ ┌────────────────────────────────────┐
│ │ 内核代码段 │
│ │ 操作系统内核自己的机器指令 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ 内核数据段 │
│ │ 内核全局数据结构 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ PCB / task_struct │
│ │ 进程控制块 │
│ │ 保存 pid、状态、调度信息、内存信息等 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ kernel stack 内核栈 │
│ │ 进程进入内核态后使用 │
│ │ 系统调用、中断、异常处理时使用 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ 页表相关结构 │
│ │ 维护虚拟地址到物理地址的映射 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ 文件描述符表 / file 对象 / socket │
│ │ open/read/write/socket 等内核资源 │
│ └────────────────────────────────────┘
│
│ ┌────────────────────────────────────┐
│ │ 调度队列、信号结构、定时器等 │
│ │ 操作系统管理进程所需的其他结构 │
│ └────────────────────────────────────┘
│
0xffffffff
────────────────────────────────────────────
高地址
有几个地方需要注意,
只读数据段rodata
是程序加载时就确定好的静态区域
为什么需要它?为了防止常量被错误修改、让多个位置只共享同一份只读数据以优化性能
例如下述代码会报错Segmentation fault,因为"hello" 这个字符串字面量放在 rodata 段,而 rodata 段的内存页权限是只读的
char* p = "hello";//正确
p[0] = 'H';//报错
再例如下述代码不会报错,因为这里是用字符串字面量 "hello" 初始化一个数组,因此修改的也是数组副本里的字符
char arr[] = "hello";
arr[0] = 'H';
内核数据段
内核数据段全局只有一份实例,它被所有进程共享,不是每个进程创建时拷贝一份
内核数据段存的是:内核的全局状态 + 系统运行中创建的一堆内核数据结构,如CPU核数、系统时间、调度队列、物理内存入口、文件系统入口等
举个例子,每个进程就像教室里的学生,内核数据段就像教室里的黑板,黑板记录着值日名单、课程列表等,所有学生通过这块统一的黑板知道班级的总体状况。整个教室只有一个黑板实例,学生自己不持有黑板,但知道黑板在哪以及怎么看黑板
进程控制块PCB
PCB实际是一个概念名,而不是一块具体的内核内存空间,它本身放在内核空间的动态内存区,这个在 内存管理篇 会细讲。所以上面内核虚拟地址空间的图略不准确,主要是为了便于理解进程主要结构
用于描述和管理进程的核心数据结构,保存了一个进程运行所需的全部上下文信息(直接或间接保存)
它保存几类信息:CPU上下文、进程标识信息、进程调度信息、进程资源信息
保存方式或结构有两种:
- 直接保存:保存资源本身,直接保存了进程信息、内核栈指针等
- 间接保存:保存资源引用,虚拟内存空间指针、页表基址、文件描述符表基址等
内核栈kernel stack
内核栈 = 这个进程在内核态执行时的函数调用历史 + CPU现场保存区 + 临时变量,一般存放:用户态 RIP(返回地址)、用户态 RSP、通用寄存器保存区、内核函数调用栈帧、文件描述符、锁状态等
进程内核态工作流程如下:
- 进程运行在用户态
- 发生系统调用/中断:进入内核态
- 切换栈(关键):令RSP = 当前进程的 kernel_stack_top
- 保存用户态现场:将RIP、RSP、通用寄存器等压入内核栈
- 执行内核函数:如
read()等,每次函数调用都在内核栈上push / pop - 返回用户态:恢复寄存器、RIP、RSP等,回到用户态现场
页表
它的实际位置和PCB一样在内核空间的动态内存区
系统以页(通常4KB)为基本单位拆分物理内存和虚拟内存,然后用页表建立虚拟地址 → 物理地址映射。地址的高位作为页号,用来定位页的位置;而低位作为页内偏移量,用来定位页内数据具体位置
每个进程有自己独立的页表
简化后的访存流程:
拿到虚拟地址
->取虚拟地址的高位作为虚拟页号去页表查到对应的页表项(含物理页号、状态位等)
->如果查到,用物理页号+虚拟地址低位组合成物理地址
->根据物理地址去查物理内存中的具体数据
实际系统访存是比较复杂的,会先查TLB,TLB失效了才查页表;实际页表也通常设计为多级页表、按需创建,否则单张页表太大塞不下
进程能做到地址空间隔离和扩展就是靠页表机制
简单来说,每个进程有自己的页表,因此在它看来,自己就拥有了整片内存空间,可以访问内存的所有位置。但这些地址是虚拟地址,要经过页表转换成物理地址才能去访问物理内存。而实际物理内存也不会把所有空间提供给一个进程,而是在某进程访问这片空间时才准备一页物理空间并放好它需要的数据。
举个不恰当的例子,物理内存就像房子,进程就像租户,有个租户白天上班晚上睡觉,有个租户白天睡觉晚上上班,两个租户不会同时住在这个房子里,就像两个进程不能同时被一个CPU核心运行并访问内存,房东告诉两个租户你们都是这房子唯一租户,两个租户也都以为这个房子任何时间都只属于自己一个人。而实际上居住情况并非如此,但是居住效果是一样的
文件描述符表FD table
它的实际位置和PCB一样在内核空间的动态内存区
文件描述符表是每个进程在内核空间中维护的一张“索引表”,用来实现 **fd(整数) → file(内核对象)**的映射
进程打开或创建一个文件时,会返回一个文件描述符fd,它表示进程能以fd为下标去进程的文件描述符表中访问对应的文件对象
伪代码类似:FILE file = fd_table[fd]
- file 是一个数据结构,表示该文件在该进程中的实例或状态,记录文件当前读写位置、文件打开模式、inode指针等
- inode 是文件本体的元数据(即文件自身属性),记录文件大小、文件类型、磁盘位置等
进程访问文件过程就是:fd->file->inode->data(文件的数据块,可能在磁盘或已经加载到内存中)
举个例子,进程是校长,文件是小明,fd表示小明的全校排名,fd_table就是成绩榜单,file是小明的学号、班级、姓名等学生信息,inode就是小明本人和个人信息,小明家就是文件的数据。校长说要去家访全校第一名,但他不知道全校第一是谁,于是他去成绩榜单看谁是第一个学生,看到了小明的信息,按照信息找到了小明本人,问了小明个人基本信息,然后去和小明去他家里家访
用户的堆/mmap会在其他篇讲解
用户态与内核态
为了更好更安全的进程抽象,CPU和操作系统为应用程序提供了两种运行模式:用户态与内核态。用来限制应用程序可以执行的指令和可以访问的地址空间范围。
CPU通过某个控制寄存器中的一个模式位来描述进程所处的模式
当设置了模式位时,进程运行在内核模式,可以执行指令集中任何指令,可以访问系统中任何内存位置
未设置模式位时,进程运行在用户模式,不允许执行特权指令,不允许发起I/O,也不允许直接引用地址空间中内核区的代码和数据。必须通过系统调用接口间接访问内核代码和数据
应用程序的进程初始是运行在用户态中的
进程从用户态变为内核态唯一方法是通过中断、故障、陷入(系统调用就是一种陷入)等异常机制。异常发生时,传递到异常处理程序,CPU将用户态变为内核态进行处理;返回应用程序代码时,CPU把运行模式又变回用户态
CPU内部有各种寄存器
CPU 内部
┌──────────────────────────────┐
│ PC / RIP │
│ 保存进程下一条要执行的指令地址 │
│ 它的值通常指向 text 段 │
├──────────────────────────────┤
│ SP / RSP │
│ 用户态时指向 user stack │
│ 内核态时指向 kernel stack │
├──────────────────────────────┤
│ BP / RBP │
│ 指向当前进程的栈帧基址 │
├──────────────────────────────┤
│ 通用寄存器 │
│ RAX、RBX、RCX、RDX... │
├──────────────────────────────┤
│ 状态寄存器 │
│ 保存当前进程的条件码、中断状态等│
└──────────────────────────────┘
进程的状态
- 创建态:进程刚被创建,开始准备PCB等资源
- 就绪态:进程准备就绪,放入操作系统的就绪队列,等待CPU调度
- 运行态:进程在CPU上执行。一个CPU核心同时只能运行一个进程
- 阻塞态:进程因等待某事件(磁盘IO、网络数据、锁、sleep……)而暂停执行,退出CPU
- 终止态:进程执行完毕或被杀死,操作系统回收资源
存在以下转换规则:
新建态 -> 就绪态
就绪态 -> 运行态
运行态 -> 就绪态
运行态 -> 阻塞态
阻塞态 -> 就绪态
运行态 -> 终止态
进程创建的过程
创建流程
- 内核分配 PCB,填写如pid、状态、优先级、父进程等基本信息
- 创建内核栈
- 建立用户虚拟地址空间
- 继承/复制页表、文件描述符表等资源
- 把进程加入就绪队列
Linux系统中通过fork()创建进程
系统调用 pid_t fork() ,用于创建当前进程的子进程,返回值是子进程PID
fork()出的子进程复制了父进程的运行环境,包括代码段、数据段、堆、栈、文件描述符表等,但有不同的PCB、内核栈、调度信息等
最关键的是,fork 之后,父子进程从 fork 返回的位置继续向下执行
值得注意的一点是,现代操作系统为了节省开销,在fork后通常不会立刻复制出一份独立的内存空间给子进程,而是父子进程共享同一物理内存页,并且页只标记为只读。当某一方尝试写入这一页后,会触发缺页异常,操作系统再复制这个页面,让父子进程拥有自己独立的物理内存页副本
Linux系统中通过exec()运行新程序
exec()不创建新进程,而是用一个新程序替换当前进程的地址空间(代码、数据、堆、栈……)
shell中运行程序
shell命令就是fork+exec+wait的具体实践
shell本身是一个进程,等待用户输入。用户通过shell输入一个可执行目标文件或一个命令的的名字,比如ls。那么shell就会调用fork()创建一个子进程,然后调用execl("/bin/ls", "ls", "-l", NULL);让这个子进程执行/bin/ls这个可执行文件。而父进程shell在fork后会执行wait()等待这个命令执行完毕并回收子进程
什么是进程上下文切换
什么是进程的CPU上下文
指的是一个进程在 CPU 上运行到某一刻时,CPU 里保存的那一整套“执行现场”
操作系统必须保存进程的 CPU 上下文,这样若进程被切换走再切换回来时,把这些值恢复回 CPU,进程就能像没被打断一样继续执行
常见上下文包括:
1. PC / RIP / EIP:程序计数器
2. SP / RSP / ESP:栈指针
3. BP / RBP / EBP:栈帧基址指针
4. 通用寄存器
5. 状态寄存器 / 标志寄存器
6. 页表相关寄存器
7. 内核态相关寄存器
1.PC:程序计数器
Program Counter:保存进程下一条要执行的指令地址。
2.SP:栈指针
Stack Pointer:保存当前栈顶位置
它会随着局部变量、函数的出栈入栈而不断变化
以调用某个函数func()举一个简单例子:
调用func
->进入func:SP中存func的最初地址
->定义局部变量a:变量a入栈,SP向低地址增长
->函数返回:整个func函数栈弹出,SP指向调用func前的栈顶
相关的崩溃问题:
- 栈溢出(stack overflow):SP不断向低地址增长,到了非法内存
- 访问野指针:函数返回局部变量指针,外部访问了该指针指向的内存,但该指针指向内存已失效
- 未定义行为:访问已经结束生命周期的对象,属于未定义行为——函数返回后,尝试访问已失效的内存,可能得到残留旧数据,可能产生数据覆盖,也可能报错Segmentation fault
3.BP:栈帧基址指针
Base Pointer:保存当前函数栈帧基址
BP是相对固定的,只有产生函数调用/返回时才发生变化
依旧以调用某个函数func()举例:
在main中运行:PC指向main代码指令,BP指向main基址,SP指向当前栈顶
->调用func:将返回地址压入栈(下一条main指令)
->进入func:把旧BP入栈(保存旧BP),让BP指向当前SP的位置,建立新的函数栈帧
->执行func:PC指向func代码段指令,SP不断变化为局部变量分配空间
->返回func:恢复栈顶,让SP指向当前BP指向的地址,让BP指向旧BP指向的地址,PC指向返回地址对应的指令
此时只是栈寄存器指向的栈地址发生了变化,func函数栈帧中仍有旧数据,但已经不可访问
4.通用寄存器
用于保存计算过程中的临时数据、函数参数、返回值等
5.状态寄存器 / 标志寄存器
存 CPU 当前的一些状态,比如是否进位、是否溢出、CPU 权限等
6.页表寄存器
CPU 访问虚拟地址时,通过页表翻译成真正的内存物理地址
页表寄存器存的就是当前页表的物理基址,页表本体在内存中
进程上下文切换的流程
进程运行时,进程的CPU上下文在CPU内部的寄存器和执行单元中,其他资源则在内存中
切换进程时会发生:
1.CPU进入内核态
系统调用发生,CPU进入内核态
2.换出当前进程的CPU上下文
CPU上下文就是上述的寄存器等。发生切换时,系统会把这些寄存器里的信息拷贝到进程的内核地址空间中,主要是PCB和内核栈中
- PCB存放进程状态、调度位置、内核栈指针等进程的管理信息
- 内核栈存放PC、BP、SP、通用寄存器等寄存器现场,过程参见前述内核栈的内容
3.更新进程状态
running -> ready/blocking
4.调度器选择下一个进程
5.恢复新进程的CPU上下文
从新进程内核空间中取出task_struct / kernel stack,恢复RIP、RSP、RBP等寄存器现场
6.CPU返回用户态,执行新进程
进程上下文切换的开销有哪些
上下文切换本身不做业务计算,但会消耗 CPU 时间。频繁上下文切换会降低系统性能
上下文切换设计:
1. 保存当前进程寄存器
2. 恢复另一个进程寄存器
3. 切换内核栈
4. 切换页表
5. 可能导致 TLB 失效
6. CPU cache 命中率下降
7. 调度器本身也要执行代码
8. ……
主要的开销:
1.页表切换与TLB失效
TLB(Translation Lookaside Buffer)是虚拟地址翻译的高速缓存,它把最近用过的地址翻译结果缓存起来,可视作CPU内部的小型页表
CPU需要访问内存时,先尝试使用TLB翻译虚拟地址,如果无法查到,才去页表寄存器里获取页表基址,再去内存里查页表
当进程切换后,页表寄存器的内容也发生了切换,导致新进程运行时TLB可能频繁失效,增加了数据访问开销
2.CPU cache 命中率下降
CPU cache是CPU内部存储热点数据的高速缓存,以cache line(64B)内存块为基本单位(页通常是4KB)
CPU要访问内存时,先得到实际物理地址,然后去cache中检查是否命中,如果命中直接使用该行数据,否则再去访存
当进程切换后,由于cache存储的大部分是旧进程的热点数据,所以cache命中率大大下降,增加了数据访问开销
3.寄存器切换
寄存器切换开销主要有两点:
- 当前进程的 CPU 执行现场(寄存器状态)保存到内存,再从内存恢复另一个进程的寄存器状态所带来的直接开销。代价较小
- CPU 执行状态重置,如流水线清空、TLB失效等。代价较高
4.内核栈切换
进程间的通信方式
进程间通信 IPC(),就是让两个或多个进程在彼此独立的地址空间之间交换数据、同步状态、协同完成任务。比如同主机的跨进程通信、前后端通信、后端与数据库通信、不同后端服务间的通信……
由于每个进程的用户地址空间是隔离的,所以必须通过内核提供的机制进行通信
常见 IPC 分类:
├── 管道 pipe
├── 命名管道 FIFO
├── 消息队列 message queue
├── 共享内存 shared memory
├── 信号 signal
├── 信号量 semaphore
├── 套接字 socket
└── 文件 / mmap 等间接方式
pipe 和 FIFO
pipe
pipe(管道)是内核维护的一块**“环形缓冲区”**。之所以用引号,是因为这块缓冲区并非严格意义上的环形,而是通过首尾双指针模拟环形
通过 pipe() 函数创建。创建 pipe 后内核会准备好一段缓冲区并返回两个 fd( fd[0] 和 fd[1]),它们实际作为了该 pipe 的读写入口——fd[0] 是读端,fd[1] 是写端,可以理解为:
进程A 内核 进程B
│ │
├── fd[1] → FILE对象 → 写入 → [ pipe buffer ] → 读取 ← FILE对象 ← fd[0]
//pipe是单向字节流,即读端只能读,写端只能写,且没有数据边界
需要注意的是,pipe只能用于父子进程通信,因为 fork 出的子进程和父进程的文件描述符表中那两个 fd 会指向同一个内核 pipe 缓冲区
pipe 还具有阻塞机制,缓冲区满时写端 write() 阻塞,缓冲区空时读端 read() 阻塞
FIFO
FIFO(命名管道)顾名思义,相比 pipe 唯一的区别就是有具体路径名字,能让无亲缘关系的进程通信
在操作系统的文件系统中,FIFO被视为一个文件(如 /tmp/myfifo),类型是FIFO,且读写权限为 prw-r--r--
通过 mkfifo("myfifo", 0666) 函数创建,第一个参数是管道名称,第二个参数是管道文件权限。由于 FIFO 本身相当于一个文件,所以不同进程可以通过打开/读取/写入文件的方式来进行通信。但它仍然和 pipe 一样,是单向字节流,只维护内核缓冲区,不会落到磁盘
二者区别
pipe:fd → file → pipe_buffer(没有文件系统入口)
FIFO:fd → file → inode(文件系统节点)
↓
pipe_buffer
怎么使用
int fd[2];
pipe(fd);//创建pipe
write(fd[1] ...)//写入
read(fd[0] ...)//读出
//FIFO用法差不多,只是创建
补充:struct file
借此契机,讲一下 struct file 到底是什么角色,在涉及文件处理时我们常会看到它
Unix/Linux把进程能操作的东西都统一抽象成文件,文件描述符 fd 和 struct file 就是具体实现。fd不细讲了,就是进程层面对一个文件的标识,仅仅是一个整数;file 是一个文件层面的通用数据结构,代表着“一次打开”,比如一个文件在一个进程中被打开两次,就代表着两个文件实体,在内核中表现为:
fd1 → file对象1 → inode(a.txt)
fd2 → file对象2 → inode(a.txt)
而 file 对象本身记录的是这一次打开的状态,比如文件引用计数(有多少fd指向了这个file对象)、操作权限(能读还是写)、文件指针(当前在文件的哪个位置读写)、操作函数表引用(指向底层对象的操作函数集合的指针)、具体底层对象引用(指向底层对象的指针)等等,这些都是通用状态,不管打开什么东西都需要维护这些信息,因此抽象出来放到 struct file
非通用的是操作函数表引用和具体底层对象引用,以 pipe 为例
fd → file
├── f_op → pipe_file_ops
└── private_data → pipe对象
pipe_file_ops 是指针,指向了一个函数表,里面提供了操作 pipe 对象的所有函数。当上层调用 read(fd) 时,file 会实际调用 pipe 对象自己的操作函数表里的 read()
private_data是指针,指向了内核创建的那个 pipe 对象,里面就是具体的数据,或者说,就是 pipe_buffer
消息队列 mq
内核维护的一条“按消息为单位组织的队列”(message queue),进程 A 往队列里放消息,进程 B 从队列里取消息
与 pipe 比较的话,pipe 是字节流传输,缓冲区内的多次写入会一并被读出,而 mq 一次写只能写入一条消息,一次读也只能读出一条消息,即每一条消息是完整独立的单位,不会被拆,也不会粘连
需要注意的是,消息被读取后就被消费掉了,即从消息队列中移除
mq 在内存中本身是一个链表,连接着多个 msg 节点。当它被创建时,内核会赋予它一个 msgid,并在内核数据结构的全局 IPC 表中建立一个msgid -> struct msg_queue 的映射。后续程序操作这个消息队列就通过 msgid 去查表访问
key_t key = ftok("file1", 1);//利用文件名,为该消息队列创建一个内核中的唯一编号,作为可被多个进程识别的公共key
int msgid = msgget(key, 0666 | IPC_CREAT); // 创建消息队列,设定创建权限和创建选项
msgsnd(msgid, &msg, sizeof(msg.mtext), 0); // 写入消息,msg结构体第一个成员必须是long long类型,表示消息type,后续的 成员数量和类型都可以自定义
msgrcv(msgid, &msg, sizeof(msg.mtext), 1, 0); // 读出第一条类型为 1 的消息,这里的msg是接收缓冲区,
msgctl(msgid, IPC_RMID, NULL); // 删除消息队列
局限性:
- 性能不高,涉及两次或更多拷贝(写入拷贝、读出拷贝),pipe 也有这个问题
- 队列大小和消息大小有限,吞吐率低
- 不支持分布式
共享内存
多个进程的虚拟地址映射到同一段物理内存页,实现通过内存直接通信,不涉及数据拷贝,是性能最高的一类 IPC
简单说,就是弄了一块可以被多个进程直接共用的物理内存
创建机制和 mq 很像。当它被创建时,内核赋予它一个 shmid,这是操作系统层面对共享内存的唯一标识(而 key 则是应用程序层面的唯一标识),同时全局 IPC 表中维护一个 shmid -> shm 对象 的映射,shm对象又保存这块共享内存的元信息和指针
但是映射和使用阶段不一样。映射阶段依旧使用shmid去查 shm 对象,然后建立虚拟地址->共享内存的页表项,再返回该虚拟地址给应用程序;使用阶段直接通过 MMU 和页表翻译虚拟地址,把这块共享内存当自己的内存使用
shmat()
这才是共享内存中的核心。它做的事情是:通过 shmid 查询 IPC 表从而找到 shm 对象(保存 shm 的元信息),进行权限检查等操作,并获取 shm 的物理页集合(因为共享内存的物理页通常不是一整段物理内存),然后在当前进程用户虚拟地址空间找一段空闲区域(通常在堆和栈中间的 mmap 区)建立一段虚拟内存 VMA,然后在页表中建立虚拟内存->shm物理内存的映射表项(可能立即建立,也可能后续通过缺页异常延迟按需建立),最后把这块虚拟内存起址作为返回值进行返回
怎么使用
int key = ftok("shmfile", 1);//按照文件名生成唯一key
int shmid = shmget(key, 1024, 0666 | IPC_CREAT);// 创建共享内存
char *addr = (char *)shmat(shmid, NULL, 0); // 把内核中已经创建好的共享内存段,映射到当前进程的用户虚拟地址空间 中,并返回一个可以直接读写的用户态地址
strcpy(addr, "hello"); // 写入共享内存
printf("%s\n", addr); // 读取共享内存
shmdt(addr); // 解除映射
shmctl(shmid, IPC_RMID, NULL); // 删除共享内存
可能的问题
不像 mq 和 pipe 读写时依靠内核函数 write() 和 read() 能保证数据同步、防止并发写坏,共享内存由于相当于进程访问自己的用户地址,多进程可能同时访问一块共享内存,会有并发安全问题,所以一般不会裸用,而是由程序员自行设计搭配信号量、互斥锁 、条件变量等机制来进行同步
以最经典的 共享内存+信号量+生产者/消费者模型 为例,为共享内存设计一个数据结构:
当 shmat() 返回共享内存的指针时,强转类型为 SharedData*,把这片内存当成一个结构体进行操作
补充一点,sem_wait() 是 P 操作,表示在只有信号量当前值大于 0 时将它 -1,否则阻塞;sem_post()是 V 操作,将信号量+1并唤醒一个阻塞的 P 操作
struct SharedData {
sem_t empty; // 表示缓冲区是否为空,可以写。empty = 1:一开始缓冲区是空的,可以写
sem_t full; // 表示缓冲区是否有数据,可以读。full = 0:一开始没有数据,不能读
sem_t mutex; // 互斥锁,保护共享数据区。mutex = 1:锁未被占用,一次只允许一个进程操作共享数据
int len;
char buffer[BUFFER_SIZE];
};
写进程怎么做?
sem_wait(&shm->empty); // 等待可写,即等待缓冲区为空
sem_wait(&shm->mutex); // 缓冲区为空后立刻加锁
// 写共享内存
strcpy(shm->buffer, "hello");//写入数据
shm->len = strlen(shm->buffer);//记录写入数据的长度
sem_post(&shm->mutex); // 解锁
sem_post(&shm->full); // 通知有数据可读
读进程逻辑相反,差不多,略
套接字Socket
Socket 是最通用的 IPC 方式之一
它是内核提供的通信端点,进程通过它发送和接收数据。它既能用于本机进程通信,也能用于不同机器之间通信
可以理解为,socket 就是两个进程的一对邮箱,发消息前先放到自己的邮箱再发到对方邮箱,对方也从邮箱读取消息
它在内核中的链路是 socket_fd → file → socket对象 → 协议栈缓冲区,socket_fd 通过进程的文件描述符表定位到 file 对象,进而定位到socket 对象,进而使用读/写缓冲区
TCP socket
这块主要是计算机网络的内容,会在 Socket篇 细讲,这里只从操作系统层面进行理解
TCP socket 是面向连接的套接字,对应的地址族通常是 AF_INET(ipv4)、通信方式是 SOCK_STREAM (字节流)
它在内核中的核心对象有:struct socket(包含通信模式、socket状态和指向struct sock的指针)、struct sock(包含地址四元组、读/写缓冲区、序号seq、窗口大小、拥塞控制状态、重传定时器、协议操作函数……)统称为 socket 对象
服务端socket:
socket()//设定地址族和通信模式,内核创建一个socket对象,返回fd(它在应用程序层面的唯一标识)
↓
bind()//将socket与本地某个 ip + port 进行绑定,表示占用本机的某个端口,等待别人连接。内核也会维护一个记录socket<->port的表
↓
listen()//设定监听队列大小,让socket进入监听状态。队列有两类:半连接队列(正在握手)、全连接队列(连接已建立)
↓
accept()//找到socket的监听队列,从全连接队列中取出一个已经建立的连接,为这个连接建立新的file和fd;若无连接则默认阻塞
↓
read/write 或 recv/send//以写为例,拷贝数据到内核发送缓冲区->根据 MSS、窗口等切分数据->构造TCP报文->IP层->网卡驱动发送
↓
close()//file引用计数-1,socketfd引用计数-1,引用计数归零就触发四次挥手、回收资源
客户端socket:
socket()//设定地址族和通信模式,内核创建一个socket对象,返回fd(它在应用程序层面的唯一标识)
↓
connect()//根据sockfd找到socket,若socket没有bind本地端口和ip,那么内核自动分配。根据传入的远端ip和端口,发起三次握手
↓
read/write 或 recv/send//以写为例,拷贝数据到内核发送缓冲区->根据 MSS、窗口等切分数据->构造TCP报文->IP层->网卡驱动发送
↓
close()//file引用计数-1,socketfd引用计数-1,引用计数归零就触发四次挥手、回收资源
UDP socket
是无连接的套接字,地址族通常是 AF_INET(ipv4)、通信方式是 SOCK_DGRAM (数据包)
不同于TCP,它是有消息边界的,使用的读写函数是 sendto/recvfrom ,表示一包一包写/一包一包读;并且 UDP 没有连接状态机、没有三次握手、没有可靠重传、没有按序保证,其内核 socket 对象单纯维护端口、地址、缓冲区等信息,没有复杂连接信息
UNIX domain socket
对应地址族是 AF_UNIX,通信模式为字节流或数据包,非常常用,它看起来像网络 socket,但不经过 IP 和网卡
以内核统一视角看三者结构
TCP socket 是:fd → file → socket → sock → TCP 协议栈 → IP 层 → 网卡
UDP socket 是:fd → file → socket → sock → UDP 协议栈 → IP 层 → 网卡
UNIX domain socket 是:fd → file → socket → unix_sock(一个特殊的 inode) → 本机内核 socket 缓冲区
流程和 TCP 类似:
//服务端↓
socket();
bind(fd,"/tmp/server.sock")//绑定的不是 IP:port,而是文件路径(若没有就创建),并把路径对应的 inode 与unix_sock 绑定
listen();
accept();
send/recv
//客户端↓
socket();
connect(fd,"/tmp/server.sock");
send/recv
简单来讲,原先创建了 fd 和 socket 时,进程可以通过 fd 查询自己的文件描述符表找到 socket 对象,但无法跨进程查;使用 bind 创建一个 socket 类型的文件,并把它和 socket 对象绑定,其他进程就可以通过文件路径查目录,查到文件的 inode,而 inode 指向的就是这个 socket 对象。既能使用 fd -> file -> socket 对象来定位,又能使用 path + 目录 -> inode ->socket 对象来定位
mmap / 文件 / 信号 / 信号量
mmap
不细讲了,它可以看成基于文件的共享内存,不过它是靠 fd → file → inode → page cache → 映射到多个进程,也需要同步机制
具体机制是:
mmap(fd) -> 把文件内容映射到进程虚拟地址空间 char *p = mmap(fd) -> 进程直接通过指针访问
文件
最朴素的IPC,也就是普通的文件打开和读写,但是实时性差、效率低、复杂,不细说了
信号
信号是一种事件通知,一般不是为了传数据,它是内核给进程发送的一个异步事件通知,主要特点是轻量、异步
具体机制是:
进程 A kill(pid, sig) //sig是信号值,通常就是一个整数
↓
进入内核
↓
找到目标进程 PCB/task_struct
↓
在目标进程的 pending signal 集合里标记该信号//如果blocking signal中标记了该信号,表明进程屏蔽了这个信号,信号直接失效
↓
目标进程从内核态返回用户态前,内核安排执行 signal handler//触发信号处理函数,如果进程没有注册,直接触发进程退出
信号量
不是用来传数据,而是用来同步和互斥
通常搭配共享内存使用,参见讲共享内存时的举例
完结,请等待后续更多知识更新!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)