Skip to content

Os

硬件基础

CPU

常用寄存器:

  • 通用寄存器:存储需要进行运算的数据
  • 程序计数器:存储CPU下一条要执行的指令的内存地址
  • 指令寄存器:存放当前正在被CPU执行的指令

总线:

  • 地址总线:指定CPU要操作的内存地址
  • 数据总线:用于读写内存数据
  • 控制总线:用于发送和接收信号

CPU读写内存数据:

  • 通过地址总线指定内存的地址
  • 通过控制总线接收读/写命令
  • 通过数据总线传输数据

CPU执行程序过程:

  1. CPU读取程序计数器,控制单元操作地址总线,指定需要访问的内存地址,指令数据通过地址总线传给CPU,CPU收到指令数据后将其存入指令寄存器
  2. 程序计数器的值自增,指向下一条指令。自增的大小由CPU位宽决定,如:32位CPU的指令是4字节,需要四个内存地址存放,此时,程序计数器的值自增4。
  3. CPU分析指令寄存器中的指令,确定指令的类型和参数:如果是计算类型的指令,就把指令交给逻辑运算单元;如果是存储类型的指令,就把指令交给控制单元执行。

代码执行过程:

  1. 高级语言 → 编译器处理 → 汇编/机器码。
  2. 机器码 → CPU 逐条执行指令(取指、译码、执行)。
  3. 计算结果 → 存入变量 a 的内存地址。

a=2+1:

  1. 编译器分析代码,发现1、2是数据,存入数据段
  2. 编译器将这行代码翻译成4条指令:

  3. 将数据段的数据1存入寄存器R0

  4. 将数据段数据2存入寄存器R1
  5. 将寄存器R1和R2数据相加,存入R2
  6. 将寄存器R2的数据存回数据段下一个地址。

  7. 编译完成后,具体执行程序的时候,程序计数器会被设置为第一条指令的地址,依次执行四条指令

存储

存储器的层次结构

  1. 寄存器:处理速度最快(0.5个时钟周期)
  2. CPU Cache:略慢,通常分为L1(2-4个时钟周期)、L2(10-20个时钟周期)、L3(20-60个时钟周期)三层,L1 Cache通常分为数据缓存和指令缓存,读取速度最快,存储空间小。使用SRAM(静态随机存储器)芯片,电路简单,访问速度快
  3. 内存:使用DRAM(动态随机存储器)芯片,功耗低,容量大。(200-300个时钟周期)
  4. 硬盘(SSD/HDD)

img

img

每个存储器只和相邻存储器交换数据

CPU Cache

L1 Cache通常分为数据缓存和指令缓存,L1 Cache和L2 Cache是每个CPU核心独有的,L3 Cache是多个CPU核心共享的。

img

img

CPU Cache的数据结构

CPU Cache由多个Cache Line组成,Cache Line(缓存块)是CPU从内存读取数据的基本单位。Cache Line由有效位valid bit(有效位为0则直接访问内存,重新加载数据)、标志Tag和数据块Data Block组成。

CPU Cache读取过程

直接映射Cache DMC

内存块的地址始终映射在一个CPU Cache Line的地址(对内存地址取模运算实现),这样多个内存块会被映射到同一个CPU Cache Line,为了区分这些不同的内存块,使用组标记Tag记录当前CPU Cache Line中存储的数据对应的内存块,从而区分不同的内存。

CPU在从CPU Cache读取数据的时候,只读取CPU所需要的数据片段,通过偏移量在CPU Cache Line中找到所需的数据。

一个内存的访问地址包括组标记、CPU Cache Line索引、偏移量三种信息。CPU Cache中的数据结构由索引、有效位、组标记、数据块组成。

CPU访问内存地址过程:

  1. 根据内存地址中的索引信息计算CPU Cache的索引,找出对应CPU Cache Line的地址
  2. 根据有效位判断数据是否有效,有效则往下执行,否则从内存重载数据
  3. 对比内存地址中的组标记和CPU Cache Line中的组标记,确认CPU Cache Line中的数据是我们要访问的数据,是的话往下执行,否则从内存中重载数据
  4. 根据内存地址中的偏移量,从CPU Cache Line中读取对应的字。

调优

如何写出CPU缓存命中率高的代码?

当CPU访问内存数据时,如果数据不在CPU Cache中,则会一次性加载一个CPU Cache Line大小的数据,所以在遍历数组时,按照内存布局顺序访问可有效利用CPU Cache带来的好处。(提升数据缓存命中率)

减少非连续跳转,利用循环的局部性,优化分支预测,增加连续执行的有效指令数量,提升缓存块的空间利用率(提升指令缓存命中率)

如何提升多核CPU缓存命中率?

因为L1、L2缓存是每个核心独立的,如果一个线程在不同核心之间来回切换,缓存的命中率就会降低,因此,可以把线程固定到某一个CPU核心上。

CPU缓存一致性

CPU写数据方法:

  • 写直达(Write Through):数据在Cache中则更新Cache,再写入内存;不在则直接写内存(每次写操作都会写内存,耗时长)
  • 写回(Write Back):

  • 数据在CPU Cache里,更新数据,将Cache Block标记为脏

  • 数据对应的Cache Block存放的是别的内存地址的数据的话,检查Cache Block是否为脏:

  • 脏,写回内存,读入要写的数据,更新,标记为脏

  • 不脏,从内存读入要写的数据到Cache Block,更新,标记为脏

CPU缓存一致性:多核心+L1 L2是每个核心独立就会带来缓存一致性问题。

解决方案:

  • 写传播:某个CPU核心里的Cache数据更新时,必须传播到其他核心Cache(总线嗅探)
  • 事务串行化:某个CPU核心里对数据的操作顺序必须在其他核心看来顺序是一样的(MESI协议)

总线嗅探:CPU核心监听总线,有更新时广播事件,检查是否有想用数据在自己的L1 Cache,有则更新

MESI协议:

操作系统结构

内核

连接应用程序和硬件。一般提供四个基本功能:

  • 管理进程、线程,决定哪个进程、线程使用CPU(进程调度)
  • 管理内存,决定内存的分配和回收(内存管理)
  • 管理硬件设备,为进程与硬件设备之间提供通信能力(硬件通信)
  • 提供系统调用,它是用户程序与操作系统之间的接口

内核具有很高的权限,可以控制cpu、内存、硬盘等硬件,而应用程序具有的权限很小。内核一般分为两个区域:

  • 内核空间,这个内存空间只有内核程序可以访问
  • 用户空间,这个内存空间专门给应用程序使用

用户空间的代码只能访问局部内存空间,内核空间的代码可以访问所有内存空间。

当程序使用系统调用时,会产生一个中断。发生中断后,CPU会中断当前在执行的用户程序,跳转到中断处理程序。内核处理完后,主动触发中断,把CPU执行权限交给用户程序,回到用户态继续工作。

Linux内核设计的理念:

  • MultiTask 多任务:有多个任务同时执行(并发/并行)
  • SMP 对称多处理:每个CPU的地位是相等的,对资源的使用权限也是相通的,多个CPU共享同一个内存,每个CPU都可以访问完整的内存和硬件资源。
  • ELF 可执行文件链接格式:可执行文件的存储格式。
  • Monolithic Kernel 宏内核:完整的可执行程序,拥有最高权限。

宏内核的特征是系统内核的所有模块都运行在内核态。(linux)

微内核架构的内核只保留最基本的能力,比如进程调度、虚拟机内存、中断等,把一些应用放到用户空间,比如驱动程序、文件系统等。这样服务与服务是隔离的,单个服务出现故障或者完全攻击,也不会导致整个操作系统挂掉,提高了系统的稳定性和可靠性。(鸿蒙)

还有一种内核叫混合型内核,内核里面有一个最小版本的内核,然后其他模块在这个基础上搭建,实现的时候会跟宏内核类似,把整个内核做成一个完整的程序。(Windows NT)

内存管理

虚拟内存

进程持有的虚拟地址会通过CPU中的内存管理单元MMU的映射关系,来转换成物理地址,再通过物理地址访问内存。

内存分段

程序是由若干逻辑分段组成的,不同的段有不同的属性,所以用分段的形式把这些段分离出来。分段机制下的虚拟地址由两部分组成:段选择因子和段内偏移量。

  • 段选择因子就保存在段寄存器里,段璇子因子里面最重要的是段号,用作段表的索引,段表里面保存的是这个段的基地址、段的界限和特权等级
  • 虚拟地址中的段内偏移量应该位于0和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

分段解决了程序本身不需要关心具体物理内存地址的问题,但是也有不足:

  • 内存碎片问题
  • 内存交换的效率低

内存碎片分为内部碎片和外部碎片。内存分段管理可以做到根据实习需求分配内存,所以不会出现内部碎片,但是由于每个段的长度不固定,所以多个段未必能恰好使用所有内存空间,会产生多个不连续的小物理内存,导致新的程序无法被装载,所以会出现外部碎片。

解决外部内存碎片的问题就是内存交换。内存交换空间在Linux里就是Swap空间,是从硬盘划分出来的,用于内存和硬盘的空间交换。

分段的方式很容易产生外部碎片,因此不得不Swap内存区域,就会产生性能瓶颈。

内存分页

分页就是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。每页4KB。页表是存储在内存里的。当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后返回用户空间,恢复进程的运行。

因为内存分页机制分配内存的最小单位是页,所以会出现页内浪费,会有内部碎片。

分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存,只有在程序运行中,需要用到对应虚拟内存页面里的指令和数据时,再加载到物理内存里去。

多级页表

如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,在需要时才创建二级页表。页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有100多万个页表项来映射,二级页表只需要1024个页表项。

TLB在CPU芯片中,常称为页表缓存、快表,有了TLB,CPU在寻址时会先查TLB。

段页式内存管理

  • 先将程序划分为多个有逻辑意义的段
  • 再把每个段划分为多个页

地质结构由段号、页号、页内偏移组成。

逻辑地址:程序所用的地址,通常是没被段式内存管理映射的地址。

线性地址/虚拟地址:通过段式内存管理映射的地址。

Linux内存主要采用的是页式内存管理,同时也涉及了段机制。

Linux系统中的每个段都是从0地址开始的整个4GB虚拟空间,也就是所有的段的起始地址都是一样的。这种做法屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。

在Linux中,虚拟地址空间又被划分为内核空间和用户空间。

每个进程都有独立的虚拟内存,但是每个虚拟内存中的内核地址其实关联的是相同的物理内存。

用户内存空间从低到高分别是6种不同的内存段:

  • 代码段,二进制可执行代码
  • 数据段,已初始化的静态常量和全局变量
  • BSS段,未初始化的静态变量和全局变量
  • 堆段,malloc动态分配的内存,从低地址开始向上增长
  • 文件映射段,动态库、共享内存等,从低地址开始向上增长(mmap)
  • 栈段,局部变量和函数调用的上下文。栈的大小是固定的,一般是8MB

内存分配

malloc是C的库函数,用于动态分配内存。

malloc申请内存的时候,会有两种方式向操作系统申请内存:

  • 通过brk()系统调用从堆分配内存:将堆顶指针向高地址移动,获得新的内存空间。
  • 通过mmap()系统调用在文件映射区分配内存:通过mmap()系统调用中私有匿名映射的方式,在文件映射区分配一块内存,也就是从文件映射区偷一块内存。

如果用户分配的内存小于128KB,通过brk()申请内存;如果分配的内存大于128KB则通过mmap()申请内存。malloc()分配的是虚拟内存。如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存。只有在访问一分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,才会触发缺页中断,然后操作系统会建立虚拟内存和物理内存的映射关系。

free释放内存后,如果是通过brk申请的,会放回malloc内存池,如果是通过mmap申请的,会归还操作系统。

不全部用 mmap 的核心原因是:

  1. 系统调用和内核管理开销大
  2. 容易造成地址空间碎片
  3. 无法像 malloc 那样在用户态做内存池优化
  4. 历史兼容性和内核限制

为什么不全部用 brk?

  • brk 需要连续地址空间,容易碎片化。
  • brk 只能调整堆顶,无法高效回收内存(只能顺序回收)。
  • 不适合大块内存,释放后可能浪费。
  • 不支持文件映射,灵活性差(一个进程只有一条brk堆,mmap可以在地址空间中创建很多独立区域)。

malloc 的实现 = 系统调用 (brk/mmap) + 用户态内存池管理

  • 小块内存 → brk + arena + bin 管理(fastbin/smallbin)
  • 大块内存 → mmap 独立分配,释放立即归还
  • free → 回收空闲块,尝试合并,或 munmap

虚拟内存的作用:

  • 可以使得进程对运行内存超过物理内存大小
  • 由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程没有办法访问其他进程的页表,解决了多进程间地址冲突的问题。
  • 页表项除了物理地址之外还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在。在内存访问方面提供了更好的安全性。

内存分配过程

当应用程序读写了这块虚拟内存,CPU就会去访问这个虚拟内存,这时会发现这个虚拟内存没有映射到物理内存,CPU就会产生缺页中断,进程就会从用户态切换到内核态,将缺页中断交给内核的缺页中断函数处理。

缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,建立虚拟内存和物理内存之间的映射。如果没有空闲的物理内存,内核就会开始回收内存:

  • 直接内存回收:如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个过程是同步的,会阻塞进程的执行。如果直接回收内存后,空闲的物理内存仍然无法满足此次物理内存的申请速度,会触发OOM机制。
  • 后台内存回收:物理内存紧张的时候会唤醒kswapd内核线程来回收内存,这个过程是异步的。

有两类内存可以被回收:

  • 文件页:内核缓存的磁盘数据和内核缓存的文件数据都叫文件页。回收干净页的方式是先写回磁盘再释放内存。
  • 匿名页:这部分内存没有实际载体,这部分内存很可能还有再次访问,所以不能直接释放,它们是通过Linux的Swap机制,把不常访问的内存写到磁盘中,然后释放内存。再次访问时,从磁盘读入内存。

文件页和匿名页的回收都是基于LRU算法。LRU回收算法实际上维护着active和inactive两个双向链表:

  • active_list活跃内存页链表存放的是最近被访问过的内存页
  • inactive_list不活跃内存页链表存放的是很少被访问的内存页

可被回收的内存类型有文件页和匿名页:

  • 文件页的回收:对于干净页是直接释放内存,不会影响性能,对于脏页会先写回到磁盘再释放内存,这个过程有磁盘IO,会影响系统性能
  • 匿名页的回收:如果开启了Swap机制,那么Swap机制会将不常访问的匿名页换出到磁盘,下次访问再从磁盘换入内存,这个过程会影响系统性能。

剩余内存在页低阈值和页最小阈值之间会触发kswapd,回收至大于高阈值为止。如果剩余内存小于页最小阈值,就会触发直接内存回收,这时应用程序会被阻塞。

img

SMP架构指的是一种多个CPU共享资源的硬件架构(UMA),随着CPU的核数增多,多个CPU通过一个总线访问内存,总线的带宽压力会越来越大,这时候就有了NUMA。

要保护进程不被 OOM 杀掉,常用方法有:

  1. oom_score_adj=-1000(最直接有效)
  2. cgroup / systemd 限制其他进程内存
  3. 调整 overcommit 策略,避免系统过度承诺内存
  4. 关键进程可 预分配或锁定内存

缓存

Linux实现了两个LRU链表:活跃LRU链表和非活跃LRU链表,将数据分为了冷数据和热数据,然后分别进行LRU算法。预读页只需要加入到inactive list区域的头部,当页真正被访问时,才插入active_list的头部

缓存污染

批量读如一次性数据,这些数据都被加到活跃LRU链表,之前缓存在活跃LRU链表的热点数据全都被淘汰,整个活跃LRU链表被污染。对后续其他数据访问会造成大量磁盘IO。

提高进入活跃LRU链表的门槛,保证活跃LRU链表的数据不会被轻易替换掉。Linux在内存页第二次访问的时候才将页从inactive升级到active list

Linux虚拟内存

虚拟地址就是给程序的“假象地址”,它隔离进程、简化编程,并由操作系统和硬件负责映射到真实物理内存,从而实现安全、高效、灵活的内存管理。引入虚拟内存后每个进程有自己独立的虚拟地址空间,进程间相互隔离。

进程在内核中的描述符是task_struct,在task_struct中,有一个专门描述进程虚拟地址空间的内存描述符mm_struct。每个进程都有唯一的mm_struct结构体,当我们调用fork函数创建进程的时候,表示进程地址空间的mm_struct结构会随着进程描述符task_struct的创建而创建。

子进程在新创建出来之后它的虚拟内存空间和父进程的虚拟内存空间是一样的。通过fork函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝,直接从父进程中拷贝到子进程中。当通过vfork或者clone系统调用创建出的子进程,父进程和子进程的虚拟内存空间是共享的。

是否共享地址几乎是进程和线程之间的本质区别,Linux内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已。

内核线程和用户态线程的区别是内核线程没有相关的内存描述符mm_struct,内核线程对应的task_struct结构体中的mm指向Null,所以内核线程之间调度是不涉及地址空间切换的。

  • 父子进程:各自拥有不同的 mm_struct(即不同地址空间)。
  • 进程和线程:线程共享同一 mm_struct,进程则不共享。
  • 内核线程和用户态线程:内核线程没有 mm_struct,用户态线程有。

内核虚拟内存空间

直接映射区

映射物理内存,主要存放内核数据、页缓存、物理页。分为ZONE_DMA、ZONE_NORMAL

vmalloc区

采用动态映射的方式映射物理内存中的高端内存。使用vamlloc进行内存分配。非连续物理内存映射,适合需要大块虚拟内存但物理上不连续的情况。

永久映射区 kmap

这段虚拟地址空间中允许建立与物理高端内存的长期映射关系。

固定映射区 fixmap

固定映射区的虚拟地址可以自由映射到物理内存的高端地址上,但是虚拟地址是固定的,被映射的物理地址是可变的。

临时映射区 kmap_atomic

提供一个临时的虚拟地址,让内核可以访问高端物理内存页

物理内存

内存也叫随机访问存储器RAM:

  • 静态RAM SRAM,用于CPU高速缓存LCache、L2Cache、L3Cache,访问速度快,容量小
  • 动态RAM DRAM,主存,访问速度慢,容量大

多个存储器模块连接到存储控制器上就聚合成了主存。DRAM芯片就包装在存储器模块,每个存储器模块中包含8个DRAM芯片。每个DRAM芯片的存储结构是一个二维矩阵,存储的元素称为超单元supercell,每个supercell大小为一字节。

CPU和内存之间的数据交互是通过总线完成的,数据在总线上的传送是通过一系列步骤完成的,这些步骤称为总线事务。数据从内存传送到CPU称为读事务,数据从CPU传送到内存称为写事务。

总线上传输的信号包括:地址信号、数据信号、控制信号。

CPU每次会向内存读写一个cache line大小的数据(64字节),内存一次只能吞吐8个字节。

CPU从内存读取数据的过程:

  1. CPU将物理内存地址A放到系统总线上,IO bridge将信号传递到存储总线上
  2. 主存收到信号通过存储控制器将存储总线上的物理内存地址A读取出来
  3. 存储控制器通过物理内存地址A定位到具体的存储器模块,从DRAM芯片中取出物理内存地址A对应的数据X
  4. 存储控制器将读取到的数据X放到存储总线上,IO bridge将存储总线上的数据信号转换为系统总线上的数据信号,然后沿着系统总线传递
  5. CPU芯片感受到系统总线上的数据信号,将数据从系统总线上读取出来并拷贝到寄存器

CPU向内存写入数据过程:

  1. CPU将要写入的物理内存地址A放入系统总线
  2. 通过IO bridge的信号转换,将物理内存地址A传递到存储总线上
  3. 存储控制器感受到存储总线上的地址信号,将物理内存地址A从存储总线上读取出来,等待数据的到达
  4. CPU将寄存器中的数据拷贝到系统总线上,通过IO bridge信号转换,将数据传递到存储总线上
  5. 存储控制器感受到存储总线上的数据信号,将数据从存储总线上读取出来
  6. 存储控制器通过内存地址A定位到具体的存储器模块,最后将数据写入存储器模块中的8个DRAM芯片

物理内存管理

为了快速索引到具体的物理内存页,内核为每个物理页struct page结构体定义了一个索引编号:PFN,PFN和struct page是一一对应的。

内核提供了两个宏来完成PFN与物理页结构体struct page之间的相互转换:page_to_pfn和pfn_to_page。

FLATMEM平坦内存模型

struct page和物理内存页一一对应。只适合管理一整块连续的物理内存,对于多块非连续的物理内存来说会造成很大的内存空间浪费。因为用于组织物理页的底层数据结构是mem_map数组,数组要求物理页是连续的,所以只能为不连续的部分(内存地址空洞)也分配struct page来填充,如果存在大量的地址空洞,那么其对应的struct page将会占用大量的内存空间,导致巨大的浪费。

DISCONTIGMEM非连续内存模型

内核将物理内存从宏观上划分成了一个一个的节点,每个node节点管理一块连续的物理内存。(类似链表+数组)

SPARSEMEM稀疏内存模型

随着内存技术的发展,内核可以支持物理内存的热插拔了,这样一来物理内存的不连续成为常态。SPARSEMEM的核心思想是对粒度更小的连续内存块进行精细的管理,用于管理连续内存块的单元被称作section。(类似数组+数组)

我认为sparsemem和discontigmem很类似,只不过管理粒度更小了,在sparsemem中,管理粒度来到了每一个连续块section,而不是全局范围,但是两者在组织形式上分别是数组+数组/链表+数组。

进程管理

挂起状态:进程没有占用实际物理内存空间的情况。分为阻塞挂起(在外存等待某个事件的出现)和就绪挂起(在外存,但只要进入内存,即刻允许)。

PCB是进程存在的唯一标识。

PCB包含:

  • 进程描述信息

  • 进程标识符

  • 用户标识符

  • 进程控制和管理信息

  • 进程当前状态

  • 进程优先级

  • 资源分配清单

  • CPU相关信息

PCB通常通过链表的方式组织,把具有相同状态的进程链在一起,组成各种队列。(就绪队列、阻塞队列)

创建进程过程:

  • 申请空白PCB,填入控制和管理进程的信息
  • 分配运行时所必需的资源
  • 将PCB插入就绪队列等待调度

终止进程过程:(正常结束、异常结束、外界干预 kill)

  • 查找需要终止的进程PCB
  • 如果处于执行状态,终止执行,将CPU分配给其他进程
  • 如果还有子进程,将子进程交给1号进程接管
  • 将进程所有资源归还系统
  • PCB从所在队列删除

阻塞进程过程:

  • 找到PCB
  • 如果是运行状态,保护现场,状态转为阻塞态,停止运行
  • PCB插入到阻塞队列

唤醒进程过程:

  • 从阻塞队列找到PCB
  • 从阻塞队列移出,设置为就绪状态
  • 将PCB插入就绪队列

CPU寄存器和程序计数器是CPU在运行任何任务前必须依赖的环境,叫做CPU上下文。

CPU上下文切换可分为:进程上下文切换、线程上下文切换、中断上下文切换。

进程是由内核管理和调度的,进程的切换只能发生在内核态。进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包含内核堆栈、寄存器等内核空间资源。

线程的优点:

  • 一个进程可以同时存在多个线程
  • 线程之间可以并发执行
  • 线程之间共享地址空间和文件资源

线程缺点:

  • 当进程中的一个线程崩溃了会导致所属进程的所有线程崩溃。(针对C/C++)

线程与进程的最大区别在于:线程是调度的基本单位,进程是资源分配的基本单位。当两个进程属于同一个进程,上下文切换时,只需要切换线程的私有数据、寄存器等不共享数据。

线程分为用户线程、内核线程和轻量级线程。

用户线程

用户线程的整个线程调度和管理操作系统是看不见的,由用户级线程库函数来完成,包括线程的创建、终止、同步和调度等。

优点:可用于不支持线程技术的操作系统;用户线程的切换无需用户态和内核态的切换,速度快。

缺点:

  • 如果一个线程阻塞,进程所包含的所有线程都不能执行
  • 用户态线程没法打断当前运行中的线程
  • 时间片分配给进程,多线程执行时,每个线程得到的时间片少,执行慢

内核线程

优点:一个内核线程阻塞不会影响其他内核线程的运行;分配给线程,多线程的进程获得更多CPU时间

缺点:内核维护进程和线程的上下文信息;线程的创建、终止、切换都是通过系统调用进行,系统开销大

轻量级进程LWP

轻量级进程是内核支持的用户线程,一个进程可有一个或多个LWP,每个LWP是跟内核线程一对一映射的。

调度算法

FCFS先来先服务

SJF短作业优先

HRRN高响应比优先:优先权=(等待时间+要求服务时间)/要求服务时间

RR时间片轮转调度算法

HPF最高优先级调度算法

MFQ多级反馈队列调度算法:优先级越高时间片越短,每个优先级下先来先服务,没运行完降优先级入队

进程通信

管道

分为匿名管道(|)和命名管道(FIFO)。管道就是内核里面的一串缓存。在shell里通过|匿名管道将多个命令连接在一起,实际上就是创建了多个子进程。对于匿名管道,它的通信范围是存在父子进程关系的进程。对于命名管道,它可以在不相关的进程间通信。

消息队列

消息队列是保存在内核中的消息链表。消息队列的生命周期随内核。消息队列不适合比较大的数据传输。消息队列通信过程中,存在用户态和内核态之间的数据拷贝开销。

共享内存

共享内存的机制就是拿出一块虚拟地址空间来,映射到相同的物理内存。

信号量

信号量其实是一个整型的计数器,主要用于实现进程间的互斥和同步,而不是用于缓存进程间通信的数据。

信号

对于异常情况下的工作模式,需要用信号的方式来通知进程。

常见信号:

  • 1 SIGHUP 挂起,常见于守护进程
  • 2 SIGINT 终止进程 ctrl+c
  • 3 SIGQUIT 进程终止并产生core dump(调试用)
  • 9 SIGKILL 强制终止,不可被进程捕获和忽略
  • 11 SIGSEGV 段错误(Segmentation Fault),非法内存访问时内核发送的信号。
  • 15 SIGREM 终止,默认的kill信号,进程可以做清理工作后退出
  • 18 SIGCONT 继续,让被暂停的进程恢复运行
  • 19 SIGSTOP 停止,暂停进程,不可被捕获和忽略
  • 20 SIGTSTP 终端停止,可以被捕获并处理,ctrl+z
  • 17 SIGCHLD 子进程状态改变时发给父进程,父进程可用wait回收子进程

Socket

Socket不仅可以跨网络与不同主机通信,还可以在同主机上进程间通信。

针对TCP协议通信的socket模型:

  • 服务端和客户端初始化socket,得到文件描述符
  • 服务端调用bind,绑定IP和端口
  • 服务端调用listen监听
  • 服务端调用accept等待客户端连接
  • 客户端调用connect发起连接请求
  • 服务端accept返回用于传输的socket文件描述符
  • 客户端调用write写入数据;服务端调用read读取数据
  • 客户端调用close断开连接,服务端read数据的时候读到EOF等待数据处理完后调用close

针对UDP协议通信的socket模型:

  • 初始化socket
  • 调用bind绑定ip port
  • 调用sendto和recvfrom进行通信

针对本地进程间通信的socket模型:

img

哲学家就餐:

  • 每次只能一个哲学家开始就餐
  • 奇数偶数拿叉子顺序相反
  • 两边没进餐才可以进餐

死锁

死锁四个条件:互斥、持有并等待、不可剥夺、循环等待

可以使用pstack+gdb定位死锁问题。pstack可以显示每个线程的栈跟踪信息,通过观察函数调用,定位死锁

避免死锁最常用的方法是使用资源有序分配法来破环循环等待。

互斥锁

互斥锁加锁失败有两次上下文切换:运行-睡眠(加锁失败)、睡眠-就绪(锁释放)

自旋锁

通过CPU提供的CAS函数(Compare And Swap)在用户态完成加锁和解锁。自旋锁一直自旋,直到锁可用。在单核CPU上,需要抢占式的调度器,因为自旋的线程永远不会放弃CPU。

乐观锁 悲观锁

悲观锁在访问共享资源前先上锁。乐观锁先修改完共享资源,再验证这段时间内有没有冲突发生,如果没有,操作完成,如果有,放弃本次操作。

文件非法访问:

  • 只读内存写入
  • 访问没有权限的地址
  • 访问不存在的内存

线程崩溃后一般通过SIGSEGV信号,使得进程崩溃。

如果进程没有注册自己的信号处理函数,系统会执行默认的信号处理程序;如果注册了,则会执行自己的信号处理函数。

为什么线程崩溃不会导致JVM线程崩溃

因为JVM自定义了自己的信号处理函数,拦截了SIGSEGV信号。