多路转接IO——select服务器、poll服务器、epoll服务器

目录

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函数

如上图所示,select是系统提供的一个多路转接接口。在<<高级IO的相关知识点>>一文中说过,IO = 等待 + 拷贝数据,多路转接IO可以支持一次等待多个文件描述符就绪,并且等待的时间还是重叠的,所以select作为多路转接IO中的一种函数,select系统调用可以让我们的程序同时等待(或者说监视)多个文件描述符的上的事件是否就绪。select只负责等待(或者说监视),当监视的多个文件描述符中有一个或多个文件描述符的事件就绪时,select就会成功返回并将对应文件描述符的对应就绪事件告知调用者。

头文件说明:

  • 上图红框中的头文件是新版,蓝框中的头文件是旧版,我们可以直接使用新版的,因为更方便,只包一个<sys/select.h>文件即可。

参数说明:

  • nfds:select系统调用可以让我们的程序同时等待(或者说监视)多个文件描述符的上的事件是否就绪,其中nfds-1就是这多个文件描述符中的最大值,比如说,如果想等待5号和8号文件描述符,因为在这多个文件描述符中最大值是8,又因为nfds-1也表示这多个文件描述符中的最大值,所以8 = nfds -1,所以如果想等待5号和8号文件描述符,就需要将nfds设置成9。其实也很好理解,文件描述符本质就是PCB中的文件描述符表(或者说数组)的下标,select底层在等待(或者说监视)多个文件描述符时肯定是要遍历数组的,nfds就表示遍历的最大范围,设置最大范围是为了避免select遍历整个数组浪费CPU资源。
  • timeout:如下图红框处所示,timeval结构体中有两个成员,time_t类型的成员表示秒,suseconds_t类型的成员表示微秒,假如有struct timeval x,x.tv_sec=2,x.tv_usec=1,则x整体就表示2秒+1微秒,即2.000001秒。我们让select系统调用在同时等待(或者说监视)多个文件描述符时,也可以选择不同的等待策略,比如可以选择以阻塞模式等待(体现在代码上就是让timeout指针指向nullptr)、可以选择以非阻塞模式等待(体现在代码上就是让timeout指针指向一个值为{0,0}的timeval结构体变量,值为{0,0}表示等待0秒+0微妙=0秒,等待0秒也就是没有进行等待,也就表示非阻塞了)、可以选择定时,在规定时间内则以阻塞模式等待,超时就直接返回(比如想定时为5秒,则体现在代码上就是让timeout指针指向一个值为{5,0}的timeval结构体变量,5秒+0微妙=5秒)。如果选择定时5秒,但只过了2秒就有文件描述符就绪了进而导致select函数返回,则此时timeout指针指向的timeval结构体变量的值就为{3,0},表示还剩余3秒+0微妙=3秒;如果超时了,则此时timeout指针指向的timeval结构体变量的值就为{0,0},表示还剩余0秒+0微妙=0秒。所以可以看到,timeout是一个输入输出型参数,调用时由用户设置select的等待时间,返回时表示timeout的剩余时间。使用timeout时要注意,因为timeout是一个输入输出型参数,所以如果一开始设置定时5秒,但后来select函数返回时timeout只剩下3秒或者0秒,这时如果想要timeout重新变成5秒,则需要对timeout重新进行设置。

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。

  • 图1如下。
  • 图2如下。

如果把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进行了深刻地理解,这里我们再对它们做一个简单的总结,如下:

  • readfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已经就绪。
  • writefds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已经就绪。
  • exceptfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已经就绪。

返回值说明:

  • select系统调用可以让我们的程序同时等待(或者说监视)多个文件描述符的上的事件就绪,在等待的这多个文件描述符中,如果只有一个就绪,则select函数就会调用成功并返回1,如果同时有多个文件描述符就绪,则有几个就绪,返回值就是几。
  • 如果返回值是0,则说明select函数是以定时模式调用,并且超时了,这时返回值就是0。
  • 如果返回值是-1,则说明select函数发生了某些异常,调用失败,一般发生这种情况都是传给select函数的参数非法造成的。

select服务器

selectServer的不完善版本的基础框架(构造函数、析构函数和类成员)

如下代码所示,selectServer的基础框架就是一个正常的TCP服务端的基础框架,写过很多次了,就不再赘述。

需要注意的是,select服务器我们只编写read读取的逻辑,写入和异常不做处理,这是为了图方便,但不必担心,因为select服务器并不常用,最常用的是epoll服务器,我们会在编写epoll服务器时将代码补充完整。

#include<iostream>
using namespace std;
#include <sys/select.h>
#include <string.h>
#include <unistd.h>//提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供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,或者说如何看待监听套接字listen_sock呢?

  • 实际上监听套接字对应的文件描述符listen_sock和其他文件描述符的工作模式都是一样的。举个例子,对于其他普通文件的文件描述符,当内核缓冲区中没有数据时上层调用read就会阻塞,当内核缓冲区中有数据时上层调用read才能读取到数据;而对于监听套接字对应的文件描述符,虽然上层不是从该文件描述符对应的struct file中的内核缓冲区中读取数据,但上层是需要调用accept函数从该文件描述符对应的struct file中的连接队列里获取连接对象的,当连接队列中没有连接对象时上层调用accept就会阻塞,当连接队列中有连接对象时上层调用accept才能获取到连接对象。所以综上可以发现,进程调用accept函数获取新连接的过程本质也是IO的过程,在这一点上监听套接字listen_sock和其他文件描述符都是一样的。
  • 然后注意,因为当前所编写的select服务器旨在让selectServer服务端进程能同时等待多个文件描述符,所以对于selectServer的start函数,是不能像普通的TcpServer的start函数一样直接调用accept函数的,这是因为如果此时并没有客户端和selectServer服务端3次握手建立连接成功,那么监听套接字listen_sock的连接队列里就没有连接对象,那么调用accept函数就会导致selectServer进程在accept函数处陷入阻塞,导致selectServer进程只能等待(或者说监视)监听套接字listen_sock的读事件就绪,而无法做任何其他事情,其中就包括等待(或者说监视)其他的文件描述符是否就绪(比如这时如果有某个文件描述符A对应的struct file中的内核缓冲区中有数据可以让上层拿走,但因为selectServer进程在accept函数处阻塞了,没有空闲等待或者说监视该文件描述符A,所以上层也就不会知道A的内核缓冲区就绪了),这就违背了select服务器的初衷了,所以对于selectServer的start函数而言不能像普通的TcpServer的start函数一样直接调用accept函数。
  • 额外说一下,对于上一段的内容可能有人会说,【之所以不能让selectServer进程直接调用accept函数以避免该进程陷入阻塞进而避免该进程无法等待或者说监视其他的文件描述符是否就绪,就是因为当下的编程模式是单进程的,我们为什么不创建子进程或者新线程呢?比如创建后,我们就可以让主进程直接调用accept函数,这样即使主进程陷入阻塞了,因为有其他子进程或者新线程在调用read函数等待其他的文件描述符就绪,所以也能让selectServer服务端进程能同时等待多个文件描述符?】,这里笔者想说的是,如果selectServer服务端进程也按照多进程或者多线程的模式编写,那selectServer服务端进程和咱们以前编写的【多进程版版本、多线程版本的TcpServer服务端进程】就没有任何区别了,体现不出来selectServer的独特之处了。独特在哪呢?答案:selectServer是可以在不创建任何子进程或者新线程的情况下(即在单进程的情况下)同时等待多个文件描述符就绪的,并且还能同时为多个客户端提供服务,换言之,TcpServer服务器通过多个进程才能做到的事现在让selectServer服务器去做,selectServer服务器只需要一个进程即可做到,这就是selectServer的优势和独特之处。

既然如此,那如何在selectServer的start函数中调用accept呢?

因为select系统调用可以让我们的进程同时等待(或者说监视)多个文件描述符的上的事件是否就绪,所以我们可以把监听套接字listen_sock也添加进select函数中,让select函数帮当前进程等待(或者说监视)监听套接字对应的struct file中的连接队列就绪,在连接队列没有就绪时,因为select函数可以被设置成非阻塞模式或者定时模式,所以这时当前进程就可以趁机处理一些其他的业务,根据上面的理论,我们可以暂时编写出如下代码(注意该代码只是半成品)

#include<iostream>
using namespace std;
#include <sys/select.h>
#include <string.h>
#include <unistd.h>//提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供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,没有文件描述符就绪,可以在这个分支里趁机处理一会其他的业务"<<endl;
                break;
            case -1:
                cout<<"select error"<<endl;
                break;
            default:
                cout<<"select success,有"<<x<<"个文件描述符就绪"<<endl;
                if (FD_ISSET(_listen_sock, &fs) > 0)//如果是监听套接字的连接队列就绪,则accept获取连接对象(或者说获取服务套接字)
                {
                    cout<<"get a new link"<<endl;
                    sockaddr_in peer;
                    socklen_t len = sizeof(peer);
                    int service_sock = accept(_listen_sock, (sockaddr*)&peer, &len);
                    if (service_sock < 0)
                    {
                        cout<<"accept error"<<endl;
                        //注意该break是终止switch的,而不是终止while循环的
                        break;
                    }
                    else
                    {
                        //accept获取一个服务套接字(或者说连接对象)后,可以直接对该服务套接字进行read/recv吗?
                        /*
                            未完待续
                                    */
                    }
                }
                //如果是服务套接字的内核缓冲区就绪,则直接进行read/recv,此时调用read/recv是不会被阻塞的。                
                /*
                    未完待续
                            */
                break;
            }
        }
    }

private:
    int _listen_sock;
    uint16_t _port;
};

在上面代码的注释中提出了一个问题,我们需要先回答这个问题,然后根据问题的答案的思路才能继续完善上面的半成品代码,问题为:accept获取一个服务套接字(或者说连接对象)后,可以直接对该服务套接字进行read/recv吗?

答案是不行,因为服务套接字的内核缓冲区可能没有数据,这时调用read/recv就会导致当前进程陷入阻塞,这样一来当前进程就无法做任何其他事情了,其中就包括等待(或者说监视)其他的文件描述符是否就绪,这就违背了select服务器的初衷,所以我们不能冒然调用read/recv,而要在明确地知道该服务套接字的内核缓冲区中有数据时才能调用read/recv,这样一来才能避免read/recv导致当前进程陷入阻塞。那么谁才能让当前进程明确地知道该服务套接字的内核缓冲区中有数据呢?也只能靠select函数,我们可以在accept获取一个服务套接字后就立刻将该套接字设置进select函数中(即立刻让select函数关心该套接字的读事件是否就绪),然后下次循环时再通过select函数等待该服务套接字的内核缓冲区就绪。于是现在就有3个问题需要解决:

  • 1、进入下一次循环时如何让当前进程知道该服务套接字的值是多少以将该套接字设置进select函数呢?
  • 2、在不断循环的过程中,随着accept获取的服务套接字越来越多,我们就需要将越来越多的服务套接字文件描述符设置进select函数中,注意select函数的第一个参数nfds是等于用户往select函数中设置的文件描述符的最大值加一的,当服务套接字越来越多时,多个文件描述符中的最大值也是会越来越大的,这时传给select函数的参数nfds的值就也应该越来越大,换言之,传给nfds参数的值不能像上面的代码一样是个固定的值listen_sock+1,而应该动态计算出传给nfds的值(再举个例子证明这一点,比如如果最初需要让select函数监视的文件描述符是1、3、4、7,后来如果不需要监视7了,这时就需要将nfds的值设置成4+1=5),那么如何让当前进程知道在本次循环中需要让select函数监视的多个文件描述符中的最大值是多少从而计算出传给参数nfds的值呢?
  • 3、在上文讲解select函数时说过,和select函数的struct timeval*类型的参数timeout一样,因为select函数剩下的3个fd_set*指针类型的参数readfds、writefds、exceptfds都是输入输出型参数,所以在select函数调用完毕时这3个指针指向的fd_set位图变量可能已经被修改了,所以在进入下一次循环前,如果想要select函数继续关心某些文件描述符的读、写、异常事件是否就绪,是需要重新对这3个指针指向的fd_set位图变量进行设置的,注意因为在上文中说过对于select服务器我们只编写read读取的逻辑,写入和异常不做处理(这是为了图方便,但不必担心,因为select服务器并不常用,最常用的是epoll服务器,我们会在编写epoll服务器时将代码补充完整),所以这里只需要重新对readfds指针指向的fd_set位图变量进行设置,那么如何让当前进程知道在本次循环中哪些文件描述符需要继续被设置进select函数的readfds中呢?

对于以上3个问题,我们的解决思路如下:

  • 在selectServer类中设置一个数组成员int fd_arry[NUM],NUM是#define NUM ((sizeof(fd_set))*8),为什么数组上限是NUM呢?fd_set位图类型的变量只有NUM个比特位,每个比特位都用于映射一个文件描述符,比如第一个比特位(把最低位称为第一个比特位)映射0号文件描述符、第二个比特位映射1号文件描述符....以此类推,所以只有位于左闭右闭区间【0号,NUM-1号】中的文件描述符能被映射进fd_set位图类型的变量中,即最多有NUM个文件描述符能被映射进fd_set位图类型的变量中,既然fd_arry数组是用于保存应被设置进fd_set位图变量中的文件描述符的,而我们又知道最多有NUM个文件描述符能被映射进fd_set位图类型的变量中,那么当然数组fd_arry的上限是NUM了。注意虽然说数组的存储上限是能存NUM个文件描述符,但并不是说只要数组没有被存满,就能存储accept到的服务套接字service_sock,举个例子,如果进程在调用accept函数获取服务套接字前就open打开了许多文件,比如打开了100个文件(其中不包括自动打开的0、1、2号文件描述符对应的标准输入/输出/错误文件),则再调用accept函数获取到的文件描述符就会从104开始(从104开始是因为除了0、1、2,还有一个监听套接字占用了某个文件描述符,所以在accept之前共有104个文件描述符,那么accept获取到的文件描述符就是第105个文件描述符,所以该文件描述符就是104号了),那么很显然,如果数组从104号文件描述符开始往后存储NUM个文件描述符,一定会有很多超过区间【0号,NUM-1号】的非法文件描述符被存进数组中,注意因为这些非法文件描述符不在区间内,所以它们是无法被映射进位图中的,但编码的逻辑又是把数组中的所有文件描述符映射进位图中,所以这些非法文件描述符被存进数组中就有问题,所以不能只要数组没有被存满,就将accept获取的service_sock存进数组中,所以要注意在编码时设置好if判断处理这一点。
  • 设置好数组成员fd_arry后,然后让该数组在每次循环结束前都把accpet获取到的服务套接字存起来,以让当前进程在进入下一次循环时知道该服务套接字的值是多少进而将该服务套接字设置进select函数的readfds中;同时让该数组在每次循环结束前都把需要继续被等待(或者说监视)的文件描述符给记录下来,这样一来,在进入下一次循环后,就能通过遍历数组计算出需要重新传给nfds参数的值,就能通过遍历数组知道哪些文件描述符需要重新被设置进select函数的readfds中。(至于为什么不用vector而用原生数组,是为了暴露一些select服务器的问题出来,如果使用vector,则存在封装,不容易把问题暴露出来,这一点会在讲解select服务器的缺点时说明)
  • 对于这个fd_arry数组,我们首先将数组中的所有值都初始化成-1,规定如果某下标上存的值是-1,则表示该位置还没有存储任何文件描述符,如果某下标上存的值不是-1(因为文件描述符不可能小于0,所以如果某下标上的值不是-1,则一定是大于等于0的数字),则认为该下标上存了文件描述符,然后规定0号下标上的值永远是监听套接字。这两个规定体现在代码上就是,我们在调用socket函数创建监听套接字后要立刻将监听套接字对应的文件描述符存进数组的0号下标上,后序每accept成功获取到一个服务套接字后,就立刻遍历fd_arry数组将该服务套接字对应的文件描述符存进第一个遇到的值为-1的位置(即存进第一个表示没有存储文件描述符的位置),从这里我们也能看出来数组fd_arry的下标号和文件描述符的号数不具有任何映射关系(即0号下标不一定存0号文件描述符,1号下标不一定存1号文件描述符.....),而是该下标上存储的元素的值是几,就存储的是几号文件描述符。

selectServer的整体代码、即完善版本的基础框架(构造函数、析构函数和类成员)和完善版本的start函数

根据上面的思路,我们就能完善selectServer类的基础框架,并编写出完整的start函数的代码,如下所示。

#include<iostream>
using namespace std;
#include <sys/select.h>
#include <string.h>
#include <unistd.h>//提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供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<NUM; i++)
            _fd_arry[i] = -1;

        _listen_sock = socket(AF_INET, SOCK_STREAM, 0);
        //在文中说过,规定0号下标上永远存储监听套接字对应的文件描述符
        _fd_arry[0] = _listen_sock;

        //让服务端进程能在TIME_WAIT状态下重复绑定同一个端口号
        int opt = 1;
        setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

        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;
        while(1)
        {
            //进入循环后第一件事就是把数组中的文件描述符全设置进select函数中,并找出最大的文件描述符
            FD_ZERO(&fs);
            int maxfd = _listen_sock;
            for(int i=0; i<NUM; i++)
            {
                if (_fd_arry[i] == -1)
                    continue;
                FD_SET(_fd_arry[i], &fs);
                if (_fd_arry[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,没有文件描述符就绪,可以在这个分支里趁机处理一会其他的业务"<<endl;
                break;
            case -1:
                cout<<"select error"<<endl;
                break;
            default:
                cout<<"select success,有"<<x<<"个文件描述符就绪"<<endl;
                if (FD_ISSET(_listen_sock, &fs) > 0)//如果是监听套接字的连接队列就绪,则accept获取连接对象(或者说获取服务套接字)
                {
                    cout<<"get a new link"<<endl;
                    sockaddr_in peer;
                    socklen_t len = sizeof(peer);
                    int service_sock = accept(_listen_sock, (sockaddr*)&peer, &len);
                    if (service_sock < 0)
                    {
                        cout<<"accept error"<<endl;
                        //注意该break是终止switch的,而不是终止while循环的
                        break;
                    }
                    else 
                    {
                        //accept获取一个服务套接字(或者说连接对象)后,可以直接对该服务套接字进行read/recv吗?答案是不行,至于原因已经在文章中说过了
                        if (service_sock > NUM-1)
                        {
                            cout<<"service_sock为"<<service_sock<<",无法映射进fd_set位图中,接下来服务端会直接close断开和该客户端的连接"<<endl;
                            break;
                        }

                        int i = 0;
                        for(; i<NUM; i++)
                        {
                            if(_fd_arry[i] != -1)
                                continue;
                            _fd_arry[i] = service_sock;
                            break;
                        }
                        if(i == NUM)
                        {
                            cout<<"select等待(或者说监视)的文件描述符已经达到了自己能等待的上限了,无法再往select函数中添加新的文件描述符"<<endl;
                            close(service_sock);
                        }
                    }
                }
                //如果是服务套接字的内核缓冲区就绪,则直接进行read/recv,此时调用read/recv是不会被阻塞的。                
                for(int i=1; i<NUM; i++)
                {
                    if(_fd_arry[i] == -1)//如果该下标上没有服务套接字,则直接进入下一次循环查看下一个下标上是否有服务套接字
                        continue;
                    else//如果该下标上有服务套接字,则检查该服务套接字是否就绪
                    {
                        if (FD_ISSET(_fd_arry[i], &fs) == 0)//如果没有就绪,则直接进入下一次循环
                            continue;
                        else if (FD_ISSET(_fd_arry[i], &fs) > 0)//如果就绪,则直接进行read/recv
                        {
                            //说一下,这里在read读取时是有bug的,这是因为我们无法确定是否read到了一个完整的报文,也没有解决粘包问题。想要处理这个bug,我们就得自己定一
                            //个协议,因为select服务器并不常用,所以为了图方便,这里我们就不改这个bug了(不改也是能勉强用的),我们在编写epoll服务器时再处理这个bug。
                            cout<<"get a new IO"<<endl;
                            char buffer[1024];
                            ssize_t s = read(_fd_arry[i], buffer, sizeof(buffer)-1);
                            if (s > 0)
                            {
                                buffer[s] = 0;
                                cout<<"client["<<i<<"]#:"<<buffer<<endl;
                            }
                            else if (s == 0)
                            {
                                cout<<"client["<<i<<"]quit, me too"<<endl;
                                //注意一定要先close文件描述符_fd_arry[i],再把文件描述符从数组fd_arry中清除(即设置成-1),这是因为如果反着来,就会close(-1)
                                close(_fd_arry[i]);
                                _fd_arry[i] = -1;
                            }
                            else    
                                cout<<"read error"<<endl;
                        }
                    }
                }
                break;
            }
        }
    }

    void debug()
    {
        for(int i=0;i<NUM;i++)
        {
            if(_fd_arry[i]==-1)
                continue;
            cout<<_fd_arry[i]<<' ';
        }
        cout<<endl;
    }

private:
    int _listen_sock;
    uint16_t _port;
    int _fd_arry[NUM]; //#define NUM (sizeof(fd_set)*8)
};

对selectServer服务器的测试

将下图1的代码编译并运行后,直接如下图2所示打开多个终端,并在每个终端中通过telnet命令连接服务端进程,如下图2的红框处所示,出现的数字越来越多表示不断有telnet和服务端进程建立连接成功(凭什么这么说呢?这是因为这些数字是debug函数打印的,debug函数的逻辑是把目前_fd_arry数组中存储的所有文件描述符全部打印出来,而根据上面的编码逻辑,只有服务端进程成功调用accept函数获取到服务套接字或者说新连接的时候,才会把该套接字对应的文件描述符存进_fd_arry数组中,注意3次握手建立连接是发生了listen函数后,accept函数前,既然现在accept函数都成功调用完毕了,连接自然也是建立成功了的),并且如下图2的蓝框处所示,可以发现多个telnet客户端进程是可以同时给单进程模式的selectServer服务端进程发数据的,这就证明了咱们的代码是没有问题的。

  • 图1如下。
  • 图2如下。 

select服务器的优缺点

优点:

效率高(说一下,这里的效率高是对比【以往以阻塞IO模式编写的多进程或者多线程版本的TCP服务器】,select服务器作为多路转接IO模型中的一种,对比poll和epoll服务器就是弟弟),为什么效率高呢?select服务器作为多路转接IO模型中的一种,当然也具有多路转接IO模型的优点,即select服务器支持一次等待多个文件描述符就绪,支持同时为多个客户端提供服务。注意虽然以往以阻塞IO模式编写的多进程或者多线程版本的TCP服务器也能支持等待多个文件描述符就绪,支持同时为多个客户端提供服务,但是以往的TCP服务器是多进程或者多线程模式,而现在的select服务器是单进程模式,注意创建子进程或者新线程是对服务器有很大的消耗的,相比于以往的来几个客户端就创建几个子进程或者新线程为其提供服务的模式,现在的select服务器的【不管来多少个客户端,我都只靠一个主进程为它们提供服务】的模式无疑是消耗更少的,所以效率就高了。

  • 在有一种场景下这种效率高得还能更明显,比如说服务器和1000个客户端建立了连接,但只有10个是活跃的,其他990个客户端都只建立连接,一条数据都不发送,这时如果服务器是以多进程或者多线程模式编写的,则不管活不活跃,都要创建1000个子进程或者新线程为它们提供服务,这就相当于有990个子进程或者新线程占用了服务器的大量资源,但一点作用都没有起到,于是这台服务器的资源利用效率就极度低下了,但如果服务器是select服务器,则只需要一个主进程就能为1000个客户端提供服务,如果只有10个是活跃的,其他990个客户端压根不给我发数据,那我给这10个客户端提供服务的速度还能更快,可以看到,在这种场景下select服务器不光所需的消耗更少,还能提高自己的服务速度(也就是提高双方的通信速度),所以效率就高得更明显了。

缺点:

缺点1:首先要知道,在每一次循环中调用select函数前都要重新设定select函数的3个fd_set*指针类型的参数readfds、writefds、exceptfds以确定哪些文件描述符的读、写、异常事件需要被select函数关心,重新设定这几个参数时是需要遍历_fd_arry数组的;然后要知道,在每一次循环中每当select函数检测到有文件描述符就绪时也是需要遍历_fd_arry数组以确定是哪个文件描述符就绪的。综上可以发现,select服务器中充满了大量重复的遍历数组的操作,这就会导致服务器的效率很低,这就是select服务器的缺点。

  • 说一下,为什么在咱们的selectServer的代码中,不让_fd_arry是一个vector而用原生数组呢?其就是为了暴露出select服务器的缺点充满大量重复的遍历数组的操作】,因为你想,如果使用vector,使用vector的接口如push_back,erase,虽然这些接口的底层也会遍历数组,但因为存在封装,所以我们就不容易看出来。

缺点2:select函数同时能等待(或者说监视)的文件描述符是有上限的,位图fd_set类型中的比特位上限是多少,select函数同时能等待的文件描述符的上限就是多少,也就是sizeof(fd_set)*8,在上文中通过程序证明过在笔者的环境下该值是1024,对于一个服务器而言,只能给1024个客户端提供服务是远远不够的,这就是select服务器的缺点。

正是因为select服务器具有以上这些缺点,才会有poll服务器的诞生,接下来咱们就来看看poll服务器的工作模式。注意在讲解poll函数时还会说明poll服务器如何改善上面的两个缺点。

poll函数

poll函数和select函数一样,poll作为多路转接IO中的一种函数,poll系统调用也可以让我们的程序同时等待(或者说监视)多个文件描述符的上的事件是否就绪。poll只负责等待(或者说监视),当监视的多个文件描述符中有一个或多个事件就绪时,poll就会成功返回并将对应文件描述符的就绪事件告知调用者。注意poll函数的功能是比select函数的功能更强大的,讲解完poll函数的参数后你就能感受到这点。

头文件说明:

  • #include<poll.h>文件即可。

参数说明:

  • 对于第一个参数,即指针类型的struct pollfd *fds,实际上我们把它看作数组类型struct pollfd fds[]更贴切,这里笔者想表达的意思是fds指针并不是指向一个struct pollfd变量,而是指向一堆连续的struct pollfd变量,指向多少个该变量呢?第二个参数nfds的值是多少,就是多少。poll只负责等待(或者说监视),当监视的多个文件描述符中有一个或多个文件描述符的事件就绪时,poll就会成功返回并将对应文件描述符的对应就绪事件告知调用者,告知的方式就和当前参数fds有关,举个例子,如上图第二个红框处所示,当前参数fds指针就指向这样的结构体变量,在调用poll函数时,该结构体中的fd成员就用于告诉poll函数需要关心哪个文件描述符,events成员就用于告诉poll函数需要关心该文件描述符上的哪些事件就绪(结合下图进行思考,events成员是short类型,共16个比特位,每个比特位都表示一种事件,其中就包括读事件、写事件、异常事件,将对应事件的比特位置为1就表示需要poll函数关心该类事件的就绪情况,如何设置呢?举个例子,假如是想设置读和异常事件,则events = POLLIN | POLLERR即可。从下图我们可以看到poll函数所能检测到的事件就绪类型是远远多于select的);当poll函数检测到了有几个文件描述符的某些事件就绪导致poll函数返回后,通过检查revents成员就可以知道哪些事件是否就绪(结合下图进行思考,revents成员是short类型,共16个比特位,每个比特位都表示一种事件,其中就包括读事件、写事件、异常事件,如果poll函数检测到某些事件就绪,则poll函数就会将这些事件对应的比特位设置成1,举个例子,假如读事件就绪,则poll函数会将revents的从低位开始的第一个比特位设置成1,则我们通过revents&POLLIN得到的数字就一定不会是0,所以我们编写代码时就能根据判断revents&POLLIN的值是不是0来判断读事件是否就绪),通过检查fd成员就能知道是哪个文件描述符的事件就绪。
  • 对于第二个参数nfds_t nfds,在上一段已经说明过该参数的用途了,不再赘述。
  • 对于第三个参数int timeout,我们让poll系统调用在同时等待(或者说监视)多个文件描述符时,也可以选择不同的等待策略,比如可以选择以阻塞模式等待(体现在代码上就是让timeout等于-1)、可以选择以非阻塞模式等待(体现在代码上就是让timeout等于0,值为0表示等待0毫秒,等待0毫秒也就是没有进行等待,也就表示非阻塞了)、可以选择定时,在规定时间内则以阻塞模式等待,超时就直接返回(比如想定时为5秒,因为poll这里的单位是毫秒,所以体现在代码上就是让timeout等于5000)注意,和select函数中的timeout不一样,poll函数中的timeout是int类型,并且不是输入输出型参数,它只作为输入型参数,所以进入下一次while循环继续调用poll函数时是不用像上文调用select函数一样重新设置timeout的值的。

说一下,在上文中讲解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类的构造函数中就会new出用户指定的nfds个struct pollfd类型的变量,假设用指针_fds管理这块空间,则对于这个_fds数组,我们首先会将数组中的所有struct pollfd变量的fd成员都初始化成-1,规定如果某下标上存的struct pollfd变量的fd成员是-1,则表示该位置还没有存储任何文件描述符,反之如果存的struct pollfd变量的fd成员不是-1(因为文件描述符不可能小于0,所以如果某下标上的值不是-1,则一定是大于等于0的数字),则认为该下标对应的位置上存了文件描述符,然后规定0号下标上的struct pollfd变量的fd成员永远是监听套接字。这两个规定体现在代码上就是,我们在调用socket函数创建监听套接字后要立刻将监听套接字对应的文件描述符存进_fds数组的0号下标对应的struct pollfd变量的fd成员中,后序每accept到一个服务套接字对应的文件描述符service_sock,都立刻遍历_fds数组,找出第一个遇到的fd成员为-1的struct pollfd变量,然后将service_sock的值填充到该struct pollfd变量的fd成员上(说简单点就是找第一个没有存储文件描述符的位置,然后将service_sock存进该位置从这里我们也能看出来数组_fds的下标号和文件描述符的号数不具有任何映射关系(即0号下标不一定存0号文件描述符,1号下标不一定存1号文件描述符.....),而是该下标上存储的struct pollfd变量的fd成员是几,就存储的是几号文件描述符。
  • 此时可能有人会疑惑,说【你不是说poll函数同时能等待(或者说监视)的文件描述符是没有上限的吗?如果在编码时、在构造函数中new出用户指定的nfds个struct pollfd类型的变量,因为nfds是一个固定的数字,所以随着不断accept,该_fds数组一定会被文件描述符充满,而poll又只能等待_fds数组中的文件描述符,那么此时poll函数同时能等待的文件描述符不就有上限了吗?】,这里笔者想说的是,你混淆了概念,poll可以等待的文件描述符的确是没有上限的,比如说如果_fds数组被文件描述符充满了,我们可以对_fds数组做扩容,比如realloc扩2倍,扩容扩2倍后,poll可以等待的文件描述符的数量就又乘以了2倍,所以只要你服务器扛得住,你可以一直扩容,这样一来理论上poll可以等待的文件描述符就没有上限了。

返回值说明:

  • poll系统调用可以让我们的程序同时等待(或者说监视)多个文件描述符的上的事件就绪,在等待的这多个文件描述符中,如果只有一个就绪,则poll函数就会调用成功并返回1,如果同时有多个文件描述符就绪,则有几个就绪,返回值就是几。
  • 如果返回值是0,则说明poll函数是以定时模式调用,并且超时了,这时返回值就是0。
  • 如果返回值是-1,则说明poll函数发生了某些异常,调用失败,一般发生这种情况都是传给poll函数的参数非法造成的。

poll服务器

pollServer的整体代码

关于pollServer的整体代码,我们直接将上文selectServer的整体代码拿过来,然后在其基础上稍微进行修改即可得到,大体框架都是不需要改变的,比如说:

  • 对于pollServer的基础框架(即构造函数、析构函数和类成员),我们的修改思路为:编码逻辑是不变的(如果忘了编码逻辑是怎样的,建议回看select服务器实现的过程),根据上文讲解poll函数时的理论,我们可知需要在类成员中增加struct pollfd* _fds成员、增加nfds_t _nfds成员(nfds_t本质是typedef后的unsigned long int的别名)、增加int _timeout成员,然后还需要在构造函数中初始化它们,比如先通过构造函数的参数(或者说用户传递的参数)初始化_nfds、timeout,然后new出_nfds个struct pollfd变量并将这块空间交给_fds指针管理,然后把_fds数组中的所有struct pollfd变量的fd成员设置成-1表示该位置还没有存储任何文件描述符等等,至于关于构造函数和析构函数的更详细的内容,建议参考上文讲解poll函数时的理论和下文的代码。
  • 对于pollServer的start函数,我们的修改思路为:编码逻辑不变(如果忘了编码逻辑是怎样的,建议回看select服务器实现的过程),只是把【因为需要调用select函数而形成的代码写法】换成【因为需要调用poll函数而形成的代码写法】。

结合上面的理论,pollServer的整体代码如下。

#include<iostream>
using namespace std;
#include <poll.h>
#include <string.h>
#include <unistd.h>//提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供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<nfds; i++)
            _fds[i].fd = -1;

        _listen_sock = socket(AF_INET, SOCK_STREAM, 0);
        //在文中说过,规定0号下标上永远存储监听套接字对应的文件描述符
        _fds[0].fd = _listen_sock;
        _fds[0].events = POLLIN;

        //让服务端进程能在TIME_WAIT状态下重复绑定同一个端口号
        int opt = 1;
        setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

        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);
    }

    ~pollServer()
    {
        if(_listen_sock > 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,没有文件描述符就绪,可以在这个分支里趁机处理一会其他的业务"<<endl;
                break;
            case -1:
                cout<<"poll error"<<endl;
                break;
            default:
                cout<<"poll success,有"<<x<<"个文件描述符就绪"<<endl;
                if (_fds[0].revents & POLLIN > 0)//如果是监听套接字的连接队列就绪,则accept获取连接对象(或者说获取服务套接字)
                {
                    cout<<"get a new link"<<endl;
                    sockaddr_in peer;
                    socklen_t len = sizeof(peer);
                    int service_sock = accept(_listen_sock, (sockaddr*)&peer, &len);
                    if (service_sock < 0)
                    {
                        cout<<"accept error"<<endl;
                        //注意该break是终止switch的,而不是终止while循环的
                        break;
                    }
                    else
                    {
                        //accept获取一个服务套接字(或者说连接对象)后,可以直接对该服务套接字进行read/recv吗?答案是不行,至于原因已经在文章中说过了
                        int i = 0;
                        for(; i<_nfds; i++)
                        {
                            if(_fds[i].fd != -1)
                                continue;
                            _fds[i].fd = service_sock;
                            _fds[i].events = POLLIN;//如果还需要关心写事件,则改成_fds[i].event = POLLIN | POLLOUT;即可
                            break;
                        }
                        if(i == _nfds)
                        {
                            //我们说【poll可以等待的文件描述符是没有上限的,_fds数组中有多少个struct pollfd变量就能同时等待多少文件描述符】,在这句话中,虽然poll的确能等待
                            //任意多个文件描述符,但问题在于编写代码时、最初创建struct pollfd _fds[]数组时需要给数组设定一个固定的值,而在不断accept时,该数组一定会被打满,
                            //所以此时就有两种选择,可以选择断开刚刚accept到的来自于新的客户端的连接,也可以选择扩容struct pollfd _fds[]数组让poll函数等待更多的文件描述符
                            //,常见的扩容思路有,【对原数组_fds进行realloc开2*_nfds个struct pollfd变量的空间即可,使用realloc会自动将原数组中的元素拷贝到新空间中。】,这
                            //里为了图方便,我们就不作演示了。
                            close(service_sock);
                        }
                    }
                }
                //如果是服务套接字的内核缓冲区就绪,则直接进行read/recv,此时调用read/recv是不会被阻塞的。                
                for(int i=1; i<_nfds; i++)
                {
                    if(_fds[i].fd == -1)//如果该下标上没有服务套接字,则直接进入下一次循环查看下一个下标上是否有服务套接字
                        continue;
                    else//如果该下标上有服务套接字,则检查该服务套接字是否就绪
                    {
                        if (_fds[i].revents & POLLIN == 0)//如果没有就绪,则直接进入下一次循环
                            continue;
                        else if (_fds[i].revents & POLLIN > 0)//如果就绪,则直接进行read/recv
                        {
                            //说一下,这里在read读取时是有bug的,这是因为我们无法确定是否read到了一个完整的报文,也没有解决粘包问题。想要处理这个bug,我们就得自己定一
                            //个协议,因为相比于epoll服务器,poll服务器并不常用,所以为了图方便,这里我们就不改这个bug了(不改也是能勉强用的),我们在编写epoll服务器时
                            //再处理这个bug。
                            cout<<"get a new IO"<<endl;
                            char buffer[1024];
                            ssize_t s = read(_fds[i].fd, buffer, sizeof(buffer)-1);
                            if (s > 0)
                            {
                                buffer[s] = 0;
                                cout<<"client["<<i<<"]#:"<<buffer<<endl;
                            }
                            else if (s == 0)
                            {
                                cout<<"client["<<i<<"]quit, me too"<<endl;
                                //注意一定要先close文件描述符_fds[i].fd,再把文件描述符从数组_fds中清除(即设置成-1),这是因为如果反着来,就会close(-1)
                                close(_fds[i].fd);
                                _fds[i].fd = -1;
                            }
                            else    
                                cout<<"read error"<<endl;
                        }
                    }
                }
                break;
            }
        }
    }

    void debug()
    {
        for(int i=0;i<_nfds;i++)
        {
            if(_fds[i].fd == -1)
                continue;
            cout<<_fds[i].fd<<' ';
        }
        cout<<endl;
    }

private:
    int _listen_sock;
    uint16_t _port;
    struct pollfd *_fds;
    nfds_t _nfds;//本质是typedef后的unsigned long int的别名
    int _timeout;
};

对pollServer服务器的测试

将下图1的代码编译并运行后,直接如下图2所示打开多个终端,并在每个终端中通过telnet命令连接服务端进程,如下图2的红框处所示,出现的数字越来越多表示不断有telnet和服务端进程建立连接成功(凭什么这么说呢?这是因为这些数字是debug函数打印的,debug函数的逻辑是把目前_fds数组中存储的所有文件描述符全部打印出来,而根据上面的编码逻辑,只有服务端进程成功调用accept函数获取到服务套接字或者说新连接的时候,才会把该套接字对应的文件描述符存进_fds数组中,注意3次握手建立连接是发生了listen函数后,accept函数前,既然现在accept函数都成功调用完毕了,连接自然也是建立成功了的),并且如下图2的蓝框处所示,可以发现多个telnet客户端进程是可以同时给单进程模式的pollServer服务端进程发数据的,这就证明了咱们的代码是没有问题的。

  • 图1如下。
  • 图2如下。 

poll服务器的优缺点

优点:

优点1:效率高(说一下,这里的效率高是对比【以往以阻塞IO模式编写的多进程或者多线程版本的TCP服务器】,poll服务器作为多路转接IO模型中的一种,对比epoll服务器就是弟弟),为什么效率高呢?poll服务器作为多路转接IO模型中的一种,当然也具有多路转接IO模型的优点,即poll服务器支持一次等待多个文件描述符就绪,支持同时为多个客户端提供服务。注意虽然以往以阻塞IO模式编写的多进程或者多线程版本的TCP服务器也能支持等待多个文件描述符就绪,支持同时为多个客户端提供服务,但是以往的TCP服务器是多进程或者多线程模式,而现在的poll服务器是单进程模式,注意创建子进程或者新线程是对服务器有很大的消耗的,相比于以往的来几个客户端就创建几个子进程或者新线程为其提供服务的模式,现在的poll服务器的【不管来多少个客户端,我都只靠一个主进程为它们提供服务】的模式无疑是消耗更少的,所以效率就高了。

  • 在有一种场景下这种效率高得还能更明显,比如说服务器和1000个客户端建立了连接,但只有10个是活跃的,其他990个客户端都只建立连接,一条数据都不发送,这时如果服务器是以多进程或者多线程模式编写的,则不管活不活跃,都要创建1000个子进程或者新线程为它们提供服务,这就相当于有990个子进程或者新线程占用了服务器的大量资源,但一点作用都没有起到,于是这台服务器的资源利用效率就极度低下了,但如果服务器是poll服务器,则只需要一个主进程就能为1000个客户端提供服务,如果只有10个是活跃的,其他990个客户端压根不给我发数据,那我给这10个客户端提供服务的速度还能更快,可以看到,在这种场景下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不及时读取网卡中的数据就可能导致数据丢失,所以定期去查这种方案不可行。

(结合下图思考)实际上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的整套工作流程及其原理如下:

  • (结合上图思考)调用epoll_create函数时,OS会在内核层创建一个红黑树RBTree和一个就绪队列ReadyQueue(当然目前红黑树还只是一颗空树,就绪队列也是一个空的队列,说一下,就绪队列本质是一个双向链表,而不是真的队列queue),该红黑树上的节点对象中有fd成员和events成员,fd成员表示上层应用层希望让epoll模型(epoll模型并不是某个函数,我们把epoll的整套工作模式称为epoll模型)关心哪个文件描述符,events成员表示上层应用层希望让epoll模型关心该文件描述符的哪些事件;该就绪队列上的节点对象中有fd成员和revents成员,fd成员表示哪个文件描述符的事件就绪了,revents成员表示该文件描述符的哪些事件就绪了。
  • (结合上图思考)后序调用epoll_ctl函数本质就是在对该红黑树做增删改,比如调用epoll_ctl设置关心10号文件描述符的写事件时,就是往该红黑树中插入了一个【fd成员为10、events成员为EPOLLIN】的struct RBTreeNode节点变量(插入是遵循红黑树的插入算法的,并且在插入时是以fd为key值决定向左遍历还是向右遍历),做完这些epoll_ctl函数就结束了。
  • 当10号文件描述符的写事件真的就绪时,OS就会构建一个【fd成员为10、revents成员为EPOLLIN】的struct ReadyNode节点变量并将其插入到就绪队列的末尾。
  • 后序调用epoll_wait函数本质就是从就绪队列的队头开始把就绪队列中的节点提取到上层应用层。

问题:在上面我们说“调用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的值就是哪个套接字文件对应的文件描述符。顺便说一下,即使接受缓冲区中此时并没有新的数据从网卡中到来,只要接受缓冲区中的数据没有被上层读光,那么OS也会自动调用该callback(int 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_create函数 

结合上文讲解的epoll原理我们可知epoll_create函数就用于创建一个epoll模型,即【创建红黑树、创建就绪队列、并向底层驱动层注册回调函数】,如上图所示,该struct eventpoll就表示epoll模型,其中的rbr成员就是红黑树,rdllist成员就是就绪队列。

参数说明:

  • size:自从Linux2.6.8之后,size参数是被忽略的,但size的值必须设置为大于0的值。

返回值说明:

  • (结合下图思考)epoll模型创建成功则返回epoll模型对应的文件描述符epfd,否则返回-1,同时错误码会被设置。有人可能会疑惑说【epoll模型只是一个struct eventpoll类型的变量,epoll模型和文件描述符有什么关系呢?或者说什么叫做epoll模型对应的文件描述符呢?】,这里笔者要说的是,当OS为当前进程创建epoll模型时,同时就会创建一个struct file,在该struct file中有一个成员指针就指向epoll模型,该struct file的地址会被记录在当前进程的文件描述符表中,该struct file在几号下标上,epoll_create函数的返回值就是几,这就是epoll模型和文件描述符之间的关系。也正是因为epoll模型和文件描述符有联系,所以用户未来需要在当前进程中用到某个epoll模型时,只需要把该epoll模型对应的文件描述符epfd告诉OS,然后OS就能通过当前进程的文件描述符表找到对应的struct file文件对象,进而通过该文件对象找到epoll模型,这也是为什么epoll中的3个函数(即epoll_create、epoll_ctl、epoll_wait)都需要int epfd这个参数。

注意当不再使用某个epoll模型时,必须调用close函数关闭epoll模型对应的文件描述符epfd。

epoll_ctl函数

结合上文讲解的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:表示具体的动作,取值只有三种,并且分别用三个宏来表示,如下:

  • EPOLL_CTL_ADD:注册新的文件描述符到指定的epoll模型的红黑树中。
  • EPOLL_CTL_MOD:修改已经注册到epoll模型的红黑树上的文件描述符的监视事件。
  • EPOLL_CTL_DEL:在epoll模型的红黑树上删除指定的节点,调用epoll_ctl函数时需要给参数fd传一个值x,红黑树上的哪个节点对象的fd成员的值和x相等,则哪个节点就需要被删除。

3、int fd:需要监视的文件描述符。

4、struct epoll_event *event:用于表示需要监视该文件描述符上的哪些事件。struct epoll_event结构如下图所示,struct epoll_event结构中有两个成员,第一个成员events表示的是需要监视的事件,第二个成员data是一个联合体结构,一般只会使用该结构当中的fd,表示需要监听的文件描述符。

uint32_t events的常用取值如下:

  • EPOLLIN:表示对应的文件描述符可以读,即读事件就绪(对端的套接字被close掉后也会导致本地的套接字的读事件就绪)。说一下,在poll服务器中,poll函数的第一个参数struct pollfd *fds指针指向的对象的类型为struct pollfd,在struct pollfd中也有一个成员uint32_t events,在该events中表示写事件的取值为POLLIN,注意不要把POLLIN和当前讲解的EPOLLIN混淆了。
  • EPOLLOUT:表示对应的文件描述符可以写,即写事件就绪。
  • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(还记得在TCP协议一文中讲解的URG标志位吗,当数据的TCP报头中的URG标志位为1,则该数据就是紧急数据,该数据到来时,本地的套接字的EPOLLPRI事件就会就绪)
  • EPOLLERR:表示对应的文件描述符发生了错误。
  • EPOLLHUP:表示对应的文件描述符被挂断,即对端将文件描述符关闭了。说一下,在上文中说过如果对端文件描述符关闭了会导致本地文件描述符的EPOLLIN读事件就绪,所以有时候即使没有让epoll关心本地文件描述符的EPOLLHUP事件,只要让epoll关心了本地文件描述符的EPOLLIN事件,我们通过EPOLLIN也能处理对端文件描述符关闭的问题,但注意这种情况并不一定会发生在每种OS中,所以严格意义上说,如果想要检测对端的文件描述符是否被关闭,是需要让epoll关心本地文件描述符的EPOLLHUP事件的就绪情况的(如果就绪,则说明对端close了,如果不就绪,则说明对端没有close),这才是标准的、正确的做法。
  • EPOLLET:将epoll的工作方式设置为边缘触发(Edge Triggered)模式(在下文中会说明什么是边缘触发模式)
  • EPOLLONESHOT:只监视一次某文件描述符的事件,当监视完这次事件之后,如果还需要继续监视该文件描述符,则就需要重新将该文件描述符添加到epoll模型的红黑树中。什么意思呢?举个例子,如下图所示,如果我们想要epoll模型只监视一次4号文件描述符的写事件的就绪情况,则需要让x.events = EPOLLIN | EPOLLONESHOT,然后通过epoll_ctl函数为4号文件描述符构建红黑树节点并将构建的节点挂到epoll模型中的红黑树上,此后如果4号文件描述符的写事件真的就绪,则回调函数callback会负责为4号文件描述符构建就绪队列节点并将构建的节点放到就绪队列的末尾,做完这些后,因为之前设置了EPOLLONESHOT,所以回调函数还会将4号文件描述符对应的红黑树节点从红黑树中删除,所以当4号文件描述符本次就绪后,如果还想要epoll模型继续监视该文件描述符,则就需要重新调用epoll_ctl将该文件描述符添加到epoll模型的红黑树中。

返回值说明:

函数调用成功返回0,调用失败返回-1,同时错误码会被设置。

epoll_wait函数

结合上文讲解的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系统调用可以在就绪队列中获取多个节点(每个节点对象都有int fd成员和uint32_t revents成员(或者说short revents成员)),而epoll_wait函数的返回值就表示epoll_wait从就绪队列中获取到的节点个数。说一下,因为epoll_wait函数获取的每个节点都会被挨个存到epoll_wait函数的参数struct epoll_event *events中,所以我们在编写【遍历events数组处理已经就绪的文件描述符】的代码时,通过epoll_wait的返回值就能知道该循环多少次以让遍历events数组时的每次循环都能处理完一个文件描述符、都是一次有效的操作。
  • 如果返回值是0,则说明poll函数是以定时模式调用,并且超时了,这时返回值就是0。
  • 如果返回值是-1,则说明函数调用失败,此时错误码会被设置。

epoll_wait调用失败时,错误码可能被设置为:

  • EBADF:传入的epoll模型对应的文件描述符无效。
  • EFAULT:events指向的数组空间无法通过写入权限访问。
  • EINTR:此调用被信号所中断。在高级IO一文中的非阻塞IO部分详细说明过EINTR。
  • EINVAL:epfd不是一个epoll模型对应的文件描述符,或传入的maxevents值小于等于0。

缩减版的epoll服务器(当前epoll服务器只是为了演示epoll_create、epoll_ctl、epoll_wait的用法)

需要注意的是,当前epoll服务器只是一个缩减版,是为了演示epoll_create、epoll_ctl、epoll_wait的用法而编写的,所以当前epoll服务器只编写read读取的逻辑,写入和异常不做处理,这是为了图方便,但不必担心,我们会在编写完整版的epoll服务器时演示如何编写写入和异常的逻辑。

关于epollServer的整体代码,我们直接将上文selectServer的整体代码拿过来,然后在其基础上稍微进行修改即可得到,大体框架都是不需要改变的,比如说:

  • 对于epollServer的基础框架(即构造函数、析构函数和类成员),我们的修改思路为:编码逻辑是不变的(如果忘了编码逻辑是怎样的,建议回看select服务器实现的过程),根据上文讲解epoll_wait函数时的理论,我们可知需要在类成员中增加struct epoll_event* _arry成员、增加int _arry_size、增加int _timeout成员,然后还需要在构造函数中初始化它们,比如先通过构造函数的参数(或者说用户传递的参数)初始化_arry_size成员、timeout成员,然后new出_arry_size个struct epoll_event变量并将这块空间交给_arry指针管理,至于关于构造函数和析构函数的更详细的内容,建议参考下文的代码,如果代码的某些步骤不理解,则建议再看看上文的epoll原理和epoll的3个函数。
  • 对于epollServer的start函数,我们的修改思路为:编码逻辑不变(如果忘了编码逻辑是怎样的,建议回看select服务器实现的过程),只是把【因为需要调用select函数而形成的代码写法】换成【因为需要调用epoll的3个函数而形成的代码写法】。

结合上面的理论,epollServer的整体代码如下。

#include<iostream>
using namespace std;
#include <unistd.h>//提供close
#include <sys/epoll.h>
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供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超时,目前没有文件描述符就绪,可以在这个分支里趁机处理一会其他的业务"<<endl;
                break;
            case -1:
                cout<<"epoll_wait error"<<endl;
                break;
            default:
                for(int i=0; i<x; i++)
                {
                    if (_arry[i].data.fd == _listen_sock)//listen_sock就绪
                    {
                        int service_sock = accept(_listen_sock, nullptr, nullptr);
                        if (service_sock < 0)
                            continue;
                        else
                            cout<<"accept sucess, service_sock:"<<service_sock<<endl;
                        epoll_event ee;
                        ee.events = EPOLLIN;
                        ee.data.fd = service_sock;
                        epoll_ctl(_epfd, EPOLL_CTL_ADD, service_sock, &ee);
                    }
                    else//普通文件描述符就绪
                    {
                        char buffer[1024];
                        ssize_t size = read(_arry[i].data.fd, buffer, sizeof(buffer)-1);
                        if(size < 0)
                        {
                            cout<<"read error,即将close掉该发生错误的service_sock"<<endl;
                            epoll_ctl(_epfd, EPOLL_CTL_DEL, _arry[i].data.fd, nullptr);
                            close(_arry[i].data.fd);
                        }
                        else if (size == 0)
                        {
                            cout<<"client quit,me too"<<endl;
                            epoll_ctl(_epfd, EPOLL_CTL_DEL, _arry[i].data.fd, nullptr);
                            close(_arry[i].data.fd);
                        }
                        else
                            cout<<"client["<<_arry[i].data.fd<<"]"<<buffer<<endl;
                    }
                }
            }
        }        
    }
private:
    int _listen_sock;
    int _epfd;
    epoll_event *_arry;
    int _arry_size;
    int _timeout;
};

对缩减版的epoll服务器的测试

将下图1的代码编译并运行后,直接如下图2所示打开多个终端,并在每个终端中通过telnet命令连接服务端进程,如下图2的红框处所示,不断有telnet和服务端进程建立连接成功,并且如下图2的蓝框处所示,可以发现多个telnet客户端进程是可以同时给单进程模式的epollServer服务端进程发数据的,这就证明了咱们的代码是没有问题的。

  • 图1如下。
  • 图2如下。 

epoll的两种工作方式

epoll有两种工作方式,分别是水平触发工作模式和边缘触发工作模式。但注意select和poll只有一种工作方式,他俩都只能是水平触发工作模式。

水平触发(LT,Level Triggered)

epoll、select、poll默认状态下都就是LT工作模式,在该种模式下,只要有文件描述符的事件就绪,则epoll、select、poll就会一直通知用户有文件描述符的事件就绪,说具体一点就是:

  • 对于select和poll函数来说,只要正在监视的文件描述符中有文件描述符的事件就绪,则select和poll函数就会立刻调用成功并返回,举个例子,当在while循环中先调用poll或者select函数监视6号文件描述符的写事件,然后调用read从6号文件描述符的接收缓冲区中读取数据时,如果此时6号文件描述符的接收缓冲区中有3K的数据,并且如果在第一次循环中只read读取了1K的数据导致接收缓冲区中还剩下2K的数据,则在下一次循环中调用select或者poll函数时,select或者poll函数就会继续识别到6号文件描述符的写事件就绪,然后继续被调用成功并且立刻返回。
  • 对于epoll模型来说,只要正在监视的文件描述符中有文件描述符的事件就绪,则回调函数callback就会立刻为该文件描述符构建就绪队列节点并将节点放进就绪队列中,这时调用epoll_wait函数就会立刻调用成功并返回(即这时调用epoll_wait函数从就绪队列中获取节点就会立刻获取成功),举个例子,当在while循环的上方先调用epoll_ctl让epoll模型关心6号文件描述符的写事件,然后在while循环中【先调用epoll_wait函数等待6号文件描述符被回调函数callback放进就绪队列中,然后调用read从6号文件描述符的接收缓冲区中读取数据】时,如果此时6号文件描述符的接收缓冲区中真的到来了3K的数据,则在第一次循环中调用epoll_wait函数就会立刻调用成功并返回,如果在第一次循环中只read读取了1K的数据导致接收缓冲区中还剩下2K的数据,则在下一次循环中调用epoll_wait函数时就会继续调用成功并且立刻返回。此时有人可能会疑惑说【在上文中说过epoll_wait本质就是把就绪队列中的节点拿到上层应用层,那么在第一次循环中调用epoll_wait函数把6号文件描述符对应的节点从就绪队列中拿走后,按理来说就绪队列中应该不存在回调函数为6号文件描述符构建的节点了啊,为什么在第二次循环中调用epoll_wait函数还能从就绪队列中获取6号文件描述符呢?】,这里笔者要说的是,epoll模型如果检测到6号文件描述符的接收缓冲区中还有数据没被上层read读取完,则OS是会再次调用回调函数为6号文件描述符构建就绪队列节点并将节点放到就绪队列中的。

边缘触发(ET,Edge Triggered)

epoll可以将工作模式改成ET模式(select和poll则只能是LT),在ET模式下,只有文件描述符的就绪事件数量从无到有或者从少到多,即只有发生变化的时候,epoll才会通知用户有文件描述符的事件就绪,说具体一点就是:

  • 当在while循环的上方先调用epoll_ctl让epoll模型关心6号文件描述符的写事件,然后在while循环中【先调用epoll_wait函数等待6号文件描述符被回调函数callback放进就绪队列中,然后调用read从6号文件描述符的接收缓冲区中读取数据】时,如果此时6号文件描述符的接收缓冲区中真的到来了3K的数据,因为6号文件描述符的写事件是从无到有,所以在第一次循环中调用epoll_wait函数就会立刻调用成功并返回,如果在第一次循环中只read读取了1K的数据导致接收缓冲区中还剩下2K的数据,因为此时epoll是ET模式,所以对比上文中epoll在LT模式下的情况,即【在下一次循环中调用epoll_wait函数时就会继续调用成功并且立刻返回】,此时因为6号文件描述符的写事件并没有从少到多,即6号文件描述符的接收缓冲区中没有新的数据到来,所以在下一次循环中调用epoll_wait函数时就不会调用成功(不会成功本质是因为,虽然epoll模型检测到6号文件描述符的接收缓冲区中还有数据没被上层read读取完,但因为epoll当前是ET模式,所以epoll模型在6号文件描述符的接收缓冲区中没有新的数据到来时就不会判定6号文件描述符的写事件就绪,所以OS就不会再次调用回调函数为6号文件描述符构建就绪队列节点并将节点放到就绪队列中,所以在第二次循环中调用epoll_wait就无法从就绪队列中拿到6号文件描述符了,所以在第二次循环中调用epoll_wait函数时就不会调用成功了,此时如果epoll_wait的timeout参数是-1,即以阻塞模式调用epoll_wait,则进程就阻塞在epoll_wait函数处;如果timeout是大于0的值,即以定时模式调用epoll_wait,则超时后就会处理一些其他的由编码者布置的作业。

问题:如何让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模式时更高效的,举个例子,如下。

  • 假设6号文件描述符的接收缓冲区中有3K的数据,则epoll模型处于ET模式时,调用一次epoll_wait检测到读事件就绪时,会倒逼程序员编码调用read把3K数据全读完,所以在ET模式下总共只调用了一次epoll_wait就把6号文件描述符的事件处理完毕了;
  • 而当epoll模型处于LT模式下时,epoll模型检测到有文件描述符的读事件就绪时,是可以不立即进行处理,或者只处理一部分的(这是因为只要该文件描述符的接收缓冲区中的数据没有被read处理完,则在下一次循环时epoll_wait还会通知用户该文件描述符的读事件就绪,这样在下一次循环时就可以继续调用read读取该文件描述符的接收缓冲区中剩余的数据)如果在每次循环中调用epoll_wait检测到读事件就绪时,真的只read读取1K的数据,则想要把3K的数据读取完,就需要循环3次,所以在LT模式下需要调用3次epoll_wait才能把6号文件描述符的事件处理完毕。
  • 既然处理同样的事情,LT模式需要调用3次epoll_wait,而ET模式只需要调用一次epoll_wait,当然ET模式更高效了,毕竟每次调用epoll_wait都是要消耗CPU资源的。
  • 这时机灵的小伙伴可能会说【如果程序员在设计编码时,让epoll在LT模式下检测到6号文件描述符的读事件就绪时也read把接收缓冲区中的3K数据全部读取完毕,不就和ET模式一样、也只需要调用1次epoll_wait了?这样一来epoll处于ET模式时和epoll处于LT模式时的效率不就一样高了吗?】,这里笔者想说的是,没错,你说的完全正确,所以在上文中只说了ET模式一般是比LT模式的效率高,并没有说ET模式一定比LT模式的效率高。
  • 还有一个学了网络原理才能明白的ET模式高效的点:因为ET模式会倒逼程序员让上层应用层尽快将文件描述符的接受缓冲区中的数据全部取走,所以在该模式下工作的服务器在接收到客户端的信息后,就有很大的可能性在给客户端发送响应报文时发送一个更大的接受窗口,这样一来,客户端就很可能有更大的滑动窗口,就可以一次向服务器发送更多的数据以提高IO吞吐。

问题:那么问题来了,ET模式下,epoll模型检测到有文件描述符的读事件就绪时,如何调用read才能把该文件描述符的接收缓冲区中的数据读取完呢?或者说问题是在ET模式下如何进行读写?

答案:因为在ET工作模式下,只有文件描述符的就绪事件从无到有或者从少到多,即发生变化的时候epoll_wait才会通知用户、才会调用成功,这就倒逼用户当文件描述符的读事件就绪时必须一次性把该文件描述符的接收缓冲区中的数据全部读取完毕,否则可能再也没有机会进行读取了;倒逼用户当文件描述符的写事件就绪时必须一次性把该文件描述符的发送缓冲区写满,否则可能再也没有机会进行写入了。因此,读数据时必须循环调用read函数进行读取,写数据时必须循环调用write函数进行写入,举个例子,如下。

  • 当某文件描述符的读事件就绪时,循环调用read函数进行读取,直到某次调用read读取时,实际读取到的字节数小于期望读取的字节数,则说明该文件描述符的接收缓冲区中的数据已经被全部读取完了。
  • 注意当调用read读取时、实际读取的字节数和期望读取的字节数相等时,此时是有可能刚好把该文件描述符的接收缓冲区中的数据读取完的(比如当接收缓冲区中共有2K数据,并且程序员给read函数传的参数是1K,即期望让read函数读取1K的数据时,则在第一次循环中read读取完1K后,在第二次循环中调用read再读取1K后,接收缓冲区就没有数据了),但即使读取完了,因为上层应用层无法判定已经读取完了(毕竟read函数没有被阻塞,并且返回值还大于了0),所以这时我们是需要进入下一次循环再调用read读取接收缓冲区中的数据的,问题来了,如果我们再调用read函数进行读取,那么read就会因为接收缓冲区没有数据而被阻塞住,注意这里的阻塞是非常严重的,因为我们这里写的select、poll、epoll服务器都是单进程的服务器(单进程是因为,我们的目的就是编写出能够同时为多个客户端提供服务的单进程的服务器),如果当前进程被阻塞在read函数处,并且此后该接收缓冲区中再也没有新的数据到来,那么我们的服务器就永远卡住、相当于挂掉了,因此,在ET工作模式下循环调用read函数读取某文件描述符的接收缓冲区时,必须将该文件描述符设置为非阻塞状态,至于如何设置,请参考<<高级IO的相关知识点>>一文中的非阻塞IO部分。
  • 调用write函数写数据时也是同样的道理,需要循环调用write函数进行数据的写入,并且必须将对应的文件描述符设置为非阻塞状态。

再次强调:

  • 结合上文我们可知epoll在ET工作模式下,read和write读写哪个文件描述符,则哪个文件描述符就必须在被读写前被设置为非阻塞状态,这是必须的,不是可选的。
  • 而select、poll、epoll在LT工作模式下,read和write读写某个文件描述符时,该文件描述符在被读写前不一定需要被设置为非阻塞状态,这是可选的,可以选择默认的方式,即选择不将文件描述符设置为非阻塞状态,这是因为LT模式下,select、poll、epoll服务器都不需要一次性把文件描述符的读写事件处理完,即不需要循环调用read或者write,只需要调用一次read或者write,而只调用一次read或者write是不可能导致进程陷入阻塞的(为什么呢?举个例子,比如read读取100字节,但接收缓冲区只有50字节时,此时read就会成功读取50字节并返回50;比如write写入100字节,但发送缓冲区只有50字节时,此时write就只会写入50字节并返回50。可以看到本次read或者write最多只会让内核缓冲区变为空或者变为满,而不是在内核缓冲区已经为空或者已经为满的情况下调用read或者write,所以只调用一次read或者write是不可能导致进程陷入阻塞的),所以即使不将文件描述符设置为非阻塞状态,当前进程也不可能陷入阻塞,所以也就可以选择不将文件描述符设置为非阻塞状态了;当然也可以选择将文件描述符设置为非阻塞状态,因为即使是在LT模式下,即使select、poll、epoll服务器会一直通知当前进程有文件描述符的事件就绪,但为了提高LT模式的效率,我们也是可以一次性把文件描述符的读写事件处理完的,而想要一次性处理完,根据上文的内容我们可知就得将文件描述符设置为非阻塞状态。

注意,以上内容对于监听套接字listen_sock是同样有效的,所以epoll模型以ET模式进行工作时,是需要将listen_sock设置成非阻塞模式,并且当listen_sock的读事件就绪时,是需要循环调用accept函数保证一次性将listen_sock的读事件处理完毕的。

完整版的epoll服务器

我们以【让当前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对象。为什么呢?原因如下。

  • 因为当前版本的epoll提供的服务是计算,并且epoll的工作模式还是ET模式,所以对于监听套接字的读事件不能像上文中只是简单地调用一次accept获取一个连接对象,而是需要循环调用accept将连接队列中的所有连接对象全部获取(如果不理解请回看上文讲解的epoll的两种工作方式);对于服务套接字的读事件也不能像上文中只是简单地调用一次read并把读取到的数据打印出来,而是需要循环调用read将目标套接字的接收缓冲区中的所有数据全部读到应用层缓冲区,然后判断当前数据中是否具有完整的业务请求数据,如果有,则将该完整的业务请求数据拆分提取出来再进行业务处理,如果没有,则需要将数据暂时保存在应用层缓冲区,这样一来在下一次epoll模型检测到该套接字的读事件就绪(即接收缓冲区中有新的数据到来时)以让服务器进程可以调用read函数时,服务器就可以把read到的新数据拼凑在之前的数据后面,服务器就能通过这样的方式得到一个完整的业务请求数据以进行业务处理。注意因为服务套接字有多个,从每个套接字的接收缓冲区中read读取到的数据都可能不是一个完整的请求数据,所以每个套接字都需要一块独立的应用层缓冲区暂时保存read到的数据(这里的“独立”表示不同套接字使用不同的应用层缓冲区)
  • 服务器从目标套接字的接收缓冲区中得到业务请求数据后,处理完一个业务请求数据并得到处理结果数据后,是需要让epoll模型关心该套接字的写事件的,这样一来,当epoll模型检测到该套接字的写事件就绪(即检测到该套接字的发送缓冲区中有剩余空间)以让服务器进程可以调用write函数时,就可以通过write函数把处理结果数据发回给客户端,问题来了,如果这时该套接字的发送缓冲区已经处于接近满载的状态,则该处理结果数据就会只有一部分被write函数拷贝到发送缓冲区,剩下的一部分则需要被暂存在应用层缓冲区,这样一来,在下一次epoll模型检测到该套接字的写事件就绪以让服务器进程可以调用write函数时,才可以再调用write将其拷贝到发送缓冲区。注意因为服务套接字有多个,并且每个套接字都可能遇到这种情况,所以每个套接字也都需要一块应用层缓冲区暂时保存被发剩下的数据。
  • 综上两段,我们就可以选择为套接字sock封装一个connection类,类成员中包括int sock,string _inbuffer、string _outbuffer,让_inbuffer充当那块用于暂时保存read到的数据的缓冲区,让_outbuffer充当那块用于暂时保存被发剩下的数据的缓冲区。这样一来,往后我们就让服务器把read读到的数据全部放在_inbuffer缓冲区中,让服务器把需要write发送给客户端的数据全部放在_outbuffer缓冲区中。

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<connection*>,后序拿着events数组中的文件描述符遍历vector<connection*>,看哪个connection对象中的int sock成员和该文件描述符相等则哪个connection对象就对应该文件描述符;但最好还是选择哈希表unordered_map<int,connection*>,并在每为一个文件描述符new出connection对象时,就将该文件描述符和connection对象的映射关系保存到哈希表中,这样一来,后序拿着events数组中的文件描述符查找哈希表即可得到对应的connection对象,进而找到connection对象中的_inbuffer和_outbuffer缓冲区。我们当前的epoll服务器就选择哈希表unordered_map,所以等会编写代码时是需要在epollServer类中增加哈希表unordered_map成员的。

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关心该服务套接字的写事件,在LT模式下,那么只要发送缓冲区有空间,则服务器就需要调用write将_outbuffer中的数据发到发送缓冲区,而我们又知道_outbuffer中是没有数据的,这样一来,就相当于服务器会频繁地、无意义地调用write函数,浪费CPU资源。注意这时调用write函数并不会失败,只是没有意义,且会浪费CPU资源。而在ET模式下,因为最初OS判定服务套接字的写事件是否就绪只看发送缓冲区是否有空间资源,而最初发送缓冲区是为空的,所以这时写事件就能直接就绪,然后无意义的调用一次write,说无意义也是因为_outbuffer中没有数据,当然这都是只是小问题,更严重的是,往后无论_outbuffer中是否来了数据,因为发送缓冲区的空间资源再也无法从无到有,也无法从少到多,于是该服务套接字的写事件就再也无法就绪了。这分别就是在LT和ET模式下暂时不要让epoll关心写事件的原因了),也不需要关心异常事件(这是因为后序调用read和write时,如果文件描述符出异常,则read和write函数自然会调用失败并返回-1,调用失败我们直接就可以调用我们编写的用于进行异常处理的Excepter函数)

那什么时候让epoll模型关心服务套接字的写事件呢?根据上一段我们可以反推出答案,即在服务套接字对应的connection对象的_outbuffer缓冲区中有数据时再让epoll模型关心服务套接字的写事件(更详细地说就是,在服务器read读取到完整的业务请求数据后、处理完业务请求数据得到处理结果数据后,将处理结果数据放到_outbuffer中后,再让epoll模型关心服务套接字的写事件,这样一来,往后epoll模型检测到该服务套接字的写事件就绪(即在LT模式下就是检测到该服务套接字的发送缓冲区中有剩余空间;在ET模式下就是检测到该服务套接字的发送缓冲区的空间资源从无到有或者从少到多)时,在LT模式下调用write发送_outbuffer缓冲区中的数据时,write才会真正起到作用,write才会有意义,在ET模式下调用write发送_outbuffer缓冲区中的数据时,才不会导致服务套接字的写事件永远无法就绪。同理,从这里我们还可以推导出一个结论,调用write把_outbuffer缓冲区中的数据发完后,是需要调用epoll_ctl让epoll模型取消对该服务套接字的写事件的关心的,其原因和上一段中暂时不需要关心写事件的原因同理,即也是因为在LT模式下让epoll模型继续关心该服务套接字的写事件没有意义,只会导致服务器频繁地、无意义地调用write函数,浪费CPU资源;也是因为在ET模式下会导致服务套接字的写事件永远无法就绪。

6、剩余思路在下面代码的注释中,请参考代码。

epollServer的整体代码

结合上面的思路,epollServer的完整代码如下。(protocol.h的代码在epollServer的下面)

#include<iostream>
using namespace std;
#include <unistd.h>//提供close
#include <sys/epoll.h>
#include<functional>
#include<fcntl.h>
#include<unistd.h>
#include"protocol.h"
#include <unordered_map>
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供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超时,目前没有文件描述符就绪,可以在这个分支里趁机处理一会其他的业务"<<endl;
            else if(x < 0)
                cout<<"epoll_wait error"<<endl;
            else
            {
                for(int i=0; i<x; i++)
                {
                    int sock = _arry[i].data.fd;
                    int revents = _arry[i].events;
                    if(sock == _listen_sock)
                        Accepter(_table[sock]);
                    else
                    {
                        if(revents&EPOLLIN)
                            Reader(_table[sock]);
                        if(revents&EPOLLOUT)
                            Writer(_table[sock]);                            
                        //如果文件描述符发生了异常,则在Reader函数中调用read就一定会出问题,在Writer函数中调用write就一定会出问题,所以异常事件会在Reader和Writer函数中处理
                    }
                }                
            }
        }
        
    }

    void Accepter(connection* c)
    {
        //因为监听套接字被设置成了ET模式,所以要一次性将连接队列中的所有连接对象取出来,所以要循环调用accpet
        while(1)
        {
            sockaddr_in peer;
            memset(&peer, 0, sizeof(peer));
            socklen_t len = sizeof(peer);
            int service_sock = accept(_listen_sock, (sockaddr*)&peer, &len);
            if(service_sock < 0)
            {
                //因为监听套接字在上面已经被设置成了非阻塞模式,所以当连接队列中没有连接对象时,accept函数不会阻塞,而是会返回-1并将错误码errno设置成EAGAIN(又称EWOULDBLOCK)
                //,这时就应该break结束循环调用accept。
                if(errno == EAGAIN || errno == EWOULDBLOCK)
                    break;
                //如果accept返回-1并将错误码设置成了EINTR,则说明之前accept函数被信号中断了,此时需要continue重新调用accept。注意虽然这个概率比较低,但我们还是补齐这个逻辑。
                else if(errno == EINTR)
                    continue;
                else 
                {
                    cout<<"accept error"<<endl;
                    Excepter(c);
                    break;
                }
            }
            else
            {
                printf("accept success,client ip:%s,client port:%d\n", inet_ntoa(peer.sin_addr), ntohs(peer.sin_port));
                //先调用SetNonBlock函数(咱们封装的SetNonBlock函数中有fcntl函数,本质是在调用fcntl)将获取的服务套接字设置成非阻塞模式,然后再将该套接字添加进epoll模型
                //并添加进哈希表中
                SetNonBlock(service_sock);
                epoll_event ee;
                ee.data.fd = service_sock;
                //对于服务器的service_sock来说,一开始是只能让epoll关心读事件就绪的,后序等在应用层分配给该套接字的outbuffer有数据了、需要给客户端回复信息时,才需要
                //让epoll关心写事件就绪。
                ee.events = EPOLLIN | EPOLLET;
                epoll_ctl(_epfd, EPOLL_CTL_ADD, service_sock, &ee);
                connection* p = new connection(service_sock);
                _table[service_sock] = p;
            }
        }
    }

    //在reader中,需要先将套接字的接收缓冲区中的所有数据全部读取到应用层的_inbuffer缓冲区,然后在应用层做字符串分析将字符串拆分成一个个完整的业务请求报文,然后挨个把每个请求
    //报文反序列化,然后进行业务处理,然后再将处理结果序列化,然后再将序列化后的结果放到应用层的_outbuffer缓冲区,然后再将结果write发送给客户端
    void Reader(connection* c)
    {
        int flag = 0;
        while(1)
        {
            char temp[1024];
            ssize_t size = read(c->_sock, temp, sizeof(temp));
            if(size == 0)
            {
                cout<<"client quit,me too"<<endl;
                flag = 1;
                Excepter(c);
                break;
            }
            //因为所有服务套接字在上面已经被设置成了非阻塞模式,所以当接收缓冲区中没有数据时,read函数不会阻塞,而是会返回-1并将错误码errno设置成EAGAIN(又称EWOULDBLOCK)
            //,这时就应该break结束循环调用read。
            else if(size < 0)
            {
                if(errno == EAGAIN || errno == EWOULDBLOCK)
                    break;
                //如果read返回-1并将错误码设置成了EINTR,则说明之前read函数被信号中断了,此时需要continue重新调用read。注意虽然这个概率比较低,但我们还是补齐这个逻辑。
                else if(errno == EINTR)
                    continue;
                else 
                {
                    cout<<"read error"<<endl;
                    flag = 1;
                    Excepter(c);
                    break;
                }
            }
            else
                c->_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:"<<msg<<endl;
            if(msg.empty() == true)
                break;
            else
            {
                Request rq;
                rq.Deserialize(msg);
                Response resp = calculate(rq);
                c->_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"<<endl;
                    Excepter(c);
                    flag = 1;
                    break;
                }
            }
            else
            {
                c->_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"<<endl;
            return false;
        }
        else
        {
            fcntl(fd, F_SETFL, fl | O_NONBLOCK);
            return true;
        }
    }
private:
    int _listen_sock;
    epoll_event *_arry;
    int _arry_size;
    int _timeout;
    int _epfd;
    unordered_map<int, connection*> _table;
};

在上文的思路中说过我们是要定制协议的,这里我们就不重新写了,而是直接把<<对协议的基本认识>>一文中的协议protocol.h的整体代码拿过来,然后只做一处修改即可,即把#define SEP “\r\n”改成#define SEP “@”,如果对协议的编写过程感兴趣,请参考该篇文章。

#pragma once
#include<iostream>
using namespace std;
#include <string.h>
 
#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;
}

对完整版的epoll服务器的测试

将下图1的代码编译并运行后,直接如下图2所示打开多个终端,并在每个终端中通过telnet命令连接服务端进程,如下图2的红框处所示,不断有telnet和服务端进程建立连接成功,并且如下图2的蓝框处所示,可以发现多个telnet客户端进程是可以同时给单进程模式的epollServer服务端进程发业务请求数据的,这就说明咱们的代码没有问题。说一下,因为没有编写客户端,是靠telnet充当客户端,所以下图2中客户端发送业务请求数据时需要用户手搓若干个完整的业务请求数据,所以下图2中客户端收到服务端发送的处理结果数据时,数据还是以协议中定制的格式呈现给用户的,还是没有进行过反序列化的。

  •  图1如下。
  • 图2如下。 

  • 18
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值