代码在:
https://github.com/sjmshsh/System-Call-Learn
通过阅读本篇文章,你可以收获:
- 理解五种IO模型的基本概念,重点是IO多路转接
- 掌握select,poll,epoll系统调用接口并实现简易的TCP服务器
- 理解epoll的底层原理
- 理解epoll的LT模式和ET模式
- 理解select和epoll的优缺点和对比
五种IO模型
把数据拷贝到磁盘上,接受网络数据等等,这些工作都是操作系统内核完成的,其实外面调用系统调用的最终目的是进行拷贝操作,把数据从用户态缓存区拷贝到内核缓冲区,或者把数据从内核缓冲区拷贝到用户缓冲区,我们把这些行为统称为IO,以读取网络数据为例,其实这就是一个生产者消费者模型,我们的用户态缓存是消费者,网络对方是生产者,内核缓冲区是临界区。
那么对端没有数据的时候,我们前面写的大部分程序都不得不等待,所以IO不只是把数据拷贝过去,把数据读进来也是一种拷贝,等待也属于IO的一个环节。所以定义:
IO = 拷贝数据 + 等待
那么什么是高校的IO呢?
在软件层,本质上就是减少单位时间内等待的比重。
因此所有IO的话题基本都是在讨论两个东西:改变等待的方式,减少等待的比重。
小故事
以钓鱼为故事背景。
张三 一心一意,我一心一意,除了钓鱼什么都不能干。
李四 三心二意,我一会儿看鱼钓到了没有,一会儿找别人聊天。
王五 聪明人,我在鱼竿上面挂了一个铃铛,然后就彻底搞别的东西去了,当鱼上钩的时候,铃铛声就告诉我鱼已经上钩了。
张三和李四都是主动关注鱼有没有上钩,只是张三除了这件事情之外什么都干不了了,而李四可以去干一些别的事情
而王五没有主动关注鱼有没有上钩,因为铃铛会告诉我鱼什么时候上钩了,在这期间,王五可以干任何事情
这个时候又来了一个人:赵六 有钱人。
张三,李四,王五都只有一把鱼竿。而赵六搞了一卡车鱼竿。把所有的500个鱼竿都插在岸边。
我一眼扫过去,看哪个鱼竿有反应就去哪个鱼竿。
田七 更有钱了,老板。
所以田七直接让别人去钓鱼,田七给别人钓鱼相关的工具,并且给了他电话和水桶。(这个水桶很重要,因为异步IO必须要有一个缓冲区,我们把调用得到的结果存放在缓冲区里面,同步就不需要,因为同步可以直接得到调用结果)
田七说:“我去开会了,你帮我钓鱼,钓的鱼放在桶里面,等你把桶放满了之后,给我打电话。”
那么从钓鱼的角度来看的话,谁的钓鱼效率最高呢?
钓鱼要分两步 = 等 + 钓
那么钓鱼效率怎么表示呢?答案是等的比重越小,钓鱼效率越高。
张三,李四,王五,田七都只有一个鱼竿,概率是1 / 500(假设一共有500个鱼竿在钓鱼),但是赵六有490个鱼竿,也就是他的概率是490 / 500,明显赵六的概率大多了。
赵六的效率其实是最高的,赵六的方式叫做多路转接,鱼竿相当于是fd(文件描述符)
张三叫做阻塞等待
李四叫做非阻塞等待
张三和李四在钓鱼的效率上是完全一样的,但是李四和张三不同的地方在于他们等的方式不一样,李四在等的过程中在干别的事情。
等于上钩=等IO事件就绪
王五叫做信号驱动,它有回调函数方案。
赵六有很多个鱼竿,从钓鱼的角度效率肯定是最高的,叫做多路转接,或者多路复用
田七只是发起了钓鱼,交给了小王来全部完成,通过电话通知田七,田七直接拿走了所有的鱼(桶是田七的)
田七这种方案叫做异步IO。
张三,李四,王五,赵六都是整体参与了钓鱼这个过程的,王五表面上没有参与,但是铃铛只是告诉王五有鱼来了,真正的把鱼钓上来王五还是得亲自去,而田七是真正的全盘都没有参与。张三,李四,王五,赵六叫做同步IO,田七叫做异步IO。
同步和异步的区别只有一个:就是你是否参与IO的过程,IO=等+拷贝
网上的同步和异步的标准概念:
同步与异步
同步和异步是消息的通知机制。
同步就是在发出一个功能调用的时候,在没有得到结果之前,该调用就不返回。
同步:同步是指一个进程在执行某个请求的时候,如果该请求需要一段时间才能返回信息,那么这个进程会一直等待下去,直到收到返回信息才继续执行下去。
异步:异步是指进程不需要一直等待下去,而是继续执行下面的操作,不管其他进程的状态,当有信息返回的时候会通知进程进行处理,这样就可以提高执行的效率了,即异步是我们发出的一个请求,该请求会在后台自动发出并获取数据,然后对数据进行处理,在此过程中,我们可以继续做其他操作,不管它怎么发出请求,不关心它怎么处理数据。
以上总结起来,通俗地讲,也就是说,同步需要按部就班地走完一整个流程,完成一整个动作,打个比方:同步的时候,你在写程序,然后你妈妈叫你马上拖地,你就必须停止写程序然后拖地,没法同时进行。而异步则不需要按部就班,可以在等待那个动作的时候同时做别的动作,打个比方:你在写程序,然后你妈妈让你马上拖地,而这时你就贿赂你弟弟帮你拖地,于是结果同样是拖好地,你可以继续敲你的代码而不用管地是怎么拖的哈哈。
同步与异步适用的场景
就算是ajax去局部请求数据,也不一定都是适合使用异步的,比如应用程序往下执行时以来从服务器请求的数据,那么必须等这个数据返回才行,这时必须使用同步。而发送邮件的时候,采用异步发送就可以了,因为不论花了多长时间,对方能收到就好。总结得来说,就是看需要的请求的数据是否是程序继续执行必须依赖的数据
前面说的有点儿杂,现在总结一下:
- 张三是阻塞IO,因为张三一直在等待钓鱼的过程
- 李四是非阻塞IO,因为李四在等待钓鱼的过程的时候可以干其他事情,但是要时不时的查看鱼是否上钩了
- 王五是信号驱动IO,因为王五的鱼竿上面有铃铛,当铃铛响的时候,王五就可以自己钓鱼了。这个信号驱动让王五在等待钓鱼这件事情上面是异步的因为王五不需要一直在旁边干等钓鱼这件事情。但是对于钓鱼的整个事件来说,王五一直在岸边,他一直在钓鱼,等鱼来的时候他必须亲自把鱼钓上来,因此这是一种同步。
- 赵四只是鱼竿多一点,是多路IO复用,其本质也是同步,因为把鱼钓上来也是赵四做的
- 田七是真正的异步IO,他发起了钓鱼这件事情,然后自己开会去了,不需要去等待钓鱼这件事情的完成再去开会。
小故事讲完了,相信大家现在对基本的IO模型已经有认知了,那么我们现在用官方一点的话语来重新讲解一遍。
阻塞IO
我们前面使用过的大部分的IO接口,例如read,write,recvfrom都是阻塞IO,调用后就等待数据到达,数据到达后,从内核缓冲区拷贝到用户缓冲区然后返回,这就是典型的阻塞IO。
非阻塞IO
非阻塞等待就是每次轮询问操作系统,数据准备好了没有,没有准备好就立马返回,做别的事情,如果准备好了就把数据拷贝过来,我们在前面使用非阻塞等待方式等待子进程是同样的思路。
信号驱动IO
首先自定义注册一个信号方法SIGIO
,然后调用后做自己的事情,操作系统东西来了就会发信号,通过信号通知我们,然后就会去执行曾经注册的信号来拷贝数据。
多路转接IO
使用select
,poll
,epoll
,他们相当于把等待工作直接交给几个接口,调用方法只负责拷贝,就绪了我来告诉你,你来拷贝,相当于把文件描述符交给这些接口,然后不断的用别的接口去只做拷贝工作。
异步IO
我调用和异步IO函数,顺便把一个缓冲区给操作系统,当数据准备好了,操作系统给我发个信号,让我来处理就好了,进程本身是不参与IO,只是发起了IO,成为异步IO,只要等或者拷贝有一方你是参与的,你就不是异步IO,是同步IO。
IO事件就绪:又叫做等事件就绪,分为写事件就绪和读事件就绪,就绪了意思就是你要的数据的个数已经超过了某个阈值,可以决定来拷贝到内核态了。
非阻塞IO的使用
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
int main()
{
while (true)
{
char buffer[1024];
ssize_t s = read(0, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = '\0';
write(1, buffer, strlen(buffer));
}
}
return 0;
}
这个代码是经典的阻塞IO。
现在我们利用一个系统调用接口改成非阻塞。
这个函数的功能是:
/* Values for the second argument to `fcntl'. */
#define F_DUPFD 0 /* Duplicate file descriptor. */
#define F_GETFD 1 /* Get file descriptor flags. */
#define F_SETFD 2 /* Set file descriptor flags. */
#define F_GETFL 3 /* Get file status flags. */
#define F_SETFL 4 /* Set file status flags. */
我们利用功能三,可以吧文件描述符设置为非阻塞状态。
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
void SetNonBlock(int fd)
{
// 获得对应文件描述符的文件状态标志
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main()
{
// 把read设置为非阻塞
SetNonBlock(0);
while(1)
{
char buffer[1024];
ssize_t s = read(0, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = '\0';
write(1, buffer, strlen(buffer));
printf("read success, s : %d, errno : %d\n", s, errno);
}
else
{
printf("read fail, s : %d, errno : %d\n", s, errno);
}
sleep(1);
}
return 0;
}
在非阻塞的情况下,如果数据没有准备就绪,系统会以错误的形式返回,但是这其实并不是一种错误。
没有就绪和真正的出错都是以出错形式返回,那么怎么区别?利用errno
。
修改的代码如下:
IO多路转接之select
select定位:只负责等待,得到fd就绪,通知上层进行读取或者写入,它本身没有读取或者写入的功能。
但是read,write,recv,send本身也又等待的功能,那为什么还要用select呢?
因为select的作用是它可以同时等待多个fd,而上面的接口只能等待一个fd。
我们现在来了解一下这个系统调用接口:
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数含义:
-
nfds是所等待的最大的文件描述符值+1,nfds = maxfd + 1。
-
fd_set是一个位图结构,比特位的位置代表哪一个文件描述符。
-
我们想要什么样的等待结果呢,一般是三类:读就绪、写就绪、异常就绪,所以这三个参数readfds、writefds、exceptfds表示关心这三个情况的位图。
-
以读为例,两点:1.用户告诉内核,你要帮我关心那些fd上的读事件就绪 2.内核告诉用户,你所关心的哪些fd的读事件已经就绪。 这就是select的核心功能。
-
这三个参数readfds writefds exceptfds都是输入输出型参数,输入时,几号文件描述符被置1了,就说明几号文件描述符需要别关心,输出时,如果某位被置1了,那么就说明这个事件就续了。
-
select有三种等待策略:
- 只要不就绪,那就不返回——阻塞等待
- 只要不就绪,立马返回;——非阻塞等待
- 设置好deadline,deadline之内如果如果补就绪就等待,就绪了就返回,deadline之外立刻返回。
- 第一个参数是秒,第二个参数单位是微秒。
- timeout参数也是一个输入输出型参数,输入时表示前面说的设置deadline,返回等待还剩余多少秒返回了,如果是超时了,时间就是0.
- timeout一般有3种设置方式,nullptr:阻塞等待,timeout = {0, 0}: 非阻塞,timeout = {5, 0}:等5s。
返回值含义:
表示有多少个事件就绪了,=0表示超时,小于0表示出错,如你有文件描述符根本就没打开你让我等。
设置位图:
为了操作位图,os也给我们提供了接口:
FD_CLR | 把文件描述符fd设置出位图 |
---|---|
FD_ISSET | 判定一个文件描述符是否在位图中 |
FD_SET | 把文件描述符fd设置入位图 |
FD_ZERO | 清空位图 |
这里先埋下一个坑,我们来看一下这个位图有多大:
#include <iostream>
#include <sys/select.h>
using namespace std;
int main()
{
cout << sizeof(fd_set) * 8 << endl;
return 0;
}
运行得到大小是1024,是一个有限度的,可以被用完的空间大小
然后我们来用select实现一个TCP server,代码在GitHub里面,可以去自行查看。
你实现一下select就可以很明显的发现它的缺陷了!!
-
select因为输入输出型参数表示的含义不同,意味着每一轮调用select,都要对fd_set进行重新设置,这个重新设置是select的缺点,因为它需要进行一次遍历,时间复杂度提升了。
-
每次fd_set更新后,相当于原本的文件描述符信息消失了,我们需要自己去保存原有的文件描述符,所以用第三方数组或者容器来控制。
-
fd_set的大小是确定的(1024),所以select能检测的文件描述符也是有上限的。但是我们查看我们有多少打开的文件
太多了!!!
-
select底层需要轮询的检测fd的读写事件就绪,第一个参数是文件描述服务+1就是用来遍历的,速度不快。
-
select会较为高频的进行用户到内核,内核到用户的拷贝问题,每次都要重新设置文件描述符位图,每次os也要把这些重新关心,每次都要把fd_set从用户拷贝到内核,再从内核拷贝回用户,然后在位图作为输出型参数再修改一遍。
socket就绪条件
读就绪
- socket内核接收缓冲区中的字节数,大于等于低水位标记SO_RCVLOWAT.此时可以无阻塞的读取文件描述符,并且返回值大于0。
- socketTCP通信中,对端关闭链接,此时socket读,则返回0;
- 监听socket上有新的链接请求;
- socket上有未处理的错误
写就绪
- socket内核中,发送缓冲区中的可用字节数大于等于低水位标记SO_RCVLOWAT,此时可以无阻塞的读取该文件描述符,并且返回值大于0;
- socket的鞋操作被关闭(close活shutdown),对一个写关闭的socket进行操作,会触发SIGPIPE信号;
- socket上使用非阻塞connect链接成功或失败之后;
- socket上有未读区的错误。
IO多路转接之poll
先说结论,poll比select好一点,但是本质上没有发生改变。
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
- fds是一个poll函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合.
- nfds表示fds数组的长度.
- timeout表示poll函数的超时时间, 单位是毫秒
通过events | POLLIN
运算把事件添加进来即可,检查时用revents & POLLIN
即可。
返回结果:
- 返回值小于0,表示出错
- 返回值等于0,表示poll函数等待超时
- 返回值大于0,表示poll由于监听的文件描述符就绪返回
poll的优势和劣势
- 他的优势是没有像select一样的固定长度,这是poll唯一的优势,其他的select的劣势poll一个都没有解决。
IO多路转接之epoll
epoll初始
epoll是干嘛的呢,也和select和poll一样,只负责“等”,通过用户设置的某些fd及其事件,告知内核,让内核帮用户关心,一旦就绪,就通知上层,作用和select和poll是一样的。
按照man手册的说法,是为了处理大批量句柄而作了改进的poll(extend poll),它实在内核2.5.44版本中被引入的(MacOS中就没有epoll,但有与之类似的kqueue。
它具备很多优点,被公认为Linux2.6下性能最好的多路IO就绪通知方法。
epoll相关的系统调用
epoll有3个系统调用:
int epoll_create(int size);
创建一个epoll句柄:
- 自从linux2.6.8之后,size参数是被忽略的.
- 用完之后, 必须调用close()关闭
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
- 它不同于select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型.
- 第一个参数是epoll_create()的返回值(epoll的句柄).
- 第二个参数表示动作,用三个宏来表示.
- 第三个参数是需要监听的fd.
- 第四个参数是告诉内核需要监听什么事
第二个参数的取值:
- EPOLL_CTL_ADD :注册新的fd到epfd中;
- EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
- EPOLL_CTL_DEL :从epfd中删除一个fd
然后来看一下这个结构体:
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_PACKED;
events可以是这几个宏的集合:
- EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
- EPOLLOUT : 表示对应的文件描述符可以写;
- EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
- EPOLLERR : 表示对应的文件描述符发生错误;
- EPOLLHUP : 表示对应的文件描述符被挂断;
- EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
- EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里
这里可以看到里面有一个属性是边缘触发和水平触发,这两个点我们后面会进行讲解。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
收集在epoll监控的事件中已经发送的事件.
- 参数events是分配好的epoll_event结构体数组.
- epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存).
- maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size.
- 参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞).
- 如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函数失败
相关代码可以在GitHub上面去看。
epoll工作原理
- 红黑树
- 回调机制
- 就绪队列
我们先来思考一个问题:
我们知道我们的OS底层还有网卡驱动和网卡硬件。
那么问题来了,OS是如何知道我的网卡驱动里面有数据需要读取了呢?
通过中断的方式来告知操作系统,例如我在键盘上输入abcd按回车的时候,计算机硬件会通过中断的方式,告诉CPU我有数据准备好了,然后内核根据中断向量表,再调用相应的系统调用接口,把数据拷贝到内核。
- 凡是在红黑树中的节点,对应的fd和事件,就是OS需要关心的!
- 我们有一个缓冲区,当有数据来的时候,会调用相应的回调机制,回调机制的内容是把相关的信息添加到一个就绪队列中,然后当CPU发现这个就绪队列不为空的时候,就代表有数据就绪了,就会唤醒相应的进程,去执行相应的数据。
那么epoll的3个接口和这些东西有什么关系呢??
epoll_create: 在操作系统中创建红黑树,创建就绪队列,告知操作系统我需要使用回调机制了。
因为epoll模型是很多数据结构组成的,数据结构是可以用结构体抽象的,所以我们只需要在进程结构体的fd数据里面就可以找到对应的epoll模型,因此epoll_create返回的是文件描述符。
epoll_ctl:在红黑树中添加相应的节点,这样OS就相当于记忆了你需要用的fd。然后建立fd对应的回调策略。
epoll_wait以O(1)时间复杂度,检测是否有事件就绪。
就绪队列:
struct event_queue {
// 就绪队列的头节点
read_events *head;
//
task_struct *q;
}
当进程要访问这个队列,然后发现这个队列为空的时候,操作系统把进程的状态设置为非R的状态,然后链到q里面。
然后当你的head不为空的时候,我就可以直接从这个q里面找到对应的PCB,然后设置为R,就可以直接运行了。
所以我的进程等待就绪的本质就是:
我的进程要等待的东西一定要先组织再描述,先组织再描述就一定需要一个结构体,结构体里面存放的是我的相关资源的数据结构,以及我等待的进程PCB。当资源就绪的时候,我们就可以直接从它的结构体里面找到PCB,然后唤醒它,然后去执行相关的操作。
也就是说唤醒进程的本质是:操作系统发现资源就绪了(数据结构不为空),然后通过这个资源的结构体找到对应的PCB,然后操作系统把PCB的状态设置为R,然后进程这个时候才被唤醒,去执行相应的操作。
系统有很多就绪队列,是运行队列跟CPU有关系,所以运行独立只有一个。我们所谓的等待某个东西,都是跟上面一样,在就绪队列里面这样等待的。
那么我们再继续扩大一点,其实所有的外设,例如键盘等等,他们也是有对应的数据结构的,操作系统在等待键盘进行输入等等操作的过程中,也是涉及到了我们上面所说的等待。而这个等待就是,我们的外设的数据结构里面有一个等待队列,这个队列里面把PCB给放进去了,当有数据的时候就可以直接找到对应的PCB。
-
红黑树节点是通过fd构建的,fd作为key
-
为什么要有红黑树这个东西,我不要它不行吗???
答:epoll和poll的一个很大的区别在于,poll每次调用时都会存在一个将pollfd结构体数组中的每个结构体元素从用户态向内核态中的一个链表节点拷贝的过程,而内核中的这个链表并不会一直保存,当poll运行一次就会重新执行一次上述的拷贝过程,这说明一个问题:poll并不会在内核中为要监听的文件描述符长久的维护一个数据结构来存放他们,而epoll内核中维护了一个内核事件表,它是将所有的文件描述符全部都存放在内核中,系统去检测有事件发生的时候触发回调,当你要添加新的文件描述符的时候也是调用epoll_ctl函数使用EPOLL_CTL_ADD宏来插入,epoll_wait也不是每次调用时都会重新拷贝一遍所有的文件描述符到内核态。当我现在要在内核中长久的维护一个数据结构来存放文件描述符,并且时常会有插入,查找和删除的操作发生,这对内核的效率会产生不小的影响,因此需要一种插入,查找和删除效率都不错的数据结构来存放这些文件描述符,那么红黑树当然是不二的人选。
有人说,那我直接在回调里面做这些事情不就可以了吗,可以,从技术上可以实现,但是回调强调的是逻辑,使用红黑树专门记录这些关系是最好的。也很清晰。
我们完善一下epoll的代码:
#include <iostream>
#include <sys/epoll.h>
#include <unistd.h>
#include <string>
#include "Sock.hpp"
#include <cstdlib>
#define SIZE 128
#define NUM 64
static void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
exit(1);
}
// 1.建立tcp 监听socket
uint16_t port = (uint16_t)atoi(argv[1]);
int lisen_sock = Sock::Socket();
Sock::Bind(lisen_sock, port);
Sock::Listen(lisen_sock);
// 2. 创建epoll模型,获得epfd(文件描述符)
int epfd = epoll_create(SIZE);
// 3. 先添加listen_sock和它所关心的事件添加到内核
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = lisen_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, lisen_sock, &ev);
// 4. 事件循环
volatile bool quit = false;
struct epoll_event revs[NUM];
while (!quit)
{
int timeout = 1000;
// 这里传入的数组,仅仅是尝试从内核中拿回来已经就绪的事件
int n = epoll_wait(epfd, revs, NUM, timeout);
switch (n)
{
case 0:
std::cout << "time out ..." << std::endl;
break;
case -1:
std::cerr << "epoll error ... " << std::endl;
break;
default: // 有事件就绪
std::cout << "有事件就绪!" << std::endl;
// 5. 处理就绪事件
for (int i = 0; i < n; i++)
{
int sock = revs[i].data.fd; // 暂时方案
std::cout << "文件描述符: " << sock << "上面有事件就绪了" << std::endl;
if (revs[i].events & EPOLLIN)
{
std::cout << "文件描述符: " << sock << "读事件就绪" << std::endl;
if (sock == lisen_sock)
{
std::cout << "文件描述符: " << sock << "链接数据就绪" << std::endl;
// 5.1 处理链接事件
int fd = Sock::Accept(lisen_sock);
if (fd >= 0)
{
std::cout << "获取新链接成功了: " << fd << std::endl;
// 能不能立即读取呢??不能!
struct epoll_event _ev;
_ev.events = EPOLLIN;
_ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &_ev); // 新的fd托管给了epoll
std::cout << "已经将" << fd << "托管给epoll了" << std::endl;
}
else
{
// DO nothing!
}
}
else
{
// 5.2 正常的读取处理
std::cout << "文件描述符: " << sock << "正常数据就绪" << std::endl;
char buffer[1024];
ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
if (s > 0)
{
buffer[s] = 0;
std::cout << "client [" << sock << "]# " << buffer << std::endl;
}
else if (s == 0)
{
// 对端关闭链接
std::cout << "client quit " << sock << std::endl;
close(sock);
epoll_ctl(epfd, EPOLL_CTL_DEL, sock, nullptr);
std::cout << "sock: " << sock << "delete from epoll success" << std::endl;
}
else
{
// 读取失败
std::cout << "recv error " << sock << std::endl;
close(sock);
epoll_ctl(epfd, EPOLL_CTL_DEL, sock, nullptr);
std::cout << "sock: " << sock << "delete from epoll success" << std::endl;
}
}
}
else if (revs[i].events & EPOLLOUT)
{
// 处理写事件
}
else
{
// TODO
}
}
break;
}
}
close(epfd);
close(lisen_sock);
return 0;
}
epoll工作方式:LT模式和ET模式
这个东西select和poll是没有的,不需要关心
- LT:水平触发
- ET:边缘触发
LT是只要你还没拿完这个就绪队列中的东西,每轮epoll_wait都会提醒我们;而ET是提醒我们一次后,不管有没有拿完就绪队列中的事情,都不再提醒了,相对效率会高一点。
显然,select/poll只有LT模式,没有ET模式,epoll默认处于LT模式。
这个水平的意思是示波器一直处于通知的高电频,称为水平触发,从低电频变化时去告诉的情况,称为边缘触发。
那么怎么修改epoll为ET模式呢?
在每次添加事件时,在修改epoll_event中的events成员变量改为EPOLLIN | EPOLLET即可。
修改listen_sock的事件状态,并且注释掉accept试一下:
发现确实只提醒一次了。
但是有一个问题,ET模式下,有事件就绪只会通知一次,那如果准备读取时,怎么保证将所有的过来的东西全部读取完呢?比如来了三个链接,答案是不能保证。。。所以只能循环读取,那么什么时候读取完毕呢,循环读取一定在读取的最后一次卡住,也就是被阻塞住,这对服务器来说是不能接受的,为了解决这个问题,要将epoll在ET模式下的文件描述符设置为非阻塞。
epoll使用场景
epoll的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll的性能可能适得其反.
对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用epoll.
例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网APP的入口服务器, 这样的服务器就很适合epoll.
如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll就并不合适. 具体要根
据需求和场景特点来决定使用哪种IO模型
epoll中的惊群问题简单了解
在多线程或者多进程环境下,有些人为了提高程序的稳定性,往往会让多个线程或者多个进程同时在epoll_wait监听的socket描述符。当一个新的链接请求进来时,操作系统不知道选派那个线程或者进程处理此事件,则干脆将其中几个线程或者进程给唤醒,而实际上只有其中一个进程或者线程能够成功处理accept事件,其他线程都将失败,且errno错误码为EAGAIN。这种现象称为惊群效应,结果是肯定的,惊群效应肯定会带来资源的消耗和性能的影响。
epoll的优点(解决了select和poll的问题)
- 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
- 数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
- 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中,
- epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响.
- 没有数量限制: 文件描述符数目无上限