1.Unix五种IO模型
[1] blocking IO - 阻塞IO
[2] nonblocking IO - 非阻塞IO
[3] IO multiplexing - IO多路复用
[4] signal driven IO - 信号驱动IO
[5] asynchronous IO - 异步IO
2.用户空间 / 内核空间
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
3.进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的,并且进程切换是非常耗费资源的。
4.文件描述符
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
5.缓存I/O
缓存I/O又称为标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,操作系统会将I/O的数据缓存在文件系统的页缓存中,即数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
首先提出问题,Linux系统调用是如何完成一个I/O操作?
Linux系统将内存分为内核区和用户区,Linux内核给管理所有的硬件资源,应用程序通过系统调用与内核交互,达到使用硬件资源的目的。例如,应用程序通过系统调用read对文件描述符fd发起一个读操作,这时候内核通过驱动程序向硬件发送读指令,并将读到的数据放在这个fd对应结构体的缓存区中,但这个结构体是在内核内存区的,需要将这个数据读到用户区,这样就完成了一次读操作。
所以一个输入操作一般有两个阶段:
对于一个socket上的操作
第一步:一般是等待数据到达网络,当分组到达时,它被拷贝到内核中某个缓冲区。
第二步:将数据从内核缓冲区拷贝到应用程序缓冲区。
下面依次讲解Unix/Linux下可用的5种I/O模型:
最流行的i/o模型。缺省时,所有套接字都是阻塞的。阻塞,使进程被挂起而等待I/O的读写就绪。
应用程序调用一个I/O函数,导致应用程序阻塞,等待数据准备好。如果数据没有准备好,一直等待……数据准备好了,从内核拷贝到用户空间,IO函数成功指示。(为了便于理解模型,考虑 UDP 数据 报,因为 UDP 数据报比 TCP 数据报要简单一些,并且把 recvfrom 视为系统调用,下同)
当把一个套接字设置成非阻塞方式时,即通知内核:当请求的i/o操作不能完成时,不要进程睡眠,而应返回一个错误。这样我们的I/O操作函数将不断的测试数据是否已经准备好,如果没有准备好继续测试。(返回不成功指示)
使用这种IO模型,我们一般需要循环调用,我们称此过程为轮询(polling),应用进程连续不断的查询内核,看看某操作是否准备好,这对CPU是极大的浪费,但这种模型只是偶尔才遇到。
i/o复用模型一般调用select或poll实现,进程阻塞于这两个系统调用上,而不是阻塞于真正的i/o系统调用上。
这两个函数可以同时阻塞多个I/O操作,对多个I/O操作进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。
与阻塞i/o模型相比,由于使用了系统调用select,似乎比阻塞i/o还差。但select的好处在于可以等待多个描述字准备好。
I/O 复用并非限于网络编程,许多正是应用程序也需要使用这项技术。
让内核在描述字准备好时用信号SIGIO通知进程。这种模型的好处是当等待数据报到达时,可以不阻塞。前提是允许套接口进行信号驱动i/o 。
使用这种模型,首先我们允许socket进行信号驱动I/O,并通过系统调用sigaction安装一个信号处理程序。此系统调用立即返回,进程继续工作,它是非阻塞的。
当数据报准备好被读时,就为该进程生成个sigio信号。我们随即可以在信号处理程序中调用recvfrom来读取数据报,并通知主循环数据报已准备号被处理,也可以通知主循环,让它来处理数据报。
无论我们如何处理sigio信号,这种模型的好处是当等待数据报到达时,可以不阻塞。主循环可以继续执行,只是等待信号处理程序的通知:或者数据报已准备好被处理,或者数据报已准备好被读取。
异步i/o让内核启动操作,并在整个操作完成后(包括将数据从内核拷贝到应用进程的缓冲区)通知我们。
异步i/o让与信号驱动i/o的区别是:后者是由内核通知我们何时可以启动一个i/o操作,而前者是由内核通知我们i/o操作何时完成。
另外上述i/o模型中,前四个模型:阻塞i/o模型、非阻塞i/o模型、i/o复用模型和信号驱动模型都是同步i/o模型,因为真正的i/o操作(recvfrom)阻塞进程,只有异步i/o模型与此异步i/o定义相匹配。
学习了TCP和UDP的网络编程,我们理解了整个的一个编程过程及一些关键API的作用及使用。下面开始讲解IO多路复用。
学习着网络突然插入个IO多路复用,和网络有毛线的关联,蒙圈啊??为什么要用它?它又怎么用?同时在说相关联的三种机制,select ,poll,epoll。我们逐步深入,带你理清他们的产生过程。
假如现在要实现一个可以并发的网络服务器,那么我们该怎么写??前面我们说过使用多线程,多进程来实现并发,这些方法当然是可行的,但是并不是很理想,CPU的上下文切换是十分占用资源的。那么不用这种方式,一个进程要监控那么多的客户端,我们很容易想到的方法就是循环遍历每个客户端的状态。我们知道在Linux中一切皆文件,我们把每个客户端理解为一个小房子,我们轮询的去查看每个房子是不是有人,轮询一遍后,然后对有人的房子进行标记,然后再对有标记的进行处理。我们的I/O多路复用就是利用了这种思路。
先说一下IO多路复用的官方套话:通过一种机制(系统内核缓冲I/O数据),让单个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。
按照这种思路,我们在用户态下按照这种思路也是可以实现的,我们的内核在内核态下帮我们实现了这个逻辑并且封装成了函数,就是select函数。
那么我们只要知道怎么用这个函数,就能通过一个进程实现并发服务。
那么我们通过man select指令来详细的查看一下这个函数原型及参数含义。
select在监视文件描述符之前,我们还要对文件描述符进行设置,设置需要用到以下几个和select相关的宏定义:
void FD_ZERO(fd_set *fdset) //从fdset中清除所有的文件描述符
void FD_SET(int fd,fd_set *fdset) //将fd加入到fdset
void FD_CLR(int fd,fd_set *fdset) //将fd从fdset里清除
int FD_ISSET(int fd,fd_set *fdset) //判断fd是否在fdset集合中
select大概理解是什么意思了,那么到底怎么用,看一个实例我们就很清楚了,假如我们的客户端做一个类似qq的东西,此时我们要接收键盘输入的信息,进行显示,同时要接受客户端的信息,我们使用select进行处理。
如下所示 :第一部分忽略,就是创建及连接的过程,第二部分开始select的应用。
(1)首先创建了rset的这么一个集合.(bitmap 默认1024位,有1024个坑位,需要监听的fd位上置1,不监听的置0.有点像我们平时使用1个字节的不同位来表示多种状态值的操作)
(2)使用FD_ZERO清除所有的文件描述符,
(3)FD_SET将远程服务器的fd加入到集合中,
(4)select阻塞等待,这里设置的阻塞时间是5s.
(5)当服务器发过来数据后,通过FD_ISSET来进行判断是哪个fd触发了,然后分别进行处理。
这里键盘输入的fd我们没有添加,可以通过FD_SET添加更多的文件描述符到集合中。
首先我们定义了rset文件描述符集合,程序本身执行在用户态空间,select函数直接将用户态空间的rset拷贝到内核态,然后内核进行判断每个位是否有数据到来。(内核态进行监听效率提高)
当有数据到来的时候,内核将fd置位,1个或多个fd置位,select函数返回。
然后FD_ISSET,判断是哪个fd置位了。
形象的可以这么理解,有这么一个酒店,有1024个房间(rset),然后全部打扫干净后,准备开始营业了,此时来了5个客人,在前台录好信息,入住房间,这些人就归你这个酒店来监控管理了。早上的时候,服务员巡逻,有的客人出去了,将门口的牌子置为“请打扫房间”,然后服务员就告诉前台,有客人出去了要打扫房间,前台告诉扫地阿姨,然后扫地阿姨查看这5个房间哪个房间要扫,然后进行处理。
select所用的bitmap源码:
static __inline__ void __FD_SET(unsigned long fd, __kernel_fd_set *fdsetp)
{
unsigned long _tmp = fd / __NFDBITS;
unsigned long _rem = fd % __NFDBITS;
fdsetp->fds_bits[_tmp] |= (1UL<<_rem);
}
#define __NFDBITS (8 * sizeof(unsigned long))
typedef struct {
unsigned long fds_bits [__FDSET_LONGS];
} __kernel_fd_set;
#define __FDSET_LONGS (__FD_SETSIZE/__NFDBITS)
#define __FD_SETSIZE 1024
我们的select有几个弊端:
select是比较早(1984)实现的一种方法,有缺点,随着时间的推进,1997年实现了poll机制,优化了select的一些缺点。
poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll底层数据结构是pollfd结构体数组,pollfd结构体中revents的引入,每次只重置位revents,没走的客户就不用重录信息啦。
来看一下poll的实现。
events,用户需要输入事件,可赋值如下参数
(1)将需要监控的文件描述符放进fds数组中
(2)调用poll函数
(3)函数成功返回后根据返回值遍历fds数组,将关心的事件与结构体中的revents相与判断事件是否就绪。
(4)事件就绪执行相关操作。
poll机制的引入,解决了select的第一和第二个缺点,其他两个缺点仍然没有解决。
epoll,2002年实现,是为了提高效率并且解决上面提到的几个弊端。
epoll是Linux目前大规模网络并发程序开发的首选模型。
执行epoll_create会在内核的高速cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点。
epoll相关函数:
无论是select,poll还是epoll,它们都需要内核把fd消息通知给用户空间。因此,如何避免不必要的内存拷贝就很重要了。
epoll是通过内核与用户空间mmap同一块内存实现的。
mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址
不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见。
减少用户态和内核态之间的数据交换的复制开销。内核可以直接看到epoll监听的句柄,效率高。
回调机制:
epoll采用回调机制。在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。
epoll中当有事件触发,同样也需要置位fd,但是对置位的fd进行重新排序(统一放到就绪链表中),并且epoll_wait返回值会返回一共有多少个fd触发了事件。之前阿姨打扫房间,不知道几个客人走了,也不知道位置在哪,现在会告诉你走了3个人,并且将他们三个的房间都整理到了挨着的位置,此时阿姨的效率将大大提高了。时间复杂度变为了O(1)。
epoll会事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处不需要遍历文件描述符,而是通过监听回调的的机制)
epoll对文件描述符的操作有两种模式:水平触发LT(level trigger)和边缘触发ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
水平触发LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
边缘触发ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
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模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。
1、select 应用场景
select 的 timeout 参数精度为微秒,而 poll 和 epoll 为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。
select 可移植性更好,几乎被所有主流平台所支持。
2、 poll 应用场景
poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。
3、 epoll 应用场景
只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。
需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。
epoll并不是在所有的应用场景都会比select和poll高很多。尤其是当活动连接比较多的时候,回调函数被触发得过于频繁的时候,epoll的效率也会受到显著影响!所以,epoll特别适用于连接数量多,但活动连接较少的情况。
网络socket编程实现并发服务器——IO多路复用(理解其中的代码实现)
FD_ZERO(fd_set* fds) //清空集合
FD_SET(int fd, fd_set* fds) //将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds) //判断指定描述符是否在集合中
FD_CLR(int fd, fd_set* fds) //将给定的描述符从文件中删除
select()函数以及FD_ZERO、FD_SET、FD_CLR、FD_ISSET
(1)执行fd_set set; FD_ZERO(&set);则set用位表示是0000,0000。
(2)若fd=5,执行FD_SET(fd,&set);后set变为0001,0000(第5位置为1)
(3)若再加入fd=2,fd=1,则set变为0001,0011
(4)执行select(6,&set,0,0,0)阻塞等待( 第一个参数代表需要检查的文件描述字个数(即检查到fd_set的第几位),值是所监听的文件描述符中最大的一个加1(如在readset,writeset,exceptset中所含最大的fd为5,则nfds=6,因为fd是从0开始的)。设这个值是为提高效率,使函数不必检查fd_set的所有1024位。)
(5)若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011。注意:没有事件发生的fd=5被清空。
epoll经典代码示例
我们前面提到select支持的最大fd数量有限制(1024个),而poll/epoll等支持的最大fd数量没有限制。
对于这句话,我们还需要考虑系统的限制,系统单个进程最多能打开的文件描述符数量有限,因此虽然poll、epoll支持的数量没有限制,但最终可以打开的文件描述符数量是取决于系统单个进程能打开的fd数量。
可以理解为:
最大的fd数量=min( io复用支持的fd数量 , 系统单个进程最多能打开的fd数量)
1、 用户态将文件描述符传入内核的方式
2、内核态检测文件描述符读写状态的方式
3、找到就绪的文件描述符并传递给用户态的方式
select:将之前传入的fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
poll:将之前传入的fd数组拷贝传出用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
epoll:epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可。
这里返回的文件描述符是通过mmap让内核和用户空间共享同一块内存实现传递的,减少了不必要的拷贝。
4、重复监听的处理方式
对于效率问题,需要注意一点:在并发量低、监听的事件都比较活跃的情况下,select/poll的效率就不见得比epoll慢多少了。
同时需要注意数据结构的区别
select
epoll