一、概念说明

1. 用户空间与内核空间、用户态与内核态

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

用户程序处于内核态时可以访问内核空间和用户空间,处于用户态时,只能访问用户空间。通常用户程序始于用户态,在运行过程中会出现内核态和用户态的转换。

用户态切换为内核态的三种情况如下:

  • 系统调用:为了使上层应用能够访问到这些资源,内核为上层应用提供访问的接口
  • 异常事件: 当CPU正在执行运行在用户态的程序时,突然发生某些预先不可知的异常事件,这个时候就会触发从当前用户态执行的进程转向内核态执行相关的异常事件,典型的如缺页异常
  • 外围设备的中断:当外围设备完成用户的请求操作后,会像CPU发出中断信号,此时,CPU就会暂停执行下一条即将要执行的指令,转而去执行中断信号对应的处理程序,如果先前执行的指令是在用户态下,则自然就发生从用户态到内核态的转换。

系统调用的本质其实也是中断,相对于外围设备的硬中断,这种中断称为软中断。从触发方式和效果上来看,这三种切换方式是完全一样的,都相当于是执行了一个中断响应的过程。但是从触发的对象来看,系统调用是进程主动请求切换的,而异常和硬中断则是被动的。
image.png
image.png

2. 进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
0. 保存虚拟内存现场(TLB(页表)失效,这也是比线程切换成本大的主要原因)
1. 保存处理机上下文,包括程序计数器和其他寄存器。
2. 更新PCB(Process Control Block)信息。
3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
4. 选择另一个进程执行,并更新其PCB。
5. 更新内存管理的数据结构。
6. 恢复处理机上下文。
注:总而言之就是很耗资源,具体的可以参考这篇文章:进程切换

3. 进程的阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。

4. 文件描述符

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

文件描述符,一般叫fd。linux万物皆文件,fd就是抽象之后的体现。linux中,我们很多操作都是靠着这个fd,具体来说,当我们想操作一个资源的时候,就会调用操作系统对应的接口,这个接口就会返回一个fd,后续我们就可以通过这个fd去操作这个资源,比如启动一个服务器的时候,会调socket函数拿到一个fd,操作文件的时候调用open拿到一个fd,进程间通信的时候通过pipe拿到两个fd。这些设计得益于linux vfs的设计。在fd下面的黑盒子里,存在以下关系,pcb->fd->file->inode。pcb是描述进程的结构体,fd是进程范围的,他是pcb里的字段。file和inode是系统级的,file结构体描述的是我们对资源的打开模式,比如只读,还有读写的位置指针和引用计数(被多少个fd指向)等等信息。inode是描述一个资源的结构体,比如一个文件会对应一个inode。一个进程间通信的管道对应一个inode。inode里记录了资源的类型,比如普通文件,网络socket,通信管道等等,在打开资源的时候,操作系统会根据不同资源的类型,赋值不同的函数集给inode,当我们后续通过fd操作资源的时候,就会调用inode中对应的函数。vfs实现的代码里,基本就是ops->read,ops->write。具体的实现交给不同的模块。比如文件系统ext就会实现对普通文件的逻辑,网络socket就会实现tcp等协议的逻辑。

5. 缓存 IO

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存(page cache)中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存 I/O 的缺点
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

6. 同步、异步、阻塞、非阻塞

以烧水为例。
故事:老王烧开水。
出场人物:老王,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。
老王想了想,有好几种等待方式

  1. 老王用水壶煮水,并且站在那里,不管水开没开,每隔一定时间看看水开了没。-同步阻塞

老王想了想,这种方法不够聪明。

  1. 老王还是用水壶煮水,不再傻傻的站在那里看水开,跑去寝室上网,但是还是会每隔一段时间过来看看水开了没有,水没有开就走人。-同步非阻塞

老王想了想,现在的方法聪明了些,但是还是不够好。

  1. 老王这次使用高大上的响水壶来煮水,站在那里,但是不会再每隔一段时间去看水开,而是等水开了,水壶会自动的通知他。-异步阻塞

老王想了想,不会呀,既然水壶可以通知我,那我为什么还要傻傻的站在那里等呢,嗯,得换个方法。

  1. 老王还是使用响水壶煮水,跑到客厅上网去,等着响水壶自己把水煮熟了以后通知他。- 异步非阻塞

老王豁然,这下感觉轻松了很多。

  • 同步和异步
    同步就是烧开水,需要自己去轮询(每隔一段时间去看看水开了没),异步就是水开了,然后水壶会通知你水已经开了,你可以回来处理这些开水了。 同步和异步是相对于操作结果来说,会不会等待结果返回。
  • 阻塞和非阻塞
    阻塞就是说在煮水的过程中,你不可以去干其他的事情,非阻塞就是在同样的情况下, 可以同时去干其他的事情。阻塞和非阻塞是相对于线程是否被阻塞。

二、I/O 模型

一个输入操作通常包括两个阶段:

  • 等待数据准备好(在网络IO上可以体现为一直在监听端口,等待客户端连接并发送数据)
  • 从内核向进程复制数据(客户端发送的数据先会存在内核缓冲区,然后再读到用户进程缓冲区)

image.png
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待数据到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。

Unix 有五种 I/O 模型:

  • 阻塞式 I/O(BIO)
  • 非阻塞式 I/O(NIO)
  • I/O 复用(select 和 poll)
  • 信号驱动式 I/O(SIGIO)
  • 异步 I/O(AIO)

1. 阻塞式 I/O(BIO)

1492928416812_4.png
image.png

  • 当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。
  • 而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
  • Java网络编程中启动一个Socket之后就会阻塞,监听端口直到有客户端与其连接,这里的阻塞是在第一个阶段。
  • 建立连接后往往new一个新的线程用于读取客户端数据,以保证可以继续监听别的客户端连接,否则主线程还是阻塞的。(但是创建一个新线程本质是让操作系统clone一个轻量级进程,所以Java多线程也是有进程id的)

    所以,blocking IO的特点就是在IO执行的两个阶段都被block了。

  1. /**
  2. * 服务端
  3. */
  4. public class TCPServer {
  5. public static void main(String[] args) throws Exception {
  6. ServerSocket server = new ServerSocket(6666);
  7. // 服务端,保持启动
  8. while (true) {
  9. Socket socket = server.accept(); // 阻塞1,等待客户端建立连接请求
  10. System.out.println("a client connected, port:" + socket.getPort());
  11. // 需要开启一个线程读写数据,否则主线程阻塞,其他client无法连接server
  12. // BIO 每个连接占用一个线程
  13. new Thread(() -> {
  14. DataInputStream dis = null;
  15. try {
  16. dis = new DataInputStream(socket.getInputStream());
  17. String str = null;
  18. while (true) {
  19. // 阻塞2,等待客户端发送消息
  20. if ((str = dis.readUTF()) != null) {
  21. System.out.println("port #" + socket.getPort() + ":" + str);
  22. } else {
  23. dis.close();
  24. socket.close();
  25. break;
  26. }
  27. }
  28. } catch (IOException e) {
  29. e.printStackTrace();
  30. }
  31. }).start();
  32. }
  33. }
  34. }
  35. /**
  36. * 客户端
  37. */
  38. public class TCPClient {
  39. public static void main(String[] args) throws IOException {
  40. Socket socket = new Socket("127.0.0.1", 6666);
  41. DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
  42. Scanner sc = new Scanner(System.in);
  43. while (true) {
  44. dos.writeUTF(sc.nextLine());
  45. }
  46. }
  47. }

2. 非阻塞式 I/O(NIO)

IO模型及 select、poll、epoll - 图6
当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。由于 CPU 要处理更多的系统调用,因此这种模型的 CPU 利用率比较低。

所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。

public class TCPServer {
    public static void main(String[] args) throws Exception {
        LinkedList<SocketChannel> clients = new LinkedList<>();  // 连接的所有客户端
        ServerSocketChannel ss = ServerSocketChannel.open();  // 服务端开启监听,接收客户端
        ss.bind(new InetSocketAddress(9090));
        ss.configureBlocking(false);  // 非阻塞
        while (true) {
            SocketChannel client = ss.accept(); // 1.监听不会阻塞,立即返回,没有连接则为null
            // 没有客户端连接返回null
            if (client == null) {
//                System.out.println("未连接...");
            } else {
                // 有客户端连接添加到列表
                client.configureBlocking(false);
                System.out.println("a client connected, port:" + client.socket().getPort());
                clients.add(client);
            }
            ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
            // 遍历已经连接的客户端能不能读写数据
            for (SocketChannel c : clients) {
                // 2.读取也不会阻塞
                if (c.read(buffer) > 0) {
                    buffer.flip();
                    byte[] bytes = new byte[buffer.limit()];
                    buffer.get(bytes);
                    String str = new String(bytes);
                    System.out.println(c.socket().getPort() + ":" + str);
                    buffer.clear();
                }
            }
        }
    }
}

3. I/O 多路复用

IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO(socket)。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
1492929444818_6.png
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。

这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

public class SelectorServer {
    public static void main(String[] args) throws Exception {
        ServerSocketChannel channel = ServerSocketChannel.open();
        Selector selector = Selector.open();
        // 绑定一个端口
        channel.socket().bind(new InetSocketAddress(6666));
        // 设置非阻塞
        channel.configureBlocking(false);
        // 把channel注册到selector,关心事件为OP_ACCEPT(有新的客户端连接)
        SelectionKey register = channel.register(selector, SelectionKey.OP_ACCEPT);
        while (true) {
            // 等待1秒,如果没有事件发生就返回
            if (selector.select(1000) == 0) {
                System.out.println("服务器等待了1秒,无连接");
                continue;
            }
            // 返回的>0,表示已经获取到了关注的事件
            // 获取到相关的selectionKey集合,反向获取通道
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            // 使用迭代器遍历selectionKeys
            Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                // 根据key对应的通道发生的事件,做相应处理
                // 有新客户端连接
                if (key.isAcceptable()) {
                    // 该客户端生成一个socketChannel
                    SocketChannel socketChannel = channel.accept();
                    System.out.println("客户端连接成功,生成一个socketChannel" + socketChannel.hashCode());
                    // 将socketChannel设置为非阻塞
                    socketChannel.configureBlocking(false);
                    // 将socketChannel注册到selector,关注事件为OP_READ,同时给socketChannel关联一个Buffer
                    socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                }
                if (key.isReadable()) {
                    // 通过key反向获取到对应的Channel
                    SocketChannel keyChannel = (SocketChannel) key.channel();
                    // 获取到该channel关联的Buffer
                    ByteBuffer buffer = (ByteBuffer) key.attachment();
                    keyChannel.read(buffer);
                    System.out.println("from客户端:" + new String(buffer.array()));
                }
                // 手动从集合中移除当前的SelectionKey,防止重复操作
                keyIterator.remove();
            }
        }
    }
}

4. 信号驱动 I/O

应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom 将数据从内核复制到应用进程中。

相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高。
IO模型及 select、poll、epoll - 图8

5. 异步 I/O(AIO)

IO模型及 select、poll、epoll - 图9
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它收到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

和同步 IO 相比,异步 IO 就是把数据从内核缓存拷贝到用户内存的事情也是后台并行做了

五大 I/O 模型比较

blocking和non-blocking的区别
调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

synchronous IO和asynchronous IO的区别
两者的区别就在于synchronous IO做“IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。

有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的“IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。

而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。也就是说,数据从kernel拷贝到用户内存是后台并行完成的,不会block进程

各个IO Model的比较汇总
IO模型及 select、poll、epoll - 图10

三、I/O 多路复用

select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

1. select

传递给select函数的参数会告诉内核:

  • 我们所关心的文件描述符(告诉内核我们要监听哪些socket)
  • 对每个描述符,我们所关心的状态(可读/可写/异常)
  • 我们要等待多长时间(可以阻塞多长时间,传0表示这是一个非阻塞的select调用)

从select函数返回后,内核告诉我们的信息:

  • 对我们的要求已经做好准备的描述符的个数
  • 对于三种条件哪些描述符已经做好准备。fd_set哪些位被修改,则表示哪些位已就绪(读/写/异常)

有了这些返回信息,我们可以调用合适的I/0函数(read或write),并且这些函数不会再阻塞,因为缓冲区里面已经确定有东西了

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

返回:做好准备的文件描述符的个数,超时为0,错误为-1

最后一个参数timeout指明我们要等待的时间,有三种情况:

  • timeout=null,等待无限长时间
  • =0,不等待,直接返回(非阻塞)
  • !=0,等待指定时间

中间的三个参数readfds, writefds, exceptfds指向文件描述符集。这些参数指明了我们关心哪些描述符和需要满足什么条件(可读,可写,异常)。一个文件描述符集保存在fd_set类型中,fd_set其实就是位图(长度为1024,所以可监听的文件描述符的个数是受限的)。这里的文件描述符可以认为是socket的标识

maxfdp1是描述符数组的最大有效位个数,这样就不用遍历数组的全部1024个位置了。

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。fd_set参数在每次发起系统调用之前都需要置位,所以也比较繁琐
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png

2. poll

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

不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。

struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events to watch */
    short revents; /* returned events witnessed */
};

pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。

从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

3. select和poll比较

1. 功能
select 和 poll 的功能基本相同,不过在一些实现细节上有所不同。

  • select 会修改描述符,而 poll 不会;
  • select 的描述符类型使用数组实现,FD_SETSIZE 大小默认为 1024,因此默认只能监听少于 1024 个描述符。如果要监听更多描述符的话,需要修改 FD_SETSIZE 之后重新编译;而 poll 没有描述符数量的限制;
  • poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高。
  • 如果一个线程对某个描述符调用了 select 或者 poll,另一个线程关闭了该描述符,会导致调用结果不确定。

2. 速度
select 和 poll 速度都比较慢,每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。

3. 可移植性
几乎所有的系统都支持 select,但是只有比较新的系统支持 poll。

4. epoll

epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

4.1 epoll接口

epoll操作过程需要三个接口,分别如下:

int epoll_create(int size);// 创建一个epoll的句柄,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);

1. int epoll_create(int size);
该函数是一个系统函数,函数在内核空间内开辟一块新的空间,可以理解为epoll结构空间,返回值为epoll的文件描述符编号,方便后续使用。

创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
该函数是epoll的事件注册函数,epoll与select不同,select函数是调用时指定需要监听的描述符和事件,epoll首先将用户感兴趣的描述符事件注册到epoll空间。此函数是非阻塞函数,作用仅仅是增删改epoll空间内的描述符信息。
参数:

  • epfd:是epoll_create()的返回值,函数将依靠该编号找到对应的epoll结构
  • op:表示对fd的操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
  • fd:是需要监听的fd(文件描述符)
  • epoll_event:是告诉内核需要监听什么事件,struct epoll_event结构如下:
    struct epoll_event {
    __uint32_t events;  /* Epoll events */
    epoll_data_t data;  /* User data variable */
    };
    //events可以是以下几个宏的集合:
    EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
    EPOLLOUT:表示对应的文件描述符可以写;
    EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
    EPOLLERR:表示对应的文件描述符发生错误;
    EPOLLHUP:表示对应的文件描述符被挂断;
    EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
    EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
    

3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的产生,类似于select()调用。根据参数timeout,来决定是否阻塞。该函数返回需要处理的事件数目,如返回0表示已超时。
参数:

  • epfd:是epoll_create()的返回值,函数将依靠该编号找到对应的epoll结构
  • events:用来从内核得到事件的集合,有几个就绪就返回几个,不会像select有很多无用的位
  • maxevents:告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,
  • timeout:超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。

image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png

4.2 工作模式

epoll对文件描述符的操作有两种模式:LT(level trigger)ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
  LT模式(水平触发):事件就绪后,用户可以选择处理或者不处理,如果用户本次未处理,那么下次调用epoll_wait时仍然会将未处理的事件打包给你。
  ET模式(边缘触发):事件就绪后,用户必须处理,因为内核不给你兜底了,内核把就绪的事件打包给你后,就把对应的就绪事件清除掉了。
EI模式在很大程度上减少了epoll时间被重复触发的次数,因此效率要比LT模式高

LT模式
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。

ET模式
ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

总结
假如有这样一个例子:
1. 我们已经把一个用来从管道中读取数据的文件句柄(RFD)添加到epoll描述符
2. 这个时候从管道的另一端被写入了2KB的数据
3. 调用epoll_wait(2),并且它会返回RFD,说明它已经准备好读取操作
4. 然后我们读取了1KB的数据
5. 调用epoll_wait(2)……
LT模式:
如果是LT模式,那么在第5步调用epoll_wait(2)之后,仍然能受到通知。
ET模式:
如果我们在第1步将RFD添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait(2)之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET 工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。
当使用epoll的ET模型来工作时,当产生了一个EPOLLIN事件后,
读数据的时候需要考虑的是当recv()返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取:

while(rs){
      buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);
      if(buflen < 0){
            // 由于是非阻塞的模式,所以当errno为EAGAIN时,表示当前缓冲区已无数据可读
            // 在这里就当作是该次事件已处理处.
            if(errno == EAGAIN){
                break;
            }
            else{
                return;
            }
      }
      else if(buflen == 0){
         // 这里表示对端的socket已正常关闭.
      }
      if(buflen == sizeof(buf){
          rs = 1;   // 需要再次读取
      }
      else{
          rs = 0;
      }
}

Linux中的EAGAIN含义

Linux环境下开发经常会碰到很多错误(设置errno),其中EAGAIN是其中比较常见的一个错误(比如用在非阻塞操作中)。
从字面上来看,是提示再试一次。这个错误经常出现在当应用程序进行一些非阻塞(non-blocking)操作(对文件或socket)的时候。
例如,以 O_NONBLOCK的标志打开文件/socket/FIFO,如果你连续做read操作而没有数据可读。此时程序不会阻塞起来等待数据准备就绪返回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。
又例如,当一个系统调用(比如fork)因为没有足够的资源(比如虚拟内存)而执行失败,返回EAGAIN提示其再调用一次(也许下次就能成功)。

4.3 代码演示

下面是一段不完整的代码且格式不对,意在表述上面的过程,去掉了一些模板代码。

#define IPADDRESS   "127.0.0.1"
#define PORT        8787
#define MAXSIZE     1024
#define LISTENQ     5
#define FDSIZE      1000
#define EPOLLEVENTS 100
listenfd = socket_bind(IPADDRESS,PORT);
struct epoll_event events[EPOLLEVENTS];
//创建一个描述符
epollfd = epoll_create(FDSIZE);
//添加监听描述符事件
add_event(epollfd,listenfd,EPOLLIN);
//循环等待
for ( ; ; ){
    //该函数返回已经准备好的描述符事件数目
    ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1);
    //处理接收到的连接
    handle_events(epollfd,events,ret,listenfd,buf);
}
//事件处理函数
static void handle_events(int epollfd,struct epoll_event *events,int num,int listenfd,char *buf)
{
     int i;
     int fd;
     //进行遍历;这里只要遍历已经准备好的io事件。num并不是当初epoll_create时的FDSIZE。
     for (i = 0;i < num;i++)
     {
         fd = events[i].data.fd;
        //根据描述符的类型和事件类型进行处理
         if ((fd == listenfd) &&(events[i].events & EPOLLIN))
            handle_accpet(epollfd,listenfd);
         else if (events[i].events & EPOLLIN)
            do_read(epollfd,fd,buf);
         else if (events[i].events & EPOLLOUT)
            do_write(epollfd,fd,buf);
     }
}
//添加事件
static void add_event(int epollfd,int fd,int state){
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&ev);
}
//处理接收到的连接
static void handle_accpet(int epollfd,int listenfd){
     int clifd;     
     struct sockaddr_in cliaddr;     
     socklen_t  cliaddrlen;     
     clifd = accept(listenfd,(struct sockaddr*)&cliaddr,&cliaddrlen);     
     if (clifd == -1)         
     perror("accpet error:");     
     else {         
         printf("accept a new client: %s:%d\n",inet_ntoa(cliaddr.sin_addr),cliaddr.sin_port);                       //添加一个客户描述符和事件         
         add_event(epollfd,clifd,EPOLLIN);     
     } 
}
//读处理
static void do_read(int epollfd,int fd,char *buf){
    int nread;
    nread = read(fd,buf,MAXSIZE);
    if (nread == -1)     {         
        perror("read error:");         
        close(fd); //记住close fd        
        delete_event(epollfd,fd,EPOLLIN); //删除监听 
    }
    else if (nread == 0)     {         
        fprintf(stderr,"client close.\n");
        close(fd); //记住close fd       
        delete_event(epollfd,fd,EPOLLIN); //删除监听 
    }     
    else {         
        printf("read message is : %s",buf);        
        //修改描述符对应的事件,由读改为写         
        modify_event(epollfd,fd,EPOLLOUT);     
    } 
}
//写处理
static void do_write(int epollfd,int fd,char *buf) {     
    int nwrite;     
    nwrite = write(fd,buf,strlen(buf));     
    if (nwrite == -1){         
        perror("write error:");        
        close(fd);   //记住close fd       
        delete_event(epollfd,fd,EPOLLOUT);  //删除监听    
    }else{
        modify_event(epollfd,fd,EPOLLIN); 
    }    
    memset(buf,0,MAXSIZE); 
}
//删除事件
static void delete_event(int epollfd,int fd,int state) {
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_DEL,fd,&ev);
}
//修改事件
static void modify_event(int epollfd,int fd,int state){     
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&ev);
}
//注:另外一端我就省了

4.4 epoll总结

在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)
epoll的优点主要是以下几个方面:

  1. 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048。举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。select的最大缺点就是进程打开的fd是有数量限制的。这对于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。
  2. IO的效率不会随着监视fd的数量的增长而下降(O(1))。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。

    如果没有大量的idle -connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle- connection,就会发现epoll的效率大大高于select/poll。

5. select、epoll对比

select的缺点:
1、单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在linux内核头文件中,有这样的定义:#define __FD_SETSIZE 1024)
2、内核 / 用户空间内存拷贝问题,select每次都会改变内核中的句柄数据结构集,因而每次select调用时都需要从用户空间向内核空间复制所有的句柄数据结构,产生巨大的开销
3、select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件
4、select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程

epoll实现:
epoll在内核中会维护一个红黑树和一个双向链表,红黑树存放通过epoll_ctl方法向epoll对象中添加进来的事件,所以不需要每次调用epoll_wait都全量复制所有的事件结构。双向链表存放就绪的事件,所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法,这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。调用epoll_wait就会直接返回链表中的就绪事件,效率高。
select适合少量活跃连接,一般几千。
epoll适合大量不太活跃的连接。
如果有大量活跃连接可以使用多进程+select。

6. select、poll 和 epoll 之间的区别?

  1. select:时间复杂度 O(n)

select 仅仅知道有 I/O 事件发生,但并不知道是哪几个流,所以只能无差别轮询所有流,找出能读出数据或者写入数据的流,并对其进行操作。所以 select 具有 O(n) 的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

  1. poll:时间复杂度 O(n)

poll 本质上和 select 没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个 fd 对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的。

  1. epoll:时间复杂度 O(1)

epoll 可以理解为 event poll,不同于忙轮询和无差别轮询,epoll 会把哪个流发生了怎样的 I/O 事件通知我们。所以说 epoll 实际上是事件驱动(每个事件关联上 fd)的。

7. 应用场景

很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。

1. select 应用场景

  • select 的 timeout 参数精度为微秒,而 poll 和 epoll 为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。
  • select 可移植性更好,几乎被所有主流平台所支持。

2. poll 应用场景

  • poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。

3. epoll 应用场景

  • 只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。
  • 需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。
  • 需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。