1. 同步I/O
在操作系统中,程序运行的空间分为内核空间和用户空间, 用户空间所有对io操作的代码(如文件的读写、socket的收发等)都会通过系统调用进入内核空间完成实际的操作。
而CPU的速度远远快于硬盘、网络等I/O。在一个线程中,CPU执行代码的速度极快,然而,一旦遇到I/O操作,如读写文件、发送网络数据时,就需要等待 I/O 操作完成,才能继续进行下一步操作,这种情况称为同步 I/O。
在某个应用程序运行时,假设需要读写某个文件,此时就发生了 I/O 操作,在I/O操作的过程中,系统会将当前线程挂起,而其他需要CPU执行的代码就无法被当前线程执行了,这就是同步I/O操作,因为一个I/O操作就阻塞了当前线程,导致其他代码无法执行,所以可以使用 多线程或者多进程 来并发执行代码,即当某个线程/进程被挂起后,不会影响到其他线程或进程。
多线程和多进程虽然解决了这种并发的问题,但是系统不能无上限地增加线程/进程。 由于系统切换线程/进程的开销也很大,所以,一旦线程/进程数量过多, CPU的时间就花在线程/进程切换上了,真正运行代码的时间就少了, 这样会导致系统性能严重下降。
2. 异步I/O
当程序需要对I/O进行操作时,它只发出I/O操作的指令,并不等待I/O操作的结果, 然后就去执行其他代码了。一段时间后,当I/O返回结果时,再通知CPU进行处理。 这样子用户空间中的程序不需要等待内核空间中的 I/O 完成实际操作,就可执行其他任务,提高了CPU的利用率。
简单来说就是,用户不需要等待内核完成实际对I/O的读写操作就直接返回了。
3. 阻塞I/O
在linux中,默认情况下所有的socket都是阻塞的,一个典型的读操作流程大致如下:
当用户进程调用了 read()/recvfrom() 等系统调用函数,它会进入内核空间中,当这个网络I/O没有数据的时候,内核就要等待数据的到来,而在用户进程这边,整个进程会被阻塞,直到内核空间返回数据。
当内核空间的数据准备好了,它就会将数据从内核空间中拷贝到用户空间,此时用户进程才解除阻塞的的状态,重新运行起来。
所以,阻塞I/O的特点就是在I/O执行的两个阶段(用户空间与内核空间)都被阻塞了。
4. 非阻塞I/O
linux下,可以通过设置socket使其变为非阻塞模式,这种情况下,当内核空间并无数据的时候,它会马上返回结果而不会阻塞,此时用户进程可以根据这个结果自由配置,比如继续请求数据,或者不再继续请求。当对一个非阻塞socket执行读操作时,流程是这个样子:
当用户进程调用 read()/recvfrom() 等系统调用函数时,如果内核空间中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个error。
对于应用进程来说,它发起一个 read() 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道内核中的数据还没有准备好,那么它可以再次调用 read()/recvfrom() 等函数。
当内核空间的数据准备好了,它就会将数据从内核空间中拷贝到用户空间,此时用户进程也就得到了数据。
所以,非阻塞I/O的特点是用户进程需要不断的 主动询问 内核空间的数据准备好了没有。
5. 多路复用I/O
多路复用I/O就是我们说的 select,poll,epoll 等操作,复用的好处就在于 单个进程 就可以同时处理 多个 网络连接的I/O,能实现这种功能的原理就是 select、poll、epoll 等函数会不断的 轮询 它们所负责的所有 socket ,当某个 socket 有数据到达了,就通知用户进程。
一般来说I/O复用多用于以下情况:
- 3. 服务器既要处理监听套接口,又要处理已连接套接口时
- 4. 如果一个服务器即要处理TCP,又要处理UDP时
与多进程和多线程技术相比, I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程 ,从而大大减小了系统的开销。
但select,poll,epoll本质上都是同步I/O,因为他们都需要 在读写事件就绪后自己负责进行读写 ,也就是说这个读写过程是 阻塞 的,而 异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间 。
5.1 select
5.1.1 select工作流程
select机制会监听它所负责的所有socket,当其中一个socket或者多个socket可读或者可写的时候,它就会返回,而如果所有的socket都是不可读或者不可写的时候,这个进程就会被阻塞,直到超时或者socket可读写,当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
- 1. 用户进程调用
select() 函数,如果当前没有可读写的socket,则用户进程进入阻塞状态。 - 2. 对于内核空间来说,它会从用户空间拷贝
fd_set 到内核空间,然后在内核中遍历一遍所有的socket描述符,如果没有满足条件的socket描述符,内核将进行休眠,当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的内核进程,即在socket可读写时唤醒,或者在超时后唤醒。 - 3. 返回
select() 函数的调用结果给用户进程,返回就绪 socket 描述符的数目,超时返回0,出错返回-1。 - 4. 在
select() 函数返回后还是需要轮询去找到就绪的 socket 描述符的,此时用户进程才可以去操作 socket 。
对内核来说,整个处理的流程如下:
5.1.2 select函数原型:
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
参数说明:
- •
maxfdp1:指定感兴趣的socket描述符个数,它的值是套接字最大socket描述符加1,socket描述符0、1、2 … maxfdp1-1均将被设置为感兴趣(即会查看他们是否可读、可写)。 - •
readset:指定这个socket描述符是可读的时候才返回。 - •
writeset:指定这个socket描述符是可写的时候才返回。 - •
exceptset:指定这个socket描述符是异常条件时候才返回。 - •
timeout:指定了超时的时间,当超时了也会返回。
如果对某一个的条件不感兴趣,就可以把它设为空指针。
返回值:就绪 socket 描述符的数目,超时返回0,出错返回-1。
5.1.3 select缺点
- 1. 每次调用
select ,都需要把 fd 集合从用户态拷贝到内核态,在内核遍历传递进来的所有 fd ,这个开销在 fd 有很多时会很大。 - 2. 每次在
select() 函数返回后,都要通过遍历文件描述符来获取已经就绪的 socket 。 - 3. 单个进程能够监视的文件描述符的数量存在最大限制,
select 支持的文件描述符数量较少,默认是 1024 。
5.2 poll
poll 的实现和 select 非常相似,只是描述fd集合的方式不同, poll 使用 pollfd 结构而不是 select 的 fd_set 结构,poll不限制socket描述符的个数,因为它是使用链表维护这些socket描述符的,其他的都差不多和 select() 函数一样。
poll() 函数返回后,同样需要轮询 pollfd 来获取就绪的描述符,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。
poll 和 select 同样存在一个缺点:不论大量的文件描述符是否就绪,包含文件描述符的数组都会被整体复制于用户态和内核的地址空间之间,它的开销随着文件描述符数量的增加而线性增大。
函数原型:
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
5.3 epoll
5.3.1 epoll原理及底层实现
相对于 select 和 poll 来说, epoll 更加灵活,但是核心的原理都是当socket描述符就绪(可读、可写、出现异常)时通知应用进程,只是通知处理的方式不同而已。
epoll使用一个 epfd (epoll文件描述符)管理多个socket描述符,epoll不限制socket描述符的个数, 将用户空间的socket描述符的事件存放到内核的一个事件表中 ,这样在用户空间和内核空间的copy只需一次。当epoll记录的socket产生就绪的时候,epoll会通过callback的方式来激活这个fd,这样子在epoll_wait便可以收到通知,告知应用层哪个socket就绪了,这种通知的方式是可以直接得到哪个socket就绪的,因此相比于 select 和 poll ,它不需要遍历socket列表,时间复杂度是O(1),不会因为记录的socket增多而导致开销变大。
epoll 在内核中主要由三个关键数据结构组成:
- • 以
(struct file *, fd) 作为唯一键存储所有通过 epoll_ctl 注册的文件描述符
- • 双向链表(
struct list_head rdllist)存储已经就绪(有事件触发)的 fd - • 当
fd 上有事件发生时,对应的 epitem 会被链接到此处
- • 通过
ep_ptable_queue_proc 将 epoll 的回调函数注册到每个 fd 的等待队列中,当状态变化时回调 epoll
5.3.2 epoll操作模式
epoll对socket描述符的操作有两种模式: LT(level trigger)和ET(edge trigger) 。(LT模式是默认模式)
- •
LT模式:即水平触发模式,当epoll_wait检测到socket描述符处于就绪时就通知应用程序,应用程序可以不立即处理它。下次调用epoll_wait时,还会再次产生通知。 - •
ET模式:即边缘触发模式,当epoll_wait检测到socket描述符处于就绪时就通知应用程序,应用程序 必须 立即处理它。如果不处理,下次调用epoll_wait时,不会再次产生通知。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高 。
epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
5.3.3 epoll函数API
epoll 有 epoll_create()、epoll_ctl()、epoll_wait() 3个系统调用函数。
1. epoll_create
用于创建一个epoll的 epfd (epoll文件描述符,或者称之为句柄)
函数原型:
int epoll_create(int size);
当创建好epoll句柄后,它会占用一个fd值,必须调用close()关闭,否则可能导致fd被耗尽 。(epoll使用一个epfd管理多个socket描述符 )
size参数用来告诉内核这个监听的数目一共有多大,它其实是在内核申请一空间,用来存放用户想监听的socket fd上是否可读、可写或者其他异常,只要有足够的内存空间,size可以随意设置大小,1G的内存上能监听约10万个端口。
2. epoll_ctl
用于控制某个epoll文件描述符上的事件,可以注册事件,修改事件,以及删除事件。
函数原型:
typedefunion epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64;} epoll_data_t;struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */};int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
- •
epfd:由epoll_create()函数返回的epoll文件描述符(句柄)。 - •
EPOLL_CTL_ADD:注册要监听的目标socket描述符fd到epoll句柄中。 - •
EPOLL_CTL_MOD:修改epoll句柄已经注册的fd的监听事件。 - •
EPOLL_CTL_DEL:从epoll句柄删除已经注册的socket描述符。
- •
EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。 - •
EPOLLOUT:表示对应的文件描述符可以写。 - •
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。 - •
EPOLLERR:表示对应的文件描述符发生错误。 - •
EPOLLHUP:表示对应的文件描述符被挂断。 - •
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。 - •
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
3. epoll_wait
用于等待监听的事件的发生,类似于调用select()函数。
函数原型:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数:
- •
epfd:由epoll_create()函数返回的epoll文件描述符(句柄)。 - •
maxevents:告知内核这个events有多大,不能大于创建epoll_create()时的指定的size。
函数的返回值表示需要处理的事件数目,如返回0表示已超时。
5.3.4 epoll优势
- 1. 当调用
epoll_wait() 函数返回的不是实际的描述符,而是一个代表就绪描述符数量的值,这个时候需要去 epoll 指定的一个数组中依次取得相应数量的socket描述符即可,而不需要遍历扫描所有的socket描述符,因此这里的时间复杂度是O(1)。 - 2. 使用了内存映射(
mmap )技术,这样便彻底省掉了这些socket描述符在系统调用时拷贝的开销(因为从用户空间到内核空间需要拷贝操作)。mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和用户均可见,减少用户态和内核态之间的数据交换,不需要依赖拷贝,这样子内核可以直接看到epoll监听的socket描述符,效率极高。 - 3.
epoll 采用基于事件的就绪通知方式。在 select/poll 中,进程只有在调用一定的方法后,内核才会对所有监视的socket描述符进行扫描,而 epoll 事先通过 epoll_ctl() 来注册一个socket描述符,一旦检测到epoll管理的socket描述符就绪时,内核会采用类似 callback 的回调机制,迅速激活这个socket描述符,当进程调用 epoll_wait() 时便可以得到通知,也就是说epoll最大的优点就在于它 只管就绪的socket描述符,而跟socket描述符的总数无关 。
5.4 select、poll、epoll对比
| | |
|---|
| 检测方式 | | |
| 数据结构 | | |
| FD 限制 | | |
| 数据拷贝 | | |
| 时间复杂度 | | |
本文整理至[野火]《Linux基础与应用开发实战指南》,感兴趣的读者可以访问野火电子官网阅读。(图源同)