网络编程之BIO、NIO

今来聊聊BIO、NIO编程的原理,还有IO多路复用器selector的底层原理与零拷贝技术,算是回顾下socket这些网络编程。

BIO编程

BIO ( blocking IO),看名就能知道这是个阻塞IO的编程。相信大家入门java的时候都应该学过socket,刚入门学的就是BIO编程了。
我们端对端(比如电脑与电脑)想要连接发送消息,就可以通过socket来进行,平时使用的微信,QQ都是通过socket来实现的,对应编程来说,就需要两个角色: S e r v e r S o c k e t \color{#FF0000}{ServerSocket} ServerSocket S o c k e t \color{#FF0000}{Socket} Socket ,每个端都有自己的socket,服务器端使用的是ServerSocket建立连接,连接后会生成一个Socket,客户端使用的是Socket。代码如下:
服 务 器 端 \color{#FF7D00}{服务器端}
1、先建立个 S e r v e r S o c k e t \color{#FF0000}{ServerSocket} ServerSocket,开放一个端口号port,然后就可以等待客户端的连接了
2、 连接建立后,会生成一个 S o c k e t \color{#FF0000}{Socket} Socket对象,通过它与客户端进行交互

public class BIOServerSocket {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8081);
        Socket clientSocket = serverSocket.accept();
        System.out.println("接收连接...");

        InputStream readClientDataIS = clientSocket.getInputStream();
        byte[] bytes = new byte[1024];
        StringBuffer stringBuffer = new StringBuffer();
        int len = readClientDataIS.read(bytes);
        stringBuffer.append(new String(bytes, 0, len));
        System.out.println("接收来自客户端的信息:" + stringBuffer);
    }
}

客 户 端 \color{#FF7D00}{客户端}
1、建立一个 S o c k e t \color{#FF0000}{Socket} Socket对象,并绑定服务器ip和端口号,请求连接
2、信息正确后就能建立连接,通过socket对象与服务器进行交互

public class BIOClientSocket {

    public static void main(String[] args) throws IOException {
        Socket clientSocket = new Socket("localhost", 8081);
        System.out.println("本地已连接...");
        OutputStream outputStream = clientSocket.getOutputStream();
        String sendMsg = "Hello server";
        outputStream.write(sendMsg.getBytes(StandardCharsets.UTF_8));
        outputStream.flush();

        clientSocket.close();
    }
}

建立连接的demo代码就是这么简单,但是因为BIO是阻塞IO,所以,服务端连接了客户端后,服务端就会一直阻塞在那里,即便有新的客户端请求连接,也会阻塞在那做不了交互。我们肯定会想到使用多线程,在较小的连接情况下,我们可以试试,但是如果很多客户端进行连接的话,则服务器会抗不住的,所以这种编码方式基本都不被采用。
BIO模型

NIO编程

non blocking IO,非阻塞IO,可以实现一个服务端线程连接多个客户端连接交互,这就能满足我们的需求,BIO是通过 S e l e c t o r \color{#FF0000}{Selector} Selector来实现IO多路复用,所有的连接都是被selector来处理的,每个客户端连接过来,都会被存储在list里,然后遍历list来进行处理交互。

NIO主要有3大组件: C h a n n e l \color{#FF0000}{Channel} Channel通道, B u f f e r \color{#FF0000}{Buffer} Buffer缓冲区, S e l e c t o r \color{#FF0000}{Selector} Selector多路复用器。
通过 C h a n n e l \color{#FF0000}{Channel} Channel建立socket间的数据传输通道,所有的数据都是通过 B u f f e r \color{#FF0000}{Buffer} Buffer缓冲区来进行操作的,客户端把数据放到 B u f f e r \color{#FF0000}{Buffer} Buffer缓冲区,服务端从 B u f f e r \color{#FF0000}{Buffer} Buffer缓冲区中读取。然后 C h a n n e l \color{#FF0000}{Channel} Channel会把自己感兴趣的事件注册到 S e l e c t o r \color{#FF0000}{Selector} Selector上,当有对应事件发生时, S e l e c t o r \color{#FF0000}{Selector} Selector会让对应 C h a n n e l \color{#FF0000}{Channel} Channel来处理事件。

来看下代码,客户端的代码是没什么变动的,变动的是服务端的处理逻辑。
服 务 器 端 \color{#FF7D00}{服务器端}
1、先通过ServerSocketChannel.open()方法获取 S e r v e r S o c k e t C h a n n e l \color{#FF0000}{ServerSocketChannel} ServerSocketChannel对象,类似ServerSocket。
2、然后通过serverSocket.socket()方法获取ServerSocket对象,并绑定端口号port,设置非阻塞。
3、创建 S e l e c t o r \color{#FF0000}{Selector} Selector对象,底层是epoll对象。
4、为serverSocket对象在 S e l e c t o r \color{#FF0000}{Selector} Selector上注册OP_ACCEPT连接接受事件。
5、线程就会一直轮询的检测是否有事件发生,通过 s e l e c t o r . s e l e c t ( ) \color{#FF0000}{selector.select()} selector.select()控制
6、有事件过来了,获取事件列表,遍历处理
7、根据事件类型做对应的处理:

  • 如果是连接事件,则先获取 S e r v e r S o c k e t C h a n n e l \color{#FF0000}{ServerSocketChannel} ServerSocketChannel对象,然后通过该对象获取 S o c k e t C h a n n e l \color{#FF0000}{SocketChannel} SocketChannel对象,连接建立好了,则为该 S o c k e t C h a n n e l \color{#FF0000}{SocketChannel} SocketChannel注册读事件,
  • 如果是读事件,则获取 S o c k e t C h a n n e l \color{#FF0000}{SocketChannel} SocketChannel对象,然后做读取数据操作。
public class NIOServerSocket {

    public static void main(String[] args) throws IOException {
        // 创建channel
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        // 通过socket()方法获取ServerSocket,然后绑定端口号
        serverSocket.socket().bind(new InetSocketAddress(8082));
        // 设置为非阻塞
        serverSocket.configureBlocking(false);
        // 使用Selector,创建epoll
        Selector selector = Selector.open();
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);

        while (true){
            // 等待客户端的事件发生,有才往下走
            selector.select();

            // 有事件过来了,遍历列表做事件处理,selectionKeys是对应着SocketChannel在Selector的令牌标识
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();

            while (iterator.hasNext()){
                SelectionKey selectionKey = iterator.next();
                // 处理OP_ACCEPT事件
                if (selectionKey.isAcceptable()) {
                    // 获取ServerSocketChannel对象
                    ServerSocketChannel serverSocketChannel  = (ServerSocketChannel) selectionKey.channel();
                    // 通过ServerSocketChannel对象来获取SocketChannel
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    // 设置SocketChannel非阻塞
                    socketChannel.configureBlocking(false);
                    System.out.println("客户端连接成功...");
                    // 连接事件处理完了就为该socketChannel注册读事件
                    socketChannel.register(selector, SelectionKey.OP_READ);
                }else if (selectionKey.isReadable()){ // 处理OP_READ事件
                    // 已经是建立过连接的了,直接获取SocketChannel对象
                    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
                    // 读写数据都是通过ByteBuffer的
                    ByteBuffer byteBuffer = ByteBuffer.allocate(256);
                    int readLen = socketChannel.read(byteBuffer);
                    if (readLen > 0){
                        System.out.println("客户端消息:" + new String(byteBuffer.array()));
                    }else if (readLen == -1) {
                        System.out.println("客户端断开连接...");
                        socketChannel.close();
                    }
                }
                // 处理完了时间就把事件删除掉
                iterator.remove();
            }
        }
    }
}

流程图如下:
NIO流程

我们再来仔细的梳理下:

  • 首先,服务端实例化 S e r v e r S o c k e t C h a n n e l \color{#FF0000}ServerSocketChannel ServerSocketChannel对象和 S e l e c t o r \color{#FF0000}Selector Selector对象,然后 S e r v e r S o c k e t C h a n n e l \color{#FF0000}ServerSocketChannel ServerSocketChannel S e l e c t o r \color{#FF0000}Selector Selector注册 O P _ A C C E P T \color{#FF0000}{OP\_ACCEPT} OP_ACCEPT连接接受事件, S e r v e r S o c k e t C h a n n e l \color{#FF0000}ServerSocketChannel ServerSocketChannel只对 O P _ A C C E P T \color{#FF0000}{OP\_ACCEPT} OP_ACCEPT连接接受事件感兴趣,只会处理这类事件。
  • 客户端连接服务端成功后,会产生 S o c k e t C h a n n e l \color{#FF0000}SocketChannel SocketChannel对象,此时就可以向 S e l e c t o r \color{#FF0000}Selector Selector注册 O P _ R E A D \color{#FF0000}{OP\_READ} OP_READ事件,然后就可以发送数据了。
  • 当客户端向 B u f f e r \color{#FF0000}Buffer Buffer发送数据时,会产生 中 断 \color{#FF0000}中断 ,此时操作系统就会向就绪事件列表 r d l l i s t \color{#FF0000}rdllist rdllist存放事件(可以理解为SocketChannel)
  • 我们的线程Thread就会去遍历 r d l l i s t \color{#FF0000}rdllist rdllist列表,依次处理里边的事件,比如读取客户端发送的数据。

在NIO里,主要有4种事件类型:读、写,连接、连接接受
服务端的ServerSocketChannel只关心OP_ACCEPT事件,其他的都是SocketChannel所关心的事件

// 源码里做的定义
// Operation-set bit for read operations.
public static final int OP_READ = 1 << 0;
// Operation-set bit for write operations.
public static final int OP_WRITE = 1 << 2;
// Operation-set bit for socket-connect operations.
public static final int OP_CONNECT = 1 << 3;
// Operation-set bit for socket-accept operations.
public static final int OP_ACCEPT = 1 << 4;
IO多路复用器

在jdk1.4中,我们的IO多路复用器是使用select()或者poll()内核函数来实现的,只是因为他俩有局限性,所以后面才引入了epoll实现,那我们先来了解下select和poll,一口一口的吃。

IO多路复用:一个进程监视着多个文件描述符fd,一旦fd读或写就绪了,就会通知程序执行相应的读写操作。select、poll、epoll都是同步IO。

select与poll

可以通过man select命令来查看select在linux中的定义,下面我把一部分(全文太长)摘出来了:

int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);

DESCRIPTION
       select()  and  pselect() allow a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become "ready" for some class of I/O operation (e.g., input possible).  A file descriptor is
       considered ready if it is possible to perform a corresponding I/O operation (e.g., read(2) without blocking, or a sufficiently small write(2)).
       select() can monitor only file descriptors numbers that are less than FD_SETSIZE; poll(2) does not have this limitation.  See BUGS.
       The operation of select() and pselect() is identical, other than these three differences:
       (i)    select() uses a timeout that is a struct timeval (with seconds and microseconds), while pselect() uses a struct timespec (with seconds and nanoseconds).
       (ii)   select() may update the timeout argument to indicate how much time was left.  pselect() does not change this argument.
       (iii)  select() has no sigmask argument, and behaves as pselect() called with NULL sigmask.
       Three independent sets of file descriptors are watched.  The file descriptors listed in readfds will be watched to see if characters become available for reading (more precisely, to see if a read will not block;  in  particular, a file descriptor is also ready on end-of-file).  The file descriptors in writefds will be watched to see if space is available for write (though a large write may still block).  The file descriptors in exceptfds will be watched for exceptional conditions.  (For examples of some exceptional conditions, see the discussion of POLLPRI in poll(2).)
       On exit, each of the file descriptor sets is modified in place to indicate which file descriptors actually changed status.  (Thus, if using select() within a loop, the sets must be reinitialized before each call.)
       Each of the three file descriptor sets may be specified as NULL if no file descriptors are to be watched for the corresponding class of events.

一堆英文,有没有脑袋嗡嗡的,翻译下大概就是下面的意思:

int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);

来看看select函数的相关参数,上面的英文描述就是讲述参数的:

  • nfds:可以看到上面参数中有3个set,这个参数的值应该设置为3个set中最大文件描述符fd的编号+1,检查set中的fd时,直到这个为上限。
  • readfds: 可读的文件描述符fd集合
  • writefds:可写的fd集合
  • exceptfds:异常的fd集合
  • timeout:超时等待时间

局限一:select能够打开的文件描述符是有限的,最大是32个整数的大小,而这个整数的占位大小和操作系统位数有关,比如32位的操作系统最大可以监听32*32=1024个fd,定义在了 F D _ S E T S I Z E \color{#FF0000}FD\_SETSIZE FD_SETSIZE里。
select最大文件数
局限二:select需要遍历set里的所有fd,但是不是每个fd都是需要去遍历的,为什么这么说呢?打个比方,在我们的网络编程里,是通过socket进行通信的,socket在linux下也是一个个fd,在我们的socket传输数据的时候,我们才需要去读取数据,也就是说这些socket才是select所需要遍历的,其他空闲的socket其实是不需要的,那么这样就会造成浪费,性能也会下降。

linux大哥就在想,这群用户太难伺候了,得想办法升级下,捏啊捏的就捏出了个poll。

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

那与select之间做了什么改动呢?

首先存储fd的数据结构变了:set 变为 链表
基于此最大的优化就是监听的fd没有限制了,但是就fd越多的话,性能就会越差,这点也是一样的。
局限二一样还是存在的,并没有解决。

linux大哥知道已经忽悠不下去了,经过一番战斗,终于在内核2.6中推出了epoll

epoll

epoll将IO多路复用的功能拆分成3个函数,分别是

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);

这样拆分不就更加灵活了嘛。
这几个函数是有对应到上面的示例代码的,下方给大佬们过下源码就清楚了。

  • 先来看看 e p o l l _ c r e a t e ( i n t   s i z e ) \color{#FF0000}epoll\_create(int\ size) epoll_create(int size)方法:

调用该方法时创建一个epoll对象,需要一个size参数来指定初始监听fd数,这个跟select、poll的含义是不一样的,类似于初始化map实例的大小一样,并不是说最大值。从2.6.8开始,其实是可以省略size参数,但一定要大于0,见下图描述。
epoll描述
执行完epoll_create方法后,会在/proc/进程id/fd目录下创建对应的fd值,所以使用完了epoll,一定要关闭它,不然会被耗尽资源。
fd值

  • 然后是 e p o l l _ c t l ( i n t   e p f d , i n t   o p , i n t   f d , s t r u c t   e p o l l _ e v e n t   ∗ e v e n t ) \color{#FF0000}epoll\_ctl(int\ epfd, int\ op, int\ fd, struct\ epoll\_event\ *event) epoll_ctl(int epfd,int op,int fd,struct epoll_event event)方法:
    • int epfd:指的是执行epoll_create方法后返回的fd值,可以理解为epoll对象
    • int op:操作类型,是针对fd参数的,需要对fd执行什么样的操作
    • int fd:是指被监听的fd对象,按上面的代码来说可以理解为socketChannel
    • epoll_event *event:指的是事件,表示要监听fd的什么样的事件

op的类型如下:
op类型
EPOLL_CTL_ADD表示注册fd到epfd中,EPOLL_CTL_MOD表示修改epfd中的fd,EPOLL_CTL_DEL表示删除epfd中的fd。

event的类型很多,主要说两个:EPOLLIN(读事件)、EPOLLOUT(写事件)
event类型
由此可知,create_ctl()方法是将fd与事件绑定到epoll上,比如将socket的read事件绑定到epoll上。

  • 最后是 i n t   e p o l l _ w a i t ( i n t   e p f d , s t r u c t   e p o l l _ e v e n t   ∗ e v e n t s , i n t   m a x e v e n t s , i n t   t i m e o u t ) \color{#FF0000}int\ epoll\_wait(int\ epfd, struct\ epoll\_event\ *events, int\ maxevents, int\ timeout) int epoll_wait(int epfd,struct epoll_event events,int maxevents,int timeout)方法
    • int epfd:指的是执行epoll_create方法后返回的fd值,可以理解为epoll对象
    • epoll_event ∗events:表示事件的集合
    • int maxevents:表示最大的事件值,最大的返回值,也可以理解为events的大小
    • int timeout:表示等待时间

该函数是等待epfd上注册的事件的到来,如果没有事件到来就会阻塞住。

epoll也优化了select函数的局限性,不再遍历所有事件,而是遍历有效的事件,怎么控制有效呢?上面也有提到过,是通过内核的物理中断来控制的,当有数据接收到时才会触发中断,然后把事件放到集合里,空闲的socket就不会触发中断,尽管该socket是连接状态。

还有一点就是:select和poll是需要内核将数据拷贝到用户空间的,这是有IO消耗的,而epoll不一样,epoll是让内核和用户空间共享一块内存的,就避免了拷贝的动作。

linux大佬实现了epoll,原理我们是有所了解了,那linux大佬们是如何实现的呢?
在此之前,我们先探究下上面示例代码的源码。

NIO底层源码解读

NIOServerSocket类的代码中,关于epoll的代码其实就几行而已

// 使用Selector,创建epoll
Selector selector = Selector.open();
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
// 等待客户端的事件发生,有才往下走
selector.select();
  1. 先点进去看下 S e l e c t o r . o p e n ( ) \color{#FF0000}Selector.open() Selector.open()方法
    open
    这里有两个方法,先来看下provider()方法
    provider
    跟踪下DefaultSelectorProvider.create()方法
    create
    最后返回个window系统下的SelectorProvider实现对象。

很明显,我们看不了linux下的代码,那么我们就得去下载openjdk的源码:
jdk8版本的:github/openjdk/jdk8
jdk最新版本的:github/openjdk/jdk
我下载了jdk8版本的,因为我看过jdk版本的代码,但是因为本地安装的jdk8,所以在找implRegister方法的时候找不到,踩坑了。

找下linux实现的,可以看到不再是WindowsSelectorProvider类,而是EPollSelectorProvider类
epollS
再来看看方法openSelector(),还是定位到window系统的实现,我们再来找找linux实现的
winOpenSelector

linux实现的类是EPollSelectorImpl
impl
直接返回new EPollSelectorImpl对象,来看看EPollSelectorImpl初始化都干了啥
epollImpl
可以看到EPollSelectorImpl构造器主要就2行代码:
实例化new EPollArrayWrapper()对象并调用initInterrupt()方法。

EPollArrayWrapper构造器代码:
arrayWrapper
主要是创建epoll对象,MAX_UPDATE_ARRAY_SIZE大小是64k

epollCreate()调用的是本地方法,epollCtl()和epollWait()都一样,如下:
epoll_func
JNI写的代码有些规律,都是用下划线_ 连接,可以搜一下EPoll+epollCreate,用_下划线连接,如下:
可以看到是JNI调用的
jni_create
jni_ctl
jni_wait
直接调用内核的epoll_create(),epoll_ctl(),epoll_wait()方法

梳理下调用流程:
open_flow

  1. 然后是 s e r v e r S o c k e t . r e g i s t e r ( s e l e c t o r , S e l e c t i o n K e y . O P _ A C C E P T ) \color{#FF0000}serverSocket.register(selector, SelectionKey.OP\_ACCEPT) serverSocket.register(selector,SelectionKey.OP_ACCEPT)方法

register方法返回的是SelectionKey对象
register
直接跟踪下去
sel.register
继续跟踪register方法,看到有个implRegister方法
implRegister
跟踪到EPollSelectorImpl#implRegister方法
epoll.implRegister
由此可见:这一行代码主要是将socket放到时间数组pollWrapper里。
梳理下流程图:
register_flow

  1. s e l e c t o r . s e l e c t ( ) \color{#FF0000}selector.select() selector.select()方法解析
    跟踪下SelectorImpl#select()
    select
    跟踪下SelectorImpl#lockAndDoSelect()
    lockAndDoSelect
    this.doSelect(var1)方法是个抽象方法,需要实现,linux系统下的实例方法是EPollSelectorImpl#doSelect(long timeout)
    doselect
    来看看pollWrapper.poll(timeout)方法都干了啥
    poll_func
    看到了epollWait()方法了,到这行代码就是开始等待客户端的事件过来了,前面还有个updateRegistrations()方法,跟踪下,看看是什么牛鬼蛇神
    updateRegistrations
    又看到一个熟悉的函数epollCtl(),先判断下操作类型,然后将事件注册到epfd中,最后就阻塞着等待事件的触发。
    所以调用内核的epoll_ctl和epoll_wait方法都是在selector.select()这行代码里执行的。
    但是在最新版本的代码里epoll_ctl并不是在这执行的,而是在第一行代码open()里执行的。

现在再回头看下NIOServerSocket类的代码就会清晰很多了。

这样我们整个流程图就出来了:
all_flow
了解了底层源码实现,那么epoll到底是为何能够如此高效呢?其实前面也提过,函数拆分,rdllist存储ready状态的socket等,现在来详细的了解下

epoll高效原理及底层机制

为了很好的去理解epoll的工作本质,我们从网络传输数据来入手讲述。

要知道两端要进行数据传输或者接收,是需要网卡的存在的,没有网卡那么啥也干不了,自己玩自己呗。

那么操作系统怎么知道自己接收了数据呢?

其实是通过中断机制,从物理层来说就是给予cpu不同的电压变化,当有中断请求时,这是有着很高权限的,cpu必须做出响应,比如键盘按键,鼠标点击啊,cpu都是要响应的。当然啦,如果某请求要处理很久的话,cpu会把该请求分为2部分,先处理响应,然后去处理其他的中断请求,剩下的部分交给内核线程来处理。
数据传输过程
当没数据时,我们的用户程序是会被阻塞的,阻塞时,cpu就会把去处理其他的进程,进程也会与socket建立些关系。
这里要了解下:在linux系统下所有设备文件都是fd,当我们创建一个socket时,操作系统会为该socket对象创建一个fd对象,
socket有几个重要的区域: 发 送 缓 存 区 , 接 收 缓 存 区 , 等 待 队 列 \color{#FF0000}发送缓存区,接收缓存区,等待队列
发送缓存区:当本进程要发送数据时,会先把数据发送到该socket缓存区中。
接收缓存区:当其他进程发送数据过来时,其他进程会先把数据发送到接收缓存区中。
等待队列:该队列保存了所有等待socket事件的进程引用。

基于此,再把图精细些:
socket-wait-queue

在上图中,进程2也有创建socket,阻塞操作时,放到的应该是不一样的socket的等待队列,局限于空间,我就没再画另一个socket,在这里做一下说明。

当我们的用户进程有多个socket的时候,就会把用户进程放到每个socket的等待队列里,这就是select函数的思想。
select会先准备一个数组保存所有需要监视的socket(用户进程内自己的socket),当有数据过来时,cpu会修改对应socket的FD_ISSET标识,但是select并不知道哪个socket接收了数据,所以会去遍历socket数组,判断FD_ISSET标识。

每次将用户进程添加进socket等待队列或者移除,都是需要遍历的,所以就规定了socket数组的大小为1024。
在将用户进程从等待队列中放到工作队列中时,开始工作,这也要遍历socket数组才能知道哪个socket接收到了数据。

基于此,select的改进版epoll就出来了。
函数拆分:epoll_create(), epoll_ctl(), epoll_wait() 这个相信大家都比我清楚了。
就绪列表rdllist:存储了接收到数据的socket,那具体是个什么光景呢?

在我们调用epoll_create函数时,会创建一个数据结构为eventpoll的对象,即epoll对象,该对象有个rdllist列表属性。
当有socket接收了数据,不同于select,select是操作进程,将进程放到工作队列,而epoll则是操作系统会操作epoll对象,将sokcet对象引用到rdllist中,当然,eventpoll对象也会放到各个socket的等待队列中,此处是eventpoll对象而不是进程引用了,而进程则放到了eventpoll对象的等待队列(eventpoll对象也有等待队列)。
eventpoll对象
epoll_rdllist
为了能够保证rdllist能够实现快速插入删除操作,采用了双向链表结构。
而对于socket数组,为了方便检索,则是使用的红黑树的数据结构。

细心的大哥可能会看到第二步有个DMA,那么这个DMA又是个什么鬼呢?下面拓展下 零 拷 贝 技 术 \color{#FF0000}零拷贝技术

零拷贝技术

零拷贝技术并不是说一次拷贝操作都没有,没有这样的技术,零拷贝是指减少数据拷贝,避免传输数据过程中不必要的中间拷贝次数,从而提高数据传输效率,还有减少用户空间和内核空间之间切换上下文带来的消耗。

我们先来了解些细节操作,以便了解零拷贝技术是如何减少这些冗余操作的。
先来看看一张图:这是个两个应用程序之间的一个通讯简图:
write发送数据

进程A给进程B发送数据时,得先调用write函数把应用进程缓存区里的数据拷贝到套接字(socket)的发送缓存区(SO_SNDBUF),然后将发送缓存区里的数据发送到应用进程B系统的socket接收缓存区区(SO_RECVBUF)里,最后再复制到进程B的应用缓存区里。

这是正常的流程走向,但是当我们调用write()方法时,应用进程缓存区的没法完全写到发送缓存区时,这时候就可能会造成阻塞,只有数据完全的写入到了socket的发送缓存区时,write()方法才会返回正常,表示write()方法执行成功,但是这并不代表数据已经发送给对端了,有可能是在己方的socket发送缓存区中。

java也要遵从该机制,但是该数据就不应该放在堆内存中,因为堆内存会有垃圾回收机制,到时候数据的内存地址就会有所变动(这个是不行的),所以放置在直接内存比较好,虽然有一定的概率会被垃圾回收,但是比较小,而且放置堆内存也要搬运到直接内存才发送出去,所以也避免了一次搬运操作。

在我们的数据从磁盘上搬运到系统内核缓存区时,是需要cpu和中断的参与的,为了避免这些小事占用cpu,所以就引入了DMA技术。
D M A \color{#FF0000}DMA DMA:direct memory access,直接内存存取,DMA类似于一个小型的cpu,能够处理数据的读写请求。
传统方式
只是引入DMA技术时,还是避免不了拷贝的动作,只是减少了CPU的工作,这种传统方式的数据传输是需要4次拷贝,4次的上下文切换的,看左边的数据搬运情况,右边的没画完善,可以忽略。

这样就需要去完善这种情况,于是引进了 m m a p ( 内 存 映 射 ) \color{#FF0000}mmap(内存映射) mmap()技术,将磁盘地址和用户应用缓存区地址进行一一映射,这样就可以直接将数据从磁盘中拷贝到用户空间。
mmap内存映射
这样就避免了一次cpu的拷贝,即需要3次拷贝,4次的上下文切换。

这时候就该 s e n d f i l e \color{#FF0000}sendfile sendfile大哥出场了,这是个系统调用函数,它可以将系统缓存中的数据拷贝到socket buffer中(其实是把数据位置和长度发给socket buffer),如下图:
sendfile方式
这样就只需要3次拷贝,2次上下文切换了。

还有个重量级大佬要登场: s p l i c e \color{#FF0000}splice splice函数调用,是可以将系统缓存中的数据搬运到任何一个内核内存空间中,包括我们的socket buffer,这样就能避免了我们CPU的参与,它是通过建立pipeline来支持该操作的。如下图:
splice
这样就是只需要2次拷贝,2次上下文切换。

最早期的零拷贝技术是从sendfile开始定义的,如果DMA设备支持,它也是可以省略从系统缓存到socket buffer的cpu拷贝的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值