五种I/O模型
阻塞I/O
- 概念:在内核将数据准备好之前,系统调用会一直等待,直到数据准备就绪并从内核拷贝到用户空间,所有的套接字,默认都是阻塞方式;
- 优点:流程简单
- 缺点:效率低下
非阻塞I/O
- 概念:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回 EWOULDBLOCK 错误码,非阻塞 I/O 往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询;
- 优点:效率相较于阻塞 I/O 稍有提高
- 缺点:需要循环操作,开销大且不够实时
信号驱动I/O
- 概念:应用程序建立 SIGIO 信号处理程序,当内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行 I/O 操作;
- 优点:效率更高,实施性更强
- 缺点:操作流程更复杂,需要自定义信号处理
I/O多路转接
- 概念:虽然从流程图上看起来和阻塞 I/O 类似,实际上最核心在于 I/O 多路转接能够同时等待多个文件描述符的就绪状态,只要其中一个描述符数据准备完毕,那么进程就可以进行处理了;
- 作用:I/O 多路转接模型,就是针对大量的描述符进行 I/O 就绪事件监控,让进程仅仅针对已经就绪了 I/O 事件的描述符进行操作,避免了进程对未就绪的描述符进行操作所带来的性能损失或者阻塞;
- 优点:能在众多阻塞等待的文件描述符中,及时处理已经已就绪的描述符
- 缺点:操作更复杂
异步I/O
- 概念:由内核在数据拷贝完成时,通知应用程序直接可以进行处理了,而前面介绍的信号驱动是告诉应用程序何时可以开始拷贝数据,异步 I/O 属于一步到位;
- 优点:对于资源利用率极高,效率更高
- 缺点:流程是最复杂的
简单概念
- 从阻塞 I/O 到异步 I/O 是一个对资源利用率以及效率提高的过程,也是流程变得复杂的过程;
- 阻塞:为了完成某个功能,发起一个调用,如果完成功能的条件不具备,则一直等待直到条件具备;
- 非阻塞:为了完成某个功能,发起一个调用,如果完成功能的条件不具备,则直接报错返回;
- 阻塞 VS 非阻塞:通常用于描述某个接口发起调用后是否能够立即返回;
- 同步:所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回,但是一旦调用返回,就得到返回值了,换句话说,就是由调用者主动等待这个调用的结果,否则不能执行其他操作;
- 异步:异步则是相反,调用在发出之后,这个调用就直接返回了,所以当前并没有结果返回,换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果,而是在调用发出后,被调用者通过状态 / 通知来通知调用者,或通过回调函数处理这个调用;
- 异步 VS 同步:通常用于描述功能的完成流程,区别在于功能是否由自身完成,同步流程清晰简单,但是效率低一些,异步对资源利用率更高(褒义 / 贬义),效率更高,但是流程较复杂;
- 异步阻塞:发起一个调用,让系统完成任务,进程一直等着系统完成任务;
- 异步非阻塞:发起一个调用,让系统完成任务,进程继续做自己的事情;
非阻塞I/O
fctnl函数
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... );
- 五种功能
- 复制一个现有的描述符(cmd = F_DUPFD);
- 获得 / 设置文件描述符标记(cmd = F_GETFD 或 F_SETFD);
- 获得 / 设置文件状态标记(cmd = F_GETFL 或 F_SETFL);
- 获得 / 设置异步 I/O 所有权(cmd = F_GETOWN 或 F_SETOWN);
- 获得 / 设置记录锁(cmd = F_GETLK、F_SETLK 或 F_SETLKW);
实现SetNoBlock函数
- 基于 fcntl,实现一个 SetNoBlock 函数,将文件描述符设置为非阻塞;
void SetNoBlock(int fd){
int fl = fcntl(fd, F_GETFL);
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
- 使用 F_GETFL 将当前的文件描述符的属性取出来,这是一个位图;
- 然后再使用 F_SETFL 将文件描述符设置回去,设置回去的同时,加上一个 O_NONBLOCK 参数;
轮询方式读取标准输入
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL);
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main() {
SetNoBlock(0);
while (1) {
char buf[1024] = {0};
ssize_t read_size = read(0, buf, sizeof(buf) - 1);
if (read_size < 0) {
perror("read");
sleep(1);
continue;
}
printf("input:%s\n", buf);
}
return 0;
}
select模型
接口
fd_set rfds;
——设置读描述符集合,fd_set wfds;
——设置写描述符集合,fd_set efds;
——设置异常描述符集合;void FD_ZERO(fd_set* set);
:清空集合;void FD_SET(int fd, fd_set* set);
:将描述符添加到集合中;int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
:发起监控调用;
nfds
:所有要监控集合中,描述符的最大值 + 1;rdset / wrset / exset
:分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描述符的集合;timeout
:监控超时等待时间;
- NULL——阻塞等待直到有事件就绪;
- 0——非阻塞等待,仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生;
- 特定的时间值:监控指定的时间,时间结束后,根据不同的结果返回相应的返回值,它有两个成员,
tv_sec
——设置秒数,tv_usec
——设置微秒数;
- 返回值:
- 执行成功则返回文件描述符状态已改变的个数;
- 如果返回 0 代表在描述符状态改变前已超过 timeout 时间;
- 当有错误发生时则返回 -1,此时参数 readfds、writefds、exceptfds 和 timeout 的值变成不可预测,错误原因存于 errno,常见错误值有:EBADF——文件描述符为无效的或该文件已关闭,EINTR——此调用被信号所中断,EINVAL——参数 n 为负值,ENOMEM——核心内存不足;
void FD_CLR(int fd, fd_set *set);
:用来删除监控描述符集合中指定的描述符 fd;int FD_ISSET(int fd, fd_set *set);
:用来判断 fd 描述符是否仍存在描述符就绪集合中;
流程
- 定义指定 I/O 事件的描述符集合,添加要监控的描述符到对应集合中;
- 将发起监控调用,将集合拷贝到内核进行 I/O 就绪事件监控,就绪 / 超时后将返回描述符集合,在返回前会将集合中没有就绪的描述符移除;
- 轮询遍历判断哪个描述符还在集合中,以此来确定哪些描述符就绪了什么事件,进而进行对应的 I/O 操作;
特性
fd_set结构
- fd_set 就是一个整数数组,更严格的说是一个位图,使用位图中对应的位来表示要监视的文件描述符,并提供了一组操作 fd_set 的接口,来比较方便的操作位图;
特点
select
本质上是一个位图,可监控的文件描述符个数取决与宏sizeof(fd_set)
的值,所以如果sizeof(fd_set) = 512
,那么可监控的最大文件描述符是 512 * 8 = 4096;- 将 fd 加入 select 监控集的同时,尽量再使用一个数据结构 array 保存 select 监控集中的fd;
- 一是用于在
select
返回后,array 作为源数据和就绪集合进行FD_ISSET
判断; - 二是 select 返回后会把以前加入的但并无事件发生的 fd 清空,此时如果需要再次监控刚才的那些描述符,则需要先
FD_ZERO
,然后从 array 获取 fd 逐一加入监控集合中;
- 在向监控集合中添加描述符或者删除描述符时,需要维护一个 maxfd,这样可以很方便的为
select
函数设置第一个参数;
优点
缺点
- 每次调用
select
,都需要手动设置 fd 集合,从接口使用角度来说也非常不便; - 每次调用
select
,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大; select
监控原理是在内核中进行轮询遍历,性能随着描述符的增多而下降;select
支持的文件描述符数量有限,虽然可以自己设置,但是比较麻烦;select
返回的是就绪集合但是无法直接提供就绪描述符,需要进行遍历才能判断哪些描述符已经就绪了;
代码
封装select
#include <iostream>
#include <vector>
#include <time.h>
#include <sys/select.h>
#include "tcpsocket.hpp"
class Select{
public:
Select()
:_max_fd(-1)
{
FD_ZERO(&_rfds);
}
bool Add(TcpSocket &sock){
int fd = sock.GetFd();
FD_SET(fd, &_rfds);
_max_fd = _max_fd > fd ? _max_fd : fd;
return true;
}
bool Del(TcpSocket &sock){
int fd = sock.GetFd();
FD_CLR(fd, &_rfds);
for (int i = _max_fd; i >= 0; i--) {
if(FD_ISSET(i, &_rfds)) {
_max_fd = i;
break;
}
}
return true;
}
bool Wait(std::vector<TcpSocket> *arry) {
struct timeval tv;
tv.tv_sec = 3;
tv.tv_usec = 0;
fd_set tmp = _rfds;
int ret;
ret = select(_max_fd+1, &tmp, NULL, NULL, &tv);
if (ret < 0) {
perror("select error");
return false;
}else if (ret == 0) {
arry->clear();
return true;
}
for(int i = 0; i <= _max_fd; i++) {
if (FD_ISSET(i, &tmp)) {
TcpSocket sock;
sock.SetFd(i);
arry->push_back(sock);
}
}
return true;
}
private:
fd_set _rfds;
int _max_fd;
};
poll模型
接口
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
fds
:要监控的描述符事件结构数组,数组每个元素都为pollfd
结构体,每一个元素中包含了三部分内容:文件描述符、监听的事件集合、返回的事件集合;nfds
:数组中有效结点个数,或者是需要监控数组中前 nfds 个描述符;timeout
:超时时间,以毫秒为单位;- 返回值:等于 0 表示超时,小于 0 表示出错,有就绪事件则返回就绪个数;
struct pollfd{
int fd;
short events;
short revents;
}
struct pollfd
中 events 和 revents 的取值:点此访问
流程
- 定义事件结构体数组,为每个需要监控的描述符定义事件结构——
pollfd
结构体,然后将该结构体加入数组中; - 发起监控调用,将数组中有效结点拷贝到内核进行监控,超时 / 就绪则调用返回,返回前将描述符就绪的事件记录到结构体的 revents 成员中;
- 调用返回后,遍历事件数组,通过每个结点的 revents 成员确定对应节点描述符是否就绪了某个事件;
特性
- 优点
- 不同与
select
使用三个位图来表示三个 fdset 的方式,poll
使用一个pollfd
的指针实现,pollfd
结构包含了要监视的 event 和已就绪的 event,不再使用select
"参数–值"传递的方式,接口使用比select
更方便; poll
并没有最大数量限制(但是数量过大后性能也是会下降);
- 缺点
- 和
select
函数一样,poll
返回后,需要轮询遍历pollfd
来获取就绪的描述符; - 每次调用
poll
都需要把大量的pollfd
结构从用户态拷贝到内核中,这个开销很大; - 同时连接的大量客户端在同一时刻可能只有少数处于就绪状态,因此随着监视的描述符数量的增长,空遍历的次数增加,其效率会线性下降;
- 监控原理依然是轮询遍历,性能随着描述符增多而下降;
- 跨平台移植性较差;
epoll模型
概念
- 地位:按照 man 手册的说法,
epoll
是为处理大批量句柄而作了改进的poll
,它是在 2.5.44 内核中被引进的,它几乎具备了之前所说的一切优点,被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法;
接口
int epoll_create(int size);
:在内核中创建一个epoll
的句柄(一个eventpoll
结构体实例),用完之后, 必须调用close()
关闭;
size
:监控的数量上限,在 Linux2.6 之后被忽略,只要大于 0 即可;- 返回值:成功返回
epoll
描述符,失败返回 -1;
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
:事件注册函数;
epfd
:创建epoll
后返回的句柄;op
:表示该注册函数要执行什么操作,具体有三个选项;
EPOLL_CTL_ADD
:添加新的 fd 到 epfd 中;EPOLL_CTL_MOD
:修改已经注册的 fd 的监听事件;EPOLL_CTL_DEL
:从 epfd 中删除一个 fd;
fd
:需要监控的描述符;event
:针对要监控的描述符所定义的事件结构体;- 返回值:成功返回 0,失败返回 -1;
struct epoll_event{
uint32_t events;
epoll_data_t data;
}__EPOLL_PACKED;
typedef union epoll_data{
void* ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
:监控函数;
epdf
:创建epoll
后返回的句柄;events
:epoll_event
类型的数组首地址,监控调用返回后,该数组中保存了所有就绪描述符所对应的事件结构;maxevents
:告诉内核这个 events 数组有多大;timeout
:超时时间,以毫秒为单位,置 0 会立即返回,置 -1 是阻塞等待;- 返回值:超时返回 0,出错返回 -1,有就绪描述符则返回具体个数;
流程
- 在内核中创建一个
epoll
句柄,每一个epoll
对象都有一个独立的eventpoll
结构体实例,该结构体中主要成员为一个红黑树(rbr)和一个队列(rdllist),其它的暂时不用关注,红黑树中存储需要监控的文件描述符,当红黑树中有文件描述符就绪,就将该描述符的事件结构加入队列中;
- 在
epoll
中,对于每一个事件,都会建立一个epitem
结构体:
struct epitem{
struct rb_node rbn;
struct list_head rdllink;
struct epoll_filefd ffd;
struct eventpoll *ep;
struct epoll_event event;
}
- 向内核
epoll
句柄中添加要监控的描述符以及对应的事件结构,这些事件都会挂载在红黑树中,这样一来,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是 lg2n,其中 n 为树的高度); - 所有添加到
epoll
中的事件都会与设备(网卡)驱动程序建立回调关系,当某个描述符就绪时,就会调用相应的回调方法,这个回调方法在内核中叫ep_poll_callback
,它会将发生的事件添加到rdlist
双链表中; - 当调用
epoll_wait
检查是否有事件发生时,只需要检查eventpoll
对象中的rdlist
双链表中是否有epitem
元素即可,如果rdlist
不为空,则把发生的事件复制到epoll_wait
函数第二个参数所传入的数组中,同时将事件数量返回给用户,这个操作的时间复杂度是 O(1); - 监控调用返回后,只需要遍历 events 数组,逐个对节点中的描述符进行对应事件处理即可;
epoll事件触发方式
socket就绪条件
- 读就绪
socket
内核中,接收缓冲区中的字节数大于等于低水位标记SO_RCVLOWAT
(一个基准值,默认为 1 字节);socket
TCP 通信中,对端关闭连接;- 监听
socket
上有新的连接请求; socket
上有未处理的错误;
- 写就绪
socket
内核中,发送缓冲区的空闲位置大小大于等于低水位标记SO_SNDLOWAT
(一个基准值,默认为 1 字节);socket
的写操作被关闭(调用close()
或者shutdown(write)
);socket
使用非阻塞connect
连接成功或失败之后;socket
上有未读取的错误;
水平触发
- 概念:EPOLLLT,是描述符被监控时的默认方式;
- 特性:只要所监控的描述符就绪了,那么就触发事件;
- 实例:我们把一个 tcp
socket
添加到epoll
描述符中,这个时候socket
的另一端发送了 2KB 的数据,然后调用epoll_wait
并且返回了该socket
,这说明它已经准备好读取操作,调用read
,读取了 1KB 的数据,此时缓冲区还剩 1KB 的数据,当再次调用epoll_wait
时,epoll_wait
会立刻返回并通知socket
读事件就绪,直到缓冲区上所有的数据都被处理完,epoll_wait
才不会立刻返回;
边缘触发
- 概念:EPOLLET,该触发方式需要进行设置,在
epoll_ctl()
函数的第四个参数struct epoll_event
结构体中的 events 成员加入该方式,则表示当前描述符的监控已被设置为边缘触发; - 特性:只有当所监控的描述符状态发生改变,才会触发事件;
- 实例:我们把一个 tcp
socket
添加到epoll
描述符中,这个时候socket
的另一端发送了 2KB 的数据,然后调用epoll_wait
并且返回了该socket
,这说明它已经准备好读取操作,调用read
,读取了 1KB 的数据,此时缓冲区还剩 1KB 的数据,当再次调用epoll_wait
时,epoll_wait
不会再返回了,因为此种情况下,缓冲区的状态并没有改变,所以不会触发事件; - 优点:边缘触发可以减少
epoll
触发的次数,不过代价就是需要在一次响应就绪过程中就把
所有的数据都处理完,否则可能会出现问题; - 要求:使用 EPOLLET 触发方式的话,需要将描述符设置为非阻塞,这个不是接口上的要求,而是工程实践上的要求,下面举一个实例来说明这个问题;
- 假设有这样一个场景:客户端向服务端发送一个 10k 的请求,服务端收到了这个请求,在将全部请求数据处理完后才会向客户端返回一个应答数据,而如果客户端收不到应答,就不会发送第下一个请求;
- 假设服务端所监控的描述符是阻塞式的
read
,第一次调用epoll_wait
后,因为缓冲区中有 10k 数据,所以会返回描述符已就绪,此时我们只读出 1k 数据,那么剩下的 9k 数据就会待在缓冲区中; - 如果服务端所监控的描述符被设置为边缘触发,那么在第一次读完 1k 数据后,虽然缓冲区中仍有 9k 数据,但是接下来再次调用
epoll_wait
后,并不会返回就绪,这 9k 数据将会一直待在缓冲区中,直到有下一次新数据到来,epoll_wait
才会返回就绪; - 那么问题来了:
1、服务器只读到 1k 个数据,而它要读完 10k 才会给客户端返回响应数据;
2、客户端要读到服务器的响应,才会发送下一个请求;
3、客户端发送了下一个请求,epoll_wait
才会返回,服务端才能去读缓冲区中剩余的数据; - 这就形成了一个死循环,程序无法进行下去了,所以服务端在收到请求后,需要将所有数据全部读出,要想达成这个目的,我们需要循环将缓冲区中的内容取出,直到缓冲区中无数据,但是如果是阻塞读取,那么循环读取到缓冲区中没有数据时就会阻塞,因此必须设置为非阻塞,此时循环读取到缓冲区中没有数据时就会报错返回,这个错误为
EAGAIN
,我们通过这个错误码就能判断什么时候读取完毕;
特性
- 优点
- 所能监控的描述符数量没有上限;
- 描述符以及事件结构只需要向内核拷贝一次;
- 监控原理采用异步阻塞,监控由系统完成,进程只需要判断返回的就绪链表是否为 NULL 即可,性能不会随着描述符增多而下降;
- 监控完毕返回的是就绪描述符集合,不需要循环判断,减少空遍历;
- 缺点:跨平台移植性较差;
代码
封装epoll
#include <iostream>
#include <vector>
#include <cstdlib>
#include <sys/epoll.h>
#include "tcpsocket.hpp"
class Epoll{
public:
Epoll():_epfd(-1){
_epfd = epoll_create(1);
if (_epfd < 0) {
perror("epoll_create error");
exit(-1);
}
}
bool Add(TcpSocket &sock) {
int fd = sock.GetFd();
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN | EPOLLET;
int ret = epoll_ctl(_epfd, EPOLL_CTL_ADD, fd, &ev);
if (ret < 0) {
perror("epoll ctl add error");
return false;
}
return true;
}
bool Del(TcpSocket &sock) {
int fd = sock.GetFd();
int ret;
ret = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, NULL);
if (ret < 0) {
perror("epoll ctl del error");
return false;
}
return true;
}
bool Wait(std::vector<TcpSocket> *arry) {
struct epoll_event evs[10];
int ret;
ret = epoll_wait(_epfd, evs, 10, 3000);
if (ret < 0) {
perror("epoll_wait");
return false;
}else if (ret == 0) {
arry->clear();
std::cout << "timeout\n";
return true;
}
for (int i = 0; i < ret; i++) {
if (evs[i].events & EPOLLIN) {
TcpSocket sock;
sock.SetFd(evs[i].data.fd);
arry->push_back(sock);
}
}
return true;
}
private:
int _epfd;
};
边缘触发下的数据接收
bool Recv(std::string *buf) {
char tmp[4096] = {0};
int len = 4096;
int total = 0;
while(total < len){
int ret=recv(_sockfd, tmp + total, 5, 0);
if (ret < 0) {
if (errno == EAGAIN){
printf("所有数据都读完了\n");
break;
}
perror("recv error");
return false;
}else if (ret == 0) {
printf("peer shutdown");
return false;
}
total += ret;
}
buf->assign(tmp, total);
return true;
}
服务端代码
- 无论是基于
select
还是基于epoll
,服务端的代码都十分类似,如果基于epoll
所监控的描述符是水平触发,那么二者的代码一模一样,如果基于epoll
所监控的描述符是边缘触发,那么在epoll
的的代码中需要将所监控的描述符设置为非阻塞的,其他一模一样;
#include "tcpsocket.hpp"
#include "select.hpp"
#include "epoll.hpp"
int main(int argc, char *argv[])
{
if (argc != 3) {
printf("usage: ./tcp_src 192.168.2.2 9000\n");
return -1;
}
bool ret;
std::string srvip = argv[1];
uint16_t srvport = std::stoi(argv[2]);
TcpSocket lst_sock;
CHECK_RET(lst_sock.Socket());
lst_sock.SetNonBlock();
CHECK_RET(lst_sock.Bind(srvip, srvport));
CHECK_RET(lst_sock.Listen());
Epoll s;
s.Add(lst_sock);
while(1) {
std::vector<TcpSocket> arry;
ret = s.Wait(&arry);
if (ret == false) {return 0;}
for (int i = 0; i < arry.size(); i++) {
if (arry[i].GetFd() == lst_sock.GetFd()){
TcpSocket clisock;
std::string ip; uint16_t port;
ret=lst_sock.Accept(&clisock,&ip,&port);
if (ret == false) {continue;}
std::cout<<"conn:"<<ip<<"-"<<port<<"\n";
s.Add(clisock);
}else {
std::string buf;
ret = arry[i].Recv(&buf);
if (ret == false) {
s.Del(arry[i]);
arry[i].Close();
continue;
}
std::cout<<"client say: "<<buf<<std::endl;
buf.clear();
std::cout << "server say: ";
std::cin >> buf;
ret = arry[i].Send(buf);
if (ret == false) {
s.Del(arry[i]);
arry[i].Close();
}
}
}
}
lst_sock.Close();
}