从内核与用户空间理解NIO和BIO

linux 系统会划分为User space and Kernel space
cpu对空间管理分为4个不同的级别:Ring0–Ring3。 Ring0下,可以执行特权指令,在Ring3则有很多限制。linux系统则利用这一特性,使用了其中两级来分别运行linux内核与应用程序,这样使操作系统本身得到充分的保护。 用户代码运行在Ring3,内核代码运行在Ring0.
内核空间与用户空间是程序执行的两种不同的状态,通过系统调用和硬件中断能够完成从用户控件到内核空间的转移

用户和内核空间理解IO
IO输入为例,首先是用户空间进程向内核请求某个磁盘空间数据,然后内核将磁盘数据读取到内核空间的buffer中,然后用户空间的进程再将内核空间buffer中的数据读取到自身的buffer中,然后进程就可以访问使用这些数据。
内核空间是指操作系统内核运行的空间,是为了保证操作系统内核的能够安全稳定地运行而为内核专门开辟的空间;而用户空间是指用户程序运行的空间。这里要在磁盘空间和用户空间中加一个内核空间的缓存区的原因有两个:
1)一个是用户空间的程序不能直接去磁盘空间中读取数据,必须由经由内核空间通过DMA来获取;
2)一般用户空间的内存分页与磁盘空间不会对齐,因此需要由内核空间在中间做一层处理。

io对文件拷贝操作:硬盘 -->内核空间 -->用户线程空间 -->内核空间 -->硬盘
io对socket操作: scoket -->内核空间 -->用户线程空间 -->内核空间 -->socket

特别说明:
磁盘–>内核空间 是否阻塞就是这个阶段
内核空间–>用户空间 是否同步指的就是这个阶段

整个IO过程的流程如下:
1)程序员写代码创建一个缓冲区(这个缓冲区是用户缓冲区):哈哈。然后在一个while循环里面调用read()方法读数据(触发"syscall read"系统调用)
byte[] b = new byte[4096];
while((read = inputStream.read(b))>=0) {
total = total + read;
// other code…
}
2)当执行到read()方法时,其实底层是发生了很多操作的:
①内核给磁盘控制器发命令说:我要读磁盘上的某某块磁盘块上的数据。–kernel issuing a command to the disk controller hardware to fetch the data from disk.
②在DMA的控制下,把磁盘上的数据读入到内核缓冲区。–The disk controller writes the data directly into a kernel memory buffer by DMA
③内核把数据从内核缓冲区复制到用户缓冲区。–kernel copies the data from the temporary buffer in kernel space
这里的用户缓冲区应该就是我们写的代码中 new 的 byte[] 数组。

bio 处理read过程图示:
在这里插入图片描述

当左边的应用进程发出了“system call”命令后,kernel首先进入第一阶段“wait for data”(磁盘/网络数据进入系统缓冲区),然后再进入第二阶段“copying the data”(系统缓冲区数据进入我声明的byte数组),最后“return OK”返回到用户进程中,即BIO在两个阶段都是block的,阻塞并同步。

nio处理read过程图示:
在这里插入图片描述

很明显的可以观测到NIO在IO操作的准备数据阶段时有一个轮询操作,会不停地发出“system call”到kernel轮询数据是否准备好,没准备好,应用进程可以处理其他事,准备好了之后在发出一个“system call”到kernel进行第二个阶段复制数据,这个过程是blocking的,所以NIO的特点就是在IO执行的第一阶段不会阻塞,但是在第二阶段将数据从内核拷贝到进程这个真是的IO操作还是会阻塞。

多路复用的nio的read过程:
在这里插入图片描述

多路复用的NIO则是上述的普通NIO的补充,在并发量过大的情况下,不可能每个线程都要轮询自己的IO状态,这时就可以使用selector管理所有的IO通道channel,之用开启一个线程,便可解决成千上万的高并发问题

aio
NIO 2.0引入了新的异步通道的概念,并提供了对异步文件通道和异步套接字通道的实现。(基于NIO)
Asynchronous IO,字面意思即异步的IO,完全不阻塞,那我们看看这个的read操作图示:
在这里插入图片描述

通过图示可以很清楚得发现,如果是AIO发起read操作之后,kernel收到请求后会立即响应应用进程application,所以应用进程完全可以做其他的事,不会造成任何的block。待kernel第一、二阶段都已经完成之后,会给应用进程发送一个signal,告诉它read操作已经完成。所以AIO的特点是在IO的两个阶段都不会发生阻塞,而是全权交给系统内核才完成,内核完成后通过信号告知应用进程即可。

不同的IO对线程的占用情况:

BIO是一个连接一个线程。
BIO的大并发问题:在使用同步I/O的网络应用中,如果要同时处理多个客户端请求,或是在客户端要同时和多个服务器进行通讯,就必须使用多线程来处理。也就是说,将每一个客户端请求分配给一个线程来单独处理。这样做虽然可以达到我们的要求,但同时又会带来另外一个问题。由于每创建一个线程,就要为这个线程分配一定的内存空间(也叫工作存储器),而且操作系统本身也对线程的总数有一定的限制

NIO是一个请求一个线程(非有效请求即不间断查询是否读完)
NIO的最重要的地方是当一个连接创建后,不需要对应一个线程,这个连接会被注册到多路复用器上面,所以所有的连接只需要一个线程就可以搞定,当这个线程中的多路复用器进行轮询的时候,发现连接上有请求的话,才开启一个线程进行处理,也就是一个请求一个线程模式。

AIO是一个有效请求一个线程。
当进行读写操作时,只须直接调用API的read或write方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。 即可以理解为,read/write方法都是异步的,完成后会主动调用回调函数。

nio有什么问题:
1)NIO在不同的平台上的实现方式是不一样的,如果你工作用电脑是win,生产是linux,那么建议直接在linux上调试和测试
2)NIO在IO操作本身上还是阻塞的,即数据从内核空间到用户空间也是阻塞的,该过程会出现由于资源过份庞大而导致线程长期阻塞,最后造成性能瓶颈的情况
少量数据:kernel直接读取所有磁盘数据,后面不再进行任何磁盘读取,内存拷贝还是比较快的,所以此时可以稍微加速数据,性能也是可以接受的
大量数据:kernel必须多次读取磁盘数据,后面需要不断进行磁盘读取,会导致严重的瓶颈

netty中对nio的改进
1 零拷贝

  1. Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
  2. Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。
  3. Netty 的文件传输采用了 transferTo 方法,它可以直接将内核空间的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。
    2 内存池
    随着 JVM 虚拟机和 JIT 即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区 Buffer,情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty 提供了基于内存池的缓冲区重用机制
    3 使用了reactor线程模型

详解zerocopy
我们知道,JVM(JAVA虚拟机)为JAVA语言提供了跨平台的一致性,屏蔽了底层操作系统的具体实现细节,因此,JAVA语言也很难直接使用底层操作系统提供的一些“奇技淫巧”。
而要实现zerocopy,首先得有操作系统的支持。其次,JDK类库也要提供相应的接口支持。幸运的是,自JDK1.4以来,JDK提供了对NIO的支持,通过java.nio.channels.FileChannel类的transferTo()方法可以直接将字节传送到可写的通道中(Writable Channel),并不需要将字节送入用户程序空间(用户缓冲区)

下面就来详细分析一下经典的web服务器(比如文件服务器)干的活:从磁盘中中读文件,并把文件通过网络(socket)发送给Client。
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
从代码上看,就是两步操作。第一步:将文件读入buf;第二步:将 buf 中的数据通过socket发送出去。但是,这两步操作需要四次上下文切换(用户态与内核态之间的切换) 和 四次拷贝操作才能完成。
在这里插入图片描述

①第一次上下文切换发生在 read()方法执行,表示服务器要去磁盘上读文件了,这会导致一个 sys_read()的系统调用。此时由用户态切换到内核态,完成的动作是:DMA把磁盘上的数据读入到内核缓冲区中(这也是第一次拷贝)。
②第二次上下文切换发生在read()方法的返回(这也说明read()是一个阻塞调用),表示数据已经成功从磁盘上读到内核缓冲区了。此时,由内核态返回到用户态,完成的动作是:将内核缓冲区中的数据拷贝到用户缓冲区(这是第二次拷贝)。
③第三次上下文切换发生在 send()方法执行,表示服务器准备把数据发送出去了。此时,由用户态切换到内核态,完成的动作是:将用户缓冲区中的数据拷贝到内核缓冲区(这是第三次拷贝)
④第四次上下文切换发生在 send()方法的返回【这里的send()方法可以异步返回,所谓异步返回就是:线程执行了send()之后立即从send()返回,剩下的数据拷贝及发送就交给底层操作系统实现了】。此时,由内核态返回到用户态,完成的动作是:将内核缓冲区中的数据送到 protocol engine.(这是第四次拷贝)
这里对 protocol engine不是太了解,但是从上面的示例图来看:它是NIC(NetWork Interface Card) buffer。网卡的buffer???

下面这段话,非常值得一读:这里再一次提到了为什么需要内核缓冲区。
Use of the intermediate kernel buffer (rather than a direct transfer of the data
into the user buffer)might seem inefficient. But intermediate kernel buffers were
introduced into the process to improve performance. Using the intermediate
buffer on the read side allows the kernel buffer to act as a “readahead cache”
when the application hasn’t asked for as much data as the kernel buffer holds.
This significantly improves performance when the requested data amount is less
than the kernel buffer size. The intermediate buffer on the write side allows the write to complete asynchronously.
一个核心观点就是:内核缓冲区提高了性能。咦?是不是很奇怪?因为前面一直说正是因为引入了内核缓冲区(中间缓冲区),使得数据来回地拷贝,降低了效率。
那先来看看,它为什么说内核缓冲区提高了性能。
对于读操作而言,内核缓冲区就相当于一个“readahead cache”,当用户程序一次只需要读一小部分数据时,首先操作系统从磁盘上读一大块数据到内核缓冲区,用户程序只取走了一小部分( 我可以只 new 了一个 128B的byte数组啊! new byte[128])。当用户程序下一次再读数据,就可以直接从内核缓冲区中取了,操作系统就不需要再次访问磁盘啦!因为用户要读的数据已经在内核缓冲区啦!这也是前面提到的:为什么后续的读操作(read()方法调用)要明显地比第一次快的原因。从这个角度而言,内核缓冲区确实提高了读操作的性能。

再来看写操作:可以做到 “异步写”(write asynchronously)。也即:wirte(dest[]) 时,用户程序告诉操作系统,把dest[]数组中的内容写到XX文件中去,于是write方法就返回了。操作系统则在后台默默地把用户缓冲区中的内容(dest[])拷贝到内核缓冲区,再把内核缓冲区中的数据写入磁盘。那么,只要内核缓冲区未满,用户的write操作就可以很快地返回。这应该就是异步刷盘策略吧。

既然,你把内核缓冲区说得这么强大和完美,那还要 zerocopy干嘛啊???
如果需要处理的数据远大于内核缓冲区,此时内核缓冲区需要多次的读取磁盘,所以该方案无法支持大量数据的操作。
终于轮到zerocopy粉墨登场了。当需要传输的数据远远大于内核缓冲区的大小时,内核缓冲区就会成为瓶颈。这也是为什么zerocopy技术合适大文件传输的原因。内核缓冲区为啥成为了瓶颈?—我想,大量数据:kernel必须多次读取磁盘数据,后面需要不断进行磁盘读取,此时内存拷贝也会被阻塞等待磁盘读取,会导致严重的瓶颈

下面来看看zerocopy技术是如何来处理文件传输的。
当 transferTo()方法 被调用时,由用户态切换到内核态。完成的动作是:DMA将数据从磁盘读入 Read buffer中(第一次数据拷贝)。然后,还是在内核空间中,将数据从Read buffer 拷贝到 Socket buffer(第二次数据拷贝),最终再将数据从 Socket buffer 拷贝到 NIC buffer(第三次数据拷贝)。然后,再从内核态返回到用户态。
在这里插入图片描述

上面整个过程就只涉及到了:三次数据拷贝和二次上下文切换。感觉也才减少了一次数据拷贝嘛。但这里已经不涉及用户空间的缓冲区了。
三次数据拷贝中,也只有一次拷贝需要到CPU的干预。(第2次拷贝),而前面的传统数据拷贝需要四次且有三次拷贝需要CPU的干预。

如果说zerocopy技术只能完成到这步,那也就 just so so 了。
We can further reduce the data duplication done by the kernel if the underlying network interface card supports
gather operations. In Linux kernels 2.4 and later, the socket buffer descriptor was modified to accommodate this requirement.
This approach not only reduces multiple context switches but also eliminates the duplicated data copies that
require CPU involvement.
也就是说,如果底层的网络硬件以及操作系统支持,还可以进一步减少数据拷贝次数 以及 CPU干预次数。
在这里插入图片描述

从上图看出:这里一共只有两次拷贝 和 两次上下文切换。而且这两次拷贝都是DMA copy,并不需要CPU干预(严谨一点的话就是不完全需要吧.)。
整个过程如下:
用户程序执行 transferTo()方法,导致一次系统调用,从用户态切换到内核态。完成的动作是:DMA将数据从磁盘中拷贝到Read buffer
用一个描述符标记此次待传输数据的地址以及长度,DMA直接把数据从Read buffer 传输到 NIC buffer。数据拷贝过程都不用CPU干预了。

应用:
当Kafka Consumer向Kafka Broker拉取分区消息时,Kafka Broker采用zerocopy技术传递数据。

nio实现非零拷贝和零拷贝的代码实例

    // nio 间接拷贝 内核空间->用户空间->内核空间
    public static boolean inDirectCopyFile(FileInputStream fis, FileOutputStream fos) throws IOException {
        FileChannel srcFileChannel = fis.getChannel();
        FileChannel targetFileChannel = fos.getChannel();
        //间接获取ByteBuffer
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        while(true) {
            byteBuffer.clear();
            int readByte = srcFileChannel.read(byteBuffer);
            if(readByte == -1) {
                break;
            }
            byteBuffer.flip();
            targetFileChannel.write(byteBuffer);
        }
        return true;
    }
    // nio 直接拷贝 内核空间->内核空间
    public static boolean directCopyFile(FileInputStream fis, FileOutputStream fos) throws IOException {
        FileChannel srcFileChannel = fis.getChannel();
        FileChannel targetFileChannel = fos.getChannel();
        //间接获取ByteBuffer
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
        while(true) {
            byteBuffer.clear();
            int readByte = srcFileChannel.read(byteBuffer);
            if(readByte == -1) {
                break;
            }
            byteBuffer.flip();
            targetFileChannel.write(byteBuffer);
        }
        return true;
    }
    // nio零拷贝方法
    public static void nioTransferTo() throws IOException {
        File source = new File("/Users/mazhen/desktop/软件/kesmac.zip");
        File dest = new File("/Users/mazhen/desktop/软件/kesmac.zip1");
        FileChannel sourceChannel = new FileInputStream(source).getChannel();
        FileChannel destChannel = new FileOutputStream(dest).getChannel();
        sourceChannel.transferTo(0, sourceChannel.size(), destChannel);
    }

看下效果:

mac测试 --时间都是ms
1.1G 350M
传统bio方式 20238 6065
indirectBuffer 15556 4673
directBuffer 13268 4600
transferTo 1214 350 —强大的零拷贝

需要拷贝的方式:直接拷贝(使用indirectBuffer)和间接拷贝(使用directBuffer),差别30%性能,差别并不大;
非directBuffer时,内核空间–>用户空间数据–>内核空间。
directBUffer时,内核空间1–>内核空间2。
使用directBuffer少了一次内存拷贝,但是性能提升真的有限

零拷贝和需要拷贝的方式相比,效率差别很大,10-20倍左右性能差距!!!!
所以建议使用transferTo的零拷贝方式

上面使用directBuffer好像没啥卵用,或者是因为我的文件不够大。。。

转载:从内核与用户空间理解NIO和BIO

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值