16DayMakeCppServer

原github传送门:https://github.com/yuesong-feng/30dayMakeCppServer/tree/main
感谢每一位写教程的人,此处记录我的学习过程和清晰易懂的知识点补充。
你要愿意的话,看我的也可以^_^

Day01从最简单的socket开始

前置知识:

  • makefile :代码写完是拿来用的,所以要把源文件编译成可执行文件(cpp -> exe),源文件可能引用了很多库,而被引用的库又引用了一些库..就是说,它们会存在复杂的依赖关系。如果手动去编译,不仅工作量大,还很复杂,而Make工具就是用来做这个事情的。你只需要写清楚Makefile,把目标告诉它,编译的细节和顺序交给它来保证。此外,makefile还支持增量编译、执行构建任务(clean、test等)。

  • socket:socket 原意是“插座”,翻译为"套接字"。我们把插头插到插座上就能从电网获得电力供应,同理为了与远程计算机传输数据,计算机需要连到因特网,socket 就是用来连接的工具。

  • 一切皆文件:很著名的一句话"在unix/Linux中,一切都是文件!"。
    UNIX/Linux 会给每个文件分配一个 ID(一个整数),这个ID被称为文件描述符(File Descriptor)。例如:用 0 表示标准输入文件(stdin),对应硬件设备:键盘;用 1 表示标准输出文件(stdout),对应硬件设备:显示器。UNIX/Linux 程序在执行任何 I/O 操作时,本质都是读/写文件描述符。一个文件描述符只是一个和所打开文件相关联的整数,背后可能是硬盘上的普通文件、FIFO、管道、终端、键盘、显示器,甚至是一个网络连接。网络连接也是一个文件,它也有文件描述符
    我们可以通过 socket() 函数创建网络连接(或者说打开一个网络文件),socket() 的返回值就是文件描述符。有了文件描述符,可以使用普通的文件操作函数来传输数据了,例如:用 read() 读取从远程计算机传来的数据;用 write() 向远程计算机写入数据。只要用 socket() 创建了连接,剩下的就是文件操作了。
    至于真正的难点:socket()是怎么实现的?操作系统和相关的库已经帮忙封装好了,打算先学应用,再深啃。

server.cpp

#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
int main() {
	int sockfd = socket(AF_INET, SOCK_STREAM, 0);

	struct sockaddr_in serv_addr;//结构体 定义在arpa/inet.h里
	bzero(&serv_addr, sizeof(serv_addr));//初始化 填充为0

	serv_addr.sin_family = AF_INET;//IPV4
	serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
	/*
	inet_addr把点分十进制的IPV4地址转成网络字节序。
	为什么要转成网络字节序?
	字节序有大端小端之分,比如0x1234在大端字节序里为0x12、0x34
	小端反之,如果不统一显然会出错。
	*/
	serv_addr.sin_port = htons(8888);//htons将16位主机字节序转为网络字节序。
	bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
	/*
	为什么要绑定?
	把套接字理解为插座,那IP+端口就是插头。
	*/
	listen(sockfd, SOMAXCONN);
	/*
	创建套接字且bind后并不能直接接受客户端连接请求
	但listen一下就能被动接受
	*/
	struct sockaddr_in clnt_addr;
	socklen_t clnt_addr_len = sizeof(clnt_addr);
	bzero(&clnt_addr, sizeof(clnt_addr));
	int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);
	printf("new client fd %d! IP:%s Port: %d\n", clnt_sockfd, inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));
	
}

client.cpp

#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
int main() {
	int sockfd = socket(AF_INET, SOCK_STREAM, 0);
	struct sockaddr_in serv_addr;
	bzero(&serv_addr, sizeof(serv_addr));
	serv_addr.sin_family = AF_INET;
	serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
	serv_addr.sin_port = htons(8888);
	bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
	connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));

}

Makefile

server:
	g++ server.cpp -o server && g++ client.cpp -o client

Day02处理异常

这块主要是对Day01的补充:对socketfd进行读(read())/写(write()),以及封装好util.cpp用来处理异常。没有什么新知识,除了注意:linux的文件描述符有限,用完要closed。这和malloc后要free一样,是个好习惯。

发现在博客园传大片的项目代码很不优美,于是知识点补充继续写在这里(干货预警:D),也会同步在我的Github。可能会更好的阅读体验:https://github.com/liyishui2003/30DayMakeCppServer/tree/main

Day03 高并发还得用epoll

前置知识:

  • IO复用 :所有的服务器都是高并发的,可以同时为成千上万个客户端提供服务,这一技术称为IO复用。IO复用和多线程有相似之处,但本质不同。IO复用针对IO接口,多线程针对CPU。linux中使用select, poll和epoll来实现该技术。

  • epoll:由epoll_create(创建)、epoll_ctl(支持增/删/改) 和 epoll_wait(等待)三个系统调用组成。

int epfd = epoll_create1(0);       //参数是一个flag,一般设为0,详细参考man epoll

epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);    //添加事件到epoll,此处ev是一个epoll_event结构体,存有events和data
epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev);    //修改epoll红黑树上的事件
epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);   //删除事件

int nfds = epoll_wait(epfd, events, maxevents, timeout);
/*
events是一个epoll_event结构体数组,maxevents是可供返回的最大事件大小
一般为events的大小,timeout表示最大等待时间,设置为-1表示一直等待。
*/
  • LT和ET:epoll 有两种工作模式:
    水平触发(LT,Level Triggered):默认工作模式。当被监控的文件描述符上有事件发生时,epoll_wait 通知程序。如果程序在本次处理中没有将数据全部读取或写入,epoll_wait 在下一次调用时仍然会通知该事件,直到数据被处理完。
    边缘触发(ET,Edge Triggered):更高效。只有文件描述符的 I/O 事件状态发生变化时(不可读变可读,从不可写变可写),epoll_wait 才通知程序。因此程序要在一次处理中尽可能处理数据,否则可能错过后续数据。好东西都有代价,ET考验代码功底,且必须搭配非阻塞socket使用。

  • 阻塞/非阻塞套接字:阻塞套接字进行操作时,如果操作条件不满足,程序被阻塞,直到操作完成或发生错误。非阻塞套接字则不会阻塞程序执行,会立即返回错误码(通常是 EWOULDBLOCK 或 EAGAIN),程序继续执行其他任务。

Q:为什么ET模式一定要搭配非阻塞套接字?
A:

阻塞套接字读数据时若数据量较大,一次 read 操作无法读完,并且会阻塞程序执行。
后续新数据来了,由于没有状态变化(此刻还卡在这,当然没变化),epoll 不会再次通知(ET模式的定义)。
这就导致部分数据无法读取,数据丢失。

这块变动比较大的主要是server.cpp

server.cpp

#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include "util.h"
#define MAX_EVENTS 1024
#define READ_BUFFER 1024

void setNonBlocking(const int fd){
    fcntl(fd,F_SETFL,fcntl(fd,F_GETFL) | O_NONBLOCK);
    /*
    F_GETFL 是fcntl的一个命令,获取 fd 的状态标志。
    这些标志描述了例如是否为阻塞模式、是否为追加模式等。
    O_NONBLOCK表示非阻塞,用 | 表示在fd的状态里加一个非阻塞
    */
}

int main() {
	
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    errif(sockfd == -1,"socket create error");
	struct sockaddr_in serv_addr;
	bzero(&serv_addr, sizeof(serv_addr));

	serv_addr.sin_family = AF_INET;//IPV4
	serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
	serv_addr.sin_port = htons(8888);//htons将16位主机字节序转为网络字节序。
	
    int bindRet=bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
    errif(bindRet==-1,"bind error");
	int listenRet=listen(sockfd, SOMAXCONN);
    errif(listenRet==-1,"bind error");
	
    int epfd=epoll_create1(0);//创建epoll实例,用来同时监视多个IO事件
    errif(epfd==-1,"epoll create error");
    
    
    struct epoll_event events[MAX_EVENTS],ev;
    bzero(&events,sizeof(events));
    
    bzero(&ev,sizeof(ev));
    ev.events=EPOLLIN | EPOLLET;
    //原本只有EPOLLIN,多一个EPOLLET表示采用et模式
    ev.data.fd=sockfd;
    setNonBlocking(sockfd);
    epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
    //向 epoll 实例(epfd)中添加一个需要监控的文件描述符(sockfd)
    //及其关注的事件(&ev)
    

   while(true){
        int nfds = epoll_wait(epfd,events,MAX_EVENTS,-1);
        //events里有nfds个待处理事件,包括服务器和客户端
        
        for(int i=0;i<nfds;++i){
            if(events[i].data.fd==sockfd){
                
                struct sockaddr_in clnt_addr;
                bzero(&clnt_addr, sizeof(clnt_addr));
                socklen_t clnt_addr_len = sizeof(clnt_addr);
                
                
                int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);
                //调用 accept 接受新连接,从而得到新的客户端套接字 clnt_sockfd
                errif(clnt_sockfd==-1,"accept error");

                bzero(&ev,sizeof(ev));
                ev.data.fd=clnt_sockfd;//同理,关联来自客户端的套接字和ev
                ev.events=EPOLLIN | EPOLLET;//设置模式为ET+关注读入事件
                setNonBlocking(clnt_sockfd);
                epoll_ctl(epfd,EPOLL_CTL_ADD,clnt_sockfd,&ev);
            }
            else if(events[i].events & EPOLLIN){
                char buf[READ_BUFFER];
                while(true){
                    bzero(&buf,sizeof(buf));
                    ssize_t bytesRead=read(events[i].data.fd,buf,sizeof(buf));
                    if(bytesRead > 0){
                        printf("message from client fd %d: %s\n",events[i].data.fd,buf);
                        write(events[i].data.fd,buf,sizeof(buf));
                    }
                    else if(bytesRead==-1&&errno==EINTR){
                        //errno在<errno.h>里,有一系列预定义的值
                        //比如EACCES/EBADF/EINTR/ENOMEM
                        //此处EINTR表示正常中断
                        printf("continue reading\n");
                        continue;
                    }
                    else if(bytesRead==-1&&( (errno==EAGAIN || errno==EWOULDBLOCK))){
                        //这两个错误码都表示资源暂时不可用
                        printf("finish reading once,errno: %d\n",errno);
                    }
                    else if(bytesRead==0){//EOF,客户端断开连接
                        printf("EOF,client fd %d disconnected\n",events[i].data.fd);
                        close(events[i].data.fd);
                        //关闭socket会自动将文件描述符从epoll树上移除
                    }
                }
            }
        }
   }
}

Day04 看看我们的第一个类

这章节变动比较大,用类分装了Epoll、InetAddress、Socket,不过server的代码也因此清晰了好多。
简单概述下涉及到的,包括计算机网络知识和语法知识在内的新知识点:

  • 在实现类的函数时,使用了::解析运算符,表明该函数属于该类。本质上和在结构体声明里直接写函数无区别

  • ~用来定义析构函数(负责释放资源(delete/free/...)的函数)

  • 初始化列表是用来给结构体赋值的一种方式,直接写在函数声明后面、函数体前面。比如"InetAddress(const char* ip,uint16_t port) : addr_len(sizeof(addr))"。在函数中对成员变量赋值时,会先调用默认构造函数初始化,再调用赋值运算符进行赋值,多了一次默认构造的过程,效率较低。而写在初始化列表里则不会先调用默认构造函数。

    不同的用武之地:
    常量成员、引用成员、没有默认构造函数的类成员时就必须用初始化列表初始化;
    而需要根据传入的参数判断赋什么值时,就只能用函数体初始化了。

  • 服务器和客户端建立通信的过程为:
    1.服务器建立套接字,并绑定IP+端口,设置成listen模式接受客户端请求
    2.客户端建立套接字,发起连接请求
    3.服务器收到请求后,通过accept系统调用,会将请求取出,并_专门_为该连接创建一个新的套接字,也就是这里的clnt_sockfd。而此时最开始建立的套接字会一直打开,继续监听其它请求。
    注:套接字只在计算机内部有用,用来标识到底是哪个连接;也就是说客户端和服务器的套接字是各用各的,互不关心;那它们怎么通信?通过暴露在外的端口,通过IP+端口就能确定我要联系上的是哪个套接字。

其它的主要是语法,不用怎么盘整个项目的逻辑,都注释在代码里了。见:https://github.com/liyishui2003/30DayMakeCppServer/tree/main/04_封装我们的第一个类

Day05 epoll高级用法-Channel登场

前置知识:

为什么要有channel?事实上 “Channel” 并非 epoll 本身的概念,而是在一些网络库(如 muduo 网络库)中为了更方便地使用 epoll 所抽象出来的一个概念,简单地说它将文件描述符(fd)及其感兴趣的事件和相应的回调函数封装在一起。

为什么要把它们封装在一起?回忆一下,在之前的专题中,我们往epoll里放的都是文件描述符,然后在server.cpp里实现了handleReadEvent()这一函数,用来处理读事件。但一个服务器在现实场景中,肯定能同时提供不同服务(如HTTP/FTP等),就算文件描述符上发生的事件都是可读事件,不同的连接类型也将决定不同的处理逻辑,仅仅通过一个文件描述符来区分显然会很麻烦。因此我们需要加以区分,同时封装也能方便开发者调用。

那么如何实践?

原本我们用event(也就是下面的epoll_event类).data(下面的epoll_data).fd(epoll_data的成员变量,表示一个文件描述符)来作为事件在红黑树中的标识,任何增删改查都通过这个fd。

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 */
} __EPOLL_PACKED;

但注意到这里epoll_data是一个union类型。试想你有一个类有很多变量,但任何时刻只会用到一个,却要为它开辟如此多空间,太奢侈了——于是有了union。union允许不同的数据类型共享同一块内存空间,大小由最大的数据类型决定,同一时刻只能用一个数据类型。

观察到epoll_data里有一个指针ptr,启发我们让ptr指向channel类,就可以通过event.data.ptr来访问channel类,进而在channel类里根据不同的服务类型(HTTP/FTP)调用不同的处理函数。

来看channel的声明:

class Channel{
private:
    Epoll *ep;
    int fd;
    uint32_t events;
    uint32_t revents;
    bool inEpoll;
};
  • 这里记录了Epoll类型的指针ep,是因为Epoll类封装了 epoll 相关操作,如 epoll_create、epoll_ctl等。Channel 对象代表的是一个文件描述符及其感兴趣的事件,当需要将fd注册到 epoll 实例中,或者修改感兴趣的事件时,就需要借助 Epoll 类的方法来完成。虽然我们为了方便处理不同事件抽象出了channel,但当需要调用epoll的各种函数时,仍需要和它联系在一起,在这里通过记录指针来实现。

  • fd没什么好说的,肯定要存

  • events表示该文件描述符感兴趣的事件,例如 EPOLLIN(可读事件)、EPOLLOUT(可写事件)等,如果又可读又可写,events就等于EPOLLIN | EPOLLOUT。

  • revents存储 epoll 实际返回的就绪事件,即当 epoll_wait 检测到该文件描述符上有事件发生时,会将这些事件信息存储在 revents 中。比如可读了,revents就设为EPOLLIN。

  • inEpoll 表示该fd是否注册到epoll实例中。

好了,现在看完channel的定义,来看几个实例,彻底搞懂是怎么抽象出来的。

情况一:channel的声明

Channel *servChannel = new Channel(ep, serv_sock->getFd());

这行代码调用了Channel的构造函数,第一个传入指向epoll实例的指针,第二个传入fd,表示该channel链接的是该实例和该fd。

情况二:设置channel监听可读事件

首先调用

servChannel->enableReading();

而channel的enableReading()是这么写的:

void Channel::enableReading(){
    events = EPOLLIN | EPOLLET;
    ep->updateChannel(this);
}

除了修改events的值外,本质是调用了Epoll类的updateChannel(),再来看它写了什么:

void Epoll::updateChannel(Channel *channel){
    int fd = channel->getFd();
    struct epoll_event ev;
    bzero(&ev, sizeof(ev));
    ev.data.ptr = channel;
    ev.events = channel->getEvents();
    if(!channel->getInEpoll()){
        errif(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == -1, "epoll add error");
        channel->setInEpoll();
    } 
    else {
        errif(epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev) == -1, "epoll modify error");
    }
}

epoll_ctl要求传入一个epoll_event参数(这里fd也作为参数传入了,所以ev里有用的其实是刚才写的events = EPOLLIN | EPOLLET),为了满足这个要求,声明一个ev。接着就是调用函数。发现了吗?印证了我们刚才说“Channel 对象代表的是一个文件描述符及其感兴趣的事件,当需要将fd注册到 epoll 实例中,或者修改感兴趣的事件时,就需要借助 Epoll 类的方法来完成”。

情况三:channel返回需要处理的事件

原本写的是

std::vector<epoll_event>events = ep->poll();

现在都抽象成channel了,就返回channel指针:

std::vector<Channel*> activeChannels = ep->poll();

poll函数则同样调用了epoll_wait函数,函数返回的是events类型,同理要转化成channel类。这里做了两个事情:① 获取ch指针 ② 把有变化的events赋给ch的Revents(刚才说了,这里存储的是返回的事件)。

std::vector<Channel*> Epoll::poll(int timeout){
    std::vector<Channel*> activeChannels;
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, timeout);
    errif(nfds == -1, "epoll wait error");
    for(int i = 0; i < nfds; ++i){
        Channel *ch = (Channel*)events[i].data.ptr;
        ch->setRevents(events[i].events);
        activeChannels.push_back(ch);
    }
    return activeChannels;
}

看完这三个例子,这块的更新也就讲完了,耶( •̀ ω •́ )y

Day06 事件驱动

什么是事件驱动?

回忆一下我们最开始Day01时,声明了socket实例,然后写了handlevent()来处理发生的事件。这样"客户端请求-服务器处理-响应给客户端"的模式有个名字叫请求驱动。当数据量很大时,这样简单的流程很容易被阻塞:比如有个客户端请求了大量数据,那服务器就一直卡在这里处理它,其它请求被堵住了。可能有人会说那我们就多搞几个线程支持并发,这样也有新的问题:不停创建线程和销毁线程都是有开销的。如果同时有数千个客户端请求,服务器为每个请求创建一个线程,会使系统的内存和 CPU 资源不堪重负。

因此在实践中,我们不为每个请求单独创建线程,而是开一个线程池,用IO复用技术来监听请求,把请求分配给对应的线程。这样的模式也叫"事件驱动模式",英文名是 Reactor 模式。但 Reactor不是只在服务器里有用,进程通信、Node.js等也会应用,它是一种思想。著名的redis和Nginx都是Reactor模式。

Reactor: I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程。

Reactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下:

  • Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;
  • 处理资源池负责处理事件,如 read -> 业务逻辑 -> send;

Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:

  • Reactor 的数量可以只有一个,也可以有多个;
  • 处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程;

将上面的两个因素排列组设一下,理论上就可以有 4 种方案选择:

  • 单 Reactor 单进程 / 线程
  • 单 Reactor 多进程 / 线程
  • 多 Reactor 单进程 / 线程
  • 多 Reactor 多进程 / 线程

事件驱动怎么实现

接下来我们把服务器改造成 "单 Reactor 多进程 / 线程" 模式。

首先我们将整个服务器抽象成一个Server类,这个类中有一个main-Reactor (在这个版本没有 sub-Reactor , 所以说是单 Reactor),里面的核心是一个负责监听事件的EventLoop成员,每次有事件发生时它就会通知我们(在程序中返回给我们Channel指针)。然后Server再根据事件的类型决定是要调用handleReadEvent( )还是newConnection( )。但这里"判断是哪种类型进而采取不同措施"虽然调用的是Server里的函数,却是通过channel发起调用的,这个机制非常重要,下面会讲。

class Server {
private:
    EventLoop *loop;
public:
    Server(EventLoop*);
    ~Server();
    void handleReadEvent(int);
    void newConnection(Socket *serv_sock);
};

好!这时关键就变成了EventLoop是什么,来看它的定义:

class EventLoop {
private:
    Epoll *ep;
    bool quit;
public:
    EventLoop();
    ~EventLoop();
    void loop();
    void updateChannel(Channel*);
};

发现其实都很熟悉,不过是绑定到一个epoll实例ep上,此外两个函数分别是loop( )负责监听并处理,updateChannel(Channel*)负责修改。
loop函数体如下:

void EventLoop::loop(){
    while(!quit){
    std::vector<Channel*> chs;
        chs = ep->poll();
        for(auto it = chs.begin(); it != chs.end(); ++it){
            (*it)->handleEvent();
        }
    }
}

函数里,先通过ep->poll( )拿到需要处理的事件,再调用channel类的handleEvent( )具体处理。而这里的 ep->poll( )就是上一章写到的:

std::vector<Channel*> Epoll::poll(int timeout){
    std::vector<Channel*> activeChannels;
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, timeout);
    errif(nfds == -1, "epoll wait error");
    for(int i = 0; i < nfds; ++i){
        Channel *ch = (Channel*)events[i].data.ptr;
        ch->setRevents(events[i].events);
        activeChannels.push_back(ch);
    }
    return activeChannels;
}

发现通过层层封装,EventLoop这个类本质还是在调用epoll_wait( )。
那监听完,又是怎么具体处理的呢?如果直接去看它的实现,你会发现只有一句:

void Channel::handleEvent(){
    callback();
}

???于是引入了此篇另一个难点"回调函数"。

回调函数

来看此时channel类的声明,已经大有不同:

class Channel
{
private:
    EventLoop *loop;
    int fd;
    //此处省略 events/revents/inEpoll
    std::function<void()> callback;
public:
    Channel(EventLoop *_loop, int _fd);
    ~Channel();

    void handleEvent();
    void enableReading();
    // 此处省略一些get和set函数
    void setRevents(uint32_t);
    void setCallback(std::function<void()>);
};

顺便附上channel.cpp里的有关部分:

void Channel::handleEvent(){
    callback();
}
void Channel::setCallback(std::function<void()> _cb){
    callback = _cb;
}

怎么理解代码里的std::function<void()> callback?

std::function<void()> 是 C++ 标准库中的一个通用多态函数包装器,它可以存储、复制和调用任何可调用对象,例如普通函数、成员函数、lambda 表达式等。你可以理解为它是一个容器,里面装的不是平时我们常见的变量啊,数据结构啊,而是函数!没错,函数也是可以这么装起来的。

在 Channel 类中,callback 用于存储与该 Channel 对象相关的事件处理逻辑。如果这个 channel 监听客户端连接,那它的 callback 可能是处理新连接的函数;如果有个 channel 监听数据可读的 Channel,其 callback 可能是读取数据的函数。

在这里,我们先声明了一个函数包装器叫callback:

std::function<void()> callback;

然后设置callback:

void Channel::setCallback(std::function<void()> _cb){
    callback = _cb;
}

调用channel的handleEvent()时,其实是在调刚才绑好的函数:

void Channel::handleEvent(){
    callback();
}

在新建Channel时,根据Channel描述符的不同分别绑定了两个回调函数,分别是newConnection()和handlrReadEvent()。

  • newConnection()函数被绑定到服务器socket上,如果服务器socket有可读事件,Channel里的handleEvent()函数实际上会调用Server类的newConnection()新建连接。具体写法为:
Channel *servChannel = new Channel(loop, serv_sock->getFd());
std::function<void()> cb = std::bind(&Server::newConnection, this, serv_sock);
servChannel->setCallback(cb);
servChannel->enableReading();

此处std::bind( );是c++标准库的函数,第一个传入可调用实体,紧接着一组参数进行绑定,bind会返回一个新的可调用对象。这个新可调用对象在被调用时,会以绑定的参数去调用原始的可调用实体。也就是说,这里的可调用实体是Server类的newConnection函数,长这样:

void Server::newConnection(Socket *serv_sock){
    InetAddress *clnt_addr = new InetAddress();      //会发生内存泄露!没有delete
    Socket *clnt_sock = new Socket(serv_sock->accept(clnt_addr));       //会发生内存泄露!没有delete
    printf("new client fd %d! IP: %s Port: %d\n", clnt_sock->getFd(), inet_ntoa(clnt_addr->addr.sin_addr), ntohs(clnt_addr->addr.sin_port));
    clnt_sock->setnonblocking();
    Channel *clntChannel = new Channel(loop, clnt_sock->getFd());
    std::function<void()> cb = std::bind(&Server::handleReadEvent, this, clnt_sock->getFd());
    clntChannel->setCallback(cb);
    clntChannel->enableReading();
}

我们给bind传了this和serv_sock作为参数进去,在被调用时,相当于调用:

newConnection(serv_sock);

哎那为什么还要传个this进去?这又涉及到bind的机制了,使用 std::bind 绑定成员函数时,必须提供一个对象实例,这样在调用绑定后的可调用对象时,才能明确是哪个对象的成员函数被调用。简单来说,因为newConnection是Server类的成员函数,要调用时必须通过某个实例,所以我们传一个this进去,this是一个隐含的指针,它指向调用成员函数的对象本身。所以可以再改写成:

newConnection(this->serv_sock);

另一个函数的道理大差不差,还是一起看一下。

  • handlrReadEvent()被绑定到新接受的客户端socket上。这样如果客户端socket有可读事件,Channel里的handleEvent()函数实际上会调用Server类的handlrReadEvent()响应客户端请求。
    Channel *clntChannel = new Channel(loop, clnt_sock->getFd());
    std::function<void()> cb = 
    std::bind(&Server::handleReadEvent, this, clnt_sock->getFd());
    clntChannel->setCallback(cb);
    clntChannel->enableReading();

精简后的server启动代码

终于,server的代码可以精简成这样了:(这也是为什么大家看很多项目源码,发现启动入口都很简单,是因为真正执行的部分往往被封装在一个个类里,对外开放的只有接口)

int main() {
    EventLoop *loop = new EventLoop();
    Server *server = new Server(loop);
    loop->loop();
    return 0;
}

先声明了loop,然后拿着这个loop去new了一个server,看看server类的构造函数:

Server::Server(EventLoop *_loop) : loop(_loop){    
    Socket *serv_sock = new Socket();
    InetAddress *serv_addr = new InetAddress("127.0.0.1", 8888);
    serv_sock->bind(serv_addr);
    serv_sock->listen(); 
    serv_sock->setnonblocking();
       
    Channel *servChannel = new Channel(loop, serv_sock->getFd());
    std::function<void()> cb = std::bind(&Server::newConnection, this, serv_sock);
    servChannel->setCallback(cb);
    servChannel->enableReading();

}

调用server类的构造函数时,完成了这些事:

  • ① 最开始写socket时做的:绑端口绑ip
  • ② 现在抽象出channel了,所以初始化channel,把channel跟EventLoop和fd绑起来。发现了吗?上一章channel绑定的还是fd和Epoll,这里变成了fd和EventLoop,但EventLoop又绑定了Epoll,说明EventLoop其实是对Epoll多封装了一层后抽象出来的东西。
  • ③ 因为这个channel是服务器里负责监听的,所以指定回调函数为newConnection。回忆一下- 服务器和客户端建立通信的过程为:
    1.服务器建立套接字,并绑定IP+端口,设置成listen模式接受客户端请求
    2.客户端建立套接字,发起连接请求
    3.服务器收到请求后,通过accept系统调用,会将请求取出,并_专门_为该连接创建一个新的套接字,也就是这里的clnt_sockfd。而此时最开始建立的套接字会一直打开,继续监听其它请求。
    这里的channel对应的就是服务器最开始建立的套接字。

前期准备都完成后,最后就是开始监听,loop->loop();再贴一下该函数的具体内容:

void EventLoop::loop(){
    while(!quit){
    std::vector<Channel*> chs;
        chs = ep->poll();
        for(auto it = chs.begin(); it != chs.end(); ++it){
            (*it)->handleEvent();
        }
    }
}

就是在不断监听,处理请求了。对应"3.服务器收到请求后,通过accept系统调用,会将请求取出,并_专门_为该连接创建一个新的套接字,也就是这里的clnt_sockfd。而此时最开始建立的套接字会一直打开,继续监听其它请求。"这步。

07_Acceptor也抽象出来

说实话这章真的挺抽象的。回顾一下服务器处理多个客户端请求的流程:

1.服务器建立套接字,并绑定IP+端口,设置成listen模式接受客户端请求

2.客户端建立套接字,发起连接请求

3.服务器收到请求后,通过accept系统调用,会将请求取出,并_专门_为该连接创建一个新的套接字,也就是这里的clnt_sockfd。而此时最开始建立的套接字会一直打开,继续监听其它请求。

考虑把"服务器建立套接字,并绑定IP+端口,设置成listen模式接受客户端请求"这块抽象出来,封装成一个acceptor类。该类有以下特点:

  • 只负责监听请求,如果有新的客户端请求,回去告诉服务器你要干活了,至于服务器后面怎么_专门_为该客户端连接创建一个新的套接字,和我没关系。
    "回去告诉服务器你要干活了"可以用上一章学的"回调机制"实现。

  • 要高效监听请求,就得丢到epoll里去,所以我的成员变量里要有一个channel,声明channel时又要有EventLoop,所以这些都要有

  • 既然我的角色相当于服务器负责监听的套接字,那fd、ip地址肯定也都要有。

由此,可得acceptor的声明为:

class Acceptor
{
private:
    EventLoop *loop;
    Socket *sock;
    InetAddress *addr;
    Channel *acceptChannel;
public:
    Acceptor(EventLoop *_loop);
    ~Acceptor();
    void acceptConnection();
    std::function<void(Socket*)> newConnectionCallback;
    void setNewConnectionCallback(std::function<void(Socket*)>);
};

接下来看下acceptor.cpp都实现了什么,首先是构造函数:

Acceptor::Acceptor(EventLoop *_loop) : loop(_loop)
{
    sock = new Socket();
    addr = new InetAddress("127.0.0.1", 8888);
    sock->bind(addr);
    sock->listen(); 
    sock->setnonblocking();
    acceptChannel = new Channel(loop, sock->getFd());
    std::function<void()> cb = std::bind(&Acceptor::acceptConnection, this);
    acceptChannel->setCallback(cb);
    acceptChannel->enableReading();
}

发现这部分的内容和之前Server类的构造函数几乎一模一样:

Server::Server(EventLoop *_loop) : loop(_loop){    
    Socket *serv_sock = new Socket();
    InetAddress *serv_addr = new InetAddress("127.0.0.1", 8888);
    serv_sock->bind(serv_addr);
    serv_sock->listen(); 
    serv_sock->setnonblocking();
       
    Channel *servChannel = new Channel(loop, serv_sock->getFd());
    std::function<void()> cb = std::bind(&Server::newConnection, this, serv_sock);
    servChannel->setCallback(cb);
    servChannel->enableReading();
}

再一次说明acceptor本质上是对Server类的server socket的封装。

唯一变动的是这里多了一层acceptor,原本的channel直接绑定Server类的newConnection,
这里的channel先绑了acceptor的acceptConnection函数,然后Server初始化时,又指定了acceptor绑定Server类的newConnection:

Server::Server(EventLoop *_loop) : loop(_loop), acceptor(nullptr){ 
    acceptor = new Acceptor(loop);
    std::function<void(Socket*)> cb = std::bind(&Server::newConnection, this, std::placeholders::_1);
    acceptor->setNewConnectionCallback(cb);
}

所以绑定的东西本质还是没变,只不过中间多了一层acceptor,回调机制从:channel -> server 变成了channel -> acceptor -> server。

我们费尽周折这么做的意义何在?

  • 职责分离,模块之间解耦
  • 抽象出来的acceptor很通用,服务器可以借此处理多个服务。比如一个acceptor监听80端口,另一个监听25端口,两个之间不会互相阻塞。
  • 如果搭配linux的SO_REUSEPORT(它允许多个socket绑定同一个端口),可以提高并发能力。

后两点是我的个人判断,至于到底为什么要抽象出acceptor,在原教程和陈硕的《Linux多线程服务端编程》里都没找到很满意的答案。

posted @   liyishui  阅读(11)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
点击右上角即可分享
微信分享提示