1. 说说你知道的几种 I/O 模型

【常见的五大I/O模型】

常见的五大I/O模式分别为: 同步阻塞I/O (Blocking I/O) BIO、非阻塞I/O (Non-blocking I/O) NIO、I/O多路复用、信号量驱动I/O、异步I/O AIO

我们假如要烧水喝,看不同模型是怎么烧的水喝

I/O 模型 特性 烧水案例
同步阻塞I/O BIO 数据从网卡到内核,再从内核到用户空间,都是阻塞操作。 自己动手烧水,一直盯着,等水烧开了,倒在杯子里喝。
非阻塞I/O NIO 数据从网卡到内核不阻塞,read不到数据直接返回,但是从内核到用户空间会阻塞 (用户轮询read) 自己动手烧水,隔两分钟看一下,水烧开没有。等水烧开了,倒在杯子里喝。
I/O多路复用 只有一个线程查看多个连接是否有数据准备就绪 (看从网卡能不能read到数据到内核) 找专门烧水的领居帮忙,他把水烧好了之后,会喊你来拿。但是你要自己倒在杯子里喝。
信号驱动I/O 数据从网卡到内核之后会自动通知用户程序,然后让他read读取数据 去烧水房烧水,全自动的,有个通知灯。水烧完了之后会按你家的门铃,但是有客人来了,也会按门铃
异步I/O AIO 全程不阻塞,拷贝到用户空间之后直接回调。 和多路复用类似,但是烧完水之后不用自己倒水,他帮你倒好了,还吹凉了,你过来喝就行。

IO五种模型

【为什么会产生各种I/O】

下图是两个不同主机上,应用程序传递数据的过程,借助该过程来理解 I/O 是如何产生的

DMA(直接内存访问)是一种不经过CPU直接在网络适配器(网卡)和主机内存之间进行数据传输的机制,用于提升数据传输效率。

两个主机的应用程序是如何通信的

【同步阻塞 I/O BIO

同步阻塞I/O BIO 的工作机制:应用程序被阻塞,直到数据复制到应用进程的缓冲区才返回。阻塞并意味着整个操作系统都被阻塞。其他程序还可以执行,不消耗CPU事件。同步阻塞 I/O BIO 中,应用程序发起 read 调用来读取数据之后,一直被阻塞,直到内核把数据copy到用户空间。该方案适合客户端连接数量不高的情况。下图的readrecvfrom 函数是一个意思。

同步阻塞IO结构图

【非阻塞式 I/O NIO

非阻塞式 I/O NIO 的工作机制:应用程序执行read 系统调用之后,内核返回一个错误码。应用程序可以继续执行,但是需要不断的轮询 read 来获取 I/O是否完成,这种方式称之为 轮询 polling 。等到数据准备就绪,从内核空间copy到用户空间的时候,进程才被阻塞,直到内核copy完成。该方案比较低效,会不停的消耗CPU资源。

同步非阻塞IO结构图

【I/O 多路复用】

I/O 多路复用是一种支持面向缓存的,基于通道I/O的操作方法,它是基于同步非阻塞I/O设计的。适合高负载、高并发应用。I/O 复用是通过 select 或者 poll 机制,让单个进程能够同时处理多个套接字的读写事件。当任一套接字可读的时候,被阻塞的进程会被唤醒并继续执行。和多进程或者多线程方案相比, I/O 复用避免了创建和切换 进程/线程带来的额外开销。在没有I/O复用的情况下,每次建立一个新的 Socket 连接 都需要单独启动一个线程来处理。

【注意】I/O 多路复用相较于同步非阻塞I/O NIO 少了轮询调用 read 的操作 (轮询polling), 减少了CPU资源的消耗。复用同一个线程,处理多个socket 中的事件,防止创建过多线程导致的上下文切换的开销。

I/O 复用的具体工作流程:

  1. 线程首先发起 select 调用,询问内核数据是否准备就绪
  2. 等待内核把数据准备好,用户线程再发起read 调用
  3. 数据从内核空间copy到用户空间,该过程应用程序还是阻塞的

IO多路复用结构图

【信号驱动 I/O】

使用 sigaction 系统调用,内核立即返回。应用程序继续执行,内核等待数据准备就绪之后,发送 SIGIO 信号。应用程序受到信号之后,开始 read 系统调用,数据从内核空间复制到用户空间,该过程应用程序阻塞。 和 I/O 多路复用比较相似。

信号驱动IO结构图

【异步 I/O AIO

异步 I/O AIO 是基于事件和回调机制实现的,应用程序 read 系统调用之后,直接返回,完全不阻塞。当后台处理完之后,操作系统通知相应的线程进行后续的操作。

异步IO结构图

【五大 I/O 模型比较】

五大IO模型比较图

2. Select、Poll、Epoll 之间有什么区别?

特性 select poll epoll
触发机制 水平触发 水平触发 边缘触发
文件描述符存储方式 位图 动态数组 红黑书 + 就绪链表
性能 随着文件描述符量增加,性能下降 ( O(N) ) 随着文件描述符量增加,性能下降( O(N) ) 监听列表为 O(1) ,调用事件回调 o(k)

【水平触发和边缘触发的区别】

  • 边缘触发:事件发生的时候只通知一次,需要用户立即处理,如果没有处理,后续不会再通知
  • 水平触发:事件发生的时候会反复通知,直到处理完成

3. 线程和进程有什么区别?

  • 进程 (Process):进程是运行中的程序实例,拥有独立的内存空间和系统资源。比如打开的微信/抖音/微博,都是一个单独的进程。

  • 线程 (Thread):线程也叫轻量级的进程,比进程轻量。一般来说,一个进程里面有多个线程同时执行,并且共享进程的资源。在 Linux 当中,线程可以共享内存空间、文件句柄、网络连接等。

  • 协程:用户态轻量级线程,由程序控制切换,无需内核参与。协程切换只需要保存和恢复上下文,开销比线程小得多,适合处理高并发任务(比如异步I/O

【注意】 多线程不是越多越好,因为线程越多就会增加切换成本,可能导致系统负载过高。而且需要同步机制避免数据竞争和死锁问题。

【进程和线程的区别】

特性 进程 线程
本质 操作系统进行资源分配的基本单元 构成任务调度与执行的核心单元
开销 独立的代码和数据段,进程切换成本高 线程共享进程的资源,维护独立的堆栈和程序计数器,线程切换成本低
内存分配 每个进程分配独立的内存空间 除了CPU之外,系统不会给线程分配内存,线程组共享资源
稳定性 进程隔离性强,崩溃时不会影响其他进程 线程共享进程资源,崩溃可能导致整个进程异常终止
安全性 有独立的内存空间,安全性高 多个线程共享内存空间,存在数据竞争和线程安全的问题,需要用同步和互斥机制来解决

进程和线程的区别

【JVM 视角下的进程和线程】

一个进程可以有多个线程,多个线程之间共享堆和方法区 (元空间),但是各自有独立的程序计数器、虚拟机栈和本地方法栈,确保线程间执行的上下文彼此隔离。

  • 线程共享数据区:堆、方法区 ( JDK8 之后叫元空间)、执行引擎
  • 线程隔离区域:程序计数器、虚拟机栈、本地方法栈、每个线程都有独立的脚本

JVM视角下的进程和线程-JDK对比

【进程切换和线程切换的区别】

  • 时间效率:线程切换比进程切换快,因为线程共享地址空间,而进程需要切换页面和上下文(涉及更多资源)
  • 空间效率:线程共享内存和文件资源,数据交换不需要内核参与,效率更高;进程切换涉及更复杂的上下文保存和恢复。

【进程和线程切换的上下文是什么?】

进程控制块 (process control block) PCB 数据结构是用来描述进程的

PCB是进程存在的唯一标识,其中包含

  • PCB内容:进程唯一标识符、状态信息(创建、就绪、运行、阻塞、结束)、优先级、资源分配清单(内存和文件句柄)、CPU寄存器值等
  • 进程状态变迁:创建、就绪、运行、阻塞、结束,五种状态当中进行切换

进程的状态切换

【进程的上下文切换】

  • 进程的上下文:包含虚拟内存全局变量 等用户空间的资源,还包括内核堆栈寄存器等内核空间的资源。

  • 进程上下文切换:将上一个进程 进程A 的上下文保存到当前进程 进程B 的PCB中,当需要运行另外一个进程 进程A 的时候,需要从 进程B 的PCB取出上下文,恢复到CPU中,使得 进程A 可以从中断点继续执行。

  • 进程切换发生的场景

    • 时间片用完:某个进程的时间片用完了,进程变为就绪态
    • 资源不足:某个进程所需要的资源不足,会被挂起,变成就绪挂起状态
    • 主动挂起:进程被主动挂起
    • 优先级不足:遇到更高优先级的进程,需要被调度成阻塞状态
    • 硬件中断:突然断电了

【线程的上下文切换】

  • 不同进程内的线程切换:相当于不同进程之间的上下文切换 (比如两个单线程的进程)
  • 同一个进程内的线程切换:因为虚拟内存是共享的,所以切换的过程中,虚拟内存(堆、方法区、执行引擎)不动,只切换线程的私有数据 (本地方法栈、虚拟机栈)、寄存器等共享的数据

【文件句柄、内存空间、网络连接】

  • 文件句柄:文件句柄是操作系统给进程打开的每个文件分配的唯一整数标识符,用来跟踪文件的位置、权限等状态信息。比如区餐厅点餐,服务员给一个牌子(桌号)。这个牌子就是句柄,代表你的桌子。服务员不知道你是谁,只能通过桌号,找到你的桌子,给你上菜。

  • 内存空间:内存空间包含代码段、数据段、堆区、栈区、文件映射段

    • 代码段:存放二进制可执行代码,通常是只读的
    • 数据段:存放全局变量和静态变量,分为已初始化数据区和未初始化数据区 (BSS段)
    • 堆区:用于动态分配内存
    • 栈区:存放函数的局部变量、参数、返回地址等,大小固定
    • 文件映射段:包括动态库、共享内存等 (mmap 分配内存)

    进程的内存空间结构图

  • 网络连接:Linux里面,网络连接是一种特殊的文件,可以通过文件句柄进行操作。一般是用来两台计算机之间通过网络协议 (TCP/IP) 建立的通信信道。

4. 进程之间的通信方式有哪些?

【为什么进程需要进行通信?】

不同的进程有不同的用户地址空间,进程A的全局变量,进程B是看不到的。进程之间想要交换数据需要通过内核,在内核开辟缓冲区。进程A从用户空间copy数据到内核缓冲区,进程B再从内核缓冲区读走数据,实现进程之间的通信。

进程之间的通信方式结构图

【进程之间的通信方式】

  • 管道/匿名管道:管道是一种单向通信的方式,用于父进程和子进程之间,或者同一主机上的不同进程之间传递数据。可以是匿名的,也可是命名的。

    ps -ef | grep [name] # Linux指令的|就是匿名管道
    
  • 命名管道:和匿名管道类似,遵循先进先出原则,以磁盘文件的形式存在,可以实现本机任意两个进程的通信。

    mkfifo pipeDemo # 创建命名管道
    echo "Hello! World!" > pipeDemo # 向管道内写入数据
    cat < pipeDemo # 读取pipeDemo管道的数据,显示 Hello! World!
    
  • 信号:异步的通信方式,通知接受进程某个事件已经发生,一般用于进程之间发送中断或者终止命令。

  • 信号量:信号量是一种同步原语,用于管理对共享区域的访问,可以用来实现进程间的互斥访问和同步操作。

  • 消息队列:消息队列是链表形式的,遵循先进先出的原则。允许进程A向进程B发送消息,消息在队列中按照顺序存储。但是读取的过程中,进程B不一定非要按照现金先出的顺序读取,可以随机查询。

  • 共享内存:共享内存允许多个进程访问同一块内存区域,可以看见其他进程对共享进程的更新,从而实现快速的数据交换。但是需要注意数据同步的问题,避免出现数据一致性问题。

  • 套接字 (socket):套接字其实就是协议+IP+端口,允许在网络上的不同主机上面的进程进行通信,比如说微信/飞书发消息。

  • 文件:进程可以通过读写文件来进行通信,这种方式通常用于进程之间的间接通信,比如临时文件或者共享文件。

【消息队列详解】

消息队列就是保存在内核中的消息链表,消息队列的生命周期随内核存在而存在。如果不主动释放或者不关闭系统,则会一直存在。匿名管道的生命周期是随进程存在而存在的,进程结束就销毁了。和管道不同,消息队列不需要其他进程在队列上面等待消息到达,可以随时往消息队列里面写入消息。

消息队列的缺点:

  • 通信不及时:因为不需要其他进程在消息队列等着,进程只有轮询检查消息队列,如果有时间间隔,肯定会有没来得及看消息的时候
  • 数据大小存在限制:消息队列不适合传输比较大的数据,而且需要把数据从用户态copy到内核态里面

【共享内存】

进程A和进程B都拿出一块虚拟地址空间映射到相同物理内存,一个进程写入,另外一个进程就可以马上看到,不用从用户态copy数据到内核态,提高进程间的通信速度。但是需要某种同步机制(比如信号量)来达到进程之间的同步和互斥 (比如进程A需要通过信号量通知进程B,它正在写入)。另外,两个进程写同一个地址,先写的进程会发现内容被覆盖了。

进程通信-共享内存

【信号量机制】

信号量是用来防止进程之间因为竞争共享资源的,而造成的数据错乱。引入保护机制,使得共享的资源在任意时刻只能被一个进程访问。信号量是整型计数器,用于实现进程间的互斥和同步,不是用来缓存进程之间的通信数据的。信号量有两种操作,P 操作和 V 操作:

  • P 操作:将信号量减去1,如果相减之后信号量 < 0,则表明资源被占用。如果信号量 >= 0, 表明资源还可以继续使用
  • V 操作:将信号量加上1,如果相加之后信号量 <= 0, 说明有阻塞进程,唤醒该进程运行。如果相加之后,信号量 > 0,则表明没有阻塞进程,直接运行。

如果信号量初始化为 1,代表是互斥信号量。如果初始化为 0,代表是同步信号量。

【基于TCP协议通信的套接字socket编程模型】

  • TCP套接字通信
    1. 服务端和客户端初始化套接字 socket 的 文件描述符
    2. 服务端 bind 绑定 IP 和 端口, listen 监听 ,accept 等客户端进行连接
    3. 客户端 connect 向服务端地址和端口发起连接请求
    4. 服务端 accpet 返回传输 socket 文件描述符
    5. 客户端 write 写入数据,服务端 read 读取
    6. 客户端 close 的时候,服务端 read 会读取到 EOF (End of File), 处理完数据之后,服务端 close 关闭链接
  • UDP套接字通信:每次通信的时候调用sendtorecvfrom(), 都需要传入目标主机的IP地址和端口

进程通信-UDP和TCP套接字对比

5. 进程间的调度算法知道吗?

  • 先来先服务 (FCFS, First Come First Service):按照进程到达的先后顺序进行调度,先到达的进程先执行,后达到的进程后执行。就像区银行取钱,挨个排队一样。

    进程调度算法-先来先服务

  • 最短作业优先 (SJF, Short Job First):按照进程的执行时间进行排序,执行时间短的进程优先执行,以减少平均等待时间。但是,可能会出现饥饿现象,执行时间长的程序,可能要等很久才能被执行。可以类比成银行让客户按照办理业务的时间进行排队,依次处理。

    进程调度算法-最短作业优先

  • 优先级调度:给每个进程分配一个优先级,根据优先级高低进行调度,优先级高的进程先执行。但是,优先级较低的程序需要等很久才能执行,可能会出现饥饿现象。

    进程调度算法-优先级调度

  • 时间片轮转 (PR):将CPU时间分成多个时间片,每个进程轮流占用一个时间片,如果一个进程在该时间片结束的时候还没有执行完成,则将其移到队列末尾,等待下一次调度。

    进程调度算法-时间片轮转

  • 多级反馈队列调度 (MFQS, Multilevel Feedback Queue Scheduling)多级的意思是有多个队列,每个队列的优先级从高到低,优先级越高时间片越短(为了避免优先级低的进程被饿死了)。反馈的意思是如果有新的进程进入优先级高的队列的时候,立即停止当前运行的进程,转到优先级最高的队里去从头运行。(让优先级高的队列,得到快速处理的保障)具体工程流程如下:

    1. 设置多个队列,每个队列赋予不同的优先级,优先级从高到低,同时优先级越高的队列,时间片设置的越短
    2. 如果有新的进程,会把它放到第一级队列的末尾,按照先来先服务的原则排队等待被调度。如果在第一级队列规定时间片没有运行完成,则将其转入第二级队列末尾,直到完成为止。
    3. 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行的时候,有新进程进入较高优先级的队列,则停止当前运行的进程,并且将其移动到原来队列的队尾。然后从较高优先级队列开始,重复刚才的过程。

    进程调度算法-多级反馈队列调度

6. 网络I/O阻塞的原因?

网络I/O会被阻塞是因为进行网络数据传输的过程中,操作系统在等待数据的发送或接收完成之前,进程将被挂起,直到数据传输完成之后才恢复进程执行。

网络I/O阻塞的主要原因:

  • 等待数据到达或发送完成:当进程尝试从网络套接字 socket 读取数据的时候,如果数据还没到达(数据未就绪),操作系统会让进程进入阻塞状态,直到数据到达为止。同样,数据未能立即发出去的时候,发送操作也可能被阻塞,等待缓冲区有空闲空间。
  • 系统资源有限:当系统资源(网络缓冲区、连接数)被占满的时候,进一步的I/O请求可能会被阻塞,等待资源释放后才能继续。
  • 默认的阻塞行为:大多数网络API (比如recvsendaccept等) 在默认情况下都是阻塞的,调用这些API的时候,如果条件不满足,会让调用者等待,直到I/O操作完成。

7. 什么是用户态和内核态?

运行模式 权限级别 可执行操作 特点
用户态 较低 不能直接访问硬件或者越权操作,需要通过系统调用让内核执行敏感操作 安全性高,程序出现问题,不会影响系统的稳定性。
内核态 较高 可直接访问硬件资源并执行如内存管理、进程调度等于越权操作 能够高效管理硬件和系统资源

用户态和内核态CPU 的状态,描述了CPU在执行指令是的特权级别和范围权限。进程运行的过程当中,可能会因为CPU状态的切换,在用户态和内核态之间更替。

简单来说,一个进程当中,如果CPU 是用户态就是线程运行进程本身的程序代码,如果 CPU 是内核态就是把线程交给操作系统运行。

  • CPU是用户态,进程只能访问自己的存储空间:用户态下,进程不能直接使用系统资源,只能访问自己的存储空间,不能越权访问系统的资源。也不能改变CPU的工作状态(用户态、内核态、空闲状态)。在用户态下,进程无法修改CPU的工作状态。在内核态下,操作系统可以通过修改CPU寄存器的值来切换权限,实现内核态和用户态的切换。
  • CPU是内核态,进程可以通过os访问系统资源:内核态下,进程可以通过执行操作系统的程序,来直接使用计算机的所有硬件资源。但是,必须从用户态切换到内核态才可以,这样也保证了安全性和稳定性。

【为什么要区分用户态和内核态】

因为 CPU 的所有指令当中,有部分指令是非常危险的,操作不当就会导致系统崩溃。而且部分指令可能涉及到硬件的操作,参数很多,很容易出问题。所以凡是涉及到 I/O 读写,内存分配等硬件资源的操作的时候,为了保证安全性和稳定性,往往不能让进程直接操作,而是通过系统调用(调用操作系统的程序)让 CPU 从用户态切换到内核态,程序在内核态下面运行。

其中,CPU 的指令是有权限分级的,不同级别的权限包含不同的 CPU 指令集。比如 InterCPUCPU 的指令集操作权限从高到低分为四个级别:ring0ring1ring2ring3

【注意】

  1. ring3 的权限最低,只能使用常规的 CPU 指令集,不能使用操作硬件资源的 CPU 指令集,比如I/O读写、网卡访问、申请内存等操作都不可以。ring0 的权限最高,可以使用所有的 CPU 指令集
  2. Linux系统当中只采用了 ring0ring3 这两个权限。ring0 对应的就是内核态,程序完全在操作系统内核当中运行。ring3 对应的是用户态,程序在自己的存储空间当中运行。

内核态和用户态示意图

【用户态和内核态的空间】

在内存资源的使用上,操作系统对用户态和内核态也做了限制。内存结构如下图所示,包含了内核空间和用户空间(程序代码和数据、堆内存、栈内存、命令行参数和环境变量等)。每个进程创建的时候,都会分配虚拟空间地址,和内存结构一样。虚拟空间就记录,对应的在实际内存中存放的位置。虚拟地址空间与物理内存通过**内存管理单元(MMU)页表(Page Table)**实现动态映射。例如,在Linux ( 32 位系统)下,总共的内存空间是 2^32 bytes = 4GB ,内核态为 1G,用户态为 3G。 **【注意】**内核态的地址空间存放整个内核的代码,所有的内核模块和内核维护的数据,这一部分是所有进程共享的。所有进程的内核态逻辑地址是共享同一块内存地址的。同时 CPU 处于内核态的时候,进程可以操作全部范围的虚拟空间地址,并且属于内核态的高位虚拟空间只有内核态下,程序才能操作。

内核态和用户态在进程中的结构图和Linux环境

【用户态和内核态的切换】

用户态和内核态的切换具有一定的开销,下面是从用户态切换到内核态的流程(比如发起 I/O 调用)

  1. 保留用户态的现场 (上下文、程序计数器(寄存器)、用户栈)
  2. 复制用户参数,从用户栈切换到内核栈,CPU 进入内核态
  3. 额外的检查 (因为内核代码对用户是不信任的)
  4. 执行内核态中相关的代码
  5. 复制内核态代码执行结果,回到用户态
  6. 恢复用户态现场(上下文、程序计数器(寄存器)、用户栈)

从用户态主动切换到内核态,需要有入口才行,操作系统提供了统一的入口(系统调用)。系统调用就是一组通用的访问接口,这些接口就叫系统调用。

Linux架构图-用户态-内核态

【用户态什么时候切换到内核态】

  • 系统调用:用户态进程通过系统调用向操作系统申请资源完成工作,比如 fork() 创建子进程,就是一个创建新进程的系统调用。系统调用的核心是系草系统为用户特别开放的一个中断来实现的,成为软中断
  • 异常:当 CPU 在执行用户态的进程的时候,发生了一些没有预知的异常。此时,当前运行进程会切换到处理该异常的内核态中的相关进程,就是从用户态切换到内核态了。比如出现缺页异常
  • 中断:当 CPU 在执行用户态的进程的时候,外围设备完成用户请求的操作之后,会向 CPU 发出相应的中断信号。此时, CPU 会暂停执行下一条即将执行的指令,转到与中断信号对应的内核态下的处理程序去执行,从用户态切换到了内核态。比如硬盘读写完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作。