背景知识
- 阻塞IO(Blocking IO)
- ⾮阻塞IO(Non-Blocking IO)
- 多路复⽤IO(IO Multiplexing)
- 信号驱动IO(Signal Driven IO)
- 异步IO(Asynchronous IO)
1. IO操作阶段划分
阶段1:等待数据准备好(即每一个套接字都有一个发送缓冲区),当⽤户进程调⽤了 read 这个系统调⽤,kernel 就开始了 IO 的第⼀个阶段:准备数据。对于 network io 来说,很多时候数据在⼀开始还没有到达(⽐如,还没有收到⼀个完整的数据包), 这个时候 kernel 就要等待⾜够的数据到来,此时⽤户进程会被阻塞。当所有数据到达时,会被复制到内核的缓冲区中;
阶段2:内核拷⻉数据到进程,当 kernel ⼀直等到数据准备好了,它就会将数据从 kernel 缓冲区中拷⻉到⽤户内存,然后 kernel 返回拷⻉结果, ⽤户进程才解除 block 的状态,重新运⾏起来。
根据是否同步等待阶段1,IO模型划分为 阻塞IO / ⾮阻塞IO;
根据是否同步等待阶段2,IO默认划分为 同步IO / 异步IO;
2. 阻塞IO(Blocking IO)
调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的去检查这个函数有没有返回,必须等这个函数返回才能进行下一步动作。
在 linux 中,默认情况下所有的 socket 都是 blocking,⼀个典型的读操作流程如下:
Blocking IO 的特点就是在 IO 执⾏的两个阶段(等待数据 和 拷⻉数据)都被 block 了。基于Blocking IO的 ⼀问⼀答 的服务器:
Socket(套接字)是一种用于网络通信的编程接口,它提供了一种机制,使得应用程序能够通过网络进行数据传输和通信。Socket可以看作是应用层与传输层之间的接口,它允许应用程序通过网络发送和接收数据。
Socket提供了一组函数和方法,使得应用程序能够创建、连接、发送和接收数据等操作。通过Socket,应用程序可以与其他计算机上的应用程序建立网络连接,进行数据交换和通信。
1)创建Socket:应用程序通过调用Socket函数或方法创建一个Socket对象。在创建Socket时,需要指定网络协议(如TCP或UDP)和通信域(如IPv4或IPv6)。
2)绑定Socket:如果应用程序需要在特定的网络接口和端口上进行通信,它可以将Socket绑定到一个特定的地址和端口。这样,其他应用程序就可以通过该地址和端口与该Socket进行通信。
3)连接Socket:如果应用程序是客户端,它可以使用Socket连接到远程服务器的地址和端口。如果应用程序是服务器,它可以使用Socket监听指定的地址和端口,等待客户端的连接请求。
4)发送和接收数据:一旦Socket连接建立,应用程序就可以使用Socket发送和接收数据。发送数据时,应用程序将数据写入Socket,然后Socket将数据发送到远程端。接收数据时,应用程序从Socket中读取数据,然后处理接收到的数据。
5)关闭Socket:当通信完成或不再需要时,应用程序可以关闭Socket,释放相关的资源。
Socket提供了一种灵活且强大的机制,使得应用程序能够进行各种类型的网络通信,包括客户端和服务器之间的通信、点对点通信、多播通信等。它被广泛应用于各种网络编程场景,如Web服务器、聊天应用、文件传输等。
2.1 阻塞IO存在的问题
⼤部分的 socket 接⼝都是阻塞型的。所谓阻塞型接⼝是指系统调⽤(⼀般是 IO 接⼝) 不返回调⽤结果并让当前线程⼀直阻塞,只有当该系统调⽤获得结果或者超时出错时才返回。
实际上,除⾮特别指定,⼏乎所有的 IO 接⼝ ( 包括 socket 接⼝ ) 都是阻塞型的。这给⽹络编程带来了⼀个很⼤的问题,如在调⽤ send() 的同时,线程将被阻塞,在此期间,线程将⽆法执⾏任何运算或响应任何的⽹络请求。
改进:多线程/多进程处理连接
多线程(或多进程)的⽬的是让每个连接都拥有独⽴的线程(或进程),这样任何⼀个连接的阻塞都不会影响其他的连接。
具体使⽤多进程还是多线程,并没有⼀个特定的模式。传统意义上,进程的开销要远远⼤于线程,所 以如果需要同时为较多的客户机提供服务,则不推荐使⽤多进程;如果单个服务执⾏体需要消耗较多的 CPU 资源,譬如需要进⾏⼤规模或⻓时间的数据运算或⽂件访问, 则进程较为安全。通常,使⽤pthread_create ()创建新线程,fork()创建新进程。
我们假设对上述的服务器 / 客户机模型,提出更⾼的要求,即让服务器同时为多个客户机提供⼀问⼀答的服务。于是有了如下的模型。
当一个服务执行体需要消耗较多的CPU资源时,使用进程较为安全的原因有以下几点:
1)独立的内存空间:每个进程都有自己独立的内存空间,这意味着一个进程的内存访问不会影响其他进程的内存。这样可以避免由于一个进程的错误导致整个系统崩溃或其他进程受到影响。如果一个服务执行体需要进行大规模或长时间的数据运算或文件访问,可能会导致内存泄漏或其他内存相关的问题。使用进程可以将这些问题限制在单个进程内,不会对其他进程产生负面影响。
2)进程隔离:每个进程都在操作系统级别进行隔离,具有独立的资源分配和管理。这样,一个进程的异常或错误不会影响其他进程的正常运行。如果一个服务执行体需要进行复杂的数据运算或文件访问,可能会引发各种异常情况,如内存溢出、文件系统错误等。使用进程可以将这些异常隔离在单个进程内,不会对其他进程产生连锁效应。
3)容错性强:由于进程之间相互独立,一个进程的崩溃或异常不会导致整个系统的崩溃。如果一个服务执行体需要进行大规模或长时间的数据运算或文件访问,可能会面临各种风险,如计算错误、资源竞争等。使用进程可以保证即使其中一个进程出现问题,其他进程仍然可以正常工作,提高了整个系统的容错性。
然而,需要注意的是,使用进程会带来一些额外的开销,如内存开销和进程间通信的开销。因此,在选择使用进程还是线程时,需要综合考虑系统的需求、资源限制以及性能等因素。
2.2 多线程/多进程处理连接的问题
如果要同时响应成百上千路的连接请求,则⽆论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,⽽线程与进程本身也更容易进⼊假死状态。
引⼊"线程池"或者"进程池",旨在减少创建和销毁线程/进程的频率,维持⼀定合理数量的线程/进程,并复⽤空闲的线程/进程重新承担新的执⾏任务。但是,"线程池"或者"进程池"技术也只是在⼀定程度上缓解了频繁调⽤IO接⼝带来的资源占⽤。且所谓池始终有其上限,当请求⼤⼤超过上限时, 池构成的系统对外界的响应并不⽐没有池的时候效果好多少。所以使⽤池必须考虑其⾯临的响应规模,并根据响应规模调整池的⼤⼩,池并⾮越⼤越好(原因见补充部分);
实现举例:
假死状态:在多线程或多进程的网络编程中,假死状态(Deadlock)指的是一种情况,其中两个或多个线程(或进程)在等待对方释放资源或完成某个操作,导致它们无法继续执行下去,从而陷入无限等待的状态。
1)资源消耗:线程池和进程池的大小直接关系到系统资源的消耗。每个线程或进程都需要占用一定的内存和CPU资源。如果线程池或进程池过大,会导致系统资源消耗过多,可能会导致系统负载过重,甚至引发资源竞争和性能下降。
2)上下文切换:线程或进程的切换会引起上下文切换的开销。当线程或进程数量过大时,频繁的上下文切换会导致系统性能下降。上下文切换涉及保存和恢复寄存器、内核调度等操作,会占用额外的CPU时间。
3)同步和竞争条件:线程池和进程池中的线程或进程可能需要共享资源或进行同步操作。当线程或进程数量过多时,可能会增加竞争条件的发生概率,导致性能下降和数据不一致性。过大的线程或进程池需要更复杂的同步机制,增加了编程的复杂性和出错的可能性。
4)系统调度开销:线程或进程数量过多会增加系统调度的开销。操作系统需要对线程或进程进行调度和管理,过大的线程或进程池会增加调度算法的复杂性和开销。
因此,为了避免过多的资源消耗、上下文切换、竞争条件和系统调度开销,线程池和进程池的大小应该根据系统资源和应用需求进行合理的调整。需要综合考虑系统的硬件资源、并发负载、任务类型等因素,选择适当的线程池或进程池大小,以达到最佳的性能和资源利用率。
3. 非阻塞IO(Non-Blocking IO)
Linux 下,可以通过设置 socket 使其变为 non-blocking,即使⽤⾮阻塞IO。
⾮阻塞IO是在等待数据时,如果数据未就绪,内核不会阻塞进程,⽽是返回错误码,从⽽避免进程阻塞在某个IO操作上,为单线程并发/异步处理IO提供可能;
当对⼀个 non-blocking socket 执⾏读操作时,流程如下:
1)当⽤户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,它并不会 block ⽤户进程,⽽是⽴刻返回⼀个 error;
2)从⽤户进程⻆度讲 ,它发起⼀个 read 操作后,并不需要等待,⽽是⻢上就得到了⼀个结果。⽤户进程判断结果是⼀个 error 时,它就知道数据还没有准备好,于是它可以先做其它事情,或者择机再次发送 read 操作;
3)⼀旦 kernel 中的数据准备好了,并且⼜再次收到了⽤户进程的system call,那么它⻢上就将数据拷⻉到了⽤户内存,然后返回,所以,在⾮阻塞式 IO 中,⽤户进程其实是需要不断的主动询问 kernel 数据准备好了没有。
⾮阻塞的接⼝相⽐于阻塞型接⼝的显著差异在于,在被调⽤之后⽴即返回。在⾮阻状态在,recv()接⼝在被调⽤后会⽴即返回,返回值含义如下:
- 返回值⼤于 0,表示接受数据完毕,返回值即是接受到的字节数;
- 返回 0,表示连接已经正常断开;
- 返回 -1,且 errno 等于 EAGAIN,表示 recv 操作还没执⾏完成;
- 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操作遇到系统错误 errno;
fcntl( fd, F_SETFL, O_NONBLOCK );
由于服务端处理IO是⾮阻塞的,就可以使⽤⼀个线程处理多个连接的请求。
可以看到服务器线程可以通过循环调⽤ recv()接⼝,可以在单个线程内实现对所有连接的数据接收⼯作。但是上述模型绝不被推荐。因为,循环调⽤ recv()将⼤幅度推⾼ CPU 占⽤率。此外,在这个⽅案中 recv()更多的是起到检测操作是否完成 的作⽤,实际操作系统提供了更为⾼效的检测 操作是否完成 作⽤的接⼝,例如 select()多路复⽤模式, 可以⼀次检测多个连接是否活跃。
4. 多路复⽤IO(IO Multiplexing)
IO multiplexing ⼜称事件驱动 IO(event driven IO),常⻅实现包括:select / poll / epoll。其好处在于单个 process 可以同时处理多个⽹络连接的 IO。它的基本原理就是有 function 会不断轮询所负责的 socket句柄,当某个socket有数据到达,就通知⽤户线程,流程示意如下:
当⽤户进程调⽤了 select,那么整个进程会被 block。同时,kernel 会监视所有 select 负责的socket,当任何⼀个 socket 中的数据准备好了,select 就会返回。这个时候⽤户进程再调⽤ read 操作,将数据从 kernel 拷⻉到⽤户进程。
在多路复⽤模型中,对于每⼀个 socket,⼀般都设置成为 non-blocking,但是,如上图所示,整个⽤户的 process 其实是⼀直被 block 的。只不过 process 是被 select 这个函数 block,⽽不是被socket IO 给 block。因此 select() 与⾮阻塞 IO 类似。
注:区别与阻塞IO的是,IO多路复⽤,仅当所有socket均⽆事件时,⽤户线程才会阻塞。只要有⼀个socket有事件,则⽤户线程不会被阻塞;
不⼀定,IO多路复⽤需要使⽤更多的系统调⽤,⽐如上图 select 需要两个系统调⽤(select和read),⽽ Blocking IO 只调⽤了⼀个系统调⽤(read)。
使⽤ select 以后最⼤的优势是⽤户可以在⼀个线程内同时处理多个 socket 的 IO 请求。⽤户可以注册多个 socket,然后不断地调⽤ select 读取被激活的 socket,即可达到在同⼀个线程内同时处理多个 IO 请求的⽬的(如何实现?)。⽽在同步阻塞模型中,必须通过多线程的⽅式才能达到这个⽬的。
如果处理的连接数不是很⾼的话,使⽤ select/epoll 的 web server 不⼀定⽐使⽤ multi-threading + blocking IO 的 web server 性能更好,可能延迟还更⼤。IO多路复⽤的优势并不是对于单个连接能处理得更快,⽽是在于能并发的处理更多的连接。
4.1 select 函数
1)首先要构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中。
2)调用一个系统函数,监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行 I/O 操作时,该函数才返回。
- 这个函数是阻塞;
- 函数对文件描述符的检测的操作是由内核完成的;
3)在返回时,它会告诉进程有多少(哪些)描述符要进行I/O操作。
#include <sys/types.h>
#include <unistd.h>
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);- 参数:- nfds : 委托内核检测的最大文件描述符的值 + 1- readfds : 要检测的文件描述符的读的集合,委托内核检测哪些文件描述符的读的属性- 一般检测读操作- 对应的是对方发送过来的数据,因为读是被动的接收数据,检测的就是读缓冲区- 是一个传入传出参数- writefds : 要检测的文件描述符的写的集合,委托内核检测哪些文件描述符的写的属性- 委托内核检测写缓冲区是不是还可以写数据(不满的就可以写)- exceptfds : 检测发生异常的文件描述符的集合- timeout : 设置的超时时间struct timeval {long tv_sec; /* seconds */long tv_usec; /* microseconds */};- NULL : 永久阻塞,直到检测到了文件描述符有变化- tv_sec = 0 tv_usec = 0, 不阻塞- tv_sec > 0 tv_usec > 0, 阻塞对应的时间- 返回值 :- -1 : 失败- >0(n) : 检测的集合中有n个文件描述符发生了变化// 将参数文件描述符fd对应的标志位设置为0
void FD_CLR(int fd, fd_set *set);
// 判断fd对应的标志位是0还是1, 返回值 : fd对应的标志位的值,0,返回0, 1,返回1
int FD_ISSET(int fd, fd_set *set);
// 将参数文件描述符fd 对应的标志位,设置为1
void FD_SET(int fd, fd_set *set);
// fd_set一共有1024 bit, 全部初始化为0
void FD_ZERO(fd_set *set);
1)每次调用select都需要把socket从用户态拷贝到内核态,开销较大;
2)select只知道自己监控了几个socket,但不知道那几个socket中的数据准备好了,所以需要对其进行遍历;
3)select支持的socket数量较小,一般为1024;
4.2 poll 函数
#include <poll.h>
struct pollfd {int fd; /* 委托内核检测的文件描述符 */short events; /* 委托内核检测文件描述符的什么事件 */short revents; /* 文件描述符实际发生的事件 */
};struct pollfd myfd;
myfd.fd = 5;
myfd.events = POLLIN | POLLOUT;int poll(struct pollfd *fds, nfds_t nfds, int timeout);- 参数:- fds : 是一个struct pollfd 结构体数组,这是一个需要检测的文件描述符的集合- nfds : 这个是第一个参数数组中最后一个有效元素的下标 + 1- timeout : 阻塞时长-0 : 不阻塞-1 : 阻塞,当检测到需要检测的文件描述符有变化,解除阻塞>0 : 阻塞的时长- 返回值: -1 : 失败>0(n) : 成功,n表示检测到集合中有n个文件描述符发生变化
补充:
1)每次调用select都需要把socket从用户态拷贝到内核态,开销较大;
2)select只知道自己监控了几个socket,但不知道那几个socket中的数据准备好了,所以需要对其进行遍历;
4.3 epoll 函数
#include <sys/epoll.h>
// 创建一个新的epoll实例。在内核中创建了一个数据,这个数据中有两个比较重要的数据,一个是需要检测的文件描述符的信息(红黑树),还有一个是就绪列表,存放检测到数据发送改变的文件描述符信息(双向链表)。int epoll_create(int size);- 参数:-size : 目前没有意义了。随便写一个数,必须大于0 - 返回值: -1 : 失败> 0 : 文件描述符,操作epoll实例的typedef union 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 */
};常见的Epoll检测事件:- EPOLLIN- EPOLLOUT- EPOLLERR// 对epoll实例进行管理:添加文件描述符信息,删除信息,修改信息
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);- 参数:- epfd : epoll实例对应的文件描述符- op : 要进行什么操作EPOLL_CTL_ADD: 添加EPOLL_CTL_MOD: 修改EPOLL_CTL_DEL: 删除- fd : 要检测的文件描述符- event : 检测文件描述符什么事情// 检测函数
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);- 参数:- epfd : epoll实例对应的文件描述符- events : 传出参数,保存了发送了变化的文件描述符的信息- maxevents : 第二个参数结构体数组的大小- timeout : 阻塞时间- 0 : 不阻塞- -1 : 阻塞,直到检测到fd数据发生变化,解除阻塞- > 0 : 阻塞的时长(毫秒)- 返回值:- 成功,返回发送变化的文件描述符的个数 > 0- 失败 -1
- 用户不读数据,数据一直在缓冲区,epoll 会一直通知;
- 用户只读了一部分数据,epoll会通知;
- 缓冲区的数据读完了,不通知;
LT(level - triggered)是缺省的工作方式,并且同时支持 block 和 no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的。
读缓冲区读缓冲区有数据 - > epoll检测到了会给用户通知
- 用户不读数据,数据一致在缓冲区中,epoll下次检测的时候就不通知了;
- 用户只读了一部分数据,epoll不通知;
- 缓冲区的数据读完了,不通知;
ET(edge - triggered)是高速工作方式,只支持 no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。
ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
struct epoll_event { uint32_t events; /* Epoll events */epoll_data_t data; /* User data variable */
};常见的Epoll检测事件:- EPOLLIN- EPOLLOUT- EPOLLERR- EPOLLET
1)每次调用epoll时,系统都会在内核区创建一个数据结构,该数据结构包含了红黑树、双向链表,红黑树包含了socket集合,双向链表包含了数据准备好了的socket;
3)无需遍历所有socket去查找数据准备好了的socket;
5. 信号驱动IO(Signal Driven IO / SIGIO)
信号驱动IO,即对每个读写动作,提前指定好回调函数,并通过 sigaction() 函数注册对 SIGIO 信号的处理。当内核触发 SIGIO 信号后,就调⽤相应的回调函数,处理数据:
1)⾸先我们允许套接⼝进⾏信号驱动 I/O,并安装⼀个信号处理函数,进程继续运⾏并不阻 塞;
2)当数据报准备好读取时,内核就为该进程产⽣⼀个 SIGIO 信号。随后既可 以在信号处理函数中调⽤ read 读取数据报,并通知主循环数据已准备好待处理,也可以⽴ 即通知主循环,让它来读取数据报。
⽆论如何处理 SIGIO 信号,这种模型的优势在于等待数 据到达(第⼀阶段)期间,进程可以继续执⾏,不被阻塞。免去了 select 的阻塞与轮询,当 有活跃套接字时,由注册的 handler 处理。
注:信号驱动IO仍然需要同步等待阶段2完成,故仍然是⼀种同步IO;
5. 异步IO(Asynchronous IO)
Linux 内核从 2.6 开始,也引⼊了⽀持异步响应的 IO 操作,如 aio_read, aio_write,这就是异步IO。 流程示意如下:
1)⽤户进程发起read操作之后,⽴刻就可以开始去做其它的事。⽽另⼀⽅⾯,从kernel 的⻆度,当它受到⼀个 asynchronous read 之后,⾸先它会⽴刻返回,所以不会对⽤户进程产⽣任何 block。
2)然后,kernel 会等待数据准备完成,然后将数据拷⻉到⽤户内存,当这⼀切都完成之后,kernel 会给⽤户进程发送⼀个 signal,告诉它 read 操作完成了。
异步 IO 是真正⾮阻塞的,它不会对请求进程产⽣任何的阻塞,因此对⾼并发的⽹络服务器实现⾄关重要。
问题:Blocking和Non-Blocking的区别在哪?
对于阶段1,调⽤ Blocking IO 会⼀直 block 对应的进程,直到有读写事件就绪,⽽ Non-Blocking IO 在 kernel 还在准备数据的情况下会⽴刻返回,并不会同步等待;
问题:Synchronous IO和Asynchronous IO的区别在哪?
Synchronous IO 做 IO operation 的时候会将 process 阻塞。 按照这个定义,之前所述的 blocking IO,Non-Blocking IO,IO Multiplexing 都属于 Synchronous IO。
Non-Blocking IO 在执⾏ read 时,如果 kernel 的数据没有准备好,不会 block 进程。但是当 kernel 中数据准备好的时候,read 会将数据 从 kernel 拷⻉到⽤户内存中,在这段时间内进程是被 block 的。⽽ Asynchronous IO 则不⼀样,当进程发起 IO 操作之后,就直接返回再也不理睬了, 直到kernel 发送⼀个信号,告诉进程说 IO 完成。在这整个过程中,进程完全没有被 block。
Non-Blocking IO 中,虽然进程⼤部分时间都不会被 block,但是它仍然要求进程去主动的 check, 并且当数据准备完成以后,也需要进程主动的再次调⽤ recvfrom 来将数据拷⻉到⽤户内存。 ⽽Asynchronous IO 则完全不同。它就像是⽤户进程将整个 IO 操作交给了他⼈(kernel)完成,然后他⼈做完后发信号通知。在此期间,⽤户进程不需要去检查 IO 操作的状态,也不需要主动的去拷⻉数据。
6. 总结
阻塞IO、⾮阻塞IO、IO多路复⽤、信号驱动IO的主要区别在于对阶段1(等待数据准备好)的处理;对于阶段2(内核拷⻉数据到进程)的处理⽅式是⼀致的,都是同步等待数据拷⻉完成;因⽽这4种IO模型均属于同步IO模型;
⽽异步IO模型,针对阶段1、阶段2都会进⾏处理,在⽤户进程看来,所进⾏的IO操作的2个阶段都不会block 进程的运⾏;
7. 参考
如果需要本文 WORD、PDF 相关文档请在评论区留言!!!
如果需要本文 WORD、PDF 相关文档请在评论区留言!!!
如果需要本文 WORD、PDF 相关文档请在评论区留言!!!