目录
目录
大多数文件系统的默认 IO 操作都是缓存 IO。其读写过程如下:
level-triggered and edge-triggered
网络IO
什么是IO
在Linux的世界,一切皆是文件,所谓的文件就是一串二进制流,像Socket、FIFO、管道,都是数据流。
在信息交换数据读写的过程中需要进行的操作,我们称为IO操作。IO有内存IO、网络IO和磁盘IO三种,一般我们说的IO指的是后两种。
为什么需要IO模型
如果使用同步的方式来通信的话,所有的操作都在一个线程内顺序执行完成,这么做缺点是很明显的:
因为同步的通信操作会阻塞同一个线程的其他任何操作,只有这个操作完成了之后,后续的操作才可以完成,所以出现了同步阻塞+多线程(每个Socket都创建一个线程对应),但是系统内线程数量是有限制的,同时线程切换很浪费时间,适合Socket少的情况。因该需要出现IO模型。
Linux的IO模型
Linux系统数据读取的过程:数据从磁盘中通过DMA(DMA,全称Direct Memory Access,即直接存储器访问)copy数据到内核空间中的内核缓冲区,再调用read函数发送到用户空间中的用户缓冲区,而数据存储过程则相反,在用户进程通过触发内核空间中的write函数将数据写到内核Socket缓冲区,再通过DMA将数据拷贝到磁盘。
用户空间和内核空间
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全,用户进程不能直接操作内核,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行,这种行为被称为进程切换。
进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
文件描述符
文件描述符(File Descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数,实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。
当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
缓存IO
大多数文件系统的默认 IO 操作都是缓存 IO。
其读写过程如下:
- 读操作:操作系统检查内核的缓冲区有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回;否则从磁盘、网卡等中读取,然后缓存在操作系统的缓存中;
- 写操作:将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘、网卡等中由操作系统决定,除非显示地调用了 sync 同步命令。
假设内核空间缓存无需要的数据,用户进程从磁盘或网络读数据分两个阶段:
- 阶段一: 内核程序从磁盘、网卡等读取数据到内核空间缓存区;
- 阶段二: 用户程序从内核空间缓存拷贝数据到用户空间。
缓存IO的缺点:
数据在传输过程中需要在应用程序地址空间和内核空间进行多次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存开销非常大。
同步阻塞
用户空间的应用程序执行一个系统调用,这会导致应用程序阻塞,什么也不干,直到数据准备好,并且将数据从内核复制到用户进程,最后进程再处理数据,在等待数据到处理数据的两个阶段,整个进程都被阻塞,不能处理别的网络IO。
- 调用应用程序处于一种不再消费 CPU 而只是简单等待响应的状态,因此从处理的角度来看,这是非常有效的。
这也是最简单的IO模型,在通常FD较少、就绪很快的情况下使用是没有问题的。
同步非阻塞
非阻塞的系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。
- 进程在返回之后,可以干点别的事情,然后再发起系统调用。
- 重复上面的过程,循环往复的进行系统调用。这个过程通常被称之为轮询。
- 轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。
- 需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
- 这种方式在编程中对Socket设置O_NONBLOCK即可。
信号驱动IO
信号驱动是利用信号机制,让内核告知应用程序文件描述符的相关事件。
但信号驱动IO在网络编程的时候通常很少用到,因为在网络环境中,和socket相关的读写事件太多了,比如下面的事件都会导致SIGIO信号的产生:
- TCP连接建立
- 一方断开TCP连接请求
- 断开TCP连接请求完成
- TCP连接半关闭
- 数据到达TCP socket
- 数据已经发送出去(如:写buffer有空余空间)
上面所有的这些都会产生SIGIO信号,但我们没办法在SIGIO对应的信号处理函数中区分上述不同的事件,SIGIO只应该在IO事件单一情况下使用,比如说用来监听端口的socket,因为只有客户端发起新连接的时候才会产生SIGIO信号。
异步IO
异步IO和信号驱动IO差不多,但它比信号驱动IO可以多做一步:相比信号驱动IO需要在程序中完成数据从用户态到内核态(或反方向)的拷贝,异步IO可以把拷贝这一步也帮我们完成之后才通知应用程序。
同步IO vs 异步IO
1. 同步IO指的是程序会一直阻塞到IO操作如read、write完成
2. 异步IO指的是IO操作不会阻塞当前程序的继续执行
所以根据这个定义,上面阻塞IO当然算是同步的IO,非阻塞IO也是同步IO,因为当文件操作符可用时我们还是需要阻塞的读或写,同理IO多路复用和信号驱动IO也是同步IO,只有异步IO是完全完成了数据的拷贝之后才通知程序进行处理,没有阻塞的数据读写过程。
IO多路复用
IO多路复用,这是一种进程预先告知内核的能力,让内核发现进程指定的一个或多个IO条件就绪了,就通知进程。
使得一个进程能在一连串的事件上等待。IO复用的实现方式目前主要有Select、Poll和Epoll。
select
相关函数定义如下:
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
select的调用会阻塞到有文件描述符可以进行IO操作或被信号打断或者超时才会返回。
select将监听的文件描述符分为三组,每一组监听不同的需要进行的IO操作。readfds是需要进行读操作的文件描述符,writefds是需要进行写操作的文件描述符,exceptfds是需要进行异常事件处理的文件描述符。这三个参数可以用NULL来表示对应的事件不需要监听。
当select返回时,每组文件描述符会被select过滤,只留下可以进行对应IO操作的文件描述符。
#FD_xx系列的函数是用来操作文件描述符组和文件描述符的关系。
#FD_ZERO用来清空文件描述符组。每次调用select前都需要清空一次。
fd_set writefds;
FD_ZERO(&writefds);
#FD_SET添加一个文件描述符到组中,FD_CLR对应将一个文件描述符移出组中
FD_SET(fd, &writefds);
FD_CLR(fd, &writefds);
#FD_ISSET检测一个文件描述符是否在组中,我们用这个来检测一次select调用之后有哪些文件描述符可以进行IO操作
if (FD_ISSET(fd, &readfds)){
/* fd可读 */
}
select可同时监听的文件描述符数量是通过FS_SETSIZE来限制的,在Linux系统中,该值为1024,当然我们可以增大这个值,但随着监听的文件描述符数量增加,select的效率会降低。
poll
相关函数定义:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
#include <signal.h>
#include <poll.h>
int ppoll(struct pollfd *fds, nfds_t nfds,
const struct timespec *tmo_p, const sigset_t *sigmask);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};
和select用三组文件描述符不同的是,poll只有一个pollfd数组,数组中的每个元素都表示一个需要监听IO操作事件的文件描述符。events参数是我们需要关心的事件,revents是所有内核监测到的事件。
epoll
相关函数定义如下:
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
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_pwait(int epfd, struct epoll_event *events,
int maxevents, int timeout,
const sigset_t *sigmask);
epoll_create、epoll_create1用于创建一个epoll实例,而epoll_ctl用于往epoll实例中增删改要监测的文件描述符,epoll_wait则用于阻塞的等待可以执行IO操作的文件描述符直到超时。
level-triggered and edge-triggered
这两种底层的事件通知机制通常被称为水平触发和边沿触发,真是翻译的词不达意,如果我来翻译,我会翻译成:状态持续通知和状态变化通知。
这两个概念来自电路,triggered代表电路激活,也就是有事件通知给程序,level-triggered表示只要有IO操作可以进行比如某个文件描述符有数据可读,每次调用epoll_wait都会返回以通知程序可以进行IO操作,edge-triggered表示只有在文件描述符状态发生变化时,调用epoll_wait才会返回,如果第一次没有全部读完该文件描述符的数据而且没有新数据写入,再次调用epoll_wait都不会有通知给到程序,因为文件描述符的状态没有变化。
select和poll都是状态持续通知的机制,且不可改变,只要文件描述符中有IO操作可以进行,那么select和poll都会返回以通知程序。而epoll两种通知机制可选。
不同IO多路复用方案优缺点
poll vs select
poll和select基本上是一样的,poll相比select好在如下几点:
- poll传参对用户更友好。比如不需要和select一样计算很多奇怪的参数比如nfds(值最大的文件描述符+1),再比如不需要分开三组传入参数。
- poll会比select性能稍好些,因为select是每个bit位都检测,假设有个值为1000的文件描述符,select会从第一位开始检测一直到第1000个bit位。但poll检测的是一个数组。
- select的时间参数在返回的时候各个系统的处理方式不统一,如果希望程序可移植性更好,需要每次调用select都初始化时间参数。
而select比poll好在下面几点
- 支持select的系统更多,兼容更强大,有一些unix系统不支持poll
- select提供精度更高(到microsecond)的超时时间,而poll只提供到毫秒的精度。
但总体而言 select和poll基本一致。
epoll vs poll&select
- 在需要同时监听的文件描述符数量增加时,select&poll是O(N)的复杂度,epoll是O(1),在N很小的情况下,差距不会特别大,但如果N很大的前提下,一次O(N)的循环可要比O(1)慢很多,所以高性能的网络服务器都会选择epoll进行IO多路复用。
- epoll内部用一个文件描述符挂载需要监听的文件描述符,这个epoll的文件描述符可以在多个线程/进程共享,所以epoll的使用场景要比select&poll要多。