网络编程 | 彻底搞懂网络 IO 模型
nanshan 2025-05-23 18:54 2 浏览 0 评论
令人头大的 IO
说起网络 IO 相关的开发,很多人都头大,包括我自己,写了几年的代码,对 IO 相关的术语说起来也是头头是道,什么 NIO、IO 多路复用等术语一个接一个。但是也就自己知道,这些概念一团乱,网上各种各样的文章也没一个权威易懂的,并且很多文章说起 IO 就扯上 Java 的 NIO 包,专注的大多是如何使用(术)而不是 IO 的本质(道)。所以写这篇文章来从 socket 编程的痛点,转到 NIO 的解决方案,再到多路复用器的发展来一起梳理网络IO 模型。
从 Socket 编程说起
做业务开发的同学,常常面对的是 Spring Boot 这些框架帮我们搭建好的 Server 框架,但是如果往下去看框架帮我们实现的代码最终会看到 Socket 相关的源码, Socket 相关的代码实际上就是 TCP 网络编程。
目前主流的 HTTP 框架,比如 Golang 原生的 HTTP net/http,都是基于 TCP 编程实现的,按照 HTTP 协议约定,解析 TCP 传输流过来的数据,最终将传输数据转换为一个 Http Request Model 交给我们业务的 Handler 逻辑处理。
例如,Golang 的 原生 Http 框架 net/http 为例就有这么些代码片段:
l, err = sl.listenTCP(ctx, la) // 监听连接请求
rw, e := l.Accept() // 创建连接
go c.serve(connCtx) // 调用新的协程处理请求逻辑
w, err := c.readRequest(ctx) // 读取请求
serverHandler{c.server}.ServeHTTP(w, w.req) // 执行业务逻辑,并返回结果
Socket 编程的过程
如上图所示:
- 服务端需要先绑定(Bind)并监听(Listen)一个端口,这个时候会有一个欢迎套接字(welcomeSocket)
- welcomeSocket 调用 Accept 方法,接受客户端的请求,如果没有请求那么会阻塞住
- 客户端请求指定端口,welcomeSocket 从阻塞中返回一个已连接套接字(connectionSocket)用于专门处理这个客户端请求
- 客户端往请求套接字写入数据(Stream)
- 服务端从已连接套接字可以持续读到数据,TCP 底层保证数据的顺序性
- 服务端可以往已连接套接字写入数据,客户端从请求的套接字中可以读到数据
- 客户端关闭连接,服务端也可以主动关闭连接
如果用代码手写 Socket 服务端,用 Java 实现是这样的:
public class Server {
public static void main(String[] args) throws Exception {
String clientSentence;
String capitalizedSentence;
ServerSocket welcomeSocket = new ServerSocket(6789);
while (true) {
Socket connectionSocket = welcomeSocket.accept(); // 当没请求会阻塞住
System.out.println("connection build succ!");
BufferedReader inFromClient = new BufferedReader(new InputStreamReader(connectionSocket.getInputStream()));
DataOutputStream outToClient = new DataOutputStream(connectionSocket.getOutputStream());
clientSentence = inFromClient.readLine(); // 连接上但是客户端还没写入数据会阻塞住
System.out.println("read succ!");
capitalizedSentence = clientSentence.toUpperCase() + '\n';
outToClient.writeBytes(capitalizedSentence);
System.out.println("write succ!");
}
}
}
我们可以用 Telnet 连接上去尝试下,但是很快我们会发现两个问题:
- Accept 是阻塞的,如果一个客户端网络比较差,三次握手时间长整个服务端就卡住了
- Read 是阻塞的,如果客户端连接上了,但是迟迟不发数据(比如我们 telnet 上,但是不写)整个服务端就卡住了
优化思路:多线程处理,避免 read 阻塞
对于 Read 是阻塞的问题,我们开线程来处理,这样当一个请求连接上迟迟不写数据也不会影响到其他连接的处理了。当然这里得考虑到量级,如果量级太大的话需要改成线程池避免线程过多。
public class Server {
public static void main(String[] args) throws Exception {
ServerSocket welcomeSocket = new ServerSocket(6789);
while (true) {
Socket connectionSocket = welcomeSocket.accept(); // 当没请求会阻塞住
System.out.println("connection build succ!");
new Thread(new Runnable() {
@Override
public void run() {
try {
String clientSentence;
String capitalizedSentence;
BufferedReader inFromClient = new BufferedReader(new InputStreamReader(connectionSocket.getInputStream()));
DataOutputStream outToClient = new DataOutputStream(connectionSocket.getOutputStream());
clientSentence = inFromClient.readLine(); // 连接上但是客户端还没写入数据会阻塞住
System.out.println("read succ!");
capitalizedSentence = clientSentence.toUpperCase() + '\n';
outToClient.writeBytes(capitalizedSentence);
System.out.println("write succ!");
}catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
}
}
这个时候,我们用 telnet 客户端连接已经感受不到服务端的瓶颈了。但是从我们上的分析来看,Accept 还是有瓶颈的,就是同时只能对一个请求做连接,而且即使是线程池的模式,如果连接(特别是空闲的)很多,最终也会出现阻塞的情况。
如果你的业务场景是连接数不多,同时又需要频繁的交互数据,那么用 BIO 模式无论是对时延还是资源使用都有不错的效果(相当于 VIP 1v1 服务)。
但是,我们的服务端代码通常是面对海量的连接的,且很多客户端连接上,并不会马上发送请求,例如聊天室应用,很久用户才会发送1条消息。这个时候如果还是这种 1v1 模式,那么有 100w 个用户,就需要维护 100个连接,显然是不合适,这太浪费资源了,而且很低效,大部分线程都是在 Block 等待用户数据。
所以,这个时候 NIO(异步io)横空出世了。网上有一张比较好的对比图,可以很好地解释差异:
我们上文说的就是 阻塞I/O ,而现在要讲的是非阻塞I/O。图上主要阐述的是 `read()` 方法的过程,主要包括两部分:
- 第一阶段:等待TCP RecvBuffer 数据就绪,这个在传统的BIO里如果数据没就绪,就会阻塞等待,不消耗CPU
- 第二阶段:将数据从内核拷贝到用户空间,消耗CPU但是速度非常快,属于 memory copy
非阻塞I/O
所以对于 非阻塞I/O 来说,主要要优化的是调用 `read()` 方法数据还未就绪导致阻塞问题。这个解决方法很简单,大部分编程语言都有提供 nio 的方法,只要数据还没准备就绪不要block,直接返回给调用者就可以了。这样我们这个线程就可以接着去处理其他连接的数据,这样就不用每个连接单独只有一个线程来服务了。
I/O 多路复用
对于非阻塞 I/O 模式,开发者仍然需要不断去轮询事件状态,如果请求量级很大, 这样的机制同样还是会浪费很多资源,同时开发难度较高。其实想一想,我们作为开发者的诉求无非就是监听某些事件,比如完成链接(accept完成)、数据就绪(可read)等。关于事件的监听其实也无关乎编程语言,在操作系统层面就可以做而且可以做的更高效。操作系统上提供了一系列系统调用,比如 select/poll/epool,这些系统调用后会阻塞,当有对应的事件到来触发我们注册到事件上的Handler逻辑。
所以简单来说,就是上文说的 非阻塞I/O 用户自行写轮询查看状态的逻辑被收敛到操作系统这里提供的 I/O 复用器了,整个程序执行起来的逻辑大概变成这样。
interface ChannelHandler{
void channelReadable(Channel channel);
void channelWritable(Channel channel);
}
class Channel{
Socket socket;
Event event;//读,写或者连接
}
//IO线程主循环:
class IoThread extends Thread{
public void run(){
Channel channel;
while(channel=Selector.select()){//选择就绪的事件和对应的连接
if(channel.event==accept){
registerNewChannelHandler(channel);//如果是新连接,则注册一个新的读写处理器
}
if(channel.event==write){
getChannelHandler(channel).channelWritable(channel);//如果可以写,则执行写事件
}
if(channel.event==read){
getChannelHandler(channel).channelReadable(channel);//如果可以读,则执行读事件
}
}
}
Map<Channel,ChannelHandler> handlerMap;//所有channel的对应事件处理器
}
Reactor 模型
目前大多高性能的网络IO框架主要都是基于IO多路复用 + 池化技术的的 Reactor 模型,Reactor 其实只是一个网络模型概念并不是具体的某项具体技术。常见的主要有三种,单Reactor + 单进程/单线程、单Reactor + 多线程、多Reactor + 多进程/多线程。
单Reactor + 单进程/单线程
多路复用器 Select 返回结果后,有个 Dispatch 用于分发结果事件。如果是连接建立事件,Acceptor接受连接并创建对应的Handler来处理后续事件。如果不是连接事件,直接调用对应的 Handler,Handler 完成数据读取 read 、process、send 的完整业务流程。
这种模式优点是简单、不用考虑进程间通信、线程安全、资源竞争等问题,但是也有自身局限性,也就是无法充分利用多核资源,适用于业务场景处理很快的场景,比如 Redis 就是用这种方案。
单Reactor + 多线程
相比于上一种方案,不同的是 Handler 只负责数据读取不负责处理事件,而是有一个单独的 Worker 线程池来做具体的事情。之所以 processor 要隔离单独的线程池是因为 `read` 方法本身是需要消耗 cpu 资源的,通常不适合大于 cpu 核数,而用户自定义的 processor 逻辑里可能有各种网络请求,比如 RPC 请求,如果隔离开来,那么 processor 可以设置更大的线程数,提升吞吐量。
这种模式已经可以比较充分利用到多核资源了,但是问题在于主线程承担了所有的事件监听和响应。瞬间高并发时可能成为瓶颈,这就需要多 Reactor 的方案了。
多Reactor + 多进程/多线程
处理步骤:
- 父进程中 mainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor接收,将新的连接分配给某个子进程。
- 子进程的 subReactor 将 mainReactor 分配的连接加入连接队列进行监听,并创建一个Handler 用于处理连接的各种事件。
- 当有新的事件发生时,subReactor 会调用连接对应的 Handler 来进行响应。
- Handler 完成 read→处理→send 的完整业务流程。
目前著名的开源系统 Nginx 采用的是多 Reactor 多进程,采用多 Reactor 多线程的实现有Memcache 和 Netty。不过需要注意的是 Nginx 中与上图中的方案稍有差异,具体表现在主进程中并没有mainReactor来建立连接,而是由子进程中的subReactor建立。
异步非阻塞 I/O
服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理,AIO又称为NIO2.0,在JDK7才开始支持。但是由于 Linux 上 AIO 的底层实现并不好,所以目前没有被广泛使用。比如大名鼎鼎的Netty框架也是使用NIO而非AIO。
总结
这篇文章从 socket 编程出发,你了解到了怎么利用socket编写服务端代码,然后在 socket 编程时发现了痛点,一个在于 accept 建立连接会阻塞线程,另一个在于 read 数据时会阻塞,为了解决阻塞可能导致的低效问题,我们尝试了用多线程方法来初步解决。
但是在这之后,我们又看到面对海量连接时,BIO 力不从心的现象,所以引入了 NIO 模型。这里阐述了从 NIO 到 多路复用器的进步,相当于是操作系统帮我们做了海量连接事件的监听,这个模式也被称作 Reactor 模式。最后讲到了异步I/O,虽然理想很美好,但是底层基建并不完善,目前这种模式在生产中被使用还比较少。
我写这篇文章,并没有描述很多具体的API,因为我希望通过这个文章来帮助大家真正了解IO模型的本质,而不是罗列topic,或者硬性记忆API,因为编程语言很多,而解决方案的思想是统一的。这也是我们学习应该注意的,更多的应该学其道,而不是学其术。
相关推荐
- 电脑cpu占用率高?怎么办?1分钟快速解决!
-
案例:电脑cup过高怎么办?【我的电脑运行缓慢,导致我学习和工作的效率很低。刚刚查看了一下电脑,发现它的cpu占用率很高。有没有小伙伴知道如何解决此电脑cpu过高的问题?】电脑是我们生活中不可缺少的工...
- CPU使用率100%怎么办
-
当电脑的CPU使用率达到100%时,往往会引发一系列令人头疼的问题,如卡顿、过载、过热甚至死机。这些问题不仅严重影响了电脑的正常使用,还可能对硬件造成损害。为了有效应对这一挑战,我们可以采取一系列措施...
- 提高CPU利用率方法
-
一、背景:一般小项目服务器的虚拟机服务器CPU很难达到要求的,要求一般都是使用率达到60%-90%,除非是数据库服务器,还有计算很频繁的应用服务器,不然是大部分的都不能达到要求的,无法达到要求,就得是...
- Go到Rust:代码对比揭示60% CPU使用率降低的技术路径
-
Go与Rust作为现代系统级编程语言,在并发处理和内存管理上采取了截然不同的设计哲学。本文通过四个典型场景的代码对比,剖析两种语言在CPU效率层面的核心差异,揭示为何部分技术团队通过语言迁移实现了60...
- 一招教你解决CPU占用率100%的问题 #电脑小技巧
-
大家好,今天讲一下CPU占用率100%的解决方法。·首先点运行,在这块输入gpedit.msc回车。·打开管理模板,Windows组件,MicrosoftDefender防病毒。·点开扫描,扫描期间...
- 技术丨教你降低CPU与内存占用率,让系统快如闪电
-
当内存和CPU都达到了较大的占用率时,很可能会导致系统崩溃。该如何解决这一问题?本期视频将指导大家:如何有效减少内存和CPU的占用率。快来看看具体操作步骤吧!1.尝试运行ePSA硬件检测首先,尝试运...
- Serv00服务器搭建代理节点全流程|无需保号保活|Cloudflare隧道
-
注册图文教程(2024)「链接」视频教程BiliBili:Serv00服务器搭建代理节点全流程|无需保号保活|Cloudflare隧道|serv00-play脚本_哔哩哔哩_bilibiliS...
- 600+ 道 Java面试题及答案整理(建议收藏)
-
小七整理了最近几年最新、最全的Java面试题,题目涉及Java基础、集合、多线程、IO、分布式、Spring全家桶、MyBatis、Dubbo、缓存、消息队列、Linux…等等。题库共6...
- 网络编程 | 彻底搞懂网络 IO 模型
-
令人头大的IO说起网络IO相关的开发,很多人都头大,包括我自己,写了几年的代码,对IO相关的术语说起来也是头头是道,什么NIO、IO多路复用等术语一个接一个。但是也就自己知道,这些概念一...
- 开源全方位运维监控工具:HertzBeat
-
HertzBeat:实时监控系统性能,精准预警保障业务稳定-精选真开源,释放新价值。概览HertzBeat是一款深受广大开发者喜爱的开源实时监控解决方案。它以其简洁直观的设计理念和免安装Agent的...
- 网络安全工程师必知的75个网络端口
-
作为一名网络安全工程师,必须熟知网络端口,一般将端口分为以下3类:(1)公认端口(Well-KnownPorts):范围从0到1023(2)注册端口(RegisteredPorts):从1024到...
- PHP技能评测
-
公司出了一些自我评测的PHP题目,现将题目和答案记录于此,以方便记忆。1.魔术函数有哪些,分别在什么时候调用?__construct(),类的构造函数__destruct(),类的析构函数__cal...
- 2020年Dubbo30道高频面试题!还在为面试烦恼赶快来看看
-
前言Dubbo是一个分布式服务框架,致力于提供高性能和透明化的RPC远程服务调用方案,以及SOA服务治理方案。简单的说,dubbo就是个服务框架,如果没有分布式的需求,其实是不需要用的,只有在分布式的...
- 2018年度回顾:挖矿木马为什么会成为病毒木马黑产的中坚力量
-
一、概述根据腾讯御见威胁情报中心监测数据,2018年挖矿木马样本月产生数量在百万级别,且上半年呈现快速增长趋势,下半年上涨趋势有所减缓。由于挖矿的收益可以通过数字加密货币系统结算,使黑色产业变现链条十...
- 自查风险突出的30个服务高危端口
-
在计算机网络中,端口是一种用于区分不同网络服务或应用程序的逻辑地址。每个网络服务或应用程序都需要至少一个端口(号)来实现网络通信。当某个端口开放时,便能接收来自于其它计算机或网络设备的连接请求和数据。...
你 发表评论:
欢迎- 一周热门
-
-
爱折腾的特斯拉车主必看!手把手教你TESLAMATE的备份和恢复
-
如何在安装前及安装后修改黑群晖的Mac地址和Sn系列号
-
[常用工具] OpenCV_contrib库在windows下编译使用指南
-
WindowsServer2022|配置NTP服务器的命令
-
Ubuntu系统Daphne + Nginx + supervisor部署Django项目
-
WIN11 安装配置 linux 子系统 Ubuntu 图形界面 桌面系统
-
解决Linux终端中“-bash: nano: command not found”问题
-
NBA 2K25虚拟内存不足/爆内存/内存占用100% 一文速解
-
Linux 中的文件描述符是什么?(linux 打开文件表 文件描述符)
-
K3s禁用Service Load Balancer,解决获取浏览器IP不正确问题
-
- 最近发表
- 标签列表
-
- linux 查询端口号 (58)
- docker映射容器目录到宿主机 (66)
- 杀端口 (60)
- yum更换阿里源 (62)
- internet explorer 增强的安全配置已启用 (65)
- linux自动挂载 (56)
- 禁用selinux (55)
- sysv-rc-conf (69)
- ubuntu防火墙状态查看 (64)
- windows server 2022激活密钥 (56)
- 无法与服务器建立安全连接是什么意思 (74)
- 443/80端口被占用怎么解决 (56)
- ping无法访问目标主机怎么解决 (58)
- fdatasync (59)
- 405 not allowed (56)
- 免备案虚拟主机zxhost (55)
- linux根据pid查看进程 (60)
- dhcp工具 (62)
- mysql 1045 (57)
- 宝塔远程工具 (56)
- ssh服务器拒绝了密码 请再试一次 (56)
- ubuntu卸载docker (56)
- linux查看nginx状态 (63)
- tomcat 乱码 (76)
- 2008r2激活序列号 (65)