参考资料:
写在开头:本文为个人学习笔记,内容比较随意,夹杂个人理解,如有错误,欢迎指正。
目录
前言
要讲解I/O模型我们搞懂几个概念。
Socket:中文翻译套接字,百度百科的说法是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象,简单来说就是客户端将数据通过网线发送到服务端,客户端发送数据需要一个出口,服务端接收数据需要一个入口,这两个“口子”就是 Socket。
fd:file descriptor,文件描述符,非负整数。“一切皆文件”,linux 中的一切资源都可以通过文件的方式访问和管理。而 FD 就类似文件的索引(符号、指针),指向某个资源,内核(kernel)利用 FD 来访问和管理资源。
在下文中,我们所说的socket与fd是划等号的,因为当我们调用内核函数创建 socket 后,内核返回给我们的是 socket 对应的文件描述符(fd),我们对 socket 的操作都是通过 fd 来进行的。
同步与异步:同步与异步是针对应用程序和内核的交互而言的,同步指的是用户进程触发I/O操作并等待或者轮询的去查看IO操作是否就绪,而异步是指用户进程触发IO操作以后便开始做自己的事情,而当IO操作已经完成的时候会得到IO完成的通知。
阻塞与非阻塞:阻塞与非阻塞是针对于进程在访问数据的时候,根据IO操作的就绪状态来采取不同的方式,简单来说就是一种读取或者写入操作方法的实现方式,阻塞方式下读取或者写入函数将一直等待,而非阻塞方式下,读取或写入方法会立即返回一个状态值(例如-1)。
正式由于同步、异步,阻塞非阻塞的不同实现方式,因此才有了多种不同的IO模型,需要注意的是,不能一概而论认为同步或阻塞就是低效,具体还要看应用和系统特征。
Unix IO 模型
对于一个网络 I/O 通信过程,比如网络数据读取,会涉及两个对象,一个是调用这个 I/O 操作的用户线程,另外一个就是操作系统内核。一个进程的地址空间分为用户空间和内核空间,用户线程不能直接访问内核空间。当用户线程发起 I/O 操作后,网络数据读取操作会经历两个步骤:
- 用户线程等待内核将数据从网卡拷贝到内核空间。
- 内核将数据从内核空间拷贝到用户空间。
各种 I/O 模型的区别就是:它们实现这两个步骤的方式是不一样的。
一、同步阻塞 I/O
用户线程发起 read 调用后就阻塞了,让出 CPU。内核等待网卡数据到来,把数据从网卡拷贝到内核空间,接着把数据拷贝到用户空间,再把用户线程叫醒。
大致流程如下:
(1)应用进程发起 read 系统调用
(2)应用进程阻塞等待数据就绪
(3)数据通过网络传输到达网卡,然后再到内核socket缓冲区,当数据被拷贝到内核 socket 缓冲区时,此时处于就绪状态
(4)将数据从内核拷贝到应用程序缓冲区,返回成功
注意,在阻塞的过程中,其它程序还可以执行,因此阻塞不意味着整个操作系统都被阻塞。因为其他程序还可以执行,因此不消耗 CPU 时间,这种模型的执行效率会比较高。
同步阻塞IO的缺点在于一次只能处理一个IO请求,在处理一个IO请求时,其他请求全部都阻塞。
当然,使用多线程可以解决这一问题,即我们通过开启多个线程的方式,来让并发的IO请求得到处理。但是这样又会引入新的问题。
线程资源是宝贵的,创建、切换、销毁都是耗费资源,而且也并不是所有的连接中的socket都是就绪状态,拿来处理IO请求并不合适。虽然可以通过线程池的技术来优化,但高并发环境下线程的数量肯定是跟不上并发的数量的,不可能每个连接都分配一个线程,这就造成了性能的瓶颈。
二、同步非阻塞 I/O
用户线程不断的发起 read 调用,数据没到内核空间时,内核会直接返回错误,应用程序不断轮询内核,每次都返回失败,直到数据到了内核空间。这一次 read 调用后,在等待数据从内核空间拷贝到用户空间这段时间里,线程还是阻塞的,等数据到了用户空间再把线程叫醒。由于 CPU 要处理更多的系统调用,因此这种模型是比较低效的。
大致流程如下:
(1)服务端调用 read,数据未就绪,内核返回-1
(2)服务端调用 read,数据未就绪,内核返回-1
(3)服务端调用 read,数据就绪
(4)将数据从内核拷贝到应用程序缓冲区,返回成功
这种模型提供了非阻塞调用的方式,从操作系统层面解决了阻塞问题。好处在于单个 socket 阻塞,不会影响到其他 socket。但是需要不断的遍历进行系统调用,有一定开销。
三、I/O 多路复用
用户线程的读取操作分成两步了,线程先发起 select 调用(IO多路复用有几种实现方式,这里仅以select为例介绍),目的是问内核数据准备好了吗?等内核把数据准备好了,用户线程再发起 read 调用。在等待数据从内核空间拷贝到用户空间这段时间里,线程还是阻塞的。那为什么叫 I/O 多路复用呢?因为一次 select 调用可以向内核查多个数据通道(Channel)的状态,所以叫多路复用。
大致流程如下:
(1)应用程序首先发起 select 系统调用,传入要监听的文件描述符集合
(2)内核遍历应用程序传入的 fd 集合,如果有就绪的 fd 则会对就绪的 fd 打标,然后返回
(3)应用程序遍历 fd 集合,找到就绪的 fd,进行相应的事件处理
IO多路复用的有点在于不需要每个 FD 都进行一次系统调用,解决了频繁的用户态内核态切换问题,当然它也有其缺点,但对应不同的实现方式缺点也不相同,这个我们下文会做详细介绍。
四、信号驱动 I/O
首先开启 Socket 的信号驱动 I/O 功能,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。信号驱动式 I/O 模型的优点是我们在数据报到达期间进程不会被阻塞,我们只要等待信号处理函数的通知即可
相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高。
五、异步 I/O
用户线程发起 read 调用的同时注册一个回调函数,read 立即返回,等内核将数据准备好后,再调用指定的回调函数完成处理。在这个过程中,用户线程一直没有阻塞。
以经典的收作业的例子总结以下上文中的几种模型:
同步阻塞:逐个收作业,先收A,再收B,接着是C、D,如果有一个学生还未做完,则你会等到他写完,然后才继续收下一个。
解析:这就是同步阻塞的特点,只要中间有一个未就绪,则你会被阻塞住,从而影响到后面的其他学生。
同步非阻塞:逐个收作业,先收A,再收B,接着是C、D,如果有一个学生还未做完,则你会跳过该学生,继续去收下一个。
解析:可以看到同步非阻塞相较于同步阻塞已经是更好的方案了,你不会因为某个学生未就绪而阻塞住,这样就可以减少对后续学生的影响。缺点是,你要不断的去询问那个未做完的学生好了没,这造成了系统调用的消耗。
IO多路复用:学生写完了作业会举手,然后你下去收作业(这里暂时忽略不同实现方式间的差异)。
解析:这个方案的好处在于你不用逐个的去进行系统调用,而是批量进行查询,一次监控多个学生。
上文有提到,IO多路复用存在不同的实现方式,下文就来介绍下。
I/O多路复用的方式
I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。实现方式有select,poll以及epoll。
以下代码及动图出自《I/O 多路复用解析》
一、select
select 允许应用程序监视一组文件描述符,等待一个或者多个描述符成为就绪状态,从而完成 I/O 操作。
/**
* 获取就绪事件
*
* @param nfds 3个监听集合的文件描述符最大值+1
* @param readfds 要监听的可读文件描述符集合
* @param writefds 要监听的可写文件描述符集合
* @param exceptfds 要监听的异常文件描述符集合
* @param timeval 本次调用的超时时间
* @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
*/
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
fd_set 使用数组实现,数组大小使用 FD_SETSIZE 定义,所以只能监听少于 FD_SETSIZE 数量的描述符(默认值1024)。有三种类型的描述符类型:readset、writeset、exceptset,分别对应读、写、异常条件的描述符集合。
timeout 为超时参数,调用 select 会一直阻塞直到有描述符的事件到达或者等待的时间超过 timeout。
成功调用返回结果大于 0(这里的返回结果即为继续事件数),出错返回结果为 -1,超时返回结果为 0。
select流程如下:
(1)用户空间发起 select 系统调用,将监听的 fd 集合从用户空间拷贝到内核空间
(2)内核遍历 fd 集合,检查数据是否就绪
(3)如果遍历一遍后发现没有 fd 就绪,则会将当前用户进程阻塞,让出 CPU 给其他进程
(4)当客户端将数据发送到服务端,进入内核后,会通过数据库包找到对应的socket
(5)socket 检查是否有阻塞等待的进程,如果有则唤醒该进程
(6)用户进程恢复运行后,会再遍历 fd 集合进行检查,此时它会检查到某些 fd 已经就绪了,它会给这些 fd 打上标记,然后结束阻塞,返回到用户空间
(7)用户空间知道有事件就绪,遍历 fd 集合,找到就绪的 fd,进行相应的事件处理,例如将数据从内核缓冲区拷贝到应用程序缓冲区,然后执行处理逻辑。
这里有一个难点,很多人有疑问,select返回的是一个int值,代表就绪的事件数量,用户进程是如何知道哪些事件就绪了的呢,这就需要理解下fd_set的复用效果。
fd_set 在 select 的整个调用过程中表达了两种不同的意思。在入参时,fd_set 表示应用程序要监听哪些 fd;在回参时,fd_set表示哪些 fd 已经就绪了。应用程序传入的 fd_set 其实是个位图,例如我们要监听 fd = 1、fd = 4,则传入 0000 0101,也就是 5。当内核处理完毕,将就绪的 fd 返回时,会将就绪的 fd 对应的位标记为1,然后覆盖掉入参的 fd_set,所以我们最终返回时的 fd_set 表示的是哪些 fd 是就绪的。
总结一下:select将 socket 是否就绪检查逻辑下沉到操作系统层面,避免大量系统调用。
好处:不需要每个 FD 都进行一次系统调用,解决了频繁的用户态内核态切换问题。缺点则在于
缺点:
- 监听的 FD的数量存在限制,默认1024,虽然可以通过宏定义修改
- 每次调用需要将 FD 从用户态拷贝到内核态
- 不知道具体是哪个文件描述符就绪,需要遍历全部文件描述符,造成了O(n)时间复杂度的开销,因此要监控的数量越多时间消耗越久
- 入参的3个 fd_set 集合每次调用都需要重置(因为fd_set调用前调用后代表的含义不同)
二、poll
poll 函数基本同 select,只是对 select 进行了一些小优化,一个是优化了1024个文件描述符上限,另一个是新定义了 pollfd 数据结构,使用两个不同的变量来表示监听的事件和就绪的事件,这样就不需要像 select 那样每次重置 fd_set 了。
/**
* 获取就绪事件
*
* @param pollfd 要监听的文件描述符集合
* @param nfds 文件描述符数量
* @param timeout 本次调用的超时时间
* @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
*/
int poll(struct pollfd *fds,
unsigned int nfds,
int timeout);
struct pollfd {
int fd; // 监听的文件描述符
short events; // 监听的事件
short revents; // 就绪的事件
}
pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
select 和 poll 速度都比较慢,每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。但几乎所有的系统都支持 select,但是只有比较新的系统支持 poll。
三、epoll
在select/poll时代当发生百万个连接时,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。
epoll工作流程
epoll的设计和实现select完全不同。epoll通过在linux内核中申请一个简易的文件系统(文件系统一般用采用红黑树的结构)。把原先的select/poll调用分成了3个部分
/**
* 创建一个epoll
*
* @param size epoll要监听的文件描述符数量
* @return epoll的文件描述符
*/
int epoll_create(int size);
/**
* 事件注册
*
* @param epfd epoll的文件描述符,epoll_create创建时返回
* @param op 操作类型:新增(1)、删除(2)、更新(3)
* @param fd 本次要操作的文件描述符
* @param epoll_event 需要监听的事件:读事件、写事件等
* @return 如果调用成功返回0, 不成功返回-1
*/
int epoll_ctl(int epfd,
int op,
int fd,
struct epoll_event *event);
/**
* 获取就绪事件
*
* @param epfd epoll的文件描述符,epoll_create创建时返回
* @param events 用于回传就绪的事件
* @param maxevents 每次能处理的最大事件数
* @param timeout 等待I/O事件发生的超时时间,-1相当于阻塞,0相当于非阻塞
* @return 大于0:已就绪的文件描述符数;等于0:超时;小于:出错
*/
int epoll_wait(int epfd,
struct epoll_event *events,
int maxevents,
int timeout);
(1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
(2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字
(3)调用epoll_wait收集发生的事件的连接
如此一来,要实现上面说的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。
从上面的调用方式就可以看到epoll比select/poll的优越之处:因为后者每次调用时都要传递你所要监控的所有socket给select/poll系统调用,这意味着需要将用户态的socket列表复制到内核态,如此大量的数据由用户态复制到内核态,非常低效。而我们调用epoll_wait时就相当于以往调用select/poll,但是这时却不用传递socket句柄给内核,因为内核已经在epoll_ctl中拿到了要监控的句柄列表。
当调用epoll_create时,会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。
epoll的高效就在于,当我们调用epoll_ctl往里塞入百万个句柄时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。
那么,这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。
如此,一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。
总结:
epoll 直接将 fd 集合维护在内核中,通过红黑树来高效管理 fd 集合,同时维护一个就绪列表,当 fd 就绪后会添加到就绪列表中,当应用空间调用 epoll_wait 获取就绪事件时,内核直接判断就绪列表即可知道是否有事件就绪。
优点:
- 解决了 select 和 poll 的缺点,高效处理高并发下的大量连接,同时有非常优异的性能。
缺点:
- 跨平台性不够好,只支持 linux,macOS 等操作系统不支持
- 相较于 epoll,select 更轻量可移植性更强
- 在监听连接数和事件较少的场景下,select 可能更优
工作模式
epoll 的描述符事件有两种触发模式:LT(level trigger)和 ET(edge trigger)。
1. LT 模式
当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait() 会再次通知进程。是默认的一种模式,并且同时支持 Blocking 和 No-Blocking。
2. ET 模式
和 LT 模式不同的是,通知之后进程必须立即处理事件,下次再调用 epoll_wait() 时不会再得到事件到达的通知。很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
epoll 和 select、poll 默认都是 LT 模式,LT 模式会更安全一点,而 ET 则是 epoll 为了性能开发的一种新模式,LT 模式下内核在返回就绪事件之前都会进行一次额外的判断,如果 fd 量较大,会有一定的性能损耗。
补充
IO多路复用是否同步、是否阻塞?
要回答这个问题,我们首先回顾下开头对于同步、异步,阻塞、非阻塞的介绍,同步指的是用户进程触发I/O操作并等待或者轮询的去查看IO操作是否就绪。由于多路复用仍然是应用程序主动去查看数据是否就绪,因此,多路复用依然属于同步IO(只有使用了特殊的 API 才是异步 IO),归类如下图:
明确了是否同步,我们再来看是否阻塞呢,先说答案,select/poll/epoll本身是同步的,可以阻塞也可以不阻塞,这与具体的实现有关,下面我们通过《Linux/UNIX系统编程手册》中的一些介绍来进行介绍。
(1)select是否阻塞
在使用int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)函数时,可以设置timeval决定该系统调用是否阻塞。
(2)关于Poll是否阻塞
在使用int poll(struct pollfd *fds, nfds_t nfds, int timeout)函数获取信息时,可以通过指定timeout的值来决定是否阻塞(当timeout<0时,会无限期阻塞;当timeout=0时,会立即返回)
(3)关于epoll是否阻塞
在使用epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)函数来获取是否有发生变化/事件的文件描述符时,可以通过指定timeout来指定该调用是否阻塞(当timeout=-1时,会无限期阻塞;当timeout=0时,会立即返回)。
epoll是否使用了共享内存空间进行优化
《Linux/unix系统编程手册》中并没有提到内存共享,而是这么说:
- 调用select 和 poll时,要传递一个标记了所有监视文件描述符的数据结构给内核,调用返回时,内核再将所有标记为就绪态的文件描述符的数据结构传回来
- epoll通过epoll_ctl 在内核建立了一个数据结构,该接口会将要监视的文件描述符记录下来。稍后调用epoll_wait时就不用再传递任何文件描述符的信息给内核了
同时翻阅了一些别的文章,也有朋友翻阅了linux的源码,也未曾找到所谓的共享内存mmap,因此个人认为是没有的,如果有了解的怕朋友可以告知一下。