一篇文章学会高级IO

18 篇文章 2 订阅

理解IO的本质

IO是数据在传输时的一种动作描述,分为输入数据(I)和输出数据(O)两种动作。和一般而言,IO都需要维护一个收发数据的缓冲区,例如read、recv函数和write、send函数,它们的数据缓冲区都是由系统帮助创建的。对于C语言中常用到的scanf函数和printf函数,同样不需要用户自己去维护缓冲区(scanf的接收缓冲区由C语言库维护)。但是,也正是程序员无法过度干涉缓冲区的原因,IO的细节并不会直接暴露程序员,默认的一套IO机制在某些场景下可能并不合适。

image-20230509112430466

例如:程序员编写一个单进程的服务程序,多个客户端的请求中,一旦有一个客户端的请求阻塞(接收数据的缓冲区没数据,读取数据的动作阻塞),会导致整个服务器阻塞住。在其他客户看来,就是服务器死机了,没办法正常响应请求了。

上面的IO模型就是阻塞式IO模型,在上面的情境中并不适合。当然还有很多种IO模型,下文会一一讲解。到现在为止,IO的本质清楚了:数据的拷贝与传输。那么基于它的特性以及应用场景,选择适合的IO模型就是我们程序员该做的事情了。

认识五种IO模型

总共有五种IO模型:

1.阻塞式IO

2.非阻塞式IO

3.信号驱动式IO

4.多路转接式IO

5.异步式IO

上面五种IO模型中,前两种是最基础的IO模型,后三者中又属多路转接最为常用。下面我们一一介绍。

阻塞式IO

基本上所有的套接字默认都是阻塞式IO。

在IO条件就绪之前,系统调用会一直等待,称之为阻塞。

image-20230509213101204

例如:

调用recv函数时,如果底层的数据没有就绪,此时程序不会再向下运行而是阻塞等待,直到数据就绪完成数据拷贝,才会继续向下执行。

非阻塞式IO

仍是以调用recv函数为例:

image-20230509215348906

非阻塞式IO在底层数据没有就绪的时候,并不会阻塞在recv函数处,而是会返回-1,同时也会设置errno这个全局变量为EWOULDBLOCK或者是EAGAIN,代表并非是读取错误,而是数据读取完了或者数据没就绪。

如何设置非阻塞式IO呢?–使用fcntl函数

image-20230509220140888

头文件:<unistd.h>、<fcntl.h>

参数

fd:文件描述符,也可以是套接字,本质一样。

cmd:函数功能参数,有多种选项,不同的选项决定了fcntl这个函数的功能。选项都是宏,本质就是整数。

image-20230509221649952

具体解释:

​ 1.复制一个现有的描述符(cmd=F_DUPFD)

​ 2.获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD)

​ 3.获得/设置文件状态标记(cmd=F_GETFL或F_SETFL)

​ 4.获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN)

​ 5.获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW)

返回值:根据不同的cmd返回对应的值。

以下是成功时的返回值

​ 1.cmd=F_DUPFD,返回一个新的描述符

​ 2.cmd=F_GETFD,返回文件描述符标记

​ 3.cmd=F_GETFL,返回文件状态标记

​ 4.cmd=F_GETOWN,返回异步I/O所有权

​ 5.cmd=F_GETLK,返回记录锁

其余的所有参数成功返回0,失败返回-1并设置errno。

设置一个文件描述符为非阻塞主要用到了第三种功能,代码如下:

void SetNonBlock(int fd)
{
    int fl = fcntl(fd, F_GETFL);		//获取文件描述符fd的标记状态,写进返回值f1中
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);//设置fd的状态:将原来的属性按位或上O_NONBLOCK这个宏,完成非阻塞性质的设置
}

信号驱动式IO

image-20230510104422386

用户程序在建立SIGIO的信号处理程序之后,就不再一直关心数据是否就绪了,程序可以继续执行后续代码,一旦内核将数据准备好了,就会发送SIGIO信号给用户进程,此时就会自动立即调用之前写好的回调函数。

多路转接式IO

多路转接IO的点是能够监听多个文件描述符,并且程序员可以在通过编码来控制是或否阻塞等待多个文件描述符。如果选择阻塞,那么就是在阻塞式IO的基础之上增加了监视多个文件描述符的功能。同理,非阻塞多路转接是在非阻塞式IO的基础之上增加了监视多个文件描述符的功能。并且相比于信号驱动式的立即调用回调函数进行数据的读写,多路转接可以获得哪些就绪的文件描述符的信息而不立即进行处理,自行决定什么时候处理,算是稍稍的解耦了。

下面以select函数实现的阻塞式多路转接读取数据为例作图:

image-20230510134012894

上面的阻塞等待过程中,只要有一个文件描述符的数据就绪,就会导致可读条件的设置,select函数就会返回。

异步式IO

内核在数据就绪后,完成对数据的拷贝并通知用户程序。(不同于信号驱动的通知数据就绪)

image-20230510210834303

高级IO的理解以及意义

根据上述五种IO模型,大致可以总结为:IO = 等待 + 拷贝数据,其中有些模型的等待并不是在用户程序中完成的,而是内核协助完成。高级IO就是花费更少的时间在等待这个动作上,等多的是关注拷贝这个动作。因此,非阻塞式IO、多路转接IO都可以算是高级IO,前者是直接跳过了等待的阶段转去执行其他动作,轮询检测IO条件是否就绪。而后者则是将多个文件描述符的等待时间重叠,变相的减少了等待时间。

高级IO一般应用于服务型程序中,大大缩减了因为阻塞而导致的性能浪费。

多路转接式IO的深入学习

多路转接的灵魂:监听多个文件描述符,并形成就绪任务队列。以单进程实现类似于多线程的操作,并不保证先到先服务。

多路转接主要有三种实现形式:select型、poll型、epoll型

这里在引入一个概念:ET和LT工作模式

LT工作模式:

LT(Level Triggered)又叫水平触发工作模式,是默认的多路转接工作模式。就以接收数据作为示例,一旦有数据就绪并且没有被一次性取完,那么在下次再次获取和检测哪些事件就绪时,上次没有被取完数据的依然被认为是事件就绪的。也就是说适用于阻塞式读取和非阻塞读取。

ET工作模式:

ET(Edge Triggered)又叫边缘触发工作模式,与LT不同,一旦有数据了,只要取数据,无论一次性是否取完,都会认为该就绪事件已经被处理完毕了,再次检测就绪事件,该事件就不被关心了。这也就倒逼着程序员选择非阻塞式循环一次性读取完所有的数据。

在接下来的三个模型中,将只会使用LT工作模式,而ET工作模式将只会在Reactor中尝试使用。

select模型

select模型依赖于select函数实现,观察select函数以及衍生出的一系列函数:

select函数详解

image-20230510221532708

其中我们先忽略pselect函数。

​ 理解数据类型fd_set和数据结构 struct timeval

​ 1.fd_set是一个位图结构的数据类型,源码如下:

image-20230510222155170

​ fd_set其实就是一个整形数组,数组大小可以用sizeof(fd_set)来计算,经测试是128字节大小,也就意味着位图最多可以表示 128*8=1024个 文件描述符的状态。比特位上为0表示条件不就绪,为1表示就绪,而比特位的下标对应着一个文件描述符。

​ 2.struct timeval结构体

image-20230510222843516

​ 这个结构体中的第一个成员变量表示秒数,第二个成员变量表示微秒数。加起来就是阻塞的时间,超时直接返回。

函数名:select

头文件:<sys/time.h>、<sys/types.h>、<unistd.h>

参数:

readfds:对读事件关心的位图,select函数返回时将设置其中的某些比特位,以表示某些文件描述符的读事件就绪。

writefds:对写事件关心的位图,select函数返回时将设置其中的某些比特位,以表示某些文件描述符的写事件就绪。

exceptfds:对异常事件关心的位图,select函数返回时将设置其中的某些比特位,以表示某些文件描述符的异常事件的发生。

timeout:等待的时间。设置为NULL,select函数会一直阻塞等待;若timeout->tv_sec=0并且timeout->tv_usec=0,则select为非阻塞式获取各个位图所关心的事件;若timeout->tv_sec!=0或者timeout->tv_usec!=0,则select函数会等待对应的时间。

nfds:所有所关系的文件描述符中的最大数+1

返回值:

-1:发生错误,设置errno

0:超时返回

n:三个返回的描述符集中包含的文件描述符数

先看一下具体的工作图解:

image-20230511162801191

封装网络套接字:Sock.hpp

由于会经常用到网络套接字的创建、绑定、监听之类的操作,这里就直接封装到一个Sock类里面了。

#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cerrno>
#include <cassert>
using namespace std;

class Sock
{
public:
    // 创建监听套接字
    static int Socket()
    {
        int listenSock = socket(PF_INET, SOCK_STREAM, 0);
        if (listenSock < 0)
        {
            exit(1);
        }
        int opt = 1;
        setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
        
        // SO_REUSEADDR选项允许在bind()调用中使用已经被其他socket占用的地址。
        // 这个选项通常用于在socket关闭后立即重新启动服务器程序,而无需等待之前的连接完全断开。
        // 如果没有设置这个选项,在socket关闭后,由于TCP协议的TIME_WAIT状态,
        // 操作系统会保留地址一段时间,阻止其他socket使用。使用SO_REUSEADDR选项可以避免这个问题。

        // SO_REUSEPORT选项允许多个socket绑定同一个IP地址和端口号。
        // 这个选项通常用于实现高可用性的服务器程序,其中多个服务器程序可以同时监听同一个端口,
        // 以便在主服务器宕机时,备用服务器能够立即接管服务。使用SO_REUSEPORT选项可以避免端口占用的问题。
        // 需要注意的是,这个选项只在某些操作系统上可用,例如Linux 3.9以上的内核版本。

        return listenSock;
    }
    // 填充网络信息,并绑定端口号
    static void Bind(int sock, uint16_t port)
    {
        struct sockaddr_in local;
        bzero((void *)&local, sizeof(local));
        local.sin_family = PF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;

        if (bind(sock, (const struct sockaddr *)&local, sizeof(local)) < 0)
        {
            exit(2);
        }
    }

    // 监听连接
    static void Listen(int listenSock)
    {
        if (listen(listenSock, 5) == -1)
        {
            exit(3);
        }
    }

    // 创建连接,并返回套接字
    static int Accept(int listenSock, string *clientIp, uint16_t *clientPort)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        //获取远端客户端网络信息
        int socket= accept(listenSock, (struct sockaddr *)&peer, &len);
        if (socket < 0)
        {
            return -1;
        }
        // 创建成功,返回客户端的IP和端口号
        if (clientPort)
            *clientPort = ntohs(peer.sin_port);
        if (clientIp)
            *clientIp = inet_ntoa(peer.sin_addr);
        return socket;
    }
};

selectServer.cc(服务器文件)

#include"Sock.hpp"
#include<sys/select.h>
#define DFL -1 //设置默认值
#define NUM (sizeof(fd_set)*8) //文件描述符的位图的大小
int fdArray[NUM]={0}; //用来保存文件描述符的数组
char buffer[32];//由于工作模式是LT,因此接收字符串超过31字节会自动下次再次接着获取。

//服务端使用手册
void Usage(string process)
{
    cerr << "Usage:\n\t " << process << " port\n";
    cerr <<"Example:\n\t" << process <<" 8080\n"; 
}

//输出数组arr的num个值
void showArray(int arr[],int num)
{
    cout<<"array list:[";
    for(int i=0;i<num;++i)
    {
        if(fdArray[i]==DFL) continue;
        else
            cout<<arr[i]<<",";
    }
    cout<<"]"<<endl;
}
void selectHander(int listenSock, fd_set& readfds)
{
    //遍历fd数组,看到底是哪个文件描述符就绪了
    for(int i=0;i<NUM;++i)
    {
        //如果是无效文件描述符在,则继续遍历
        if(fdArray[i]==DFL) continue;

        //如果是监听套接字并且就绪了,则添加对应的文件描述符到fd数组中去
        if(i==0 && fdArray[i]==listenSock)
        {   
            //如果readfds集合里面有关于listenSock的就绪事件
            if(FD_ISSET(listenSock,&readfds))//判断函数,属于sys/select.h文件中的宏定义
            {
                cout<<"有新连接建立"<<endl;
                string ip;
                uint16_t port;
                int sock=Sock::Accept(listenSock,&ip,&port);
                if(sock<0)
                {
                    return;
                }
                cout<<"连接建立成功,["<<ip<<":"<<port<<"], fd="<<sock<<endl;

                //将新接收的文件描述符加入数组中去,并展示用户维护的fdArray数组。
                int j=0;
                for(;j<NUM;++j)
                {
                    if(fdArray[j]==DFL) break;
                }
                if(j==NUM)
                {
                    cerr<<"服务器爆满,请稍后再试……"<<endl;
                    close(sock);
                }
                else
                {
                    fdArray[j]=sock;
                    showArray(fdArray,NUM);
                }
            }
        }
        else//普通的IO事件
        {
            //如果读事件就绪
            if(FD_ISSET(fdArray[i],&readfds))
            {
                //接受数据
                bzero(buffer,sizeof(buffer));
                ssize_t s=recv(fdArray[i],buffer,sizeof(buffer)-1,0);//读条件就绪,因此不会阻塞
                if(s>0)
                {
                    buffer[s]=0;
                    cout<<"client["<<fdArray[i]<<"]# "<<buffer<<endl;
                }
                else if(s==0)
                {
                    cout << "client[" << fdArray[i] << "] quit, server close " << fdArray[i] << endl;
                    close(fdArray[i]);
                    fdArray[i] = DFL; // 去除对该文件描述符的select事件监听
                    showArray(fdArray, NUM);
                }
                else
                {
                    cout << "client[" << fdArray[i] << "] error, server close " << fdArray[i] << endl;
                    close(fdArray[i]);
                    fdArray[i] = DFL; // 去除对该文件描述符的select事件监听
                    showArray(fdArray, NUM);
                }
            }
        }
    }
}
int main(int argc, char* argv[])
{

    if(argc!=2)
    {
        Usage(argv[0]);
        exit(1);
    }

    //建立服务器的网络监听机制
    int listenSock=Sock::Socket();
    Sock::Bind(listenSock,(uint16_t)atoi(argv[1]));
    Sock::Listen(listenSock);


    //初始化fd数组
    for(int i=0;i<NUM;++i)
    {
        fdArray[i]=DFL;
    }
    fdArray[0]=listenSock;

    //建立连接,但必须是在就绪之后才去执行动作
    while(true)
    {
        int maxfd=DFL;
        fd_set readfds;
        FD_ZERO(&readfds);//清空读就绪集
        //更新maxfd
        for(int i=0;i<NUM;++i)
        {
            if(fdArray[i]==DFL) continue;
            FD_SET(fdArray[i],&readfds);
            if(maxfd<fdArray[i])
            {
                maxfd=fdArray[i];
            }
        }

        //设置等待时间10秒,超出时间不再阻塞,返回结果
        struct timeval timeout={10,0};
        int n=select(maxfd+1,&readfds,nullptr,nullptr,&timeout);
        //根据返回值n选择合适的处理方式
        switch(n)
        {
            case -1://函数调用出错
            cerr<<errno << ":" << strerror(errno)<<endl;
            break;
            case 0://超时
            cout << "time out ... : " << (unsigned long)time(nullptr) << endl;
            break;
            default:
            selectHander(listenSock,readfds); //有事件就绪
            break;
        }
    }

    return 0;
}

运行结果

本地环回测试充当客户端

image-20230511164600260

小结

select模型有一个致命的缺陷,那就是由于fd_set数据类型的大小是固定的,也就是最多同时监听1024个文件描述符,意味着基本失去了作为网络服务器模型的机会。

另外一个让人很不爽的点就是需要每次调用select之前,都需要对fd_set类型的事件关心位图进行重写,程序员维护的数组仅仅是用来重写位图,有点呆呆的。

poll模型

先对接下来的主要内容进行一下概括:

1.poll函数解析

2.Sock.hpp(封装的网络套接字功能)

3.pollServer.cc(服务器文件)

poll函数详解

image-20230511184838730

其中的ppoll函数忽略,重点关注poll函数。

​ 首先认识数据结构pollfd以及数据类型nfds_t,观察源代码如下:

image-20230511185231048

​ 1.nfds_t本质就是unsigned long int类型。

​ 2.struct pollfd结构体的成员变量有fd、events、revents三个。其中fd代表的就是文件描述符,events代表的是对于fd所关心的事件, revents则代表着就绪事件集合。

不难发现,相比于select函数的fd_set,pollfd结构体内容更加明了,将关心事件与就绪事件分隔开,这就意味着程序员不需要每次重新设置关心事件。但是空间开销肯定是要比fd_set的要大很多。

头文件:<poll.h>

参数:

fds:pollfd结构体数组,管理多个文件描述符。

nfds:结构体数组中元素的个数

timeout:阻塞的时间,单位是微秒,超时直接返回。

Sock.hpp

#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cerrno>
#include <cassert>
using namespace std;

class Sock
{
public:
    // 创建监听套接字
    static int Socket()
    {
        int listenSock = socket(PF_INET, SOCK_STREAM, 0);
        if (listenSock < 0)
        {
            exit(1);
        }
        int opt = 1;
        setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
        
        // SO_REUSEADDR选项允许在bind()调用中使用已经被其他socket占用的地址。
        // 这个选项通常用于在socket关闭后立即重新启动服务器程序,而无需等待之前的连接完全断开。
        // 如果没有设置这个选项,在socket关闭后,由于TCP协议的TIME_WAIT状态,
        // 操作系统会保留地址一段时间,阻止其他socket使用。使用SO_REUSEADDR选项可以避免这个问题。

        // SO_REUSEPORT选项允许多个socket绑定同一个IP地址和端口号。
        // 这个选项通常用于实现高可用性的服务器程序,其中多个服务器程序可以同时监听同一个端口,
        // 以便在主服务器宕机时,备用服务器能够立即接管服务。使用SO_REUSEPORT选项可以避免端口占用的问题。
        // 需要注意的是,这个选项只在某些操作系统上可用,例如Linux 3.9以上的内核版本。

        return listenSock;
    }
    // 填充网络信息,并绑定端口号
    static void Bind(int sock, uint16_t port)
    {
        struct sockaddr_in local;
        bzero((void *)&local, sizeof(local));
        local.sin_family = PF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;

        if (bind(sock, (const struct sockaddr *)&local, sizeof(local)) < 0)
        {
            exit(2);
        }
    }

    // 监听连接
    static void Listen(int listenSock)
    {
        if (listen(listenSock, 5) == -1)
        {
            exit(3);
        }
    }

    // 创建连接,并返回套接字
    static int Accept(int listenSock, string *clientIp, uint16_t *clientPort)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        //获取远端客户端网络信息
        int socket= accept(listenSock, (struct sockaddr *)&peer, &len);
        if (socket < 0)
        {
            return -1;
        }
        // 创建成功,返回客户端的IP和端口号
        if (clientPort)
            *clientPort = ntohs(peer.sin_port);
        if (clientIp)
            *clientIp = inet_ntoa(peer.sin_addr);
        return socket;
    }
};

pollServer.cc

#include"Sock.hpp"
#include<sys/select.h>
#include <poll.h>
#define DFL -1 //设置默认值
#define NUM 1024 //维护文件描述符的最大数目
struct pollfd fdArray[NUM]; //用来保存文件描述符相关poll结构体的数组
char buffer[1024]; //接收数据用的数组

//服务端使用手册
void Usage(string process)
{
    cerr << "Usage:\n\t " << process << " port\n";
    cerr <<"Example:\n\t" << process <<" 8080\n"; 
}
//输出数组arr的num个值
void showArray(struct pollfd arr[],int num)
{
    cout<<"array list:[";
    for(int i=0;i<num;++i)
    {
        if(arr[i].fd==DFL) continue;
        else
            cout<<arr[i].fd<<",";
    }
    cout<<"]"<<endl;
}
void pollHander(int listenSock)
{
    //遍历fd数组,看到底是哪个就绪了
    for(int i=0;i<NUM;++i)
    {
        //如果是无效文件描述符在,则继续遍历
        if(fdArray[i].fd==DFL) continue;

        //如果是监听套接字并且就绪了,则添加对应的文件描述符到fd数组中去
        if(i==0 && fdArray[i].fd==listenSock)
        {   
            //有关于listenSock的读就绪事件
            if(fdArray[i].revents & POLLIN)
            {
                cout<<"有新连接建立"<<endl;
                string ip;
                uint16_t port;
                int sock=Sock::Accept(listenSock,&ip,&port);
                if(sock<0)
                {
                    return;
                }
                cout<<"连接建立成功,["<<ip<<":"<<port<<"], fd="<<sock<<endl;

                //将新接收的文件描述符加入数组中去,并展示用户维护的fdArray数组。
                int j=0;
                for(;j<NUM;++j)
                {
                    if(fdArray[j].fd==DFL) break;
                }
                if(j==NUM)
                {
                    cerr<<"服务器爆满,请稍后再试……"<<endl;
                    close(sock);
                }
                else
                {
                    fdArray[j].fd=sock;
                    fdArray[j].events=POLLIN;
                    fdArray[j].revents=0;
                    showArray(fdArray,NUM);
                }
            }
        }
        else//普通的IO事件
        {
            //如果就绪
            if(fdArray[i].revents & POLLIN)
            {
                ssize_t s=recv(fdArray[i].fd,buffer,sizeof(buffer)-1,0);//读条件就绪,因此不会阻塞
                if(s>0)
                {
                    buffer[s]=0;
                    cout<<"client["<<fdArray[i].fd<<"]# "<<buffer<<endl;
                }
                else if(s==0)
                {
                    cout << "client[" << fdArray[i].fd << "] quit, server close " << fdArray[i].fd << endl;
                    close(fdArray[i].fd);
                    fdArray[i].fd = DFL; // 去除对该文件描述符的select事件监听
                    fdArray[i].events=0;
                    fdArray[i].revents=0;
                    showArray(fdArray, NUM);
                }
                else
                {
                    cout << "client[" << fdArray[i].fd << "] error, server close " << fdArray[i].fd << endl;
                    close(fdArray[i].fd);
                    fdArray[i].fd = DFL; // 去除对该文件描述符的select事件监听
                    fdArray[i].events=0;
                    fdArray[i].revents=0;
                    showArray(fdArray, NUM);
                }
            }
        }
    }
}
int main(int argc, char* argv[])
{
    if(argc!=2)
    {
        Usage(argv[0]);
        exit(1);
    }
    //建立服务器的网络监听机制
    int listenSock=Sock::Socket();
    Sock::Bind(listenSock,(uint16_t)atoi(argv[1]));
    Sock::Listen(listenSock);
    //初始化fd数组
    for(int i=0;i<NUM;++i)
    {
        fdArray[i].fd=DFL;
        fdArray[i].events=0;
        fdArray[i].revents=0;
    }
    fdArray[0].fd=listenSock;
    fdArray[0].events=POLLIN;

    //建立连接,但必须是在就绪之后才去执行动作
    while(true)
    {
        //设置等待时间,超出时间不再阻塞,返回结果
        int timeout=100000;//单位是微秒
        int n=poll(fdArray,NUM,timeout);
        //根据返回值n选择合适的处理方式
        switch(n)
        {
            case -1://函数调用出错
            cerr<<errno << ":" << strerror(errno)<<endl;
            break;
            case 0://超时
            cout << "time out ... : " << (unsigned long)time(nullptr) << endl;
            break;
            default:
            pollHander(listenSock);//有事件就绪
            break;
        }
    }
    return 0;
}

运行结果

image-20230511221850565

小结

poll模型和select模型的回调函数很像,基本的思路是一样的,只不过是更换了数据类型。但是还是有缺点,就是还是需要用户自己维护一个数组。不过还好的是该数组不再是为了重写关心的事件类型了,因为结构体里面直接有了反馈信息revents变量。

epoll模型

主要内容:epoll_create、epoll_ctl、epoll_wait函数,Sock.hpp、Log.hpp(日志文件)、epollServer.hpp(服务器头文件)、main.cc(主程序)。

下面这个是抽象出来的epoll模型图,具体的函数将在下面介绍,这里可先看一下有个大致的流程概念。

image-20230511235526232

在介绍三个函数之前,先查看会用到的epoll_event结构体的源代码:

image-20230512001644267

events包含了链接它对应的文件描述符和事件的状态。

epoll_create

image-20230511223013744

头文件:<sys/epoll.h>

参数:

size:从Linux 2.6.8开始,size参数被忽略,但必须大于零。详细介绍如下:

在最初的epoll_create()实现中,size参数通知内核调用程序所期望的文件描述符的数量以添加到epoll实例。内核使用这些信息作为在内部数据结构中初始分配的空间量的提示描述事件的文字。(如果有必要,如果调用方的使用量超过了给定的提示大小,内核会分配更多的空间。)如今,不再需要此提示(内核在不需要提示的情况下动态调整所需数据结构的大小),但大小必须仍然是大于零,以确保在旧内核上运行新的epoll应用程序时的向后兼容性。

函数功能:创建一个epoll模型,在驱动层面添加回调函数,一旦有数据就绪就可以通知创建好的epoll模型。

返回值:一旦成功,这个系统调用将返回一个非负的文件描述符。出现错误时,返回-1,并设置errno以指示错误。

epoll_ctl

image-20230512001311033

头文件:<sys/epoll.h>

参数:

epfd:epoll模型对应的文件描述符(epoll_create函数成功时的返回值)。

op:操作码

fd:针对哪个文件描述符进行的操作

event:事件状态集合,一般是单个事件状态集合的地址

函数功能:

1.op=EPOLL_CTL_ADD,在文件描述符epfd引用的epoll实例上注册目标文件描述符fd,并将事件与 链接到fd的内核文件 关联起来。也就是在红黑树中插入fd以及事件所构成的节点,并将一个全双工的内核缓冲区与fd建立映射关系,除了删除该文件描述符,后续的事件就绪不再需要访问红黑树获得fd。

2.op=EPOLL_CTL_MOD,更改与目标文件描述符fd关联的事件状态,即改变fd所关心的事件。

3.op=EPOLL_CTL_DEL,从epfd引用的epoll实例中删除(注销)目标文件描述符fd。该事件被忽略,并且可以为NULL(Linux 2.6.9内核版本之前必须为非空指针)。

返回值:成功时返回0,失败时返回-1,并设置errno。

epoll_wait

image-20230512164406011

这里我们只关心epoll_wait函数。

头文件:<sys/epoll.h>

参数:

epfd:epoll模型对应的文件描述符(epoll_create函数成功时的返回值)。

events:事件状态集合数组的首地址,输出型参数,用来存放从就绪队列中获得的就绪事件状态集合。

maxevents:数组的大小

timeout:阻塞时间,单位微秒

函数功能:获取就绪事件

返回值:就绪事件的个数

接下来是epoll模型的服务端相关代码。

Sock.hpp

#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cerrno>
#include <cassert>
using namespace std;

class Sock
{
public:
    // 创建监听套接字
    static int Socket()
    {
        int listenSock = socket(PF_INET, SOCK_STREAM, 0);
        if (listenSock < 0)
        {
            exit(1);
        }
        int opt = 1;
        setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
        
        // SO_REUSEADDR选项允许在bind()调用中使用已经被其他socket占用的地址。
        // 这个选项通常用于在socket关闭后立即重新启动服务器程序,而无需等待之前的连接完全断开。
        // 如果没有设置这个选项,在socket关闭后,由于TCP协议的TIME_WAIT状态,
        // 操作系统会保留地址一段时间,阻止其他socket使用。使用SO_REUSEADDR选项可以避免这个问题。

        // SO_REUSEPORT选项允许多个socket绑定同一个IP地址和端口号。
        // 这个选项通常用于实现高可用性的服务器程序,其中多个服务器程序可以同时监听同一个端口,
        // 以便在主服务器宕机时,备用服务器能够立即接管服务。使用SO_REUSEPORT选项可以避免端口占用的问题。
        // 需要注意的是,这个选项只在某些操作系统上可用,例如Linux 3.9以上的内核版本。

        return listenSock;
    }
    // 填充网络信息,并绑定端口号
    static void Bind(int sock, uint16_t port)
    {
        struct sockaddr_in local;
        bzero((void *)&local, sizeof(local));
        local.sin_family = PF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;

        if (bind(sock, (const struct sockaddr *)&local, sizeof(local)) < 0)
        {
            exit(2);
        }
    }

    // 监听连接
    static void Listen(int listenSock)
    {
        if (listen(listenSock, 5) == -1)
        {
            exit(3);
        }
    }

    // 创建连接,并返回套接字
    static int Accept(int listenSock, string *clientIp, uint16_t *clientPort)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        //获取远端客户端网络信息
        int socket= accept(listenSock, (struct sockaddr *)&peer, &len);
        if (socket < 0)
        {
            return -1;
        }
        // 创建成功,返回客户端的IP和端口号
        if (clientPort)
            *clientPort = ntohs(peer.sin_port);
        if (clientIp)
            *clientIp = inet_ntoa(peer.sin_addr);
        return socket;
    }
};

Log.hpp

#pragma once
#include <cstdio>
#include <ctime>
#include <cstdarg>
#include <cassert>
#include <cstring>
#include <cerrno>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <pthread.h>

#define DEBUG 0
#define NOTICE 1
#define WARINING 2
#define FATAL 3
#define LOGFILE "serverTcp.log"
class Log
{
public:
    Log()
        : logFd_(-1)
    {
    }
    static Log* getInstance()//获取单例对象,并保证线程安全
    {
        pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
        if(instance_==nullptr)
        {
            pthread_mutex_lock(&mutex);
            if(instance_==nullptr)
            {
                instance_=new Log();
            }
            pthread_mutex_unlock(&mutex);
        }
        return instance_;
    }

    void enable()//完成对于日志文件LOGFILE的重定向
    {
        umask(0);
        logFd_ = open(LOGFILE, O_CREAT | O_APPEND | O_WRONLY, 0666);
        if (logFd_ != -1)//完成对标准输入、输出流的重定向
        {
            dup2(logFd_, 1);
            dup2(logFd_, 2);
        }
    }

    ~Log()
    {
        if (logFd_ != -1)
        {
            close(logFd_);//关闭文件描述符
        }
    }

private:
    int logFd_;//文件描述符
    static Log* instance_;//单例对象
};
Log* Log::instance_=nullptr;
const char *log_level[] = {"DEBUG", "NOTICE", "WARINING", "FATAL"};
void logMessage(int level, const char *format, ...)//可变参数format
{
    assert(level >= DEBUG);
    assert(level <= FATAL);
    char *name = getenv("USER");
    char logInfo[1024];
    va_list ap;
    va_start(ap, format);

    vsnprintf(logInfo, sizeof(logInfo) - 1, format, ap);

    va_end(ap);
    FILE *out = (level == FATAL) ? stderr : stdout;
    fprintf(out, "%s | %u | %s | %s\n",
            log_level[level], (unsigned int)time(nullptr), name == nullptr ? "unknow" : name, logInfo);

    fflush(out); // 将C缓冲区中的数据刷新到OS
    fsync(fileno(out));   // 将OS中的数据尽快刷盘
}

epollServer.hpp

将服务器封装成一个EpollServer类

#pragma once
#include "Log.hpp"
#include "Sock.hpp"
#include <sys/epoll.h>
#include <functional>
#define NUM 1024
int gsize=128;
class EpollServer
{
public:

    // 设置回调函数接口,处理方式与服务器解耦
    using func_t=function<int(int)>;

    //构建服务器必须要有端口号和回调函数
    EpollServer(uint16_t port,func_t func)
        :func_(func)
        ,port_(port)
        ,epfd_(-1)
        ,listenSock_(-1)
    {}

    //服务器类释放空间时,关闭监听套接字和epoll套接字
    ~EpollServer()
    {
        if(listenSock_!=-1) close(listenSock_);
        if(epfd_!=-1) close(epfd_);
    }
    // 初始化服务器的各个成员变量
    void Init()
    {
        // 获得监听套接字,并建立TCP网络监听机制
        listenSock_=Sock::Socket();
        Sock::Bind(listenSock_,port_);
        Sock::Listen(listenSock_);

        // 建立epoll模型
        epfd_=epoll_create(gsize);
        if(epfd_<0)
        {
            logMessage(FATAL,"errno:%d,%s",errno,strerror(errno));
            exit(4);
        }
        logMessage(DEBUG,"服务器初始化成功……");
    }

    // 服务器运行的接口
    void Run()
    {
        // 首先添加监听事件到epoll模型中
        addEvent(listenSock_,EPOLLIN);
        // 用户维护一个就绪事件数组
        struct epoll_event revs[NUM];
        int timeout=10000; //阻塞等待10秒
        while(true)
        {
            int n=epoll_wait(epfd_,revs,NUM,timeout);
            switch(n)
            {
                case -1:
                logMessage(WARINING,"%s",strerror(errno));
                break;
                case 0:
                logMessage(DEBUG,"time running out……");
                break;
                default:
                epollHander(revs,n);
                break;
            }
        }
    }
private:
    // 处理就绪事件
    void epollHander(struct epoll_event revs[],int num)
    {
        // 遍历数组,一一处理事件
        for(int i=0;i<num;++i)
        {
            //如果读事件是否就绪
            if(revs[i].events&EPOLLIN)
            {
                //如果是监听套接字
                if(revs[i].data.fd==listenSock_)
                {
                    logMessage(NOTICE,"有新链接要创建啦……");
                    string clientIp;
                    uint16_t clientPort;
                    int sock=Sock::Accept(listenSock_,&clientIp,&clientPort);//创建新连接
                    if(sock<0)
                    {
                        logMessage(WARINING,"新链接创建失败……");
                        continue;
                    }
                    logMessage(NOTICE,"新链接创建完成,sock=%d",sock);
                    addEvent(sock,EPOLLIN); //添加对新链接的读事件关心
                }
                else // 普通IO事件
                {
                    // 调用回调函数处理IO事件
                    int n=func_(revs[i].data.fd);

                    //规定返回值<0就认为出错或者是客户端退出
                    if(n<=0)
                    {
                        int x = epoll_ctl(epfd_, EPOLL_CTL_DEL, revs[i].data.fd, nullptr);
                        assert(x == 0);
                        (void)x;
                        close(revs[i].data.fd);//一定要手动关闭文件描述符,避免造成资源泄露
                    }
                }
            }
            else //不就绪的话不做任何处理
            {}
        }
    }

    //添加新的事件,参数:sockfd是文件描述符,events是事件的状态集合
    void addEvent(int sockfd,int events)
    {
        struct epoll_event ev;
        ev.events=events;
        ev.data.fd=sockfd;
        //调用epoll_ctl添加新事件
        int n=epoll_ctl(epfd_,EPOLL_CTL_ADD,sockfd,&ev);
        if(n!=0)
        {
            logMessage(WARINING,"epoll_ctl failed, %s",strerror(errno));
        }
        else
        {
            logMessage(DEBUG,"epoll_ctl success.");
        }
    }
private:
    //网络监听套接字
    int listenSock_;
    //端口号
    uint16_t port_;
    //epoll模型对应的文件描述符
    int epfd_;
    //回调函数
    func_t func_;
};

main.cc

#include"EpollServer.hpp"
#include <memory>
char buffer[1024];// 暂存获取的字符串

// 读事件的回调函数,这里就是简单的字符获取并输出
int myfunc(int sock)
{
    ssize_t s=read(sock,buffer,sizeof(buffer)-1);
    if(s>0)
    {
        buffer[s]=0;
        logMessage(DEBUG,"client[%d]# %s",sock,buffer);
    }
    else if(s==0)
    {
        logMessage(NOTICE,"client[%d] quit.",sock);
    }
    else
    {
        logMessage(WARINING,"error:%s,client[%d] quit.",strerror(errno),sock);
    }
    return s;
}

// 服务器使用手册
void Usage(string process)
{
    cerr << "Usage:\n\t " << process << " port\n";
    cerr <<"Example:\n\t" << process <<" 8080\n"; 
}
int main(int argc, char* argv[])
{
    if(argc!=2)
    {
        Usage(argv[0]);
        exit(1);
    }
    uint16_t port=atoi(argv[1]);

    // 智能指针获取服务器对象
    unique_ptr<EpollServer> es(new EpollServer(port,myfunc));
    // 初始化服务器并启动
    es->Init();
    es->Run();
    return 0;
}

运行结果

image-20230512221907825

小结

与poll模型相比,多路转接epoll模型在以下方面做了优化:

1.支持更多的文件描述符:在Linux系统中,每个进程都有一个限制,即能够同时打开的文件描述符的数量,而epoll模型可以支持更多的文件描述符,因此可以更好地处理大量的连接。

2.更快的速度:epoll模型可以避免每次调用时都需要遍历文件描述符列表的情况,由于epoll模型只需要在新连接到来或者连接断开时才会遍历一次,因此可以更快地处理连接。

3.支持更多的事件类型:epoll模型支持多种事件类型,包括读、写、关闭等,而poll模型只支持读写事件,因此epoll模型可以更好地处理不同类型的事件。

4.更少的内存开销:epoll模型只需要维护一个事件表,而poll模型需要维护所有文件描述符的状态,因此epoll模型可以减少内存开销。

总之,多路转接epoll模型相比poll模型具有更好的性能和扩展性,可以更好地处理大量的连接和不同类型的事件。

基于epoll模型的多路转接实现Reactor模型

Reactor模型是一种基于事件驱动的网络编程模式,用于实现高效的并发网络通信。在Reactor模型中,可以将网络通信分为两部分:事件处理和事件触发。事件处理指的是对网络事件的处理,包括监听、接收、读写等操作;事件触发则是指当网络事件发生时,触发相应的处理操作。

Reactor模型的核心是一个事件循环,该循环不断地监听网络事件,当有事件发生时,就将事件交给相应的处理器进行处理。具体来说,Reactor模型包括以下几个组件:

  1. 事件处理器:负责处理网络事件,包括监听、接收、读写等操作。
  2. 事件处理器注册表:用于将事件处理器注册到事件循环中,当有事件发生时,可以快速地找到对应的事件处理器。
  3. 事件队列:用于存储待处理的事件,当事件循环监听到事件时,就从事件队列中取出相应的事件进行处理。
  4. 事件触发器:负责触发网络事件,当有网络事件发生时,事件触发器将事件添加到事件队列中,然后通知事件循环进行处理。

Reactor模型的优点是可以实现高效的并发网络通信,因为它可以处理大量的并发连接,并且可以同时处理多个网络事件。同时,Reactor模型也可以实现高可靠性的网络通信,因为它能够快速地检测和处理网络异常事件,避免网络故障的发生。

那么epoll模型完全可以胜任Reactor服务器的核心模型的。接下来我们就尝实现能响应客户端计算任务的服务器。

服务器工作的整体流程

先有一个整体的规划再落实到具体的实现代码是一个良好的编程习惯。

image-20230513112130330

用到的主要文件:Tcpserver.hpp、Sock.hpp、Epoller(epoll模型封装)、Protocol.hpp(协议头文件)、Service.hpp(服务功能)、Util.hpp(可能用到的辅助函数)、Log.hpp(日志文件)、Daemonize.hpp(后台进程文件)

Daemonize.hpp

#pragma once
#include <cstdio>
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

void daemonize()
{
    int fd = 0;
    //忽略一些信号
    signal(SIGPIPE,SIG_IGN);
    
    //创建进程后直接结束父进程
    if(fork()>0)
    {
        exit(0);
    }
    //调用setsid()函数,使得子进程成为一组进程的组长
    setsid();
    
    //打开特殊文件“/dev/null”,相当于回收站,一切输入的数据都会被忽略
    if((fd=open("/dev/null",O_RDWR))!=-1)
    {
        //三次重定向使得所有的输出都指向回收文件
        dup2(fd,0);
        dup2(fd,1);
        dup2(fd,2);
        if(fd>2) close(fd);//关闭特殊文件描述符,避免文件描述符泄露
    }
}

Log.hpp

#pragma once
#include <cstdio>
#include <ctime>
#include <cstdarg>
#include <cassert>
#include <cstring>
#include <cerrno>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <pthread.h>

#define DEBUG 0
#define NOTICE 1
#define WARINING 2
#define FATAL 3
#define LOGFILE "serverTcp.log"
class Log
{
public:
    Log()
        : logFd_(-1)
    {
    }
    static Log* getInstance()//获取单例对象,并保证线程安全
    {
        pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
        if(instance_==nullptr)
        {
            pthread_mutex_lock(&mutex);
            if(instance_==nullptr)
            {
                instance_=new Log();
            }
            pthread_mutex_unlock(&mutex);
        }
        return instance_;
    }

    void enable()//完成对于日志文件LOGFILE的重定向
    {
        umask(0);
        logFd_ = open(LOGFILE, O_CREAT | O_APPEND | O_WRONLY, 0666);
        if (logFd_ != -1)//完成对标准输入、输出流的重定向
        {
            dup2(logFd_, 1);
            dup2(logFd_, 2);
        }
    }

    ~Log()
    {
        if (logFd_ != -1)
        {
            close(logFd_);//关闭文件描述符
        }
    }

private:
    int logFd_;//文件描述符
    static Log* instance_;//单例对象
};
Log* Log::instance_=nullptr;
const char *log_level[] = {"DEBUG", "NOTICE", "WARINING", "FATAL"};
void logMessage(int level, const char *format, ...)//可变参数format
{
    assert(level >= DEBUG);
    assert(level <= FATAL);
    char *name = getenv("USER");
    char logInfo[1024];
    va_list ap;
    va_start(ap, format);

    vsnprintf(logInfo, sizeof(logInfo) - 1, format, ap);

    va_end(ap);
    FILE *out = (level == FATAL) ? stderr : stdout;
    fprintf(out, "%s | %u | %s | %s\n",
            log_level[level], (unsigned int)time(nullptr), name == nullptr ? "unknow" : name, logInfo);

    fflush(out); // 将C缓冲区中的数据刷新到OS
    fsync(fileno(out));   // 将OS中的数据尽快刷盘
}

Util.hpp

#pragma once
#include <unistd.h>
#include <fcntl.h>
#include "Log.hpp"
class Util
{
public:
    //设置fd文件描述符为非阻塞性质
    static void SetNonBlock(int fd)
    {
        int fl = fcntl(fd, F_GETFL);
        fcntl(fd, F_SETFL, fl | O_NONBLOCK);
    }
};

Protocol.hpp

#pragma once
#include <string>
#include <vector>
#define SEP '#'
#define SEP_LEN sizeof(SEP)

#define SPACE " "
#define SPACE_LEN strlen(SPACE)
#define CRLF "\r\n"
#define CRLF_LEN strlen(CRLF)


using namespace std;
// 数据的分包
void PackageSplit(string &str, vector<string> &result)
{
    while (true)
    {
        int pos = str.find(SEP);
        if (pos == string::npos) break;
        result.push_back(str.substr(0, pos));
        str.erase(0, pos+SEP_LEN);
    }
}
//请求类
class Request
{
public:
    Request()
    {}
    Request(int x,int y,char op):x_(x),y_(y),op_(op)
    {}
    //反序列化
    int Deserialize(string message)
    {
        int pos1=message.find(SPACE);
        int pos2=message.rfind(SPACE);
        if(pos1==string::npos || pos2==string::npos) return -1;
        //从字符串中提取数字和运算符
        x_=atoi(message.substr(0,pos1).c_str());
        y_=atoi(message.substr(pos2+SPACE_LEN).c_str());
        op_=message.substr(pos1+SPACE_LEN,pos2-(pos1+SPACE_LEN))[0];
        return 0;
    }
public:
    int x_;
    int y_;
    char op_;
};

// 相应类
class Response
{
public:
    Response()
    {}
    Response(int code,int result):code_(code),result_(result)
    {}
    //序列化
    void Serialize(string& out)
    {
        out=to_string(code_);
        out+=SPACE;
        out+=to_string(result_);
        out+=CRLF;
    }
public:
    // 计算码
    int code_;
    // 计算结果
    int result_; 
};

Service.hpp

#pragma once
#include "Protocol.hpp"
#include <functional>
using service_t = function<Response(const Request&)>;
Response Calculate(const Request& req)
{
    Response resp(0,0);
    int x=req.x_;
    int y=req.y_;
    char op=req.op_;
    switch(op)
    {
        case '+':
            resp.result_=x+y;
        break;
        case '-':
            resp.result_=x-y;
        break;
        case '*':
            resp.result_=x*y;
        break;
        case '/':
            if(y==0)
            {
                resp.code_=-1;
                break;
            }
            resp.result_=x/y;
        break;
        case '%':
            if(y==0)
            {
                resp.code_=-2;
                break;
            }
            resp.result_=x%y;
        break;
        //预期外的操作符
        default:
            resp.code_=-3;
        break;
    }
    return resp;
}

Sock.hpp

#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cerrno>
#include <cassert>
using namespace std;

class Sock
{
public:
    // 创建监听套接字
    static int Socket()
    {
        int listenSock = socket(PF_INET, SOCK_STREAM, 0);
        if (listenSock < 0)
        {
            exit(1);
        }
        int opt = 1;
        setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
        
        // SO_REUSEADDR选项允许在bind()调用中使用已经被其他socket占用的地址。
        // 这个选项通常用于在socket关闭后立即重新启动服务器程序,而无需等待之前的连接完全断开。
        // 如果没有设置这个选项,在socket关闭后,由于TCP协议的TIME_WAIT状态,
        // 操作系统会保留地址一段时间,阻止其他socket使用。使用SO_REUSEADDR选项可以避免这个问题。

        // SO_REUSEPORT选项允许多个socket绑定同一个IP地址和端口号。
        // 这个选项通常用于实现高可用性的服务器程序,其中多个服务器程序可以同时监听同一个端口,
        // 以便在主服务器宕机时,备用服务器能够立即接管服务。使用SO_REUSEPORT选项可以避免端口占用的问题。
        // 需要注意的是,这个选项只在某些操作系统上可用,例如Linux 3.9以上的内核版本。

        return listenSock;
    }
    // 填充网络信息,并绑定端口号
    static void Bind(int sock, uint16_t port)
    {
        struct sockaddr_in local;
        bzero((void *)&local, sizeof(local));
        local.sin_family = PF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;

        if (bind(sock, (const struct sockaddr *)&local, sizeof(local)) < 0)
        {
            exit(2);
        }
    }

    // 监听连接
    static void Listen(int listenSock)
    {
        if (listen(listenSock, 5) == -1)
        {
            exit(3);
        }
    }

    // 创建连接,并返回套接字
    static int Accept(int listenSock, string *clientIp, uint16_t *clientPort)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        //获取远端客户端网络信息
        int socket= accept(listenSock, (struct sockaddr *)&peer, &len);
        if (socket < 0)
        {
            return -1;
        }
        // 创建成功,返回客户端的IP和端口号
        if (clientPort)
            *clientPort = ntohs(peer.sin_port);
        if (clientIp)
            *clientIp = inet_ntoa(peer.sin_addr);
        return socket;
    }
};

Epoller

#pragma once
#include <sys/epoll.h>
#include <unistd.h>
#include <cstdlib>
#include <cerrno>
#include "Log.hpp"
static int esize = 128;
class Epoller
{
public:
    // 构建epoll模型
    static int CreateEpoll()
    {
        int epfd = epoll_create(esize);
        if (epfd < 0)
        {
            logMessage(FATAL, "epoll_create error: %d,%s", errno, strerror(errno));
            exit(4);
        }
        return epfd;
    }

    // 添加链接以及它所关心的事件
    static bool AddEvent(int epfd, int sock, uint32_t events)
    {
        struct epoll_event ev;
        ev.events = events;
        ev.data.fd = sock;
        int n = epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);
        return n == 0;
    }

    // 修改链接的关心事件
    static bool ModEvent(int epfd, int sock, uint32_t events)
    {
        struct epoll_event ev;
        ev.events = events;
        ev.data.fd = sock;
        int n = epoll_ctl(epfd, EPOLL_CTL_MOD, sock, &ev);
        return n == 0;
    }

    //从epoll模型中删除链接
    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, struct epoll_event revs[], int num)
    {
        //阻塞式等待获取就绪事件
        int n = epoll_wait(epfd, revs, num, -1);
        if (n == -1)
        {
            logMessage(FATAL, "LoopOnce error: %d,%s", errno, strerror(errno));
            exit(5);
        }
        return n;
    }
};

Tcpserver.hpp

#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <cstdlib>
#include <unistd.h>
#include <cassert>
#include <unordered_map>
#include <functional>
#include "Sock.hpp"
#include "Epoller.hpp"
#include "Util.hpp"
#include "Protocol.hpp"
using namespace std;
#define ESIZE 1024
#define BUFFER_SIZE 1024
class Connection;
class TcpServer;
// func_t 是连接事件就绪时的回调方法
// callback_t 是对客户端数据处理的回调方法
using func_t = function<int(Connection *)>;
using callback_t =function<int(Connection*,string&)>;


// 链接类
class Connection
{
public:
    Connection(int sock, uint32_t events,TcpServer *r) : sock_(sock),events_(events), R_(r)
    {
    }
    // 链接结束时,要关闭文件描述符
    ~Connection()
    {
        if (sock_ > 0)
            close(sock_);
    }
    // 设置链接的回调函数
    void SetRecver(func_t recver) { recver_ = recver; }
    void SetSender(func_t sender) { sender_ = sender; }
    void SetExcepter(func_t excepter) { excepter_ = excepter; }

public:
    // 链接对应的网络套接字
    int sock_;
    // 链接所关心的事件
    uint32_t events_;
    // 回指TcpServer,调用类内方法
    TcpServer *R_;
    // 读写缓冲区
    string inbuffer_;
    string outbuffer_;
    // 回调函数:读、写、异常
    func_t recver_;
    func_t sender_;
    func_t excepter_;
};

// 基于TCP协议的服务类
class TcpServer
{
public:
    TcpServer(callback_t cb, int port) : cb_(cb),listenSock_(-1), epfd_(-1)
    {
        // 就绪队列的初始化
        revs_ = new struct epoll_event[ESIZE];
        assert(revs_);
        // TCP网络通信的基础搭建
        listenSock_ = Sock::Socket();
        Sock::Bind(listenSock_, port);
        Sock::Listen(listenSock_);
        // 创建epoll模型
        epfd_ = Epoller::CreateEpoll();
        // 添加监听事件到epoll模型中,模式为ET(边缘触发)
        // 监听事件只关心读是否就绪
        // 在添加监听事件时,要设置监听事件的回调函数
        bool flag = AddConnection(epfd_, listenSock_, EPOLLIN | EPOLLET,
                                  std::bind(&TcpServer::Accepter, this, std::placeholders::_1), nullptr, nullptr);
        if (!flag)
        {
            logMessage(WARINING, "Add listenSock failed, %d:%s", errno, strerror(errno));
        }
        logMessage(DEBUG, "Add listenSock success,sockfd:%d", listenSock_);
    }

    // 添加链接
    bool AddConnection(int epfd, int sock, uint32_t events, func_t recver, func_t sender, func_t excepter)
    {
        //判断是否是边缘触发,是的话就设置sock为非阻塞
        if(events & EPOLLET)
        {
            Util::SetNonBlock(sock);
        }
        // 添加sock到epoll模型
        if (!Epoller::AddEvent(epfd, sock, events))
            return false;
        // 创建链接对象
        Connection *conn = new Connection(sock, events,this);
        if (!conn)
            return false;
        // 设置链接的回调函数
        conn->SetRecver(recver);
        conn->SetSender(sender);
        conn->SetExcepter(excepter);
        // 把链接的sock以及它的链接对象conn在connections_中管理起来,方便查找。
        connections_.insert(make_pair(sock, conn));
        return true;
    }
    // Accepter函数时监听套接字的回调函数,非阻塞读取链接请求
    int Accepter(Connection *conn)
    {
        // 非阻塞式获取链接请求
        while (true)
        {
            string clientIp;
            uint16_t clientPort = 0;
            int sockfd = Sock::Accept(conn->sock_, &clientIp, &clientPort);
            // 出现异常返回值,有三种情况:
            if (sockfd < 0)
            {
                if (errno == EINTR) // 1.中断导致的继续accept
                    continue;
                else if (errno == EAGAIN || errno == EWOULDBLOCK) // 2.读完所有的连接请求时,正常退出
                    break;
                else // 3.真正的读取错误
                {
                    return -1;
                }
            }
            // 获取链接请求正常,尝试添加到epoll模型中,对普通套接字设置回调函数
            bool flag = AddConnection(epfd_, sockfd, EPOLLIN | EPOLLET,
                                      std::bind(&TcpServer::TcpRecver, this, std::placeholders::_1),
                                      std::bind(&TcpServer::TcpSender, this, std::placeholders::_1),
                                      std::bind(&TcpServer::TcpExcepter, this, std::placeholders::_1));
            if (!flag)
            {
                logMessage(WARINING, "Add connection failed, %d:%s", errno, strerror(errno));
            }
            logMessage(DEBUG, "Add connection success,sockfd:%d", sockfd);
        }
        return 0;
    }
    //任务派发
    void Dispatcher(int epfd,struct epoll_event revs[],int num)
    {
        //获取就绪事件队列
        int n = Epoller::LoopOnce(epfd,revs,num);
        //遍历队列,执行对应的任务
        for(int i=0;i<n;++i)
        {
            int sock=revs[i].data.fd;
            uint32_t revents=revs[i].events;
            //先排除是否有异常情况,如果有,就转去读和写的事件中去处理异常,也就是统一管理异常。
            if(revents & EPOLLERR) revents |=(EPOLLIN | EPOLLOUT);
            if(revents & EPOLLHUP) revents |= (EPOLLIN | EPOLLOUT);
            //读事件就绪,可能包含有异常,没关系,TcpRecver会在读取时进行处理。
            if(revents & EPOLLIN)
            {
                if(IsExist(sock) && connections_[sock]->recver_) 
                {
                    connections_[sock]->recver_(connections_[sock]);
                }
            }
            //写事件就绪,可能包含有异常,没关系,TcpSender会在写入时进行处理。
            if(revents & EPOLLOUT)
            {
                //先判断一下epoll模型中是否还存在这个链接,因为在之前可能因为一些错误而导致该链接被删除
                if(IsExist(sock) && connections_[sock]->sender_) 
                {
                    connections_[sock]->sender_(connections_[sock]);
                }
            }
        }
    }
    // 判断sock文件描述符是否存在
    bool IsExist(int sock)
    {
        auto it=connections_.find(sock);
        return it!=connections_.end();
    }

    // 接收函数
    int TcpRecver(Connection *conn)
    {
        //非阻塞读取
        while(true)
        {
            char buffer[BUFFER_SIZE];
            ssize_t s=recv(conn->sock_,buffer,sizeof(buffer)-1,0);
            if(s>0)
            {
                buffer[s]=0;
                string str=buffer;
                //处理客户端多余的回车键
                while(str.find("\r\n")!=string::npos)
                {
                    str.erase(str.find("\r\n"),strlen("\r\n"));
                }
                conn->inbuffer_+=str;//将每次读取的数据放入链接的接收缓冲区inbuffer_中
            }
            else if(s==0)//客户端正常退出
            {
                logMessage(DEBUG,"client[%d] quit.",conn->sock_);
                conn->excepter_(conn); //转到异常回调函数统一处理
                break;
            }
            else
            {
                if(errno & EINTR) continue;
                else if(errno & EAGAIN || errno & EWOULDBLOCK) break;
                else//读取错误
                {
                    logMessage(FATAL, "recv error: %d:%s", errno, strerror(errno));
                    conn->excepter_(conn);//转到异常回调函数统一处理
                    break;
                }
            }
        }
        //读取数据完毕,进行数据处理
        vector<string> result;
        // 进行数据报的分片
        PackageSplit(conn->inbuffer_,result);
        // 获取多个独立的数据报,一一调用回调函数处理
        for(string message:result)
        {
            cb_(conn,message);
        }
    }

    // 发送函数
    int TcpSender(Connection *conn)
    {
        while(true)
        {
            ssize_t s=send(conn->sock_,conn->outbuffer_.c_str(),conn->outbuffer_.size(),0);
            if(s>0)
            {
                conn->outbuffer_.erase(0,s);
            }
            else
            {
                if(errno == EINTR) continue; //由于硬件中断而导致的,继续发送信息
                else if(errno== EAGAIN || errno == EWOULDBLOCK) break;// 正常发送数据完毕,退出循环
                else      //正真的发送信息出错了,输出出错日志,然后退出循环
                {
                    logMessage(FATAL,"client[%d] send error: %s",conn->sock_,strerror(errno));
                    return -1;
                }
            }
            return 0;
        }
    }

    // 异常处理函数
    int TcpExcepter(Connection *conn)
    {
        if(!IsExist(conn->sock_)) return -1;
        int sock=conn->sock_;
        //1.先在epoll模型中删除对该链接的关心
        bool flag=Epoller::DelEvent(epfd_,sock);
        if(flag) logMessage(DEBUG,"delete event from epoll success.");
        else return -1;
        //2.释放链接的空间,其析构函数会自动关闭文件描述符
        delete conn;
        logMessage(DEBUG,"close sock:%d, delete memory success.",sock);
        //3.在map中删除对该链接的管理
        connections_.erase(sock);
        logMessage(DEBUG,"map erase success.");
        return 0;

    }

    // 设置读写功能
    void EnableReadWrite(int sock,bool readable,bool writeable)
    {
        uint32_t events=connections_[sock]->events_;
        if(readable) events |= EPOLLIN;
        if(writeable) events |= EPOLLOUT;
        Epoller::ModEvent(epfd_,sock,events);
    }

    // 启动服务器,死循环进行任务派发
    void Run()
    {
        while(true)
        {
            Dispatcher(epfd_,revs_,ESIZE);
        }
    }
private:
    // 监听套接字
    int listenSock_;
    // epoll模型
    int epfd_;
    // 链接的管理
    unordered_map<int, Connection*> connections_;
    // 就绪队列
    struct epoll_event *revs_;
    //服务器协议回调函数
    callback_t cb_;
};

运行结果

image-20230513153251126

服务器的日志:

image-20230513153401337

总结

这个和多线程处理网络请求很相似,但是本质确实是不同的。

多路转接是指使用一个线程监视多个IO事件,当有IO事件发生时,线程会被唤醒并处理该事件。多路转接技术可以使用 select、poll、epoll 等机制实现。它的优势在于可以节省线程的创建和销毁开销,减少系统资源的占用,并且在处理大量连接时,可以有效地避免线程数量过多导致的系统负载问题。

多线程处理IO事件,是指使用多个线程来处理IO事件。每个线程都会独立地处理一个或多个IO事件。多线程处理IO事件的优势在于可以利用多核CPU的优势,提高系统的并行处理能力。同时,在处理一些比较耗时的IO操作时,可以避免阻塞其他线程,提高系统的响应速度。

总体来说,多路转接适合处理大量连接,但是每个连接的IO操作比较简单的情况。而多线程处理IO事件则适合处理IO操作比较耗时,或者每个连接的IO操作比较复杂的情况。需要根据具体的场景选择合适的技术。

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值