操作系统
写在前面
本篇文章学习自https://xiaolincoding.com/os/ 和Javaguide
面试题:
- fork()调用系统底层发生了什么?
- 子进程可以访问父进程的全局变量吗?(不可以)
- 进程间通信方式?
- 缺页中断。
硬件结构
冯诺依曼模型
最重要的是定义计算机基本结构为 5 个部分,分别是运算器、控制器、存储器、输入设备、输出设备,这 5 个部分也被称为冯诺依曼模型。
运算器、控制器是在中央处理器里的,存储器就我们常见的内存,输入输出设备则是计算机外接的设备,比如键盘就是输入设备,显示器就是输出设备。
存储单元和输入输出设备要与中央处理器打交道的话,离不开总线。所以,它们之间的关系如下图:
CPU 内部还有一些组件,常见的有寄存器、控制单元和逻辑运算单元等。其中,控制单元负责控制 CPU 工作,逻辑运算单元负责计算,而寄存器可以分为多种类,每种寄存器的功能又不尽相同。
常见的寄存器种类:
- 通用寄存器,用来存放需要进行运算的数据,比如需要进行加和运算的两个数据。
- 程序计数器,用来存储 CPU 要执行下一条指令「所在的内存地址」,注意不是存储了下一条要执行的指令,此时指令还在内存中,程序计数器只是存储了下一条指令「的地址」。
- 指令寄存器,用来存放当前正在执行的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里。
总线
总线是用于 CPU 和内存以及其他设备之间的通信,总线可分为 3 种:
- 地址总线,用于指定 CPU 将要操作的内存地址;
- 数据总线,用于读写内存的数据;
- 控制总线,用于发送和接收信号,比如中断、设备复位等信号,CPU 收到信号后自然进行响应,这时也需要控制总线;
当 CPU 要读写内存数据的时候,一般需要通过下面这三个总线:
- 首先要通过「地址总线」来指定内存的地址;
- 然后通过「控制总线」控制是读或写命令;
- 最后通过「数据总线」来传输数据;
程序的执行流程
程序实际上是一条一条指令,所以程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是 CPU 了。
那 CPU 执行程序的过程如下:
- 第一步,CPU 读取「程序计数器」的值,这个值是指令的内存地址,然后 CPU 的「控制单元」操作「地址总线」指定需要访问的内存地址,接着通知内存设备准备数据,数据准备好后通过「数据总线」将指令数据传给 CPU,CPU 收到内存传来的数据后,将这个指令数据存入到「指令寄存器」。
- 第二步,「程序计数器」的值自增,表示指向下一条指令。这个自增的大小,由 CPU 的位宽决定,比如 32 位的 CPU,指令是 4 个字节,需要 4 个内存地址存放,因此「程序计数器」的值会自增 4;
- 第三步,CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行;
简单总结一下就是,一个程序执行的时候,CPU 会根据程序计数器里的内存地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。
CPU 从程序计数器读取指令、到执行、再到下一条指令,这个过程会不断循环,直到程序执行结束,这个不断循环的过程被称为 CPU 的指令周期。
存储器的层次结构
对于存储器,它的速度越快、能耗会越高、而且材料的成本也是越贵的,以至于速度快的存储器的容量都比较小。
其中,存储空间越大的存储器设备,其访问速度越慢,所需成本也相对越少。
CPU 并不会直接和每一种存储器设备直接打交道,而是每一种存储器设备只和它相邻的存储器设备打交道。
比如,CPU Cache 的数据是从内存加载过来的,写回数据的时候也只写回到内存,CPU Cache 不会直接把数据写到硬盘,也不会直接从硬盘加载数据,而是先加载到内存,再从内存加载到 CPU Cache 中。
所以,每个存储器只和相邻的一层存储器设备打交道,并且存储设备为了追求更快的速度,所需的材料成本必然也是更高,也正因为成本太高,所以 CPU 内部的寄存器、L1\L2\L3 Cache 只好用较小的容量,相反内存、硬盘则可用更大的容量,这就我们今天所说的存储器层次结构。
内核
什么是内核呢?
计算机是由各种外部硬件设备组成的,比如内存、cpu、硬盘等,如果每个应用都要和这些硬件设备对接通信协议,那这样太累了,所以这个中间人就由内核来负责,让内核作为应用连接硬件设备的桥梁,应用程序只需关心与内核交互,不用关心硬件的细节。
内核有哪些能力呢?
现代操作系统,内核一般会提供 4 个基本能力:
- 管理进程、线程,决定哪个进程、线程使用 CPU,也就是进程调度的能力;
- 管理内存,决定内存的分配和回收,也就是内存管理的能力;
- 管理硬件设备,为进程与硬件设备之间提供通信能力,也就是硬件通信能力;
- 提供系统调用,如果应用程序要运行更高权限运行的服务,那么就需要有系统调用,它是用户程序与操作系统之间的接口。
内核是怎么工作的?
内核具有很高的权限,可以控制 cpu、内存、硬盘等硬件,而应用程序具有的权限很小,因此大多数操作系统,把内存分成了两个区域:
- 内核空间,这个内存空间只有内核程序可以访问;
- 用户空间,这个内存空间专门给应用程序使用;
用户空间的代码只能访问一个局部的内存空间,而内核空间的代码可以访问所有内存空间。因此,当程序使用用户空间时,我们常说该程序在用户态执行,而当程序使内核空间时,程序则在内核态执行。
应用程序如果需要进入内核空间,就需要通过系统调用,下面来看看系统调用的过程:
内核程序执行在内核态,用户程序执行在用户态。当应用程序使用系统调用时,会产生一个中断。发生中断后, CPU 会中断当前在执行的用户程序,转而跳转到中断处理程序,也就是开始执行内核程序。内核处理完后,主动触发中断,把 CPU 执行权限交回给用户程序,回到用户态继续工作
CPU缓存一致性
CPU 在读写数据的时候,都是在 CPU Cache 读写数据的,原因是 Cache 离 CPU 很近,读写性能相比内存高出很多。对于 Cache 里没有缓存 CPU 所需要读取的数据的这种情况,CPU 则会从内存读取数据,并将数据缓存到 Cache 里面,最后 CPU 再从 Cache 读取数据。
而对于数据的写入,CPU 都会先写入到 Cache 里面,然后再在找个合适的时机写入到内存,那就有「写直达」和「写回」这两种策略来保证 Cache 与内存的数据一致性:
- 写直达,只要有数据写入,都会直接把数据写入到内存里面,这种方式简单直观,但是性能就会受限于内存的访问速度;
- 写回,对于已经缓存在 Cache 的数据的写入,只需要更新其数据就可以,不用写入到内存,只有在需要把缓存里面的脏数据交换出去的时候,才把数据同步到内存里,这种方式在缓存命中率高的情况,性能会更好;
当今 CPU 都是多核的,每个核心都有各自独立的 L1/L2 Cache,只有 L3 Cache 是多个核心之间共享的。所以,我们要确保多核缓存是一致性的,否则会出现错误的结果。
要想实现缓存一致性,关键是要满足 2 点:
- 第一点是写传播,也就是当某个 CPU 核心发生写入操作时,需要把该事件广播通知给其他核心;
- 第二点是事物的串行化,这个很重要,只有保证了这个,才能保障我们的数据是真正一致的,我们的程序在各个不同的核心上运行的结果也是一致的;
**基于总线嗅探机制的 MESI 协议**,就满足上面了这两点,因此它是保障缓存一致性的协议。
MESI 协议,是已修改、独占、共享、已失效这四个状态的英文缩写的组合。整个 MSI 状态的变更,则是根据来自本地 CPU 核心的请求,或者来自其他 CPU 核心通过总线传输过来的请求,从而构成一个流动的状态机。另外,对于在「已修改」或者「独占」状态的 Cache Line,修改更新其数据不需要发送广播给其他 CPU 核心。
🔴🟢🟡补充:
CPU缓存一致性(Cache Coherence)是指在多处理器系统中,保证各个处理器缓存中的数据与主存中的数据保持一致,以防止不同处理器读取到不同版本的数据。CPU缓存一致性问题通常出现在多核处理器或多处理器系统中,每个处理器都有自己的缓存。当多个处理器并发地读取和写入共享内存区域时,必须确保缓存数据的一致性。
缓存一致性问题
缓存一致性问题可以描述为以下几种典型情景:
写失效(Write Invalidation):
- 处理器A将数据写入自己的缓存,但处理器B的缓存中仍保留旧数据。这会导致数据不一致。
写更新(Write Update):
- 处理器A将数据写入自己的缓存并更新内存,处理器B的缓存需要同步更新此数据,以确保一致性。
缓存一致性协议
为了解决缓存一致性问题,现代处理器通常采用缓存一致性协议(Cache Coherence Protocols)。最常见的缓存一致性协议有以下几种:
MSI协议(Modified, Shared, Invalid):
- 数据在缓存中的状态可以是Modified(已修改)、Shared(共享)、Invalid(无效)三种状态。
- 当处理器对数据进行写操作时,会将该数据标记为Modified,并使其他缓存中相同数据变为Invalid。
MESI协议(Modified, Exclusive, Shared, Invalid):
- 在MSI的基础上增加了Exclusive状态,表示该数据只存在于一个缓存中且未被修改。
- 处理器在读取数据时,如果该数据在缓存中是Exclusive状态,则可以直接使用,无需访问主存。
MOESI协议(Modified, Owner, Exclusive, Shared, Invalid):
- 在MESI的基础上增加了Owner状态,表示该数据在某个缓存中是最新的版本且共享给其他处理器。
- 处理器可以从Owner缓存中获取最新数据,而无需访问主存。
MESIF协议(Modified, Exclusive, Shared, Invalid, Forward):
- 在MESI的基础上增加了Forward状态,用于优化读操作。当多个缓存共享同一数据时,Forward状态缓存负责向其他处理器转发数据。
缓存一致性操作
缓存一致性协议通过以下操作来维持一致性:
嗅探(Snooping):
- 处理器通过嗅探总线上的通信来监视其他处理器的读写操作。如果检测到其他处理器对共享数据的写操作,缓存行状态会相应地更新。
目录协议(Directory-Based Protocols):
- 系统维护一个目录,记录每个内存块在哪些缓存中存在以及它们的状态。处理器在读写数据时,通过查询目录来更新缓存状态。
总线加锁(Bus Locking):
- 在某些情况下,处理器可以通过锁定总线来保证对数据的独占访问,防止其他处理器干扰。
通过这些协议和操作,CPU可以确保各处理器缓存中的数据一致性,避免并发访问导致的数据不一致问题,提高系统的稳定性和性能。
内存管理
为什么要有虚拟内存
进程管理
进程和线程共享哪些资源
共享的资源和不共享的资源划分如下表:
资源类型 | 共享/不共享 | 描述 |
---|---|---|
代码段 | 共享 | 所有线程执行相同的代码段。 |
进程地址空间 | 共享 | 包括堆和全局变量,所有线程可以访问和修改。 |
打开的文件描述符 | 共享 | 所有线程可以访问进程打开的文件和套接字。 |
当前工作目录 | 共享 | 所有线程共享相同的当前工作目录。 |
用户ID和组ID | 共享 | 线程共享相同的用户ID和组ID,具有相同的权限。 |
信号处理器 | 共享 | 所有线程共享相同的信号处理器。 |
定时器 | 共享 | 由进程设置的定时器是线程共享的。 |
栈 | 不共享 | 每个线程有自己的栈,用于存储局部变量和函数调用信息。 |
线程局部存储 | 不共享 | 每个线程有自己的线程局部存储,用于存储线程特有的数据。 |
程序计数器 | 不共享 | 每个线程有自己的程序计数器,指示当前执行的指令。 |
寄存器 | 不共享 | 每个线程有自己的一组寄存器。 |
信号掩码 | 不共享 | 每个线程可以有自己的信号掩码。 |
调度信息 | 不共享 | 每个线程可以有自己的优先级和调度属性。 |
进程通信
进程通信涉及在不同进程之间传递数据或信息。由于进程通常在独立的内存空间中运行,它们需要特定的机制来交换数据。**进程通信的主要目标是数据交换和信息传递,使得多个进程能够协同工作。**
管道
匿名管道顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|
」竖线就是匿名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来匿名管道是只能用于存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。
命名管道突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。
消息队列
消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
共享内存+信号量
共享内存可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。
那么,就需要信号量来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。
信号
信号是异步通信机制,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL
和 SIGSTOP
,这是为了方便我们能在任何时候结束或停止某个进程。
Socket
如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。
进程同步
进程同步旨在协调多个进程的执行顺序,以防止数据不一致和竞态条件(Race Conditions)。当多个进程同时访问和操作共享资源时,进程同步是必需的。**进程同步的主要目标是确保共享资源的完整性和一致性,防止数据冲突和死锁等问题。**
常用的进程同步机制包括:
**互斥锁 (Mutexes)**:确保某一时刻只有一个进程能够访问共享资源。
**信号量 (Semaphores)**:可以用来限制对多个资源的访问,分为计数信号量和二进制信号量。
**条件变量 (Condition Variables)**:用于进程等待特定条件变为真时再继续执行。
**读写锁 (Read-Write Locks)**:允许多读单写的访问模式,提高并发性。
PCB 是什么?包含哪些信息?
PCB(Process Control Block) 即进程控制块,是操作系统中用来管理和跟踪进程的数据结构,每个进程都对应着一个独立的 PCB。你可以将 PCB 视为进程的大脑。
当操作系统创建一个新进程时,会为该进程分配一个唯一的进程 ID,并且为该进程创建一个对应的进程控制块。当进程执行时,PCB 中的信息会不断变化,操作系统会根据这些信息来管理和调度进程。
PCB 主要包含下面几部分的内容:
- 进程的描述信息,包括进程的名称、标识符等等;
- 进程的调度信息,包括进程阻塞原因、进程状态(就绪、运行、阻塞等)、进程优先级(标识进程的重要程度)等等;
- 进程对资源的需求情况,包括 CPU 时间、内存空间、I/O 设备等等。
- 进程打开的文件信息,包括文件描述符、文件类型、打开模式等等。
- 处理机的状态信息(由处理机的各种寄存器中的内容组成的),包括通用寄存器、指令计数器、程序状态字 PSW、用户栈指针。
每个 PCB 是如何组织的呢?
通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:
- 将所有处于就绪状态的进程链在一起,称为就绪队列;
- 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列;
- 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。
那么,就绪队列和阻塞队列链表的组织形式如下图:
除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。
一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。
进程有哪几种状态?
我们一般把进程大致分为 5 种状态,这一点和线程很像!
- **创建状态(new)**:进程正在被创建,尚未到就绪状态。
- **就绪状态(ready):进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源**,一旦得到处理器资源(处理器分配的时间片)即可运行。
- **运行状态(running)**:进程正在处理器上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。
- **阻塞状态(waiting)**:又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。
- **结束状态(terminated)**:进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。
进程的控制
我们熟知了进程的状态变迁和进程的数据结构 PCB 后,再来看看进程的创建、终止、阻塞、唤醒的过程,这些过程也就是进程的控制。
01 创建进程
操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。
创建进程的过程如下:
- 申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识等;
- 为该进程分配运行时所必需的资源,比如内存资源;
- 将 PCB 插入到就绪队列,等待被调度运行;
02 终止进程
进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill
掉)。
当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。
终止进程的过程如下:
- 查找需要终止的进程的 PCB;
- 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程;
- 如果其还有子进程,则应将该进程的子进程交给 1 号进程接管;
- 将该进程所拥有的全部资源都归还给操作系统;
- 将其从 PCB 所在队列中删除;
03 阻塞进程
当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。
阻塞进程的过程如下:
- 找到将要被阻塞进程标识号对应的 PCB;
- 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;
- 将该 PCB 插入到阻塞队列中去;
04 唤醒进程
进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。
如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。
唤醒进程的过程如下:
- 在该事件的阻塞队列中找到相应进程的 PCB;
- 将其从阻塞队列中移出,并置其状态为就绪状态;
- 把该 PCB 插入到就绪队列中,等待调度程序调度;
进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。
线程相比进程能减少开销
线程是调度的基本单位,而进程则是资源拥有的基本单位。
对于,线程相比进程能减少开销,体现在:
- 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
- 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
- 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
- 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;
所以,不管是时间效率,还是空间效率线程比进程都要高。
协程与线程和进程的对比
协程(Coroutine)是操作系统和编程语言中一种用于并发编程的技术,它允许在执行过程中暂停和恢复函数,提供比传统线程更轻量级的并发机制。协程有几个关键特点,使其与线程和进程有显著区别。
协程的特点
- 轻量级:协程的创建和切换开销非常小,通常仅仅是函数调用和返回的开销,不需要像线程那样依赖操作系统调度和上下文切换。
- 用户态切换:协程的切换是在用户态完成的,不涉及内核态操作,避免了线程切换时的上下文切换开销。
- 非抢占式:协程的切换是协作式的,即协程主动让出执行权,而不是由操作系统抢占。
- 状态保存:协程在暂停执行时保存当前执行的状态(如局部变量和程序计数器),在恢复时从保存的状态继续执行。
对比
- 线程:由操作系统管理,支持真正的并行执行(在多核处理器上),但创建和上下文切换开销较大,容易产生竞态条件和死锁问题。
- 进程:独立的内存空间,隔离性强,适合执行完全独立的任务,但创建和通信开销更大。
- 协程:用户态管理,轻量级,适合I/O密集型任务和高并发场景,但不适合CPU密集型任务,因为单线程内的协程无法并行执行。
总之,协程通过提供一种轻量级的并发机制,简化了异步编程模型,提高了程序的执行效率,特别适合I/O密集型和高并发任务。
线程的实现方式
线程的实现方式主要包括用户线程、内核线程和轻量级进程(LWP)。每种实现方式在管理、调度和性能方面都有不同的特点和适用场景。以下是对这三种线程的详细解释。
1. 用户线程(User Thread)
特点:
- 用户空间实现:用户线程在用户空间中实现,由用户态的线程库管理和调度,而不是由操作系统内核管理。
- 轻量级:由于线程的创建、切换和同步在用户态完成,开销较小,效率较高。
- 不可抢占:用户线程的调度由用户程序控制,通常采用协作式调度,线程主动让出控制权。
- 无内核干预:内核对用户线程不可见,线程切换不涉及内核态,因此不会引起内核上下文切换。
优点:
- 高效:线程操作在用户态进行,避免了系统调用的开销。
- 灵活:用户程序可以自定义调度策略,适应特定应用需求。
缺点:
- 阻塞问题:如果一个用户线程进行阻塞系统调用(如I/O操作),整个进程都会被阻塞,因为内核无法识别和调度其他线程。
- 多核利用不足:用户线程无法利用多处理器系统的并行计算能力,因为内核只看到一个单一的进程。
2. 内核线程(Kernel Thread)
特点:
- 内核空间实现:内核线程在内核空间实现,由操作系统内核直接管理和调度。
- 抢占式调度:内核线程可以被操作系统内核抢占和调度,支持多任务并发。
- 可见性:内核线程对操作系统内核可见,内核可以识别和管理每个线程的状态。
优点:
- 阻塞处理:内核线程的阻塞操作不会影响其他线程的执行,因为内核可以调度其他可运行的线程。
- 多核支持:内核线程可以在多个处理器上并行执行,充分利用多核系统的计算能力。
缺点:
- 高开销:线程的创建、切换和同步需要系统调用,开销较大。
- 复杂性:内核线程的管理和调度需要操作系统内核的支持,增加了实现的复杂性。
3. 轻量级进程(LightWeight Process, LWP)
特点:
- 混合实现:轻量级进程是一种内核支持用户线程的实现方式,它在内核中为每个用户线程创建一个内核线程,从而提供用户线程的灵活性和内核线程的性能。
- 一对一模型:每个用户线程对应一个内核线程,操作系统内核负责调度和管理这些内核线程。
优点:
- 综合优势:结合了用户线程和内核线程的优点,既有用户线程的高效性,又有内核线程的阻塞处理和多核支持能力。
- 并发性:支持多线程的并发执行,能够充分利用多处理器资源。
缺点:
- 开销较高:每个用户线程都需要一个对应的内核线程,因此线程管理的开销较高。
- 复杂性:实现和调度都较为复杂,需要操作系统和线程库的协同工作。
线程实现方式总结
特点 | 用户线程(User Thread) | 内核线程(Kernel Thread) | 轻量级进程(LightWeight Process, LWP) |
---|---|---|---|
实现位置 | 用户空间 | 内核空间 | 混合实现(内核支持用户线程) |
调度方式 | 用户态库调度 | 内核调度 | 内核调度 |
线程可见性 | 内核不可见 | 内核可见 | 内核可见 |
阻塞处理 | 整个进程阻塞 | 不影响其他线程 | 不影响其他线程 |
多核支持 | 利用不足 | 充分利用 | 充分利用 |
操作开销 | 低 | 高 | 较高 |
应用场景 | 适用于轻量级并发 | 适用于高并发和阻塞操作 | 综合高效并发和多核支持 |
理解这三种线程实现方式的差异有助于在实际应用中选择合适的并发编程模型,以充分利用系统资源,提高程序性能和响应速度。
协程和用户线程对比
协程和用户线程确实有许多相似之处,但它们并不是完全相同的概念。以下是协程和用户线程的详细对比,帮助理解它们的相似性和差异。
相似之处
- 用户态管理:协程和用户线程都在用户态实现和管理,不依赖于操作系统内核的调度。
- 轻量级:两者都具有轻量级的特点,创建和切换的开销较小。
- 非抢占式:通常都采用非抢占式调度方式,需要主动让出执行权。
差异
调度模型:
- 用户线程:由用户态的线程库进行管理和调度,线程调度通常需要显式调用库函数来切换上下文。用户线程更像是轻量级的线程,多个用户线程可能映射到一个内核线程,依赖操作系统的线程模型。
- 协程:协程是更加灵活的控制流结构,可以在函数内部挂起和恢复执行。调度更细粒度,可以在任意位置暂停和恢复。协程的切换通常是通过语言内置的关键字或库函数实现的,调度完全在应用程序控制之下。
执行模型:
- 用户线程:通常有自己的栈空间和上下文,可以被调度执行类似于独立的执行单元。
- 协程:更像是具有可保存状态的函数,执行到
yield
或await
等关键字时挂起,再次调用时从挂起处继续执行。协程共享同一个线程的栈空间,切换时只保存和恢复少量的状态信息。
并发性:
- 用户线程:多个用户线程可能会在不同的内核线程上并发执行(取决于底层实现),适合多核并行计算。
- 协程:通常在单个线程内调度,不涉及真正的并行执行。适用于大量I/O操作和高并发场景,但不适合CPU密集型任务。
实现复杂性:
- 用户线程:需要显式管理线程创建、同步和调度,编程模型类似于多线程编程。
- 协程:提供了更高层次的抽象,简化了异步和并发编程,编写协程通常更简单直观。
总结
协程和用户线程虽然都在用户态实现,且具有轻量级和高效的特点,但它们在调度模型、执行方式和适用场景上有显著区别:
- 协程:更适合处理异步I/O操作和高并发任务,提供细粒度的控制流管理,编程模型简洁。
- 用户线程:适用于多线程编程模型,需要显式管理线程同步和调度,能更好地利用多核处理器资源。
理解这些区别有助于选择合适的并发编程模型,根据具体应用场景和需求决定使用协程还是用户线程。
僵尸线程和孤儿线程
在操作系统中,僵尸进程和孤儿进程是与进程的生命周期和进程关系管理相关的两种特殊状态。理解它们有助于处理进程间的关系和资源管理问题。
僵尸进程 (Zombie Process)
定义
僵尸进程是已经终止但其终止状态还没有被父进程获取的进程。当一个进程完成其执行并调用exit
系统调用后,它会进入终止状态。操作系统会保留该进程的部分信息(如进程ID和退出状态)以便父进程通过wait
系统调用获取。如果父进程没有及时调用wait
,该进程的部分信息将保留在系统中,此时该进程被称为僵尸进程。
特点
- 状态:僵尸进程处于终止状态,不会占用CPU和内存资源,但会占用进程表项(PID)。
- 识别:通过命令
ps
可以看到进程状态为Z
(表示Zombie)。 - 清理:父进程调用
wait
或waitpid
获取子进程的终止状态后,系统会清理僵尸进程的资源。如果父进程已经终止,init进程(PID 1)会接管并清理它们。
孤儿进程 (Orphan Process)
定义
孤儿进程是其父进程已经终止,但它自己仍然在运行的进程。当一个父进程终止时,其仍在运行的子进程会成为孤儿进程。这些孤儿进程会被系统的init进程(PID 1)接管,成为init进程的子进程。
特点
- 状态:孤儿进程继续运行,不会受父进程终止的影响。
- 管理:孤儿进程被init进程接管,init进程会负责对它们进行适当的管理和清理。
在这段时间内,可以使用ps
命令观察子进程的父进程ID变为1,表示它被init进程接管。
总结
- 僵尸进程:已终止但父进程未获取其状态的进程,不释放进程表项。清理方法是父进程调用
wait
或waitpid
。 - 孤儿进程:父进程已终止但进程自身仍在运行的进程,被init进程接管并管理。孤儿进程正常运行,不需要特殊清理。
理解和处理僵尸进程和孤儿进程对于系统资源管理和进程关系管理至关重要。
线程崩溃了,进程也会崩溃吗?
正常情况下,操作系统为了保证系统安全,所以针对非法内存访问会发送一个 SIGSEGV 信号,而操作系统一般会调用默认的信号处理函数(一般会让相关的进程崩溃)。
但如果进程觉得”罪不致死”,那么它也可以选择自定义一个信号处理函数,这样的话它就可以做一些自定义的逻辑,比如记录 crash 信息等有意义的事。
回过头来看为什么虚拟机会针对 StackoverflowError 和 NullPointerException 做额外处理让线程恢复呢,针对 stackoverflow 其实它采用了一种栈回溯的方法保证线程可以一直执行下去,而捕获空指针错误主要是这个错误实在太普遍了。
为了这一个很常见的错误而让 JVM 崩溃那线上的 JVM 要宕机多少次,所以出于工程健壮性的考虑,与其直接让 JVM 崩溃倒不如让线程起死回生,并且将这两个错误/异常抛给用户来处理。
死锁问题
简单来说,死锁问题的产生是由两个或者以上线程并行执行的时候,争夺资源而互相等待造成的。
死锁只有同时满足**互斥、持有并等待、不可剥夺、环路等待**这四个条件的时候才会发生。
所以要避免死锁问题,就是要破坏其中一个条件即可,最常用的方法就是使用**资源有序分配法**来破坏环路等待条件。
解决方法
解决死锁的方法可以从多个角度去分析,一般的情况下,有预防,避免,检测和解除四种。
预防死锁是破坏这四个状态
预防:
而死锁的避免相反,它的角度是允许系统中同时存在四个必要条件 ,只要掌握并发进程中与每个进程有关的资源动态申请情况,做出 明智和合理的选择 ,仍然可以避免死锁,因为四大条件仅仅是产生死锁的必要条件。
我们将系统的状态分为 安全状态 和 不安全状态 ,每当在为申请者分配资源前先测试系统状态,若把系统资源分配给申请者会产生死锁,则拒绝分配,否则接受申请,并为它分配资源。
如果操作系统能够保证所有的进程在有限的时间内得到需要的全部资源,则称系统处于安全状态,否则说系统是不安全的。很显然,系统处于安全状态则不会发生死锁,系统若处于不安全状态则可能发生死锁。
那么如何保证系统保持在安全状态呢?通过算法,其中最具有代表性的 避免死锁算法 就是 Dijkstra 的银行家算法,银行家算法用一句话表达就是:当一个进程申请使用资源的时候,银行家算法 通过先 试探 分配给该进程资源,然后通过 安全性算法 判断分配后系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待,若能够进入到安全的状态,则就 真的分配资源给该进程
调度算法
进程调度
先来先服务调度算法
最简单的一个调度算法,就是非抢占式的先来先服务(*First Come First Severd, FCFS*)算法了。
顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。
FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。
最短作业优先调度算法
最短作业优先(*Shortest Job First, SJF*)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
高响应比优先调度算法
前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。
那么,高响应比优先 (*Highest Response Ratio Next, HRRN*)调度算法主要是权衡了短作业和长作业。
每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:
从上面的公式,可以发现:
- 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;
- 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会;
时间片轮转调度算法
最古老、最简单、最公平且使用最广的算法就是时间片轮转(*Round Robin, RR*)调度算法。
每个进程被分配一个时间段,称为时间片(*Quantum*),即允许该进程在该时间段中运行。
- 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程;
- 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;
另外,时间片的长度就是一个很关键的点:
- 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
- 如果设得太长又可能引起对短作业进程的响应时间变长。将
通常时间片设为 20ms~50ms
通常是一个比较合理的折中值。
最高优先级调度算法
前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。
但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(*Highest Priority First,HPF*)调度算法。
进程的优先级可以分为,静态优先级或动态优先级:
- 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
- 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
- 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
- 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
多级反馈队列调度算法
多级反馈队列(*Multilevel Feedback Queue*)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。
顾名思义:
- 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
- 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;
来看看,它是如何工作的:
- 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;
- 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
- 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;
可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。