Loading

Java NIO原理 (Selector、Channel、Buffer、零拷贝、IO多路复用)

系列文章目录和关于我

零丶背景

最近有很多想学的,像netty的使用、原理源码,但是苦于自己对于操作系统和nio了解不多,有点无从下手,遂学习之。

一丶网络io的过程

image-20230610201828763

上图粗略描述了网络io的过程,了解其中的拷贝过程有利于我们理解非阻塞io,以及IO多路复用的必要性。

  1. 数据从网卡到内核缓冲区
    网卡通过DMA的方式将网络帧copy到内核空间

    并不是拷贝到内核空间就完事了,因为还需要根据协议对数据进行处理。

    所以网卡使用硬中断通知cpu,cpu响应后会使用网卡注册函数进行收包,然后协议层处理网络帧。

  2. 数据从内核缓冲区到用户空间

    根据协议处理好的数据,还需要拷贝到用户空间才能被运行在内核态的应用程序使用==>cpu进行数据拷贝。随后内核唤醒用户进程,相当于我们的java程序从阻塞io中被唤醒,继续执行下一行代码的执行。

二丶Socket通信过程与其中的阻塞点

img

这其中有几个阻塞的过程

  • accept 系统调用:等待客户端建立tcp连接

    这个问题不大,没有连接那么阻塞服务端线程,可以节约cpu资源。

  • read系统调用:等待请求数据来到用户空间

    数据从网卡到用户空间的过程,线程时阻塞的

  • Servlet#service 处理请求是一个同步过程

    tomcat根据http协议构造request,并和response作为参数,找到对应Servlet调用service方法,Servlet#service方法执行结束,返回内容才能通过write系统调用回应数据。

    这导致在业务处理上需要使用线程池来让服务端可以处理多个并发请求。

  • write系统调用:响应数据写回

    write系统调用将servlet处理后的响应数据,写回到文件描述符中。

三丶NIO解决了什么问题

1.单线程监测若干个文件描述符是否可以执行IO操作

这就是常说的IO多路复用,那为什么需要IO多路复用?

尽量使用较少的系统资源处理更多的连接,如果当前单台服务器接收了1w个请求,服务端当如何处理?

1.1 传统BIO模型

image-20230611122304883

上面是一段java BIO模型并发处理多请求的实例代码,它有以下不足

  • 大量的线程占用很大的内存空间
  • 线程切换会带来很大的开销
  • process方法中需要需要调用read系统调用,阻塞直到可读,并没有真正进行读写操作。

1.2. 非阻塞IO

image-20230611124511674

上面是非阻塞IO的一个实例

socketChannel.configureBlocking(false)可以让后续的read在通道数据没有就绪的时候直接返回-1,而不是让线程阻塞。这个特性让调度线程池中的线程减少了阻塞,从而节省了线程资源。

image-20230405164149306

但是这种方式也不是没有任何缺点,多次系统意味着多次系统调用,每次系统调用都需要,用户态<=>内核态的来回切换,需要cpu保存进程的上下文,调用结束还需要恢复进程的上下文。

image-20230611133144345

1.3 IO多路复用

image-20230611135115721

如上是Java IO多路复用的简陋例子。操作系统提供了多路复用的机制,将连接上来的客户端都进行注册,然后不断循环扫描各个客户端连接,监听客户端的请求。但是,多路复用轮询扫描各个客户端连接的过程在操作系统内核中进行极大的加快了多路复用的效率,减少了用户态和内核态的切换

2.减少堆内内存<=>堆外内存的拷贝开销

使用NIO Channel读写时需要需要先读到堆外内存,然后拷贝到堆内内存,如果直接使用堆外内存则可以减少堆外到堆内的拷贝过程。

下图是将Channel数据读取到Buffer,调用IOUtil#read的源码

image-20230611154939538

下图是将Buffer数据写入到Channel,调用IOUtil#write的源码

image-20230611155307043

2.1 为什么需要再堆外内存和堆内内存来回捯饬?

写入Buffer数据到文件描述符,or读取文件描述符数据到Buffer都是需要进行系统调用的,执行系统调用依赖于执行native方法,而执行native方法的线程被认为是处于SafePoint,处于SafePoint就有可能发生 GC 重排列对象内存的情况。

并且这个写入和读取是针对地址的(如下图,最终的native调用需要传入地址)如果写入或者读取buffer由于gc移动,那么地址会改变,但是native方法调用可不管这个,就导致读写出现错误。因此需要依赖于堆外内存。

image-20230611155717440

2.2 为什么Socket基于Inpustream,OutputStream没有这个问题

image-20230611160657076

以SokcetInputStream的读为例,读最终调用socktRead0这个native方法,入参fd是当前Socket对应的文件描述符,byte数组就是数据最终读入的目的地。

下图是native 方法socketRead0的实现

image-20230611161135896

可以看到,其实是先将socket fd内容读取到c语言声明的数组,然后拷贝到Java byte[],这个c语言声明的数组其实作用类似于直接内存!

3.减少内核空间和用户空间的拷贝开销

上面说了直接内存的作用:减少堆外堆内的拷贝开销。无论堆外堆内,都是用户空间的拷贝。

3.1 DMA控制器替CPU打工

image-20230611162822090

上图是读取磁盘文件的时序图,可以看到如果没有DMA技术,蓝色部分需要CPU来完成,将浪费宝贵的资源。

再DMA读取到足够数据后,会发送中断信号给CPU,让CPU将内核缓冲区数据,拷贝到用户缓冲区,随后CPU再来调度Java程序,Java程序才能操作到用户缓冲区的数据。

3.2 零拷贝

3.2.1 传统文件传输

如下图是我们使用IO流,读取磁盘文件,通过Socket API 发送的流程,其中需要read,和 write 系统调用,每次系统调用都意味着用户态与内核态的上下文切换

并且还有四次数据拷贝,其中两次由DMA负责打工,两次由CPU负责拷贝。

image-20230611163605920

如何优化:

  • 如果Java程序不需要对磁盘数据内容进行再加工(业务操作)那么不需要拷贝到用户空间,从而减少拷贝次数
  • 由于用户空间没有操作网卡和磁盘的权限,操作这些设备需要由操作系统内核完成,那么如果操作系统提供新的系统调用函数,岂不是就可以减少用户态与内核态的上下文切换
3.2.2 mmap + write

image-20230611164712477

  • 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核共享这个缓冲区;
  • 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
  • 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的

所以mmap优化了什么?

mmap并没有减少系统调用带来的内核态用户态切换开销,只是应用程序和内核共享缓冲区,从而让cpu可以直接将内核缓冲区的数据,拷贝到socket缓冲区,不需要拷贝到用户缓冲区,再从用户缓冲区拷贝到socket缓冲区。

3.2.3 sendfile

linux 提供sendfile系统调用,只需这一个系统调用就可以从一个文件描述符拷贝数据到另外一个文件描述符

image-20230611165644768

image-20230611165437243

sendfile可以减少write,read导致的系统调用,从而优化效率。

如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术,那么还可以进一步优化。

image-20230611165914874

  1. 通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
  2. 缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝。

这便是所谓的零拷贝,减少内存层面拷贝数据的次数,以及系统调用内核态用户态的切换,从而优化性能。

3.3 NIO中的零拷贝

3.3.1 FileChannel#map

NIO中的FileChannel.map()方法使用了mmap系统调用实现内存映射方式

将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。这种方式适合读取大文件,同时也能对文件内容进行更改,但是如果其后要通过SocketChannel发送,还是需要CPU进行数据的拷贝。

image-20230611172227076

如上是MappedByteBuffer的获取方式,其实底层是通过反射调用DirectByteBuffer的构造方法实现的,其中的cleaner是直接内存的回收器,传入的unmapper会被回调,从而调用native方法实现资源释放。

image-20230611172125469

这种方式适合读取大文件,同时也能对文件内容进行更改。

3.3.2 FileChannel#transferTo,transerFrom

image-20230611173326560

在操作系统层面是调用的一个sendFile系统调用。通过这个系统调用,可以在内核层直接完成文件内容的拷贝。

4.FileChannel#force强制刷盘

由于CPU的运行速度非常快,所以CPU在执行指令时,通常只能与缓存进行交互,而不适合直接操作像磁盘、网卡这样的硬件。也因此,在进行文件写入时,操作系统也是先写入到page cache中,缓存起来,然后再往硬件写入。

缓存有利也有弊,使用page cache页缓存,应用程序将数据都写入到了page cache中,但是却没有真正写入磁盘。如果这个时候出现断电,那么将出现缓存数据丢失。

FileChannel#force会进行fsync系统调用

image-20230611174940240

fsync可以实现将page cache缓存内容进行落盘,从而保证不丢失(redis aof可以设置持久化机制,通常设置每秒落盘一次,这里落盘也是fsync系统调用)。为了性能考虑,应用程序不可能每写入一点数据就调用fsync,fsync也是有性能损耗的。

四丶IO多路复用 select/poll/epoll

上面我们聊到了IO多路复用解决了什么问题,以及NIO Selector的基本使用,但是没有探究在操作系统层面是如何实现的,下面来学习一下。

1.select系统调用

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout)
  • nfds: 最大的文件描述符+1,代表监听这一组描述符(为什么要+1?因为除了当前最大描述符之外,还有可能有新的fd连接上来)
  • fd_set: 是一个位图集合, 对于同一个文件描述符,可以监听不同的事件
  • readfds:文件描述符“可读”事件
  • writefds:文件描述符“可写”事件
  • exceptfds:文件描述符“异常”事件,一般内核用的,实际编程很少使用
  • timeout:超时时间:0是立即返回,-1是一直阻塞,如果大于0,则达到设置值的微秒数即返回
  • 返回值: 所监听的所有监听集合中满足条件的总数(满足条件的读、写、异常事件的总数),出错时返回-1,并设置errno。如果超时时间触发,则返回0

select 其实就是把NIO中用户态要遍历的fd数组拷贝到了内核态,让内核态来遍历,因为用户态判断socket是否有数据依旧需要通过系统调用,切换到内核态进行。

可以看到select依赖了很多位图参数,系统调用完后需要用户程序遍历一次位图才能直到哪一个fd具备了io事件,并且这个位图大小最大为1024,导致select用起来需要很多位操作并且最多只能支持1024路IO。

2.poll系统调用

int poll(struct pollfd *fds, nfds_t nfds/*最大监听的文件描述符个数*/, int timeout/*最大监听的文件描述符个数*/);

其中pollfd为:

struct pollfd {
      int   fd;         /* file descriptor */
      short events;     /* requested events */
      short revents;    /* returned events */
};

poll可以看作升级版select,它突破了1024个文件描述符的限制,并且poll函数的监听和返回是分开的,简化了代码实现。

虽然poll不需要遍历所有的文件描述符了,只需要遍历加入数组中的描述符,范围缩小了很多,但缺点仍然是需要遍历,当加入数组描述符很多,但是存在事件的fd很少,这个遍历操作还是有点不划算的。

3.epoll系统调用

在linux环境下,java nio中的selector就是基于epoll实现的。

3.1 epoll_create

int epoll_create(int size)
    //返回一个fd
    //传入大小作为参考值

epoll_create返回一个特殊的文件描述符,它代表红黑树的根节点。size则是树的大小,它代表你将监听多少个文件描述符。epoll_create将按照传入的大小,构造出一棵大小为size的红黑树。

3.2 epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epfd 是epoll_create的返回值,也就说红黑树的根节点
// op 表示操作,比如增加,修改,删除
//fd 是需要增加,修改,删除的文件描述符
// struct epoll_event *event 是一个结构体,如下
struct epoll_event {
               uint32_t     events;      /* Epoll events 读事件or写事件,or 异常事件*/
               epoll_data_t data;        /* User data variable */
           };
 typedef union epoll_data {
               void        *ptr;
               int          fd;//代表一个文件描述符,初始化的时候传入需要监听的文件描述符,当监听返回时,此处会传出一个有事件发生的文件描述符,因此,无需我们遍历得到结果了
               uint32_t     u32;
               uint64_t     u64;
           } epoll_data_t;

用来操作epoll句柄,可以使用该函数往红黑树里增加文件描述符,修改文件描述符,和删除文件描述符。

3.3 epoll_wait

int epoll_wait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout);
//epfd 是epoll_create的返回值,也就说红黑树的根节点
// struct epoll_event *events 是一个数组,返回的所有触发了事件的文件描述符集合
//maxevents代表这个数组的大小
//timeout 0代表立即返回,-1代表永久阻塞,如果大于0,则代表超时等待毫秒数

3.4 水平触发,边缘触发

epoll有两种触发方式,分别为水平触发边沿触发

  • 水平触发

    只要有数据处于就绪状态,那么可读事件就会一直触发。

    举个例子,假设客户端一次性发来了4K数据 ,但是服务器recv函数定义的buffer大小仅为1024字节,那么一次肯定是不能将所有数据都读取完的,这时候就会继续触发可读事件,直到所有数据都处理完成。

    epoll默认的触发方式就是水平触发。

  • 边缘触发

    只有数据发送过来的时候会触发一次,即使数据没有读取完,也不会继续触发。

  • 触发方式的设置:

    水平触发和边沿触发在内核里 使用两个bit mask区分,分别为:

    • EPOLLLT 水平 触发
    • EPOLLET 边沿触发

    需要在注册事件的时候将其与需要注册的事件做一个位或运算即可:

    ev.events = EPOLLIN;    //LT
    ev.events = EPOLLIN | EPOLLET;   //ET
    

4.总结

select函数需要一次性传入所有需要监控的连接(在内核中是FD),并在内核中对这些FD进行持续的扫描。当发现其中有FD不老实时,就会通知应用程序有客户端事件发生了, 上层应用接到通知后,就只能自己再去遍历所有的FD,寻找有事件发生的连接,然后进行业务处理。
但是select受限于操作系统,扫描的FD个数是受限的。

于是出现了Poll函数,解决了slelect文件描述符受限的问题。但是,上层应用程序依然要自己去遍历所有客户端,寻找哪个客户端上有事件发 生。高并发场景下,性能依然严重受限。
于是又出现了epoll机制。

epoll机制会直接返回有事件发生的FD。这样就省掉了上层应用频繁扫描所有客户端的消耗,进一步解决多路复用的高并发问题。

posted @ 2023-06-11 18:53  Cuzzz  阅读(991)  评论(0编辑  收藏  举报