从系统调用分析IO模型演变,一篇讲懂BIO、NIO、多路复用(selector,poll,epoll)原理


前言

本篇文章通过java代码IO模型实现配合内核调用日志来分析linux的IO模型由阻塞IO——>非阻塞IO——>IO多路复用 的演变过程,以及演变原因,由于信号驱动 IO模型与TCP协议不适配,异步IO在linux中不成熟故不进行讨论。


1. BIO(blocking IO)阻塞IO

BIO为阻塞型IO模型,在接收客户端连接(accept)和读取客户端发送数据(recv)时会发生阻塞。
解释一下这里的阻塞:

  • 白话解释:服务端执行了accept函数调用后,当前线程就会进入“等待”状态释放cpu的占用,直到有客户端连接服务器为止;服务端调用recv读取客户端发来的数据包时,当前线程就会进入“等待”状态释放cpu的占用,直到有客户端发来消息为止;
  • 原理分析:socket主要有三部分组成发送缓冲区+接收缓冲区+等待列表,当服务器socket调用accept函数,就回将当前线程的引用挂在等待列表中,如果socket接收到了客户端连接则唤醒等待列表中的线程。
    图来自知乎作者-罗培羽

1.1单线程下的BIO

首先我们看下单线程下的BIO服务器实现:

  • 弊端:当存在clientA连接服务器后但是不发消息,当前服务器将阻塞在等待读取clientA发送消息的read方法,此时无法再次执行accept()方法,无法再与其他客户端建立连接
  • 解决方案:当服务器每接收到一个连接就创建一个新的线程,在线程内等待读取客户端发送消息的阻塞,这样就不影响服务器接收下一个连接了====》多线程BIO
package com.lago;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;

public class OneThreadBIOServer {
    
    
    public static void main(String[] args)throws Exception {
    
    
        //创建单线程阻塞型Socket服务器,绑定监听端口
        ServerSocket serverSocket = new ServerSocket();
        InetSocketAddress inetSocketAddress = new InetSocketAddress("192.168.2.170", 8080);
        serverSocket.bind(inetSocketAddress);

        while (true){
    
    
            // 等待客户端连接
            Socket client = serverSocket.accept();

            System.out.println("已经有客户端"+client.getInetAddress().getHostAddress()+":"+client.getPort());

            byte[] buffer=new byte[200];

            int read = client.getInputStream().read(buffer);
            // 通过连接通道读取客户端发送的消息调用系统函数rect();
            String body = new String(buffer, 0, read, "UTF-8");

            System.out.println("接到客户端"+client.getInetAddress().getHostAddress()+":"+client.getPort()+"消息:"+body);
        }
    }
}

1.2多线程下的BIO

结合1.1中的弊端和解决思路看下多线程下的BIO服务端实现,此时服务器支持同时处理多个连接但是也存在显而易见的问题

  • 弊端:没建立一个连接就需要创建一个线程,随着连接数量增大,线程创建越来越多会消耗大量内存资源以及线程上下文切换浪费时间
  • 解决方案:尝试单线程完成socket服务器编写,这个前提下是操作系统能提供一个不阻塞的socket()函数来解决socket服务器accept等待客户端连接阻塞和recv读取消息阻塞问题,否则单线程无法满足接收多个连接的需求。
package com.lago;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;

public class ManyThreadBIOServer {
    
    
    public static void main(String[] args) throws Exception{
    
    
        // 创建阻塞型消息服务器,并且绑定监听端口8080
        ServerSocket serverSocket = new ServerSocket();
        InetSocketAddress inetSocketAddress = new InetSocketAddress("192.168.2.170", 8080);
        serverSocket.bind(inetSocketAddress);

        while (true){
    
    
            // 等待客户端连接
            Socket client = serverSocket.accept();
            System.out.println("已经有客户端"+client.getInetAddress().getHostAddress()+":"+client.getPort());

            // 创建新的线程,在线程里进行客户端的消息读取,避免客户端只连接不发消息时服务器无法接受其他客户端连接
            Thread thread = new Thread(new ClientThread(client));
            thread.start();
        }

    }

    public static class ClientThread implements Runnable{
    
    
      private Socket client;

      public ClientThread(Socket client){
    
    
          this.client=client;
      }


        @Override
        public void run(){
    
    
          byte[] buffer=new byte[200];
            while (true){
    
    
                // 通过连接建立的通道读取客户端发送的消息
                String content = null;
                try {
    
    
                    int read = client.getInputStream().read(buffer);

                    content = new String(buffer, 0, read, "UTF-8");
                } catch (IOException e) {
    
    
                    e.printStackTrace();
                }
                System.out.println("接到客户端"+client.getInetAddress().getHostAddress()+":"+client.getPort()+"消息:"+content);

            }
        }
    }
}

2.NIO(non-blocking)非阻塞IO

应用户需求,linux内核进行了升级推出来新的函数fcntl可以标记socket为非阻塞,标记了非阻塞后的socket在调用acept和recv函数时无论有无连接或数据都会返回不会阻塞。看下NIO模型单线程下支持并发的服务器的代码,在某些情境下还是存在弊端:

  • 弊端:虽然可以单线程完成支持多连接的socket服务端,但是如果有1万个连接但是只有一个发送消息,还是会调用函数recv读取一万遍,其中有9999遍是无效调用,要知道应用程序是不能直接调用内核函数的,应用程序调用内核函数时会触发软件中断,效果类似线程上下文切换,会暂停当前应用程序让出CPU切换至内核函数执行,执行完后再切换回应用程序,这是会有进程的现场保护和现场恢复过程会占用CUP的时间和资源。
  • 解决方案:如果可以吧socket集合交给内核去管理,让内核帮我们去遍历socket集合,返回给我们有数据可读的客户端,然后我们只进行有效读取。
package com.lago;

import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.LinkedList;
import java.util.List;

public class NIOServer {
    
    
    public static void main(String[] args)throws Exception {
    
    
        // 以连接服务器的客户端集合
        List<SocketChannel> sockets=new LinkedList<>();

        // 创建socket服务器,设置为非阻塞类型,绑定并监听端口
        ServerSocketChannel serverSocketChannel=ServerSocketChannel.open();

        serverSocketChannel.configureBlocking(false);

        InetSocketAddress inetSocketAddress = new InetSocketAddress("192.168.2.170", 8080);

        serverSocketChannel.bind(inetSocketAddress);


        while (true){
    
    
            Thread.sleep(1000);

            // 获取连接服务器的客户端
            SocketChannel client = serverSocketChannel.accept();

            if (client!=null){
    
    
                // 设置客户端为非阻塞类型,保证rect()时,无消息也不阻塞。
                client.configureBlocking(false);

                System.out.println("已经有客户端"+client.socket().getInetAddress().getHostAddress()+":"+client.socket().getPort());
                // 将新连接的客户端添加到以连接客户端集合
                sockets.add(client);
            }else {
    
    
                System.out.println("无客户端连接...");
            }

            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(200);

            for (SocketChannel socketChannel : sockets) {
    
    
                int read = socketChannel.read(byteBuffer);

                if(read>0){
    
    
                    byteBuffer.flip();
                    byte[] buffer=new byte[byteBuffer.limit()];
                    byteBuffer.get(buffer);
                    String content = new String(buffer, 0, read, "UTF-8");
                    System.out.println("接到客户端"+socketChannel.socket().getInetAddress().getHostAddress()+":"+socketChannel.socket().getPort()+"消息:"+content);
                    byteBuffer.clear();
                }
            }
        }
    }
}

3.IO multiplexing 多路复用IO

应用户需求,内核升级提供了三个多路复用器函数依次是selector、poll、epoll,目前在并发量很大的情景下用的最多的是epoll,而且优势明显。多路复用即让一个进程去监听多个socket。

依次看下这三个多路复用器的实现原理:

3.1 selector

要想真真正正的理解selector就必须要理解fd_set这种数据结构(selector之fd_set),是一种long类型的数组,每一个元素都能与一个一打开的文件句柄(文件描述符)建立联系,建立关心的过程由程序员完成,当调用select()时由内核根据文件的IO状态来修改fd_set中对应位置的元素值,由此来通知执行了select()的进程那个socket或文件是可读的。

了解的selector的fd_set后下面分别从代码实现(应用程序角度)和实现原理(内核角度)两方面来剖析selector,并分析其被取代的原因。

3.1.1 selector代码实现

由于java中IOAPI中的Selector.open();在最新的linux中显式调用时底层都是采用的epoll并未采用selector,所以selector的代码实现我们采用c++来显式调用,并且用c++实现更能体现fd_set的妙用和重要性,方便理解。对c++不熟悉的同学,可以只关注我备注的代码行。

// 创建服务器socket,sockfd为socket文件的文件描述符。
sockfd =socket(AF_INET,SOCK_STREAM,0);

memset(&addr,0,sizeof(addr));

addr.sin_family=AF_INET;

addr.sin_port=htons(2000);

addr.sin_addr.s_addr=INADDR_ANY;
// 为服务器socket绑定2000端口
bind(sockfd,(struct sockaddr*)&addr,sizeof(addr));
// 监听2000端口,并设置客户端最大等待数量为5
listen(sockfd,5);


//(~ ̄▽ ̄)~ 此时我们启动了5个客户端连接当前服务端(客户端代码就不赘述了)

for(i=0;i<5;i++){
    
    
   memset(&client,0,sizeof(client));
   addrlen=sizeof(client);
   // 由于此时已有五个客户端连接了服务器,此处for循环调用accept函数(看不懂参数无需关心,只要知道accpet函数作用即可)获取5个客户端的文件描述符,组成文件描述符数组fds[],假设数组值为[3,5,6,7]
   fds[i]=accept(sockfd,(struct sockaddr*)&client,&addrlen);
  
   if(fds[i]>max){
    
    
   // 记录这五个client的文件描述符最大值,max=7
   		max=fds[i];
   }
}

//(~ ̄▽ ̄)~ 别眨眼,主角登场

while(1){
    
    
// rset即fd_set类型的数据,FD_ZERO的作用是将rset中的值全部置零[0,0,0,0,0,0,0,0,....]
  FD_ZERO(&rset)
  for(i=1;i<5;i++){
    
    
  	// 将rset中下标等于fds[i]的元素置1,循环完毕后rset=[0,0,0,1,0,1,1,1,0,....]
  	FD_SET(fds[i],&rset);
  }
// 调用select函数监听五个客户端文件是否有消息可读
// 第一个参数max+1代表rset的最大有效长度,rset默认长度为1024,但是目前最大有效长度为8,第8位以后都是0,内核遍历到8即可,可以提高性能。
// 第二个参数要求传入监听文件有可惜可读事件,第三个监听可写事件...,我们这里只关心客户端是否发送消息过来,故只想参数2中传入rset
// select会做什么事情呢? select函数会在内核中遍历rset检测对应的文件句柄是否有消息可读,如果有消息可读则对应值不变,如果无消息可读则值置0,加入只要客户端3,6发了消息,那执行完select之后rset值为[0,0,0,1,0,0,1,0,0,....]
select(max+1,&rset,null,null,null);

for(i=0;i<5;i++){
    
    
	// 判断rset中下标为fds[i]的值是否为1
 	if(FD_INSET(fds[i],&rset)){
    
    
 	memset(buffer,0,MAXBUF);
 	// 读取客户端发来的消息
 	read(fds[i],buffer,MAXBUF);
 	}
}

}

在代码实现层面(应用程序角度)分析selector的弊端:

  1. 在执行select时每次都要将相同的rset赋值到内核
  2. 要想获取真正有数据的socket文件描述符引用,每次都要讲全部链接遍历一遍判断是否在执行完select的rset里,如果链接过多性能则下降

3.1.2 selector原理分析

selector原理分析的原理剖析其实就是剖析select()函数到底干了什么事情,在应用程序执行select函数时会有两种情景1.select要监听的socket集合已接收到客户端消息,读缓存区中已有可读取数据;2.select要监听的socket集合未接收到客户端消息,读缓存区中没有可读取数据。接下来分别进行分析。

3.1.2.1 已接收到数据
  1. 当程序执行select程序时会触发软件终端,从用户态切换值内核态并将rset等参数向内核进行copy
  2. 在内核中 遍历 rset,通过调用recv进行判断,如果下标对应的socket有可读取数据,当前元素不变,如果未有可读数据则当前下标对应的元素值置0
  3. 如果遍历完rset里还有1,即说明socket集合里存在可读socket,直接返回,切换成用户态,并且将rset复制回来。
3.1.2.2 未接收到数据
  1. 当前情景下遍历完rset会发现rset所有元素都是0,即说明socket集合中无接收到消息的socket,当前线程需要进入阻塞状态,从运行队列中取出来,遍历 socket集合为全部socket的等待队列添加当前进程的引用。
    在这里插入图片描述

  2. 当socket1对应客户端程序发来消息时,在消息到达服务器网卡时会触发网卡中断执行中断程序,首先将消息内容copy到socket1的读缓存区中,然后遍历全部socket解除进程中在缓存区中的引用(必须解除全部缓存区引用进程A才能解除阻塞,进入运行队列,分取cpu资源)。
    在这里插入图片描述

  3. 然后切换成用户态,并且将rset复制回来,由应用程序进行消息读取

3.1.3 selector弊端总结+解决设想

selector弊端总结:

  1. 首先在应用程序层面在select函数返回后,并不清楚那个socket可以读取,需要遍历socket的fds[ ]与rset进行比对,对无用连接进行遍历。
  2. 每次只需select函数都会将rset向内核进行copy
  3. 如果全部socket都没有可读数据,需要遍历socket向等待队列中绑定进程引用;当有1个socket收到消息后,要遍历全部socket解除等待队列中进程的引用
  4. 在selector中rset的最大值为1024,限制了客户端的链接数量(补充弊端)

解决设想:

  1. 在未出现新socket的情况下不需要重复向内核中copy 已添加的socket信息
  2. 当有1个socket收到消息时不要遍历全部socket就能释放应用程序
  3. 多路复用器能保存并且返回收到消息的socket文件描述符引用,这样应用程序执行完select函数后就可以,只遍历有效socket,直接进行读取数据了。
  4. 多路复用器能打破1024的限制。

3.2 poll

poll的代码实现和实现原理与selector一样,只是rset不在限制数量,打破了1024的限制,可以监听更多socket,但是并没有解决selector的弊端,在大量并发的情景下,由于应用程序和内核都需要进行多次socket的遍历以及越来越大rset copy,随着并发量的增加性能会越来越低。

3.3 epoll

epoll的出现彻底打破了selector的弊端,下面还是在代码实现(用应程序角度)和实现原理(内核角度)来分析epoll是如何打破selector的弊端完成我们的解决设想

3.3.1 epoll代码实现

此处采用了java代码进行实现,但是要知道java之所以跨平台是因为jdk已经在底层进行了封装为我们屏蔽了操作系统的差异性,epoll在linux中一共提供了三个核心函数epoll_create/epoll_ctl/epoll_wait,在下面的代码备注中有注明。

package com.lago;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.Charset;
import java.util.Iterator;
import java.util.Set;

public class SelectorServer {
    
    
    public static void main(String[] args) throws Exception {
    
    
        // 创建Socket服务器,绑定端口8080
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(8080));
        serverSocketChannel.configureBlocking(false);
        // 创建多路复用器selector :调用epoll_create函数创建eventpoll
        Selector selector = Selector.open();
        // 为socket服务器添加selector的引用:调用epoll_ctl将eventpoll添加至socket的等待队列中,并制定感兴趣的事件为有客户端连接时。
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);


        while (true){
    
    
            // 调用epoll_wait,如果eventpoll中的rdlist(readList)不为空不阻塞,如果为空阻塞,并且将main函数java进程
            // 添加至eventpoll的等待队列(进程挂起,释放cpu)
            int select = selector.select();

            // 如果有客户端连接服务器、或者收到客户端发来的消息,网卡会向cpu发送中断信号,触发中断程序唤醒java进程并且将有状态改变socket
            // 对象添加至rdlist。所以能走到这一步说明rdlist里必然有socket,这里获取eventpoll中rdlist里的socket集合。

            // rdlist中的socket处理完后不会清空,再有新的socket收到消息时会继续追加到rdlist中,为了防止socketA使用后
            // 还始终在rdlist中,rdlist在遍历处理完后程序显示清空一下。
            Set<SelectionKey> selectionKeys = selector.selectedKeys();

            for (SelectionKey selectionKey : selectionKeys) {
    
    
                if(selectionKey.isAcceptable()){
    
    
                    // 触发事件为有客户端连接,说明这个socket为服务端ServerSocketChannel
                    // 如果为服务端接收到新的连接,则获取新接入的客户端并且将客户端也添加selector的引用
                    ServerSocketChannel serverSocket = (ServerSocketChannel) selectionKey.channel();
                    // 获取新接入的客户端
                    SocketChannel client = serverSocket.accept();
                    // 为客户端也添加selector的引用:调用epoll_ctl将eventpoll添加至新接入客户端的等待队列中,并制定感兴趣的事件为接收到数据时。
                    client.configureBlocking(false);
                    client.register(selector,SelectionKey.OP_READ);

                    System.out.println("已经有客户端"+client.socket().getInetAddress().getHostAddress()+":"+client.socket().getPort());

                }else if(selectionKey.isReadable()){
    
    
                    // 触发事件为接收到数据,说明这个socket为客户端SocketChannel
                    SocketChannel socketClient= (SocketChannel) selectionKey.channel();
                    ByteBuffer byteBuffers=ByteBuffer.allocateDirect(200);
                    // 获取客户端传入的消息
                    long read = socketClient.read(byteBuffers);

                    byteBuffers.flip();

                    String receiveData= Charset.forName("UTF-8").decode(byteBuffers).toString();

                    byteBuffers.clear();

                    System.out.println("接收到客户端"+socketClient.socket().getInetAddress().getHostAddress()+":"+socketClient.socket().getPort()+":"+receiveData);

                }
            }
            // 清空已经rdlist里已经经过处理的socket引用
            selectionKeys.clear();
        }
    }
}

在代码层面分析比较epoll的优势:

  1. selector 在执行select时完成两部分工作维护等待队列+进程阻塞,致使每次都要copy rset到内核;epoll将这两部分工作拆分成了两个函数epoll_ctl(添加新的socket引用,维护等待队列)和epoll_wait(进程阻塞,等待结果),因此epoll在不出现新连接的情况下不需要向内核copy socket的文件引用句柄,只会单纯调用epoll_wait。
  2. epoll执行完epoll_wait后可以直接获取到准备就绪的fd集合引用直接进行遍历读取,无需遍历全部fds集合,相比selector在高并发情况下效果显著

3.3.2 epoll 原理分析

epoll原理其实就是分析epoll_create/epoll_ctl/epoll_wait三个核心函数究竟干了什么,下面逐个分析

3.3.1.1 epoll_create

epoll_create会创建一个eventpoll对象包括:

  1. 就绪列表:用于存放准备就绪的socket引用句柄
  2. 监听事件列表:用于存放epoll需要监听的socket引用句柄
  3. 等待队列:用于存放调用了epoll_wait的进程
    在这里插入图片描述
3.3.1.2 epoll_ctl

将需要监听的socket添加到epoll_event的监听事件列表中

3.3.1.3 epoll_wait
  1. 当进程A调用epoll_wait时会首先检测eventpoll的就绪列表中有误数据,如果有数据之间返回;如果没有数据则将进程中变为阻塞状态,从运行队列中取出来,添加到eventpoll的等待队列中,并且将event_poll添加到监听事件列表中所有socket的等待队列中(此处注意两点1.相对于selector的遍历rset看是否有socket就绪,epoll无需遍历之间检测就绪队列是否有数据即可,在高并发时可以提升性能;2.相对于selector进程添加到各个socket的等待队列,epoll将进程A添加到event_poll的等待队列,方便后面的释放)
    在这里插入图片描述

  2. 当client1对应客户端发来消息时,消息到达了网卡时触发网卡硬件中断,中断程序首先将消息copy到client1对应socket的读缓存区里,然后通过等待队列中的event_poll引用找到event_poll,为event_poll的就绪队列里添加自己的引用,并且移除等待队列中的event_poll同时移除event_poll等待队列中的进程A

  3. 进程A回到运行队列分配到cup,获取到就行列表中的socket引用,遍历进行消息读取(遍历的全都是活跃连接,都是有效遍历)

3.3.3 epoll 相对于selector的优势总结

  1. 不用每次查询(select/epoll_wait)时向内核copy全部socket引用句柄(rset)【少copy数据】
  2. 在内核中不用循环rset判断是否有socket就绪,只要判断就绪列表是否为空即可【减少遍历】
  3. 进程阻塞时添加到eventpoll的等待队列中而不是全部socket,网卡中断触发后不用遍历socket是否进程,只要eventpoll释放进程即可【减少遍历】
  4. 进程获取查询(select/epoll_wait)结果后selector还需要遍历全部socket进行数据读取,epoll只需遍历就绪列表中的有效连接进行数据读取【减少遍历】

注:epoll的优势就是不会随着socket的增加而性能下降。但事情无绝对,并不是全部场景都推荐使用epoll。在并发量小,并且都是活跃连接的情况下selector反而更合适一些;通过上面的原理分析不难看出epoll设计相对于selector的无脑遍历更复杂一些,类似于空间换时间,自然会有一些额外消耗,只有在连接高到一定数量的情况下,epoll的额外消耗才能抵消selector的遍历,在高并发下才能显现他的优势

猜你喜欢

转载自blog.csdn.net/yangxiaofei_java/article/details/114556272