欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

BIO、NIO、select、poll、epoll的个人理解

程序员文章站 2022-03-04 23:00:46
...

阅读之前建议了解一下socket()、bind()、listen()、accept()、recv()系统调用

1、BIO

1.1 Java代码示例


public class TestSocket{
    public static void main(String[] args){
        Serversocket server = new ServerSocket(8090);
        while(1){
            Socket client = server.accept();
            
            new Thread(new Runnable(){
                Socket ss;
                public Runnable setSS(Socket s){
                    ss = s;
                    return this;
                }
                public void run(){
                    try{
                        InputStream in = ss.getInputStream();
                        BufferReader reader = new BufferReader(new InputStreamReader(in));
                        while(1){
                            sout(reader.readLine());
                        }
                    }catch (IOException e){
                        e.printStackTrace();
                    }
                }
            }.setSS(client)
            ).start();
        }
    }
}

2.1 out.主线程号中的日志内容


#######  Serversocket server = new ServerSocket(8090);
socket()=3  # 假设server的文件描述符为3
bind(3, 8090)
listen(3)
accept(3,   # 该方法阻塞, 等待客户端连接   Socket client = server.accept(); 
### 当有客户端建立连接之后返回一个新的文件描述符, accept(3, ……) = 5, 假设新的客户端连接的文件描述符为5
### clone()一个线程,该线程也阻塞等待文件描述符是否有数据到达,如果有处理他的数据或者请求   recv(5,
accept(3,  # 继续等待客户端连接

2.2 out.子线程号中的日志内容(每个线程都建立一个连接)

recv(5,     # 该方法阻塞, 等待客户端的请求

BIO、NIO、select、poll、epoll的个人理解


2、NIO

2.1 Java代码示例

public class SocketNIO{
    public static void main(String[] args){
        
        LinkedList<SocketChannel> clients = new LinkedList<>();
        
        ServerSocketChannel ss = ServerSocketChannel.open();
        ss.bind(new InetSocketAddress(9090));
        ss.configureBlocking(false);  //☆☆☆☆☆☆☆
        
        
        while(1){
            SocketChannel client = ss.accept();//不会阻塞, 如果accpet()系统调用返回的文件描述符为-1,client则为null
            /*
             *	ss.configureBlocking(false);  //☆☆☆☆☆☆☆已经把ss设置为非阻塞
             *  ss.accept()的时候,调用accept系统调用,如果有请求连接则返回对应的文件描述符,
             *  如果没有连接请求,则也不阻塞,返回-1
            */
            if(client == null){
                sout("null");
            }else{
                client.configureBlocking(false);  //☆☆☆☆☆☆☆
                int port = client.socket().getPort();
                sout("client port" + port);
                clients.add(client);
            }
            
            ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
            
            for(SocketChannel c:clients){
                int num = c.read(buffer);
                if(num > 0){
                    buffer.flip();
                    byte[] aaa = new byte[buffer.limit()];
                    buffer.get(aaa);
                    String b = new String(aaa);
                    buffer.clear();
                }
            }
        }
    }
}

2.2 out.主线程号中的内容(此时一个主线程即可完成BIO中多个线程完成的任务)

#######  Serversocket server = new ServerSocket(8090);
socket()=3  # 假设server的文件描述符为3
bind(3, 8090)
listen(3)
accept(3, ……) = -1   # 该方法不再阻塞等待客户端连接    
### 当有客户端建立连接之后返回一个新的文件描述符, accept(3, ……) = 5, 假设新的客户端连接的文件描述符为5
### recv(5,
accept(3, ……) = -1  # 继续等待客户端连接

2.3 弊端

  • ​ while()循环中嵌套了一层for遍历整个client链表,查看客户端连接中是否有数据到达,每次查看是否有数据到达都要调用 recv() 系统调用,用户态到内核态的切换造成的中断保护现场是比较费时的操作,当连接数量过大的时候,效率就会异常低下
while(1){
    SocketChannel client = ss.accept();//不会阻塞, 如果accpet()系统调用返回的文件描述符为-1,client则为null

    if(client == null){
        sout("null");
    }else{
        client.configureBlocking(false);  //☆☆☆☆☆☆☆
        int port = client.socket().getPort();
        sout("client port" + port);
        clients.add(client);
    }

    ByteBuffer buffer = ByteBuffer.allocateDirect(4096);

    for(SocketChannel c:clients){
        int num = c.read(buffer);
        if(num > 0){
            buffer.flip();
            byte[] aaa = new byte[buffer.limit()];
            buffer.get(aaa);
            String b = new String(aaa);
            buffer.clear();
        }
    }
}

BIO、NIO、select、poll、epoll的个人理解


3、多路复用

3.1 select(UNIX环境高级编程版)


int FD_ISSET(int fd, fd_set *fdset);  //返回值:若fd在文件描述符集中,返回非0值;否则,返回0
void FD_CLR(int fd, fd_set *fdset);   //清除最后一位
void FD_SET(int fd, fd_set *fdset);   //开启描述符中的一位
void FD_ZERO(fd_set *fdset);          //所有描述符位置位0
#include <sys/select.h>
int select(int maxfpd1, fdset *read_fds, fdset *write_fds, fdset *exception_fds, struct timeval *restrict tvpr);
//返回值为可以操作的文件描述符的数量。

3.1.1 参数说明:

tvpr

  • tvpr == NULL, 永远阻塞

  • tvpr -> tv_sec == 0 || tvpr -> tv_usec == 0, 不阻塞,直接返回

  • tvpr -> tv_sec != 0 || tvpr -> tv_usec != 0, 阻塞指定的秒数和微妙数

read_fds, write_fds, exception_fds

BIO、NIO、select、poll、epoll的个人理解

maxfdp1

  • 最大的文件描述符编号+1,最大为1024。通过指定我们关注的最大的描述符,内核只需要在此范围内搜索打开的位。

3.1.2 图14-16的操作对应的操作

fdset readset, writeset;
FD_ZERO(&readset);
FD_ZERO(&writeset);
FD_SET(0, &readset); 
FD_SET(3, &readset); 
FD_SET(1, &writeset); 
FD_SET(2, &writeset); 
max_fd = 4;
int a = select(4, &readset, &writeset, NULL, NULL);
//系统调用完成后,返回可读可写的数量之和
if(a > 0){   //服务程序操作可以读写的文件描述符
    for(i=0; i<4; ++i){
        if(FD_ISSET(i, readset)){
            //操作
        }
        if(writeset(i, readset)){
            //操作
        }
    }
}

BIO、NIO、select、poll、epoll的个人理解

注意:先把readset和writeset的文件描述符对应的位置位1,然后交给select()系统调用去判断哪个文件描述符打开,如果某个fd不可读或者写,该位置位0,

如果可读,在数组中仍然为1.

系统调用完成后,返回可读可写的数量之和

服务程序,循环遍历所有文件描述符用 FD_ISSET() 判断是否进行读写操作

3.1.3 优势

  • 通过一次系统调用把所有的fds传递给内核,内核进行遍历,这种遍历减少了BIO的多次系统调用的开销

3.1.4 弊端:

  • 因为select直接在**&readset,&writeset**上做出修改,导致两个数组不可重用,必须每次重新赋值

  • 每次select都要重新遍历全量的fds

3.2 poll

# include <poll.h>
int poll(struct pollfd fdarry[], nfds_t nfds, int timeout);

struct pollfd{
    int fd;
    short events;
    short revents;
}

3.2.1 pollfd

​ poll不是构建一个描述符集,而是构造一个pollfd的数组,每个数组的元素制定一个描述符编号**(结构体中的fd)以及我们对该描述符感兴趣的条件(short events)**。

​ 当poll系统调用完成后,同样返回可以操作的文件描述符数量,服务程序检查pollfd中的revents字段

int a = poll(*fdarray, nfds, 0);
if(a > 0){   //服务程序操作可以读写的文件描述符
    for(i=0; i<4; ++i){
        if(pollfd->revents){  //☆☆☆☆☆☆☆
            //操作
        }
    }
}

3.2.2 优势

  • 内核操作为文件描述符创建的结构体中的revents字段,没有破坏其他结构体中其他的字段,所有不用每次重新构造类似于readset那样的bitmap
  • 没有了select最大支持1024个文件描述符的限制

3.2.3 弊端:

  • 每次poll都仍要重新遍历全量的fds

  • 服务程序也要遍历全量的fds,查看每个文件描述符的revents字段是否需要读写操作

3.3 epoll(Linux特有)

查阅《Linux高性能服务器编程》,没有提到callback函数,只有在《Netty权威指南第二版》和一些网络的博客上见到callback的字眼,所以分为两个理解版本,暂时不知道哪个对,待我查看完内核源码之后,续更博客。

《Linux高性能服务器编程》的简介中写“本书是Linux服务器编程的经典著作,由资深Linux软件开发工程师所写”,既然不是内核开发的参与人员,故只有自己看完内核源码之后才有资格说两个版本谁对谁错、还是都错。在此之前暂时先采纳这个自称“经典著作”的版本吧

该书在153页写到

epoll_wait() 如果检测到事件,就将所就绪事件从内核事件表中复制到他的第二个参数events指向的数组中。这个数组只用于epoll_wait检测到的就绪事件

所以有了第一个理解版本

第二个理解版本是因为实在是网上的博客和《Netty权威指南第二版》第7页的中间部分:

epoll是根据每个fd上面的callback函数实现的。只有“活跃”的socket才会主动调用callback函数。

但是Linux系统man出来的三个epoll函数都没有关于callback的解释,故本人更倾向于第一种理解。

更博还是等到看完源码之后。

3.3.1 理解版本一

BIO、NIO、select、poll、epoll的个人理解

参数说明

epoll是Linux特有的I/O复用函数,epoll使用一组函数来完成任务,而不是单个函数。epoll把用户关心的文件描述符放到内核的一个事件表中,所以epoll需要使用一个额外的文件描述符来表示内核中的事件表。

#include <sys/epoll.h>
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);
  • epoll_create() 返回一个文件描述符,该文件描述符“描述”的是内存中的一块内存区域,即概述中所说的内核中的事件表,size现在不起任何作用。

  • epoll_ctl()用来操作内核事件表,

    • int epfd表示epoll_create() 返回的事件表

    • int fd:新创建的socket文件描述符

    • int op

      • EPOLL_CTL_ADD: 事件表中添加一个文件描述符,内核应该关注的socket的事件在epoll_creat的内存区域,其实这是一个结构体,结构体中有红黑树的根节点和一个链表,添加到事件表中的文件描述符以红黑树的形式存在,防止重复添加
      • EPOLL_CTL_MOD:修改fd上注册的事件
      • EPOLL_CTL_DEL:删除fd上注册的事件
    • struct epoll_event *event

      • struct epoll_event{
            _uint32_t events; //epoll事件,读、写、异常三种
            epoll_data_t data; //用户数据
        }
        
        struct epoll_data{
            void* prt;
            int fd;
            _uint32_t u32;
            _uint64_t u64;
        }epoll_data_t;
        
  • epoll_wait()该函数返回就绪文件描述符的个数

    • maxevents 指定最多监听多少个时间,必须 > 0;
    • struct epoll_event * events 函数检测到事件时,将所有的就绪事件从内核事件表中复制到epoll_creat的链表中。
      服务程序这么一来就可以只查看链表中有没有就绪的文件描述符事件就可以了。省去了遍历所有描述符的时间

3.3.2 理解版本二(callback版本)

BIO、NIO、select、poll、epoll的个人理解

参数说明

epoll是Linux特有的I/O复用函数,epoll使用一组函数来完成任务,而不是单个函数。epoll把用户关心的文件描述符放到内核的一个事件表中,所以epoll需要使用一个额外的文件描述符来表示内核中的事件表。

#include <sys/epoll.h>
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);
  • epoll_create() 返回一个文件描述符,该文件描述符“描述”的是内存中的一块内存区域,即概述中所说的内核中的事件表,size现在不起任何作用。

    • //epoll_create的时候创建了一个 struct eventpoll 结构体(内核自己创建的),每次创建epoll_create时,返回一个文件描述符epfd,内核就是通过这个数据结构来管理epoll的,或者说这个fd和这个struct evenpoll绑定了。
      struct eventpoll {
      
          spin_lock_t       lock;        //对本数据结构的访问
      
          struct mutex      mtx;         //防止使用时被删除
      
          wait_queue_head_t     wq;      //sys_epoll_wait() 使用的等待队列
      
          wait_queue_head_t   poll_wait;       //file->poll()使用的等待队列
      
          struct list_head    rdllist;        //事件满足条件的链表
      
          struct rb_root      rbr;            //用于管理所有fd的红黑树(树根)
      
          struct epitem      *ovflist;       //将事件到达的fd进行链接起来发送至用户空间
      
      }
      
  • epoll_ctl()用来操作内核事件表

    • //当向系统中添加一个fd时,就创建一个epitem结构体,这是内核管理epoll的基本数据结构
      struct epitem {
      
          struct rb_node  rbn;        //用于主结构管理的红黑树
      
          struct list_head  rdllink;  //事件就绪队列
      
          struct epitem  *next;       //用于主结构体中的链表
      
          struct epoll_filefd  ffd;   //这个结构体对应的被监听的文件描述符信息
      
          int  nwait;                 //poll操作中事件的个数
      
          struct list_head  pwqlist;  //双向链表,保存着被监视文件的等待队列,功能类似于select/poll中的poll_table
      
          struct eventpoll  *ep;      //该项属于哪个主结构体(多个epitm从属于一个eventpoll)
      
          struct list_head  fllink;   //双向链表,用来链接被监视的文件描述符对应的struct file。因为file里有f_ep_link,用来保存所有监视这个文件的epoll节点
      
          struct epoll_event  event;  //注册的感兴趣的事件,也就是用户空间的epoll_event
      
      }
      
    • int epfd表示epoll_create() 返回的事件表

    • int fd:新创建的socket文件描述符

    • int op

      • EPOLL_CTL_ADD: 事件表中添加一个文件描述符,内核应该关注的socket的事件在epoll_event结构体中,添加到事件表中的文件描述符以红黑树的形式存在,防止重复添加
      • EPOLL_CTL_MOD:修改fd上注册的事件
      • EPOLL_CTL_DEL:删除fd上注册的事件
    • struct epoll_event *event

      • struct epoll_event{
            _uint32_t events; //epoll事件,读、写、异常三种
            epoll_data_t data; //用户数据
        }
        
        struct epoll_data{
            void* prt;
            int fd;
            _uint32_t u32;
            _uint64_t u64;
        }epoll_data_t;
        
  • epoll_wait()该函数返回就绪文件描述符的个数

    • maxevents 指定最多监听多少个时间,必须 > 0;
    • struct epoll_event * events ,当有该结构中事件发生时,主动调用callback函数?具体是让callback函数把该事件放到那个就绪事件链表里还是直接返回到用户态的服务程序做操作,没搞清楚。(版本二图画的有问题,以后再去processOn改完再上传过来)

3.2 性能分析

如果该服务器拥有两个CPU,则这两个CPU实现了异步处理就绪文件描述符。极大的提高了应用程序索引就绪文件描述符的效率!

3.3 工作模式(LT模式、ET模式)

BIO、NIO、select、poll、epoll的个人理解

BIO、NIO、select、poll、epoll的个人理解

简言之就是:

  • LT模式
    • fd可读之后,如果服务程序读走一部分就结束此次读取,LT模式下该文件描述符仍然可读
    • fd可写之后,如果服务程序写了一部分就结束此次写入,LT模式下该文件描述符也仍然可写
  • ET模式
    • fd可读之后,如果服务程序读走一部分就结束此次读取,ET模式下该文件描述符是不可读,需要等到下次有数据到达时才可变为可读,所有我们要保证循环读取数据,以确保把所有数据读出
    • fd可写之后,如果服务程序写了一部分就结束此次写入,ET模式下该文件描述符是不可写的,我们要保证写入数据,确保把数据写满

3.4 EPOLLONESHOT

即使我们使用ET模式,一个socket仍然可能被多次触发。比如在并发时,一个线程在读取完数据之后开始处理,处理过程中又有新事件触发,此时另外一个线程被唤醒来读取数据。为了防止多个线程同时操作一个socket,就可以注册EPOLLONESHOT 事件,注册此事件的文件描述符,操作系统最多触发可读、可写、异常事件中的一个,而且只触发一次。但是每次处理完一个事件之后,需要使用epoll_ctl 来修改他的EPOLLONESHOT事件,是其他工作线程有机会处理这个socket

4、最后附上一份Java的多路复用代码

public class IOMultiplexing {

    private ServerSocketChannel server = null;
    private Selector selector = null;  //linux 多路复用器 (可能是select poll   epoll中的任何一个)
    int port = 9090;

    public void initServer(){
        try {
            server = ServerSocketChannel.open();
            server.configureBlocking(false);
            server.bind(new InetSocketAddress(port));

            //如果是在epoll模式下, open() -> epoll_create -> fd3
            selector = Selector.open();

            // server约等于 listen状态的 fd4,
            /*
            * register:
            * 如果系统调用为 select, poll, jvm开辟一个数组,把fd放进去
            * 如果系统调用为 epoll: epoll_ctl(fd3, ADD, fd4, EPOLLIN)
            * */
            server.register(selector, SelectionKey.OP_ACCEPT);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void start(){
        initServer();
        System.out.println("服务已启动");
        try{
            while (true){
                Set<SelectionKey> keys = selector.keys();
                System.out.println(keys.size()+ " size");

                /*
                * 1. 调用多路复用器(select, poll, epoll)
                * 如果系统调用为 select, poll, select(fd4)传递给内核,让内核判断该文件描述符就绪否
                *
                * 如果系统调用为 epoll:此时的selector.select()方法相当于调用了epoll_wait()系统调用
                * */
                while (selector.select(500) > 0){
                    Set<SelectionKey> selectionKeys = selector.selectedKeys(); //返回就绪的fd集合
                    Iterator<SelectionKey> iterator = selectionKeys.iterator();
                    while (iterator.hasNext()){
                        SelectionKey next = iterator.next();
                        iterator.remove();
                        if (next.isAcceptable()){
                            //select() 和 poll() 放到数组里
                            //epoll() 调用 epoll_create()放到内核空间的红黑树里
                            acceptHandler(next);
                        }else if (next.isReadable()){
                            readHandler(next);
                        }else if (next.isWritable()){
                            writeHandler(next);
                        }
                    }
                }
            }
        }catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void writeHandler(SelectionKey next) {
    }

    private void readHandler(SelectionKey selectionKey) {
        
    }

    private void acceptHandler(SelectionKey selectionKey) {
        try {
            ServerSocketChannel ssc = (ServerSocketChannel) selectionKey.channel();
            SocketChannel client = ssc.accept(); // 接受客户端连接
            client.configureBlocking(false);
            ByteBuffer buffer = ByteBuffer.allocate(8192); // 设置最大空间
            //select() 和 poll() 放到数组里
            //epoll() 调用 epoll_create()放到内核空间的红黑树里
            client.register(selector, SelectionKey.OP_READ, buffer);
            System.out.println("新客户端: "+ client.getRemoteAddress());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}