目录
IO读写的基本原理
1. 内核空间与用户空间:IO 操作的 “前提条件”
计算机系统为保证内核安全,防止用户程序直接操作内核引发风险,通过划分内存空间(内核空间 + 用户空间)限制用户程序权限。用户程序(用户空间)无法直接操作硬件,这是 IO 操作必须依赖系统调用的根本原因。
只有先解释空间划分,才能说明用户程序为何无法直接读写 IO 设备,进而为后续系统调用的必要性提供依据。
内核空间(Kernel Space):操作系统内核所在空间,受保护,仅内核态可访问。内核在这里管理着系统的关键资源,执行内存管理、进程调度、设备驱动等核心任务。
用户空间(User Space):运行普通用户程序的空间。当用户程序处于用户态时,无法访问内核空间。如果用户程序想要访问硬件设备或调用系统资源(进行 IO 操作),必须通过系统调用(System Call)这一 “桥梁”,实现用户态到内核态的转换。
什么是用户态和内核态?
操作系统为管理程序权限设置的两种 “状态”,类似 “普通用户” 和 “管理员” 身份
用户态(User Mode):
普通用户程序的 “低权限状态”。此时程序只能访问用户空间的资源,不能直接操作内核空间或硬件设备,就像普通用户不能随意修改系统核心文件。
内核态(Kernel Mode):
操作系统内核的 “高权限状态”。此时程序可访问内核空间的所有资源,并能直接控制硬件设备,类似管理员拥有修改系统核心的权限。
上面我们在用户空间那里说了“实现用户态到内核态的转换”,我们可以想一想,谁在转换?
是正在运行的用户程序(进程)的执行状态在转换。不是 “程序本身” 或 “空间” 在变,而是程序当前 “权限级别” 的切换 —— 从低权限的用户态,变为高权限的内核态。
2. 系统调用触发流程:IO 操作的 “执行路径”
基于用户空间不能直接访问内核资源的前提,用户程序发起 IO 操作( read/write)时,必须通过系统调用从用户态切换到内核态,由内核接管硬件操作。
用户程序执行 IO 操作流程:
用户程序发起 read ()/write () 等 IO 操作请求,这一请求触发从用户态到内核态的切换。
操作系统内核接管控制权,根据请求执行对应的硬件操作。对于磁盘读取操作,内核会与磁盘驱动程序交互,安排磁盘寻道、数据读取等具体硬件动作。在这个过程中,产生了内核缓冲区与用户缓冲区之间的数据交互。
补充:调用 read () 读取文件内容或 write () 写入数据都属于IO操作
3. 缓冲区间的复制:IO 操作的 “核心本质”
read():把数据从内核缓冲区复制到用户缓冲区。
当内核完成从物理设备读取数据后,这些数据先存放在内核缓冲区,read () 函数再将其搬运到用户程序能够访问的用户缓冲区,供用户程序后续处理。
write():把数据从用户缓冲区复制到内核缓冲区。
用户程序准备好要写入的数据存放在用户缓冲区,write () 函数将数据传递给内核缓冲区,之后再由内核负责将数据写入到对应的物理设备中。
IO操作关键在于 不是直接读写物理设备,而是通过缓冲区交互。
通过一张图来总结一下系统调用 read 和 write 的流程,图中展示了用户程序通过系统调用 read() 或 write() 与网卡之间的数据交互过程。
client(客户端)表示通过网络向服务器发送数据的远程主机。client不直接参与操作系统内部,只是数据的发送源或接收端。
网卡作为硬件设备,负责将客户端的数据接收进来,通过底层驱动送入内核空间,并负责与操作系统的内核通信。
内核缓冲区是操作系统负责管理的区域,用户程序不能直接访问;
用户缓冲区是用户进程自己的数据区,用户程序只能访问这部分。
read() 和 write() 实际不是直接读写物理设备,而是 在用户缓冲区和内核缓冲区之间复制数据;
内核缓冲区是中间桥梁,连接了 用户空间和硬件设备(网卡);
系统调用是必须的中转机制,用户态无法直接访问内核缓冲区或硬件。
在 Linux 系统中,所有的数据交互都需通过内核缓冲区作为中转站。
四种主要的IO模型
什么是IO模型?
IO 模型(Input/Output Model)是操作系统处理 IO 操作的底层机制,定义了用户程序与内核之间数据交互的方式。它解决的核心问题是:如何高效处理大量并发的 IO 请求,同时降低系统资源消耗。
为什么要学习IO模型?
在服务器性能优化领域,IO 操作是当之无愧的关键瓶颈点。大量实践数据表明,超过 90% 的服务器性能问题并非由 CPU 计算能力不足引发,而是源于 IO 操作的效率瓶颈。无论是磁盘读写、网络数据传输还是其他形式的输入输出交互,IO 操作的耗时和资源占用往往成为系统性能的主要制约因素。
从高并发编程的技术实践来看,IO 模型构成了底层优化的核心基础。从传统的 Tomcat 服务器到高性能的 Netty 框架,这些主流技术方案的底层实现都高度依赖 IO 模型的优化。不同的 IO 模型设计直接影响着系统对并发连接的处理能力、线程资源的利用率以及整体的响应效率,是高并发系统构建不可或缺的技术根基。
在进行技术选型时,IO 模型的选择是必须重点考量的因素。不同的业务场景对 IO 操作有着不同的需求特性,比如短连接与长连接的差异、高并发与低并发的区别等,这些都需要匹配相应的 IO 模型。只有根据业务场景的具体特点选择合适的 IO 模型,才能充分发挥系统性能,满足实际应用的需求。
1. 同步阻塞 IO(BIO):最基础的模型
核心原理:
用户线程调用 IO 操作后,会一直阻塞等待内核完成数据准备和复制,直到数据返回才继续执行。Java 中Socket和ServerSocket的默认模式即为此模型。
为什么叫同步阻塞IO?
为什么叫同步?
同步的含义是:用户线程主动发起IO操作,并等待内核准备和复制数据的整个过程;用户线程必须亲自完成IO操作的全过程,包括等待和复制阶段。
为什么叫阻塞?
发起read()或write()调用后,线程立即进入阻塞状态;直到数据准备好、复制完、系统调用返回,线程才能恢复运行。
总结:
同步阻塞IO = 用户自己发起IO(同步) + IO过程中一直等待不能干别的(阻塞)
在BIO模型中,从优点来看,BIO模型的编程实现较为简单,对于小规模连接场景适用性较好,例如当连接数量少于 100 个时,开发人员能够轻松驾驭这种模型进行程序设计。然而,它的缺点也不容忽视,由于每个连接都需要占用一个独立线程,在并发量较大的情况下,线程数量会呈暴增态势。比如当存在 1 万连接时,就需要创建 1 万条线程,这不仅可能引发内存溢出问题,还会因 CPU 频繁进行上下文切换而产生极高的开销。基于这些特性,同步阻塞 IO 模型主要适用于传统企业内部系统以及低并发的单机服务等场景。
同步阻塞IO的具体流程如图,它详细描述了在 Linux 系统下,用户程序执行 read() 操作时的完整过程。
左侧:用户程序空间
用户线程或进程调用 read() 系统调用,在调用之后,用户线程被阻塞,直到整个IO操作完成;
也就是说,read调用 → 等待数据 → 复制数据 → 调用返回,整个过程线程都不能干别的事情。
右侧:Linux内核空间
整个IO流程分为两个阶段:
第一阶段:等待数据
内核等待数据到达网卡、缓冲区等;
在这期间,用户线程一直处于“阻塞”状态。
第二阶段:复制数据
数据准备好后,内核将数据从内核缓冲区复制到用户缓冲区;
数据复制完成之后,才释放用户线程,read 调用返回。
最终:
用户线程接收到返回值,才算完成IO操作;
阻塞解除,用户线程恢复运行,继续处理数据。
2. 同步非阻塞 IO:过渡性方案
核心原理:
用户线程调用 IO 操作后立即返回,若数据未准备好则返回错误,用户需反复轮询内核状态。
为什么叫同步非阻塞IO?
为什么叫同步?
同样是用户线程主动发起 IO 请求,内核仅负责数据处理;
用户线程需自行判断 IO 是否完成。为什么叫非阻塞?
发起read()时,如果数据没准备好,系统调用会立刻返回一个状态值;用户线程不会被挂起(阻塞),可以继续做别的事;
但为了最终读取到数据,用户线程必须反复发起系统调用轮询。
总结:
同步非阻塞IO = 用户自己发起IO(同步) + IO未完成时不会阻塞(非阻塞),但需要反复询问内核(轮询)
在同步非阻塞 IO 模型中,从优点来看,该模型的突出特点是线程不会因 IO 操作而阻塞,这使得单个线程能够同时处理多个连接请求,有效提升了线程的使用效率。然而,这一模型也存在明显的缺点。由于需要不断轮询内核以检测数据是否准备就绪,会造成大量的 CPU 资源消耗。例如,当存在 100 个连接时,若其中 99 个连接暂无数据,CPU 需对这些连接进行频繁轮询,导致资源的严重浪费。同时,该模型的编程复杂度较高,开发人员需要处理大量的错误状态,这无疑增加了开发和维护的难度。
基于上述特性,同步非阻塞 IO 模型的适用场景较为有限,主要适用于极少数对实时性要求极高且连接量极少的场景。像高频交易系统中的心跳检测功能,就对实时性有着严苛的要求,同时连接数量相对较少,在这种情况下,同步非阻塞 IO 模型能够发挥其优势,在一定程度上满足业务需求。但需要注意的是,在大多数常规场景中,由于其存在的 CPU 消耗和编程复杂度等问题,该模型并不适用。
同步非阻塞IO的系统调用流程如图,图中 read调用1 到 read调用N 是不断重复发起的系统调用,代表用户程序轮询式地尝试读取数据。
3. IO 多路复用(NIO):高并发的中流砥柱
核心原理:
通过select/epoll等系统调用,单个线程可监控多个 Socket 连接的状态。当某连接有数据时,内核通知用户线程,再执行read/write操作。
为什么叫IO多路复用?
命名核心:多路复用 + 同步IO(底层本质)
为什么仍叫同步?
用户线程仍然要主动发起read()/write()操作;内核只是通过select/epoll帮用户提前通知哪些IO准备好了,真正的读写操作仍由用户发起。
为什么叫多路复用?
一个线程可以同时监控多个IO通道(Socket);使用select/epoll等系统调用 轮询多个描述符;
可以处理成百上千个连接,不用为每个连接创建线程。
总结:
IO多路复用 = 同步IO + 内核帮你监控多个IO通道的就绪状态
select/epoll是典型的多路复用器实现
在 IO 多路复用模型中,从优点来看,该模型最大的亮点在于资源利用率极高,能够凭借单个线程处理数万甚至更多的连接。以 Nginx 为例,基于 epoll 机制,它能够轻松支持 10 万以上的连接数,这种强大的并发处理能力使得系统在面对海量连接请求时,依然能够保持稳定的性能。
不过该模型也存在一定的局限性。IO 多路复用在连接监控阶段通过select/epoll实现非阻塞,但底层 IO 操作的阻塞特性取决于文件描述符的模式:若 FD 为阻塞模式,read/write会阻塞线程;若 FD 为非阻塞模式,read/write会立即返回,需应用层循环处理。尽管 IO 多路复用本质上属于同步IO模型,但在实际开发中常与非阻塞文件描述符配合使用。此时,read/write 调用不会阻塞线程,开发者需手动控制数据复制流程、循环读写和事件管理,从而引入了典型的非阻塞IO开发复杂性。
基于上述特性,IO 多路复用模型在高并发场景中展现出显著的优势,主要适用于高并发的 Web 服务,如电商平台、社交平台等,这些场景往往需要同时处理大量的用户连接请求。此外,像 Kafka、RocketMQ 等中间件也广泛采用IO多路复用模型,以满足其在数据传输和处理过程中对高并发、高吞吐量的需求。
文件描述符(FD)的默认模式通常为阻塞模式
IO多路复用模型的read系统调用流程如图
下面解释select和epoll,也是需要关注的重点
select/epoll是典型的多路复用器实现
1. select(早期多路复用器)
基本原理:
通过select系统调用,将多个 Socket 的文件描述符(FD)放入读、写、异常事件集合中,内核遍历检查这些 FD 是否就绪,返回就绪的 FD 数量。
关键特点:
FD 数量限制:select 使用固定位图数组管理 FD,受 FD_SETSIZE 限制,Linux 默认最多支持 1024 个连接。
轮询方式:内核采用线性遍历方式检查 FD 状态,时间复杂度为 O (n),FD 数量多时效率低下;
用户空间拷贝:每次调用需将 FD 集合从用户空间拷贝到内核空间,性能损耗较大。
2. epoll(Linux 高性能多路复用器)
基本原理:
作为 select 的升级版本,epoll 通过epoll_create/control/wait三个系统调用实现:
创建 epoll 实例(内核维护一个红黑树存储 FD);
注册 FD 及事件(可读 / 可写)到 epoll 实例;
阻塞等待内核通知,仅返回就绪的 FD 列表(无需遍历所有 FD)。
关键优化点:
事件驱动:采用事件就绪列表返回机制,将就绪 FD 放入链表,不需要轮询所有 FD,性能更高。
内核空间存储:FD 集合在内核空间维护,避免用户空间与内核空间的频繁拷贝;
无 FD 数量限制:理论上仅受限于系统内存;
两种模式:
LT(Level Triggered,水平触发):只要数据未读完,就持续通知,类似 select;
ET(Edge Triggered,边缘触发):仅在数据状态变化时通知一次,需配合非阻塞 IO 使用,效率更高。
LT 类似 “只要水池里有水(数据),就一直提醒你去舀水”,直到数据被读完或缓冲区可写入。
ET 类似 “水池水位变化时(空→有 / 满→可写)提醒一次,之后不再提醒,除非再次变化”,需主动读完 / 写完数据。
4. 异步 IO(AIO):理想与现实的差距
核心原理:
用户线程发起 IO 请求后立即返回,内核完成数据准备和复制后,通过回调函数或信号主动通知用户线程。
为什么叫异步IO?
为什么叫“异步”?
用户线程只需注册一次IO操作,就可以立刻继续做别的事情;整个IO过程(等待数据 + 数据复制)都由内核完成;
完成后,内核主动回调通知用户,比如调用用户注册的回调函数或发送信号(signal)。
为什么不叫“阻塞”或“非阻塞”?
因为 用户线程根本不需要等待IO任何阶段;所以既不阻塞,也不需要轮询;
是最彻底的“非阻塞 + 非轮询”模型。
总结:
异步IO = 内核全权负责 + 用户只等待通知,是真正的异步机制
在异步 IO(AIO)模型中,从优点来看,该模型的核心亮点在于实现了真正意义上的非阻塞操作。当用户线程发起 IO 请求后,不用等待内核完成数据处理,可立即转而执行其他任务,这使得 CPU 资源能够得到最大化的利用,理论上具备最高的利用率。
然而,AIO模型在实际应用中面临着诸多挑战。一方面,操作系统层面的支持存在明显差异与不足。Linux 系统中的 AIO 实现性能表现较差,而 Windows 的 IOCP 机制相对更为成熟,但这也导致了跨平台兼容性的问题。另一方面,在 Java 编程领域,AsynchronousChannel相关 API 的应用极少,开发生态支持薄弱。此外,异步 IO 编程中由于回调层层嵌套带来的回调地狱问题,使得代码的可读性和维护性大幅降低,进一步增加了编程的复杂度。
基于上述特性,异步 IO 模型在理论上虽被视为最优选择,但在实际应用中却受到诸多限制。目前,它主要在少数对实时性要求极高的特殊场景中进行实验性使用,例如高频交易系统对毫秒级响应的严苛需求,以及实时监控系统对数据实时采集的高要求等场景。
补充:
Windows 的 IOCP(I/O Completion Port) 是一种真正的异步 IO,性能稳定,配合线程池效率极高,在高并发服务器中应用广泛。
Linux 虽然从 2.6 开始引入 AIO 接口,但实现依旧存在局限,底层仍有些操作通过线程模拟异步行为,并非完全异步,且 libaio 使用复杂、不友好。
跨平台开发时需要针对不同系统分别实现底层 AIO 逻辑,所以会带来兼容性问题。
异步IO模型的系统调用流程如图
read 调用发起后,请求立即返回,不会阻塞线程,线程可以继续做其他事情。
与同步 IO 不同,线程不需要等待数据准备和复制完成,可以立刻去处理其他业务逻辑,最大化 CPU 利用率。
当内核完成数据从网卡(或磁盘)复制到用户缓冲区后,会触发回调函数或发送事件,用户线程被通知 IO 已完成,此时再去处理结果数据。
问:NIO 为什么比 BIO 性能高?
1. BIO 是“1 连接对应 1 线程”,并发连接多时线程资源开销大;而 NIO 通过 Selector 实现“1 线程管理多连接”,显著减少线程数量和切换成本;
2. NIO 使用事件驱动模型,Selector 只关注就绪事件,避免不必要的系统调用,提高内核交互效率;
3. NIO 使用 ByteBuffer 进行块式数据处理,支持高效的定位和切换操作,适合处理大量数据,提升吞吐率。
问:epoll 比 select 好在哪里?
1. select 使用固定大小的 FD 集合(最多 1024 个),而 epoll 内部使用红黑树和就绪链表,支持更大规模的连接数;
2. select 每次调用都要遍历所有 FD,而 epoll 只返回真正就绪的 FD,避免无效遍历;
3. epoll 支持 边缘触发(ET)模式,相比 select 的水平轮询机制,响应更灵敏,效率更高;
4. epoll 事件机制基于就绪事件队列返回,提高 IO 多路复用性能。