操作系统总结

进程和线程的区别

  • 本质区别
    • 进程是资源调度以及分配的基本单位
    • 线程是 CPU 调度的基本单位
  • 所属关系:一个线程属于一个进程,一个进程可以拥有多个线程。
  • 地址空间
    • 进程有独立的虚拟地址空间
    • 线程没有独立的虚拟地址空间:线程有调用栈、程序计数器(PC)、本地存储(LS)等少量独立空间。
  • 内存
    • 系统会为每个进程分配不同的内存空间
    • 系统不会为线程分配内存线程所使用的资源来自其所属的进程
  • 并发性
    • 进程的并发性较低
    • 线程的并发性较高
    • 对于单个 CPU,操作系统会把 CPU 的运行时间划分为多个时间段,再将时间段分配给各个线程执行
    • 切换效率:
      • 进程切换效率低(因为所属的资源多) ,线程切换效率高
      • 都会涉及到上下文切换
  • 健壮性
    • 一个进程崩溃后,不会影响其他进程,进程的健壮性高。
    • 一个线程崩溃后,导致整个进程崩溃,线程的健壮性低。
    • 进程隔离性强一些

进程与线程的上下文切换过程

  • 进程由哪几个部分构成:
    • task_struct
    • 进程的虚拟地址空间。
  • 上下文由哪几个部分构成:
    • 用户级上下文 → 虚拟地址空间:
      • 代码。
      • 数据。
      • 堆栈等。
    • 系统级上下文 → task_struct
      • 进程标识信息。
      • 进程现场信息。
      • 进程控制信息。
      • 系统内核栈
    • 寄存器上下文(硬件上下文)。
      • CPU 各寄存器的内容。
      • 进程的现场信息 ,存储在系统内核栈(中断栈)
  • 何时发生切换:
    • 主动:系统调用,产生软中断。
    • 被动:时间片到了,时间中断。
    • 由中断来完成切换
  • 进程切换过程:
    • 保存当前进程的硬件上下文。
    • 修改当前进程的状态(存储在 PCB 中) ,状态由运行态改为就绪态或阻塞态 ,加入相关队列(就绪队列或阻塞队列)
    • 调度另外一个进程。
    • 修改被调度进程的状态,将其状态改为运行态。
    • 把当前进程的存储管理数据改为被调度进程的存储管理信息(页表、cache TLB → 用户级上下文切换。
    • 恢复被调度进程的硬件上下文,让 PC 指向被调度的进程代码。

进程调度策略有哪几种

  • 调度对象:
    • 线程和进程相对于操作系统而言都是任务(task_struct
    • 线程也被称之为共享用户虚拟地址空间的进程
    • 包含多个线程的进程被称之为线程组
    • 只有一个线程的进程被称之为进程
    • 没有用户虚拟地址空间的进程被称之为内核线程(内核中的所有线程共享内核虚拟地址空间)
  • 进程调度用于决定由谁(哪个或哪几个)获得处理器的执行权
  • 进程的状态:
    • 从新建到就绪:当进程创建完毕,初始化所有必要资源后,它被放入就绪队列。
    • 从就绪到运行:调度程序从就绪队列中选择一个进程并分配 CPU 时间片。
    • 从运行到就绪:当进程的时间片用完但进程尚未完成时,它会被放回就绪队列。
    • 从运行到阻塞:如果进程请求了当前不可用的资源或等待操作完成,它将转入阻塞状态。
    • 从阻塞到就绪:当进程等待的事件发生或资源变得可用时,它将被移回就绪队列。
    • 从运行到退出:进程完成所有操作并自行退出或由于某种原因被操作系统终止。
  • 调度时机:
    • 主动调度:系统调用等待某个资源。
    • 周期调度 :某些进程不主动让出 CPU,内核依靠周期性时钟来抢占调度。
    • 唤醒抢占、创建新进程抢占、内核线程抢占。
  • 进程调度算法:
    • 先来先服务(FCFS):
      • 也称为先进先出。
      • 从就绪队列中选择存在时间最长的任务执行调度。
    • 短作业优先(SJF):
      • 从就绪队列中选择估计运行时间最短的任务执行调度。
    • 高响应比优先
      • FCFSSJF 的综合。
      • 综合考虑等待时间和估计运行时间来选择任务执行调度。
    • 时间片轮转调度:
      • 为任务分配时间片,当执行完时间片后放入就绪队列。
    • 优先级调度:
      • 从就绪队列中选择优先级最高的若干任务执行调度。
      • 优先级用来描述运行的紧迫程度。
    • 多级反馈队列调度
      • 时间片轮转调度和优先级调度的综合。
      • 动态调整任务的优先级和时间片大小,从而兼顾多方面的系统目标。
      • linux(会根据时间片的剩余时间动态调整优先级,更加公平)windows(严格按照优先级取出任务分配时间片) 采用。

后台进程有什么特点

  • 前台进程:
    • 运行在前台的进程。
    • 终端是该进程的控制终端。
    • 如果终端关闭,则向依赖该终端的所有进程发送一个 SIGHUP 信号,然后进程退出。
    • 可接收终端输入,并可在终端输出。
  • 后台进程:
    • 运行在后台的进程,若在终端运行,终端关闭,进程可能退出
    • 不可接收终端输入,可在终端输出。
  • 前后台程序切换:
    • Ctrl + Z:将前台程序切换为后台程序,但是后台程序是挂起状态。
    • jobs:显示所有的后台程序。
    • fg %编号:将后台程序切换为前台程序。
    • bg %编号:让后台程序处于运行状态
    • & = (Ctrl + Z) + bg %编号
    • nohup:忽略 SIGHUP 信号。
    • Ctrl + D:断开终端 session,依赖该终端的所有进程都会收到 SIGHUP 信号。
  • 守护进程
    • 后台进程的延伸,脱离终端的后台进程,不需要考虑 SIGHUP 信号

    • 如何成为守护进程:

      • fork 子进程,并让父进程退出 ,从而让子进程被 init 进程 接管,成为孤儿进程
      • 使用 setsid() 系统调用建立新的进程会话 ,让孤儿进程成为新建会话的首进程 ,从而脱离与终端的关联
      • 打开 /dev/null,把 012 重新定向到 /dev/null,从而防止守护进程意外地从终端接收输入或向终端发送输出。
      cpp 复制代码
      void daemonize(void) {
      	int fd;
      	
      	if (fork() != 0) exit(0); // 父进程退出
      	
      	setsid(); 
      	
      	if ((fd = open("/dev/null", O_RDWR, 0)) != -1) {
      		dup2(fd, STDIN_FILENO);
      		dup2(fd, STDOUT_FILENO);
      		dup2(fd, STDERR_FILENO);
      		if (fd > STDERR_FILENO) close(fd);
      	}
      }

用户态和内核态

  • 用户态:用户程序运行时的状态。
  • 内核态:操作系统运行时的状态。
  • 为什么要区分用户态和内核态 ?
    • 内核态具备特权,能够操作外部资源
    • 为了系统的安全与稳定。
  • 如何实现用户态和内核态 ?
    • CPU
      • 特权寄存器 → 操作系统使用。
      • 普通寄存器 → 用户程序使用。
    • 内存:
      • 内核空间 → 操作系统使用。
      • 用户空间 → 用户程序使用。
  • 什么时候需要切换 ?
    • 用户态程序在需要操作系统完成特权操作的时候才发生切换。
  • 什么条件引起用户态与内核态之间的切换 ?
    • 系统调用
    • 异常,比如缺页异常
    • 中断
  • 用户态和内核态的区别:
    • 运行实体不同。
    • 是否具备特权。
    • 是否安全和稳定。
    • 实现机制。

描述系统调用整个流程

  • 系统调用是内核给用户程序提供的编程接口
  • 为什么需要系统调用:
    • 内核具有最高的权限,可直接访问所有资源。
    • 而用户只能访问受限资源,不能直接访问内存、网络、磁盘等硬件资源。
  • 中断:使程序从用户态切换到内核态或者从内核态切换到用户态
    • 属性:根据中断号去查询中断向量表,从而找到中断处理程序
      • 中断号。
      • 中断向量表。
      • 中断处理程序。
    • 类别:
      • 硬件中断。
      • 软件中断系统调用对应 int 0x80中断号是 0x80
  • 系统调用是否会引起进程或线程切换 ?
    • 不一定。
    • 如果是阻塞 IO,且 IO 未就绪,将引起线程切换;线程将由运行态转为阻塞态。
    • 如果是非阻塞 IO,且 IO 未就绪,不会引起线程切换,仅仅涉及到用户态和内核态之间的切换。
  • 系统调用引起中断上下文切换
    • 进程状态先由用户态转为内核态,再由内核态转为用户态。
    • 保存运行现场:保存 CPU 寄存器原来的用户态指令。
    • 运行内核代码:更新 CPU 寄存器内容为内核态指令,执行内核代码。
    • 恢复运行现场:更新 CPU 寄存器内容为用户态指令。
  • 系统调用流程:
    • 触发中断:
      • 将系统调用号放入 eax 寄存器
      • 执行 int 0x80
    • 切换堆栈:用户态切换为内核态。
    • 执行中断处理:
      • 根据中断号找到中断处理程序 → 通过 0x80 找到 system_call
      • 根据系统调用号从系统调用表上找到系统调用处理函数并调用
    • 从中断处理程序返回:通过 iret 将返回值返回,并且程序从内核态切换为用户态

CPU 是怎么执行指令的

  • 指令是二进制的机器码 ,由程序编译产生
  • 程序编译运行过程
    • 编译阶段:通过词法句法分析,源代码 → 汇编代码。
    • 汇编阶段:汇编代码 → 二进制机器码(.o 目标文件)
    • 链接阶段:目标文件 + 库文件 → 可执行程序。
    • 载入阶段:
      • 将可执行程序加载到内存中
        • 给进程分配虚拟内存地址空间。
        • 创建页表。
        • 加载代码段和数据段等数据,并在页表中写入映射关系。
      • 加载器把入口指令地址写入程序计数器
  • CPU 采用流水线的方式执行指令;指令执行过程被称之为指令周期CPU 周而复始地执行指令周期。
  • 指令执行过程涉及到两个组件:CPU 和内存,CPU 和内存之间通过总线 进行交互。
    • CPU
      • 寄存器:
        • 通用寄存器 → 存储计算时所需的数据。
        • 程序计数器存储要执行的指令的地址(虚拟内存地址)
        • 指令寄存器存储要执行的指令
      • 控制单元(控制器)。
      • 逻辑运算单元(运算器)。
    • 总线:
      • 地址总线操作内存地址
      • 数据总线读写内存数据
      • 控制总线 → 用来发送或者接收信号。
  • 指令周期:
    • 取得指令
      • 控制单元程序计数器 获取指令地址,通过地址总线通知内存准备数据。
      • 如果内存数据准备好了,控制单元 通过数据总线 获取指令内容,然后写入指令寄存器
    • 指令译码CPU 将指令解析成不同的操作信息。
    • 执行指令CPU 将指令放入逻辑运算单元进行运算。
    • 数据回写控制单元 将操作结果通过数据总线回写到内存中。
  • 开启下一个指令周期:控制单元程序计数器 中的指令地址 +8(如果是 64 位操作系统)。

内存管理有哪几种方式

  • 背景:
    • 为了多进程之间的内存地址访问不受影响并且相互隔离,操作系统会为每个进程独立分配虚拟地址空间
    • 每个进程都有虚拟地址空间,而物理内存只有一个,当启动程序过多时,实际使用的内存超过物理内存 ,则会发生内存交换
    • 内存交换:把不常用的内存交换到磁盘中(换出),需要的时候再加载回内存(换入)
    • 虚拟地址需要映射到物理地址。
    • 怎么管理映射关系。
  • 内存管理的方式有三种:分段 内存管理、分页 内存管理、段页式内存管理。

分段内存管理

  • 程序逻辑分段:代码段、数据段、堆段、文件映射段、栈段。
  • 虚拟地址:段号段内偏移量等构成。
  • 根据段号段表 找到段内起始地址 加上段内偏移量 得到真实的物理地址
  • 优点:程序无需关注具体的物理内存地址。
  • 缺点:
    • 会产生外部内存碎片 → 段与段之间的间隙不足以容纳其他程序。
    • 每次内存交换都会把一个程序的全部内存换出内存交换效率低

分页内存管理

  • 虚拟内存物理内存 划分为多个连续且固定大小的页linux 页大小为 4KB
  • 虚拟地址 通过查找页表 获得物理地址
  • 虚拟内存地址 转换为物理内存地址 的工作是由 CPU 中的 mmu 来处理的。
  • 若在页表中找不到物理地址 ,则会触发缺页异常(缺页中断)
  • 优点:
    • 页与页是紧密排列的,不会有外部内存碎片
    • 在内存交换中,只会将若干个页换出内存交换效率高
    • 装载程序时,无需一次性把程序装载到物理内存中,运行中需要时才会加载到内存。
  • 缺点:
    • 分配内存的最小单位是一页 ,可能没用到一页数据,造成内部内存碎片
    • 单级分页(每一个页表大约 4MB 将耗费更多内存用于存储页表。

段页式内存管理

  • 将程序划分为多个逻辑段
  • 划分为多个
  • 虚拟地址:段号段内页页内偏移
  • 段页式内存管理得到物理地址需要三次内存访问
    • 访问段表 ,得到页表起始地址
    • 访问页表 ,得到物理页号
    • 根据物理页号页内偏移值 得到物理地址
  • 优点:提高了内存利用率。
  • 缺点:增加了系统开销。

malloc 是如何分配内存的

  • 背景:
    • 进程的虚拟内存空间分布(由低地址到高地址划分):
      • 代码段:存储二进制可执行代码。
      • 已初始化的数据段:静态常量。
      • 未初始化的数据段:未初始化的静态变量或全局变量。
      • 堆段:动态分配的内存,由低地址往高地址分配。
      • 文件映射段:动态库、共享内存等,由高地址往低地址分配。
      • 栈段:局部变量、函数调用的上下文等。
      • 内核空间
    • malloc 分配的是物理内存还是虚拟内存 ?
      • 分配的是虚拟内存
      • 如果分配的内存没有被访问,则不会映射到物理内存 → 不访问就不会占用物理内存
      • 如果分配的内存被访问了,则通过缺页异常建立虚拟内存到物理内存的映射关系
  • malloc 分配内存的过程:
    • 如果分配内存的大小 < < < 128 KB,则通过 brk 系统调用堆区 分配内存。
      • 由低地址往高地址分配。
      • 优先从内存池中进行分配 ,可以减少系统调用的次数
      • 页表中的映射关系仍然存在 ,可以减少缺页异常的次数
      • 缺点:频繁 malloc / free 会出现很多的内部内存碎片
    • 如果分配内存的大小 ≥ \geq ≥ 128 KB,则通过 mmap 系统调用文件映射区 分配内存。
      • 由高地址往低地址分配。
      • 每次都会发生系统调用,进行用户态到内核态的切换。
      • 第一次访问内存时将发生缺页异常
  • free 如何工作:
    • 释放内存后,是否会把内存归还给操作系统 ?
      • 如果是通过 brk 系统调用分配内存 ,该内存仍然在 malloc 的内存池中下次可以继续使用
      • 如果是通过 mmap 系统调用分配内存free 后会立刻归还给操作系统
    • free(ptr) 传入的是内存地址,如何知道释放多大的内存 ?
      • malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 个字节
      • 16 个字节记录了内存块的描述信息,其中包含了内存块的大小
      • 当释放内存时,会free 传入的内存地址向左偏移 16 个字节从而分析出该释放多大的内存

页面置换算法有哪些

  • 背景:
    • 缺页异常:当 CPU 访问的页面不在物理内存时,便会产生缺页异常,请求操作系统将缺页调入到物理内存。
    • 缺页异常处理流程(页面目前位于磁盘上的交换空间 ):
      • CPU 中访问 load M 指令 ,接着查找 M 对应的页表项
      • 若页表项有效,CPU 直接访问物理内存,若无效,则 CPU 发起缺页异常
      • 操作系统收到缺页异常 ,执行缺页异常处理函数,先查找页面在磁盘中的位置
      • 找到磁盘中的页面后把页面换入到物理内存空闲页中
      • 修改页表中对应页表项的状态位为有效
      • CPU 继续执行访问物理内存。
    • 缺页异常处理流程(页面目前位于尚未初始化的内存区域 ):
      • 进入内核态分配物理内存更新进程页表映射关系返回到用户态恢复进程的运行
    • 如果没有找到物理内存空闲页 ,则需要调用页面置换算法将一个物理页换出到磁盘 ,并将页表项置为无效
  • 页面置换算法:
    • 出现缺页异常 ,且物理内存无空闲页 时,选择被置换物理页面的过程。
    • 选择物理页换出到磁盘,把需要访问的页换入到物理页。
    • 目标:尽可能减少页面的换入换出次数

最佳页面置换算法

  • 置换在未来最长时间不访问的页面。
  • 每个页需要记录访问时间。
  • 实际系统不会实现,代价太大。

先进先出置换算法

  • 选择在内存驻留时间最长的页面进行置换。
  • 实际系统不会使用,可能没法实现目标。

最近最久未使用置换算法

  • 选择最长时间没有被访问的页面进行置换。
  • 代价很高,需要在内存中维护更新所有页面的链表,开销较大。

最不常用置换算法

  • 选择访问次数最少的页面作为置换页。
  • 每个页面都需要记录访问次数。
  • 实际不会使用。

时钟页面置换算法

  • LRU 的一种改进。
  • 把所有页面保存在环形链表中 ,并用一个指针指向最久未使用的页面检查具体页的访问位
  • 1:访问过。0:未访问。
  • 移动过程中若访问位为 1,则清除访问位(置为 0);遇到 0,选择为置换页
  • 优化:加一个修改位,由访问位和修改位构成元组
    • u = 0, m = 0 → 未访问且未修改。
    • u = 1, m = 0 → 访问过但未修改。
    • u = 0, m = 1 → 未访问但修改过。
    • u = 1, m = 1 → 既访问又修改过。

写文件时进程宕机,数据会丢失吗

  • 写文件流程:
    • 通过 stdio 函数库选择具体的函数,把数据先写到 stdio 缓冲区中减少系统调用的次数
      • 也可以使用 setbuf 自定义用户缓冲区。
    • 通过 IO 系统调用 write 等,写到内核缓冲区 page cache减少磁盘 IO 的次数
    • 由内核发起的写操作(我们无法控制)。
      • 用户层可以调用 fsyncfdatasyncsync 将内核缓冲区中的数据刷到磁盘。
  • page cache
    • 优点:加快对数据的访问、减少磁盘 IO 的次数。
    • 缺点:
      • 占用了物理内存空间,可能会引发频繁的 swap 操作。
      • 用户层无法优化 page cache 的使用策略,通常数据库需要自己实现 page 管理。
  • 两种磁盘 IO 方式:
    • 缓存文件 IO(小文件使用)
      • 不直接与磁盘交互,而是通过 page cache 进行缓存。
    • 直接文件 IO(大文件使用)
      • 不通过 page cache,直接与磁盘交互。
  • 如果写文件没有调用 fflush/write,则数据丢失,因为数据还在用户态缓冲区。
  • 如果写文件调用了 fflush/write,则数据在内核缓冲区,只要系统不关闭,那么数据就不会丢失。
  • 假设进程宕机后,系统马上也关闭了:
    • 如果系统关闭前调用了 fsyncfdatasyncsync,则数据不会丢失。
    • 否则数据可能丢失,主要看系统是否调用了下面的三个接口。

磁盘调度算法有哪些

  • 背景:
    • 目的:为了提高磁盘的访问性能。
    • 磁盘访问:
      • 旋转时间:4ms
      • 寻道时间:10ms
      • 数据传输时间:0.3ms
  • 磁盘调度的主要目标:使磁盘的平均寻道时间最短

先来先服务

  • 按照磁盘访问的请求顺序进行调度
  • 优点:是公平的调度方式。
  • 缺点:未做任何优化,平均寻道时间较长。

最短寻道时间优先

  • 优先调度与当前磁道距离最近的磁道
  • 优点:平均寻道时间较短。
  • 缺点:离密集访问磁道较远的请求将出现饥饿现象。

电梯扫描算法

  • 先按一个方向来进行磁盘调度,直到处理完该方向的所有请求后才改变调度方向
  • 优点:解决了最短寻道时间优先的饥饿问题。
  • 缺点:中间调度频率高,两边调度频率低。

循环扫描算法

  • 朝特定方向进行磁盘调度,返回时直接复位磁头,仍朝原来的方向进行调度
  • 磁道只响应一个方向上的请求
  • 相较于电梯扫描算法,各个位置磁道响应频率相对平均。

进程间通信有哪几种方式

管道

  • 父子进程间通信。
  • 半双工。
  • 速度慢、容量有限。
cpp 复制代码
#include <unistd.h>

int pipe(int fd[2]);
read(fd[0], buffer, size);
write(fd[1], buffer, size);
cpp 复制代码
// 获取上传后返回的信息 fileid
int fd[2];
if (pipe(fd) < 0) return; // 创建无名管道:fd[0] → read,fd[1] → write

pid_t pid;
pid = fork();  // fork 子进程
if (pid < 0) return;

if (pid == 0) { // 子进程
	close(fd[0]); // 关闭读端
	dup2(fd[1], STDOUT_FILENO); // 将标准输出重定向到写管道 
	// 往标准输出写的东西都会重定向到 fd 所指向的文件 → 当 fileid 产生时输出到管道 fd[1] 
    // 通过 execlp 执行 fdfs_upload_file → fdfs_upload_file /etc/fdfs/client.conf video.mp4
    execlp("fdfs_upload_file", "fdfs_upload_file", s_dfs_path_client.c_str(), file_path, NULL); 
    // 如果 execlp 调用成功, 那么新程序将开始执行,原程序的代码则不会再继续执行, execlp 后边的代码也就不会执行了
    LogError("execlp fdfs_upload_file error"); // 执行失败
    close(fd[1]);
} else { // 父进程
	close(fd[1]); // 关闭写端
	read(fd[0], fileid, TEMP_BUF_MAX_LEN); // 等待管道写入然后读取
	
	// 等待子进程结束, 回收其资源
	wait(NULL); 
	close(fd[0]);
}

FIFO

  • 命名管道。
  • 可以在无关的进程间交换数据。
  • 速度慢。
cpp 复制代码
#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char* pathname, mode_t mode);

mkfifo("fifo_file", 0600);
int fd = open("./fifo_file");
read(fd, buffer, size);
write(fd, buffer, size);

消息队列

  • 消息的链接表,存储在内核中。
  • 消息队列独立于进程。
  • 速度慢、容量有限。
cpp 复制代码
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

// ftok 生产一个 ipc key
key_t ftok(const char* pathname, int proj_id);

// 根据 ipc key 获取消息队列唯一标识
int msgget(key_t key, int flag);

int msgsnd(int msqid, const void* ptr, size_t size, int flag);

int msgrcv(int msqid, void* ptr, size_t size, long type, int flag);
key_t key = ftok(".", 'z');

int msgctl(int msqid, int cmd, struct msqid_ds* buf);

共享内存

  • 多个进程共享的一块存储区。
  • 最快的一种 IPC
  • 需要和信号量配合使用。
cpp 复制代码
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>

// ftok 生产一个 ipc key
key_t ftok(const char* pathname, int proj_id);

// 根据 ipc key 获取共享内存唯一标识
int shmget(key_t key, size_t size, int flag);

void *shmat(int shm_id, const void* addr, int flag);
int shmdt(void* addr);
int shmctl(int shm_id, int cmd, struct shmid_ds* buf);

信号量

  • 信号量是一个计数器 ,用于进程间的互斥与同步
cpp 复制代码
#include <semaphore.h>

/*
	pshared == 0 → 一个进程中的多线程之间通信
	pshared != 0 并且是共享内存的唯一标识 → 多进程之间通信
*/
int sem_init(sem_t* sem, int pshared, unsigned int value);
int sem_post(sem_t* sem);
int sem_wait(sem_t* sem);
int sem_close(sem_t* sem);
int sem_destroy(sem_t* sem);

信号

  • 信号本质上是一种软中断 ,一种通知机制
  • 处理方法:忽略、捕获、默认动作。
cpp 复制代码
sighandler_t signal(int signum, sighandler_t handler);
int kill(pid_t pid, int sig);
cpp 复制代码
/* 
	默认情况下,往一个读端关闭的管道或 socket 连接中写数据将引发 SIGPIPE 信号。
	我们需要在代码中捕获并处理该信号, 或者至少忽略它。
	因为程序接收到 SIGPIPE 信号的默认行为是结束进程,而我们绝对不希望因为错误的写操作而导致程序退出。
	SIG_IGN 忽略信号的处理程序
*/ 
signal(SIGPIPE, SIG_IGN); // 忽略 SIGPIPE 信号

socket

  • 不会局限是否在同一机器。

线程同步的方式

  • 什么是线程同步 ?
    • 让线程对临界资源的访问按照规定的次序执行。
    • 主要解决线程之间的协同问题。
  • 为什么需要线程同步 ?
    • 在一个进程中的所有线程共享该进程的资源。
    • 如果不干预线程对临界资源的访问可能造成意想不到的错误,甚至导致进程崩溃。
  • 线程同步有哪些方式 ?

互斥锁

  • 确保同一时间只有一个线程访问临界资源
  • 当锁被占用时,其他试图获取该锁的线程都会进入阻塞状态
  • 当锁被释放时,会通过信号通知其他被锁阻塞的线程,被阻塞线程可能修改为就绪状态,也可能直接调度执行。
  • 如何实现互斥锁:
    • 互斥锁首先需要做一个内存标记记录的是线程 ID,因为互斥锁需要进行线程切换。
    • 互斥锁是为了保护临界资源,同时只允许一个线程去访问临界资源,所以会有一个阻塞队列,通过阻塞队列唤醒其它线程去访问临界资源。
    • 要屏蔽中断。
    • 底层硬件自旋锁。
  • 互斥锁的表现:
    • 先在用户态自旋一会儿。
    • 获取失败,把任务挂起(放到阻塞队列中),核心会切换其它线程去执行。
    • 休眠一段时间再次尝试获取锁。
c 复制代码
// 声明一个互斥量
pthread_mutex_t mtx;
// 初始化
pthread_mutex_init(&mtx, NULL);
// 加锁
pthread_mutex_lock(&mtx);
// 解锁
pthread_mutex_unlock(&mtx);
// 销毁
pthread_mutex_destroy(&mtx);

自旋锁

  • 当多个线程竞争锁的时候,只允许一个线程去访问临界资源,其他的线程不会处于阻塞态 ,也就是不会进行线程切换会占用 CPU 核心轮询使用临界资源的线程是否释放锁
  • 在实际自旋锁的实现过程中,它不会一直占用 CPU 不断地去轮询,它可能也会切换线程,但是和互斥锁的切换是有差别的:
    • 互斥锁被切换出去,会处于阻塞态 ,当互斥锁被释放的时候,会从阻塞队列中取出一个线程,把它转化为就绪态 ,进入就绪队列,未来被系统调度的时候再把它从就绪队列中取出,转化为运行态去运行。
    • 自旋锁被切换出去,会处于就绪态,进入就绪队列,时刻等待被系统调度。
  • 自旋锁适用于锁持有时间短的场景。
c 复制代码
// 声明
pthread_spinlock_t spin;
// 初始化
pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);
// 加锁
pthread_spin_lock(&spin);
// 解锁
pthread_spin_unlock(&spin);
// 销毁
pthread_spin_destroy(&spin);

条件变量

  • 用户态的条件让当前线程发生阻塞,当另外一个线程判断特定条件为真时,通知当前线程解除阻塞。
  • 对条件的判断需在互斥锁的保护下进行。
  • 条件变量需要和互斥锁一起使用。

读写锁

  • 当加写锁 成功时处于写状态 ,任何试图加读锁或写锁的线程都阻塞
  • 当加读锁 成功时处于读状态 ,其他试图加读锁的线程不阻塞 ,而试图加写锁的线程阻塞
  • 读写锁适用于读远大于写的场景。
  • MySQL 数据库中的行锁S 锁(读锁)X 锁(写锁)
c 复制代码
// 声明一个读写锁
pthread_rwlock_t rwlock;
// 在读之前加读锁
pthread_rwlock_rdlock(&rwlock);
// 读完释放锁
pthread_rwlock_unlock(&rwlock);

// 在写之前加写锁
pthread_rwlock_wrlock(&rwlock);
// 写完释放锁
pthread_rwlock_unlock(&rwlock);
// 销毁读写锁
pthread_rwlock_destroy(&rwlock);

信号量

  • 非负的整数计数器,用来实现对资源的控制,既可以用于线程之间、也可以用于进程之间。
  • 信号量适用于多份临界资源的访问
  • 只有当信号量大于 0 的时候,才能访问资源。
  • 比如 A 线程调用 sem_wait 将信号量减为 0 了,A 线程发生阻塞;如果 B 线程调用 sem_post 把信号量 + 1,将会解除 A 线程的阻塞,A 线程继续执行。
c 复制代码
// 初始化信号的个数
// pshared = 0 → 线程之间通信
// pshared > 0 → 进程之间通信
sem_init(&sem, pshared, value);

sem_wait(); // P 操作:--操作
sem_post(); // V 操作:++操作

线程通信的方式

  • 信号:pthread_kill(thread, 0) → 通常用于检测其他线程是否存活 → 返回 0 代表存活,返回错误代表线程不存在。
  • 线程同步的方式。

互斥锁和自旋锁的区别

  • 共同点:
    • 都具备排他性。
    • 谁持有锁谁释放锁。
  • 不同点:
    • 获取锁失败后的行为:
      • 自旋锁轮询检测。
      • 互斥锁进行阻塞。

一个线程加锁了,另一个线程可以解锁吗 ?

  • 只有条件变量是通过另外一个线程来解锁的。

虚假唤醒

  • 可能会有多个线程在 pthread_cond_wait 休眠。
  • pthread_cond_signal唤醒至少一个休眠的线程 ,也就是可能会唤醒多个休眠的线程
  • 如果使用 if,消费者线程被唤醒后会少了重新检查条件的步骤,所以使用 while
    • 为什么消费者线程被唤醒后要重新检查条件 ? 也就是为什么会出现虚假唤醒 ?
      • 在多线程环境下,可能会发生信号劫持 ,也就是 pthread_cond_signal 唤醒了多个休眠的线程。
cpp 复制代码
// 消费者线程
pthread_mutex_lock(&mtx);
...
while(condition) { // while 用来解决虚假唤醒
	pthread_cond_wait(&cond, &mtx);
	// pthread_cond_wait 内部:
    // 1. 先 unlock(&mtx)
    // 2. 在 cond 休眠
    // --- __add_task 时唤醒
    // 3. 在 cond 唤醒
    // 4. 加上 lock(&mtx);
}
... 执行逻辑
pthread_mutex_unlock(&mtx);

// 生产者线程
pthread_mutex_lock(&mtx);
... 修改条件
pthread_mutex_unlock(&mtx);
pthread_cond_signal(&cond);
cpp 复制代码
static inline void 
__add_task(task_queue_t *queue, void *task) {
    // 不限定任务类型,只要该任务的结构起始内存是一个用于链接下一个节点的指针
    // link 指向 task 的 next 指针
    void **link = (void**)task;
    *link = NULL;

    spinlock_lock(&queue->lock);
    *queue->tail /* 等价于 queue->tail->next */ = link;
    queue->tail = link;
    spinlock_unlock(&queue->lock);
    // 唤醒休眠的线程
    pthread_cond_signal(&queue->cond);
}

static inline void * 
__pop_task(task_queue_t *queue) {
    spinlock_lock(&queue->lock);
    if (queue->head == NULL) {
        spinlock_unlock(&queue->lock);
        return NULL;
    }
    task_t *task;
    task = queue->head;

    void **link = (void**)task;
    queue->head = *link;

    if (queue->head == NULL) {
        queue->tail = &queue->head;
    }
    spinlock_unlock(&queue->lock);
    return task;
}

static inline void * 
__get_task(task_queue_t *queue) {
    task_t *task;
    // 虚假唤醒
    while ((task = __pop_task(queue)) == NULL) {
        pthread_mutex_lock(&queue->mutex);
        
        pthread_cond_wait(&queue->cond, &queue->mutex);
        
        pthread_mutex_unlock(&queue->mutex);
    }
    return task;
}

CAS 是怎样的一种同步机制

  • CAS 属于原子操作。

  • 什么是原子操作 ?

    • 底层级别的同步操作:CPU 指令。
    • 不会被调度机制所打断的操作。
    • 原子性:该原子操作是不可分割的,要么都做完了,要么还没开始,不会被其他线程看到只完成了一部分。
  • 什么时候使用原子操作 ?

    • 背景:锁会造成性能损耗。
    • 操作简单、冲突较少。
  • C++ 中的 CAScompare and swap):

    • CAS 操作组成:

      • 内存中的值 V
      • 期待值 Expect
      • 替换值 New Value
    • CAS 操作流程:

      • 比较内存中的值与期待值是否相等。
      • 如果相等,写入替换值 ,并返回 true
      • 如果不相等,直接返回 false
      cpp 复制代码
      // 基于 CAS 实现自旋锁
      #include <atomic>
      
      class SpinLock {
      public:
      	SpinLock() : _flag(false) {}
      	
      	void lock() {
      		bool expect = false;
      		while (!_flag.compare_exchange_weak(expect, true)) {
      			expect = false;
      			// 解决伪失败问题
      			if (_flag.load() == true) {
      				break;
      			}
      		}
      	}
      	
      	void unlock() {
      		_flag.store(false);
      	}
      private:
      	std::atomic<bool> _flag;
      };
  • ABA 问题:

    • 线程1 取出 A修改为 B再次修改为 A
    • 线程2 取出 A,由于时间差没有感知到 A 值的变化。
    • 解决:A 增加版本号ABA 问题转变为 1A2B3C 问题
  • 伪失败问题:

    • compare_exchange_weak 允许偶然错误返回(虽然内存中的值与期待值一样,但是返回了 false)。
    • 通过循环解决。
    • compare_exchange_weakcompare_exchange_strong 性能高。

相关推荐
你好helloworld8 小时前
《操作系统真象还原》第九章(一) —— 在内核空间中实现线程
操作系统
IT 青年1 天前
操作系统(23)外存的存储空间的管理
操作系统
你好helloworld1 天前
《操作系统真象还原》第八章(一) —— 位图及其实现
操作系统
望获linux1 天前
赋能新一代工业机器人-望获实时linux在工业机器人领域应用案例
linux·运维·服务器·机器人·操作系统·嵌入式操作系统·工业控制
IT 青年2 天前
操作系统(21)文件保护
操作系统
肖老师+2 天前
期末复习.操作系统课后习题答案
操作系统
p@nd@3 天前
Oracle筑基篇-调度算法-LRU的引入
数据库·oracle·操作系统·lru
肖老师+4 天前
“从零到一:揭秘操作系统的奇妙世界”【操作系统中断和异常】
操作系统
GoGeekBaird5 天前
69天探索操作系统-第20天:页替换算法 - 深度剖析
后端·操作系统
IT 青年6 天前
操作系统(12)内存分配
操作系统