Unix IO模型, Linux IO多路复用 select poll epoll机制整理

Unix IO模型

Blocking IO - 阻塞IO
NoneBlocking IO - 非阻塞IO
IO multiplexing - IO多路复用
signal driven IO - 信号驱动IO
asynchronous IO - 异步IO
  • network IO涉及系统对象

    • application(调用这个IO的进程)
    • kernel(系统内核)
  • 交互过程

    • 阶段1:wait for data 等待数据准备(TCP/UDP包接受接收)
    • 阶段2: copy data from kernel to user 将数据从内核拷贝到用户进程中

Blocking IO - 阻塞IO

image

wait for data阶段与copy data from kernel to user阶段都是阻塞的。

NoneBlockingIO - 非阻塞IO

image

wait for data阶段非阻塞,用户进程发出recvfrom系统调用后,kernel中的数据如果没准备好,立即返回一个error。采用循环check的方式,每隔一段时间再次发送recvfrom。

copy data from kernel to user阶段都是阻塞的,一旦kernel中的数据准备好了,那么它马上就将数据拷贝到了用户内存,然后返回。

IO multiplexing - IO多路复用(select为例子)

image

与blocking I/O相似度很高,但是可以等待多个数据报就绪(datagram ready),单个process可以同时处理多个连接。核空间内select会监听指定的多个datagram (如socket连接),如果其中任意一个数据就绪了就返回。此时程序再进行数据读取操作,将数据拷贝至当前进程内。

wait for data阶段是阻塞的(select调用阻塞,而非I/O阻塞),select底层通过轮询机制来判断每个socket读写是否就绪。

copy data from kernel to user阶段都是阻塞的。

IO多路复用的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

asynchronous IO - 异步IO

image

aio_read(读取操作)会通知内核进行读取操作并将数据拷贝至进程中,该操作会立刻返回,程序可以进行其它的操作,所有的读取、拷贝工作都由内核去做,做完以后通知进程,进程调用绑定的回调函数来处理数据。

对比

image

同步-异步-阻塞-非阻塞

同步-异步

关注点为消息通信机制。

同步:发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。

异步:发出一个调用后,这个调用就直接返回了,被调用方通过状态、通知来通知调用者,或通过回调函数处理这个调用。

阻塞与非阻塞

关注点是程序在等待调用结果(消息,返回值)时的状态。

阻塞调用:调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。

非阻塞调用:在不能立刻得到结果之前,该调用不会阻塞当前线程。会以另一种方式去check。

操作系统相关

用户空间与内核空间

现在操作系统采用虚拟存储器,对于32位操作系统的寻址空间(虚拟存储空间)为4G(2的32次方)。操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。对于linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

进程切换

内核挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。

进程切换:

1. 保存处理机上下文,包括程序计数器和其他寄存器。
2. 更新PCB信息。
3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
4. 选择另一个进程执行,并更新其PCB。
5. 更新内存管理的数据结构。
6. 恢复处理机上下文。

进程阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。

文件描述符fd

文件描述符(File descriptor)是一个用于表述指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。

缓存 I/O

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

数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作会带来 CPU 以及内存开销。

select、poll与epoll

I/O多路复用就是通过一种机制,可以将想要监视的文件描述符(File Descriptor)添加到select/poll/epoll函数中,由内核监视,函数阻塞。一个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,需要在读写事件就绪后自己负责进行(阻塞)读写。

select

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()的机制中提供一种fd_set的数据结构(long类型的数组),每一个数组元素都能与一打开的文件句柄建立联系,建立联系的工作由程序完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一socket准备就绪。

缺陷:

  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,在fd很多时开销会很大
  • 单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为32位机器默认为1024个,64位机器默认为2048
  • select需要在返回后,通过遍历文件描述符来获取已经就绪的流(因为无法区分哪几个流可读/可写)。同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降

poll

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

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

poll使用一个 pollfd的指针实现。

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

poll改变了文件描述符集合的描述方式,使用了pollfd结构而不是select的fd_set结构,基于链表来存储,没有最大连接数的限制,解决了select文件描述符的数量限制问题。

缺陷

存在和select一样的其他缺陷

epoll

select和poll的增强版本。没有描述符限制,epoll使用一个文件描述符管理多个描述符,使用一个文件描述符管理多个描述符,将关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

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

  • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

 - epfd:是epoll_create()的返回值,表示epoll句柄。
 - op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和改对fd的监听事件。
 - fd:是需要监听的fd(文件描述符)
 - epoll_event:内核需要监的事件

结构如下

struct epoll_event {
    __uint32_t events;  /* Epoll events */
    epoll_data_t data;  /* User data variable */
};

typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;

//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);

等待事件的产生,类似于select()调用,最多返回maxevents个事件。 参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

- epfd 是epoll句柄
- events 表示从内核得到的就绪事件集合
- maxevents 告诉内核events的大小
- timeout 表示等待的超时事件
  • 工作模式

    • 水平触发(LT,Level Trigger)模式下,只要一个文件描述符就绪,就会触发通知,如果用户程序没有一次性把数据读写完,下次还会通知;
    • 边缘触发(ET,Edge Trigger)模式下,当描述符从未就绪变为就绪时通知一次,之后不会再通知,直到再次从未就绪变为就绪(缓冲区从不可读/写变为可读/写)。

区别:边缘触发效率更高,减少了被重复触发的次数,函数不会返回大量用户程序可能不需要的文件描述符。 为什么边缘触发一定要用非阻塞(non-block)IO:避免由于一个描述符的阻塞读/阻塞写操作让处理其它描述符的任务出现饥饿状态。

select·poll·epoll对比

image

  • select:将文件描述符放入一个集合中,调用select时,将这个集合从用户空间拷贝到内核空间,由内核根据就绪状态修改该集合的内容。集合大小有限制,32位机默认是1024(64位:2048);采用水平触发机制。select函数返回后,通过遍历这个集合,找到就绪的文件描述符
  • poll:和select几乎没有区别,区别在于文件描述符的存储方式不同,poll采用链表的方式存储,没有最大存储数量的限制;
  • epoll:通过内核和用户空间共享内存,避免了频繁内存从用户-内核空间拷贝的问题;支持的同时连接数上限很高(1G左右的内存支持10W左右的连接数);文件描述符就绪时,采用回调机制,避免了轮询(回调函数将就绪的描述符添加到一个链表中,执行epoll_wait时,返回这个链表);支持水平触发和边缘触发,采用边缘触发机制时,只有活跃的描述符才会触发回调函数。