Linux高级IO
1、理解IO
关于IO,我们调用read、write,有数据就能直接读取上来,没有数据就得等,这就是阻塞式的IO。
网络通信的本质就是进程间通信,进程间通信就是IO,I:INPUT,O:OUTPUT。
当我们收到数据是网卡先收到的,网卡会给CPU针脚发送硬件中断,操作系统会根据中断号去中断向量表找到对应方法并执行,将数据从网卡拷贝到操作系统中,操作系统再不断向上解包分用,最后拷贝到TCP的接收缓冲区,上层用户读取,由于TCP是面向字节流的,所以上层用户还要解决数据包的粘包问题。I:就是网卡收到数据,然后拷贝到操作系统内,再拷贝到TCP的接收缓冲区,用户上层调用read/recv读取。O:上层用户将数据拷贝到TCP的发送缓冲区,向下封装通过网卡发送到网络中。站在进程的角度,I就是将数据读取上来交给进程,O就是进程将数据交给网卡发送。
进程如何IO呢?——通过read、write、recv、send系统调用。
如何更好的理解IO,什么叫作高效的IO?
当服务器进程启动了,并不是就直接进行IO了,服务器大部分时间都是在等,比如客户端服务器链接已经建立好了,客户端给服务器发送数据,但是数据在网络中转发可能需要花很长时间,所以接收方就得等。甚至发送方发送缓冲区没有数据,发送方不发送数据,服务端调用recv/read就得一直等。所以在网络IO中,大部分时间都是在等待的。当等了一端时间,将数据从你的发送缓冲区拷贝到我的接收缓冲区,这时候就会将我的接收缓冲区中的数据拷贝到用户空间,这个拷贝才是真正意义上的IO。
所以,IO = 等 + 拷贝
等<->不用等,从等到不用等或者从不用等到要等,涉及了条件变化。
比如你要发送数据,如果发送缓冲区满了,那么你就要等,等到发送缓冲区有空间了,才能将数据拷贝到发送缓冲区,这是从等到不用等的过程。再比如你要读取数据,如果接收缓冲区没有数据你就要等,当接受缓冲区有数据了,这时候就可以将接收缓冲区的数据拷贝到用户空间,这也是从等到不用等的过程。而当你把发送缓冲区写满了,这时候你就不能再写入了,这时候就需要等,这是从不用等到等的过程。
因此在IO中涉及条件变化,比如read读取,刚开始接收缓冲区没有数据,要等到接收缓冲区有数据了才能读取,这就是条件发生了变化。这时候就是从等->不用等。而我们把IO中条件变化称为IO事件。
IO = 等 + 拷贝,其中等是主要矛盾,我们大部分时间都是在等。那么什么叫做高效的IO呢?
首先我们要知道,任何通信场景,IO效率一定是有上限的。就比如一个花盆里的小树苗是不可能长成参天大树的。IO的上限受到了硬件的上限。
所以高效的IO就是:IO = 拷贝。这种情况带来的结果就是充分利用硬件资源。所以我们要提高IO效率,就是要降低IO中等的比重。
高效的IO:单位时间内,等的比重越低,IO效率就越高。
2、五种IO模型
下面讲几个故事,以钓鱼为例,我们假设钓鱼的前置所有工作都做好了,现在已经坐在岸边了,那么这时候钓鱼就分为两步,钓鱼 = 等 + 钓。钓鱼的主要矛盾就是等,要高效的钓鱼就是减少等的比重。
张三:张三是村里的年轻人,属于一个新入坑钓鱼的,今天张三过来村里的河边把前置工作都做好了,鱼钩挂上鱼饵,然后就把鱼钩扔到河里,但是张三钓鱼有个特点,钓鱼的时候全身心投入,眼睛死死的盯着鱼漂,谁叫他他都不答应,鱼漂不动他也不动,当鱼漂动了,他就把鱼竿拉上来,成功钓上来一条鱼。
李四:大概过了一个多小时,又来一个钓友叫做李四,李四已经钓了一两年了,是一个有一点经验的钓友了。李四也带上和张三相同的七七八八的装备,李四看到张三就跟张三说话,但是张三理都不理,李四自讨没趣,但是张三和李四是认识的,所以李四就坐在张三旁边,把鱼钩扔到河里开始钓鱼。李四钓鱼也有特点,李四检测一下鱼漂,发现鱼漂没有什么大动作很平稳,李四就转过头继续跟张三说话,但是张三还是不理他,李四就掏出手机刷了会抖音,玩了一会又觉得没意思继续掏出报纸来看,过了一会儿又觉得没意思就瞥了一眼鱼漂,发现还是没有鱼咬钩,然后重复上述动作。当发现鱼咬钩了,李四马上站起来收杆,李四也钓上来一条鱼。
王五:中午王五吃完饭也带着自己的装备来了,王五是很有经验的钓鱼佬,瞥了一眼这两人,很不屑,两个新手,一个一动不动,一个一直在动。王五也做好准备工作,然后从口袋里摸出来一个铃铛,把铃铛挂在了鱼竿的顶部,把鱼钩扔到河里,然后鱼竿插在岸边。从此之后王五头也不抬,一直在玩手机,过了一会王五突然听到铃铛响了,王五继续头也不抬拉起鱼竿,钓上了一条鱼。然后重复上述动作。
赵六:过了一会又来了个赵六,赵六是村里的首富,赵六也是一个资深的钓鱼佬,赵六开着一辆三轮车过来,拉来了一车鱼竿(100根鱼竿),把这一百根鱼竿全部挂上鱼饵,将鱼钩都扔到河里然后插在岸边。然后赵六就来回踱步,从左向右,从右向左,不断检测是否有鱼咬钩了。当有鱼咬钩了,赵六直接就把那个鱼竿抬起来,成功钓上来一条鱼,然后将鱼竿恢复继续插在岸边,重复来回检测。
田七:后来来了一家公司的上市老总田七,田七也是从这个村里出来的,之前也是经常钓鱼,田七是方圆五百公里的首富。田七坐在车后排,司机带着他刚好路过河边,田七看到这四个货在这钓鱼,田七说我也想钓鱼。但是田七在心里盘算着,我不是喜欢钓鱼,我是喜欢吃鱼。田七就给司机小王说,小说把车停路边,我下去钓会鱼。可是当车停下来,田七刚下车,突然打来一个电话,公司要破产了,有一个特别紧急的会议,田七你赶紧过来参加会议。田七这时候就想,我还是得去处理我的事情,但是我也挺想吃鱼的,所以田七从车后备箱拿出一堆渔具给司机小王,特别是还拿了一个水桶和电话。告诉司机小王说,小王我想吃鱼,但是公司有紧急的事情,你去帮我钓鱼,当你把水桶钓满了,你打电话给我,我开车来接你。说完田七开着车扬长而去忙他的事情了,小王就去钓鱼了。当小王在钓鱼的时候田七就在公司里面开会,两个人各忙各的。
我们在进行IO都是通过文件描述符进行IO的,张三李四王五等人都有自己的鱼竿,鱼竿就是对应的文件描述符。张三李四王五等人本质就是一个进程。钓鱼就是系统调用/函数调用。
1、张三这种我们称之为阻塞IO。
2、李四检测没有鱼咬钩不会卡住,而是会做自己的事情,我们称之为非阻塞IO。但是检测一次没有就绪直接返回这是1次,而李四会不断重复上述过程,因此是轮询,李四就是非阻塞IO + 轮询。
3、王五在钓鱼头也不抬,但是王五在钓鱼之前他知道铃铛响了自己该做什么,甚至还没有钓鱼,在家里的时候王五就知道铃铛响了该做什么,王五不对鱼漂是否动做检测,只有当铃铛响了才会做对应动作,这种我们称为信号驱动式IO。
4、赵六一次可以等待多个文件描述符的方式我们称为IO多路转接或IO多路复用。
5、田七并没有钓鱼,田七只是发起了钓鱼,让别人给他钓。田七给别人缓冲区(水桶),通知方式(电话)。当缓冲区写满了,通过一定的方式来通知田七。田七这种钓鱼方式我们称为异步IO。
问题1:阻塞 VS 非阻塞
阻塞与非阻塞有什么区别?IO = 等 + 拷贝,它们拷贝的动作都是一样的,本质区别在于等的方式不同。阻塞是一直在等,非阻塞是不会卡住等待,非阻塞如果底层没有数据就直接返回了。
非阻塞IO效率高?------>从IO的效率上讲,阻塞与非阻塞是没有本质区别的。当你是一条河里的鱼,抬头一开有两个鱼钩,一个是张三的,一个是李四的,你会随机去咬其中一个。所以IO的效率是取决于对方的,服务方为什么在等?是发送方导致的。张三和李四都只有一个鱼竿,鱼咬钩的概率都是一样的,所以他们两个没有本质区别。
但是为什么说非阻塞IO效率高?因为李四可以在同样的时间内做更多的其他事情。其他事情也可能包含其他的IO。
问题2:五种IO模型中,谁的IO效率是最高的?
赵六是最高的,IO效率可能受自己的影响,但更多的是受对方的影响。今天你是河里的一条鱼,抬头发现张三的一个鱼钩,李四的一个鱼钩,王五的一个鱼钩,田七的一个鱼钩,还有赵六的100个鱼钩,一共有104条虫子,赵六鱼竿被咬钩的概率是要比其他四个人大得多的。鱼咬钩对于其他人的概率是1/104,而对于赵六是100/104。赵六任意一个fd,数据就绪的概率很高。而IO = 等 + 拷贝,所以等的比重就降低了,更多的时间会在拷贝数据上面。本质上就是可以检测多个文件描述符,这种技术就叫做多路转接。
前面四种IO我们称为同步IO,最后一种是异步IO。
问题3:信号驱动式IO的特点和效率?有没有参与IO呢?
信号驱动式IO最大的特点就是逆转了获取就绪事件的方式。IO = 等 + 拷贝,只是等的方式变了,拷贝还是需要自己参与。所以张三、李四、王五在IO这件事上面,只有等的方式不同,但是还是要自己拷贝。所以还是要深度参与钓鱼。但是信号不是异步的吗?为什么信号驱动式IO是同步IO呢?因为信号驱动式IO还是要参与IO的过程,因为要拷贝数据,所以属于同步IO范畴。
只要参与了IO的过程,就是同步IO。
那效率问题呢,王五和张三李四本质上没有区别,都是只有一个鱼竿,而且鱼咬钩的概率都是一样的。只不过王五和李四类似,可以在等期间做其他事情。
问题4:同步IO VS 异步IO
同步IO与线程那里的同步没有任何关系。田七没有等也没有钓,田七只是发起了钓鱼,所以田七是异步IO。
同步和异步的本质区别是:有没有参与IO的具体过程,参与了(局部参与也算)就是同步IO,没有参与就是异步IO。
异步IO通常扮演的是IO任务的发起者,而小王就是操作系统。
阻塞IO:在内核将数据准备好之前,系统调用会一直等待。所有的套接字,默认都是阻塞方式。
阻塞IO是最常见的IO模型。

非阻塞IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。
非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询。这对CPU来说是较大的浪费,一般只有特定场景下才使用。

信号驱动IO:内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作。

IO多路转接:虽然从流程图上看起来和阻塞IO类似。实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态。

异步IO:由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。

任何IO过程中,都包含两个步骤。第一是等待,第二是拷贝。而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让IO更高效,最核心的办法就是让等待的时间尽量少。
3、非阻塞IO
3.1、fcntl
其实系统调用提供了可以让我们进行非阻塞IO的方式,比如open函数的flags参数可以进行设置。有很多方式,但是这些方式都五花八门,记忆成本太高了,我们访问资源就是关注文件描述符,只要将文件描述符设置为非阻塞,就可以以统一的方式进行非阻塞IO。


系统调用fcntl(f control),是用来设置文件描述符标记位的函数。内核中struct file对象就包含了flags和mode,fcntl就是用来修改struct file对象中的flags和mode字段的。第一个参数fd表示文件描述符,第二个参数cmd表示功能,后面可变参数就是将来要设置传入的值。
fcntl 函数有 5 种功能:
• 复制一个现有的描述符(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)。
我们只关注获取/设置文件状态标记:F_GETFL和F_SETFL。

先调用fcntl获取文件描述符对应文件的状态标记,然后再设置原来老的加上O_NONBLOCK非阻塞。
3.2、测试阻塞IO与非阻塞IO
首先来看阻塞IO:
#include <iostream>
#include <string>
#include <unistd.h>
int main()
{
std::string tips = "Please Enter# ";
while (true)
{
write(1, tips.c_str(), tips.size());
char buffer[1024];
int n = read(0, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
std::cout << "echo# " << buffer << std::endl;
}
else if (n == 0)
{
std::cout << "read file end!" << std::endl;
break;
}
else
{
std::cout << "read error, n: " << n << std::endl;
break;
}
}
return 0;
}

我们让read从标准输入中读取数据,如果键盘不输入数据,那么read就会阻塞住一直等,当我们输入数据read返回然后输出buffer的内容。最后我们按下ctrl d,在Linux中ctrl d表示输入结束,所以read返回值为0表示读到文件结尾。
下面我们设置文件描述符0为非阻塞,再次进行测试:
#include <iostream>
#include <string>
#include <unistd.h>
#include <fcntl.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()
{
SetNonBlock(0);
std::string tips = "Please Enter# ";
while (true)
{
write(1, tips.c_str(), tips.size());
char buffer[1024];
int n = read(0, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
std::cout << "echo# " << buffer << std::endl;
}
else if (n == 0)
{
std::cout << "read file end!" << std::endl;
break;
}
else
{
std::cout << "read error, n: " << n << std::endl;
}
sleep(1);
}
return 0;
}

设置为非阻塞后,我们读取的时候直接以出错的方式返回了,返回值为-1。当我们输入数据按下回车后就可以读取到数据并输出。
O_NONBLOCK:让该fd以非阻塞方式工作。非阻塞,我们如果不输入,数据不就绪,以出错的方式返回。
但是read也有读取失败的情况,读取失败也是返回-1。读取不就绪不算失败。但是读取不就绪和失败都是返回-1,怎么区分呢?因为失败和底层数据不就绪对于我们后续的处理动作是不同的!
当读取失败,错误码表示了更详细的出错原因。errno表示最近一次系统调用的出错码。
下面我们把出错码打印出来看看:


所以当read出错返回,我们需要对错误码进行判断,如果错误码是EAGAIN,EAGAIN本质上是一个值为11的宏,或者错误码是EWOULDBLOCK,EWOULDBLOCK就是EAGAIN,就代表底层数据不就绪。

当我们调用系统调用read读取数据时,默认就是阻塞状态。如果这时候进程正在阻塞等待底层数据就绪,收到了信号,可能就会被唤醒。进程为什么会阻塞?这是因为大部分IO类系统调用本身包含了对IO事件的判断,以及对进程挂起的逻辑。如果fd就是阻塞的,当收到信号进程会被唤醒,处理完信号捕捉动作后就会继续检测,底层数据不就绪就会继续挂起。但是如果fd是非阻塞的,当进程正在进行数据拷贝,拷贝到一半被信号中断,就会直接返回。
IO过程会受到信号的影响,如果fd是非阻塞的,正在拷贝收到了信号,可能导致读取出错。如果信号中断导致IO工作没做完,出错码会被设置成EINTR。
所以读取出错返回我们还要判断errno是否是EINTR。
#include <iostream>
#include <string>
#include <cerrno>
#include <unistd.h>
#include <fcntl.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()
{
SetNonBlock(0);
std::string tips = "Please Enter# ";
while (true)
{
write(1, tips.c_str(), tips.size());
char buffer[1024];
int n = read(0, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
std::cout << "echo# " << buffer << std::endl;
}
else if (n == 0)
{
std::cout << "read file end!" << std::endl;
break;
}
else
{
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
std::cout << "底层数据,没有就绪" << std::endl;
}
else if (errno == EINTR)
{
std::cout << "被中断,重新来" << std::endl;
}
else
{
std::cout << "read error, n: " << n << ", errno: " << errno << std::endl;
break;
}
}
sleep(1);
}
return 0;
}
4、I/O多路转接之select
4.1、select函数接口
多路转接/多路复用有三种:select、poll、epoll。
多路转接核心作用:对多个文件描述符进行等待(手段),通知上层哪些fd已经就绪。本质是一种对IO事件就绪的通知机制。

select系统调用包含于头文件<sys/select.h>
1、参数nfds:表示等待的多个文件描述符中,fd最大值+1。比如你要等待fd为1、2、3、4、5、6,那么传参nfds的值就是7,也就是最大值6 + 1 = 7。
2、参数timeout:timeout是一个struct timeval*的指针类型,而struct timeval中含有两个成员,tv_sec表示秒,tv_usec表示微秒。这个参数用来设置底层select等待文件描述符的方式。
2.1、阻塞等待:将timeout设置为NULL,表示select底层阻塞等待传入的多个文件描述符。只要至少有一个fd继续,select就会返回。
2.2、非阻塞等待:设置timeout对应结构体对象为{0, 0},表示select底层非阻塞等待多个文件描述符。如果多个fd没有一个就绪,立即返回。如果有就绪,也是立即返回。
2.3、timeout方式:设置timeout对应的结构体对象为{5, 0},表示select底层5秒以内阻塞等待多个文件描述符,超时立即返回。如果5秒以内有任何一个fd就绪立即返回,如果没有fd就绪就阻塞等待,超时立即返回。
参数timeout是输入输出型参数,输入型参数用于设置等待时常,输出型参数表示剩余时间。比如传入{5, 0},5秒内没有就绪fd,超时返回,此时输出型参数就是{0, 0}表示时间耗尽。如果第3秒有fd就绪了,立即返回,输出型参数timeout为{2, 0},表示还剩下2秒。
3、select返回值:
3.1、返回值n > 0,表示有多少个fd就绪了。
3.2、返回值n < 0,表示select等待失败,通常n=-1,比如等待的多个文件描述符中有一个已经关闭了,这是被关的fd是非法的fd,但是你传给select,select检测到就出错返回。
3.3、返回值n == 0,表示底层没有fd就绪,也没有出错。
4、中间的三个参数readfds、writefds、exceptfds分别表示读文件描述符集、写文件描述符集、异常文件描述符集。因此select可以等待多个文件描述符。
fd_set是一种集合数据类型,OS给用户提供的数据类型。fd_set可以添加多个文件描述符。fd_set就是位图。
我们以读取为例,比如0000 1011,从右向左依次表示fd为0、1、2…,所以该位图表示的就是fd为0、1、3的文件描述符需要关心读事件是否就绪。所以比特位的位置表示文件描述符fd,比特位的内容表示要不要关心。
读文件描述符集—>关心读事件—>fd是否可读—>接收缓冲区是否有数据
写文件描述符集—>关心写事件—>fd是否可写—>发送缓冲区是否有空间
异常文件描述符集—>关心异常事件—>fd是否出现异常—>fd是错误的文件描述符?
如果只关心读,就只添加到readfds。如果既关心读又关心写,可以同时添加到readfds和writefds。
关于位图:

类似如图所示的数据结构,比如34,将来进行查找的时候先:34 / 32 = 1,表示在下标为1的元素中,然后:34 % 32 = 2,表示在该元素的从右往左数第二个比特位。
fd_set就是一个struct + 数组的位图,fd_set是一种具体的数据类型,并且是有固定大小的。这也就说明了fd_set能够包含的fd是有上限的,所以select能够管理的fd个数是有上限的。

我们定义一个fd_set类型,然后sizeof计算出字节数,一个字节有8个比特位,所以再乘以8,最终算出比特位个数就是可以管理的fd个数。因此select最多可以管理1024个fd。
5、readfds、writefds、exceptfds都是输入输出型参数,现在我们聚焦到readfds,readfds搞懂了另外两个也就懂了。
readfds
5.1、作为输入型参数的时候:用户告诉内核,你要帮我关心readfds位图中,被设置了的fd上的读事件。此时比特位位置表示fd具体的编号,比特位内容表示是否关心。
5.2、作为输出型参数的时候:内核告诉用户,你让我关心的readfds中,有哪些readfds已经就绪了。此时比特位的位置表示fd具体的编号,比特位内容表示是否就绪。
当用户对输出型参数中已经就绪的fd进行读取的时候,此时一定不会被阻塞。
对fd_set位图操作有如下方法:

FD_ZERO把位图清空,FD_SET将fd设置进fd_set,FD_CLR清理掉位图中的fd,FD_ISSET判断位图中fd是否就绪。
4.2、基于select的EchoServer

需要使用之前的Socket.hpp、InetAddr.hpp,Common.hpp、Log.hpp,日志带了锁所以Mutex.hpp也要加进来。然后实现SelectServer.hpp,在Main.cc中启动EchoServer。
我们先只关心读事件就绪!
1、实现SelectServer基本框架
#pragma once
#include <iostream>
#include <memory>
#include "Socket.hpp"
using namespace SocketModule;
class SelectServer
{
public:
SelectServer(uint16_t port)
:_port(port)
,_listen_socket(std::make_unique<TcpSocket>())
,_isrunning(false)
{}
void Init()
{
_listen_socket->BuildTcpSocketMethod(_port);
}
void Start()
{
}
~SelectServer()
{}
private:
uint16_t _port;
std::unique_ptr<Socket> _listen_socket;
bool _isrunning;
};
2、实现Start函数
#pragma once
#include <iostream>
#include <memory>
#include <sys/select.h>
#include "Socket.hpp"
using namespace SocketModule;
class SelectServer
{
public:
SelectServer(uint16_t port)
: _port(port), _listen_socket(std::make_unique<TcpSocket>()), _isrunning(false)
{
}
void Init()
{
_listen_socket->BuildTcpSocketMethod(_port);
}
void Start()
{
fd_set rfds;
_isrunning = true;
while (_isrunning)
{
FD_ZERO(&rfds);
FD_SET(_listen_socket->Fd(), &rfds);
struct timeval timeout = {10, 0};
int n = select(_listen_socket->Fd() + 1, &rfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
std::cout << "time out..." << std::endl;
break;
case -1:
perror("select");
break;
default:
std::cout << "有读事件就绪啦..." << " timeout: " << timeout.tv_sec << ":" << timeout.tv_usec << std::endl;
break;
}
}
_isrunning = false;
}
~SelectServer()
{
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listen_socket;
bool _isrunning;
};

当我们从listensock获取新连接,本质也是一种IO,我们这种IO只关心读事件是否就绪。
我们启动服务,用另一台机器telnet进行连接,然后我们发现左侧循环输出有读事件就绪,这是因为我们设置监听套接字进rfds,当就绪了之后我们只是打印输出信息,并没有将底层的新连接获取上来,所以再次调用select由于读事件就绪直接返回。
3、实现HandlerEvents
#pragma once
#include <iostream>
#include <memory>
#include <sys/select.h>
#include "Socket.hpp"
using namespace SocketModule;
class SelectServer
{
public:
SelectServer(uint16_t port)
: _port(port), _listen_socket(std::make_unique<TcpSocket>()), _isrunning(false)
{
}
void Init()
{
_listen_socket->BuildTcpSocketMethod(_port);
}
void Start()
{
fd_set rfds;
_isrunning = true;
while (_isrunning)
{
FD_ZERO(&rfds);
FD_SET(_listen_socket->Fd(), &rfds);
struct timeval timeout = {10, 0};
int n = select(_listen_socket->Fd() + 1, &rfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
std::cout << "time out..." << std::endl;
break;
case -1:
perror("select");
break;
default:
std::cout << "有读事件就绪啦..." << " timeout: " << timeout.tv_sec << ":" << timeout.tv_usec << std::endl;
HandlerEvents(rfds);
break;
}
}
_isrunning = false;
}
void HandlerEvents(fd_set& rfds)
{
if (FD_ISSET(_listen_socket->Fd(), &rfds))
{
InetAddr client;
int newfd = _listen_socket->Accepter(&client);
if (newfd < 0) return;
std::cout << "获得了一个新连接, client: " << client.Addr() << std::endl;
// recv??
}
}
~SelectServer()
{
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listen_socket;
bool _isrunning;
};
同时修改Socket.hpp中Accepter函数,返回整数fd:


当有读取事件就绪进入HandlerEvents函数,首先判断监听套接字fd是否在就绪文件描述符集,如果在就获取新连接,输出客户端信息。此时获取新连接是不会阻塞的,因为读事件已经就绪。问题是获取新连接后要进行读取,读事件是否就绪我们并不清楚,所以需要将newfd也托管给select,让select帮我关心newfd上的读事件是否就绪。怎么做?
4、保存历史文件描述符,方便添加至fd_set中。
每次调用select函数,都要对输入输出型参数进行重新设置。读文件描述符集、写文件描述符集、异常文件描述符集、timeval结构体对象,每次在调用select函数都要重新设置。因为当select返回会作为输出型参数,会覆盖。所以我们需要有一个设置源,将我们需要关心的读文件描述符保存起来,将来调用select之前重新设置位图结构传给select。我们这里就用一个辅助数组来实现。

首先添加一个辅助数组_fd_array,数组个数就是NUM,NUM是宏计算出select可以管理的最多fd个数。然后在Init函数中将所有fd初始化为-1,同时将监听fd添加到数组中。
需要注意的是,将来客户端连接多了该数组就会有很多与客户端进行通信的fd,但是可能客户端和服务端断开连接,中间的fd就关了,所以该数组将来不一定是连续的。

Start函数中的逻辑就需要改了,遍历数组将不为-1的fd设置到位图中,将来让select关心这些fd读事件是否就绪。同时要注意,因为现在是将辅助数组中的fd添加到读文件描述符集,所以我们遍历的时候需要计算一下最大的fd值,将来调用select传递第一个参数。

获取新连接后就需要在辅助数组中找到一个空位将newfd添加到辅助数组中,将来调用select的时候再把newfd设置进读文件描述符集。如果找不到空位说明文件描述符已经满了,输出日志信息。
void HandlerEvents(fd_set &rfds)
{
for (int i = 0; i < NUM; i++)
{
if (_fd_array[i] == gdefaultfd)
continue;
if (_fd_array[i] == _listen_socket->Fd())
{
if (FD_ISSET(_listen_socket->Fd(), &rfds))
{
InetAddr client;
int newfd = _listen_socket->Accepter(&client);
if (newfd < 0)
return;
std::cout << "获得了一个新连接, client: " << client.Addr() << std::endl;
int pos = -1;
for (int j = 0; j < NUM; j++)
{
if (_fd_array[j] == gdefaultfd)
{
pos = j;
break;
}
}
if (pos == -1)
{
LOG(LogLevel::ERROR) << "服务器已经满了...";
close(newfd);
}
else
{
_fd_array[pos] = newfd;
}
}
}
else
{
if (FD_ISSET(_fd_array[i], &rfds))
{
char buffer[1024];
int n = recv(_fd_array[i], buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
std::cout << "client# " << buffer << std::endl;
std::string message = "echo# ";
message += buffer;
send(_fd_array[i], message.c_str(), message.size(), 0);
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << _fd_array[i];
close(_fd_array[i]);
_fd_array[i] = -1;
}
else
{
LOG(LogLevel::ERROR) << "读取出错, sockfd: " << _fd_array[i];
close(_fd_array[i]);
_fd_array[i] = -1;
}
}
}
}
}
HandlerEvents这时候就需要遍历辅助数组,然后先判断是不是监听fd,如果是监听fd并且读事件就绪了,我们要获取新连接并在辅助数组中找到一个空位添加进辅助数组。否则我们就读取读事件就绪的sockfd,打印客户端发送的信息,并添加echo# 给客户端返回。
需要注意我们这里的读取和写入都是不完善的,因为TCP是面向字节流的,所以我们读取应该制定协议并解决粘包问题,写入也是需要制定协议序列化的。

进行测试,这时候我们发现以前我们单进程服务端是无法处理多个客户端通信问题的,现在单进程竟然可以处理多个客户端发送的数据。
4.3、完整代码
#pragma once
#include <iostream>
#include <memory>
#include <sys/select.h>
#include "Socket.hpp"
#define NUM sizeof(fd_set) * 8
const int gdefaultfd = -1;
using namespace SocketModule;
class SelectServer
{
public:
SelectServer(uint16_t port)
: _port(port), _listen_socket(std::make_unique<TcpSocket>()), _isrunning(false)
{
}
void Init()
{
_listen_socket->BuildTcpSocketMethod(_port);
for (int i = 0; i < NUM; i++)
_fd_array[i] = gdefaultfd;
_fd_array[0] = _listen_socket->Fd();
}
void Loop()
{
fd_set rfds;
_isrunning = true;
while (_isrunning)
{
FD_ZERO(&rfds);
struct timeval timeout = {10, 0};
int maxfd = gdefaultfd;
for (int i = 0; i < NUM; i++)
{
if (_fd_array[i] == gdefaultfd)
continue;
FD_SET(_fd_array[i], &rfds);
maxfd = std::max(maxfd, _fd_array[i]);
}
int n = select(maxfd + 1, &rfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
std::cout << "time out..." << std::endl;
break;
case -1:
perror("select");
break;
default:
std::cout << "有读事件就绪啦..." << " timeout: " << timeout.tv_sec << ":" << timeout.tv_usec << std::endl;
Dispatcher(rfds);
break;
}
}
_isrunning = false;
}
void Accepter()
{
InetAddr client;
int newfd = _listen_socket->Accepter(&client);
if (newfd < 0)
return;
std::cout << "获得了一个新连接, client: " << client.Addr() << std::endl;
int pos = -1;
for (int j = 0; j < NUM; j++)
{
if (_fd_array[j] == gdefaultfd)
{
pos = j;
break;
}
}
if (pos == -1)
{
LOG(LogLevel::ERROR) << "服务器已经满了...";
close(newfd);
}
else
{
_fd_array[pos] = newfd;
}
}
void Recver(int who)
{
char buffer[1024];
int n = recv(_fd_array[who], buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
std::cout << "client# " << buffer << std::endl;
std::string message = "echo# ";
message += buffer;
send(_fd_array[who], message.c_str(), message.size(), 0);
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << _fd_array[who];
close(_fd_array[who]);
_fd_array[who] = -1;
}
else
{
LOG(LogLevel::ERROR) << "读取出错, sockfd: " << _fd_array[who];
close(_fd_array[who]);
_fd_array[who] = -1;
}
}
void Dispatcher(fd_set &rfds)
{
for (int i = 0; i < NUM; i++)
{
if (_fd_array[i] == gdefaultfd)
continue;
if (_fd_array[i] == _listen_socket->Fd())
{
if (FD_ISSET(_listen_socket->Fd(), &rfds))
{
Accepter(); // 连接的获取
}
}
else
{
if (FD_ISSET(_fd_array[i], &rfds))
{
Recver(i); // IO的处理
}
}
}
}
~SelectServer()
{
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listen_socket;
bool _isrunning;
int _fd_array[NUM];
};
最后我们把HanderEvents改名为Dispatcher派发器,然后Start函数改为Loop循环,当有读事件就绪了就调用Dispatcher派发器,派发器中遍历辅助数组,如果是监听套接字并且读时间就绪了就派发进行获取新连接Accepter,如果是sockfd并且读事件就绪了就派发进行IO处理Recver。
select在等,一旦有事件就绪就会给我们派发出去,派发给获取新连接或IO处理。select会派发给上层,今天我们所写的代码是在一个文件里,如果未来Accepter、Recver是回调函数,我们可以提供一个注册接口,服务器就Loop循环,有任务就进行派发,有的是进行连接管理,有的是进行IO处理,只不过不在当前文件内部,这样就派发给上层了。
当有错误发生时则返回-1,错误原因存于 errno,此时参数 readfds, writefds,exceptfds 和 timeout 的值变成不可预测。
错误值可能为:
• EBADF 文件描述词为无效的或该文件已关闭。
• EINTR 此调用被信号所中断。
• EINVAL 参数 n 为负值。
• ENOMEM 核心内存不足。
4.4、select的特点和缺点
select的特点:
select可监控的文件描述符个数取决于sizeof(fd_set)的值,我们测试的是1024。
将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的 fd,一是用于在select返回后,array作为源数据和fd_set进行FD_ISSET判断。二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO 最先),扫描 array 的同时取得fd最大值maxfd,用于select的第一个参数。
select的缺点:
每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便。
每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大。
同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
select支持的文件描述符数量太小。
进程打开的文件描述符也有上限?这是进程本身的问题,并不意味着select就没有问题。而且文件描述符的个数是可以动态调整的。我们使用的云服务器可以打开的文件描述符个数是65535。
5、I/O多路转接之poll
5.1、poll函数接口
为什么需要poll?
1、select的输入输出参数是一个位图,参数每次都需要重置。
2、select等待的fd个数是有上限的。

1、参数timeout:timeout时间是以毫秒为单位的。
1.1、timeout == 0,非阻塞等待,poll扫描一轮文件描述符有没有就绪都直接返回。
1.2、timeout < 0,阻塞式等待,poll底层阻塞等待多个文件描述符,只要有至少一个fd就绪就返回。
1.3、timeout > 0,timeout时间内阻塞式等待,超时就直接返回。
这里的timeout等待方式跟select是一样的。
2、返回值n
2.1、n > 0,表示有n个fd就绪了。
2.1、n == 0,timeout超时返回或非阻塞返回。
2.2、n < 0,poll失败了,设置错误码。
3、参数fds和nfds实际上表示一个数组,fds表示数组首地址,nfds表示数组的元素个数,因此poll可以关注多个文件描述符。
struct pollfd结构体类型中有三个成员,fd表示poll要关心的fd是哪个。
调用poll:用户告诉内核,内核你要帮我关心哪些fd上的哪些事件。
poll返回:内核告诉用户,用户你要关心的哪些fd上的哪些事件已经就绪了。
poll的定位:多个fd,IO事件的等待机制,达到事件派发的目的!同select
再看struct pollfd的另两个成员变量,events表示用户告诉内核要关心哪些事件,revents表示poll返回时内核告诉用户你要我关心的哪些事件就绪了。所以fd + events就是用户告诉内核你要帮我关心这个fd上的哪些事件,当poll返回了通过fd + revents就是内核告诉用户该fd上的哪些事件就绪了。
这里输入输出信息分离了,所以不需要用户频繁的对参数进行设置。
下面看events和revents有哪些事件:

其中我们最关心的就是POLLIN:读事件就绪,POLLOUT:写事件就绪。

而这些事件本质上就是一个宏,是一个只有某个比特位为1的值。所以events和revents都是一个short的位图结构,这个位图只有16个比特位,将来我们添加要关心的事件时就通过按位异或设置到events,比如要添加读事件和写事件,就让events |= POLLIN,events |= POLLOUT即可。当poll调用返回,我们需要判断某个fd上有哪些事件就绪了,我们就通过revents & POLLIN或revents & POLLOUT的方式,判断读事件或写事件是否就绪。
所以poll将输入输出参数分析,不需要频繁进行重置。select中fd_set是个位图,fd_set是有上限的,这也就导致select关心的fd个数是有限的。而poll这里并没有写死,你可以传入数组元素个数nfds,将来也可以对数组动态扩容。此时数组的大小不由poll决定,而由用户决定。
5.2、基于poll的EchoServer
#pragma once
#include <iostream>
#include <memory>
#include <sys/poll.h>
#include "Socket.hpp"
using namespace SocketModule;
#define MAX 4096
const int gdefaultfd = -1;
class PollServer
{
public:
PollServer(uint16_t port)
: _port(port), _listen_socket(std::make_unique<TcpSocket>()), _isrunning(false)
{
}
void Init()
{
_listen_socket->BuildTcpSocketMethod(_port);
// 初始化
for (int i = 0; i < MAX; i++)
{
_fds[i].fd = gdefaultfd;
_fds[i].events = 0;
_fds[i].revents = 0;
}
// 添加listen套接字
_fds[0].fd = _listen_socket->Fd();
_fds[0].events |= POLLIN;
}
void Loop()
{
int timeout = -1;
_isrunning = true;
while (_isrunning)
{
int n = poll(_fds, MAX, timeout);
switch (n)
{
case 0:
std::cout << "timeout..." << std::endl;
break;
case -1:
perror("poll");
break;
default:
std::cout << "有事件就绪啦..., timeout" << std::endl;
Dispatcher();
TestFd();
break;
}
}
_isrunning = false;
}
void Accepter()
{
InetAddr client;
int newfd = _listen_socket->Accepter(&client);
if (newfd < 0)
return;
std::cout << "获取新连接, client: " << client.Addr() << std::endl;
int pos = -1;
for (int j = 0; j < MAX; j++)
{
if (_fds[j].fd == gdefaultfd)
{
pos = j;
break;
}
}
if (pos == -1)
{
// 扩容--TODO
std::cout << "服务器满了..." << std::endl;
close(newfd);
}
else
{
_fds[pos].fd = newfd;
_fds[pos].events |= POLLIN;
}
}
void Recver(int who)
{
char buffer[1024];
int n = recv(_fds[who].fd, buffer, sizeof(buffer)-1, 0);
if (n > 0)
{
buffer[n] = 0;
std::cout << "client# " << buffer << std::endl;
std::string message = "echo# ";
message += buffer;
send(_fds[who].fd, message.c_str(), message.size(), 0);
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << _fds[who].fd;
close(_fds[who].fd);
_fds[who].fd = gdefaultfd;
_fds[who].events = _fds[who].revents = 0;
}
else
{
LOG(LogLevel::DEBUG) << "读取出错, sockfd: " << _fds[who].fd;
close(_fds[who].fd);
_fds[who].fd = gdefaultfd;
_fds[who].events = _fds[who].revents = 0;
}
}
void Dispatcher()
{
for (int i = 0; i < MAX; i++)
{
if (_fds[i].fd == gdefaultfd)
continue;
if (_fds[i].fd == _listen_socket->Fd())
{
if (_fds[i].revents & POLLIN)
{
Accepter(); // 连接的获取
}
}
else
{
if (_fds[i].revents & POLLIN)
{
Recver(i); // IO的处理
}
// else if (_fds[i].revents & POLLOUT) TODO
}
}
}
void TestFd()
{
std::cout << "pollfd: ";
for (int i = 0; i < MAX; i++)
{
if (_fds[i].fd == gdefaultfd)
continue;
std::cout << _fds[i].fd << "[" << Events2String(_fds[i].events) << "] ";
}
}
std::string Events2String(short events)
{
std::string res;
if (events & POLLIN) res += "POLLIN";
if (events & POLLOUT) res += ",POLLOUT";
return res;
}
~PollServer()
{
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listen_socket;
bool _isrunning;
struct pollfd _fds[MAX];
};
代码比select要好写一些,直接调用poll,不需要再重新设置,然后poll返回就进行任务派发,根据监听套接字和普通套接字类型进行不同任务的派发。

5.3、poll的缺点
和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中。
同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
主要问题还是依旧需要内核在底层遍历式的检测fd是否就绪。
6、I/O多路转接之epoll
6.1、epoll函数接口
按照man手册的说法:是为处理大批量句柄而作了改进的poll。
它是在2.5.44内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44)它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。
epoll的定位:多个fd,IO事件的等待机制,达到事件派发的目的。同select和poll。本质上是一种就绪事件的通知机制。
如果我们要使用epoll,不能直接使用epoll,需要先通过epoll_create创建epoll模型。

调用epoll_create在内核中创建一个epoll模型,参数size从Linux内核2.6.8开始已经被废弃,所以可以随便写,但是必须是大于0的数。
返回值:如果创建成功返回一个文件描述符。失败返回-1,错误码被设置。


epoll_ctl用来向epoll模型中添加fd和关心的事件。
第一个参数epfd就是调用epoll_create返回的文件描述符,第二个参数op有三种操作:
EPOLL_CTL_ADD表示新增,EPOLL_CTL_MOD表示修改,EPOLL_CTL_DEL表示删除。所以op表示用户要进行什么操作。第三个参数fd就表示哪一个文件描述符。第四个参数表示什么事件。操作成功返回0,失败返回-1。
所以epoll_ctl是用户告诉内核,你要帮我关心哪些fd上的哪些事件。

epoll_wait是真正进行等的操作,第一个参数epfd就是调用epoll_create的返回值。参数timeout是毫秒级别的,同poll。返回值也是同select、poll。
剩余两个参数events和maxevents是一个数组,是输出型参数,表示内核告诉用户,用户历史关心的哪些fd上的哪些事件已经就绪了。

我们发现epoll_ctl和epoll_wait都有一个数据类型strcut epoll_event,这个数据类型里面有一个32位的整数events,events表示的就是事件类型。而epoll把输入和输出分为两个接口,因此epoll依旧是输入输出型参数分离的,所以不需要频繁设置。另一个epoll_data_t是用户可用数据,是一个联合体类型,将来需要设置里面的fd值,才能在事件触发时知道是哪个fd触发了事件。
下面是events具体的事件:
• EPOLLIN:表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭)。
• EPOLLOUT:表示对应的文件描述符可以写。
• EPOLLPRI:表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来)。
• EPOLLERR:表示对应的文件描述符发生错误。
• EPOLLHUP:表示对应的文件描述符被挂断。
• EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
• EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。

这些事件本质上也是一个宏,只有一个比特位为1的值。所以将来用户要设置进内核就通过events |= EPOLLIN。调用epoll_wait返回要进行判断就通过events & EPOLLIN。
6.2、epoll的原理
首先我们知道当对方给我发数据,是我的网卡先收到数据,然后网卡会向CPU的针脚发送硬件中断,操作系统就会去执行中断向量表中的中断方法,将数据从网卡拷贝到内核中,接着不断向上解包分用,最后拷贝到TCP的接收缓冲区中。
下面插入TCP内核相关结构信息:

使用TCPsocket创建套接字,返回的是一个监听套接字,是一个文件描述符,也就是说将来获取连接要通过该文件描述符获取的。我们都知道进程PCB为task_struct,里面有个成员struct flies_struct* files指向了struct files_struct对象,该对象中有一个文件描述符表struct files* fd_array[],当我们创建tcp套接字返回的fd为3。在struct file中还有一个无类型的指针private_data,如果我们打开的是普通文件,这个指针就为空,如果我们进行网络通信,这个指针就会指向一个struct socket的对象。这是我们网络socket的入口。
struct socket中有一个struct file* file指针,这个指针回指向struct file对象。里面还有个const struct proto_ops* ops,而struct proto_ops这个对象里面有很多函数指针,所以将来根据创建的socket类型,如果是tcp就指向tcp的方法,如果是udp就指向udp的方法,有的方法是udp没有而tcp有的,所以udp来说设置为空就行了。操作系统要管理连接,连接本质上也是一个结构体对象,连接在操作系统中并不是struct socket对象,而是struct tcp_sock对象。所以struct socket中还有一个struct sock*的指针指向了struct tcp_sock对象,至于为什么是这样需要后面才能解答。
struct tcp_sock是我们在内核创建的一个真实的tcp套接字,里面有很多字段,比如snd_ssthresh表示慢启动的阈值等。我们关注它的第一个字段struct inet_connection_sock,这个对象包含了tcp连接相关的信息。比如超时的时间、重传计数等。我们关注两个字段,第一个是struct request_sock_queue,这个就是全连接队列。还有一个字段是struct ient_sock对象,这个对象里面有目的IPdaddr,目的端口dport等信息。inet_sock里面包含了各种各样的网络信息。而inet_sock的第一个成员又是struct sock,这个struct sock在前面我们讲过,里面包含了两个队列:接收队列和发送队列,这两个队列就是TCP的发送和接收缓冲区。我们再看这两个队列对象里面的字段,里面有sk_buff的指针,而sk_buff就是报文,sk_buff里面有head、tail、data、end用来指向报文的开头、结尾、数据等信息。

所以一个struct tcp_sock对象如上图所示,第一个成员是struct inet_connection_sock对象,然后struct inet_connection_sock的第一个成员是struct ient_sock对象,然后struct ient_sock的第一个成员是struct sock对象。
最上面就是struct sock对象,所以上面的struct socket对象中有一个struct sock*的指针,直接指向tcp_sock中最上面的sock对象。未来想访问struct ient_sock的字段就直接将该指针强转成struct inet_sock*指针就行了。想访问struct inet_connection_sock对象就将该指针强转成struct inet_connection_sock*。想访问struct tcp_sock,就将该指针强转成struct tcp_sock*。
这就是C风格的多态。
当然UDP也要有,所以也有udp_sock对象:

我们发现udp_sock第一个对象是struct inet_sock,因为udp也要包含网络信息,什么源IP、源端口、目的IP、目的端口都要有。但是没有struct inet_connection_sock,因为tcp是面向连接的所以有这个对象,而udp不是面向连接的,所以不需要有。同时我们发现上面的struct socket对象还有个type字段,可以用来标识SOCK_DGRAM或SOCK_STREAM,将来初始化struct proto_ops里面的方法就可以根据是tcp/udp进行初始化了。
因此struct socket是一个基类,是我们网络socket的入口,BSD socket——通用socket接口。
所以我们创建套接字进行网络通信时,首先创建struct file,让文件描述符表中的指针指向struct file对象,然后创建struct socket,struct socket和struct file对象内都有指针互指。然后让struct socket中的struct sock*指针指向struct sock对象,未来要通过3号文件描述符获取新连接就是通过struct file一直往下找,找到struct sock*指针,强转成struct inet_connection_sock对象,然后访问全连接队列,获取一个新连接。
所以基于上面的讲解,套接字有没有数据本质上就是检测struct sock中接收队列是否为空,套接字能否写入数据本质就是写队列是否为满。

所以我们调用select、poll,底层就是遍历多个文件描述符,然后判断对应的struct sock对象中的接收队列是否为空,数据是否就绪。但是这样遍历太慢了,所以操作提供了一种底层回调机制。网络部分将数据处理完,数据就绪了就会调用一个回调方法。默认该方法为空,什么也不做。将来在操作系统中上层可以设置让该cb指向一个上层的具体方法。当底层处理完有数据到来了,自动调用callback回调。
如果将该方法注册成操作系统给目标进程发SIGIO信号,这就是信号驱动式IO的原理。
当我们创建epoll的时候,会在底层给我们创建一颗红黑树。默认这颗红黑树只有一个空节点,后来我们通过某种方式告诉底层要帮我关心文件描述符为4的EPOLLIN事件,底层会新增一个红黑树节点,这个红黑树节点里面保存了4号文件描述符和EPOLLIN事件信息。它表示的含义就是要让操作系统帮我们关心4号文件描述符上的读事件。并且还会给我们创建一个就绪队列,一旦将来4号文件描述符上的读事件就绪了,该就绪队列就会链入一个结构,里面有4号文件描述符和EPOLLIN事件信息。
这颗红黑树就表示内核要为用户关心哪些fd上的哪些事件,红黑树上的一个节点就表示内核要关心该fd上的哪些事件。就绪队列表达的含义就是内核告诉用户,哪些fd上的哪些事件已经就绪了。所以epoll_ctl就是在修改红黑树,epoll_wait就是从就绪队列中将节点拿上来。
问题是系统怎么知道文件描述符上有事件就绪了呢?
epoll_ctl除了设置红黑树节点信息,还要在底层设置回调函数。该回调函数就是判断4号文件描述符的事件类型,然后把4号文件描述符和对应事件的就绪节点插入到就绪队列中。
节点级别的迁移从红黑树迁移到就绪队列中,从此以后由底层自动驱动式完成,不用再关心。
我们把红黑树、就绪队列、底层回调这一套整体称为epoll模型。
细节1:红黑树相当于select、poll中的什么呢?
相当于select、poll中的辅助数组,只不过以前辅助数组的增加删除修改由我们自己实现,现在由操作系统内核实现。

eventpoll结构对象就是底层创建的epoll模型,该结构里的struct list_head rdllist就是就绪队列,struct rb_root rbr就是红黑树。struct epitem就是红黑树的节点,该结构里的struct epoll_filefd ffd就是文件描述符,struct epoll_event event就是该文件描述符要关心的哪些事件。
细节2:具体是怎么做到将红黑树节点迁移到就绪队列?
内核中的一个数据结构节点,并不是只能属于一种数据结构。比如我们很早讲的task_struct就是多种数据结构的节点。如上图,epitem中的struct rb_node rbn表示红黑树的链接信息,struct list_head rdllink表示就绪队列的链接信息。红黑树的节点同样也属于就绪队列的节点,原来一个节点并不在就绪队列中,当该节点上的事件就绪了,就把该节点链入就绪队列中,只需要做指针移动就可以了。就可以保证该节点处于被激活状态!
细节3:红黑树不是有key、value吗,谁作为键值呢?
该红黑数以文件描述符的值fd作为键值。
同时可以注意到eventpoll中还有锁和信号量,所以是线程安全的。
细节4:如何理解epoll模型?为什么epoll_create返回的是一个文件描述符?
struct file对象里面有个void* private_data,该指针会指向eventpoll对象,找到epoll模型。所以将来就可以通过文件描述符找到struct file对象,然后再找到epoll模型,所以调用epoll_ctl和epoll_wait都需要文件描述符。

如图,eventpoll上方已经做了说明,这个结构是被保存在struct file结构里的private_data。所以,Linux下一切皆文件。
细节5:epoll为什么高效?
从原理的角度:用户只需要注册进去,OS自己驱动,不需要再遍历轮询,就绪之后直接链入到就绪队列中。并且使用红黑树进行增删改查效率也很高。从此之后要检测文件描述符有没有事件就绪只需要检测就绪队列,判断就绪队列是否为空即可。所以检测事件就绪时间复杂度O(1)。但是有就绪的节点将来需要将信息拷贝到用户传入的缓冲区中,拷贝的事件复杂度为O(n)。
从接口的角度:epoll返回就绪事件的时候,从数组的0下标开始,一个一个的拷贝。将来要处理所有的就绪事件只需要从下标0开始,遍历到返回值即可。也就是说未来要处理的就绪事件,一定都是就绪的。以前select、poll还需要判断文件描述符是否合法,如果合法了还要判断事件是否就绪。
6.3、基于epoll的EchoServer
#pragma once
#include <iostream>
#include <memory>
#include <sys/epoll.h>
#include "Socket.hpp"
using namespace SocketModule;
const int gdefaultepfd = -1;
class EpollServer
{
const static int revs_num = 64;
public:
EpollServer(uint16_t port)
:_port(port)
,_epfd(gdefaultepfd)
,_listen_socket(std::make_unique<TcpSocket>())
,_isrunning(false)
{}
void Init()
{
_listen_socket->BuildTcpSocketMethod(_port);
// 创建epoll模型
_epfd = epoll_create(256);
if (_epfd < 0)
{
LOG(LogLevel::ERROR) << "epoll_create error";
exit(EPOLL_CREATE_ERR);
}
LOG(LogLevel::DEBUG) << "epoll_create success, epfd: " << _epfd;
// 添加listensocket进epoll模型中
struct epoll_event ev;
ev.data.fd = _listen_socket->Fd();
ev.events = EPOLLIN;
int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listen_socket->Fd(), &ev);
if (n < 0)
{
LOG(LogLevel::ERROR) << "epoll_ctl error";
exit(EPOLL_CTL_ERR);
}
}
void Loop()
{
_isrunning = true;
int timeout = -1;
while (_isrunning)
{
int n = epoll_wait(_epfd, _revs, revs_num, timeout);
switch(n)
{
case 0:
std::cout << "timeout..." << std::endl;
break;
case -1:
perror("epoll_wait");
break;
default:
std::cout << "有事件就绪啦..., timeout" << std::endl;
Dispatcher(n);
break;
}
}
_isrunning = false;
}
void Accepter()
{
InetAddr client;
int newfd = _listen_socket->Accepter(&client);
if (newfd < 0)
return;
// 将获取的newfd添加到epoll模型中,托管给epoll
struct epoll_event ev;
ev.data.fd = newfd;
ev.events = EPOLLIN;
int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, newfd, &ev);
if (n < 0)
{
LOG(LogLevel::ERROR) << "epoll_ctl error";
close(newfd);
}
LOG(LogLevel::DEBUG) << "epoll_ctl success, sockfd: " << newfd;
}
void Recver(int fd)
{
char buffer[1024];
int n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
std::cout << "client# " << buffer << std::endl;
std::string message = "echo# ";
message += buffer;
send(fd, message.c_str(), message.size(), 0);
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << fd;
// 要先将fd从epoll模型中移除再close.从epoll模型中移除fd必须保证fd是合法的.
int m = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
if (m < 0)
{
LOG(LogLevel::ERROR) << "epoll_ctl error";
return;
}
LOG(LogLevel::DEBUG) << "epoll_ctl success, sockfd: " << fd;
close(fd);
}
else
{
LOG(LogLevel::ERROR) << "读取出错, sockfd: " << fd;
int m = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
if (m < 0)
{
LOG(LogLevel::ERROR) << "epoll_ctl error";
return;
}
LOG(LogLevel::DEBUG) << "epoll_ctl success, sockfd: " << fd;
close(fd);
}
}
void Dispatcher(int rnum)
{
for (int i = 0; i < rnum; i++)
{
int fd = _revs[i].data.fd;
uint32_t revents = _revs[i].events;
if (fd == _listen_socket->Fd())
{
if (revents & EPOLLIN)
{
Accepter(); // 获取连接
}
}
else
{
if (revents & EPOLLIN)
{
Recver(fd); // IO处理
}
// else if (revents & EPOLLOUT)
}
}
}
~EpollServer()
{
_listen_socket->Close();
if (_epfd >= 0)
close(_epfd);
}
private:
uint16_t _port;
int _epfd;
std::unique_ptr<Socket> _listen_socket;
bool _isrunning;
struct epoll_event _revs[revs_num];
};

下面讲几个注意事项:
1、初始化的时候我们先创建listen套接字,接着创建epoll模型,然后将listen套接字添加进epoll模型,listen套接字的获取新连接就是读事件,所以我们设置进epoll模型当读事件就绪来通知我们,我们进行事件的派发。
2、将epoll_wait返回值传给Dispatcher,遍历的数组元素对应的fd都是就绪的,只需要判断是什么事件就绪进行派发即可。
3、在普通套接字中读取数据时若读取结束或者读取失败,需要先将该fd从epoll模型中移除再进行close。因为从epoll模型中移除的fd必须是合法的fd!
6.4、epoll的工作模式
我们在Recver和Accepter的时候都是非阻塞,直接就能读取。但是Recver里的recv是不完善的,因为你不能保证读取上来的数据是一个完整的报文,所以需要定制协议。并且你也不能保证你一次就能全部读完,所以就需要循环读,但是如果循环读取的话就可能阻塞。在accept连接的时候也是如此,如果一次来了好几个连接,那么accept一次就不行,如果循环accept也有可能会阻塞。所以epoll有两种工作模式。
epoll有2种工作方式:水平触发(LT)和边缘触发(ET)。
1、什么是LT、ET,如何理解?
假设小王在淘宝上购买了很多商品,所以小王有10个快递,小王住在学校宿舍的8楼。在小王宿舍三公里外有一家菜鸟驿站,今天小王10个快递都到菜鸟驿站了,有两个派送员:张三和李四。张三现在开始派送包裹了,骑着一辆三轮车就来了,这些包裹中其中有6个是小王的,当张三派送到小王宿舍楼下,给其他同学钱、赵、李等人打完电话让他们下来取快递,然后就给小王打了电话,让小王下来取快递。但是这时候小王正在和队友打游戏,已经推到对方高地了,此时抽不开身,所以小王就没下去取快递。张三等了一会说小王怎么还没下来,所以又给小王打了电话,然后小王还是没下来,所以张三连续打了几次电话。最后小王勉为其难下来取快递了,但是一次取不完6个快递,小王就先取了4个回宿舍。然后又不下来了,但是张三车里还有2个快递是小王的,所以张三又给小王打电话,但是小王由于某种原因就是没有下来,所以张三就一直给小王打电话。这个过程中张三给你打电话本质是对就绪事件的通知。张三通知的策略是只要我的车里有你的快递,我就要一直通知你,直到你取走。这种模式我们称为LT水平触发模式。
后来张三打了几个电话,小王还是不下来,李四骑着三轮车来了,他们互相打了个招呼,毕竟是同事。李四问张三怎么还不回去,张三说快递还没派送完呢,李四问还有几个,是谁的啊?,张三就回答说还有2个快递,是小王的。李四说你别着急,我车上也有4个快递是小王的,你帮我一起给他把。张三就想毕竟同事一场,就帮一下李四吧。然后张三继续给小王打电话,只要他车里还有小王的快递,就会一直打。当小王把快递全部取走,张三才回家。水平触发模式的特点:只要底层有数据,就一直通知上层,告知上层条件是就绪的。
当小王收完10个快递,小王觉得还不够,继续网购,又来了10个快递到菜鸟驿站。今天李四拿着小王的6个快递来了,李四到了宿舍楼下就给所有人打电话,包括小王。李四是这么说的:小王,你的快递到了,赶紧下来取,你要是不下来取,我也就给你打这一次电话,等下我就走了。所以小王没办法,只能下楼把快递取完,这是正常情况。但是今天小王也只能取4个快递,李四车里还有2个快递,李四也不管了直接走了,李四只通知小王一次。李四正准备走,张三从旁边路过,张三就问李四快递派完没,李四说还有2个小王的,张三就说我这也有4个小王的,你帮我交给他吧。李四想张三之前也帮过我,所以我也帮帮他。李四车里的包裹就由2个变成了6个,李四就给小王打电话说,小王你现在又多了4个快递,你赶紧下来取,你下来取一定要取完,不然我也就直接走了。当新收到数据,只会通知对方一次。数据从无到有,从有到多,数据变化(增多)的时候,才会通知对方一次。这种通知模式就是ET边缘触发模式。
小王就是用户层,小王下楼取快递就是调用了recv,小王的快递就是数据,张三的三轮车就是套接字的接收缓冲区,打电话就是进行事件通知的过程。对于张三只要缓冲区里有数据,就会一直通知小王。对于李四,缓冲区数据从无到有,或者从有到多才会通知小王一次,所以小王就必须把数据全部取走。
细节1:我们写的select、poll、epoll,如果读事件就绪我们不读取,那么select、poll、epoll就会不断通知上层。所以poll、epoll的默认通知方式就是:LT水平触发。
细节2:LT是怎么做到的?
一旦有数据就绪,就绪节点会一直在就绪队列中,除非你把数据取完,否则该节点就会一直在,epoll_wait就会一直返回。
细节2:ET是怎么做到的?
一旦数据就绪了,epoll_wait拷贝该节点的信息,该节点立马在就绪队列中移除,除非又有数据从网络传递到我的缓冲区中,否则不再通知,即便数据没有取完。
细节4:ET(Edge Triggered) 和 LT(Level Triggered)谁更高效呢?
ET更高效。因为:
1、不做重复通知。
2、一旦通知,数据必须取完。所以倒逼程序员必须在ET模式下把数据取完,那么接收缓冲区的剩余空间就会更大,给对方通告一个更大的接收窗口,对方滑动窗口进行流量控制就可以发送更多数据。提高IO带宽,提高IO效率。
但是这样做是有代价的,首先你必须把数据全部取完,因为如果不取完,不再进行通知,那剩下的那部分数据就丢了。
但是我们怎么保证把数据取完呢?——循环读取。对于循环读取:
1、当实际读取的数据 < 期望数据,说明读完了。
2、当阻塞住了,说明读完了。
因为可能你这次读取的时候刚好把缓冲区数组填满了,那你就要继续读,而这时候底层可能刚好就没数据了,那么你下次读取就会阻塞住了。所以ET这里,需要把fd设置为非阻塞。所以ET这里如何判断数据是否读完了?循环读取,当读取以出错形式返回,错误码被设置为EAGAIN,说明数据读完了。
细节5:ET模式下,要求所有的fd都必须是非阻塞的,LT没有这个要求
细节6:LT模式为什么没有这个要求呢?
一般LT模式下,我们只建议读取一次。如果一次就读完了,那就等epoll_wait返回,通知了再读。如果一次没读完,那么epoll_wait就会直接返回,可以继续读,所以也不怕。
细节7:LT + 非阻塞 + 循环读取 = ET?
此时,LT和ET效率确实没有本质区别。但是最主要在于ET会倒逼程序员把数据读完。
理解ET模式和非阻塞文件描述符:
使用ET模式的epoll,需要将文件描述设置为非阻塞。这个不是接口上的要求。而是 “工程实践” 上的要求。
假设这样的场景:服务器接收到一个10k的请求,会向客户端返回一个应答数据。如果客户端收不到应答,不会发送第二个10k请求。

如果服务端写的代码是阻塞式的read,并且一次只read1k数据的话(read不能保证一次就把所有的数据都读出来,参考 man 手册的说明,可能被信号打断),剩下的9k数据就会待在缓冲区中。

此时由于epoll是ET模式,并不会认为文件描述符读就绪。epoll_wait就不会再次返回。剩下的9k数据会一直在缓冲区中。直到下一次客户端再给服务器写数据epoll_wait才能返回。
但是问题来了:
服务器只读到1k个数据,要10k读完才会给客户端返回响应数据。
客户端要读到服务器的响应,才会发送下一个请求。
客户端发送了下一个请求,epoll_wait才会返回,才能去读缓冲区中剩余的数据。
所以,为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完),于是就可以使用非阻塞轮询的方式来读缓冲区,保证一定能把完整的请求都读出来。
7、Reactor模式
我们前面写的基于epoll的EchoServer,在读取数据的时候面临一个问题,怎么保证一个或多个完整的报文读完了?将来可以循环读取,或者一次读一次读然后边读边分析,读取可能读不到一个完整的报文,所以就需要我们将历史读到的数据进行缓存。否则下次在读取buffer就被释放了。并且读完响应回去该如何处理呢?这些问题我们在下面的代码中解决。

如图,我们先看Epoller.hpp,里面封装了epoll的相关接口,比如创建epoll模型、添加文件描述符和关心事件到epoll模型中、修改epoll模型中关心事件、移除epoll模型中对fd 的关心,进行epoll_wait等待事件就绪。
// Epoller.hpp
#pragma once
#include <iostream>
#include <unistd.h>
#include <sys/epoll.h>
#include "Common.hpp"
#include "Log.hpp"
using namespace LogModule;
const static int gdefaultepfd = -1;
class Epoller
{
public:
Epoller()
:_epfd(gdefaultepfd)
{}
// 创建epoll模型
void Init()
{
_epfd = epoll_create(256);
if (_epfd < 0)
{
LOG(LogLevel::ERROR) << "epoll_create error";
exit(EPOLL_CREATE_ERR);
}
LOG(LogLevel::INFO) << "epoll_create success, epfd: " << _epfd;
}
// epoll_wait等待事件就绪
int Wait(struct epoll_event revs[], int rnum, int timeout)
{
int n = epoll_wait(_epfd, revs, rnum, timeout);
if (n < 0)
{
LOG(LogLevel::WARNING) << "epoll_wait error";
return n;
}
return n;
}
// Ctrl用于控制,对epoll模型进行添加或修改操作
void Ctrl(int sockfd, uint32_t events, int flags)
{
struct epoll_event ev;
ev.data.fd = sockfd;
ev.events = events;
int n = epoll_ctl(_epfd, flags, sockfd, &ev);
if (n < 0)
{
LOG(LogLevel::WARNING) << "epoll_ctl error";
return;
}
}
void Add(int sockfd, uint32_t events)
{
Ctrl(sockfd, events, EPOLL_CTL_ADD);
}
void Update(int sockfd, uint32_t events)
{
Ctrl(sockfd, events, EPOLL_CTL_MOD);
}
// 删除epoll模型中对该fd的关心
void Delete(int sockfd)
{
int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
if (n < 0)
{
LOG(LogLevel::WARNING) << "epoll_ctl error";
}
}
~Epoller()
{
if (_epfd >= 0)
close(_epfd);
}
private:
int _epfd;
};
接着我们实现一个Connection连接类:
// Connection.hpp
#pragma once
#include <iostream>
#include <string>
#include "InetAddr.hpp"
#include "Reactor.hpp"
class Reactor;
// 基类,将来以继承得方式分别实现Listener和IOService,以统一的方式看待listensocket和普通socket
class Connection
{
public:
Connection()
:_sockfd(-1)
,_events(0)
{}
~Connection()
{}
// 一系列的获取方法
int GetSockfd() { return _sockfd; }
InetAddr GetPeer() { return _peer; }
uint32_t GetEvents() { return _events; }
Reactor* GetOwner() { return _owner; }
std::string& GetInBuffer() { return _inbuffer; }
std::string& GetOutBuffer() { return _outbuffer; }
// 一系列的设置方法
void SetSockfd(int sockfd) { _sockfd = sockfd; }
void SetPeer(InetAddr& peer) { _peer = peer; }
void SetEvents(uint32_t events) { _events = events; }
void SetOwner(Reactor* reactor) { _owner = reactor; }
void AppendInBuffer(const std::string& in)
{
_inbuffer += in;
}
void AppendOutBuffer(const std::string& out)
{
_outbuffer += out;
}
void EraseOutBuffer(int n)
{
_outbuffer.erase(0, n);
}
void Close() { close(_sockfd); }
// Listener/IOService继承Connection后实现
virtual void Recver() = 0;
virtual void Sender() = 0;
virtual void Excepter() = 0;
protected:
int _sockfd; // 该连接对应文件描述符
InetAddr _peer; // 客户端信息
uint32_t _events; // 关心的事件
std::string _inbuffer; // 输入缓冲区
std::string _outbuffer; // 输出缓冲区
Reactor* _owner; // 回指Reactor对象,方便后续连接管理
// 时间戳进行保活测试
// uint64_t _timestamp;
};
Connection包含了一个连接的基本信息,如:文件描述符、客户端信息、关心的事件、输入缓冲区、输出缓冲区。在ET模式下,读事件就绪只会通知一次,所以我们需要将数据读完,因此需要循环读取,而循环读取需要设置fd为非阻塞,并且循环读取我们就需要将之前读取的数据暂存起来,所以需要有一个inbuffer来保存数据。TCP是面向字节流的,我们读完的数据并不一定是一个完整的报文,可能是半个报文,所以也需要将数据暂存起来。
同时我们也需要一个输出缓冲区,因为我们写入并不一定每次都能写入,万一发送缓冲区被写满了呢,我们也需要将数据缓存起来。
我们还设置一个回指指针,这个指针回指Reactor对象,因为我们是在Reactor中统一进行连接管理的,将来通过该指针就能获取Reactor进行连接管理。
另外我们可以保存一个时间戳,如果进行IO处理的时候可以更新时间戳,可以利用这个时间戳实现客户端和服务端的保活机制。
Connection类中就是一些Get和Set方法,最主要还是三个虚函数Recver、Sender、Excepter,将来让子类继承后实现具体方法。
Listener实现:
// Listener.hpp
#pragma once
#include <iostream>
#include "Socket.hpp"
#include "Connection.hpp"
#include "IOService.hpp"
#include "Calculator.hpp"
using namespace SocketModule;
// 连接管理器——专用负责获取连接的模块
class Listener : public Connection
{
public:
Listener(uint16_t port)
:_port(port)
,_listensock(std::make_unique<TcpSocket>())
{
// 创建listensocket,并设置connection相关属性
_listensock->BuildTcpSocketMethod(_port);
SetSockfd(_listensock->Fd());
SetEvents(EPOLLIN | EPOLLET); // listensocket只需要关心读事件
SetNonBlock(_listensock->Fd()); // 设置为非阻塞
}
~Listener()
{
_listensock->Close();
}
virtual void Recver() override
{
// 连接可能一次到来多个,需要循环获取,所以需要将listensock设置为非阻塞
while (true)
{
InetAddr peer;
int aerrno = 0;
int sockfd = _listensock->Accepter(&peer, &aerrno);
if (sockfd > 0)
{
// 创建IOService插入unordered_map中
auto conn = std::make_shared<IOService>(sockfd, peer);
// 注册上层业务处理方法
// 这里的RegisterOnMessage不是多态调用,所以conn的类型必须是std::shared_ptr<IOService>
conn->RegisterOnMessage(HandlerRequest);
GetOwner()->InsertConnection(conn);
}
else
{
if (aerrno == EAGAIN || aerrno == EWOULDBLOCK)
{
LOG(LogLevel::DEBUG) << "accepter all connection...";
break;
}
else if (aerrno == EINTR)
{
LOG(LogLevel::DEBUG) << "accepter intr by signal, continue";
continue;
}
else
{
LOG(LogLevel::WARNING) << "accepter error";
return;
}
}
}
}
virtual void Sender() override {}
virtual void Excepter() override
{}
private:
std::unique_ptr<Socket> _listensock;
uint16_t _port;
};
Listener继承于Connection,实现具体的Recver、Sender、Excepter方法,并且Listen也要创建套接字所以有一个_listensock对象,通过之前封装的Socket.hpp创建TcpSocket。而监听套接字只需要获取新连接,也就是Recver,所以剩下两个函数方法体为空即可,不需要实现。
注意,获取新连接,可能一次到来多个连接,因此我们就不能只调用一次Accepter,所以需要循环调用Accepter,当底层以出错的形式返回,我们判断错误码,如果错误码是EAGAIN/EWOULDBLOCK,说明读取数据不就绪,读取完了,这时候我们直接break即可。
读取成功我们需要创建一个IOService对象,通过回指指针_owner获取Reactor对象将新连接插入。
IOService实现:
// IOService.hpp
#pragma once
#include <iostream>
#include <functional>
#include "Connection.hpp"
using func_t = std::function<std::string(std::string&)>;
// 只负责IO
class IOService : public Connection
{
const static int size = 1024;
public:
IOService(int sockfd, InetAddr peer)
{
SetSockfd(sockfd);
SetNonBlock(sockfd); // 设置为非阻塞
SetEvents(EPOLLIN | EPOLLET);
SetPeer(peer);
}
~IOService()
{}
void RegisterOnMessage(func_t on_message)
{
_on_message = on_message;
}
virtual void Recver() override
{
while (true)
{
char buffer[size];
// 无法保证我们读取上来的报文是一个完整的报文!!!
ssize_t n = ::recv(GetSockfd(), buffer, sizeof(buffer)-1, 0);
if (n > 0)
{
buffer[n] = 0;
AppendInBuffer(buffer);
}
else if (n == 0)
{
// 客户端把连接关闭了
Excepter();
break;
}
else
{
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
break;
}
else if (errno == EINTR)
{
continue;
}
else
{
Excepter();
return;
}
}
}
// 到这里说明读取完成,我们无法保证读取的报文是一个完整的报文,所以需要协议处理
std::string result;
if (_on_message)
{
result = _on_message(GetInBuffer());
}
// 如果没有一个完整报文,添加空串不影响
AppendOutBuffer(result);
if (!GetOutBuffer().empty())
{
// 方案1:直接Sender写入
// 方案2:使能Write
Sender();
// GetOwner()->EnableReadWrite(GetSockfd(), true, true);
}
}
virtual void Sender() override
{
while (true)
{
ssize_t n = send(GetSockfd(), GetOutBuffer().c_str(), GetOutBuffer().size(), 0);
if (n > 0)
{
EraseOutBuffer(n);
}
else if (n == 0)
{
// outbuffer没数据了.
break;
}
else
{
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
// 写缓冲区满了,下次再写
break;
}
else if (errno == EINTR)
{
continue;
}
else
{
Excepter();
return;
}
}
}
// 1. outbuffer数据写完了
// 2. 发送缓冲区被写满了,outbuffer不为空,写条件不满足,使能sockfd在epoll中的事件
// 如果outbuffer还有数据,就将读事件添加到epoll模型中
if (!GetOutBuffer().empty())
{
GetOwner()->EnableReadWrite(GetSockfd(), true, true);
}
else
{
GetOwner()->EnableReadWrite(GetSockfd(), true, false);
}
}
// IO读取的时候,所有的异常处理,全部都会转化成为这个一个函数的调用
// 出现异常,我们怎么做???
// 打印日志,差错处理,关闭连接,Reactor异常connection, 从内核中,移除对fd的关心
virtual void Excepter() override
{
LOG(LogLevel::WARNING) << "客户端连接可能结束,进行异常处理: " << GetSockfd();
GetOwner()->DeleteConnection(GetSockfd());
}
private:
func_t _on_message;
};
IOService用来进行IO处理,当读时间就绪,就会调用Recver读取数据。将来以统一的Connection看待Listen套接字和普通IO处理的sockfd。
多路转接对写的处理:
1、写事件是否就绪是看发送缓冲区是否有空间。而sockfd默认发送缓冲区就是有空间的,所以我们Recver后进行处理后可以直接Sender,或者使能开启该sockfd对写事件的关心。只有当发送缓冲区被写满了,写条件才不具备。
2、如何正确处理写入?直接写入,写满了条件不具备,此时我们将写事件托管给epoll模型。
多路转接方案设计的时候,写事件关心永远不能常开启。需要按需设置。
Reactor.hpp实现:
// Reactor.hpp
#pragma once
#include <iostream>
#include <unordered_map>
#include <memory>
#include "Connection.hpp"
#include "Epoller.hpp"
using connection_t = std::shared_ptr<Connection>;
// 事件派发器
class Reactor
{
const static int event_num = 64;
bool IsConnectionExists(int sockfd)
{
return _connections.find(sockfd) != _connections.end();
}
public:
Reactor()
:_epoller(std::make_unique<Epoller>())
,_isrunning(false)
{
_epoller->Init();
}
void InsertConnection(connection_t conn)
{
auto iter = _connections.find(conn->GetSockfd());
if (iter == _connections.end())
{
// 1.插入连接,放到unordered_map统一管理
_connections.insert(std::make_pair(conn->GetSockfd(), conn));
// 2.添加到epoll模型中,默认以ET方式开启读事件的关心
_epoller->Add(conn->GetSockfd(), conn->GetEvents());
// 3.设置回指指针
conn->SetOwner(this);
LOG(LogLevel::INFO) << "add connection success, sockfd: " << conn->GetSockfd();
}
}
void EnableReadWrite(int sockfd, bool read, bool write)
{
if (IsConnectionExists(sockfd))
{
uint32_t events = (read ? EPOLLIN : 0) | (write ? EPOLLOUT : 0) | EPOLLET;
_connections[sockfd]->SetEvents(events); // 记得修改连接关心的事件
_epoller->Update(sockfd, events);
}
}
void DeleteConnection(int sockfd)
{
if (IsConnectionExists(sockfd))
{
// 1.删除epoll模型中对该fd的关心
_epoller->Delete(sockfd);
// 2.关闭文件
_connections[sockfd]->Close();
// 3.移除unordered_map中的连接
_connections.erase(sockfd);
}
}
void Dispatcher(int rnum)
{
// 如果返回0/-1不会进入循环
for (int i = 0; i < rnum; i++)
{
int fd = _revs[i].data.fd;
uint32_t revents = _revs[i].events;
// epoll出错或对方把连接关了,直接转换成读写错误进行处理
if ((revents & EPOLLERR) || (revents & EPOLLHUP))
revents = EPOLLIN | EPOLLOUT;
if ((revents & EPOLLIN) && IsConnectionExists(fd))
_connections[fd]->Recver();
if ((revents & EPOLLOUT) && IsConnectionExists(fd))
_connections[fd]->Sender();
}
}
void LoopOnce(int timeout)
{
int n = _epoller->Wait(_revs, event_num, timeout);
Dispatcher(n);
}
void Loop()
{
int timeout = -1;
_isrunning = true;
while (_isrunning)
{
LoopOnce(timeout);
DebugPrint();
// 超时管理——TODO
}
_isrunning = false;
}
void DebugPrint()
{
std::cout << "Epoller管理的fd: ";
for (auto& iter : _connections)
{
std::cout << iter.first << " ";
}
std::cout << "\n";
}
~Reactor()
{}
private:
std::unique_ptr<Epoller> _epoller; // epoll模型
std::unordered_map<int, connection_t> _connections; // 管理所有连接
bool _isrunning; // 是否启动
struct epoll_event _revs[event_num]; // 存储epoll_wait返回的信息
};

如图,当事件就绪了操作系统就会通知上层,上面这一层就是Reactor,会激活节点派发事件,而再上层有Listener连接管理器和IOServiceIO处理器,Listener和IOService都继承于Connection,将来Reactor以统一的Connection视角进行连接管理。而Listener和IOService里面的读写异常处理方法都不同,将来事件派发执行的方法也就不同。而在连接上面还有协议的处理,因为TCP是面向字节流的,我们还需要解决粘包问题,所以需要定制协议。而在协议上层还有业务处理层。实现了软件分层代码解耦。这就是Reactor模式。
8、多执行流Reactor模式
8.1、多进程Reactor模式

主进程创建listensocket和Reactor,将listensocket托管给Reactor,然后创建管道和子进程。子进程继承listensocket,每个子进程创建一个Reactor,将子进程管道的读端注册到Reactor,当listensocket就绪了,主进程负载均衡的通知子进程事件就绪,子进程通过继承的listensocket获取新连接,创建Connection对象添加到自己的Reactor中。
accpet函数本身是线程安全的,但是这里多进程访问我们可以考虑加锁,多进程如何加锁?我们貌似只讲过进程加锁。我们可以创建一块共享内存,创建pthread_mutex_t互斥锁,并进行初始化和设置,放入共享内存中,这样就可以实现进程互斥。
这种技术方案我们称为One Thread One Loop。
8.2、多线程Reactor模式

多线程这里同样使用管道进行通信,管道不仅可以实现进程间通信,也可以实现线程间通信。
主线程创建listensocket和Reactor,把listensocket注册到Reactor中,当事件就绪把获取的fd通过管道写给Slavor线程,我们规定读写管道必须按4字节。Slavor线程将管道的读端注册到Reactor中,一旦管道读事件就绪,Slavor从管道中获取文件描述符,并注册到Reactor中。这就是多线程的One Thread One Loop。
但是之前我们线程池中的线程是在条件变量上等,而我们又需要再epoll上等,而条件变量和epoll无法整合到一起。因此之前的线程池方案行不通。而上面的方案是可以的,因为管道是基于文件描述符的,文件描述符可以被整合进epoll。所以使用管道Master和Slavor既通信了,又唤醒了Slavor。
如果今天不想用管道还有没有其他方案呢?有的,我们需要一种线程间,基于fd的通知机制。

eventfd——创建一个文件描述符用于事件通知。
第一个参数设置计数器初始值,第二参数一般传入EFD_CLOEXEC,确保文件描述符在exec时自动关闭,防止被新程序继承。
eventfd是一个轻量级的事件通知机制,基于文件描述符。
它可以和I/O多路复用机制(如epoll)结合使用。
内核维护一个64位的计数器,write会增加计数器,read会减少计数器。
read读取的时候,有两种模式,一种只read直接将计数器清0,如果计数器本来就是0就会阻塞。
特点:
1、低开销:eventfd内部是一个64位计数器,内核维护成本低。
2、支持多路复用:可以与epoll、poll或select等I/O多路复用机制结合使用。
3、原子性:读写操作是原子的,适合高并发场景。
4、广播通知:可以用于多对多的事件通知,而不仅仅是点对点通信。
5、高效性:相比传统管道,eventfd避免了多次数据拷贝,且内核开销更小。
注意事项:
1、仅用于事件通知:eventfd不能传递具体的消息内容,仅用于通知事件的发生。
2、非阻塞模式:建议设置EFD_NONBLOCK,避免阻塞操作。
3、信号量语义:如果需要信号量语义(每次读取计数器减1),需设置EFD_SEMAPHORE 。
4、资源管理:使用完eventfd后,记得关闭文件描述符。
5、结合I/O多路复用:在高并发场景下,建议结合epoll使用。

主线程是listensocket+Reactor,将listensocket注册到Reactor中,当事件就绪获取fd,将fd放入队列中,主线程和新线程对应一个eventfd,当主线程获取fd成功就负载均衡的通知其他线程。Slavor线程将eventfd读端注册到Reactor中,当事件就绪了从队列中获取fd,注册到Reactor中。
8.3、协程
不考虑Reactor,如果有个单进程,里面有1、2、3、4、5、6文件描述符,比如1->3号都是进程read,如果把所有文件描述符都设置为非阻塞,1不就绪就读取2,2不就绪就读取3,本质上就是做多个文件描述符的轮询。当我们调用read的时候,这是一个系统调用,要形成read的栈帧结构(返回值、返回地点…),而栈帧的信息是固定的,所以我们可以获取所有的栈帧信息。这时候read 1,当事件没有就绪时,我们进行如下操作:
1、保存read栈帧结构。
2、将fd=1添加到epoll里面。
3、继续读取2、3、4…
4、当epoll通知我fd=1就绪了,首先恢复栈帧,然后OS继续调用read读取到数据。
所以这就需要我们在进程中对所有的栈帧结构管理——先描述,再组织。
这时候我们发现我们调用read的时候,在IO函数调用层面,进行系统级别的切换。这就叫做协程。
921

被折叠的 条评论
为什么被折叠?



