目录
select函数
select服务器
selectServer的不完善版本的基础框架(构造函数、析构函数和类成员)
selectServer的不完善版本的start函数(如何调用accept,或者说如何看待监听套接字listen_sock)
selectServer的整体代码、即完善版本的基础框架(构造函数、析构函数和类成员)和完善版本的start函数
对selectServer服务器的测试
select服务器的优缺点
poll函数
poll服务器
pollServer的整体代码
对pollServer服务器的测试
poll服务器的优缺点
当网卡或者键盘这些外设中有数据时,OS如何知道外设中有数据呢?
epoll原理
epoll_create函数
epoll_ctl函数
epoll_wait函数
缩减版的epoll服务器(当前epoll服务器只是为了演示epoll_create、epoll_ctl、epoll_wait的用法)
对缩减版的epoll服务器的测试
epoll的两种工作方式
完整版的epoll服务器
epollServer的整体代码
对完整版的epoll服务器的测试
如上图所示,select是系统提供的一个多路转接接口。在<<高级IO的相关知识点>>一文中说过,IO = 等待 + 拷贝数据,多路转接IO可以支持一次等待多个文件描述符就绪,并且等待的时间还是重叠的,所以select作为多路转接IO中的一种函数,select系统调用可以让我们的程序同时等待(或者说监视)多个文件描述符的上的事件是否就绪。select只负责等待(或者说监视),当监视的多个文件描述符中有一个或多个文件描述符的事件就绪时,select就会成功返回并将对应文件描述符的对应就绪事件告知调用者。
头文件说明:
参数说明:
select函数剩下的3个参数readfds、writefds、exceptfds都是fd_set类型,同时也都是输入输入型参数。介绍一下fd_set类型和这3个参数,如下:
fd_set表示文件描述符集,和<<进程的信号>>一文中的信号集sigset_t类型一样,fd_set也是一个位图类型,在位图fd_set中,第一个位(把最低位称为第一个位)就表示0号文件描述符,第二个位就表示1号文件描述符....后序以此类推。
如果把readfds指针指向的fd_set位图变量中的第4个位设置为1,则表示需要让select函数关心3号文件描述符的读事件是否就绪(即关心3号文件描述符对应的struct file中的内核缓冲区中是否有数据可以让用户层拿走);如果把writefds指针指向的fd_set位图变量中的第4个位设置为1,则表示需要让select函数关心3号文件描述符的写事件是否就绪(即关心3号文件描述符对应的struct file中的内核缓冲区中是否有空间可以让用户层往其中输入数据);如果把exceptfds指针指向的fd_set位图变量中的第4个位设置为1,则表示需要让select函数关心3号文件描述符是否有异常事件,即是否发生了异常。说一下,一般来说调用select函数时是不会让select函数同时关心一个文件描述符的读事件和写事件的,举个例子,比如说把readfds指针指向的fd_set位图变量中的第4个位设置为1以让select函数关心3号文件描述符的读事件后,就不会再把writefds指针指向的fd_set位图变量中的第4个位设置为1以让select函数关心3号文件描述符的写事件。注意,虽然调用select函数时不会让select函数同时关心一个文件描述符的读事件和写事件,但能同时关心一个文件描述符的读事件和异常事件,也能同时关心一个文件描述符的写事件和异常事件。
注意, 和<<进程的信号>>一文中的信号集sigset_t类型一样,把fd_set位图变量中的第x个位设置为1时也不能自己编码进行位操作,而需要使用下图1红框处的系统提供的接口(这也是为了提高代码的跨平台性),其中FD_CLR函数表示把set指针指向的fd_set位图变量中的表示fd号文件描述符的位设置为0,比如fd为0时,就是把set指针指向的fd_set位图变量中的第1个位设置为0;FD_ISSET函数表示检查set指针指向的fd_set位图变量中的表示fd号文件描述符的位,如果位上为1,则返回真(即1),如果为0,则返回假(即0),如果给FD_ISSET函数传递的参数是错的,比如传给参数fd的值超出了fd_set位图中的比特位个数(如下图2所示,计算出的fd_set类型的大小为128字节,所以最多只有128*8=1024个比特位,如果传给参数fd的值等于1024,则1024号文件描述符需要在第1025个比特位上表示,此时就越界了,这就叫做传给参数fd的值超出了fd_set位图中的比特位个数;说一下,不同平台下的fd_set类型的大小可能不同),则返回一个负值;FD_SET函数表示把set指针指向的fd_set位图变量中的表示fd号文件描述符的位设置为1,比如fd为0时,就是把set指针指向的fd_set位图变量中的第1个位设置为1;FD_ZERO函数表示把set指针指向的fd_set位图变量中的所有比特位全设置为0。
如果把readfds指针指向的fd_set位图变量中的第4、第6、第7个位设置为1,则表示需要让select函数关心3号、5号、6号文件描述符的读事件是否就绪(即关心3号、5号、6号文件描述符对应的struct file中的内核缓冲区中是否有数据可以让用户层拿走),如果此时在这多个文件描述符中真的有文件描述符的读事件就绪,则select函数会立刻返回,比如说当5号、6号文件描述符的读事件同时就绪,则select函数会立刻结束并返回,此时readfds指针指向的fd_set位图变量中的第6(表示5号文件描述符)、第7(表示6号文件描述符)个位的值就继续为1,表示5、6号文件描述符的读事件就绪,但第4个位(表示3号文件描述符)的值就不为1而是为0了,表示3号文件描述符的读事件没有就绪;通过本段前面的叙述,我们就能推而广之地脑补出如果把writefds指针指向的fd_set位图变量中的第4、第6、第7个位设置为1,则后面会发生什么,同理,我们也能推而广之地脑补出如果把exceptfds指针指向的fd_set位图变量中的第4、第6、第7个位设置为1,则后面会发生什么。
说一下,在网络通信中,对于服务端进程通过accept函数获取到的服务套接字(不以严格的视角上看,是可以把套接字当作文件的)对应的文件描述符A,如果服务端进程将该文件描述符A设置进了select函数以让select函数等待该文件描述符A的读事件和异常事件就绪,则如果客户端进程此时调用close函数主动断开连接,服务端进程的select函数是能检测到文件描述符A的读事件就绪的。是的你没看错,就是读事件就绪,而不是异常事件就绪,这是因为客户端调用close函数发起4次挥手时本质就是在给服务端发送FIN标志位为1的TCP报文,所以服务端进程的select函数能检测到文件描述符A的读事件就绪。
注意,和select函数的struct timeval*类型的参数timeout一样,因为select函数剩下的3个fd_set*指针类型的参数readfds、writefds、exceptfds都是输入输出型参数,所以在select函数调用完毕时这3个指针指向的fd_set位图变量可能已经被修改了,所以在进入下一次循环前,如果想要select函数继续关心某些文件描述符的读、写、异常事件是否就绪,一定别忘了重新对这3个指针指向的fd_set位图变量进行设置。
走到这里我们就对select函数剩下的3个fd_set*指针类型的参数readfds、writefds、exceptfds进行了深刻地理解,这里我们再对它们做一个简单的总结,如下:
返回值说明:
如下代码所示,selectServer的基础框架就是一个正常的TCP服务端的基础框架,写过很多次了,就不再赘述。
需要注意的是,select服务器我们只编写read读取的逻辑,写入和异常不做处理,这是为了图方便,但不必担心,因为select服务器并不常用,最常用的是epoll服务器,我们会在编写epoll服务器时将代码补充完整。
#include
using namespace std;
#include
#include
#include //提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include//系统库,提供socket编程需要的相关的接口
#include//系统库,提供socket编程需要的相关的接口
#include//提供sockaddr_in结构体
#include//提供sockaddr_in结构体
//select服务器我们只编写read读取的逻辑,写入和异常不做处理,这是为了图方便,但不必担心,因为select服务器并不常用,最常用的是epoll服务器,我们会在编
//写epoll服务器时将代码补充完整。
class selectServer
{
public:
selectServer(uint16_t port)
{
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_listen_sock < 0)
exit(1);
sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = inet_addr("0.0.0.0");
if (bind(_listen_sock, (sockaddr*)&local, sizeof(local)) < 0)
exit(2);
if (listen(_listen_sock, 10) < 0)
exit(3);
}
~selectServer()
{
if(_listen_sock > 0)
close(_listen_sock);
}
private:
int _listen_sock;
uint16_t _port;
};
如何在selectServer的start函数中调用accept,或者说如何看待监听套接字listen_sock呢?
既然如此,那如何在selectServer的start函数中调用accept呢?
因为select系统调用可以让我们的进程同时等待(或者说监视)多个文件描述符的上的事件是否就绪,所以我们可以把监听套接字listen_sock也添加进select函数中,让select函数帮当前进程等待(或者说监视)监听套接字对应的struct file中的连接队列就绪,在连接队列没有就绪时,因为select函数可以被设置成非阻塞模式或者定时模式,所以这时当前进程就可以趁机处理一些其他的业务,根据上面的理论,我们可以暂时编写出如下代码(注意该代码只是半成品):
#include
using namespace std;
#include
#include
#include //提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include//系统库,提供socket编程需要的相关的接口
#include//系统库,提供socket编程需要的相关的接口
#include//提供sockaddr_in结构体
#include//提供sockaddr_in结构体
//select服务器我们只编写read读取的逻辑,写入和异常不做处理,这是为了图方便,但不必担心,因为select服务器并不常用,最常用的是epoll服务器,我们会在编
//写epoll服务器时将代码补充完整。
class selectServer
{
public:
selectServer(uint16_t port)
{
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_listen_sock < 0)
exit(1);
sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = inet_addr("0.0.0.0");
if (bind(_listen_sock, (sockaddr*)&local, sizeof(local)) < 0)
exit(2);
if (listen(_listen_sock, 10) < 0)
exit(3);
}
~selectServer()
{
if(_listen_sock > 0)
close(_listen_sock);
}
void start()
{
fd_set fs;
FD_ZERO(&fs);
FD_SET(_listen_sock, &fs);
while(1)
{
//t作为select函数的输入输出型参数,在select函数结束后是有可能被改变的,所以如果想要select函数每次都等待5秒,即如果想要t一直等于5秒,则需要把定义t的代码
//放到while循环中。
timeval t = {5,0};
int x = select(_listen_sock+1, &fs, nullptr, nullptr, &t);
switch(x)
{
case 0:
cout<<"超时time out,没有文件描述符就绪,可以在这个分支里趁机处理一会其他的业务"< 0)//如果是监听套接字的连接队列就绪,则accept获取连接对象(或者说获取服务套接字)
{
cout<<"get a new link"<
在上面代码的注释中提出了一个问题,我们需要先回答这个问题,然后根据问题的答案的思路才能继续完善上面的半成品代码,问题为:accept获取一个服务套接字(或者说连接对象)后,可以直接对该服务套接字进行read/recv吗?
答案是不行,因为服务套接字的内核缓冲区可能没有数据,这时调用read/recv就会导致当前进程陷入阻塞,这样一来当前进程就无法做任何其他事情了,其中就包括等待(或者说监视)其他的文件描述符是否就绪,这就违背了select服务器的初衷,所以我们不能冒然调用read/recv,而要在明确地知道该服务套接字的内核缓冲区中有数据时才能调用read/recv,这样一来才能避免read/recv导致当前进程陷入阻塞。那么谁才能让当前进程明确地知道该服务套接字的内核缓冲区中有数据呢?也只能靠select函数,我们可以在accept获取一个服务套接字后就立刻将该套接字设置进select函数中(即立刻让select函数关心该套接字的读事件是否就绪),然后下次循环时再通过select函数等待该服务套接字的内核缓冲区就绪。于是现在就有3个问题需要解决:
对于以上3个问题,我们的解决思路如下:
根据上面的思路,我们就能完善selectServer类的基础框架,并编写出完整的start函数的代码,如下所示。
#include
using namespace std;
#include
#include
#include //提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include//系统库,提供socket编程需要的相关的接口
#include//系统库,提供socket编程需要的相关的接口
#include//提供sockaddr_in结构体
#include//提供sockaddr_in结构体
#define NUM (sizeof(fd_set)*8)
//需要注意的是,select服务器我们只编写read读取的逻辑,写入和异常不做处理,这是为了图方便,但不必担心,因为select服务器并不常用,最常用的是epoll服务器,我们会在编
//写epoll服务器时将代码补充完整。
class selectServer
{
public:
selectServer(uint16_t port)
{
//初始化用于存储文件描述符的数组,将其中每个值都设置成-1,-1表示该位置上没有文件描述符,如果某个位置上为3,则表示该位置存储的是3号文件描述符
for(int i=0; i 0)
close(_listen_sock);
}
void start()
{
fd_set fs;
while(1)
{
//进入循环后第一件事就是把数组中的文件描述符全设置进select函数中,并找出最大的文件描述符
FD_ZERO(&fs);
int maxfd = _listen_sock;
for(int i=0; i maxfd)
maxfd = _fd_arry[i];
}
//t作为select函数的输入输出型参数,在select函数结束后是有可能被改变的,所以如果想要select函数每次都等待5秒,即如果想要t一直等于5秒,则需要把定义t的代码
//放到while循环中。
timeval t = {5,0};
//通过咱们自己编写的debug函数查看数组成员fd_arry中当前有哪些文件描述符
debug();
int x = select(maxfd+1, &fs, nullptr, nullptr, &t);
switch(x)
{
case 0:
cout<<"超时time out,没有文件描述符就绪,可以在这个分支里趁机处理一会其他的业务"< 0)//如果是监听套接字的连接队列就绪,则accept获取连接对象(或者说获取服务套接字)
{
cout<<"get a new link"< NUM-1)
{
cout<<"service_sock为"< 0)//如果就绪,则直接进行read/recv
{
//说一下,这里在read读取时是有bug的,这是因为我们无法确定是否read到了一个完整的报文,也没有解决粘包问题。想要处理这个bug,我们就得自己定一
//个协议,因为select服务器并不常用,所以为了图方便,这里我们就不改这个bug了(不改也是能勉强用的),我们在编写epoll服务器时再处理这个bug。
cout<<"get a new IO"< 0)
{
buffer[s] = 0;
cout<<"client["<
将下图1的代码编译并运行后,直接如下图2所示打开多个终端,并在每个终端中通过telnet命令连接服务端进程,如下图2的红框处所示,出现的数字越来越多表示不断有telnet和服务端进程建立连接成功(凭什么这么说呢?这是因为这些数字是debug函数打印的,debug函数的逻辑是把目前_fd_arry数组中存储的所有文件描述符全部打印出来,而根据上面的编码逻辑,只有服务端进程成功调用accept函数获取到服务套接字或者说新连接的时候,才会把该套接字对应的文件描述符存进_fd_arry数组中,注意3次握手建立连接是发生了listen函数后,accept函数前,既然现在accept函数都成功调用完毕了,连接自然也是建立成功了的),并且如下图2的蓝框处所示,可以发现多个telnet客户端进程是可以同时给单进程模式的selectServer服务端进程发数据的,这就证明了咱们的代码是没有问题的。
优点:
效率高(说一下,这里的效率高是对比【以往以阻塞IO模式编写的多进程或者多线程版本的TCP服务器】,select服务器作为多路转接IO模型中的一种,对比poll和epoll服务器就是弟弟),为什么效率高呢?select服务器作为多路转接IO模型中的一种,当然也具有多路转接IO模型的优点,即select服务器支持一次等待多个文件描述符就绪,支持同时为多个客户端提供服务。注意虽然以往以阻塞IO模式编写的多进程或者多线程版本的TCP服务器也能支持等待多个文件描述符就绪,支持同时为多个客户端提供服务,但是以往的TCP服务器是多进程或者多线程模式,而现在的select服务器是单进程模式,注意创建子进程或者新线程是对服务器有很大的消耗的,相比于以往的来几个客户端就创建几个子进程或者新线程为其提供服务的模式,现在的select服务器的【不管来多少个客户端,我都只靠一个主进程为它们提供服务】的模式无疑是消耗更少的,所以效率就高了。
缺点:
缺点1:首先要知道,在每一次循环中调用select函数前都要重新设定select函数的3个fd_set*指针类型的参数readfds、writefds、exceptfds以确定哪些文件描述符的读、写、异常事件需要被select函数关心,重新设定这几个参数时是需要遍历_fd_arry数组的;然后要知道,在每一次循环中每当select函数检测到有文件描述符就绪时也是需要遍历_fd_arry数组以确定是哪个文件描述符就绪的。综上可以发现,select服务器中充满了大量重复的遍历数组的操作,这就会导致服务器的效率很低,这就是select服务器的缺点。
缺点2:select函数同时能等待(或者说监视)的文件描述符是有上限的,位图fd_set类型中的比特位上限是多少,select函数同时能等待的文件描述符的上限就是多少,也就是sizeof(fd_set)*8,在上文中通过程序证明过在笔者的环境下该值是1024,对于一个服务器而言,只能给1024个客户端提供服务是远远不够的,这就是select服务器的缺点。
正是因为select服务器具有以上这些缺点,才会有poll服务器的诞生,接下来咱们就来看看poll服务器的工作模式。注意在讲解poll函数时还会说明poll服务器如何改善上面的两个缺点。
poll函数和select函数一样,poll作为多路转接IO中的一种函数,poll系统调用也可以让我们的程序同时等待(或者说监视)多个文件描述符的上的事件是否就绪。poll只负责等待(或者说监视),当监视的多个文件描述符中有一个或多个事件就绪时,poll就会成功返回并将对应文件描述符的就绪事件告知调用者。注意poll函数的功能是比select函数的功能更强大的,讲解完poll函数的参数后你就能感受到这点。
头文件说明:
参数说明:
说一下,在上文中讲解select服务器的缺点时说过,因为在selectServer进程中,在每次while循环中调用select函数时都需要重新设定select函数的3个fd_set*指针类型的参数readfds、writefds、exceptfds以确定哪些文件描述符的读、写、异常事件需要被select函数关心,而设置这几个参数又需要遍历_fd_arry数组,所以select服务器的缺点就是存在大量的遍历数组的操作,但学完了poll函数的参数后,我们可以预料到在poll服务器中,虽然poll函数的参数struct pollfd *fds也是输入输出型参数,但因为该参数指向的结构体中有events成员和revents成员,我们对输入和输出做了分离,所以我们无需再在每次调用poll函数时重新将文件描述符设置进poll函数中以让poll函数确定哪些文件描述符的读、写、异常等事件需要被自己关心,所以poll服务器中也就不存在上面所说的这些遍历数组的操作(注意只是不存在【因为需要设置文件描述符而导致的遍历数组的操作】,但并不是说poll服务器中没有【因为其他需求而存在的遍历数组的操作】,poll服务器中依然需要遍历数组),进而提高了poll服务器的效率,改善了select服务器的缺点。
在学完了poll函数的参数后,我们还可以发现,因为poll函数不像select函数那样是将文件描述符设置进select函数的3个fd_set*指针类型的参数指向的fd_set位图变量中,不像fd_set位图中只有固定的sizeof(fd_set)*8个比特位,而是为每个文件描述符都创建一个struct pollfd类型的变量,所以poll函数同时能等待(或者说监视)的文件描述符是没有上限的,只要服务器扛得住,则参数nfds的值是多少,就能同时等待多少文件描述符就绪,这也就改善了select服务器的缺点。
关于上一段,需要补充一点,不要认为在编码中是每先accept到一个服务套接字对应的文件描述符,然后再为该文件描述符创建一个struct pollfd类型的变量,不是这样的,而要知道在实际编码中的工作模式如下:
返回值说明:
关于pollServer的整体代码,我们直接将上文selectServer的整体代码拿过来,然后在其基础上稍微进行修改即可得到,大体框架都是不需要改变的,比如说:
结合上面的理论,pollServer的整体代码如下。
#include
using namespace std;
#include
#include
#include //提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include//系统库,提供socket编程需要的相关的接口
#include//系统库,提供socket编程需要的相关的接口
#include//提供sockaddr_in结构体
#include//提供sockaddr_in结构体
//需要注意的是,poll服务器我们只编写read读取的逻辑,写入和异常不做处理,这是为了图方便,但不必担心,因为相比于poll服务器,最常用的是epoll服务器,我们会在编
//写epoll服务器时将代码补充完整。
class pollServer
{
public:
pollServer(uint16_t port, nfds_t nfds, int timeout)
:_nfds(nfds)
,_timeout(timeout)
,_fds(new struct pollfd[nfds])
{
//初始化用于存储文件描述符的数组,将其中每个值都设置成-1,-1表示该位置上没有文件描述符,如果某个位置上为3,则表示该位置存储的是3号文件描述符
for(int i=0; i 0)
close(_listen_sock);
delete[] _fds;
}
void start()
{
while(1)
{
//通过咱们自己编写的debug函数查看数组成员_fds中当前有哪些文件描述符
debug();
int x = poll(_fds, _nfds, _timeout);
switch(x)
{
case 0:
cout<<"超时time out,没有文件描述符就绪,可以在这个分支里趁机处理一会其他的业务"< 0)//如果是监听套接字的连接队列就绪,则accept获取连接对象(或者说获取服务套接字)
{
cout<<"get a new link"< 0)//如果就绪,则直接进行read/recv
{
//说一下,这里在read读取时是有bug的,这是因为我们无法确定是否read到了一个完整的报文,也没有解决粘包问题。想要处理这个bug,我们就得自己定一
//个协议,因为相比于epoll服务器,poll服务器并不常用,所以为了图方便,这里我们就不改这个bug了(不改也是能勉强用的),我们在编写epoll服务器时
//再处理这个bug。
cout<<"get a new IO"< 0)
{
buffer[s] = 0;
cout<<"client["<
将下图1的代码编译并运行后,直接如下图2所示打开多个终端,并在每个终端中通过telnet命令连接服务端进程,如下图2的红框处所示,出现的数字越来越多表示不断有telnet和服务端进程建立连接成功(凭什么这么说呢?这是因为这些数字是debug函数打印的,debug函数的逻辑是把目前_fds数组中存储的所有文件描述符全部打印出来,而根据上面的编码逻辑,只有服务端进程成功调用accept函数获取到服务套接字或者说新连接的时候,才会把该套接字对应的文件描述符存进_fds数组中,注意3次握手建立连接是发生了listen函数后,accept函数前,既然现在accept函数都成功调用完毕了,连接自然也是建立成功了的),并且如下图2的蓝框处所示,可以发现多个telnet客户端进程是可以同时给单进程模式的pollServer服务端进程发数据的,这就证明了咱们的代码是没有问题的。
优点:
优点1:效率高(说一下,这里的效率高是对比【以往以阻塞IO模式编写的多进程或者多线程版本的TCP服务器】,poll服务器作为多路转接IO模型中的一种,对比epoll服务器就是弟弟),为什么效率高呢?poll服务器作为多路转接IO模型中的一种,当然也具有多路转接IO模型的优点,即poll服务器支持一次等待多个文件描述符就绪,支持同时为多个客户端提供服务。注意虽然以往以阻塞IO模式编写的多进程或者多线程版本的TCP服务器也能支持等待多个文件描述符就绪,支持同时为多个客户端提供服务,但是以往的TCP服务器是多进程或者多线程模式,而现在的poll服务器是单进程模式,注意创建子进程或者新线程是对服务器有很大的消耗的,相比于以往的来几个客户端就创建几个子进程或者新线程为其提供服务的模式,现在的poll服务器的【不管来多少个客户端,我都只靠一个主进程为它们提供服务】的模式无疑是消耗更少的,所以效率就高了。
优点2:在上文讲解poll函数时说过,相比于select服务器只支持同时等待有限个文件描述符,poll服务器可以同时等待的文件描述符是没有上限的,这也是poll服务器的优点。
缺点:
在上文讲解poll函数时说过,相比于select服务器,poll服务器遍历数组的次数是减少了很多的,但即使减少了很多,因为还是需要在用户层通过编码频繁遍历_fds数组(比如需要在for循环中检查_fds[i].revents中是否有事件就绪),因为还是需要在内核层频繁遍历_fds数组(比如在poll函数内部也一定是有遍历_fds数组的逻辑的,否则poll函数如何检查每个struct pollfd变量的events成员并设置好revents成员的呢?),所以poll服务器依然存在不少的遍历数组的操作,进而导致服务器的效率变低,这就是poll服务器的缺点。
正是因为poll服务器具有这个频繁遍历数组的缺点,所以才会有epoll服务器的诞生,接下来咱们就来看看epoll服务器的工作模式。
当网卡中有数据时,OS如何知道网卡中有数据了呢?
OS是不会主动定期去检测网卡中是否有数据的,因为如果是定期去查,那么在没到期时,os就不会去查,此时如果网卡中已经有数据了,那么OS就没有及时读取网卡中的数据,注意网卡这种外设临时存储数据的能力是很差的,所以OS不及时读取网卡中的数据就可能导致数据丢失,所以定期去查这种方案不可行。
(结合下图思考)实际上OS是通过一种硬件中断的方式知道网卡中有数据的,举个例子,当网卡中有数据时,网卡设备会通过硬件电路(比如系统总线)给CPU的特定针脚发送强电脉冲信号(或者说发送中断号),假如网卡是向CPU的8号针脚发送强电脉冲信号,则后序因为CPU是从自己的8号针脚中接收到电信号,所以CPU就会识别到该中断号是8号并把数字8存进某个特定的寄存器,并且CPU因为识别到了有中断号的到来,这时CPU就会立刻停止执行正在执行的进程的代码,转而通过该进程的虚拟地址空间mm_struct的3到4G找到属于内核的地址空间,当CPU检测到该寄存器中的值是8号,就会在属于内核的地址空间中找到中断向量表(如下图所示,本质就是一个void(*handler[])()的函数指针数组,在OS中是有一个模块负责管理这个数组的,每个外设的驱动层在驱动安装成功时就会提供若干读写该外设的函数给OS,OS会把这些函数的地址存储在中断向量表中)并调用位于表中的8号下标上的read_netcard()函数(该函数是OS提前就注册好的,并且该函数一定是网卡驱动层提供的)把数据从网卡外设中读取到OS中。
当敲击键盘时,OS如何知道外设键盘有数据了呢?
(结合上图思考)同理,敲击键盘时,本质也会向通过硬件电路(比如系统总线)给CPU的特定针脚发送强电脉冲信号(或者说发送中断号),假如网卡是向CPU的4号针脚发送强电脉冲信号,则后序因为CPU是从自己的4号针脚中接收到电信号,所以CPU就会识别到该中断号是4号并把数字4存进某个特定的寄存器,并且CPU因为识别到了有中断号的到来,这时CPU就会立刻停止执行正在执行的进程的代码,转而通过该进程的虚拟地址空间mm_struct的3到4G找到属于内核的地址空间,当CPU检测到该寄存器中的值是4号,就会在属于内核的地址空间中找到中断向量表并调用位于表中的4号下标上的read_keyboard()函数把数据从键盘外设中读取到OS中。
epoll的整套工作流程及其原理如下:
问题:在上面我们说“调用epoll_ctl函数设置关心10号文件描述符的写事件就只是往该红黑树中插入了一个【fd成员为10、events成员为EPOLLIN】的struct RBTreeNode节点变量,做完这些epoll_ctl函数就结束了,当10号文件描述符的写事件真的就绪时,OS就会构建一个【fd成员为10、revents成员为EPOLLIN】的struct ReadyNode节点变量并将其插入到就绪队列的末尾。”那么问题来了,10号文件描述符的写事件真的就绪时,是谁让OS创建struct ReadyNode节点变量并将其插入到就绪队列的末尾中的呢?或者说凭什么当10号文件描述符的写事件真的就绪时,OS就要创建struct ReadyNode节点变量并将其插入到就绪队列的末尾中呢?
答案:注意在调用epoll_create函数时,根据该函数的逻辑,OS除了会在内核层创建一个红黑树RBTree和一个就绪队列ReadyQueue外,OS还会在网卡驱动层注册一个回调函数callback(int fd),做完这些epoll_create函数才会结束,(结合下图思考)后序当网卡外设中有数据导致网卡外设给CPU发送8号中断号让CPU执行中断向量表中8号下标上的read_netcard函数把数据从网卡外设读取到OS中后、数据经过协议栈的相关处理被放到对应进程中的对应套接字文件的接收缓冲区后,根据OS内部的编码逻辑就会调用该callback(int fd)回调函数(数据经过协议栈的处理被放到了哪个套接字文件的接收缓冲区,该callback函数在被调用时参数fd的值就是哪个套接字文件对应的文件描述符),回调函数的逻辑是,【先通过callback的参数fd找到红黑树上的对应节点,然后结合已经发生的事件查看是否和该节点中的events成员匹配,如果匹配,则就会构建struct ReadyNode节点变量并将其插入到就绪队列的末尾;如果不匹配,则就直接函数返回,什么也不做。】所以上面问题的答案也就出来了,当10号文件描述符的写事件真的就绪时,就是回调函数callback让OS创建struct ReadyNode节点变量并将其插入到就绪队列的末尾中的。
综上我们可以看到,因为在epoll模型中,只要网卡中有数据来了,则该数据属于哪个文件描述符,后序自动调用回调函数时就会查看哪个文件描述符是否有事件就绪,所以在内核层OS就不用像poll函数的工作模式一样频繁遍历数组以检查在所有文件描述符中有哪些文件描述符就绪了;所以在应用层也不需要用户编码通过for循环遍历数组以检查在所有文件描述符中有哪些文件描述符就绪了,这是在内核层中只要有文件描述符就绪,则它就会因为回调函数被调用而被放入就绪队列中,在应用层只需要直接调用epoll_wait函数从就绪队列中拿已经就绪的文件描述符即可,这样一来,因为epoll完全不需要遍历数组,所以epoll的效率当然高了。
说一下,因为上层应用层会调用epoll_wait函数从就绪队列中获取文件描述符,下层内核层也会调用回调函数callback将已经就绪的文件描述符放入就绪队列中,所以本质上epoll模型还是一个生产者消费者模型,就绪队列就是交易场所。注意内核层OS往就绪队列中放文件描述符时,应用层可能也正在调用epoll_wait从就绪队列中获取文件描述符(用一个不是很准确的说法来说,我们可以想象应用层是一个执行流,OS也是一个执行流),如果内核层OS往就绪队列中放ReadyNode节点对象时,其中的fd成员或者其他成员还没来得及赋值就被上层应用层调用epoll_wait函数拿走了,此时应用层拿到的数据就存在问题,所以就绪队列本质还是一个临界区,考虑到这一点,epoll的所有函数都是被设计成线程安全的了的,设计的原理也很简单,在下图的struct ReadyQueue中除了有struct ReadyNode* head指针成员指向队头节点对象,还有一个mutex_t lock互斥锁成员,不管是应用层还是OS,想要访问就绪队列都得先申请这把锁,这样一来就是线程安全的了。
结合上文讲解的epoll原理我们可知epoll_create函数就用于创建一个epoll模型,即【创建红黑树、创建就绪队列、并向底层驱动层注册回调函数】,如上图所示,该struct eventpoll就表示epoll模型,其中的rbr成员就是红黑树,rdllist成员就是就绪队列。
参数说明:
返回值说明:
注意当不再使用某个epoll模型时,必须调用close函数关闭epoll模型对应的文件描述符epfd。
结合上文讲解的epoll原理我们可知epoll_ctl函数本质就是在对epoll模型中的红黑树做增删改,举个例子,调用epoll_ctl设置关心10号文件描述符的写事件时,就是往该红黑树中插入了一个【fd成员为10、events成员为POLLIN】的struct RBTreeNode节点变量,做完这些epoll_ctl函数就结束了。在这个例子中,调用epoll_ctl函数时,需要传递给第一个参数epfd的值就是epoll模型对应的文件描述符,然后因为是要往红黑树中新增节点,所以需要传递给第二个参数op的值就是EPOLL_CTL_ADD,然后因为要关心的是10号文件描述符,所以需要传递给第三个参数fd的值就是10,最后因为要关心的是10号文件描述符的写事件,所以先创建一个struct epoll_event变量x,然后x.events = POLLIN并且x.data.fd =10,然后将x的地址传递给第四个参数event。
参数说明:
1、int epfd:操作哪个epoll模型就要给参数epfd传哪个epoll模型对应的文件描述符。
2、int op:表示具体的动作,取值只有三种,并且分别用三个宏来表示,如下:
3、int fd:需要监视的文件描述符。
4、struct epoll_event *event:用于表示需要监视该文件描述符上的哪些事件。struct epoll_event结构如下图所示,struct epoll_event结构中有两个成员,第一个成员events表示的是需要监视的事件,第二个成员data是一个联合体结构,一般只会使用该结构当中的fd,表示需要监听的文件描述符。
uint32_t events的常用取值如下:
返回值说明:
函数调用成功返回0,调用失败返回-1,同时错误码会被设置。
结合上文讲解的epoll原理我们可知epoll_wait函数本质就是从就绪队列的队头开始把就绪队列中的节点提取到上层应用层。
参数说明:
1、int epfd:操作哪个epoll模型就要给参数epfd传哪个epoll模型对应的文件描述符。
2、指针类型的struct epoll_event *events:实际上我们把它看作数组类型struct epoll_event events[]更贴切,这里笔者想表达的意思是events指针并不是指向一个struct epoll_event变量,而是指向一堆连续的struct epoll_event变量,这一堆连续的struct epoll_event变量、即struct epoll_event events[]数组需要程序员在应用层中创建,创建出来有什么用呢?我们说过【epoll_wait函数本质就是从就绪队列的队头开始把就绪队列中的节点提取到上层应用层】,而想要提取到上层应用层,上层应用层肯定是需要定义变量去接收就绪队列中的节点的,所以答案就出来了,创建struct epoll_event events[]数组就是为了接收就绪队列中的节点,再说明一下接收的流程:此时内核层的就绪队列中有若干个节点(每个节点对象都有int fd成员和uint32_t revents成员(或者说short revents成员)),在应用层中创建一堆连续的struct epoll_event变量、即创建struct epoll_event events[]数组后,在应用层的struct epoll_event events[]数组中也就有了若干个struct epoll_event变量(每个变量都有uint32_t events成员和epoll_data_t data成员(或者说int data成员)),因为就绪队列中的每个节点对象的2个成员的类型和events[]数组中每个对象的2个成员的类型完全一致,所以这样一来,OS就能将位于内核层的就绪队列中的第一个节点上的数据拷贝给数组的0号下标上的struct epoll_event变量,就能将就绪队列中的第二个节点上的数据拷贝给数组的1号下标上的struct epoll_event变量...后序以此类推(从这个工作模式也能看出来传参时不能给参数events传空指针,因为如果传空指针,则epoll_wait函数就没法将位于内核层的就绪队列中的文件描述符交给上层应用层,此时函数调用就会直接错误)。注意,前面说过程序员需要在应用层中创建一堆连续的struct epoll_event变量,那么这一堆是指多少呢?没有固定的标准,如果想要尽可能地一次性从就绪队列中获取更多个就绪的文件描述符,则可以设置大一点。OS将就绪队列中就绪的文件描述符拷贝到events数组中时,具体会拷贝多少个可以参考公式min(就绪队列中节点的数量,接下来会讲的参数maxevents的值,event数组中元素的个数),即这3个因素哪个最小,则就会拷贝多少。
3、int maxevents:通过maxevents告诉OS一次性最多可以从就绪队列中拷贝多少个节点到events数组上。当就绪队列中有足够的节点时,如果maxevents的值大于了events数组中元素的个数,则epoll_wait函数也不会调用失败,而是会在把events数组填满后停止拷贝,此时本次epoll_wait函数就调用结束了,就绪队列中剩下的节点则需要在下一次调用epoll_wait函数时再被获取到应用层。一般来说maxevents的值是和events数组中元素的个数相等的。注意传参时maxevents的值不能大于调用epoll_create函数创建epoll模型时传入的size值。
4、int timeout:epoll_wait函数中的tmeout参数和poll函数中的timeout参数是完全一样的,没有任何区别。我们让epoll系统调用在同时等待(或者说监视)多个文件描述符时,也可以选择不同的等待策略,比如可以选择以阻塞模式等待(体现在代码上就是让timeout等于-1)、可以选择以非阻塞模式等待(体现在代码上就是让timeout等于0,值为0表示等待0毫秒,等待0毫秒也就是没有进行等待,也就表示非阻塞了)、可以选择定时,在规定时间内则以阻塞模式等待,超时就直接返回(比如想定时为5秒,因为epoll这里的单位是毫秒,所以体现在代码上就是让timeout等于5000)。
返回值说明:
epoll_wait调用失败时,错误码可能被设置为:
需要注意的是,当前epoll服务器只是一个缩减版,是为了演示epoll_create、epoll_ctl、epoll_wait的用法而编写的,所以当前epoll服务器只编写read读取的逻辑,写入和异常不做处理,这是为了图方便,但不必担心,我们会在编写完整版的epoll服务器时演示如何编写写入和异常的逻辑。
关于epollServer的整体代码,我们直接将上文selectServer的整体代码拿过来,然后在其基础上稍微进行修改即可得到,大体框架都是不需要改变的,比如说:
结合上面的理论,epollServer的整体代码如下。
#include
using namespace std;
#include //提供close
#include
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include//系统库,提供socket编程需要的相关的接口
#include//系统库,提供socket编程需要的相关的接口
#include//提供sockaddr_in结构体
#include//提供sockaddr_in结构体
//需要注意的是,当前epoll服务器只是一个缩减版,是为了演示epoll_create、epoll_ctl、epoll_wait的用法而编写的,所以当前epoll服务器只编写read读取的逻辑,写入和异常不做处理,
//这是为了图方便,但不必担心,我们会在编写完整版的epoll服务器时演示如何编写写入和异常的逻辑。
class epollServer
{
public:
epollServer(uint16_t port = 8080, int arry_size = 20, int timeout = 5000)
:_arry(new epoll_event[arry_size])
,_arry_size(arry_size)
,_timeout(timeout)
{
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
//让服务端进程能在TIME_WAIT状态下重复绑定同一个端口号
int opt = 1;
setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
_epfd = epoll_create(100);
if(_epfd < 0)
exit(1);
sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = inet_addr("0.0.0.0");
socklen_t len = sizeof(local);
if (bind(_listen_sock, (sockaddr*)&local, len) < 0)
exit(2);
if (listen(_listen_sock, 15) < 0)
exit(3);
epoll_event ee;
ee.events = EPOLLIN;
ee.data.fd = _listen_sock;
epoll_ctl(_epfd, EPOLL_CTL_ADD, _listen_sock, &ee);
}
~epollServer()
{
if (_listen_sock > 0)
close(_listen_sock);
if (_epfd > 0)
close(_epfd);
delete[] _arry;
}
void start()
{
while(1)
{
int x = epoll_wait(_epfd, _arry, _arry_size, 5000);
switch(x)
{
case 0:
cout<<"time out超时,目前没有文件描述符就绪,可以在这个分支里趁机处理一会其他的业务"<
将下图1的代码编译并运行后,直接如下图2所示打开多个终端,并在每个终端中通过telnet命令连接服务端进程,如下图2的红框处所示,不断有telnet和服务端进程建立连接成功,并且如下图2的蓝框处所示,可以发现多个telnet客户端进程是可以同时给单进程模式的epollServer服务端进程发数据的,这就证明了咱们的代码是没有问题的。
epoll有两种工作方式,分别是水平触发工作模式和边缘触发工作模式。但注意select和poll只有一种工作方式,他俩都只能是水平触发工作模式。
水平触发(LT,Level Triggered)
epoll、select、poll默认状态下都就是LT工作模式,在该种模式下,只要有文件描述符的事件就绪,则epoll、select、poll就会一直通知用户有文件描述符的事件就绪,说具体一点就是:
边缘触发(ET,Edge Triggered)
epoll可以将工作模式改成ET模式(select和poll则只能是LT),在ET模式下,只有文件描述符的就绪事件数量从无到有或者从少到多,即只有发生变化的时候,epoll才会通知用户有文件描述符的事件就绪,说具体一点就是:
问题:如何让epoll的工作模式是ET模式呢?又如何让epoll的工作模式是LT模式呢?
答案:在上文讲解epoll_ctl函数时说过如何设置ET模式,比如说如果我们想让epoll模型关心某文件描述符的读事件就绪并且想让epoll模型以ET模式进行工作,则通过【先让events = EPOLLIN | EPOLLET,然后调用epoll_ctl函数】即可达成目的;epoll默认的工作模式就是LT模式,即如果给events赋值时不按位或上EPOLLET,则调用epoll_ctl后epoll模型默认就是LT模式。如果epoll模型的工作模式已经是ET模式了,如果此时想把epoll的工作模式从ET改回LT,则通过【给events赋值时不按位或上EPOLLET,然后把epoll_ctl的参数op改成EPOLL_CTL_MOD,然后调用epoll_ctl函数】即可达成目的。
走到这里我们就对水平触发工作模式和边缘触发工作模式有了一个基础的认识,接下来我们对其补充一些细节。
由于在LT工作模式下,只要有文件描述符的事件就绪,epoll(因为epoll是系统调用,所以本质是OS在通知用户)就会一直通知用户(即在每次循环中调用epoll_wait函数都能立刻调用成功并返回),所以在epoll检测到有文件描述符的读事件就绪时,可以不立即进行处理,或者只处理一部分,这是因为只要该文件描述符的接收缓冲区中的数据没有被read处理完,则在下一次循环时epoll_wait还会通知用户该文件描述符的读事件就绪。
由于在ET工作模式下,只有文件描述符的就绪事件数量从无到有或者从少到多,即只有发生变化的时候,epoll才会通知用户,所以当epoll检测到有文件描述符的读事件就绪时,必须立即进行处理,而且必须全部处理完毕(即必须把该文件描述符的接收缓冲区中的所有数据全部read到应用层),这是因为此后对端的发送缓冲区可能再也不会给该文件描述符的接收缓冲区发送数据,导致该文件描述符的接收缓冲区再也不会有新的数据到来,导致epoll再也不会通知用户进行事件处理(即导致epoll_wait函数不会调用成功、不会成功地从就绪队列中获取到该文件描述符),此时该文件描述符的接收缓冲区中没有处理完的数据就相当于丢失了。
综上可以发现,epoll模型在处于ET模式下时,是会倒逼程序员在设计编码时、在检测到有文件描述符的读事件就绪时立刻把读事件处理完毕的,所以epoll模型处于ET模式时一般(并不是一定,下文会说明原因)是比epoll模型处于LT模式时更高效的,举个例子,如下。
问题:那么问题来了,ET模式下,epoll模型检测到有文件描述符的读事件就绪时,如何调用read才能把该文件描述符的接收缓冲区中的数据读取完呢?或者说问题是在ET模式下如何进行读写?
答案:因为在ET工作模式下,只有文件描述符的就绪事件从无到有或者从少到多,即发生变化的时候epoll_wait才会通知用户、才会调用成功,这就倒逼用户当文件描述符的读事件就绪时必须一次性把该文件描述符的接收缓冲区中的数据全部读取完毕,否则可能再也没有机会进行读取了;倒逼用户当文件描述符的写事件就绪时必须一次性把该文件描述符的发送缓冲区写满,否则可能再也没有机会进行写入了。因此,读数据时必须循环调用read函数进行读取,写数据时必须循环调用write函数进行写入,举个例子,如下。
再次强调:
注意,以上内容对于监听套接字listen_sock是同样有效的,所以epoll模型以ET模式进行工作时,是需要将listen_sock设置成非阻塞模式,并且当listen_sock的读事件就绪时,是需要循环调用accept函数保证一次性将listen_sock的读事件处理完毕的。
我们以【让当前epoll服务器以ET模式进行工作、并且提供的服务是网络计算器】为例,讲解epoll服务器的编写思路,如下。
1、根据上文讲解epoll_wait函数时的理论,我们可知需要在epollServer类成员中增加struct epoll_event* _arry成员、增加int _arry_size、增加int _timeout成员,然后还需要在构造函数中初始化它们,比如先通过构造函数的参数(或者说用户传递的参数)初始化_arry_size成员、timeout成员,然后new出_arry_size个struct epoll_event变量并将这块空间交给_arry指针管理。
2、然后要知道,我们需要为套接字封装一个connection类,类成员中就包括int sock,string _inbuffer、string _outbuffer,往后在服务端进程中我们就不再使用原生的套接字,而是直接使用connection对象。为什么呢?原因如下。
3、因为在上面说过,往后在服务端进程中我们不再使用原生的套接字,而是直接使用connection对象,以通过connection对象找到套接字以及分配给该套接字的_inbuffer和_outbuffer,所以在调用socket函数创建监听套接字后,我们要立刻为该套接字new一个connection对象并完成其的初始化,所以在调用accept函数获取服务套接字后,我们也要立刻为该套接字new一个connection对象并完成其的初始化。
问题来了,可以发现在到处new出connection对象后,如果我们不将所有new出的connection对象的地址保存起来,则往后epoll_wait函数检测到有若干文件描述符的事件就绪并将就绪的文件描述符放到epoll_wait函数的参数epoll_event *events中后,当前进程处理该events指针或者说events数组中就绪的文件描述符的读写事件时,就无法通过events数组中的文件描述符找到该文件描述符对应的connection对象,进而无法找到_inbuffer和_outbuffer,而如果找不到_inbuffer和_outbuffer,读写事件自然也处理不了,所以综上所述,我们是需要通过一个容器将所有new出的connection对象的地址保存起来的。
容器可以任意选择,比如可以选vector
4、在上面说过【对于监听套接字的读事件不能像缩减版的epoll服务器只是简单地调用一次accept获取一个连接对象,而是需要循环调用accept将连接队列中的所有连接对象全部获取;对于服务套接字的读事件不能像缩减版的epoll服务器只是简单地调用一次read并把读取到的数据打印出来,而是需要循环调用read将目标套接字的接收缓冲区中的所有数据全部读到应用层缓冲区中(即读到该套接字对应的connection对象的_inbuffer缓冲区中)】,而根据上文讲解的epoll的两种工作方式LT、ET的内容可知,想要循环调用read将接收缓冲区中的所有数据全部读到应用层缓冲区,想要循环调用accept将连接队列中的所有连接对象全部获取到,一定是要将对应的套接字设置成非阻塞模式的, 至于如何设置,请参考<<高级IO的相关知识点>>一文中的非阻塞IO部分。
并且注意,循环调用read将目标套接字的接收缓冲区中的所有数据全部读到应用层缓冲区中(即读到该套接字对应的connection对象的_inbuffer缓冲区中)后,是需要判断是否读取到一个完整的业务请求数据以决定是否可以开始进行业务处理的,如何判断是否是完整的业务请求数据呢?这就需要我们自己定制协议了。
5、最后要说的是,最初创建监听套接字后,只需要设置让epoll模型关心其的读事件(原因不必多说),不需要关心写事件(原因不必多说),也不需要关心异常事件(这是因为如果accept的文件描述符出异常后,accept函数自然会调用失败并返回-1,调用失败我们直接就可以调用我们编写的用于进行异常处理的Excepter函数);
后序每accept到一个服务套接字后,我们一开始只需要设置让epoll模型关心其的读事件(原因不必多说),不需要关心写事件(根据上文可知,当服务器调用read收到客户端的业务请求数据后,进行处理得到处理结果后,是会将处理结果放到服务套接字对应的connection对象的_outbuffer成员中的,所以后序服务器调用write把处理结果数据发回给客户端时就是在把_outbuffer中的数据发给客户端,问题来了,刚调用accept获取到一个服务套接字后,我们是不能确定该服务套接字什么时候读事件就绪的,如果服务套接字的读事件一直不就绪,即客户端一直不发送业务请求数据过来,那服务器中就不可能去处理业务请求数据并将处理结果放到_outbuffer中,_outbuffer中就一直为空,所以如果我们贸然让epoll关心该服务套接字的写事件,那么只要发送缓冲区有空间,则服务器就需要调用write将_outbuffer中的数据发到发送缓冲区,而我们又知道_outbuffer中是没有数据的,这样一来,就相当于服务器会频繁地、无意义地调用write函数,浪费CPU资源。注意这时调用write函数并不会失败,只是没有意义,且会浪费CPU资源。这就是暂时不需要让epoll关心写事件的原因了),也不需要关心异常事件(这是因为后序调用read和write时,如果文件描述符出异常,则read和write函数自然会调用失败并返回-1,调用失败我们直接就可以调用我们编写的用于进行异常处理的Excepter函数);
那什么时候让epoll模型关心服务套接字的写事件呢?根据上一段我们可以反推出答案,即在服务套接字对应的connection对象的_outbuffer缓冲区中有数据时再让epoll模型关心服务套接字的写事件(更详细地说就是,在服务器read读取到完整的业务请求数据后、处理完业务请求数据得到处理结果数据后,将处理结果数据放到_outbuffer中后,再让epoll模型关心服务套接字的写事件),这样一来,往后epoll模型检测到该服务套接字的写事件就绪(即检测到该服务套接字的发送缓冲区中有剩余空间)时,调用write发送_outbuffer缓冲区中的数据时,write才会真正起到作用,write才会有意义。同理,从这里我们还可以推导出一个结论,调用write把_outbuffer缓冲区中的数据发完后,是需要调用epoll_ctl让epoll模型取消对该服务套接字的写事件的关心的,其原因和上一段中暂时不需要关心写事件的原因同理,即也是因为让epoll模型继续关心该服务套接字的写事件没有意义,只会导致服务器频繁地、无意义地调用write函数,浪费CPU资源。
6、剩余思路在下面代码的注释中,请参考代码。
结合上面的思路,epollServer的完整代码如下。(protocol.h的代码在epollServer的下面)
#include
using namespace std;
#include //提供close
#include
#include
#include
#include
#include"protocol.h"
#include
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include//系统库,提供socket编程需要的相关的接口
#include//系统库,提供socket编程需要的相关的接口
#include//提供sockaddr_in结构体
#include//提供sockaddr_in结构体
struct connection
{
public:
connection(int sock)
:_sock(sock)
{}
int _sock;
string _inbuffer;
string _outbuffer;
};
class epollServer
{
public:
epollServer(uint16_t port = 8080, int arry_size = 20, int timeout = 5000)
:_arry(new epoll_event[arry_size])
,_arry_size(arry_size)
,_timeout(timeout)
{
//创建监听套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
//让服务端进程能在TIME_WAIT状态下重复绑定同一个端口号
int opt = 1;
setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
//创建epoll模型
_epfd = epoll_create(100);
if(_epfd < 0)
exit(1);
//将监听套接字、ip、port和当前进程绑定
sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = inet_addr("0.0.0.0");
socklen_t len = sizeof(local);
if (bind(_listen_sock, (sockaddr*)&local, len) < 0)
exit(2);
//开始监听
if (listen(_listen_sock, 15) < 0)
exit(3);
//先调用fcntl将监听套接字设置成非阻塞模式,然后再将该套接字设置进epoll模型和哈希表中
SetNonBlock(_listen_sock);
epoll_event ee;
ee.data.fd = _listen_sock;
ee.events = EPOLLIN | EPOLLET;
epoll_ctl(_epfd, EPOLL_CTL_ADD, _listen_sock, &ee);
connection* p = new connection(_listen_sock);
_table[_listen_sock] = p;
}
void start()
{
while(1)
{
int x = epoll_wait(_epfd, _arry, _arry_size, _timeout);
if(x == 0)
cout<<"time out超时,目前没有文件描述符就绪,可以在这个分支里趁机处理一会其他的业务"<_sock, temp, sizeof(temp));
if(size == 0)
{
cout<<"client quit,me too"<_inbuffer += temp;
}
//如果flag等于1,则说明在上面调用read发生了异常,并调用了Excepter函数将出异常的文件描述符从epoll模型和哈希表中删除,将出异常的文件描述符close掉以及delete了
//该文件描述符对应的connection对象。既然文件描述符都异常并且被清理掉了,此时执行后序的业务逻辑就没有意义了,所以此时就需要直接return退出当前函数,否则向下走会
//出现各种各样的错误,比如解引用野指针c、比如调用epoll_ctl设置不存在于epoll模型中的文件描述符。
if(flag == 1)
return;
//走到这里就完成了将套接字的接收缓冲区中的所有数据全部读取到应用层的_inbuffer缓冲区,接下来对收到的数据进行业务处理
while(1)
{
printf("inbuffer:%s\n", c->_inbuffer.c_str());
string msg = Decode(c->_inbuffer);
cout<<"msg:"<_outbuffer += (Encode(resp.Serialize()));
}
}
//如果inbuffer中有完整的业务请求数据,则走到这里就处理完了所有的数据,并将处理结果放到了_outbuffer,此时因为_outbuffer中有数据要回复给客户端了,所以此时需要让
//epoll关心该文件描述符的写事件,这是因为当_outbuffer没有需要回复给客户端的数据时,我们让epoll关心该文件描述符的写事件没有意义,比如就算该文件描述符的写事件就
//绪,该文件描述符在start函数中调用Writer函数也没有数据要发给客户端,所以我们需要在确定_outbuffer中有数据要回复给客户端了,再让epoll关心该文件描述符的写事件
if(c->_outbuffer.empty() == false)
{
printf("outbuffer:%s\n", c->_outbuffer.c_str());
epoll_event ee;
ee.data.fd = c->_sock;
ee.events = EPOLLOUT | EPOLLIN | EPOLLET;
epoll_ctl(_epfd, EPOLL_CTL_MOD, c->_sock, &ee);
}
}
//Writer的作用就是把_outbuffer中的所有数据全部发给客户端即可
void Writer(connection* c)
{
int flag = 0;
while(1)
{
ssize_t size = write(c->_sock, c->_outbuffer.c_str(), c->_outbuffer.size());
if(size < 0)
{
//因为所有服务套接字在上面已经被设置成了非阻塞模式,所以当接收缓冲区中没有空间时,write函数不会阻塞,而是会返回-1并将错误码errno设置成EAGAIN(又称EWOULDBLOCK)
//,这时就应该break结束循环调用write。
if(errno == EAGAIN || errno == EWOULDBLOCK)
break;
//如果write返回-1并将错误码设置成了EINTR,则说明之前write函数被信号中断了,此时需要continue重新调用write。注意虽然这个概率比较低,但我们还是补齐这个逻辑。
else if(errno == EINTR)
continue;
else
{
cout<<"write error"<_outbuffer.erase(0, size);
if(c->_outbuffer.empty() == true)
break;
}
}
//如果flag等于1,则说明在上面调用write发生了异常,并调用了Excepter函数将出异常的文件描述符从epoll模型和哈希表中删除,将出异常的文件描述符close掉以及delete了
//该文件描述符对应的connection对象。既然文件描述符都异常并且被清理掉了,此时执行后序的业务逻辑就没有意义了,所以此时就需要直接return退出当前函数,否则向下走会
//出现各种各样的错误,比如解引用野指针c、比如调用epoll_ctl设置不存在于epoll模型中的文件描述符。
if(flag == 1)
return;
//走到这里,_outbuffer中的数据不一定全被发送完了,有可能是因为接收缓冲区中没有空间导致上面的循环直接break了,如果没被发完,则不关闭epoll对该套接字的写事件的关心,如
//果发完了,则关闭epoll对该套接字的写事件的关心。
if(c->_outbuffer.empty() == true)
{
epoll_event ee;
ee.data.fd = c->_sock;
ee.events = EPOLLIN | EPOLLET;
epoll_ctl(_epfd, EPOLL_CTL_MOD, c->_sock, &ee);
}
}
//异常处理就是将异常的套接字从epoll模型中删除、从哈希表中删除、然后close该套接字,最后delete掉为该套接字创建的connection结构体对象
void Excepter(connection* c)
{
//在close关闭套接字前,要先将该套接字从epoll模型和哈希表table中删除
epoll_ctl(_epfd, EPOLL_CTL_DEL, c->_sock, nullptr);
_table.erase(c->_sock);
close(c->_sock);
delete c;
}
~epollServer()
{
if(_listen_sock > 0)
close(_listen_sock);
if(_epfd > 0)
close(_epfd);
delete _arry;
}
bool SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if(fl < 0)
{
cout<<"fcntl error"< _table;
};
在上文的思路中说过我们是要定制协议的,这里我们就不重新写了,而是直接把<<对协议的基本认识>>一文中的协议protocol.h的整体代码拿过来,然后只做一处修改即可,即把#define SEP “\r\n”改成#define SEP “@”,如果对协议的编写过程感兴趣,请参考该篇文章。
#pragma once
#include
using namespace std;
#include
#define SPACE " " //注意#define是不需要分号 ; 结尾的,如果加了分号,则分号也会被算进宏替换的内容
#define SPACE_LEN strlen(SPACE)
#define SEP "@"
#define SEP_LEN strlen(SEP)
class Request//客户端未来会将请求类Request对象经过序列化后发给服务端
{
public:
Request(){}
Request(int x, int y, char op):_x(x),_y(y),_op(op){}
~Request(){}
string Serialize()//未来哪个Request对象调用这个函数,就通过该Request对象的成员_x、_y、_op生成一个string对象
{
string s;
s += to_string(_x);
s += SPACE;
s += _op;
s += SPACE;
s += to_string(_y);
return s;
}
//10 + 1234
bool Deserialize(const string& message)//未来通过message字符串给调用这个函数的Request对象的成员_x、_y、_op赋值
{
size_t pos1 = message.find(SPACE);
if(pos1 == string::npos)
return false;
else
{
_x = atoi(message.substr(0, pos1).c_str());//atoi只能将C语言字符串转换成整形
_op = message[pos1+SPACE_LEN];
size_t pos2 = message.rfind(SPACE);
_y = atoi(message.substr(pos2+SPACE_LEN).c_str());
return true;
}
}
public:
int _x;//约定_x是左操作数
int _y;//约定_x是右操作数
char _op;//操作符,可以是 + - * % /
};
class Response//服务端未来会将应答类Response对象经过序列化后发送给客户端
{
public:
Response(){}
Response(int result, int status):_result(result),_status(status){}
~Response(){}
string Serialize()//未来哪个Response对象调用这个函数,就通过该Response对象的成员_result、_status生成一个string对象
{
string s;
s += to_string(_result);
s += SPACE;
s += to_string(_status);
return s;
}
//30 0
bool Deserialize(const string& message)//未来通过message字符串给调用这个函数的Response对象的成员_result、_status赋值
{
size_t pos1 = message.find(SPACE);
if(pos1 == string::npos)
return false;
else
{
_result = atoi(message.substr(0, pos1).c_str());//atoi只能将C语言字符串转换成整形
size_t pos2 = message.rfind(SPACE);
_status = atoi(message.substr(pos2+SPACE_LEN).c_str());
return true;
}
}
public:
int _result;//计算结果
int _status;//计算结果的状态,如果是0表示计算结果有效;如果是非0,则表示结果无效,比如可以用1、2、3..等等表示无效的原因,比如可以用1表示发生了除0错误导致结果无效,可以用2表示用户传来的request对象中op并不是合法的操作符,而是@或者#等等胡乱的字符。
};
//length\r\n1234567\r\n
string Decode(string& buffer)
{
int pos = buffer.find(SEP);
if(pos == string::npos)
{
return"";
}
else
{
int length = atoi(buffer.substr(0, pos).c_str());
int content_len = buffer.size() - pos - 2*SEP_LEN;
if(content_len >= length)
{
//走到这里才能保证buffer中有一个完整的报文
string s(buffer.substr(pos+SEP_LEN, length));
buffer.erase(0, length+2*SEP_LEN+pos);
return s;
}
else
{
return "";
}
}
}
string Encode(const string& buffer)
{
string s;
s += to_string(buffer.size());
s += SEP;
s += buffer;
s += SEP;
return s;
}
将下图1的代码编译并运行后,直接如下图2所示打开多个终端,并在每个终端中通过telnet命令连接服务端进程,如下图2的红框处所示,不断有telnet和服务端进程建立连接成功,并且如下图2的蓝框处所示,可以发现多个telnet客户端进程是可以同时给单进程模式的epollServer服务端进程发业务请求数据的,这就说明咱们的代码没有问题。说一下,因为没有编写客户端,是靠telnet充当客户端,所以下图2中客户端发送业务请求数据时需要用户手搓若干个完整的业务请求数据,所以下图2中客户端收到服务端发送的处理结果数据时,数据还是以协议中定制的格式呈现给用户的,还是没有进行过反序列化的。