介绍
Linux 的内核将所有外部设备都看做一个文件来操作(一切皆文件),对一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor(fd,文件描述符)。而对一个socket的读写也会有响应的描述符,称为socket fd(socket文件描述符),描述符就是一个数字,指向内核中的一个结构体(文件路径,数据区等一些属性)。
根据UNIX网络编程对I/O模型的分类,UNIX提供了5种I/O模型。
阻塞I/O模型
最常用的I/O模型,默认情况下,所有文件操作都是阻塞的。
比如I/O模型下的套接字接口:在应用进程空间中调用recvfrom
,其系统调用直到数据包到达且被复制到应用进程的缓冲区中或者发生错误时才返回,在此期间一直等待。
进程在调用recvfrom
开始到它返回的整段时间内都是被阻塞的,所以叫阻塞I/O模型。
在这个IO模型中,用户空间的应用程序执行一个系统调用(
recvform
),这会导致应用程序阻塞,什么也不干,直到数据准备好,并且将数据从内核复制到用户进程,最后进程再处理数据,在等待数据到处理数据的两个阶段,整个进程都被阻塞。不能处理别的网络IO
。直到kernel
返回结果,用户进程才解除block
的状态,重新运行起来。
非阻塞I/O模型
recvfrom
从应用层到内核的时候,就直接返回一个EWOULDBLOCK
错误,一般都对非阻塞I/O模型
进行轮询检查这个状态,看内核是不是有数据到来。
非阻塞式IO
并不让进程睡眠,而是在数据报没有准备好的时候由内核立刻返回一个错误。
当一个进程循环调用非阻塞式IO
等待数据报时,我们称之为轮询。这样做会耗费大量的CPU时间,通常只在专门提供某一功能的系统中才会用到。
在这种模式下,用户进程发出请求后,并不会阻塞,内核会里面返回一个
error(EWOULDBLOCK)
状态,然后用户进程需要轮询不断的check
状态,在轮询期间可以干点别的事,最终直到内核把数据准备好了,然后通知用户进程,把数据从内核空间拷贝到用户所在的进程进行处理。
I/O复用模型
Linux
提供select/poll
,进程通过将一个或多个fd传递给select或poll
系统调用,阻塞在select
操作上,这样,select/poll
可以帮我们侦测多个fd是否处于就绪状态。
select/poll
是顺序扫描fd是否就绪,而且支持的fd数量有限,因此它的使用受到了一些制约。
Linux
还提供一个epoll
系统调用,epoll
使用基于事件驱动方式代替顺序扫描,因此性能更高。当有fd就绪时,立即回调函数rollback
。
IO复用
的系统调用有select和poll
。系统阻塞在这两个系统调用上,而不是阻塞在真正的IO系统调用上。select
调用等待数据报套接字变为可读,然后调用真正的IO系统调用去进行IO操作,将所读的数据写入应用程序缓冲区中。
使用 IO 复用模型
的好处在于可以同时等待多个描述符就绪,甚至可以实现复杂的等待条件。
等待多个描述符的另一种实现是创建多个线程,每个线程使用一个阻塞式IO系统调用去等待一个描述符。
IO多路复用
,指的是由转门的一个进程负责轮询检查IO操作的状态,而不用每个用户进程都得自己负责轮询,这样就大大节省了线程资源。那么这就是所谓的IO 多路复用
。UNIX/Linux
下的 select、poll、epoll
就是干这个的(epoll 比 poll、select 效率高,做的事情是一样的
)。它的基本原理就是select,poll,epoll
这个function
会不断的轮询所负责的所有socket
,当某个socket
有数据到达了,就通知用户进程。
当用户进程调用了select
,那么整个进程会被block
,而同时,kernel会
监视所有select
负责的socket
,当任何一个socket
中的数据准备好了,select
就会返回。这个时候用户进程再调用read
操作,将数据从kernel拷贝到用户进程。 多路复用的特点是通过一种机制一个进程能同时等待IO文件描述符,内核监视这些文件描述符(套接字描述符),其中的任意一个进入读就绪状态,select, poll,epoll
函数就可以返回
select
kernel会
监视所有select
负责的socket
,当任何一个socket
中的数据准备好了,select
就会返回。这个时候用户进程再调用read
操作,将数据从kernel
拷贝到用户进程。
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select
函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds
。调用后select函数会阻塞,直到有描述副就绪(有数据可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024。
poll
poll使用一个 pollfd的指针实现。
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
pollfd结构包含了要监视的event和发生的event
struct pollfd {
int fd;
// file descriptor short events;
// requested events to watch short revents;
// returned events witnessed
};
poll和select
函数一样,poll
返回后,需要遍历pollfd来获取就绪的描述符。poll
没有监听最大数量限制。
epoll
epoll
使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,采用监听回调的机制,这样在用户空间和内核空间的copy只需一次,避免再次遍历就绪的文件描述符列表。
epoll
的操作过程需要三个接口:
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);
int epoll_create(int size)
;创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
对指定描述符fd执行op操作。
epfd:是epoll_create()的返回值。
op:表示op操作,用三个宏来表示:添加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队列里
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout):
等待epfd上的io事件,最多返回maxevents个事件
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,
这个maxevents的值不能大于创建epoll_create()时的size,
参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。
该函数返回需要处理的事件数目,如返回0表示已超时。
epoll的两种工作模式
- LT(level trigger,水平触发)模式:当epoll_wait检测到描述符就绪,将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。LT模式是默认的工作模式。LT模式同时支持阻塞和非阻塞socket。
- ET(edge trigger,边缘触发)模式:当epoll_wait检测到描述符就绪,将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
- ET是高速工作方式,只支持非阻塞socket。ET模式减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
select、poll、epoll 区别总结:
1、支持一个进程所能打开的最大连接数
模型 | 描述 |
---|---|
select | 单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。 |
poll | poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的 |
epoll | 虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接 |
2、FD剧增后带来的IO效率问题
模型 | 描述 |
---|---|
select | 因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。 |
poll | 同上 |
epoll | 因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。 |
3、 消息传递方式
模型 | 描述 |
---|---|
select | 内核需要将消息传递到用户空间,都需要内核拷贝动作 |
poll | 同上 |
epoll | epoll通过内核和用户空间共享一块内存来实现的。 |
信号驱动I/O模型
首先开启套接口信号驱动I/O功能,并通过系统调用sigaction
执行一个信号处理函数(此系统调用立即返回,进程继续工作,非阻塞
)。当数据准备就绪时,就为改进程生成一个SIGIO
信号,通过信号回调通知应用程序调用recvfrom
来读取数据,并通知主循环函数处理树立。
异步I/O
告知内核启动某个操作,并让内核在整个操作完成后(包括数据的复制)通知进程。
信号驱动I/O模型通知的是何时可以开始一个I/O操作,异步I/O模型有内核通知I/O操作何时已经完成。
相对于同步IO,异步IO不是顺序执行。用户进程进行
aio_read
系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket
数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段,进程都是非阻塞的。
总结
通过上面的图片,可以发现
non-blocking IO和asynchronous IO
的区别还是很明显的。在non-blocking IO
中,虽然进程大部分时间都不会被block
,但是它仍然要求进程去主动的check
,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom
来将数据拷贝到用户内存。而asynchronous IO
则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel
)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。