Java BIO、NIO、select/poll/epoll IO多路复用

233 阅读19分钟

注:本文仅关注Java IO在网络IO中的原理

什么是Socket

  Socket直接的英文翻译是套接字。我认为它很像我们现实世界中的插座。当你的插头插入插座时,插头连接的电器便能通过电线传输电来促使电器工作;在网络世界中,应用程序之于电器,网络之于电线,socket之于插座,当应用程序调用操作系统提供的socket API接口(插头插入插座)时,便能通过网络(电线)传输资源(电)给应用程序(电器)进行工作。

  客户端和服务端之间具体的网络数据传输功能是在操作系统内核态实现的,因为需要操作硬件网卡,要有比较高的操作系统权限,同时还要考虑到性能和安全。

  我们知道linux内核是由C语言实现的,针对网络数据传输功能,它在内部会实现一个sock数据结构。这个sock数据结构具备最基本的数据收发能力,它的内部会带有一个发送缓冲区和接收缓冲区,收发数据则是通过system call找到fd对应的sock结构

注:是通过fd在文件系统中找到对应的文件,然后通过文件中的信息找到内核中的sock结构,在linux中一切皆文件,创建sock结构的同时会在文件系统中创建一个对应文件

  • 当执行系统调用send(fd, msg)时,内核会通过这个fd句柄找到对应的sock结构,并将msg封装成一个链表Node节点放入数据发送队列(发送缓冲区)中;
  • 当执行系统调用recv(fd, msg)时,内核会通过这个fd句柄找到对应的sock结构,并将msg封装成一个链表Node节点放入数据接收队列(接收缓冲区)中。

  inet_sock数据结构和domain_sock数据结构相当于是对sock结构的继承。它们之间的区别是前者关注数据收发在网络传输的相关定义和功能实现,比如ip,port,TTL的网络字段定义信息,后者则是更关注本机进程之间的数据收发功能实现。

  inet_connection_sock是对inet_sock数据结构的继承,它实现了面向连接这一大关键。在原本的的基础上新增了accept队列,数据包分片大小,握手失败重试次数等相关功能,inet_connection_sock结构就是一个基于面向连接的网络数据收发结构。 tcp_sock结构是对inet_connection_sock的封装,它算是正儿八经的实现了TCP协议,inet_connection_sock结构的基础上加上了TCP特有的滑动窗口,拥塞阻塞的功能。

  除了send和recv这两个API之外,当然还需要有bind,listen,connect等等,这些方法都是socket接口提供出来的方法,本质都是使用sock结构中的代码来完成网络传输数据的功能。比如说,当你调用socket中API--connect(fd, ip, port), 它会首先通过fd找到文件系统中对应的文件,再通过文件中的信息找到kernel中的sock结构,再向对应的ip:port发起三次握手... 对于API--listen,内部其实维护了一个hash表,key是源ip+源port+目标ip+目标port组成的hash键,value就是一个对应处理的sock结构。

什么是BIO

先来一张手绘的BIO图:

Java BIO原理图.drawio.png

  我这里解释一下服务端代码中的backlog参数。在linux2.2以后,backlog的大小控制着已经完成三次握手的accept队列的长度。 因为在一个三次握手的流程中,服务端的连接会出现两种不同的状态,当客户端第一次发送SYN给服务端,此时服务端的这个连接的状态应该为SYN_RCVD; 当服务端发送SYN+ACK给客户端,并且客户端再发送ACK给服务端,服务端接收到这个ACK后连接状态会从SYN_RCVD转变成为ESTABLISHED。

  对每一个 监听 端口的socket会维护两个队列,一个是半连接队列,一个是全连接队列。

  • 半连接队列:大小设置:/proc/sys/net/ipv4/tcp_max_syn_backlog,存放状态为SYN_RCVD的连接。
  • 全连接(accept)队列:大小设置:min(backlog, /proc/sys/net/core/somaxconn),存放状态为ESTABLISHED的连接。

  再将视线放回到服务端中的代码中,当程序执行到server.accept()这一行时,此时会从accept队列中直接取连接出来,当没有连接时,服务端会阻塞。如我上图所示,最初的时候,服务端会阻塞住,等待客户端的代码执行进行三次握手这一流程。 而当客户端执行完connect(ip),此时就已完成三次握手,客户端和服务端都向下运行,最后服务端执行到line = reader.getLine()这一行再此阻塞等待客户端发送数据。

  在客户端迟迟没有发送数据的情况下,将视线放在图上的蓝色线位置。 reader.getLine()这行代码底层是一个systemcall:recvfrom,它会查看对应sock结构的数据接收队列中是否有要接收的数据,当数据接收队列中未有要接收的数据时,执行这行代码的进程/线程会被放入阻塞等待队列中,不再消耗CPU资源。进程/线程等待相关的套接字fd以及callback回调函数等信息也会被放入sock结构维护的进程等待队列中(此fd能轻松找到对应的阻塞等待的进程/线程,然后将其唤醒继续之后的流程。因为操作系统为每个进程维护了一个文件描述符表。这个表记录了进程打开的所有文件(包括套接字,因为在操作系统层面,套接字被视为一种特殊的文件)相关信息)。

  当客户端执行代码writer.write("msg")时,底层会调用write/send类型的systemcall进行用户态和内核态的切换,用户缓冲区中的数据会进行内核协议栈模块的封装(TCP头,IP头这些),然后拷贝到RingBuffer(环形缓冲区)中。网卡驱动程序在其中的角色则是负责将内核中的数据格式转换成物理网卡能够理解的信号。当网卡准备好发送数据时,驱动程序会从RingBuffer中取出数据块,经过适当的处理(如将数据进行分片、添加物理层头部等操作,具体取决于网卡的类型和网络协议),然后将其发送到物理网卡。这个过程是通过内存映射 I/O(MMIO)或直接内存访问(DMA)等方式来实现的。最后网卡将其转换为适合在网络介质(如双绞线、光纤、无线信号等)中传输的信号形式。对于有线网络,网卡会产生电信号或光信号,通过网线或光纤发送出去;对于无线网络,网卡会将数据转换为无线信号,通过天线发送到空气中,无线保真,随后便是计算机网络中的拓扑,找到传输过程中的目标ip,将数据包进行解析。

  将视线放在图中的红线处,当服务端接收到刚刚那条客户端发送过来的msg数据,它首先会存在于网卡设备中,然后驱动程序通过DMA拷贝技术将其拷贝至RingBuffer环形缓冲区,此时会触发一个软中断(IO中断),这个软中断会通过fd找到因这个网络IO而阻塞的进程/线程,并通过回调函数callback唤醒这个阻塞的进程/线程并开始抢占CPU资源,或者为其分配时间片以供其在未来消耗CPU资源。

  将视线放在途中的绿线处,此时为相对意义上的T3时刻,刚刚阻塞的进程/线程得到了CPU资源,它会将RingBuffer拷贝到sock结构的数据接收队列中的数据拷贝到用户态的缓冲区中,然后回到用户态才真正从服务端看到发送过来的这条数据。至此,BIO的全过程已经结束。

总结BIO

  从上述图片以及文字,可以看到,BIO在这个进程调用recvfrom systemcall之时会阻塞等待数据,并让出CPU资源;当客户端发送数据给了服务端,这个调用recvfrom systemcall还是在阻塞等待,虽然这个进程已经被唤醒,从阻塞队列移到了运行队列,但是数据并未从内核态复制到用户态。 也就是说,recvfrom造成了两次阻塞(数据传输IO慢),以及两次进程的上下文切换(第一次上下文切换:等待客户端传输数据,进程/线程阻塞;第二次上下文切换:数据从sock结构的数据接收队列中拷贝到用户缓冲区后,此时发生第二次进程上下文切换,之前的阻塞进程/线程重新获取CPU资源并执行后续业务逻辑)。

什么是NIO

  NIO现在有两种常见的解释,一个是New IO, 相当于一个新的IO体系,由原来的同步阻塞IO(BIO)转变为同步非阻塞IO(NIO), 有channel,buffer, selector等新的组件; 另一种解释为Non Blocking IO,从操作系统内核的角度来看称为非阻塞IO。

  相较于原本的BIO来说,它最大的改变在于将recvfrom这一调用由阻塞变为非阻塞, 让我们来看看NIO服务端和客户端的代码(左:服务端 | 右:客户端):

4a4148e49283851ce2c57cca38718af.png new ServerSocketChannel().open()new ServerSocket()意义相同,在操作系统内核中创建一个sock结构,channel.configureBlocking(false)则是取消BIO阻塞模式, 后续服务端代码执行while(true)循环时,当客户端没有通过connect API执行TCP三次握手时,服务端监听端口的socket的accept队列中无法拿到连接,但执行channel.accept()时并不会被阻塞,同时client.readedBuffer(byteBuffer)也不会阻塞,因为当它发现sock结构中的数据接收队列中没有数据时会立刻返回一个-1的值,对应Java中的API则会被包装为一个null值。

  然而同步IO模式也会发生阻塞,也就是说NIO是会发生阻塞的。当客户端发送数据到达了服务端的网卡设备,网卡通过DMA将数据拷贝到RingBuffer中,这时会发生一次I/O中断,操作系统将fd的状态从未就绪修改为已就绪,并通过fd找到对应的sock结构,将数据拷贝到sock结构维护的数据接收队列中,一旦这个数据被拷贝到数据接收队列,那这个recvfrom系统调用就会从非阻塞读变为阻塞读,等待数据从数据接收队列拷贝到用户态的用户缓冲区中,只有当数据完全拷贝到用户态的用户缓冲区中,它才会解除阻塞。那我们知道,在阻塞期间,该进程/线程就会被挂起,后面的流程和BIO就基本相同了,这里不再赘述。

总结NIO

  与BIO相比,阻塞的地方变少了。

  1. 客户端并未与服务端建立连接时,NIO不会阻塞,BIO会阻塞;
  2. 当数据还未到达服务端sock结构的数据接收队列时,NIO不会阻塞,BIO会阻塞;

其余地方与BIO是基本相同,当数据从sock结构的数据接收队列拷贝到用户缓冲区这段时间,这个进程/线程都是阻塞的,进程的上下文切换相同,都是两次。不过对于NIO值得一提的是,虽然NIO在上述两点的时间下都不会发生阻塞,但是while(true)不断去确认sock结构中的数据接收队列中是否存在数据,让CPU空转,浪费CPU资源。

IO多路复用 select/poll/epoll

为什么要有IO多路复用?

  先前对于BIO、NIO的举例,都是只有一个客户端进行连接,那么当有多个客户端连接到服务端socket监听的端口上,会出现什么情况呢?

- BIO:

  假如服务端采用的单线程,正如我先前所说,当accept一个请求后,在recv或send调用阻塞时,将无法accept其他请求(必须等上一个请求处recv或send完),无法处理并发;

  假如服务端采用的多线程,那不就意味着每accept一个连接就创建一个线程去处理这个accept连接吗?若是我连接10000个客户端,意味着我的服务端需要创建10000个系统线程来处理accept连接,而每个线程是会占用内存的,这会疯狂占用你的稀缺资源,毕竟你的内存到达不了512G;其二就是创建的大量线程就会出现大量的线程上下文切换,而accept后进行网路IO的线程甚至未必能到1/5,是大量的无效线程上下文切换。

- NIO

  NIO的做法和先前所说的一样,当客户端accept连接时,将这个connection连接fd放入一个fd数组中,多个客户端都放入这个fd数组中。然后通过轮询这个fd数组,如果没有recv事件发生就继续轮询下去,轮询到的这个fd若发生了recv事件就执行,执行完后继续下一个fd... 这样就做了很多无意义的轮询,因为有的fd可能永远不会发生recv事件。

select

  select IO多路复用的执行原理:假设现在服务端中进程A创建了一个socket,这个socket监听了一个8080端口。然后此时有9个客户端与这个socket进行了TCP三次握手建立了连接。那么操作系统会为这每个connection连接分配fd(4 - 12),(ps:没有0、1、2因为标准输入、输出、错误占用了前三个fd),这些fd在用户态中以一种bitmap的key形式存在,这个bitmap的value只能是0 | 1两种值,一开始都会置0。当这个value的值被置为1时,说明这个文件描述符需要被监听。如:

fd_set read_fds;

FD_ZERO(&read_fds) //bitmap置0

for (int i = 0; i < 9; i ++ ) FD_SET(fd[i], &read_fds); //将要监听的文件描述符置1, 比如011100000,意味着要监听值为5,6,7的fd。 此时进程A调用select(max + 1, &read_fds, NULL, NULL, NULL)会将用户态的bitmap传入到内核态,然后再进行内核态修改操作,用户态这里会出现阻塞情况。这里我先分别解释一下这五个参数,

  1. 第一个参数指的是最大文件描述符个数+1,意思是内核遍历bitmap的个数;
  2. 第二个参数指的是可读文件描述符集合,即监听读事件;
  3. 第三个参数指的是可写文件描述符集合,即监听写事件;
  4. 第四个参数指的是错误文件描述符集合,即监听错误事件;
  5. 第五个参数指的是超时时间,即在指定时间内若没有文件描述符集合被修改则不再阻塞等待,直接返回。

拿刚刚的011100000来举例说,也就是一个bitmap如下:

f1873c2dd27fc5bf7958c25292cc3ab.png

会将如图的bitmap发送进入内核态,此时进程A要监听的5,6,7还没有数据到达,那它就会让出CPU的执行权限,进入到阻塞的状态。

这里对于每个连接fd,都有一个channel,而channel是对连接套接字的封装。当进程A进入到CPU阻塞队列中时,相应的,所监听的5,6,7fd对应的channel的进程等待队列中会存放进程A的文件描述符、回调函数等相关信息。

  select在内核中会不断遍历这个bitmap中的所有fd,直到有某个fd变为已就绪状态或者select调用超时才会返回。 当客户端传输数据到达服务端网卡设备后,就会触发中断信号告知CPU现在有数据到达了,需要通过中断处理程序处理。 中断处理程序会做如下几件事:

  • 通过DMA拷贝技术将网卡设备中的数据拷贝到RingBuffer中;
  • 将这个连接套接字的fd从未就绪状态改为已就绪状态;
  • 根据该fd找到对应的连接套接字的数据接收队列,并将RingBuffer数据拷贝到队列中;
  • 修改已就绪的文件描述信息,对其打上标记;
  • 将这些信息返回给用户态;
  • 唤醒对应fd进程等待队列中的进程,让其重获CPU执行权限。

select系统调用会返回已就绪的文件个数,具体哪个文件就绪了还是需要通过外层进行一个遍历:

对于真正已经就绪的fd,内核就会将数据从对应的数据接收队列中拷贝到用户态的用户缓冲区中然后进行相应的业务处理。

select优缺点

  相较于原本的NIO,我认为select最大的优点是将判断fd的就绪状态的遍历判断完全放入了内核态中。原本的NIO相当于是在用户态进行fd数组的遍历,然后判断每个fd是否为就绪,这样会出现大量的切态。于是select采用了将当前进程已有连接的fd以bitmap的形式放入内核态进行遍历的方式来减少原本NIO的大量切态应用。

  缺点其实是显然易见的,一是bitmap的大小受操作系统本身限制,如32位下bitmap的最大大小为1024;64位下bitmap的最大大小为2048。 二则是select本身是阻塞的,阻塞结束后依旧还是需要遍历fd数组,因为select系统调用仅仅返回已就绪的fd个数,而非返回具体的fd,这里的时间复杂度是O(n)的,三则是每次bitmap需要重新初始化,以便这次的使用。

poll

  poll未采用文件描述符表这种bitmap的方式存储当前已连接的文件描述符集合,而是使用了一种叫pollfd的结构体。

  struct pollfd {

    int fd;

    short event; (event = POLLIN 这里应该是event注册为需要关注的事件如读/写/错误,后面用户态进行判断是通过 event & reevent来判断fd是否已就绪)

    short reevent;

  }

reevent这个属性是由内核去填充的,和select中的文件描述符就绪相似,若有数据到达这个fd对应的连接套接字的数据接收队列中,reevent字段会被内核填充置1,表示当前fd已就绪。此外,相较于select来说,poll不再受文件描述符的大小限制,提高了并发度。它本身的原理和select几乎相同,只是将bitmap改为结构体数组,同样需要将这些结构体数组信息从用户态拷贝到内核态以及从内核态拷贝回用户态,只是相较于select来说, 它不再需要每次进行poll系统调用时将其结构体信息清空,因为当它在用户态进行遍历(上一次)会顺手将reevent的字段置0,因此这里相对于select也是一个小小的优化。

系统调用: poll(fds, fd个数, timeout)

epoll

  对于epoll模型,需要了解三个系统调用。

  • epoll_create(size);
  • epoll_ctl(int epfd, int op, int fd, epoll_event* event);
  • epoll_wait(int epfd, epoll_event* event, int maxevents, int timeout);

   epoll_create会创建一个eventpoll模型,它返回eventpoll模型对应的fd。而epoll_create所需要参数size在linux2.6.8以前的版本是指会有5个客户端连接socket的个数,后续版本似乎没有过于实质的意义,只需保证是一个大于0的数即可。

   eventpoll是一个结构体,它由几个元素组成,分别是:rb_root rbr, 代表红黑树的根节点,这个红黑树会存放相关的连接socket的fd以及这些文件描述符上的相关事件;rdllist,代表已就绪的双端队列,它能告诉内核哪些文件描述符上的事件已就绪;

   当通过epoll_create创建好一个eventpoll模型后,就可以通过epoll_ctl来将被监听的描述符添加到红黑树或从红黑树中删除或者对监听事件进行修改。假设现在有多出五个客户端完成了三次握手,与服务端建立了连接,那么此时。这几个fd会以ADD的方式作为红黑树的节点添加到红黑树中,值得注意的是,再进行红黑树node节点的注册同时,ctl会为当前的连接socket上的等待队列注册回调函数。