Linux多路转接技术详解&Reactor模式详解

五种IO高级方式

任何IO操作都包含两个过程:

  1. 等待
  2. 拷贝

当我们调用read()函数,首先操作系统会先将数据从源头(如本地的硬盘、键盘、管道、远端的网络…)拷贝到内核缓冲区中。在这过程中可能由于文件很大,很久才能读完;可能用户一直没有在键盘输入;可能网络对端很久才发来消息,一直读不到。。。所以我们称这一步为等待过程。

然后read()再将内核缓冲区中的数据拷贝到用户空间中,这一步需要的时间就相对较短

从前,我们直接调用scanf(),cin时,程序会一直卡在输入这一步,直到用户输入回车,我们称这样的IO为阻塞式IO,即:在内核将数据准备好之前, 系统调用会一直等待。所有的套接字默认也都是阻塞式的。

但实际开发过程中,不可能让所有的IO都阻塞,其它任务都不进行。所以,让我们看一下其它的IO模型

  • 非阻塞IO:如果内核还未将数据准备好, 系统调用会直接返回, 并且将错误码errno设置为EWOULDBLOCK.

    当对文件描述符设置了非阻塞状态,我们一直轮询式地调用recvfrom,直到某一次调用返回的不再是-1,在轮询期间我们可以插入其它任务,后面会写一段demo代码

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

    我们需要提前在应用程序中设定好信号处理的代码,当调用了sigaction,操作系统会进行等待数据到来并拷贝到内核缓冲区,然后就给用户程序发SIGIO信号,在此过程中我们的程序可以继续执行其它的任务逻辑,当收到信号,就会中断当前任务去执行对应的信号处理代码,完成从内核缓冲区到用户空间的拷贝

  • 多路转接:多路转接能够同时等待多个文件描述符的就绪状态.

    多路转接的IO方式让我们可以同时进行多个IO,

    假设我们此时有4个IO要进行,对应4个文件描述符:3,4,5,6

    首先我们调用select(),将3,4,5,6这四个文件描述符交给操作系统照看,程序便进行阻塞

    一旦有任意一个或多个文件描述符完成等待过程,对应的内核缓冲区数据就绪,select()就会返回就绪的那些文件描述符,用户程序获得了这些文件描述符,就可以直接调用recvfrom()把数据从内核空间拷贝到用户空间

    然后继续把没有读写完了文件描述符通过select()交给操作系统管理,直到所有IO都完成

    这种IO方式的优势就在于可以同时进行多个文件描述符的等待,广撒网,等待的时间也就变短了

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

非阻塞式IO

将我们读写操作从默认的阻塞方式变为非阻塞,只需要将文件描述符修改为非阻塞模式

fcntl

函数原型:

#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );
  • fd:需要修改模式的文件描述符
  • cmd:需要对文件描述符进行的操作,如:
    • F_GETFL:获取此文件描述符当前的模式
    • F_SETFD:对此文件描述符进行模式设置

设计SetNoBlock函数

下面我们利用上面的fcntl()设计一个让文件描述符非阻塞化的函数:SetNoBlock()

#include <unistd.h>
#include <fcntl.h>
bool setNonBlock(int sock)
{
    int flag = fcntl(sock, F_GETFL); // 先获取文件描述符原本的选项属性
    if (flag == -1)
    {
        return false;
    }
    int n = fcntl(sock, F_SETFL, flag | O_NONBLOCK); // 设置非阻塞
    if (n == -1)
    {
        return false;
    }
    return true;
}

非阻塞demo

#include "Util.hpp"

int main()
{
    SetNoBlock(0); // 对0号描述符(标准输入)设置非阻塞
    char buffer[1024] = {0};
    while (true)
    {
        scanf("%s", buffer);
        cout << "刚刚检测到的内容是# " << buffer << endl;
        sleep(1);
    }
    return 0;
}

多路转接之select

函数原型

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, 
           fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
           struct timeval *timeout);

//对fd_set进行增删查找的接口
void FD_ZERO(fd_set *set);			//对set进行初始化
void FD_SET(int fd, fd_set *set);	//在set中设置某个文件描述符
void FD_CLR(int fd, fd_set *set);	//在set中清除某个文件描述符
int  FD_ISSET(int fd, fd_set *set); //判断某个文件描述符是已在set中进行设置
  • nfds:最大的fd+1,假设需要等待的文件描述符有1,2,4,8,nfds即设置为9,

  • readfds/writefds/exceptfds:都是输入输出型参数,输入所有需要被管理的文件描述符,带出已经就绪的文件描述符,其中fd_set是一个文件描述符集合

    我们把等待的事件分为三类:

    • 读事件(readfds)
    • 写事件(writefds)
    • 异常事件(exceptfds)

    如0号文件描述符是标准输入,我们只需要关心它的读事件,就不需要在writefds中添加

    fd_set:

    fd_set是一个有1024比特位的位图

    调用前,我们通过FD_SET()fd_set变量添加文件描述符,FD_SET(3, &set);`即表示将set的第3个二进制位设置为1;也就是说select最多只能对1024个文件描述符进行管理

    调用时,我们通过FD_SET()向位图中添加文件描述符,告诉了操作系统要监视那些文件描述符

    调用结束时,操作系统会再通过这三个参数返回已经就绪的那些df,通过使用FD_ISSET(),我们可以逐个测试哪些位被设置过,从而得知哪些fd已就绪

    注意事项:

    因为是输入输出型参数,每调用一次select,设置的set都会被覆盖

    所以用户需要自己维护一个表,记录当前在对哪些文件描述符进行管理

    每次轮询调用select()之前,都需要对set进行FD_ZERO()初始化,再FD_SET()重新添加文件描述符

  • timeout:超时退出时间

    select()可以是阻塞式的,只要没有fd就绪,就一直阻塞;

    也可以设置阻塞的时间,时间一到,即使没有fd就绪,select()依然返回

    这个阻塞的时间就是通过timeout设置的

    如果退出时间设置为0,就是非阻塞式的

    如果传入空指针,就是阻塞式的

    struct timeval

    struct timeval {
       time_t      tv_sec;     /* seconds */
       suseconds_t tv_usec;    /* microseconds */
    };
    
    • tv_sec:以秒为单位
    • tv_usec:以毫秒为单位
  • 返回值:

    • > 0:就绪的文件描述符的数量
    • = 0:设置了timeout,且没有文件描述符就绪
    • < 0:调用出现异常,errno被设置

demo

接下来我们写一段demo代码,形象的理解一下select的实际使用方法:

在先前TCP网络通讯的过程中,我们对一个监听套接字进行accept()的过程也可以看作是IO事件

也可以分为两个过程:

  • 等待远端发来连接请求建立连接
  • 将建立好的连接(套接字和对端的网络信息)拿到本地来

这样的一个获取连接的事件可以看作是一个读事件

于是,我们便可以将监听套接字作为一个读事件交给select,一旦对端发起了连接,经过三次握手,本地完成了连接,此时,select便会返回告知用户读事件就绪

如下demo,我们只关心读事件,具体关心写事件和异常事件的案例,在epoll再进行讲解

为了方便网络相关接口的调用,我们进行一个封装

Sock.hpp

#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <stdlib.h>
#include <string>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
using namespace std;
class Sock
{
    const static int gbacklog = 10;

public:
    // 创建监听套接字
    static int Socket()
    {
        int sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (sockfd < 0)
        {
            cerr << "[" << errno << "]: " << strerror(errno) << endl;
            exit(1);
        }
        // 让服务器崩掉后立马可以重启此端口
        int opt = 1;
        setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
        return sockfd;
    }
    static void Bind(int sockfd, uint16_t port)
    {
        sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = htons(INADDR_ANY);
        local.sin_port = htons(port);
        if (bind(sockfd, (sockaddr *)&local, sizeof(local)) < 0)
        {
            cerr << "[" << errno << "]: " << strerror(errno) << endl;
            exit(2);
        }
    }
    static void Listen(int sockfd)
    {
        if (listen(sockfd, gbacklog) < 0)
        {
            cerr << "[" << errno << "]: " << strerror(errno) << endl;
            exit(3);
        }
    }
    static int Accept(int listenSock, std::string& clientIp, uint16_t &clientPort)
    {
        struct sockaddr_in peer;
        socklen_t size = sizeof(peer);
        int serverSock = accept(listenSock, (sockaddr *)&peer, &size);
        if (serverSock < 0)
        {
            return -1;
        }
        clientIp = inet_ntoa(peer.sin_addr);
        clientPort = ntohs(peer.sin_port);
        return serverSock;
    }
};

接下来我们创建一个监听套接字,把它交给select,一旦远端发起了连接,我们就完成accept,并在本地打印一条消息

#include "Sock.hpp"
#include <iostream>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
using namespace std;
static void usage(const std::string porc)
{
    std::cout << "Usage:\n\t" << porc << " port" << std::endl;
    std::cout << "example:\n\t" << porc << " 8080" << std::endl;
}
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        usage(argv[0]);
        exit(0);
    }
    // 创建监听套接字,完成绑定和监听
    int listenSock = Sock::Socket();
    Sock::Bind(listenSock, atoi(argv[1]));
    Sock::Listen(listenSock);

    while (true)
    {
        // select的事件位图是一个输入输出型参数,
        // 函数返回后,位图中原本的信息会被覆盖
        // 所以每次调用select前都要对位图进行重新设置
        // 为select提供的相关参数:
        int maxfd = listenSock;
        fd_set readfds;                  // 读事件的位图
        FD_ZERO(&readfds);               // 初始化位图
        FD_SET(listenSock, &readfds);    // 将监听套接字添加到位图
        struct timeval timeout = {5, 0}; // 设置阻塞时间为5秒

        // 我们将listenSocket交给select,
        // 底层连接建立完成了,再唤醒上层进行accept()
        int n = select(maxfd + 1, &readfds, nullptr, nullptr, &timeout);
        switch (n)
        {
        case 0: // 没有事件就绪
            /* code */
            cout << "time out...: " << (unsigned long)time(nullptr) << endl;
            break;
        case -1: // 出错
            cerr << "[" << errno << "]: " << strerror(errno) << endl;
            break;
        default:
            string IP;
            uint16_t port;
            Sock::Accept(listenSock, IP, port);
            cout << IP << ": " << port << " connected!" << endl;
            break;
        }
    }
}

当服务器启动后5秒,远端发起了连接,select返回完成1,我们再完成accept,建立连接

继续将listensock交给select

再过10秒后,又有一台主机发起了连接,再次成功建立连接

但实际应用中,一台服务器不仅需要和客户端完成连接,更重要的通过这个连接为远端提供服务

而提供服务的过程中,无疑需要借助accept返回的服务套接字读取远端发来的信息,经过处理后,再向远端发送消息

这里的读取发送同样也是IO操作,我们将这些文件描述符也放入位图,交给select

这里就出现一个问题,当有多个用户与服务器建立了连接,哪些套接字就绪、哪些用户退出套接字关闭了、下一次还要把哪些套接字放入位图…

所以,我们要独立维护一个文件描述符表,记录当前正在维护的文件描述符

#include "Sock.hpp"
#include <iostream>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
using namespace std;

// select的事件位图是一个输入输出型参数,
// 函数返回后,位图中原本的信息会被覆盖
// 所以外部要独自维护一个表,每次调用select前都要对位图进行重新设置
int fdsArray[sizeof(fd_set) * 8] = {0};            // 存储所有需要交给多路转接管理的文件描述符
int gnum = sizeof(fdsArray) / sizeof(fdsArray[0]); // 最多存储的个数
static const int DFL = -1;                         // 没有使用的位置设置为DLF,使用了即具体那个文件描述符

static void usage(const std::string porc)
{
    std::cout << "Usage:\n\t" << porc << " port" << std::endl;
    std::cout << "example:\n\t" << porc << " 8080" << std::endl;
}
void showArray(int *array, int num)
{
    cout << "当前监控的文件描述符# ";
    for (int i = 0; i < num; i++)
    {
        if (array[i] != DFL)
            cout << array[i] << " ";
    }
    cout << endl;
}
static void handleEvent(fd_set &readfds)
{
    if (FD_ISSET(fdsArray[0], &readfds)) // 处理监听套接字
    {
        string IP;
        uint16_t port;
        int sock = Sock::Accept(fdsArray[0], IP, port);
        // 从数组中找一个位置把文件描述符添加进入,y
        // 下一次调用select时会被添加到位图中
        int i = 1;
        for (; i < gnum; i++)
        {
            if (DFL == fdsArray[i])
            {
                fdsArray[i] = sock;
                showArray(fdsArray, gnum);
                break;
            }
        }
        if (i == gnum)
        {
            cerr << "服务器已达到上限,无法承载更多连接" << endl;
            close(sock);
        }
        cout << IP << ": " << port << " connected!" << endl;
    }
    for (int i = 1; i < gnum; i++) // 处理普通套接字的IO事件
    {
        if (DFL == fdsArray[i])
            continue;
        if (FD_ISSET(fdsArray[i], &readfds))
        {
            // 进行read/recv
            char buffer[1024];
            ssize_t s = recv(fdsArray[i], buffer, sizeof(buffer), 0);
            if (s > 0)
            {
                buffer[s] = '\0';
                cout << "client[" << fdsArray[i] << "]# " << buffer << endl;
            }
            else if (s == 0)
            {
                cout << "client[" << fdsArray[i] << "]quit" << endl;
                close(fdsArray[i]); // 关闭文件描述符
                fdsArray[i] = DFL;  // 去除数组中的文件描述符
            }
            else
            {
                cout << "client[" << fdsArray[i] << "]error" << endl;
                close(fdsArray[i]); // 关闭文件描述符
                fdsArray[i] = DFL;  // 去除数组中的文件描述符
            }//end else
        }//end if (FD_ISSET(fdsArray[i], &readfds))
    }//end for
}//end handleEvent
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        usage(argv[0]);
        exit(0);
    }
    // 创建监听套接字,完成绑定和监听
    int listenSock = Sock::Socket();
    Sock::Bind(listenSock, atoi(argv[1]));
    Sock::Listen(listenSock);

    for (int i = 0; i < gnum; i++)
    {
        fdsArray[i] = DFL;
    }
    fdsArray[0] = listenSock; // 将0号设置为监听套接字
    while (true)
    {
        // 为select提供的相关参数:
        int maxfd = DFL;
        fd_set readfds;                // 读事件的位图
        FD_ZERO(&readfds);             // 初始化位图
        for (int i = 0; i < gnum; i++) // 遍历fdsArray,添加描述符到位图
        {
            if (fdsArray[i] == DFL) // 非文件描述符
                continue;
            FD_SET(fdsArray[i], &readfds);
            if (fdsArray[i] > maxfd) // 遍历过程中找到最大的文件描述符,select用
            {
                maxfd = fdsArray[i];
            }
        }
        struct timeval timeout = {5, 0}; // 设置阻塞时间为5秒

        // 我们将listenSocket交给select,
        // 底层连接建立完成了,再唤醒上层进行accept()
        int n = select(maxfd + 1, &readfds, nullptr, nullptr, &timeout);
        switch (n)
        {
        case 0: // 没有事件就绪
            /* code */
            cout << "time out...: " << (unsigned long)time(nullptr) << endl;
            break;
        case -1: // 出错
            cerr << "[" << errno << "]: " << strerror(errno) << endl;
            break;
        default:
            // 有事件就绪,把位图传给处理函数
            handleEvent(readfds);
            break;
        }//end switch
    }//end while
}//end main

多路转接之poll

函数原型

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

作为一个多路转接的接口,poll同样也要传入文件描述符、每个文件描述符需要关心的事件,文件描述符的数量、阻塞事件,只不过这里传入的方式略有不同

  • fds:一个结构体数组,每个元素代表一个文件描述符

    struct pollfd:

    struct pollfd {
        int   fd;         /* file descriptor */
        short events;     /* requested events */
        short revents;    /* returned events */
    };
    
    • fd:文件描述符
    • events:一个位图,记录当前文件描述符需要关心的事件,作为传入值
    • revents:同样是一个位图,函数返回后该文件描述符就绪的哪些事件会被记录到这个文件描述符

    所有的事件:

    因为poll的传入和传出用了两个位图,并不会出现select那样数据被覆盖的现象,自然也不需要用户自己再额外维护一个数组;

    同时poll的一个个文件描述符是存在数组中的,所以也不会受到位图那样的限制

  • nfds:nfd的元素个数

  • timeout:阻塞时间,以毫秒为单位

  • 返回值:就绪的文件描述符个数

demo

我们把上面的select代码改为poll版本

同样,poll的demo我们也只关心读事件

#include "Sock.hpp"
#include <iostream>
#include <poll.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
using namespace std;
static const int DFL = -1;
static const int MAXNUM = 1024;
struct pollfd fds[MAXNUM];
static void usage(const std::string porc)
{
    std::cout << "Usage:\n\t" << porc << " port" << std::endl;
    std::cout << "example:\n\t" << porc << " 8080" << std::endl;
}
void showArray()
{
    cout << "当前监控的文件描述符# ";
    for (int i = 0; i < MAXNUM; i++)
    {
        if (fds[i].fd != DFL)
            cout << fds[i].fd << " ";
    }
    cout << endl;
}
static void handleEvent()
{
    if (fds[0].revents & POLLIN) // 处理监听套接字
    {
        string IP;
        uint16_t port;
        int sock = Sock::Accept(fds[0].fd, IP, port);
        // 从数组中找一个位置把文件描述符添加进入,
        int i = 1;
        for (; i < MAXNUM; i++)
        {
            if (DFL == fds[i].fd)
            {
                fds[i].fd = sock;
                fds[i].events = POLLIN;
                fds[i].revents = 0;
                showArray();
                break;
            }
        }
        if (i == MAXNUM)
        {
            cerr << "服务器已达到上限,无法承载更多连接" << endl;
            close(sock);
        }
        cout << IP << ": " << port << " connected!" << endl;
    }
    for (int i = 1; i < MAXNUM; i++) // 处理普通套接字的IO事件
    {
        if (DFL == fds[i].fd)
            continue;
        if (fds[i].revents & POLLIN)
        {
            // 进行read/recv
            char buffer[1024];
            ssize_t s = recv(fds[i].fd, buffer, sizeof(buffer), 0);
            if (s > 0)
            {
                buffer[s] = '\0';
                cout << "client[" << fds[i].fd << "]# " << buffer << endl;
            }
            else if (s == 0)
            {
                cout << "client[" << fds[i].fd << "]quit" << endl;
                close(fds[i].fd);  // 关闭文件描述符
                fds[i].fd = DFL;   // 去除数组中的文件描述符
                fds[i].events = 0; // 事件清空
            }
            else
            {
                cout << "client[" << fds[i].fd << "]error" << endl;
                close(fds[i].fd);  // 关闭文件描述符
                fds[i].fd = DFL;   // 去除数组中的文件描述符
                fds[i].events = 0; // 事件清空
            }
        }
    }
}
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        usage(argv[0]);
        exit(0);
    }
    // 创建监听套接字,完成绑定和监听
    int listenSock = Sock::Socket();
    Sock::Bind(listenSock, atoi(argv[1]));
    Sock::Listen(listenSock);
    for (int i = 0; i < MAXNUM; i++) // 初始化poll数组
    {
        fds[i].fd = DFL;
        fds[i].events = 0;
        fds[i].revents = 0;
    }
    fds[0].fd = listenSock; // 将0号设置为监听套接字
    fds[0].events = POLLIN; // 关心读事件
    while (true)
    {
        int n = poll(fds, MAXNUM, 1000);
        switch (n)
        {
        case 0: // 没有事件就绪
            /* code */
            cout << "time out...: " << (unsigned long)time(nullptr) << endl;
            break;
        case -1: // 出错
            cerr << "[" << errno << "]: " << strerror(errno) << endl;
            break;
        default:
            // 有事件就绪,把位图传给处理函数
            handleEvent();
            cout << "handover" << endl;
            break;
        }
    }
}

多路转接之epoll

按照man手册的说法: 是为处理大批量句柄而作了改进的poll.

它是在2.5.44内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44)

它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法.

下面来到本篇的重中之重!!!!!!!!

函数原型

epoll有3个相关系统调用接口

#include <sys/epoll.h>

int epoll_create(int size);

创建一个epoll模型

  • size:自从linux2.6.8之后, size参数是被忽略的 ,但此值必须大于0

  • 返回值:调用成功返回一个文件描述符,调用失败返回-1,errno被设置

    当此句柄不再使用,需要对此文件描述符close

#include <sys/epoll.h>

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

向epoll模型中 增/删/改 文件描述符和关心的事件

​ ——用户告知内核需要关心哪些事件

  • epfd:上面epoll_create()创造的epoll模型(返回的那个文件描述符)
  • 需要进行的操作:
    • EPOLL_CTL_ADD:向epfd添加文件描述符,及其事件
    • EPOLL_CTL_MOD:对epfd中某个fd所关心的事件进行修改
    • EPOLL_CTL_DEL:将某个fdepfd中删除
  • fd:进行操作的目标文件描述符
  • event:文件描述符的事件
#include <sys/epoll.h>

int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

等待事件就绪

​ ——内核告诉用户哪些事件已就绪

  • epfd:epoll模型
  • events:已经就绪的事件
  • maxevents:此次接收拿取的事件个数
  • 阻塞时间:-1表示阻塞式,0表示非阻塞式,>0 阻塞的毫秒数

事件结构体 :

typedef union epoll_data {
    void        *ptr;
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;

struct epoll_event {
    uint32_t     events;      /* Epoll events */
    epoll_data_t data;        /* User data variable */
};

其中uint32_t events是一个位图,可以添加的事件:

  • EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
  • EPOLLOUT : 表示对应的文件描述符可以写;
  • EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
  • EPOLLERR : 表示对应的文件描述符发生错误;
  • EPOLLHUP : 表示对应的文件描述符被挂断;
  • EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
  • EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里

原理

当我们的硬件有数据到来,会向CPU特定的针脚发送硬件中断 ,然后CPU会调用OS在启动是预设的中断函数(通过不同的中断编号调用中断向量表中不同中断函数),这个函数就负责将数据从外设拷贝的内核缓冲区中,假设这个函数是:

void kernel_copy(void* src,void* dst, callback_t func)
{
    //进行拷贝
    func();
}

调用方可以向这个拷贝函数提供回调函数,函数内部完成拷贝后,会自动调用这个回调函数

epoll会对特定的一个或多个fd设定一个属于epoll的回调函数,当fd缓冲区中有数据了,就会进行回调

这里我们要先提到epoll底层的两个数据结构:

  • epoll底层维护众多文件描述符及事件的数据结构是红黑树(为了加快查找)

    红黑树的每个节点一定会存一个文件描述符和它需要关心的事件

    struct rb_node
    {
       int fd;
       struct epoll_event *events;
       //...
    };
    

    我们调用epoll_ctl()时,就会对红黑树的节点进行增删改

  • 于此同时,epoll还要为我们维护一个数据结构:就绪队列

    这个队列的每个节点保存了文件描述符和它就绪的那些事件

    struct queue_node
    {
    int fd;
       struct epoll_event *revents;
    };
    

而那个回调函数就会完成如下任务:

  1. 获取就绪的fd

  2. 用fd从红黑树中找到对应节点,知道此fd关心什么事件

  3. 获取就绪的事件是什么

  4. 用fd和就绪事件构建queue_node节点

  5. 将节点放入就绪队列

  6. epoll_wait()停止阻塞

    我们把如上的红黑树、就绪队列、回调机制统称为epoll模型,将这一套模型进行打包即可交给文件系统的某个file_struct ,返回文件描述符即掌控着这个epoll模型,这就是epoll_create()做到事

当我们调用epoll_ctl()进行操作

  • EPOLL_CTL_ADD:向红黑树添加节点
  • EPOLL_CTL_MOD:在红黑树中查找节点并修改events
  • EPOLL_CTL_DEL:在红黑树中查找节点并删除节点

当底层数据就绪,epoll_wait()被回调函数唤醒,它就只需要将就内核中绪队列的文件描述符和事件拷给用户区的参数,返回即可

然而同一个就绪队列,下层回调函数要放入节点,上层epoll_wait()要拿取节点,两个线程访问临界空间就需要保证同步和互斥,所以这个就绪队列同时也是一个生产者消费者模型,当然,这些问题epoll已经给我们解决了

demo

log.hpp

#pragma once
#include <cassert>
#include <cstdarg>
#include <cstdio>
#include <ctime>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
// 日志等级
#define DEBUG 0
#define NOTICE 1
#define WARINING 2
#define FATAL 3

const char *log_leval[] = {"DEBUG", "NOTICE", "WARINING", "FATAL"};
void logMssage(int level, const char *format, ...)
{
    assert(level >= DEBUG);
    assert(level <= FATAL);
    const char *name = getenv("USER");
    char logInfo[1024];
    va_list ap;
    va_start(ap, format); // 让dp对应到可变部分(...)
    vsnprintf(logInfo, sizeof(logInfo) - 1, format, ap);
    va_end(ap); // ap = NULL
    printf("%s | %u | %s | %s\n", log_leval[level],
            (unsigned)time(NULL), name == NULL ? "nukonw" : name,
            logInfo);
}

epollServer.hpp

#include "Sock.hpp"
#include "log.hpp"
#include "memory"
#include <functional>
#include <sys/epoll.h>
class EpollServer
{
private:
    static const int num = 256;
    using func_t = function<int(int)>;

private:
    int listenSock_; // 监听套接字
    int epfd_;       // epoll模型
    uint16_t port_;  // 服务器端口号
    func_t func_;

public:
    EpollServer(uint16_t port, func_t func)
        : listenSock_(-1),
          epfd_(-1),
          port_(port),
          func_(func) {}
    void InitEpollServer()
    {
        // 创建listenSock
        listenSock_ = Sock::Socket();

        Sock::Bind(listenSock_, port_);
        Sock::Listen(listenSock_);
        logMssage(DEBUG, "创建监听套接字成功: %d", listenSock_);

        // 创建epoll套接字
        epfd_ = epoll_create(256);
        if (epfd_ < 0)
        {
            logMssage(FATAL, "[%d]:%s", errno, strerror(errno));
            exit(4);
        }
        logMssage(DEBUG, "创建epoll成功: %d", epfd_);
    }
    void Run()
    {
        // 将epoll添加到epoll中去
        struct epoll_event event;
        event.events = EPOLLIN;
        event.data.fd = listenSock_;
        int n = epoll_ctl(epfd_, EPOLL_CTL_ADD, listenSock_, &event);
        assert(n == 0);
        while (true)
        {
            epoll_event revs[num];
            int n = epoll_wait(epfd_, revs, num, 1000);
            // 由于事件是被一个挨一个从就绪队列放到数组中去的,
            // 所以n表示就绪的文件描述符的个数,也表示需要处理的数组长度
            switch (n)
            {
            case 0: // 没有事件就绪
                /* code */
                logMssage(DEBUG, "time out...: %d", (unsigned long)time(nullptr));

                break;
            case -1: // 出错
                logMssage(FATAL, "[%d]:%s", errno, strerror(errno));
                break;
            default:
                // 有事件就绪,把位图传给处理函数
                handleEvent(revs, n);
                break;
            }
        }
    }

private:
    void handleEvent(epoll_event revents[], int num)
    {
        for (int i = 0; i < num; i++) // 处理普通套接字的IO事件
        {
            int sock = revents[i].data.fd;
            if (sock == listenSock_) // 处理监听套接字
            {
                string IP;
                uint16_t port;
                int sock = Sock::Accept(listenSock_, IP, port);
                if (sock < 0)
                {
                    logMssage(FATAL, "[%d]:%s", errno, strerror(errno));
                    continue;
                }
                struct epoll_event event;
                event.events = EPOLLIN;
                event.data.fd = sock;
                int n = epoll_ctl(epfd_, EPOLL_CTL_ADD, sock, &event);
                assert(n == 0);
                logMssage(DEBUG, "%s: %d connected!", IP.c_str(), port);
            }
            else if (revents[i].events & EPOLLIN)
            {
                int  s = func_(sock);
                if (s <= 0)
                {
                    int x = epoll_ctl(epfd_, EPOLL_CTL_DEL, sock, nullptr); // epoll无需再监管
                    close(sock);                                            // 关闭文件描述符,注意必须先从epoll去除再close
                    assert(x == 0);
                    logMssage(DEBUG, "client[%d]quit", sock);
                }
            }
        }
    }
};

main.cc

#include "epollServer.hpp"
static void usage(const std::string porc)
{
    std::cout << "Usage:\n\t" << porc << " port" << std::endl;
    std::cout << "example:\n\t" << porc << " 8080" << std::endl;
}
int handleReadEvent(int sock)
{
    // 进行read/recv
    char buffer[1024];
    ssize_t s = recv(sock, buffer, sizeof(buffer), 0);
    if (s > 0)
    {
        buffer[s] = '\0';
        cout << "client[" << sock << "]# " << buffer << endl;
    }
    return s;
}
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        usage(argv[0]);
        exit(0);
    }
    EpollServer epServer(atoi(argv[1]), handleReadEvent);
    epServer.InitEpollServer();
    epServer.Run();
}

epoll的工作方式

epoll有两种工作方式:

  • 水平触发(LT)(Level Triggered)
  • 边缘触发(ET)(Edge Triggered)

假设这样一种情况:

我们把tcp socket交给epoll

此时对端发来2KB数据

epoll_wait处的等待被唤醒,开始执行read,拿取缓冲区中的数据

但是上层并不知道有多少数据过来,一次只拿取的1KB数据

然后又继续调用 epoll_wait…

此时不同的工作模式就是又不同的效果

LT模式

如果设置了LT模式,或者就是默认的LT模式

由于缓冲区的数据没有拿完,下一次调用 epoll_wait,此事件依然是就绪状态的

即,只要缓冲区中有数据,此事件永远会处于就绪状态

ET模式

ET模式称为边缘触发,也就是只有缓冲区的数据有变化(从无到有,变得更多)时才会触发唤醒 epoll_wait

如果我们一次没有读完就直接调用 epoll_wait,此事件并不会提示就绪, epoll_wait也就不会返回

直到有新的数据发来,缓冲区的数据变多,此事件才会就绪

所以就倒逼程序员一次将缓冲区所有数据一次性读完

我们可以 试探性的循环对缓冲区读取,直到没有数据,read()返回0

但是如果使用默认的阻塞式,最后一次读取会阻塞住,这对于一个多路转接的程序是致命的

所有,在ET模式下,所有的socket一定要设定为非阻塞式。

对比LT和TE

LT是 epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完.

相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.另一方面, ET 的代码复杂程度更高了

基于epoll的Reactor模式服务器编写

至于什么是Reactor模式,我们写完服务器代码,结合案例再总结

辅助工具集

sock和epoll的封装

由于网络套接字和epoll的系统调用接口使用相对较为繁琐,所以我们用一种面向过程的方式对这些接口进行封装

Sock.hpp

#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <stdlib.h>
#include <string>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
using namespace std;
class Sock
{
    const static int gbacklog = 10;

public:
    // 创建监听套接字
    static int Socket()
    {
        int sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (sockfd < 0)
        {
            cerr << "[" << errno << "]: " << strerror(errno) << endl;
            exit(1);
        }
        // 让服务器崩掉后立马可以重启此端口
        int opt = 1;
        setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
        return sockfd;
    }
    //绑定
    static void Bind(int sockfd, uint16_t port)
    {
        sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = htons(INADDR_ANY);
        local.sin_port = htons(port);
        if (bind(sockfd, (sockaddr *)&local, sizeof(local)) < 0)
        {
            cerr << "[" << errno << "]: " << strerror(errno) << endl;
            exit(2);
        }
    }
    //监听
    static void Listen(int sockfd)
    {
        if (listen(sockfd, gbacklog) < 0)
        {
            cerr << "[" << errno << "]: " << strerror(errno) << endl;
            exit(3);
        }
    }
    //接收消息
    static int Accept(int listenSock, std::string &clientIp, uint16_t &clientPort)
    {
        struct sockaddr_in peer;
        socklen_t size = sizeof(peer);
        int serverSock = accept(listenSock, (sockaddr *)&peer, &size);
        if (serverSock < 0)
        {
            return -1;
        }
        clientIp = inet_ntoa(peer.sin_addr);
        clientPort = ntohs(peer.sin_port);
        return serverSock;
    }
};

Epoller.hpp

#pragma once
#include "log.hpp"
#include <cerrno>
#include <cstring>
#include <iostream>
#include <stdlib.h>
#include <sys/epoll.h>
#include <unistd.h>
class Epoller
{
public:
    // 创建epoll套接字
    static int CreateEpoller()
    {
        int epfd = epoll_create(256);
        if (epfd < 0)
        {
            logMssage(FATAL, "epoll_create [%d]:%s", errno, strerror(errno));
            exit(4);
        }
        logMssage(DEBUG, "创建epoll成功: %d", epfd);
        return epfd;
    }
    //向epoll添加一个事件
    static bool AddEvent(int epfd, int sock, uint32_t event)
    {
        epoll_event ev;
        ev.events = event;
        ev.data.fd = sock;
        int n = epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);
        return n == 0;
    }
    //修改epoll中的事件
    static bool ModEvent(int epfd, int sock, int event)
    {
        epoll_event ev;
        ev.events = event;
        ev.data.fd = sock;
        int n = epoll_ctl(epfd, EPOLL_CTL_MOD, sock, &ev);
        return n == 0;
    }
    //删除一个事件
    static bool DelEvent(int epfd, int sock)
    {
        int n = epoll_ctl(epfd, EPOLL_CTL_DEL, sock, nullptr);
        return n == 0;
    }
    //等待事件就绪
    static int LoopOnce(int epfd, epoll_event revs[], int num)
    {
        int n = epoll_wait(epfd, revs, num, -1);
        if (n == -1)
        {
            logMssage(FATAL, "epoll_wait [%d]:%s", errno, strerror(errno));
        }
        return n;
    }
};

应用层协议

Proctocol.hpp

#pragma once

#include <iostream>
#include <string>
#include <vector>

static const char SEP = 'X';
static const int SEP_LEN = sizeof(SEP);

void PackageSplit(std::string &inbuffer, std::vector<std::string> &result)
{
    while (true)
    {
        std::size_t pos = inbuffer.find(SEP);
        if (pos == std::string::npos)
        {
            break;
        }
        result.push_back(inbuffer.substr(0, pos));
        inbuffer.erase(0, pos + SEP_LEN);
    }
}

日志打印

log.hpp

#pragma once
#include <cassert>
#include <cstdarg>
#include <cstdio>
#include <ctime>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
// 日志等级
#define DEBUG 0
#define NOTICE 1
#define WARINING 2
#define FATAL 3
const char *log_leval[] = {"DEBUG", "NOTICE", "WARINING", "FATAL"};
void logMssage(int level, const char *format, ...)
{
    assert(level >= DEBUG);
    assert(level <= FATAL);
    const char *name = getenv("USER");
    char logInfo[1024];
    va_list ap;
    va_start(ap, format); // 让dp对应到可变部分(...)
    vsnprintf(logInfo, sizeof(logInfo) - 1, format, ap);
    va_end(ap); // ap = NULL

    fprintf(stdout, "%s | %u | %s | %s\n", log_leval[level],
            (unsigned)time(NULL), name == NULL ? "nukonw" : name,
            logInfo);
}

其它工具

Util.hpp

#pragma once
#include <fcntl.h>
#include <iostream>
#include <string>
#include <unistd.h>

class Util
{
public:
    //将文件描述符设置为非阻塞
    static void SetNonBlock(int fd)
    {
        int fl = fcntl(fd, F_GETFL);
        fcntl(fd, F_SETFL, fl | O_NONBLOCK);
    }
};

主要代码

TcpServer.hpp

#pragma once
#include "Epoller.hpp"
#include "Proctocol.hpp"
#include "Sock.hpp"
#include "Util.hpp"
#include "log.hpp"
#include <functional>
#include <iostream>
#include <list>
#include <string>
#include <unordered_map>
#include <vector>
// 基于Reactor模式,编写一个充分读取和写入的epoll(ET)server
class TcpServer;
class Connection;
using func_t = std::function<int(Connection *)>;
using tcp_callback_t = std::function<int(Connection *, std::string &)>;
class Connection
{
public:
    // 连接的服务套接字
    int sock_;
    // 当前连接的输入输出缓冲区
    std::string inBuffer;
    std::string outBuffer;
    // 连接对应的处理读、写、错误的方法
    func_t recver_;
    func_t sender_;
    func_t excepter_;
    // 方便外部函数可以通过一个Connection对象回指到TcpServer
    TcpServer *R_;

public:
    Connection(int sock, TcpServer *r)
        : sock_(sock),
          R_(r) {}
    // 设置读方法
    void setRecver(func_t recver) { recver_ = recver; }
    // 设置写方法
    void setSender(func_t sender) { sender_ = sender; }
    // 设置处理异常方法
    void setExcepter(func_t excepter) { excepter_ = excepter; }
};

class TcpServer
{
private:
    // 1.网络socket
    int listenSock_;
    // 2.epoll
    int epfd_;
    // 3.就绪事件列表
    epoll_event *revs_ = nullptr;
    static const int revs_num = 64;
    // 4.每个文件描述符都能映射到自己的Connection对象
    //   其中有这个文件描述符的读写缓冲区和处理事件方法
    unordered_map<int, Connection *> connections_;
    //处理事件的方法
    tcp_callback_t cb_;

public:
    TcpServer(int port, tcp_callback_t cb)
        : cb_(cb)
    {
        // 网络套接字
        listenSock_ = Sock::Socket();
        Sock::Bind(listenSock_, port);
        Sock::Listen(listenSock_);
        // 多路转接
        epfd_ = Epoller::CreateEpoller();
        // 初始化就绪列表
        revs_ = new epoll_event[revs_num];
        // 将监听套接字添加到epoll和connections
        AddConnection(listenSock_, EPOLLIN | EPOLLET,
                      std::bind(&TcpServer::Accepter, this, placeholders::_1));
    }
    void Run()
    {
        while (true)
        {
            Dispatcher();
        }
    }
    ~TcpServer()
    {
        if (listenSock_ != -1)
        {
            close(listenSock_);
        }
        if (epfd_ != -1)
        {
            close(epfd_);
        }
        if (revs_ != nullptr)
        {
            delete[] revs_;
        }
    }

private:
    friend int HandleRquest(Connection *conn, std::string &messages);
    void Dispatcher() // 事件派发
    {
        int n = Epoller::LoopOnce(epfd_, revs_, revs_num);
        for (int i = 0; i < n; i++)
        {
            int sock = revs_[i].data.fd;
            uint32_t revent = revs_[i].events;
            // 确保这个sock已经被放到过connections_
            auto conit = connections_.find(sock);
            if (conit == connections_.end()) // 没找到
            {
                logMssage(WARINING, "sock[%d] is not set in connections_", sock);
                continue;
            }
            // 通过connection类处理各种事件
            Connection *conn = conit->second;
            if (revent & EPOLLHUP || revent & EPOLLERR) // 对方断开连接,发生错误
            {
                // 把事件交给EPOLLIN | EPOLLOUT,recv,write可以进行判断
                // recv,write可以再进行判断,检测到出错会调用excepter
                revent |= EPOLLIN | EPOLLOUT;
            }
            if (revent & EPOLLIN)
            {
                if (conn->recver_) // 判断此方法是否已经被设置
                {
                    conn->recver_(conn); // 调用读回调
                }
            }
            if (revent & EPOLLOUT)
            {
                if (conn->sender_) // 写回调已被定义
                {
                    conn->sender_(conn);
                }
            }
        }
    }
    // 添加一个连接
    void AddConnection(int sockfd, uint32_t event,
                       func_t recver = func_t(), 
                       func_t sender = func_t(), 
                       func_t excepter = func_t())
    {
        if (event & EPOLLET)
        {
            // 将新创建的文件描述符设置为非阻塞
            Util::SetNonBlock(sockfd);
        }
        // 添加Sock到epoll
        Epoller::AddEvent(epfd_, sockfd, event);
        // 为此事件进创建并映射Connection
        Connection *conn = new Connection(sockfd, this);
        connections_[sockfd] = conn;
        // 设置方法
        conn->setRecver(recver);
        conn->setSender(sender);
        conn->setExcepter(excepter);
    }
    // 为listensock获取连接
    int Accepter(Connection *conn)
    {
        // listensock也是工作在ET的,有可能来一批事件,所以要循环读取
        while (true)
        {
            std::string clientIp;
            uint16_t clientPort = 0;
            int sockfd = Sock::Accept(conn->sock_, clientIp, clientPort);
            if (sockfd < 0)
            {
                if (errno == EINTR) // 被信号中断
                {
                    continue;
                }
                else if (errno == EAGAIN || errno == EWOULDBLOCK) // 读完了
                {
                    break;
                }
                else // 出错了
                {
                    logMssage(WARINING, "accept error");
                    return -1;
                }
            }
            logMssage(DEBUG, "get a new link success[%d]", sockfd);
            AddConnection(sockfd, EPOLLIN | EPOLLET,
                          std::bind(&TcpServer::TcpRecver, this, placeholders::_1),
                          std::bind(&TcpServer::TcpSender, this, placeholders::_1),
                          std::bind(&TcpServer::TcpExcepter, this, placeholders::_1));
        }
        return 0;
    }
    int TcpRecver(Connection *conn)
    {
        char buffer[1024];
        while (true)
        {
            ssize_t s = recv(conn->sock_, buffer, 1024, 0);
            if (s > 0)
            {
                buffer[s] = '\0';
                conn->inBuffer += buffer;
                // logMssage(DEBUG, "client[%d]# %s", conn->sock_, buffer);
            }
            else if (s == 0) // 对端退出
            {
                conn->excepter_(conn);
                logMssage(DEBUG, "client[%d] quit", conn->sock_);
                break;
            }
            else
            {
                if (errno == EAGAIN || errno == EWOULDBLOCK)
                {
                    // 读完了
                    break;
                }
                else if (errno == EINTR)
                {
                    // 被信号中断了,继续读
                    continue;
                }
                else
                {
                    // 出错了
                    logMssage(DEBUG, "recv error[%d]:%s", errno, strerror(errno));
                    conn->excepter_(conn);
                    break;
                }
            }
            // 已经将tcp缓冲区的所有的数据拿到了Connection的缓冲区
            // 现在再将Connection中的数据按照协议拆分并一块块拿出
            // 不符合完整协议的留在Connection中,等下一次发来数据再组合
            std::vector<std::string> result;
            PackageSplit(conn->inBuffer, result);
            for (auto message : result)
            {
                cb_(conn, message);
            }
        }
        return 0;
    }
    int TcpSender(Connection *conn)
    {
        // 写事件要求底层有空间
        // 但是最开始的时候写空间一定时就绪的,所以最开始只关心读事件
        // 写的时候
        // 1. 对于LT模式,如果直接写,可能因为没有空间,然后write就阻塞了,
        //    所以写之前一定要先打开对写事件的关心,如果此时底层有空间,epoll会自动进行写事件派发,然后才写入
        // 2. 对于ET模式,因为要求是非阻塞式的套接字,也可以采用上面的方法,不过为了追求高效,一般直接发送,
        //    如果发送失败,再打开写事件关心,下一次再继续将outbuffer中数据进行写入
        // 注意:读事件只有需要的时候才打开
        while (true)
        {
            size_t n = send(conn->sock_, conn->outBuffer.c_str(), conn->outBuffer.size(), 0);
            if (n > 0) // 返回值:已经发送了的数据
            {
                // 从connection缓冲区去除已经发送的数据
                conn->outBuffer.erase(0, n);
            }
            else
            {
                if (errno == EINTR)
                    continue;
                else if (errno == EAGAIN || errno == EWOULDBLOCK)
                {
                    // 可能是outbuffer没有数据了,传的size是0
                    // 也可能是内核缓冲区满了,但是outbuffer还有数据,就需要再打开写事件
                    break;
                }
                else
                {
                    conn->excepter_(conn);
                    logMssage(WARINING, "send error[%d]:%s", errno, strerror(errno));
                    break;
                }
            }
            if (conn->outBuffer.empty()) // 写完了
            {
                Epoller::EnableReadWrite(conn->sock_, epfd_, true, false);
            }
            else // 还有数据
            {
                Epoller::EnableReadWrite(conn->sock_, epfd_, true, true);
            }
        }
        return 0;
    }
    int TcpExcepter(Connection *conn)
    {
        // 一定要先从epoll移除再关闭文件描述符
        Epoller::DelEvent(epfd_, conn->sock_);
        close(conn->sock_);
        int sock = conn->sock_;
        delete conn;              // 释放connection对象
        connections_.erase(sock); // 从map中删除
        return 0;
    }
    static void EnableReadWrite(int sock, int epfd, bool readable, bool writeable)
    {
        int event = 0;
        if (readable)
        {
            event |= EPOLLIN;
        }
        if (writeable)
        {
            event |= EPOLLOUT;
        }
        ModEvent(epfd, sock, event);
    }
};

main.cc

#include "TcpServer.hpp"
static void usage(const std::string porc)
{
    std::cout << "Usage:\n\t" << porc << " port" << std::endl;
    std::cout << "example:\n\t" << porc << " 8080" << std::endl;
}
int HandleRquest(Connection *conn, std::string &messages)
{
    std::cout << messages << endl;
    // 处理业务逻辑
    string sendstr = "业务处理完成\n";
    conn->outBuffer += sendstr;
    conn->sender_(conn);
    return 0;
}
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        usage(argv[0]);
        exit(0);
    }
    TcpServer server(atoi(argv[1]), HandleRquest);
    server.Run();
}

运行测试:

服务器端:

image-20230331221723324

客户端:

image-20230331221743507

结构分析

class TcpServer
{
private:
    // 1.网络socket
    int listenSock_;
    // 2.epoll
    int epfd_;
    // 3.就绪事件列表
    epoll_event *revs_ = nullptr;
    static const int revs_num = 64;
    // 4.每个文件描述符都能映射到自己的Connection对象
    //   其中有这个文件描述符的读写缓冲区和处理事件方法
    unordered_map<int, Connection *> connections_;
};
  • 作为一个Tcp网络服务器,首先要有一个监听套接字,从而与远端建立连接

  • 我们使用epoll多路转接的方式,让我们的服务器能够同时为多个用户提供服务

  • 同时为其维护一个就绪事件列表,当epoll检测到有事件就绪,就会把哪些就绪的事件放入此列表,我们也从个列表中拿取事件从而进行处理

  • 我们将一个个事件或者说连接(如:与用户建立的连接、监听事件)描述为一个Connection

    用一个map把他们组织起来,通过sock套接字能够很快索引到一个Connection(因为epoll就绪会提供事件的sock)

    using func_t = std::function<int(Connection *)>;
    class Connection
    {
    public:
        // 连接的服务套接字
        int sock_;
        // 当前连接的输入输出缓冲区
        std::string inBuffer;
        std::string outBuffer;
        // 连接对应的处理读、写、错误的方法
        func_t recver_;
        func_t sender_;
        func_t excepter_;
        // 方便外部函数可以通过一个Connection对象回指到TcpServer
        TcpServer *R_;
    };
    
    • 这个类中一定要有能够标定此连接的文件描述符,如:监听套接字、与用户建立连接后形成的服务套接字……
    • 其次还要有读写缓冲区,需要写入sock的消息或者从sock中读出的消息都会先放到读写缓冲区中
    • 然后还要提供专属于此事件的处理读写事件和异常事件的方法(一些回调函数),当epoll检测到某种事件就绪,就会调用对应的方法,处理该事件,处理事件的时候往往还需要访问此Connection的读写缓冲区,所以还需要在回调函数参数设定一个Connection*的指针能够访问此Connection的输入输出缓冲区
    • 那些回调函数可能还需要调用TcpServer的一些代码逻辑,所以设定一个TcpServer *成员变量进行回指,从而通过一个Connection,访问到TcpServer

执行逻辑

TcpServer初始化过程分析

在main函数中我们传入端口号,创建一个TcpServer

TcpServer的构造函数中会:

  • 创建一个监听套接字,用于接收连接
  • 创建一个epoll模型,并初始化它的就绪列表
  • 将监听套接字添加到epollconnection集

在前面结构分析中,我们说每个要放入epoll的事件或者说连接都需要匹配一个Connection,通过这个Connection对事件进行处理

我们把

  • 将事件放入epoll、
  • 创建Connection、
  • 设置处理事件的方法,
  • 将Connection传入TcpServer的索引表

这一系列行为封装为一个函数

void AddConnection(int sockfd, uint32_t event,
                  func_t recver = func_t(),
                  func_t sender = func_t(),
                  func_t excepter = func_t())
{
   if (event & EPOLLET)
   {
       // 将设置了边缘触发的文件描述符设置为非阻塞
       Util::SetNonBlock(sockfd);
   }
   // 添加Sock到epoll
   Epoller::AddEvent(epfd_, sockfd, event);
   // 为此事件进创建并映射Connection
   Connection *conn = new Connection(sockfd, this);
   connections_[sockfd] = conn;
   // 设置方法
   conn->setRecver(recver);
   conn->setSender(sender);
   conn->setExcepter(excepter);
}

添加监听套接字时,

  • sockfd即传入listensocket
  • event传入EPOLLIN | EPOLLET,表示监听套接字只关心读事件并需要设置为边缘触发(我们当前的服务器都使用ET模式)
  • 三个事件处理方法中,只需要传入读事件的即可

监听套接字的读事件定义:

int Accepter(Connection *conn)
{
    // listensock也是工作在ET的,有可能来一批连接,所以要循环读取
    while (true)
    {
        std::string clientIp;
        uint16_t clientPort = 0;
        int sockfd = Sock::Accept(conn->sock_, clientIp, clientPort);
        if (sockfd < 0)
        {
            if (errno == EINTR) // 被信号中断
            {
                continue;
            }
            else if (errno == EAGAIN || errno == EWOULDBLOCK)//内核建立的连接获取完了
            {
                break;
            }
            else // 出错了
            {
                logMssage(WARINING, "accept error");
                return -1;
            }
        }
        logMssage(DEBUG, "get a new link success[%d]", sockfd);
        AddConnection(sockfd, EPOLLIN | EPOLLET,
                      std::bind(&TcpServer::TcpRecver, this, placeholders::_1),
                      std::bind(&TcpServer::TcpSender, this, placeholders::_1),
                      std::bind(&TcpServer::TcpExcepter, this, placeholders::_1));
    }
    return 0;
}

首先我们调用Sock的accept,将内核建立好的连接拿上来,获取到服务套接字

在accept过程中

  • 可能由于线程被外部用信号中断过(errno被设定为EINTR), 此时直接循环再调一遍accept即可
  • 可能内核就绪的连接全都获取完了(errno被设定为EAGAIN 或EWOULDBLOCK),结束循环返回即可
  • 再或者就是accept真的发生错误了,发送日志返回即可

如果没有发生上述问题,就代表拿取连接成功了,只需要把服务套接字传给AddConnection(),把新事件交给epoll,创建Connection,为此事件设置三种事件处理方式

服务sock的三种事件的处理方法定义:

//读事件处理
int TcpRecver(Connection *conn)
{
    char buffer[1024];
    while (true)
    {
        ssize_t s = recv(conn->sock_, buffer, 1024, 0);
        if (s > 0)
        {
            buffer[s] = '\0';
            conn->inBuffer += buffer;
            // logMssage(DEBUG, "client[%d]# %s", conn->sock_, buffer);
        }
        else if (s == 0) // 对端退出
        {
            conn->excepter_(conn);
            logMssage(DEBUG, "client[%d] quit", conn->sock_);
            break;
        }
        else
        {
            if (errno == EAGAIN || errno == EWOULDBLOCK)
            {
                // 读完了
                break;
            }
            else if (errno == EINTR)
            {
                // 被信号中断了,继续读
                continue;
            }
            else
            {
                // 出错了
                logMssage(DEBUG, "recv error[%d]:%s", errno, strerror(errno));
                conn->excepter_(conn);
                break;
            }
        }
        // 已经将tcp缓冲区的所有的数据拿到了Connection的缓冲区
        // 现在再将Connection中的数据按照协议拆分并一块块拿出
        // 不符合完整协议的留在Connection中,等下一次发来数据再组合
        std::vector<std::string> result;
        PackageSplit(conn->inBuffer, result);
        for (auto message : result)
        {
            cb_(conn, message);
        }
    }
    return 0;
}

//写事件处理
int TcpSender(Connection *conn)
{
    // 写事件要求底层有空间
    // 但是最开始的时候写空间一定时就绪的,所以最开始只关心读事件
    // 写的时候
    // 1. 对于LT模式,如果直接写,可能因为没有空间,然后write就阻塞了,
    //    所以写之前一定要先打开对写事件的关心,如果此时底层有空间,epoll会自动进行写事件派发,然后才写入
    // 2. 对于ET模式,因为要求是非阻塞式的套接字,也可以采用上面的方法,不过为了追求高效,一般直接发送,
    //    如果发送失败,再打开写事件关心,下一次再继续将outbuffer中数据进行写入
    // 注意:读事件只有需要的时候才打开
    while (true)
    {
        size_t n = send(conn->sock_, conn->outBuffer.c_str(), conn->outBuffer.size(), 0);
        if (n > 0) // 返回值:已经发送了的数据
        {
            // 从connection缓冲区去除已经发送的数据
            conn->outBuffer.erase(0, n);
        }
        else
        {
            if (errno == EINTR)
                continue;
            else if (errno == EAGAIN || errno == EWOULDBLOCK)
            {
                // 可能是outbuffer没有数据了,传的size是0
                // 也可能是内核缓冲区满了,但是outbuffer还有数据,就需要再打开写事件
                break;
            }
            else
            {
                conn->excepter_(conn);
                logMssage(WARINING, "send error[%d]:%s", errno, strerror(errno));
                break;
            }
        }
        if (conn->outBuffer.empty()) // 写完了
        {
            Epoller::EnableReadWrite(conn->sock_, epfd_, true, false);
        }
        else // 还有数据
        {
            Epoller::EnableReadWrite(conn->sock_, epfd_, true, true);
        }
    }
    return 0;
}

//异常事件处理
int TcpExcepter(Connection *conn)
{
    // 一定要先从epoll移除再关闭文件描述符
    Epoller::DelEvent(epfd_, conn->sock_);
    close(conn->sock_);
    int sock = conn->sock_;
    delete conn;              // 释放connection对象
    connections_.erase(sock); // 从map中删除
    return 0;
}

TcpServer运行过程分析

然后在main函数中继续调用server.Run();,让服务开始运行

运行逻辑就是死循环调用Dispatcher()进行事件派发

void Dispatcher() // 事件派发
{
    int n = Epoller::LoopOnce(epfd_, revs_, revs_num);
    for (int i = 0; i < n; i++)
    {
        int sock = revs_[i].data.fd;
        uint32_t revent = revs_[i].events;
        // 确保这个sock已经被放到过connections_
        auto conit = connections_.find(sock);
        if (conit == connections_.end()) // 没找到
        {
            logMssage(WARINING, "sock[%d] is not set in connections_", sock);
            continue;
        }
        // 通过connection类处理各种事件
        Connection *conn = conit->second;
        if (revent & EPOLLHUP || revent & EPOLLERR) // 对方断开连接,发生错误
        {
            // 把事件交给EPOLLIN | EPOLLOUT,recv,write可以进行判断
            // recv,write可以再进行判断,检测到出错会调用excepter
            revent |= EPOLLIN | EPOLLOUT;
        }
        if (revent & EPOLLIN)
        {
            if (conn->recver_) // 判断此方法是否已经被设置
            {
                conn->recver_(conn); // 调用读回调
            }
        }
        if (revent & EPOLLOUT)
        {
            if (conn->sender_) // 写回调已被定义
            {
                conn->sender_(conn);
            }
        }
    }
}

当第一次执行Dispatcher()

  • 首先会阻塞在epoll_wait(),因为此时epoll中只有一个监听事件,所以此时就是在等待有人发起连接

  • 一旦操作系统底层与远端进行完三次握手,完成连接,底层代码便会将listensock的读事件放到就绪列表,然后唤醒epoll_wait()的阻塞

  • 当我们遍历就绪列表,发现是listenSock的读事件就绪了,首先会拿这个文件描述符到connections_索引到监听套接字对应的Connection

  • 然后调用当初再此Connection中设置的的读事件处理方法:Accepter()

  • Accepter()中会循环调用sock::accept(),把底层所有的建立连接都拿取出来,同时将它们放入epoll,并创建Connection,在TcpServer的connections_创建从sock到Connection*的映射

此后再执行Dispatcher()就会有监听套接字和服务套接字两种事件出现,不过我们并不需要主动进行区分,因为不同种类的不同事件的处理方法,在创建事件之除就在Connection中写好了

如果是监听套接字就绪,就会自动调用上述逻辑,如果是服务套接字,又需要对三种事件分别进行处理:

  • 读事件处理会调用*TcpRecver()*

    • 首先循环将所有数据无脑全部到Connection的inBuffer缓冲区
    • 然后调用应用层定义好的协议,把报文一个一个从inBuffer缓冲区拿出,如果无法组成完整报文,就留在当前的inBuffer缓冲区中,等待下一次再接收到数据,追加到缓冲区后使报文补全
    • 然后把一个个拆分好的报文交给请求处理的函数进行处理,这个函数可以在TcpServer中设置一个回调函数,构造TcpServer的时候传入(即我们在main函数定义的*HandleRquest()*)
    • *HandleRquest()*通过传入的消息处理完业务逻辑后,将结果写入Connection的写缓冲区,再调用Connection中的写方法,将处理的结果发回给客户端
  • 写事件的处理会调用*TcpSender()*方法

    对于epoll来说,读事件和写事件实际有所差异

    写事件要求底层有空间

    ​ 但是最开始的时候写空间一定时就绪的,所以最开始只关心读事件

    ​ 写的时候

    ​ 1. 对于LT模式,如果直接写,可能因为没有空间,然后write就阻塞了,

    ​ 所以写之前一定要先打开对写事件的关心,如果此时底层有空间,epoll会自动进行写事件派发,然后才写入

    ​ 2. 对于ET模式,因为要求是非阻塞式的套接字,也可以采用上面的方法,不过为了追求高效,一般直接发送,

    ​ 如果发送失败,再打开写事件关心,下一次再继续将outbuffer中数据进行写入

    ​ 注意:读事件只有需要的时候才打开

    所以我们把缓冲区中的数据循环对sock进行写入,

    写入成功则继续写入,

    写入失败则有三种情况

    • 与读事件相同,也有可能被信号中断,continue再来一遍即可
    • 如果经过几次send,outBuffer缓冲区没有数据,或者对端一直没有把数据从读缓冲区读走,导致服务端无法发送,写缓冲区满了,此时再send则会失败,这种情况直接break即可
    • 如果send异常,可能是对端退出了,打印日志,调用异常事件处理,关闭此连接,返回即可

    当写入循环结束,

    • 有可能是所有数据都发走了,此时outBuffer为空,则需要把写事件关闭
    • 亦或者写缓冲区满了,无法发送,此时就打开epoll对此sock读事件的读关心,当写缓冲区的数据成功发出,有空间进行写入了,下次派发此sock的写事件会被写入就绪序列表,则可以继续进行写入
  • 异常事件的处理会调用*TcpExcepter()*

    当一个连接出现异常,我们直接将其关闭即可,相当于把创建连接的过程逆向执行一遍

    • 从epoll中移除文件描述符
    • 关闭文件描述符
    • 从索引集合中将此套接字移除
    • 释放connection的空间

总结何为Reactor

想我们上面的服务器

基于多路转接方案,当事件就绪的时候,采用回调的方式,进行业务处理的模式就称为反应堆模式(Reactor)

我们代码中的TcpServer就是一个反应堆,

其中一个个Connection对象就称为事件

每一个事件中都有

  • 文件描述符
  • 自己独立的缓冲区
  • 回调方法
  • 回指向反应堆的指针

反应堆中有一个事件派发函数,当epoll中的某个事件就绪,事件派发函数回调用此事件的回调函数

特性:

  • 单进程
  • 及负责事件派发,又负责IO
  • 半异步半同步:
    • 异步:事件到来是随机的
    • 同步:当前线程参与IO
  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值