关于I/O多路复用(又被称为“事件驱动”),首先要理解的是,操作系统为你提供了一个功能,当你的某个socket可读或者可写的时候,它可以给你一个通知。这样当配合非阻塞的socket使用时,只有当系统通知我哪个描述符可读了,我才去执行read操作,可以保证每次read都能读到有效数据而不做纯返回-1和EAGAIN的无用功。写操作类似。操作系统的这个功能通过select/poll/epoll/kqueue之类的系统调用函数来使用,这些函数都可以同时监视多个描述符的读写就绪状况,这样,多个描述符的I/O操作都能在一个线程内并发交替地顺序完成,这就叫I/O多路复用,这里的“复用”指的是复用同一个线程。
(1)selectman select
知识铺垫: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 pos‐ sible). A file descriptor is considered ready if it is possible to perform the corresponding I/O operation (e.g., read(2)) without blocking.
(1)内核态/用户态:
电脑启动后,第一个程序是操作系统内核(kernel),内核时负责管理计算机的硬件(eg:网卡),应用程序是不能直接与硬件交互的。
当启动之后,os内核进入内存中之后,会注册一个GDT(全局描述符表)—> 它会把内核进程所在的内存空间划分出来,剩余的予以划分。
而如果应用程序想去操作硬件的话,可以调用内核提供的”系统调用”函数(systemCall kernel),但是又没法直接调,因为处于保护模式(这个方法是在内核的内存空间内),所以有个中断系统。(中断分为软中断和硬中断,而系统调用是软中断),外设例如鼠标的移动,属于硬中断),CPU会根据终端号会有例如网卡的驱动
结论: 若一个程序想要调IO,则必须要经过操作系统内核。
(2)文件描述符:
Linux 系统中,把一切都看做是文件,当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行I/O操作的系统调用都会通过文件描述符。
文件描述符可以理解为进程文件描述表这个表的索引,或者把文件描述表看做一个数组的话,文件描述符可以看做是数组的下标。当需要进行I/O操作的时候,会传入fd作为参数,先从进程文件描述符表查找该fd对应的那个条目,取出对应的那个已经打开的文件的句柄,根据文件句柄指向,去系统fd表中查找到该文件指向的inode,从而定位到该文件的真正位置,从而进行I/O操作。
- 每个文件描述符会与一个打开的文件相对应
- 不同的文件描述符也可能指向同一个文件
- 相同的文件可以被不同的进程打开,也可以在同一个进程被多次打开
多路复用实际就是一个进城可以监视多个文件描述符(fd),一旦某个描述符就绪(无论读就绪还是写就绪),能够通知程序进行相应读或写的处理。
最老版本操作系统kernel使用select函数实现多路复用实现原理: 从select的系统调用开始: select函数的参数:第一个参数n = max + 1,即为最大文件描述符+ 1 (代表总共的bitmap连接的数量),
第二个参数是读文件描述符集合,
第三个参数是写文件描述符集合,
第四个参数是异常文件描述符集合,
第五个参数是超时时间。
实现方式:
在每次调用kernel#select函数的时候,都会涉及到用户态和内核态的切换,需要检查需要传递的select集合,其实就是需要检查fd集合中的就绪文件。这里的fd是文件系统中对应的socket生成的文件描述符,kernel#select函数被调用之后,首先会按照fd集合去检查内存中的socket套接字状态,复杂度O(N),检查完一遍后,如果没有就绪状态的socket,就需要阻塞当前调用线程(Thread.wait()),直到某个socket有数据之后,才唤醒线程。
select的缺点:1.fd_size 有限制 1024 bitmap ,fd[i] = accept()
2.fdset不可重用,新的fd进来,重新创建
3.用户态和内核态拷贝产生开销
4.O(n)时间复杂度的轮询
5.成功调用返回结果大于 0,出错返回结果为 -1,超时返回结果为 0,具有超时时间
6.fd_set是利用数组实现的
问题1:select函数去监听socket的时候,这个socket数量有没有限制呢?select 和 poll 非常的相似,select默认监听的最大文件数量是1024个,因为fd_set的结构是一个bitmap位图结构(fd_set是select函数中的参数之一),这个bitmap默认长度是1024个bit,修改的话需要重新编译内核。select函数检查到就绪状态的socket后做了两件事情:
- 在就绪状态的socket中的对应的file descriptor中设置一个标记。
- 返回select函数,唤醒java线程,在java层面,它会收到一个int结果值,表示有几个socket处于就绪状态,具体是哪个socket就绪,java程序不清楚,然后是一个O(N)系统调用,检查fd_set集合中的每一个socket的就绪状态,涉及到用户态-内核态的切换,系统调用涉及到了参数的数据拷贝,如果select默认监听的文件数量太庞大,则系统性能不好。
select/poll实现的方式不一样,poll使用数据结构让socket连接突破1024,select/poll能监听多个设备的文件描述符,只要有任何一个设备满足条件,select/poll就会返回,否则将进行睡眠等待。看起来,select/poll像是一个管家了,统一负责来监听处理了。
1.4 问题2:如果kernel#select函数第一遍O(N)去检查时未发现就绪状态的socket,那么会一直占着cpu资源去轮询检查socket吗?直到有一个socket连接就绪。知识铺垫:
操作系统调度
假设有n个进程,让n个进程在cpu上切换执行,未挂起的进程都在工作队列中,都有机会获得cpu的执行权,挂起的进程,就会从这个工作队列中移除出去,就是java层面的线程阻塞。linux系统线程就是轻量级进程。
操作系统中断
让cpu正在执行的进程先保留程序上下文,然后避让出cpu,给中断程序让道。中断程序拿到cpu执行权限,进行相应的代码执行。
一些中断的知识:
(1) 中断是为了实现多道程序并发执行而引入的一种技术。 (2) 中断的本质就是发生中断时需要操作系统介入开展管理工作。 (3) 发生CPU会立即进入核心态,针对不同的中断信号,采取不同的处理方式。 (4) 中断是CPU从用户态进入核心态的唯一途径。 (5) 中断分为内中断和外中断。 (6) 进程中断时,操作系统会保存CPU的运行环境,如程序状态字(PSW)、程序计数器、各种通用寄存器,这是为了当进程再次运行时可以从中断的状态处继续运行。
问题回答:
kernel#select在第一遍轮询中,没有发现就绪状态下的socket,就会把当前进程放入给需要检查的socket的等待队列中,socket有三个核心区域:读缓存,写缓存,还有等待队列。
假设客户端向服务端发送了数据,数据通过网线到网卡,网卡再到DMA硬件直接将数据写到内存,此时cpu不参与工作,当数据完成传输后,会触发网络数据传输完毕的中断程序,然后cpu就会执行中断程序的逻辑,根据内存中的数据包,分析出数据包是哪个socket的数据包,根据数据包中的TCP/IP协议中的端口号就能找到对应的socket实例,然后将数据导入到socket的读缓冲区里面。导入完成之后去检查socket的等待队列,是不是有等待者,然后把等待者移动到工作队列中,执行完中断程序,然后进程又回归到工作队列,又有机会获取到cpu时间片了,然后当前进程执行select函数进行检查,就会发现有就绪的socket,然后给就绪的socket的fd文件描述符打上标记,设计到用户态,内核态的切换,然后处理打过标记的socket就行了。
(2)pollman poll
poll() performs a similar task to select(2): it waits for one of a set of file descriptors to become ready to perform I/O.
问题:select 与 poll 的区别
- 实现的方式不一样,poll使用数组让socket连接突破1024。
- 传参不一样,select使用的是bitmap,poll使用的数组,表示需要检查的socket集合。
man epoll
The epoll API performs a similar task to poll(2): monitoring multiple file descriptors to see if I/O is possible on any of them. The epoll API can be used either as an edge-triggered or a level-triggered interface and scales well to large numbers of watched file descriptors. The following system calls are provided to create and manage an epoll instance:
* epoll_create(2) creates an epoll instance and returns a file descriptor referring to that
instance. (The more recent epoll_create1(2) extends the functionality of epoll_create(2).)
* Interest in particular file descriptors is then registered via epoll_ctl(2). The set of file
descriptors currently registered on an epoll instance is sometimes called an epoll set.
* epoll_wait(2) waits for I/O events, blocking the calling thread if no events are currently
available.
epoll不需要轮询,时间复杂度为O(1)
select和poll函数中的缺陷:这个两个函数每次调用都需要我们给它提供所有需要监听的socket文件描述符集合,而且程序主线程是通过死循环调用select 和 poll函数的,涉及到用户空间数据到内核空间拷贝的过程,耗费性能,可能每次只有1-2个socket_fd文件需要修改,然后就需要遍历整个集合,在kernel层面不会保留任何数据信息,每次调用都需要拷贝,select和poll函数的返回值是一个int整型,只能代表有几个socket就绪或者错误,没办法判断是哪个socket就绪,导致程序在唤醒后,还需要新一轮的系统调用去检查哪个socket是就绪状态。
epoll主要解决两个问题:
一是函数调用参数拷贝问题。
二是系统调用后不知道那些socket调用就绪的问题。
为了解决这两个问题,epoll函数在内核空间中,有个对应的数据结构(eventpoll)去存储一些数据,它是可以通过一个系统函数epoll_create() 去创建,然后返回一个eventpoll对象的id(eventpoll 的文件号)。
eventpoll的数据结构主要分为两块重要的区域:
另一块存储需要监听的socket_fd的描述符列表(epoll_ctl ()监控)
一块存储就绪列表,存放着就绪状态的socket信息(epoll_wait()监控)
epoll_ctl()函数可以根据eventpoll-id去增删改内核空间上的eventpoll对象的检查列表(即监控的socket信息),增加或者修改需要检查的socket文件描述符,已注册的描述符在内核中会被维护在一棵红黑树上。
epoll_wait 传入的参数主要是eventpoll-id,主要用于监听需要监测的socket_fd集合,默认情况下会阻塞调用线程,直到eventpoll中关联的某个socket就绪后,才会返回。
问题:eventpoll 一块存储就绪列表,存放着就绪状态的socket信息,另一块存储需要监听的socket_fd的描述符列表,存放着需要监控的socket描述符的区域使用epoll_ctl 去维护,那就绪列表是怎么维护呢?
socket有三个核心区域:读缓存,写缓存,还有等待队列,select函数调用的时候会把当前调用进程从工作队列中拿出来,然后把进程引用追加到当前进程关注的每一个socket对象的等待队列中,当socket连接的客户端发送完成数据后,数据还是通过硬件DMA的方式把数据写入内存,然后相应的硬件会向cpu发出中断信号,cpu让出位置去执行网络数据中就绪的中断程序,中断程序把内存中的网络数据写入到对应的socket的读缓存区里面,把这个socket等待队列中的进程全部移动到工作队列里面,然后select函数就返回。
当我们使用系统函数epoll_ctl时,比如新添加一个需要关注的socket,当socket连接的客户端发送完成数据后,还是会触发中断程序,中断程序把内存中的网络数据写入到对应的socket的读缓存区里面,然后它发现这个socket等待队列中等待的不是进程,是一个eventpoll对象引用,他就根据这个eventpoll引用,将当前socket引用追加到eventpoll的就绪链表的我末尾,这个等待队列保存就是调用epoll_wait()的进程,检查eventpoll对象的等待队列,如果有进程,就会把进程转移到工作队列中。也就是说eventpoll对象的等待队列里面有进程,然后把这个进程,从eventpoll#等待队列里面迁移到工作队列。
问题:epoll_wait()返回值 0 就是没有就绪的socket,大于0 就是有几个,-1表示异常,也没有表示出来哪个socket是就绪的?
对象的等待队列,如果有进程,就会把进程转移到工作队列中。也就是说eventpoll对象的等待队列里面有进程,然后把这个进程,从eventpoll#等待队列里面迁移到工作队列。
问题:epoll_wait()返回值 0 就是没有就绪的socket,大于0 就是有几个,-1表示异常,也没有表示出来哪个socket是就绪的?
epoll_wait函数在正常返回之前就会把socket事件信息拷贝到这个数组的指针里面。



