文章目录

  • select
  • select优点
  • select缺点
  • select的工作流程
  • poll
  • epoll
  • epoll的工作原理
  • epoll工作模式
  • epoll的特性
  • NIO使用多路复用器示例


select

select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。

select优点

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点,事实上从现在看来,这也是它所剩不多的优点之一。

select缺点

select的本质是采用32个整数的32位,即3232= 1024来标识,fd值为1-1024。当fd的值超过1024限制时,就必须修改FD_SETSIZE的大小。这个时候就可以标识32max值范围的fd。

  1. 随着文件描述符数量的增大,同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,造成了一些不必要的性能开销。
  2. 单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。
  3. 每次查询都要重新重复传递和Copy所关注的fds

select的工作流程

  1. 创建socket
  2. 绑定端口bind
  3. 监听listen
  4. accept
  5. write/read。

当有客户端连接到来时,select会把该连接的文件描述符放到fd_set(文件描述符(fd)的集合),然后select会循环遍历它所监测的fd_set内的所有文件描述符,如果没有一个资源可用(即没有一个文件描述符可以进行read/write可以操作),则select让该进程阻塞的等待,一直等到有资源可用为止。而fd_set是一个类似于数组的数据结构,由于它每次都要遍历整个数组,所以它的效率会随着文件描述符的数量增多而明显的变慢,除此之外在每次遍历这些描述符之前,系统还需要把这些描述符集合从内核copy到用户空间,然后再copy回去,如果此时没有一个描述符有事件发生(例如:read和write)这些copy操作和便利操作都是无用功,可见slect随着连接数量的增多,效率大大降低。可见如果在高并发的场景下select并不适用,况且select默认的最大描述符为1024。

poll

poll在1986年诞生于System V Release 3,工作原理与select几乎相同,不同的是,由于其就绪队列由链表实现,所以,其对于要处理的内核进程数量理论上是没有限制的,即其能够处理的最大并发连接数量是没有限制的(当然,要受限于当前系统中进程可以打开的最大文件描述符数ulimit)。

poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

另外,select()和poll()将就绪的文件描述符告诉进程后,如果进程没有对其进行IO操作,那么下次调用select()和poll()的时候将再次报告这些文件描述符,所以它们一般不会丢失就绪的消息,这种方式称为水平触发(Level Triggered)。

epoll

直到Linux2.6才出现了由内核直接支持的实现方法,那就是epoll,是之前的select和poll的增强版本,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

epoll的工作原理

epoll的工作流程分为以下几步 :

  • 调用epoll_create()函数创建一个epoll句柄.
  • 调用epoll_ctl()函数将监控的文件描述符进行处理.
  • 调用epoll_wait()函数,等待就绪的文件描述符.

当我们调用epoll_create系统函数时,会在内核中建一颗红黑树红黑树的节点就是调用epoll_ctl()系统函数时管理的需要监控的事件。此外,还会创建一个链表(即就绪队列),就是用来存储就绪的事件。而当调用epoll_wait()系统函数时,就仅仅只需要查看该链表之中有没有数据,有就会将链表上的数据从内核态拷贝到用户态,同时将事件的数量返回给用户,这个事件复杂度是O(1)。若没有就会立即返回,然后继续的等待,若等待的时间超过了timeout也会立即返回。
当调用epoll_ctl()进行注册事件时,会做两件事:一是将需要注册的事件挂载到红黑树之中,二是也给每个事件注册一个回调函数,建立一种回调关系。当有事件进入就绪后,就会触发网卡驱动程序发出中断,将对应的事件从红黑树中找到并添加到链表之中。这都是在内核中并结合硬件来实现的,无疑是非常之高效。

epoll工作模式

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。

LT模式是默认模式,LT模式与ET模式的区别如下:

  • LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
  • ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

epoll同样只告知那些就绪的文件描述符,而且当我们调用epoll_wait()获得就绪文件描述符时,返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一个数组中依次取得相应数量的文件描述符即可,这里也使用了内存映射(mmap)技术,这样便彻底省掉了这些文件描述符在系统调用时复制的开销。

另一个本质的改进在于epoll采用基于事件的就绪通知方式。在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。

调用select()将阻塞,直到指定的文件描述符准备好执行I/O,或者可选参数timeout指定的时间已经过去。
监视的文件描述符分为三类set,每一种对应等待不同的事件。readfds中列出的文件描述符被监视是否有数据可供读取(如果读取操作完成则不会阻塞)。writefds中列出的文件描述符则被监视是否写入操作完成而不阻塞。最后,exceptfds中列出的文件描述符则被监视是否发生异常,或者无法控制的数据是否可用(这些状态仅仅应用于套接字)。这三类set可以是NULL,这种情况下select()不监视这一类事件。
select()成功返回时,每组set都被修改以使它只包含准备好I/O的文件描述符。例如,假设有两个文件描述符,值分别是7和9,被放在readfds中。当select()返回时,如果7仍然在set中,则这个文件描述符已经准备好被读取而不会阻塞。如果9已经不在set中,则读取它将可能会阻塞(我说可能是因为数据可能正好在select返回后就可用,这种情况下,下一次调用select()将返回文件描述符准备好读取)。
第一个参数n,等于所有set中最大的那个文件描述符的值加1。因此,select()的调用者负责检查哪个文件描述符拥有最大值,并且把这个值加1再传递给第一个参数。如果这个参数不是NULL,则即使没有文件描述符准备好I/O,select()也会在经过tv_sec秒和tv_usec微秒后返回。当select()返回时,timeout参数的状态在不同的系统中是未定义的,因此每次调用select()之前必须重新初始化timeout和文件描述符set。实际上,当前版本的Linux会自动修改timeout参数,设置它的值为剩余时间。因此,如果timeout被设置为5秒,然后在文件描述符准备好之前经过了3秒,则这一次调用select()返回时tv_sec将变为2。
如果timeout中的两个值都设置为0,则调用select()将立即返回,报告调用时所有未决的事件,但不等待任何随后的事件。
文件描述符set不会直接操作,一般使用几个助手宏来管理。这允许Unix系统以自己喜欢的方式来实现文件描述符set。但大多数系统都简单地实现set为位数组。FD_ZERO移除指定set中的所有文件描述符。每一次调用select()之前都应该先调用它。

epoll的特性

  1. 支持一个进程打开大数目的socket描述符(FD)
    select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
  2. IO效率不随FD数目增加而线性下降
    传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是"活跃"的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对"活跃"的socket进行操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个"伪"AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的—比如一个高速LAN环境,epoll并不比select/poll有什么效率,相反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。
  3. 使用mmap加速内核与用户空间的消息传递。
    这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。而如果你想我一样从2.5内核就关注epoll的话,一定不会忘记手工 mmap这一步的。

NIO使用多路复用器示例

@Slf4j
public class SelectorDemo {

    int port = 8888;
    private ServerSocketChannel server = null;
    private Selector selector = null;
    private ByteBuffer readBuffer = ByteBuffer.allocateDirect(1024);
    private ByteBuffer writeBuffer = ByteBuffer.allocateDirect(1024);


    public static void main(String[] args) {
        new SelectorDemo().start();
    }

    public void initServer() {
        try {
            server = ServerSocketChannel.open();
            server.configureBlocking(false);         //设置非阻塞
            server.bind(new InetSocketAddress(port)); //绑定端口
            selector = Selector.open();
            server.register(selector, SelectionKey.OP_ACCEPT); //注册多路复用器,绑定监听事件
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void start() {
        initServer();
        log.debug("服务器已启动");
        try {
            while (true) {
                //调用select阻塞,直到selector上注册的事件发生
                while (selector.select() > 0) {
                    Set<SelectionKey> keys = selector.selectedKeys(); //获取就绪事件
                    Iterator<SelectionKey> iter = keys.iterator();

                    while (iter.hasNext()) {
                        SelectionKey key = iter.next();
                        iter.remove(); //先移除该事件,避免重复通知
                        if (key.isAcceptable()) {
                            acceptHandler(key);
                        } else if (key.isReadable()) {
                            readHandler(key);
                        }
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void readHandler(SelectionKey key) throws IOException {
        log.debug("读取事件:" + key.toString());
        SocketChannel socketChannel = (SocketChannel) key.channel();
        readBuffer.clear();
        socketChannel.read(readBuffer);
        readBuffer.flip();
        String receiveData = Charset.forName("UTF-8").decode(readBuffer).toString();
        log.debug("收到来自客户端的消息:" + receiveData);

        //这里将收到的数据发回给客户端
        writeBuffer.clear();
        writeBuffer.put(receiveData.getBytes());
        writeBuffer.flip();
        while (writeBuffer.hasRemaining()) {
            //防止写缓冲区满,需要检测是否完全写入
            log.debug("写入数据字节数:" + socketChannel.write(writeBuffer));
        }

    }

    public void acceptHandler(SelectionKey key) {
        try {
            ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
            SocketChannel socketChannel = ssc.accept(); //接收客户端
            socketChannel.configureBlocking(false);//非阻塞模式
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            socketChannel.register(selector, SelectionKey.OP_READ, buffer); 注册读事件

            //向客户端发生一条消息
            buffer.put("你好!这里是服务端。".getBytes());
            buffer.flip();
            socketChannel.write(buffer);

            log.debug("新客户端接入成功:" + socketChannel.getRemoteAddress());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}