网络IO会涉及到两个系统对象,一个是用户空间调用IO的进程或线程,一个是内核空间的内核系统 。
比如,发生IO操作read时,会经历两个阶段:
stage1:等待数据准备就绪。
stage2:将数据从内核拷贝到进程或者线程中。
一、问题引出
我们先提出几个问题:
- 为什么要有select/poll/epoll?
- 异步操作怎么理解?
- 为什么信号驱动IO网络模型不能大量操作?
- select/poll检测IO,检测的是什么?
- blocking 和 non-blocking 的区别在哪? synchronous IO 和 asynchronous IO 的区别在哪?
二、IO网络模型
2.1 阻塞IO(blocking IO)
在linux中,默认情况下所有的socket都是阻塞的,一个典型的读操作流程如下:
- 用户进程调用read系统函数,kernel就开始IO的第一个阶段:准备数据。
- 如果此时数据还没有就绪(网络IO还没有收到一个完整的数据包),kernel就需要等待足够的数据到来。
- 等待数据返回期间用户进程一直处于阻塞状态。
- 当kernel等到数据就绪后,它就会将数据从kernel拷贝到用户内存,然后kernel返回结果。
- 用户进程接收到数据后, 解除阻塞状态,重新运行起来。
因此阻塞IO的特点是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。
网络编程除非特别制定,否则几乎所有的IO接口都是阻塞的。
下面是一个简单的“一问一答”的服务器交互流程。
为了不让阻塞影响业务实现,基于该模型,简单的改进方法是:在服务器端使用多线程(或多进程)。
具体使用多线程还是多进程,没有一个特定的模式来参考。一般来说,进程的开销要远远大于线程,因此如果需要同时为较多客户机提供服务, 则不推荐使用多进程。不过, 有个情况例外:如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。
服务器同时为多个客户机提供服务改进模型:
上述多线程的服务器模型似乎完美的解决了为多个客户机提供问答服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。
这时候,我们可能会想到考虑使用“线程池”或“连接池”。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如 websphere、tomcat 和各种数据库等。
线程池:维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务,旨在减少创建和销毁线程的频率。
连接池:维持连接池的缓存池,尽量重用已有的连接,减少创建和关闭连接的频率。
不过,线程池和连接池也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且“池”总会有上限,当请求大大超过上限时,“池”构成的系统对外界的影响不比没有池的时候效果好多少。因此使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。
2.2 非阻塞IO(no-blocking IO)
在linux中,默认情况下所有的socket都是阻塞的,不过我们可以通过设置使其变为non-blocking。
非阻塞模式下 ,进行读操作流程如下:
从上述图上可以看到,当用户进程进行read请求时,无论kernel中的数据是否就绪,都会返回,不会阻塞用户进程。
在非阻塞IO中,用户进程需要不断的主动询问kernel数据准备好了没有。
非阻塞模式下,recv()函数的返回值说明:
- 大于0,表示接收到数据。
- 返回0,表示连接已经正常断开。
- 返回-1,并且errno等于EAGAIN,表示recv操作还没执行完成。
- 返回-1,并且errno不等于EAGAIN,表示recv操作遇到系统错误 errno。
上图可以看到,通过循环调用recv接口,可以在单个线程内实现对所有连接的 数据接收工作。
但是此种模型不做推荐,原因如下:
- 循环调用recv()将大幅消耗CPU占用率。
- 这里的recv()更多作用是检测“操作是否完成”,实际操作系统提供了更为高效该检测项检测接口,比如select()多路复用模式,可以一次检测多个连接是否活跃。
2.3 多路复用IO(IO multiplexing)
多路复用这种IO方式也称为事件驱动IO。select/epoll的优势在于单个process就可以同时处理多个网络连接的IO。
基本原理是,select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。流程图如下:
用户进程调用select,整个进程会被阻塞,同时kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。
select返回后,用户进程再调用read操作, 将数据从kernel拷贝到用户进程。
使用 select 最大的优势是用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。
需要注意的是:
这里需要使用两个系统调用(select 和 read),而阻塞IO只调用一个系统调用(read)。所以,如果处理的连接数不是很高的话,使用select/epoll 的 web server 不一定比使用 多线程+ 阻塞 IO 的 webserver 性能更好,可能延迟还更大。
上图中整个用户的 process 其实是一直被 block 的。不过在多路复用模型中,对于每一个 socket,一般都设置成为 non-blocking。这样的话,process 是被 select 这个函数 block,而不是被 socket IO 给 block。因此 select()与非阻塞 IO 类似。
大部分 Unix/Linux 都支持 select 函数,该函数用于探测多个文件句柄的状态变化。
select接口原型:
FD_ZERO(int fd, fd_set* fds)
FD_SET(int fd, fd_set* fds)
FD_ISSET(int fd, fd_set* fds)
FD_CLR(int fd, fd_set* fds)
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,struct timeval *timeout)
接口参数说明:
这里, fd_set 类型可以简单的理解为按 bit 位标记句柄的队列,例如要在某 fd_set中标记一个值为 16 的句柄,则该 fd_set 的第 16 个 bit 位被标记为 1。具体的置位、验证可使用 FD_SET、 FD_ISSET 等宏实现。在 select()函数中, readfds、 writefds 和exceptfds 同时作为输入参数和输出参数。如果输入的 readfds 标记了 16 号句柄,则select()将检测 16 号句柄是否可读。在 select()返回后,可以通过检查 readfds 有否标记 16 号句柄,来判断该“可读”事件是否发生。另外,用户可以设置 timeout 时间。
上述模型简单描述了使用select()接口同时从多个客户端接收数据的过程,由于select()接口可以同时对多个句柄进行读状态、写状态和错误状态的探测,因此可以很容易构建为多个客户端提供独立问答服务器系统。
上图中 ,客户端的一个connect()操作将在服务端激发一个“可读事件”,因此select()也可以探测来自客户端的connect行为。
上述模型中,最关键的地方是如何动态维护select的三个参数readfds、writefds和exceptfds:
作为输入参数:
- readfds应该标记所有的需要探测的“可读事件”的句柄,其中永远包括那个探测 connect() 的那个“母”句柄;
- writefds 和 exceptfds 应该标记所有需要探测的“可写事件”和“错误事件”的句柄 ( 使用 FD_SET() 标记 );
作为输出参数:
- readfds、 writefds 和 exceptfds 中的保存了 select() 捕捉到的所有事件的句柄值。
- 我们需要检查的所有标记位(使用FD_ISSET()检查),以确定到底哪些句柄发生了事件。
相比其他模型,使用 select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。
该模型存在的问题:
- select()接口并不是实现“事件驱动”的最好选择,因为当需要探测的句柄值较大时, select()接口本身需要消耗大量时间去轮询各个句柄。
- 该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。
幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有libevent 库,还有作为 libevent 替代者的 libev 库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。
2.4 异步IO(Asynchronous IO)
Linux下的Asynchronous IO用在磁盘IO的读写操作,并不用于网络IO。直到内核2.6版本才开始引入。
读流程如下:
- 用户进程发起read操作之后 ,立刻可以开始做其他事情,不用等待。
- kernel收到asynchronous read之后,会立刻返回,不会对用户进程造成block。
- kernel等待数据准备完成,然后将数据拷贝到用户内存。
- kernel给用户进程发送一个signal,告知read操作完成了。
异步IO是真正的非阻塞,它不会对请求进程产生任何的阻塞,对高并发的网络服务器实现至关重要。
blocking和non-blocking的区别
调用 blocking IO 会一直 block 住对应的进程直到操作完成,而non-blocking IO 在 kernel 还在准备数据的情况下会立刻返回。
synchronous IO和asynchronous IO的区别
1)定义:synchronous IO 做”IO operation”的时候会将 process 阻塞。
按照这个定义,上面提到的blocking IO、non-blocking IO、IO multiplexing都属于synchronous IO。
疑问:non-blocking IO并没有被block,为什么也属于synchronous IO?
定义中所提的IO operation指的是真实的IO操作,即read系统调用。这个时候我们需要分两部分来看:
- non-blocking IO 在执行 read 这个系统调用的时候,如果 kernel 的数据没有准备好,这时候不会 block 进程。
- 但是当 kernel 中数据准备好的时候, read 会将数据从 kernel 拷贝到用户内存中,这个时候进程是被 block 了,在这段时间内进程是被 block的。
2)asynchronous IO 则不一样,当进程发起 IO 操作之后,就直接返回再也不理睬了。在这整个过程中,进程完全没有被 block。直到 kernel 发送一个信号,告诉进程说 IO 完成。
2.5 信号驱动IO(Signal driver IO,SIGIO)
- 套接口进行信号驱动IO,并安装一个信号处理函数,进程继续运行,不阻塞。
- 数据准备好后,进程会收到一个SIGIO信号,可以在信号处理函数中调用IO操作函数处理数据。
- 数据报准备读取时,内核为该进程产生一个SIGIO信号。
- 收到信号后可以采用两种方式进行数据读取:
- 可以在信号处理函数中调用read读取数据报,并通知主循环数据已经准备好待处理;
- 也可以立即通知主循环,让它来读取数据报。
这种模型的优势在于等待数据报到达(第一阶段)期间,进程可以继续执行,不被阻塞。免去了 select 的阻塞与轮询,当有活跃套接字时,由注册的 handler 处理。
三、select/poll/epoll详解
内容引用自:
https://mp.weixin.qq.com/s?__biz=MzAxODI5ODMwOA==&mid=2666538922&idx=1&sn=e6b436efd6a4f53dcbf20f4ce11a986a&scene=23&srcid=0425xFfzV9LmmVrdeEQ4He1W#rd
IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。IO多路复用适用如下场合:
- 当客户处理多个描述符时(一般是交互式输入和网络套接字),必须使用I/O复用。
- 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
- 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口时,一般也要用到I/O复用。
- 如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
- 如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
3.1 select
1、基本原理
函数监视的文件描述符分3类:writefds、readfds和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有 数据可读、可写或except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select返回返回后,可以通过遍历fdset,找到就绪的描述符。
2、特点
select支持所有的平台,良好的跨平台支持是它一个优点。
Select的一个缺点在于单进程能够监视的文件描述符的数量存在最大限制。在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。缺点有:
- select 最大缺陷是单个进程所打开的fd是由一定限制 的,它由FD_SETSIZE设置,默认值是 1024。(一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048)
- 对socket进行扫描时是线性扫描,采用轮询的方法,效率较低。(如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的)
- 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时开销大。
3.2 poll
1、基本原理
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待列表中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
2、特点
它没有最大连接数限制,原因它是基于链表来存储的,但同样有一个缺点:
- 大量的fd数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
- poll还有一个缺点是水平触发,如果报告了fd后,没有被处理,那么下次poll会再次报告该fd。
注意:
select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量的客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,效率也会线性下降。
3.3 epoll
相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
1、基本原理
epoll支持水平触发和边沿触发,最大特点在于边沿触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。epoll使用“事件”就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
2、特点
- 没有最大并发连接的限制,能打开的fd远远大于1024(1G的内存可以监听约10w个端口)。
- 效率提升,不是轮询的方式,不会随着fd数目的增加而导致效率下降。只有活跃的fd才会调用callback;即epoll最大的优点在于它只管“活跃”的连接,而跟连接总数无关。因此在实际的网络环境中,epoll的效率就会远远高于select/poll。
- 内存拷贝,利用mmap文件映射内存加速与内核空间的消息传递,即epoll使用mmap减少复制开销。
epoll对文件描述符的操作有两种模式:LT(水平触发 level trigger)和ET(边沿触发 edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
- LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
同时支持block和no-block socket,如果你不作任何操作,内核还是会继续通知你的。 - ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
只支持no-block socket,在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在)。
注意:
如果没有大量的idle-connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle-connection,就会发现epoll的效率大大高于select/poll。
3.4 select/poll/epoll区别
1、支持一个进程能所打开的最大连接数
- select:32位系统为1024,64位系统为2048。
- poll:和select没有区别,但没有最大连接数限制,因为它是基于链表存储。
- epoll:1G可打开10w连接。
2、fd剧增后带来的IO效率问题
- select/poll:每次调用都会对连接进行线性遍历,fd增加会造成遍历速度慢。
- epoll:根据每个fd的callback实现,只有活跃的socket才会主动调用callback,在活跃socket较少的情况下使用epoll没有前面两个线性下降的性能问题,但是具有socket都活跃的情况下可能会有性能问题。
3、消息传递方式
- select/poll:内核需要将消息传递到用户空间,都需要内核拷贝动作。
- epoll:通过内核和用户空间共享一块内存来实现。