操作系统笔记
Created at 2020-02-02 Updated at 2021-12-11 Category 基础 views
概述
操作系统就是一个协调、管理和控制计算机硬件资源和软件资源的控制程序。

基本特征
并发
并发是指宏观上在一段时间内能同时运行多个程序,而并行则指同一时刻能运行多个指令。
并行需要硬件支持,如多流水线、多核处理器或者分布式计算系统。
操作系统通过引入进程和线程,使得程序能够并发运行。
共享
共享是指系统中的资源可以被多个并发进程共同使用。
有两种共享方式:互斥共享和同时共享。
互斥共享的资源称为临界资源,例如打印机等,在同一时刻只允许一个进程访问,需要用同步机制来实现互斥访问。
虚拟
虚拟技术把一个物理实体转换为多个逻辑实体。
主要有两种虚拟技术:时(时间)分复用技术和空(空间)分复用技术。
- 时分复用:多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占用处理器,每次只执行一小个时间片并快速切换。
- 空分复用:虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间的页被映射到物理内存,地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。
异步
异步指进程不是一次性执行完毕,而是走走停停,以不可知的速度向前推进。
基本功能
- 进程管理
- 内存管理
- 文件管理
- 设备管理
分时系统与实时系统
- 分时系统(Sharing time system):系统把 CPU 时间分成很短的时间片,轮流地分配给多个作业。对多个用户地多个作业都能保证足够快的响应时间,并且有效提高了资源利用率。
- 实时系统(Rael-time system):系统对外部输入的信息,能够在规定的时间内处理完并做出反应。能够集中地即时地处理并做出反应,高可靠性、安全性。
上下文切换
对于单核单线程 CPU 而言,在某一时刻只能执行一条 CPU 指令。上下文切换(Context Switch)是一种将 CPU 资源从一个进程分配给另一个进程的机制。从用户角度看,计算机能够并行运行多个进程,这恰恰是操作系统通过快速上下文切换造成的结果。在切换的过程中,操作系统需要先存储当前进程的状态(包括内存空间的指针,当前执行完的指令等等),再读入下一个进程的状态,然后执行此进程。
链接
编译系统的过程:预处理 - 编译 - 汇编 - 链接。
静态库是一个外部函数与变量的集合体。静态库的文件内容,通常包含一堆程序员自定的变量与函数,其内容不像动态链接库那么复杂,在编译期间由编译器与连接器将它集成至应用程序内,并制作成目标文件以及可以独立运作的可执行文件。而这个可执行文件与编译可执行文件的程序,都是一种程序的静态创建(static build)。
静态链接
静态链接器以一组可重定位目标文件为输入,生成一个完全链接的可执行目标文件作为输出。链接器主要完成以下两个任务:
- 符号解析:每个符号对应于一个函数、一个全局变量或一个静态变量,符号解析的目的是将每个符号引用与一个符号定义关联起来。
- 重定位:链接器通过把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
动态链接
静态库有以下两个问题:
- 当静态库更新时那么整个程序都要重新进行链接;
- 对于 printf 这种标准函数库,如果每个程序都要有代码,这会极大浪费资源。
共享库是为了解决静态库的这两个问题而设计的,在 Linux 系统中通常用 .so 后缀来表示,Windows 系统上它们被称为 DLL。它具有以下特点:
- 在给定的文件系统中一个库只有一个文件,所有引用该库的可执行目标文件都共享这个文件,它不会被复制到引用它的可执行文件中;
- 在内存中,一个共享库的 .text 节(已编译程序的机器代码)的一个副本可以被不同的正在运行的进程共享。
微内核
现代操作系统结构设计中,大多数采用了基于客户/服务器模式的微内核结构,将操作系统划分为两个部分:微内核和多个服务器。
特点
- 足够小的内核
- 基于客户/服务器模式:客户与服务器之间是借助微内核提供的消息传递机制来实现信息交互的。
- 应用“机制与策略分离”原理
- 采用面向对象技术
基本功能
- 进程/线程管理
- 低级存储器管理
- 中断和陷入处理
微内核存在的问题:微内核 OS 采用客户/服务器模式,有许多优点,但是也存在一些缺点,如客户进程与服务进程,服务进程与服务进程通信时,都需要经过微内核,会存在多次用户/内核模式及上下文切换,这使得开销较大。
内核
内核是一组程序模块,提供进程并发执行及基本功能和基本操作,通常驻留在内核空间,运行于内核态,具有直接访问硬件设备和所有内存空间的权限,是仅有的能够执行特权指令的程序。
用户态和内核态

仅在内核态下才能使用的指令称为特权指令,非特权指令在用户态和内核态下都能工作。
用户态向内核态转换的情况:
- 程序请求操作系统服务,执行系统调用。
- 在程序运行时产生中断事件(如 I/O 操作完成),运行程序被中断,转向中断处理程序处理。
- 在程序运行时产生异常事件(如在目态下执行特权指令),运行程序被打断,转向异常处理程序工作。
系统调用
系统调用是程序向系统内核请求服务的方式。
程序的执行一般是在用户态下执行的,但当程序需要使用操作系统提供的服务时,比如说打开某一设备、创建文件、读写文件等,就需要向操作系统发出调用服务的请求,这就是系统调用。
如果一个进程在用户态需要使用内核态的功能,就进行系统调用从而陷入内核,由操作系统代为完成。
中断
中断就是在计算机执行程序的过程中,由于出现了某些特殊事情,使得 CPU 暂停对程序的执行,转而去执行处理这一事件的程序。等这些特殊事情处理完之后再回去执行之前的程序,中断一般分为三类。
- 外中断:由外部设备请求引起的中断,如 I/O 完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。此外还有时钟中断、控制台中断等。
- 异常:由 CPU 执行指令的内部事件引起,如非法操作码、地址越界、算术溢出等。
- 陷入(软中断):在用户程序中执行了引起中断的指令(系统调用)而造成的中断。
进程管理
进程(Process)
进程是资源分配的基本单位。
进程控制块 (Process Control Block, PCB) 描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作。

- 就绪状态(ready):等待被调度
- 运行状态(running)
- 阻塞状态(waiting):等待资源
只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得 CPU 时间,转为运行状态;而运行状态的进程,在分配给它的 CPU 时间片用完之后就会转为就绪状态,等待下一次调度。
阻塞状态是缺少需要的资源从而由运行状态转换而来,但是该资源不包括 CPU 时间,缺少 CPU 时间会从运行态转换为就绪态。
线程(Thred)
线程是独立调度的基本单位。
一个进程中可以有多个线程,它们共享进程资源。
每个线程有自己独立的栈空间,线程彼此之间无法访问其他线程栈上的内容。作为处理机调度的最小单位,线程调度只需要保存线程栈、寄存器数据和 PC 即可,相比进程切换开销小很多。
进程与线程的区别
- 拥有资源:进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。
- 调度:线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
- 系统开销:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
- 通信方面:线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。
线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 cpu 时间片(timeslice)后就处于 RUNNING(运行) 状态。
处理机调度
调度种类
- 高级调度(High-Level Scheduling):又称为作业调度,它决定把后备作业调入内存运行。
- 中级调度(Intermediate-Level Scheduling):又称为在虚拟存储器中引入,在内、外存对换区进行进程对换。
- 低级调度(Low-Level Scheduling):又称为进程调度,它决定把就绪队列的某进程获得CPU。
进程调度算法
- 批处理系统:批处理系统没有太多的用户操作,在该系统总,调度算法目标是保障吞吐量和周转时间。
- 先来先服务:非抢占式
- 最短作业优先:非抢占式
- 最短剩余时间优先:抢占式
- 交互式系统
- 时间片轮转
- 优先级调度
- 多级反馈队列
非抢占式调度与抢占式调度
- 非抢占式:分派程序一旦把处理机分配给某进程后便让它一直运行下去,直到进程完成或发生进程调度进程调度某事件而阻塞时,才把处理机分配给另一个进程。
- 抢占式:操作系统将正在运行的进程强行暂停,由调度程序将 CPU 分配给其他就绪进程的调度方式。
衡量调度算法的性能指标
- 响应时间: 从用户输入到产生反应的时间
- 周转时间: 从任务开始到任务结束的时间
CPU任务可以分为交互式任务和批处理任务,调度最终的目标是合理的使用CPU,使得交互式任务的响应时间尽可能短,用户不至于感到延迟,同时使得批处理任务的周转时间尽可能短,减少用户等待的时间。
守护、孤儿、僵尸进程
守护进程:运行在后台的一种特殊进程,独立于控制终端并周期性地执行某些任务。
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将会成为孤儿进程。
孤儿进程将会被 init 进程(进程号为1)所收养,并由 init 进程对他们完成状态收集工作。由于孤儿进程会被 init 所收养,所以孤儿进程不会对系统造成危害。
僵尸进程:如果子进程退出,而父进程并没有调用 wait() 或 waitpid(),那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵尸进程。
系统所能使用的进程号是有限的,如果产生大量僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程。要消灭系统中大量的僵尸进程,只需要将其父进程杀死,此时僵尸进程就会变成孤儿进程,从而被 init 所收养,这样 init 就会释放所有的僵尸进程所占有的资源,从而结束僵尸进程。
进程同步
当用户创立多个线程/进程时,如果不同线程/进程同时读写相同的内容,则可能造成读写错误,或者数据不一致。此时,需要通过加锁的方式,控制临界区(critical section)的访问权限。对于semaphore而言,在初始化变量的时候可以控制允许多少个线程/进程同时访问一个临界区,其他的线程/进程会被堵塞,直到有人解锁。
临界区
只能被一个进程占用的资源就是临界资源。进程内访问临界的代码被称为临界区。
为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。
同步与互斥
- 同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系。
- 互斥:多个进程在同一时刻只有一个进程能进入临界区。
信号量机制
信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。
- down : 如果信号量大于 0 ,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0;
- up :对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。
- 当 s >= 0,表示系统中当前可用资源的数目。
- 当 s<0,其绝对值表示系统中因请求该类资源而被阻塞的进程数目。
PV操作被设计成原语,不可分割。
如果信号量的取值只能为 0 或者 1,那么就成为了互斥量,0 代表临界区已经加锁,1 代表临界区解锁。
管程
Monitor(管程)是用来实现并发的一种技术,用来解决互斥与同步问题。
管程的定义是:用来管理共享变量以及对共享变量操作的过程。
使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。
生产者-消费者问题
使用信号量实现
#define N 100
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;
void producer() {
while(TRUE) {
int item = produce_item();
down(&empty);
down(&mutex);
insert_item(item);
up(&mutex);
up(&full);
}
}
void consumer() {
while(TRUE) {
down(&full);
down(&mutex);
int item = remove_item();
consume_item(item);
up(&mutex);
up(&empty);
}
}
进程间通信(IPC)
进程通信是指进程间传输信息,而进程同步是控制多个进程按一定顺序执行。
进程通信是一种手段,而进程同步是一种目的。
- 管道:无名管道是一种特殊的文件,只存在于内存中,只支持半双工通信,只能在父子进程中使用。通过
pipe函数创建,fd[0] 用于读,fd[1] 用于写。 - FIFO:命名管道,是 FIFO 文件,存在于文件系统中,去除了管道只能在父子进程中使用的限制。FIFO 常用于客户-服务器应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程之间传递数据。
- 消息队列:相比于 FIFO,消息队列具有以下优点:
- 消息队列可以独立于读写进程存在,从而避免了FIFO 中同步管道的打开和关闭时可能产生的困难。
- 避免了 FIFO 的同步阻塞问题,不需要进程自己提供同步方法
- 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收
- 共享数据:允许多个进程共享一个给定的存储区。因为数据不需要在进程之间复制,所以这是最快的一种 IPC。需要使用信号量用来同步对共享存储的访问。多个进程可以将同一个文件映射到它们的地址空间从而实现共享内存。另外 XSI 共享内存不是使用文件,而是使用使用内存的匿名段。
- 信号量:它是一个计数器,用于为多个进程提供共享数据对象的访问。
- 套接字:与其它通信机制不同的是,它可用于不同机器间的进程通信。
线程同步
即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态,实现线程同步的方法有很多,临界区对象就是其中一种。
- 互斥量 Mutex:互斥量跟临界区很相似,只有拥有互斥对象的线程才具有访问资源的权限,由于互斥对象只有一个,因此就决定了任何情况下此共享资源都不会同时被多个线程所访问。当前占据资源的线程在任务处理完后应将拥有的互斥对象交出,以便其他线程在获得后得以访问资源。互斥量比临界区复杂。因为使用互斥不仅仅能够在同一应用程序不同线程中实现资源的安全共享,而且可以在不同应用程序的线程之间实现对资源的安全共享。
- 临界区:在任意时刻只允许一个线程对共享资源进行访问。如果有多个线程试图同时访问临界区,那么 在有一个线程进入后其他所有试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开。临界区在被释放后,其他线程可以继续抢占,并以此达到用原子方式操 作共享资源的目的。 仅能在同一进程内使用。
- 信号量:信号量对象对线程的同步方式与前面几种方法不同,信号允许多个线程同时使用共享资源 ,这与操作系统中的PV操作相同。
- 事件:事件机制,则允许一个线程在处理完一个任务后,主动唤醒另外一个线程执行任务。
Java 中可以使用 synchronized、volatile、ReentrantLock、ThredLocal、阻塞队列、原子变量 实现线程同步。
线程通信
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
线程通信有两种方式:内存共享与发送消息
- 锁机制:包括互斥锁、条件变量、读写锁
- 信号量机制(Semaphore):包括无名线程信号量和命名线程信号量
- 信号机制(Signal):类似进程间的信号处理
死锁
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象。
比如线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
产生的原因
- 系统资源不足
- 系统运行推进的顺序不合适
- 资源分配不当
必要条件
- 互斥:资源不能被共享,只能由一个进程使用。
- 请求与保持:已经得到资源的进程可以再次申请新的资源。
- 不可剥夺:已经分配的资源不能从相应的进程中被强制地剥夺。
- 循环等待:系统中若干进程组成环路,该环路中每个进程都在等待相邻进程正占用的资源。
上述条件之一不满足,就不会发生死锁。
死锁的预防
死锁的预防就是打破产生死锁的必要条件。
- 破坏请求与保持:实行资源预先分配策略。即进程在运行前一次性地向系统申请它所需要的全部资源。
- 破坏不可剥夺:即允许进程强行从占有者那里夺取某些资源。
- 破坏循环等待:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。
线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁
死锁的避免
它不限制进程有关申请资源的命令,而是对进程所发出的每一个申请资源命令加以动态的检查,并根据额检查结果决定是否进行资源分配。
银行家算法就是一个避免死锁的办法。
Java 实现死锁
public static void main(String[] args) {
final Object a = new Object();
final Object b = new Object();
Thread threadA = new Thread(new Runnable() {
public void run() {
synchronized (a) {
try {
System.out.println("now i in threadA-locka");
Thread.sleep(1000l);
synchronized (b) {
System.out.println("now i in threadA-lockb");
}
} catch (Exception e) {
// ignore
}
}
}
});
Thread threadB = new Thread(new Runnable() {
public void run() {
synchronized (b) {
try {
System.out.println("now i in threadB-lockb");
Thread.sleep(1000l);
synchronized (a) {
System.out.println("now i in threadB-locka");
}
} catch (Exception e) {
// ignore
}
}
}
});
threadA.start();
threadB.start();
}
内存管理
虚拟内存的目的是为了让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存。
为了更好的管理内存,操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每一块称为一页。这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。当程序引用到不在物理内存中的页时,由硬件执行必要的映射,将缺失的部分装入物理内存并重新执行失败的指令。
从上面的描述中可以看出,虚拟内存允许程序不用将地址空间中的每一页都映射到物理内存,也就是说一个程序不需要全部调入内存就可以运行,这使得有限的内存运行大程序成为可能。例如有一台计算机可以产生 16 位地址,那么一个程序的地址空间范围是 0~64K。该计算机只有 32KB 的物理内存,虚拟内存技术允许该计算机运行一个 64K 大小的程序。
分页
页表存储着页(程序地址空间)和页框(物理内存空间)的映射表。
页面置换算法
在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘对换区中来腾出空间。
最佳(OPT, Optimal replacement algorithm)
先进先出(FIFO,First In First Out):淘汰最先进入的页面
最近最久未使用(LRU, Least Recently Used):淘汰访问时间最久的页面
最近最少使用(LFU,Least Frequently Used):淘汰访问次数最少的页面
最近未使用(NRU,Not Recently Used):每个页面都有两个状态位:R 与 M,当页面被访问时设置页面的 R=1,当页面被修改时设置 M=1。其中 R 位会定时被清零。可以将页面分成以下四类:
- R=0,M=0
- R=0,M=1
- R=1,M=0
- R=1,M=1
当发生缺页中断时,NRU 算法随机地从类编号最小的非空类中挑选一个页面将它换出。
NRU 优先换出已经被修改的脏页面(R=0,M=1),而不是被频繁使用的干净页面(R=1,M=0)。
时钟(CLOCK):在页表项加入 访问位 Access 位 描述页面在过去一段时间内访问情况,各页面组织成环形链表。
- 页面装入内存时 访问位 置 0
- 访问页面 (读/写) 时 访问位 置 1
- 缺页时 从指针当前位置顺序检查环形链表
- 访问位为 0 则置换此页
- 访问位为 1 则访问位置 0 指针继续移动到下一个页面 直到找到可置换页面
分段
虚拟内存采用的是分页技术,也就是将地址空间划分成固定大小的页,每一页再与内存进行映射。
编译器在编译过程中会建立多个表,有 4 个表是动态增长的,如果使用分页系统的一维地址空间,动态增长的特点会导致覆盖问题的出现。
分段的做法是把每个表分成段,一个段构成一个独立的地址空间。每个段的长度可以不同,并且可以动态增长。
段页式
程序的地址空间划分成多个拥有独立地址空间的段,每个段上的地址空间划分成大小相同的页。这样既拥有分段系统的共享和保护,又拥有分页系统的虚拟内存功能。
分页与分段的区别
- 对程序员的透明性:分页透明,但是分段需要程序员显式划分每个段。
- 地址空间的维度:分页是一维地址空间,分段是二维的。
- 大小是否可以改变:页的大小不可变,段的大小可以动态改变。
- 出现的原因:分页主要用于实现虚拟内存,从而获得更大的地址空间;分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。
网络 I/O 模型

假设服务器已经在监听用户请求,建立连接后服务器调用 read() 函数等待读取用户发过来的数据流,之后将接收到的数据打印出来。 服务器端简单的流程是:建立连接 -> 监听请求 -> 等待用户数据 -> 打印数据。总结网络通信中的等待:
- 建立连接时等待对方的 ACK 包(TCP)
- 等待客户端请求(HTTP)
- 输入等待:服务器用户数据到达内核缓冲区(read 函数等待)
- 输出等待:用户端等待缓冲区有足够空间可以输入(write 函数等待)
服务器首先 accept 用户连接请求后调用 read 函数等待数据,read 函数是系统调用,运行于内核态,使用的也是内核地址空间并且从网络中取得的数据需要先写入到内核缓冲区。当 read 系统调用获取到数据后将这个数据再复制到用户地址空间的用户缓冲区中,之后返回用户态执行 printf 函数打印字符串。
- read 执行在内核态且数据流先流入内核区,printf 运行于用户态,打印的数据会先从内核缓冲区复制到进程的用户缓冲区,之后打印出来
- printf 函数一定是在 read 函数已经准备好数据之后才能执行,但 read 函数作为 I/O 操作通常需要等待而触发阻塞。调用 read 函数的是服务器进程,一旦被 read 调用阻塞,整个服务器在获取到用户数据前都不能接受其他任何用户的请求(单进程/线程)
阻塞式 I/O
一旦调用 I/O 函数必须等整个 I/O 完成才返回。如果用户输入数据迟迟不到,整个服务器就会一直被阻塞。
在阻塞的过程中,其它应用进程还可以执行,因此阻塞不意味着整个操作系统都被阻塞。因为其它应用进程还可以执行,所以不消耗 CPU 时间,这种模型的 CPU 利用率会比较高。
非阻塞 I/O
应用程序执行系统调用后会立即返回,内核返回一个错误码,如果返回值大于 0 标识完成了数据读取,返回值即读取的字节数。返回 0 表示连接已经正常断开,返回 -1 表示错误,接下来用户进程可以继续执行,但是会会不停地执行系统调用来获取 kernel 是否准备完毕,这种方式称为轮询。
非阻塞I/O虽然不再会完全阻塞用户进程,但实际上由于用户进程需要不停地询问 kernel 是否准备完数据,所以 CPU 利用率比较低,不适合做并发。
I/O 多路复用(事件驱动模型)
是前两种一样是同步 I/O。使用 select 或者 poll 等待数据,并且可以等待多个套接字中的任何一个变为可读。这个过程也会使线程阻塞,但是和阻塞 I/O 所不同的是,这两个函数可以同时阻塞多个 I/O 操作。而且可以同时对多个读操作,多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。它可以让单个进程具有处理多个 I/O 事件的能力。
如果一个 Web 服务器没有 I/O 复用,那么每一个 Socket 连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程。相比于多进程和多线程技术,I/O 复用不需要进程线程创建和切换的开销,系统开销更小。
select
select 允许应用程序监视一组文件描述符,等待一个或者多个描述符成为就绪状态,从而完成 I/O 操作。
应用场景:select 适用于实时性较高的场景。移植性更好,几乎被所有主流平台所支持。
poll
与 select 功能基本相同。select 和 poll 速度都比较慢,每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。
不同点:
- select 会修改描述符,poll不会
- select 的描述符类型使用数组实现,默认为 1024,而 poll 没有描述符数量限制
- poll 提供了更多的事件类型,并且对描述符的复用利用上比 select 高
应用场景:poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。
epoll
epoll 调用 epoll_ctl 时拷贝进内核并放到事件表中,但用户进程和内核通过 mmap 映射共享同一块存储,避免了 fd 从内核赋值到用户空间。
epoll 只需要将描述符从进程缓冲区向内核缓冲区拷贝一次,并且进程不需要通过轮询来获得事件完成的描述符。
epoll 对多线程编程更有友好,一个线程调用了 epoll_wait() 另一个线程关闭了同一个描述符也不会产生像 select 和 poll 的不确定情况。
epoll 的描述符事件有两种触发模式:LT(level trigger)和 ET(edge trigger)。
当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait() 会再次通知进程。是默认的一种模式,并且同时支持 Blocking 和 No-Blocking。
和 LT 模式不同的是,通知之后进程必须立即处理事件,下次再调用 epoll_wait() 时不会再得到事件到达的通知。
很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
应用场景:需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。如果需要监控的小于 1000 个描述符,就没有必要使用 epoll。如果需要监控的描述符状态变化多,也没有必要使用 epoll。
信号驱动 I/O
应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom 将数据从内核复制到应用进程中。
相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高。
异步 I/O
异步 I/O 就是当用户进程发起 I/O 请求后立即返回,直到内核发送一个信号,告知进程 I/O 已完成,在整个过程中,都没有进程被阻塞。
异步I/O和非阻塞I/O的区别在于,判断数据是否准备完毕的任务从用户进程本身被委托给内核来完成。
异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。