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 |
全程不阻塞,拷贝到用户空间之后直接回调。 | 和多路复用类似,但是烧完水之后不用自己倒水,他帮你倒好了,还吹凉了,你过来喝就行。 |
【为什么会产生各种I/O】
下图是两个不同主机上,应用程序传递数据的过程,借助该过程来理解 I/O
是如何产生的
DMA(直接内存访问)是一种不经过CPU直接在网络适配器(网卡)和主机内存之间进行数据传输的机制,用于提升数据传输效率。
【同步阻塞 I/O BIO
】
同步阻塞I/O BIO
的工作机制:应用程序被阻塞,直到数据复制到应用进程的缓冲区才返回。阻塞并意味着整个操作系统都被阻塞。其他程序还可以执行,不消耗CPU事件。同步阻塞 I/O BIO
中,应用程序发起 read
调用来读取数据之后,一直被阻塞,直到内核把数据copy到用户空间。该方案适合客户端连接数量不高的情况。下图的read
和 recvfrom
函数是一个意思。
【非阻塞式 I/O NIO
】
非阻塞式 I/O NIO
的工作机制:应用程序执行read
系统调用之后,内核返回一个错误码。应用程序可以继续执行,但是需要不断的轮询 read
来获取 I/O是否完成,这种方式称之为 轮询 polling
。等到数据准备就绪,从内核空间copy到用户空间的时候,进程才被阻塞,直到内核copy完成。该方案比较低效,会不停的消耗CPU资源。
【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 复用的具体工作流程:
- 线程首先发起
select
调用,询问内核数据是否准备就绪 - 等待内核把数据准备好,用户线程再发起
read
调用 - 数据从内核空间copy到用户空间,该过程应用程序还是阻塞的
【信号驱动 I/O】
使用 sigaction
系统调用,内核立即返回。应用程序继续执行,内核等待数据准备就绪之后,发送 SIGIO
信号。应用程序受到信号之后,开始 read
系统调用,数据从内核空间复制到用户空间,该过程应用程序阻塞。 和 I/O 多路复用比较相似。
【异步 I/O AIO
】
异步 I/O AIO
是基于事件和回调机制实现的,应用程序 read
系统调用之后,直接返回,完全不阻塞。当后台处理完之后,操作系统通知相应的线程进行后续的操作。
【五大 I/O 模型比较】
2. Select、Poll、Epoll 之间有什么区别?
特性 | select | poll | epoll |
---|---|---|---|
触发机制 | 水平触发 | 水平触发 | 边缘触发 |
文件描述符存储方式 | 位图 | 动态数组 | 红黑书 + 就绪链表 |
性能 | 随着文件描述符量增加,性能下降 ( O(N) ) |
随着文件描述符量增加,性能下降( O(N) ) |
监听列表为 O(1) ,调用事件回调 o(k) |
【水平触发和边缘触发的区别】
- 边缘触发:事件发生的时候只通知一次,需要用户立即处理,如果没有处理,后续不会再通知
- 水平触发:事件发生的时候会反复通知,直到处理完成
3. 线程和进程有什么区别?
-
进程 (Process):进程是运行中的程序实例,拥有独立的内存空间和系统资源。比如打开的微信/抖音/微博,都是一个单独的进程。
-
线程 (Thread):线程也叫轻量级的进程,比进程轻量。一般来说,一个进程里面有多个线程同时执行,并且共享进程的资源。在
Linux
当中,线程可以共享内存空间、文件句柄、网络连接等。 -
协程:用户态轻量级线程,由程序控制切换,无需内核参与。协程切换只需要保存和恢复上下文,开销比线程小得多,适合处理高并发任务(比如异步
I/O
)
【注意】 多线程不是越多越好,因为线程越多就会增加切换成本,可能导致系统负载过高。而且需要同步机制避免数据竞争和死锁问题。
【进程和线程的区别】
特性 | 进程 | 线程 |
---|---|---|
本质 | 操作系统进行资源分配的基本单元 | 构成任务调度与执行的核心单元 |
开销 | 独立的代码和数据段,进程切换成本高 | 线程共享进程的资源,维护独立的堆栈和程序计数器,线程切换成本低 |
内存分配 | 每个进程分配独立的内存空间 | 除了CPU之外,系统不会给线程分配内存,线程组共享资源 |
稳定性 | 进程隔离性强,崩溃时不会影响其他进程 | 线程共享进程资源,崩溃可能导致整个进程异常终止 |
安全性 | 有独立的内存空间,安全性高 | 多个线程共享内存空间,存在数据竞争和线程安全的问题,需要用同步和互斥机制来解决 |
【JVM 视角下的进程和线程】
一个进程可以有多个线程,多个线程之间共享堆和方法区 (元空间),但是各自有独立的程序计数器、虚拟机栈和本地方法栈,确保线程间执行的上下文彼此隔离。
- 线程共享数据区:堆、方法区 (
JDK8
之后叫元空间)、执行引擎 - 线程隔离区域:程序计数器、虚拟机栈、本地方法栈、每个线程都有独立的脚本
【进程切换和线程切换的区别】
- 时间效率:线程切换比进程切换快,因为线程共享地址空间,而进程需要切换页面和上下文(涉及更多资源)
- 空间效率:线程共享内存和文件资源,数据交换不需要内核参与,效率更高;进程切换涉及更复杂的上下文保存和恢复。
【进程和线程切换的上下文是什么?】
进程控制块 (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套接字通信
- 服务端和客户端初始化套接字
socket
的 文件描述符 - 服务端
bind
绑定 IP 和 端口,listen
监听 ,accept
等客户端进行连接 - 客户端
connect
向服务端地址和端口发起连接请求 - 服务端
accpet
返回传输socket
文件描述符 - 客户端
write
写入数据,服务端read
读取 - 客户端
close
的时候,服务端read
会读取到 EOF (End of File), 处理完数据之后,服务端close
关闭链接
- 服务端和客户端初始化套接字
- UDP套接字通信:每次通信的时候调用
sendto
和recvfrom()
, 都需要传入目标主机的IP地址和端口
5. 进程间的调度算法知道吗?
-
先来先服务 (FCFS, First Come First Service):按照进程到达的先后顺序进行调度,先到达的进程先执行,后达到的进程后执行。就像区银行取钱,挨个排队一样。
-
最短作业优先 (SJF, Short Job First):按照进程的执行时间进行排序,执行时间短的进程优先执行,以减少平均等待时间。但是,可能会出现饥饿现象,执行时间长的程序,可能要等很久才能被执行。可以类比成银行让客户按照办理业务的时间进行排队,依次处理。
-
优先级调度:给每个进程分配一个优先级,根据优先级高低进行调度,优先级高的进程先执行。但是,优先级较低的程序需要等很久才能执行,可能会出现饥饿现象。
-
时间片轮转 (PR):将CPU时间分成多个时间片,每个进程轮流占用一个时间片,如果一个进程在该时间片结束的时候还没有执行完成,则将其移到队列末尾,等待下一次调度。
-
多级反馈队列调度 (MFQS, Multilevel Feedback Queue Scheduling):多级的意思是有多个队列,每个队列的优先级从高到低,优先级越高时间片越短(为了避免优先级低的进程被饿死了)。反馈的意思是如果有新的进程进入优先级高的队列的时候,立即停止当前运行的进程,转到优先级最高的队里去从头运行。(让优先级高的队列,得到快速处理的保障)具体工程流程如下:
- 设置多个队列,每个队列赋予不同的优先级,优先级从高到低,同时优先级越高的队列,时间片设置的越短
- 如果有新的进程,会把它放到第一级队列的末尾,按照先来先服务的原则排队等待被调度。如果在第一级队列规定时间片没有运行完成,则将其转入第二级队列末尾,直到完成为止。
- 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行的时候,有新进程进入较高优先级的队列,则停止当前运行的进程,并且将其移动到原来队列的队尾。然后从较高优先级队列开始,重复刚才的过程。
6. 网络I/O阻塞的原因?
网络I/O会被阻塞是因为进行网络数据传输的过程中,操作系统在等待数据的发送或接收完成之前,进程将被挂起,直到数据传输完成之后才恢复进程执行。
网络I/O阻塞的主要原因:
- 等待数据到达或发送完成:当进程尝试从网络套接字
socket
读取数据的时候,如果数据还没到达(数据未就绪),操作系统会让进程进入阻塞状态,直到数据到达为止。同样,数据未能立即发出去的时候,发送操作也可能被阻塞,等待缓冲区有空闲空间。 - 系统资源有限:当系统资源(网络缓冲区、连接数)被占满的时候,进一步的I/O请求可能会被阻塞,等待资源释放后才能继续。
- 默认的阻塞行为:大多数网络API (比如
recv
、send
、accept
等) 在默认情况下都是阻塞的,调用这些API的时候,如果条件不满足,会让调用者等待,直到I/O操作完成。
7. 什么是用户态和内核态?
运行模式 | 权限级别 | 可执行操作 | 特点 |
---|---|---|---|
用户态 | 较低 | 不能直接访问硬件或者越权操作,需要通过系统调用让内核执行敏感操作 | 安全性高,程序出现问题,不会影响系统的稳定性。 |
内核态 | 较高 | 可直接访问硬件资源并执行如内存管理、进程调度等于越权操作 | 能够高效管理硬件和系统资源 |
用户态和内核态是 CPU
的状态,描述了CPU在执行指令是的特权级别和范围权限。进程运行的过程当中,可能会因为CPU状态的切换,在用户态和内核态之间更替。
简单来说,一个进程当中,如果CPU
是用户态就是线程运行进程本身的程序代码,如果 CPU
是内核态就是把线程交给操作系统运行。
- CPU是用户态,进程只能访问自己的存储空间:用户态下,进程不能直接使用系统资源,只能访问自己的存储空间,不能越权访问系统的资源。也不能改变CPU的工作状态(用户态、内核态、空闲状态)。在用户态下,进程无法修改CPU的工作状态。在内核态下,操作系统可以通过修改CPU寄存器的值来切换权限,实现内核态和用户态的切换。
- CPU是内核态,进程可以通过os访问系统资源:内核态下,进程可以通过执行操作系统的程序,来直接使用计算机的所有硬件资源。但是,必须从用户态切换到内核态才可以,这样也保证了安全性和稳定性。
【为什么要区分用户态和内核态】
因为 CPU
的所有指令当中,有部分指令是非常危险的,操作不当就会导致系统崩溃。而且部分指令可能涉及到硬件的操作,参数很多,很容易出问题。所以凡是涉及到 I/O 读写,内存分配等硬件资源的操作的时候,为了保证安全性和稳定性,往往不能让进程直接操作,而是通过系统调用(调用操作系统的程序)让 CPU
从用户态切换到内核态,程序在内核态下面运行。
其中,CPU
的指令是有权限分级的,不同级别的权限包含不同的 CPU
指令集。比如 InterCPU
把 CPU
的指令集操作权限从高到低分为四个级别:ring0
、ring1
、ring2
、ring3
。
【注意】
ring3
的权限最低,只能使用常规的CPU
指令集,不能使用操作硬件资源的CPU
指令集,比如I/O读写、网卡访问、申请内存等操作都不可以。ring0
的权限最高,可以使用所有的CPU
指令集- Linux系统当中只采用了
ring0
和ring3
这两个权限。ring0
对应的就是内核态,程序完全在操作系统内核当中运行。ring3
对应的是用户态,程序在自己的存储空间当中运行。
【用户态和内核态的空间】
在内存资源的使用上,操作系统对用户态和内核态也做了限制。内存结构如下图所示,包含了内核空间和用户空间(程序代码和数据、堆内存、栈内存、命令行参数和环境变量等)。每个进程创建的时候,都会分配虚拟空间地址,和内存结构一样。虚拟空间就记录,对应的在实际内存中存放的位置。虚拟地址空间与物理内存通过**内存管理单元(MMU)和页表(Page Table)**实现动态映射。例如,在Linux ( 32
位系统)下,总共的内存空间是 2^32 bytes = 4GB
,内核态为 1G
,用户态为 3G
。
**【注意】**内核态的地址空间存放整个内核的代码,所有的内核模块和内核维护的数据,这一部分是所有进程共享的。所有进程的内核态逻辑地址是共享同一块内存地址的。同时 CPU
处于内核态的时候,进程可以操作全部范围的虚拟空间地址,并且属于内核态的高位虚拟空间只有内核态下,程序才能操作。
【用户态和内核态的切换】
用户态和内核态的切换具有一定的开销,下面是从用户态切换到内核态的流程(比如发起 I/O
调用)
- 保留用户态的现场 (上下文、程序计数器(寄存器)、用户栈)
- 复制用户参数,从用户栈切换到内核栈,
CPU
进入内核态 - 额外的检查 (因为内核代码对用户是不信任的)
- 执行内核态中相关的代码
- 复制内核态代码执行结果,回到用户态
- 恢复用户态现场(上下文、程序计数器(寄存器)、用户栈)
从用户态主动切换到内核态,需要有入口才行,操作系统提供了统一的入口(系统调用)。系统调用就是一组通用的访问接口,这些接口就叫系统调用。
【用户态什么时候切换到内核态】
- 系统调用:用户态进程通过系统调用向操作系统申请资源完成工作,比如
fork()
创建子进程,就是一个创建新进程的系统调用。系统调用的核心是系草系统为用户特别开放的一个中断来实现的,成为软中断。 - 异常:当
CPU
在执行用户态的进程的时候,发生了一些没有预知的异常。此时,当前运行进程会切换到处理该异常的内核态中的相关进程,就是从用户态切换到内核态了。比如出现缺页异常 - 中断:当
CPU
在执行用户态的进程的时候,外围设备完成用户请求的操作之后,会向CPU
发出相应的中断信号。此时,CPU
会暂停执行下一条即将执行的指令,转到与中断信号对应的内核态下的处理程序去执行,从用户态切换到了内核态。比如硬盘读写完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作。