操作系统层面理解NIO/零拷贝

406 阅读6分钟

BIO

阻塞io

FileInputStream in = new FileInputStream(file);
byte bytes[] = new byte[1024];
in.read(bytes);   //这里阻塞

image-20210407105804161

​ read()方法,系统调用,从用户态进入到内核态,发生上下文切换,在内核态调用文件系统的方法,在DMA控制下读取数据到内核空间缓冲区,读取完成后发生中断,再从内核空间buffer区拷贝数据到洪湖空间buffer区

  • 内核缓冲区作为一个中间缓冲区。用来“适配”用户缓冲区的“任意大小”和每次读磁盘块的固定大小。

  • 用户缓冲区位于用户态空间,而DMA读取数据这种操作涉及到底层的硬件,硬件是不能直接访问用户态空间的。

NIO

非阻塞io

在调用读取方法时,如果数据没有准备好不会阻塞

     ServerSocketChannel ss = ServerSocketChannel.open();
        ss.bind(new InetSocketAddress("host", 1));
        System.out.println(" NIO server started ... ");
        ss.configureBlocking(false);
        while (true) {
            
            //  新线程里面处理
            final SocketChannel socket = ss.accept();//阻塞方法
            socket.configureBlocking(false);
            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

            int read = socket.read(byteBuffer);
            byteBuffer.flip();
            System.out.println("请求:" + new String(byteBuffer.array()));
            String resp = "服务器响应";
            byteBuffer.get(resp.getBytes());
            socket.write(byteBuffer);
            // 新线程里面处理
        }

image-20210407113326892

IO multiplexing 多路复用IO

IO多路复用(IO Multiplexing) 是这么一种机制:程序注册一组socket文件描述符给操作系统,表示“我要监视这些fd是否有IO事件发生,有了就告诉程序处理”。

IO多路复用是要和NIO一起使用的。尽管在操作系统级别,NIO和IO多路复用是两个相对独立的事情。NIO仅仅是指IO API总是能立刻返回,不会被Blocking;而IO多路复用仅仅是操作系统提供的一种便利的通知机制。操作系统并不会强制这俩必须得一起用——你可以用NIO,但不用IO多路复用,就像上一节中的代码;也可以只用IO多路复用 + BIO,这时效果还是当前线程被卡住。但是,IO多路复用和NIO是要配合一起使用才有实际意义

单个线程就可以同时处理多个网络连接。内核负责轮询所有socket,当某个socket有数据到达了,就通知用户进程。多路复用在Linux内核代码迭代过程中依次支持了三种调用,即SELECT、POLL、EPOLL三种多路复用的网络I/O模型

select
  • 句柄上限- 默认打开的FD有限制,1024个。

  • 重复初始化-每次调用 select(),需要把 fd 集合从用户态拷贝到内核态,内核进行遍历。

  • 逐个排查所有FD状态效率不高。

    image-20210407113252935

int play_select()
{
	fd_set rfds;
	FD_ZERO(&rfds);
	FD_SET(STDIN_FILENO, &rfds);
 
	fd_set wfds;
	FD_ZERO(&wfds);
	FD_SET(STDOUT_FILENO, &wfds);
	
	struct timeval tv;
	tv.tv_sec = 5;
	tv.tv_usec = 0;
	int retval = select(STDOUT_FILENO + 1, &rfds, &wfds, NULL, &tv);
	if (retval < 0)
	{
		printf("select error\n");
		return -1;
	}
 
	if (retval == 0)
	{
		printf("time out\n");
		return -1;
	}
 
 
	// 需要对所有句柄进行轮询遍历
	if(FD_ISSET(STDIN_FILENO, &rfds))
	{
		printf("can read\n");
	}
 
	if(FD_ISSET(STDOUT_FILENO, &wfds))
	{
		printf("can write\n");
	}
 
	return 0;	   
}

poll
  • 设计新的数据结构(链表)提供使用效率。

  • poll和select相比在本质上变化不大,只是poll没有了select方式的最大文件描述符数量的限制。

  • 缺点:逐个排查所有FD状态效率不高。

int play_poll()
{
	pollfd pfd[2];
	int nfds = 2;
	
	pfd[0].fd = STDIN_FILENO;
	pfd[0].events = POLLIN;
 
	pfd[1].fd = STDOUT_FILENO;
	pfd[1].events = POLLOUT;
 
	int timeout_ms = 5000; // 5000毫秒
	int retval = poll(pfd, nfds, timeout_ms);
	if (retval < 0)
	{
		printf("poll error\n");
		return -1;
	}
 
	if (retval == 0)
	{
		printf("time out\n");
		return -1;
	}
 
 
	// 需要对所有句柄进行轮询遍历
	if(pfd[0].revents & POLLIN)
	{
		printf("can read\n");
	}
 
	if(pfd[1].revents & POLLOUT)
	{
		printf("can write\n");
	}
 
	return 0;
}

epoll

没有fd个数限制,用户态拷贝到内核态只需要一次,使用事件通知机制来触发。通过epoll_ctl注册fd,一旦fd就绪就会通过callback回调机制来激活对应fd,进行相关的I/O操作。

  • 跨平台,Linux 支持最好。
  • 底层实现复杂。
  • 同步。

image-20210407113242188

int play_epoll()
{
	epoll_event evReq[10];
	epoll_event evRsp[10];
 
	evReq[0].data.fd = STDIN_FILENO;
	evReq[0].events = EPOLLIN;
 
	evReq[1].data.fd = STDOUT_FILENO;
	evReq[1].events = EPOLLOUT;
 
	// 创建管理句柄
	int epollFd = epoll_create(10);
 
	// 添加以便于管理
	epoll_ctl(epollFd, EPOLL_CTL_ADD, evReq[0].data.fd, evReq);
	epoll_ctl(epollFd, EPOLL_CTL_ADD, evReq[1].data.fd, evReq + 1);
 
	int timeout_ms = 5000; // 5000毫秒
	int retval = epoll_wait(epollFd, evRsp, 10, timeout_ms);
	if (retval < 0)
	{
		printf("epoll error\n");
		close(epollFd);
		return -1;
	}
 
	if (retval == 0)
	{
		printf("time out\n");
		close(epollFd);
		return -1;
	}
 
	// 无需遍历所有句柄,只需直接取结果就行,如下evRsp[i]对应的句柄一定是"就绪的(active)"
	for (int i = 0; i < retval; ++i) 
	{
		printf("active fd is %d\n", evRsp[i].data.fd);
	}
	
	close(epollFd);
	return 0;
}

同步、异步

  • 同步:API调用返回时调用者就知道操作的结果如何了(实际读取/写入了多少字节)。
  • 异步:相对于同步,API调用返回时调用者不知道操作的结果,后面才会回调通知结果。(取数据在另外的线程)

零拷贝

零拷贝技术是操作系统底层支持的

zerocopy技术的目标就是提高IO密集型JAVA应用程序的性能。在本文的前面部分介绍了:IO操作需要数据频繁地在内核缓冲区和用户缓冲区之间拷贝,而zerocopy技术可以减少这种拷贝的次数,同时也降低了上下文切换(用户态与内核态之间的切换)的次数。

一、经典服务器读取文件返回流程
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);

从代码上看,就是两步操作。第一步:将文件读入buf;第二步:将 buf 中的数据通过socket发送出去。但是,这两步操作需要四次上下文切换(用户态与内核态之间的切换) 和 四次拷贝操作才能完成。

image-20210407162347472

内核缓冲区的作用:

缓存,内核缓冲区确实提高了读操作的性能

异步写

用户空间请求的数据块跟磁盘读取的数据块不一致时,可以在缓冲区整合成用户空间需要的大小

当需要传输的数据远远大于内核缓冲区的大小时,内核缓冲区就会成为瓶颈

二、mmap
buf = mmap(diskfd, len);

write(sockfd, buf, len);

应用程序调用 mmap ,磁盘文件中的数据通过 DMA 拷贝到内核缓冲区,接着操作系统会将这个缓冲区与应用程序共享,这样就不用往用户空间拷贝。应用程序调用write ,操作系统直接将数据从内核缓冲区拷贝到 socket 缓冲区,最后再通过 DMA 拷贝到网卡发出去。

image-20210407164452467

缺点:

mmap 隐藏着一个陷阱,当 mmap 一个文件时,如果这个文件被另一个进程所截获,那么 write 系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump,如果服务器被这样终止了,那损失就可能不小了。

加锁解决

三、senfile

sendfile 是只发生在内核态的数据传输接口,没有用户态的参与,自然避免了用户态数据拷贝,涉及到三次数据拷贝和二次上下文切换,感觉也才减少了一次数据拷贝嘛,但这里已经不涉及用户空间的缓冲区了

image-20210407162854930

四、DMA 辅助的 sendfile

这种方法借助硬件的帮助,在数据从内核缓冲区到 socket 缓冲区这一步操作上,并不是拷贝数据,而是拷贝缓冲区描述符,待完成后,DMA 引擎直接将数据从内核缓冲区拷贝到协议引擎中去,避免了最后一次拷贝。

这里一共只有两次拷贝 和 两次上下文切换。而且这两次拷贝都是DMA copy,并不需要CPU干预

image-20210407163126928

缺点:

只适用于将数据从文件拷贝到套接字上。

五、splice

splice 去掉 sendfile 的使用范围限制,可以用于任意两个文件描述符中传输数据。

java堆外内存(直接内存)

堆内存jvm管理的,同样也是gc 的主要工作区域,可以由jvm自动回收垃圾。

堆外内存区别:

  • 直接内存是在堆外,不受jvm管理,申请过多不会引起gc

  • 写数据的时候,若数据在堆上,则需要从堆上拷贝到堆外,操作系统才可以去操作这个拷贝的数据,如果本来就是堆外的数据,就不用复制到堆外了,

    为什么不直接操作堆上的数据?

    因为Java 有gc,gc 可能不会回收要被写的数据,但是可能会移动它(把已用内存压缩在一边,清除内存碎片),操作系统是通过内存地址去操作内存的,内存地址变了,这些写到文件或者网络里的数据可能并不是我们想要写的数据,也有可能产生很多未知的错误

writeData内存区域(堆内) --> 临时的DirectByteBuffer(堆外) --> 系统内核空间buffer --> 网卡

DirectByteBuffer
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(10240);
byteBuffer.put((byte) 1);
释放堆外内存

使用完DirectByteBuffer对象只要把对象引用置为空,等待垃圾回收器去回收该对象即可,DirectByteBuffer对象被回收的时候 它的 虚引用也就是Cleaner 对象会被放到 ReferenceQueue 中,然后专门有一个ReferenceHandle 线程去处理这个队列 若发现从队列里取出的是Cleaner 对象则会执行 clean()方法,clean 方法会调用 thunk 属性的run方法 对于DirectByteBuffer来说这个thunk就是其嵌套类对象,主要看run 方法 就是释放内存。

创建buffer对象的时候同时创建了一个cleaner

private static class Deallocator
        implements Runnable
    {
        private static Unsafe unsafe = Unsafe.getUnsafe();

        private long address;
        private long size;
        private int capacity;

        private Deallocator(long address, long size, int capacity) {
            assert (address != 0);
            this.address = address;
            this.size = size;
            this.capacity = capacity;
        }

        public void run() {
            if (address == 0) {
                // Paranoia
                return;
            }
            //回收堆外内存
            unsafe.freeMemory(address);
           //内存地址置为零,防止重复回收
            address = 0;
           
            Bits.unreserveMemory(size, capacity);
        }
    }
MappedByteBuffer

A direct byte buffer whose content is a memory-mapped region of a file

内存映射,把文件映射到堆外内存,像内存一样访问文件

MappedByteBuffer主要用在对大文件的读写或对实时性要求比较高的程序当中

    // 通过 RandomAccessFile 创建对应的文件操作类,第二个参数 rw 代表该操作类可对其做读写操作
        RandomAccessFile raf = new RandomAccessFile("fileName", "rw");

        // 获取操作文件的通道
        FileChannel fc = raf.getChannel();

        // 也可以通过FileChannel的open来打开对应的fc
        // FileChannel fc = FileChannel.open(Paths.get("/usr/local/test.txt"),StandardOpenOption.WRITE);


        // 把文件映射到内存
        MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE, 0, (int)    fc.size());

        // 读写文件
        mbb.putInt(4);
        mbb.put("test".getBytes());
        mbb.force();

        mbb.position(0);
        mbb.getInt();
        mbb.get(new byte["test".getBytes().size()]);

image-20210408142504883