「硬核Netty系列」IO多路复用底层原理详解,Java面试大厂必问

举报
小白同学111 发表于 2022/12/27 21:29:41 2022/12/27
【摘要】 「硬核Netty系列」IO多路复用底层原理详解,Java面试大厂必问


# 文章目录

# 一、Socket

*   Socket读缓冲和写缓冲
*   阻塞和非阻塞
*   Socket API简单使用

# 二、I/O多路复用

什么是I/O多路复用?

文件描述符fd

**select函数**

select函数接口

select具体工作流程

**epoll讲解**

1.  基本原理
2.  epoll优点
3.  epoll接口

*   **epoll_create函数**
*   epoll_ctl 函数
*   epoll_wait函数

# 一、Socket

在计算机通信领域,socket 被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式。通过 socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。

# Socket读缓冲和写缓冲

![image.png](https://img-blog.csdnimg.cn/img_convert/3eaa9347b911f63d98e7f7aaa10d6441.png)

平时用的socket(套接字)只是一个引用,这个socket对象实际上是放在操作系统内核中。socket内部有两个重要的缓冲结构,一个是读缓冲,一个是写缓冲,读写缓冲都是有限大小的数组结构。

当我们对客户端的socket写入字节数组时,是将字节数组拷贝到内核区socket的write buffer中,内核网络模块会有单独的线程负责不停地将write buffer的数据拷贝到网卡硬件,网卡硬件再将数据送到网线,经过一些列路由器交换机,最终送达服务器的网卡硬件中。

同样,服务器内核的网络模块也会有单独的线程不停地将收到的数据拷贝到socket的read buffer中等待用户层来读取。最终服务器的用户进程通过socket引用的read方法将read buffer中的数据拷贝到用户程序内存中进行反序列化成请求对象进行处理。然后服务器将处理后的响应对象走一个相反的流程发送给客户端。

# 阻塞和非阻塞

**socket读写也是分阻塞和非阻塞**

因为write buffer空间都是有限的,所以当write buffer 满了,写操作就会阻塞,直到空间被腾出来。当有了NIO(非阻塞IO),写操作也可以不阻塞,假设write buffer空间还剩1M,此时一个客户端请求写数据是2M,那么就会先写进去1M,然后返回客户端,告知写进去多少,还剩1M没有写进去的内容用户进程会缓存起来,后续会继续重试写入。

读操作也是一样,read buffer的内容可能会是空的。这样socket的读操作也会阻塞,直到read buffer中有了足够的内容才会返回。有了NIO,就可以有多少读多少,不会阻塞。

# Socket API简单使用

**客户端简单实例代码**

```
public class TCPClient {    public static void main(String[] args) throws IOException {        Socket s=new Socket("127.0.0.1",6666);        OutputStream os=s.getOutputStream();        //将数据写入到socket        DataOutputStream dos=new DataOutputStream(os);        dos.writeUTF("Hello,server!");        //读取从服务端传回来的数据        DataInputStream dis=new DataInputStream(s.getInputStream());        System.out.println(dis.readUTF());    }}
```

**服务端简单实例代码**

```
/** * Socket服务端Demo */public class TCPServerDemo {    public static void main(String[] args) throws IOException {        //创建一个ServverSocket监听6666端口        ServerSocket ss=new ServerSocket(6666);        while (true){            Socket s=ss.accept();            System.out.println("A client connected!");            //从socket中获取数据流            DataInputStream dis=new DataInputStream(s.getInputStream());            //将数据流中的数据写入到socket            DataOutputStream dos=new DataOutputStream(s.getOutputStream());            String str=null;            if ((str=dis.readUTF())!=null){                //读取从客户端传来的数据                System.out.println(str);                System.out.println("from"+s.getInetAddress()+",port #"+s.getPort());            }            //写入数据            dos.writeUTF("Hello,"+s.getInetAddress()+",port#"+s.getPort());            dis.close();            dos.close();            s.close();        }    } 
```

代码实例很简单,这里就不再详细说明,我们就看下运行结果:
服务端运行结果

![image.png](https://img-blog.csdnimg.cn/img_convert/0544bff77a454528369ddd82f677977d.png)


客户端运行结果 

![image.png](https://img-blog.csdnimg.cn/img_convert/afdb06785d0459f1cfdb490cf297c9ea.png)


# 二、I/O多路复用

**什么是I/O多路复用**

I/O多路复用,I/O就是指的我们网络I/O,多路指多个TCP连接(或多个Channel),复用指复用一个或少量线程。连起来理解就是很多个网络I/O复用一个或少量的线程来处理这些连接。

为了大家更好的理解,我就用一个生动形象的例子来说明:

**第一年情人节**

今天是情人节,蛋蛋带着月月去了她最喜欢的烤肉店吃饭,一进去,发现有很多服务员,这时一个服务员走过来,说只为我们一桌服务,我说这感情好啊,vip的待遇呀,但发现,每桌都会有一个服务员,我去,这餐厅这么土豪吗,我有点担心它这样能撑多久。

**第二年情人节**

又过了一年,又到了情人节,蛋蛋发现去年去的那家烤肉店还在开着,于是又带着月月去了这家店,一进去就发现,没那么多服务员了,一个服务员会服务很多桌,我心想这老饭终于知道节省成本,去除多余的配置了,但是有了新的问题,就是服务员服务的桌数多了之后,等上餐的时候,不知道这个餐对应哪桌了,就会一桌一桌的问,这就有点尴尬了。

**第三年情人节**

第三年情人节,蛋蛋和月月还是去了之前几年去的那家店,这次来又有了新发现,发现餐厅终于把去年出现的问题给解决了,服务员会对每桌客人记录客人坐的桌号,这样上餐的时候根据桌号她就一下就能找到这个餐要往哪桌上,非常聪明,哈哈哈。

对应到编程界,在最开始的时候,为了实现一个服务器可以支持多个客户端连接,人们就想出了fork/thread等方法,当一个连接到来时,就fork/thread一个进程/线程去接收并处理请求,可能是那个年代用电脑的人都很少,所以一直都没有什么大问题。

到了1980年代,发明了一种叫做IO多路复用的模型(select,poll),这个模型的好处就是没必要开那么多线程和进程了,少量的线程和进程就能搞定。但是随着计算机的发展,这种IO多路复用模型有点僵化,回想下蛋蛋和月月第二年去吃饭的场景:

一个餐厅只有少量服务员服务很多顾客。

一个服务员上餐的时候需要一个一个问这个餐是谁的。

对应的编程模型就是:一个连接来了,就必须遍历所有已注册的文件描述符,来找到那个需要处理信息的文件描述符,如果已经注册了几万个文件描述符,遍历完了估计cpu也要歇菜了。

直到2002年,,互联网时代大爆炸,请求量呈指数级增长,人们通过改进IO多路复用模型,进一步优化,发明了叫做epoll的方法。这个方法就相当于蛋蛋和月月第三年去吃饭餐厅做的优化。

![image.png](https://img-blog.csdnimg.cn/img_convert/086f6a5e956987365e4e85e8acde7b74.png)

这是当年的并发图。我们可以看到蓝色的线是epoll,随着连接数的增加性能几乎不受影响。

# 文件描述符fd

inux的内核将所有外部设备都可以看做一个文件来操作。那么我们对与外部设备的操作都可以看做对文件进行操作。在此我向大家推荐一个架构学习交流圈。交流学习指导伪鑫:1253431195(里面有大量的面试题及答案)里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

我们对一个文件的读写,都是通过调用内核提供的系统调用,内核给我们返回一个文件描述符。而对一个socket的读写

也会有相应的描述符,称为socketfd(socket描述符)。描述符只是一个数字,指向内核中一个结构体(文件路径,数据区等一些属性)。我们的应用程序对文件的读写就通过对描述符的读写完成。

# select函数

select函数监视的描述符分3类,分别是writefds、readfds、和exceptfds。

在linux中,我们可以使用select函数实现I/O端口的复用,传递给select函数的参数会告诉内核:

*   我们所关心的文件描述符
*   对每个描述符,我们所关心的状态
*   我们要等待多长时间

从select函数返回后,内核告诉我们以下信息:

*   对我们的要求已经做好准备的描述符的个数
*   对于三种条件哪些描述符已经做好准备(读,写,异常)
    有了这些返回信息,我们就可以调用合适的I/O函数(通常是read或write),并且这些函数不会再阻塞。

# select函数接口

```
#include <sys/select.h> int select(int maxfdp1,fd_set*readset,fd_set*writeset,fd_set*exceptset,struct timeval*timeout);
```

返回值:做好准备的文件描述符的个数,超时为0,错误为-1.

方法中传参详解:

1.  maxfdp1:是一个整数值,是指集合中所有文件描述符的范围,即所有文件描述符的最大值加1。
2.  中间的三个参数readset,writeset,exceptset指向描述符集。这些参数指明了我们关心哪些描述符,和需要满足什么条件(可读,可写,异常)。一个文件描述符集保存在fd_set类型中,fd_set其实就是位图!
3.  timeval*timeout:它指明我们要等待的时间。

```
struct timeval(long tv_sec;/*秒*/long tv_usec;/*微秒*/)
```

有三种情况:

*   timeout==NULL 等待无限长时间。
*   timeout->tv_sec == 0 && timeout -> tv_usec == 0 不等待,直接返回。(非阻塞)
*   timeout->tv_sec != 0 || timeout -> tv_usec!=0 等待指定的时间。

# select具体工作流程

假如说,服务器端收到3个客户端的accept连接事件,构建出3个与客户端对接的socket,那么服务器程序接下来需要监听这3个客户端的读事件了。

![image.png](https://img-blog.csdnimg.cn/img_convert/0ca1ac2e64279227fbfea2496c6b3925.png)


服务器端会创建一个ServerSocket,队列中就是3个代处理的客户端连接,每个客户端都会对应一个socket。

![image.png](https://img-blog.csdnimg.cn/img_convert/64c3f529ab3ff56c35b47e7a3e938a08.png)


当服务器socket处理完这三个acceot事件,在进程的用户态堆栈的fds就是对应的三个客户端socket的文件描述符,rset就是文件描述符集。 

![image.png](https://img-blog.csdnimg.cn/img_convert/147fac44cb1cf4c310aceb27617fa516.png)


这时发起select函数调用,将用户态堆栈中的rset信息拷贝到内核态堆栈中,因为进程A对应的三个socket都没有数据,所以进程A要从运行队列中出来,进程等待队列中。

![image.png](https://img-blog.csdnimg.cn/img_convert/e63972fbb8450ed4ccdd2567b6d825d8.png)


现在客户端1和客户端2向服务器发送数据报文,服务器网卡接收到报文后,通过DMA设备会将数据存入服务器内存当中。

![image.png](https://img-blog.csdnimg.cn/img_convert/a5d13915a4bf8cb6ab34108d8f0fcc24.png)


服务器网卡转发报文后,会向CPU发起硬件中断IR,CPU会立即响应这个中断请求,假设CPU此时正在运行B进程,那么接下来会做什么事呢,接着往下看

![image.png](https://img-blog.csdnimg.cn/img_convert/9a8e82bc95f124db43ba2b08ffd6e68d.png)


CPU会将进程B当前正在运行的瞬时数据节点(执行的行数,数据等)信息保存到进程描述符。
然后会修改CPU寄存器,完成由用户态切换到内核态。接着根据IRQ向量在向量表中查找合适的中断处理程序,接着开始执行网卡的中断处理程序。

![image.png](https://img-blog.csdnimg.cn/img_convert/c52eb19ee2319858b28714af4f3f699d.png)

网卡中断处理程序由一个网卡缓冲区,里面记录了IP信息,端口号,所以可以找到对应的socket,将报文从网卡缓冲区转移到socket缓冲区,接着检查这些socket有没有对应的进程,有的话就会让进程从等待队列中移出。

![image.png](https://img-blog.csdnimg.cn/img_convert/7e2145405e7f6b758ce3c055f98bcaf7.png)


进程A就会进入运行队列当中,client1和client3中有数据,所以进程A内核态堆栈中的rset会将这两者的信息拷贝到用户态当中,接着就可以读数据给客户端了。

# epoll讲解

epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

# 基本原理

epoll对文件描述符的操作有两种模式:LT(水平触发)和ET(边缘触发),LT模式时默认模式。

LT(水平触发):事件就绪后,用户可以选择处理或者不处理,如果用户本次未处理,那么下次调用epoll_wait时仍然会将未处理的事件打包给你。

ET(边缘触发): 它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。epoll使用”事件"的就绪通知方式,通过epollctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epollwait便可以收到通知,事件就绪后,用户必须处理,因为内核不给你兜底了,内核把就绪的事件打包给你后,就把对应的就绪事件清理掉了。

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。

# epoll优点

1.  没有最大并发连接限制。能打开的fd的上限远大于1024(1G的内存上能监听约10万个端口)。
2.  效率提升,不是轮询的方式,不会随着fd数目的增加效率下降。
3.  内存拷贝,利用mmap文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。

JDK1.5_update10版本使用epoll替代了传统的select/poll,极大提升了NIO通信的性能。

# epoll接口

epoll操作过程需要三个接口,分别如下:

```
#include <sys/epoll.h>  int epoll_create(int size);int epoll_ctl(int epfd,int op,int fd,struct epoll_event*event);int epoll_wait(int epfd,struct epoll_event*events,int maxevents,int timeout);
```

# epoll_create函数

epoll_create函数是一个系统函数,函数将在内核空间内开辟一块新的空间,可以理解为epoll结构空间,返回值为epoll文件描述符编号,方便后续操作使用。

# epoll_ctl 函数

epoll_ctl 是epoll的事件注册函数,epoll与select不同,select函数是调用时指定需要监听的描述符和事件,epoll先将用户感兴趣的描述符事件注册到epoll空间内,此函数是非阻塞函数,作用仅仅是增删改epoll空间内的描述符信息。

*   参数一:epfd,epoll结构的进程fd编号,函数将依靠该编号找到对应的epoll结构。
*   参数二: op,表示当前请求类型,由三个宏定义

(EPOLL_CTL_ADD:注册新的fd到epfd中)、(EPOLL_CTL_MOD:修改已经注册的fd的监听事件)、(EPOLL_CTL_DEL:从epfd中删除一个fd)

*   参数三:fd,需要监听的文件描述符
*   参数四:event,告诉内核对该fd资源感兴趣的事件。

struct epoll_event结构如下:

```
struct epoll_event{_uint32_t_events;  epoll_data_t_data;}
```

events可以是以下几个宏的集合:
EPOLLIN、EPOLLOUT、EPOLLPRI、EPOLLERR、EPOLLHUP(挂断)、EPOLLET(边缘触发)、EPOLLONESHOT(只监听一次,事件触发后自动清除该fd,从epoll列表)

epoll_wait函数

epoll_wait等待事件的产生,类似于select()调用。根据参数timeout,来决定是否阻塞。

*   参数一:epfd,指定感兴趣的epoll事件列表。
*   参数二:*events,是一个指针,必须指向一个epoll_event结构数组,当函数返回时,内核会把就绪状态的数据拷贝到该数组中!
*   参数三:maxevents,标明参数二epoll_event数组最多能接收的数据量,即本次操作最多能获取多少就绪数据。
*   参数四: timeout,单位毫秒。

> 0: 表示立即返回,非阻塞调用。
> -1: 阻塞调用,直到有用户感兴趣的事件就绪为止。
> 大于0:阻塞调用,阻塞指定时间内如果有事件就绪则提前返回,否则等待指定时间返回。
> 返回值: 本次就绪的fd个数。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。