【Linux/C++网络篇(二) 】TCP并发服务器演进史:从多进程到Epoll的进化指南

⭐️在这个怀疑的年代,我们依然需要信仰

个人主页:YYYing.

⭐️Linux/C++进阶 系列专栏:【从零开始的linux/c++进阶编程】

系列上期内容:【Linux/C++网络篇(一) 】TCP/UDP 编程模型与 Socket 网络编程

系列下期内容:暂无


目录

前言:

TCP并发服务器的引入

一、为什么我们需要并发?

二、多进程实现并发服务器

[📖 优点](#📖 优点)

[📖 缺点](#📖 缺点)

[📖 模型总结](#📖 模型总结)

三、多线程实现并发服务器

[📖 优点](#📖 优点)

[📖 缺点](#📖 缺点)

[📖 模型总结](#📖 模型总结)

I/O多路复用

一、IO多路复用之select

[📖 select函数](#📖 select函数)

[📖 select的基本用法](#📖 select的基本用法)

[📖 select实现TCP并发服务器](#📖 select实现TCP并发服务器)

[📖 优缺点](#📖 优缺点)

[📖 模型总结](#📖 模型总结)

二、IO多路复用之poll

[📖 poll函数](#📖 poll函数)

[📖 poll实现TCP客户端](#📖 poll实现TCP客户端)

[📖 优缺点](#📖 优缺点)

[📖 总结select和poll的区别](#📖 总结select和poll的区别)

三、IO多路复用之epoll

[📖 有关epoll的基础概念](#📖 有关epoll的基础概念)

[📖 epoll相关API函数](#📖 epoll相关API函数)

[📖 epoll实现TCP并发服务器](#📖 epoll实现TCP并发服务器)

[📖 模型总结](#📖 模型总结)

[📖 epoll的两种工作模式](#📖 epoll的两种工作模式)

验证水平模式

验证边缘模式

结语

---⭐️封面自取⭐️---



前言:

对于我们上节课的TCP模型来说,每一个初学者都会遇到同一个"拦路虎":为什么我的服务器只能和一个客户端聊天?一旦第二个客户端尝试连接,程序就仿佛"死"了一样毫无反应?

那么今天,我们就来彻底解决这个问题,聊聊TCP并发服务器的三种主流实现方案:多进程、多线程和IO多路复用。


TCP并发服务器的引入

一、为什么我们需要并发?

想象一下,你开了一家只有一名服务员的餐厅(单进程/单线程服务器)。

  • 第一位客人进来了,点菜、吃饭、结账,服务员全程陪同。
  • 在这期间,第二位、第三位客人只能在门口排队,看着第一位客人吃,直到他离开才能进门。

这显然不是一家"网红餐厅"该有的样子。在计算机网络中,如果服务器一次只能处理一个连接,那它的吞吐量将低得令人发指。我们需要的是"并发"------即在同一时间段内,同时处理多个客户端的请求。


二、多进程实现并发服务器

📖 核心思想:我们主进程可以用于完成对客户端的连接请求,而子进程可以完成对客户端通信操作

话不多说,我们直接进入代码与演示环节

先看代码,我们需要注意的是此处僵尸进程的回收方式,而需要注意的点正如我们代码块说的:如果我们主线程是以wait()函数这种阻塞方式来进行回收,那么此时若没有客户端下线,我们的主线程就得一直等着,这样是没办法处理其他客户端的请求,从而达到并发的效果的,那么**waitpid(-1, NULL, WNOHANG)**就可以了吗?答案是也不行,若当客户端已经下线了,而我们的主线程还在上面的accept处卡着,那么就不能到下面进行回收了。

所以看到我们main函数一开始的写法,我们需要用到信号处理的方法,若子线程发送了SIGCHLD信号,那么我们主线程会自动处理对于这个信号的处理

cpp 复制代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
using namespace std;
#define SER_PORT 8888
#define SER_IP "192.168.160.129"

void handler(int signo){
    if(signo == SIGCHLD){
        while(waitpid(-1, NULL, WNOHANG) > 0);    // 循环非阻塞回收僵尸进程
    }
}

int main(int argc, const char *argv[]){
    // 对于下方回收子进程资源的解决方案
    // 我们可以将子信号发送的SIGCHLD信号连接到自定义信号处理函数中
    if(signal(SIGCHLD, handler) == SIG_ERR){
        perror("signal error");
        return -1;
    }

    // 1、创建用于连接的套接字文件描述符
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    if(sfd == -1){
        perror("socket error");
        return -1;
    }
    printf("socket success sfd = %d\n", sfd);

    // 2、绑定ip地址与端口号
    struct sockaddr_in sin;
    sin.sin_family = AF_INET;
    // 此处绑定数字都为网络字节序
    sin.sin_port = htons(SER_PORT);
    sin.sin_addr.s_addr = inet_addr(SER_IP);

    if(bind(sfd, (struct sockaddr*)&sin, sizeof(sin)) == -1){
        perror("bind error");
        return -1;
    }
    printf("bind success\n");

    // 3、启动监听
    if(listen(sfd, 128) == -1){
        perror("listen error");
        return -1;
    }
    printf("listen success\n");

    // 4、阻塞等待客户端的连接请求
    struct sockaddr_in cin;
    socklen_t socklen = sizeof(cin);
    while(1){
        int newfd = accept(sfd, (struct sockaddr*)&cin, &socklen);
        if(newfd == -1){
            perror("accept error");
            return -1;
        }
        printf("[%s:%d]:已连接成功!!!\n", inet_ntoa(cin.sin_addr), ntohs(cin.sin_port));
        
        // 创建子进程用于和客户端通信
        pid_t pid = fork();
        if(pid > 0){
            // wait(NULL);  不可以使用该种方式,因为是阻塞回收,没有子进程退出时,就会一直在此处阻塞
            // 那此时我们主进程就没办法继续连接更多的客户端了,相当于给我们循环服务器穿了个内裤
            // 啥事都没干,同样非阻塞waitpid(-1, NULL, WNOHANG)也是不行的,如果我们客户端已经结束了,
            // 但我们主线程还在accept处阻塞,此时子进程资源还是没办法回收
            
            // 关闭newfd,此时我们父进程不对newfd进行操作,同理子进程也不对sfd进行操作
            close(newfd);
        }else if(pid == 0){
            // 5、数据收发
            close(sfd);
            char rbuf[128] = "";
            while(1){
                bzero(rbuf, sizeof(rbuf));
                if(recv(newfd, rbuf, sizeof(rbuf), 0) == 0){
                    printf("对端已下线\n");
                    break;
                }
                printf("[%s:%d]:%s\n", inet_ntoa(cin.sin_addr), ntohs(cin.sin_port), rbuf);
                strcat(rbuf, "*_*");
                if(send(newfd, rbuf, strlen(rbuf), 0) == -1){
                    perror("send error");
                    return -1;
                }
                printf("send success\n");
            }
            close(newfd);
            exit(EXIT_SUCCESS);
        }else{
            perror("fork error");
            return -1;
        }
    }

    // 6、关闭套接字
    close(sfd);

    return 0;
}

并发效果展示,可以看到我们现在可以用两个客户端同时连接了

📖 优点

  • 稳定性高:一个进程崩溃不影响其他客户端

  • 隔离性好:进程间内存独立,安全性高

📖 缺点

  • 资源消耗大:每个进程占用独立地址空间(几MB起步),连接数上千时系统吃不消

  • 上下文切换开销:CPU 在进程间切换需要保存/恢复大量状态

  • 编程复杂:需要处理僵尸进程、信号处理等

📖 模型总结

cpp 复制代码
void handler(int signo){
    while(waitpid() > 0);       //以非阻塞形式回收僵尸进程
}


signal(SIGCHLD, handler);        //将信号与信号处理函数连接起来
sfd = socket();    //创建用于链接的套接字文件描述符
bind();             //绑定ip地址和端口号
listen();            //启动被动监听状态

while(1){
        newfd = accept();       //阻塞接受客户端连接请求
        
        pid = fork();     //创建子进程用于跟客户端进行通信
        if(pid > 0){
            //关闭newfd
            close(newfd);
        }
        else if(pid == 0){
            //子进程
            close(sfd);
            //跟客户端进行数据收发
            recv();
            send();
            close(newfd);
            
            exit(EXIT_SUCCESS);    //退出子进程
        }
        
}
close(sfd);

三、多线程实现并发服务器

📖 **核心思想:**与多进程一样,主线程用于接收客户端的连接请求, 分支线程用于跟客户端进行通信

此处我们多线程的实现就不用像多进程那些相当那么多,我们在创建线程的下方将线程设置为分离态即可

cpp 复制代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <pthread.h>
using namespace std;
#define SER_PORT 8888
#define SER_IP "192.168.160.129"

struct Info{
    int newfd;
    struct sockaddr_in cin;
};

void *deal_cli_msg(void *arg){
    // 解析传过来的参数
    int newfd = ((struct Info*)arg)->newfd;
    struct sockaddr_in cin = ((struct Info*)arg)->cin;

    // 5、数据收发
    char rbuf[128] = "";
    while(1){
        bzero(rbuf, sizeof(rbuf));
        int res = recv(newfd, rbuf, sizeof(rbuf), 0);
        if(res == 0){
            printf("对端已下线\n");
            break;
        }
        printf("[%s:%d]:%s\n", inet_ntoa(cin.sin_addr), ntohs(cin.sin_port), rbuf);
        strcat(rbuf, "*_*");
        if(send(newfd, rbuf, strlen(rbuf), 0) == -1){
            perror("send error");
            return NULL;
        }
        printf("send success\n");
    }
    close(newfd);
    pthread_exit(NULL);
}

int main(int argc, const char *argv[]){
    // 1、创建用于连接的套接字文件描述符
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    if(sfd == -1){
        perror("socket error");
        return -1;
    }
    printf("socket success sfd = %d\n", sfd);

    // 2、绑定ip地址与端口号
    struct sockaddr_in sin;
    sin.sin_family = AF_INET;
    // 此处绑定数字都为网络字节序
    sin.sin_port = htons(SER_PORT);
    sin.sin_addr.s_addr = inet_addr(SER_IP);

    if(bind(sfd, (struct sockaddr*)&sin, sizeof(sin)) == -1){
        perror("bind error");
        return -1;
    }
    printf("bind success\n");

    // 3、启动监听
    if(listen(sfd, 128) == -1){
        perror("listen error");
        return -1;
    }
    printf("listen success\n");

    // 4、阻塞等待客户端的连接请求
    struct sockaddr_in cin;
    socklen_t socklen = sizeof(cin);
    while(1){
        // 对accept而言,程序运行到此处时,
        // 会给当前客户端预分配一个最小未被使用的文件描述符
        // 后期再有新的更小的文件描述符也再不使用了,新的最小的用于下一次客户端的选定
        int newfd = accept(sfd, (struct sockaddr*)&cin, &socklen);
        if(newfd == -1){
            perror("accept error");
            return -1;
        }
        printf("[%s:%d]:已连接成功!!!\n", inet_ntoa(cin.sin_addr), ntohs(cin.sin_port));
        
        struct Info buf = {newfd, cin};

        pthread_t tid = -1;
        if(pthread_create(&tid, NULL, deal_cli_msg, &buf) != 0){
            printf("pthread_create error\n");
            return -1;
        }
        // 回收资源
        pthread_detach(tid);
    }

    // 6、关闭套接字
    close(sfd);

    return 0;
}

我们可以来看看我们运行效果,我们此处的并发效果依旧是有的

📖 优点

  • 资源占用少:线程栈通常只有几MB,且共享代码段和数据段

  • 切换速度快:线程上下文切换比进程快一个数量级

  • 通信简单:共享内存,数据交换方便

📖 缺点

  • 稳定性风险:一个线程崩溃可能导致整个进程退出(影响所有客户端)

  • 同步复杂:共享数据需要加锁(mutex),容易出现死锁、竞态条件

  • C10K 问题:连接数达到万级别时,线程切换开销仍不可忽视

📖 模型总结

cpp 复制代码
//定义分支线程
void *deal_cli_msg(void *arg){
    //跟客户端进行数据收发
    recv();
    send();
    close(newfd);
    //退出线程
    pthread_exit(NULL);
}

//主线程内容
sfd = socket();    //创建用于链接的套接字文件描述符
bind();             //绑定ip地址和端口号
listen();      //启动被动监听状态
       
while(1){
    newfd = accept();       //阻塞接受客户端连接请求

    pthread_create(&tid, NULL, deal_cli_msg, &buf);    //创建分支线程

    //将线程设置成分离态
    pthread_detach(tid);
}
close(sfd);

I/O多路复用

📖 核心思想:一个线程监视多个文件描述符 ,哪个客户端有数据来就处理哪个,没有数据时阻塞在 select/poll/epoll 上,不浪费 CPU。

在开讲之前呢,我想先引入一个例子,助于大家后续理解我们I/O多路复用的含义:

事件和函数的关系:比如,scanf是一个阻塞函数,该函数完成的是输入事件

当函数先于事件发生时,函数会阻塞等待事件的到来

当函数后于事件发生时,函数就不会阻塞,直接执行

最典型的就是我们在luogu上写题,输入样例数据的时候我们是直接一次性输入完了的,所以程序就会直接执行,但在一般的IDE上,我们是先执行程序后输入数据的,那么此时我们就会被阻塞,直到输入数据。

**不难看出:**如果事件先于函数发生,当程序再执行到函数时,函数会直接运行,不会阻塞了

那么我们现在可以看看我们I/O多路复用的原理图了。

可以看到我们现在相当于是有一个文件描述符集合在等着我们的阻塞函数进行检查,然后当有事件产生于集合中,我们阻塞函数就会去执行相应的事件,执行完后就又会回去继续进行检验是否有其他事件产生。

我们可以理解为以下的框架

  1. 准备集合:有一个文件描述符(FD)集合。
  2. 阻塞等待 :调用一个函数(如 selectepoll_wait)并阻塞,等待集合中的 FD 发生事件。
  3. 事件就绪:当有事件发生时,函数返回。
  4. 处理事件:程序处理这些就绪的事件。
  5. 循环:回到第 2 步,继续等待。

但不同的 I/O 多路复用技术,在实现这个框架时,效率和机制天差地别。


一、IO多路复用之select

📖 select函数

|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 函数原型 | int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout); |
| 头文件 | sys/select.h |
| 功能 | 阻塞等待文件描述符集合中是否有事件产生,如果有事件产生,则解除阻塞 |
| 参数说明 | 参数1:文件描述符集合中,最大的文件描述符加1 参数2、参数3、参数4:分别表示读集合、写集合、异常处理集合的起始地址 由于对于写操作而言,我们也可以转换读操作,所以,只需要使用一个集合就行 对于不使用的集合而言,直接填NULL即可 参数5:超时时间,如果填NULL表示永久等待,如果想要设置时间,需要定义一个如下结构体类型 的变量,并将地址传递进去 struct timeval { long tv_sec; /* 秒数 */ long tv_usec; /* 微秒 */ }; and struct timespec { long tv_sec; /* 秒数 */ long tv_nsec; /* 纳秒 */ }; |
| 返回值 | >0:成功返回解除本次阻塞的文件描述符的个数 =0:表示设置的超时时间,时间已经到达,但是没有事件事件产生 =-1:表示失败,置位错误码 |

注意:当该函数解除阻塞时,文件描述符集合中,就只剩下本次触发事件的文件描述符,其余的文件描述符就被删除了

cpp 复制代码
//专门针对于文件描述符集合提供的函数
       void FD_CLR(int fd, fd_set *set);       //将fd文件描述符从容器set中删除
       int  FD_ISSET(int fd, fd_set *set);     //判断fd文件描述符,是否存在于set容器中
       void FD_SET(int fd, fd_set *set);      //将fd文件描述符,放入到set容器中
       void FD_ZERO(fd_set *set);               //清空set容器

📖 select的基本用法

在实现tcp并发服务器之前,我们先看看select的基本用法:

该需要注意的点代码块里也写了,在此就不过多赘述了。

cpp 复制代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
using namespace std;
#define SER_PORT 8888          //服务器端口号
#define SER_IP "192.168.31.49"     //服务器IP地址

int main(int argc, const char *argv[]) {
    //1、创建用于连接的套接字文件描述符
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    //参数1:AF_INET表示使用的是ipv4的通信协议
    //参数2:SOCK_STREAM表示使用的是tcp通信
    //参数3:由于参数2指定了协议,参数3填0即可
    if(sfd == -1){
        perror("socket error");
        return -1;
    }
    printf("socket success sfd = %d\n", sfd);

    //2、绑定ip地址和端口号
    //2.1 填充要绑定的ip地址和端口号结构体
    struct sockaddr_in sin;
    sin.sin_family = AF_INET;       //通信域
    sin.sin_port = htons(SER_PORT);   //端口号
    sin.sin_addr.s_addr = inet_addr(SER_IP);    //ip地址

    //2.2 绑定工作
    //参数1:要被绑定的套接字文件描述符
    //参数2:要绑定的地址信息结构体,需要进行强制类型转换,防止警告
    //参数3:参数2的大小
    if(bind(sfd, (struct sockaddr*)&sin, sizeof(sin)) ==-1){
        perror("bind error");
        return -1;
    }
    printf("bind success\n");

    //3、启动监听
    //参数1:要启动监听的文件描述符
    //参数2:挂起队列的长度
    if(listen(sfd, 128) ==-1){
        perror("listen error");
        return -1;
    }
    printf("listen success\n");

    //4、阻塞等待客户端的连接请求
    //定义变量,用于接受客户端地址信息结构体
    struct sockaddr_in cin;             //用于接收地址信息结构体的
    socklen_t socklen = sizeof(cin);        //用于接收地址信息的长度

    //定义文件描述符集合
    fd_set readfds, tempfds;       //读文件描述符集合
    //将该文件描述符集合清空
    FD_ZERO(&readfds);
    //将0号文件描述符以及sfd文件描述符放入到集合中
    FD_SET(0, &readfds);
    FD_SET(sfd, &readfds);

    while(1){
        //将reafds备份一份放入tempfds中
        tempfds = readfds;
        //调用阻塞函数,完成对文件描述符集合的管理工作
        int res = select(sfd+1, &tempfds, NULL, NULL, NULL);
        if(res == -1){
            perror("select error");
            return -1;
        }else if(res == 0){
            printf("time out !!!\n");
            return -1;
        }

        //程序执行至此,表示一定有其中至少一个文件描述符产生了事件,只需要判断哪个文件描述符还
在集合中
        //就说明该文件描述符产生了事件
        //表示sfd文件描述符触发了事件
        if(FD_ISSET(sfd, &tempfds)){
            int newfd = accept(sfd, (struct sockaddr *)&cin, &socklen);
            //参数1:服务器套接字文件描述符
            //参数2:用于接收客户端地址信息结构体的容器,如果不接收,也可以填NULL
            //参数3:接收参数2的大小,如果参数2为NULL,则参数3也是NULL
            if(newfd == -1){
                perror("accept error");
                return -1;
            }

            printf("[%s:%d]:已连接成功,newfd = %d!!!!\n", 
                    inet_ntoa(cin.sin_addr), 
                    ntohs(cin.sin_port), newfd);
        }
        //判断0号文件描述符是否产生了事件
        if(FD_ISSET(0, &tempfds)){
            char wbuf[128] = "";     //字符数组
            fgets(wbuf, sizeof(wbuf), stdin);      //从终端读取数据,阻塞函数
            printf("触发了键盘输入事件:%s\n", wbuf);
        }
                                                               
    }
    close(sfd);

    return 0;
}

📖 select实现TCP并发服务器

值得一提的是此处的tempfds,我们为什么需要定义这个tempfds,要理解为什么需要它,我们必须先了解 select 函数的一个核心特性:"破坏性"

  1. 调用前 :你传给 select 的集合,告诉它"我对这些文件描述符感兴趣,请帮我检查它们"。
  2. 调用后select 返回时,它会原地修改 这个集合。集合中所有没有 发生事件的 FD 对应的位都会被清零,只留下那些已经就绪(有事件发生)的 FD。
  • readfds

    • 这是一个永久性的、完整的文件描述符集合。
    • 它记录了从程序开始到当前,所有你希望监控的 FD,包括监听套接字 sfd、标准输入 0 以及所有已连接的客户端套接字。
    • 它在整个服务器的主循环中保持不变(除非有新连接加入或旧连接断开)。
  • tempfds

    • 这是一个临时性的、一次性的集合。
    • 在每次 select 调用之前 ,代码都会执行 tempfds = readfds;。这相当于给 readfds 拍了一张"快照",并将这份快照交给 select 函数。
    • select 函数会放心地修改这份"快照"(tempfds),把其中未就绪的 FD 全部清除。
    • select 返回后,tempfds 就只包含本次循环中那些已经就绪的 FD。你的程序通过 FD_ISSET(i, &tempfds) 来判断具体是哪个 FD 触发了事件。
cpp 复制代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
using namespace std;
#define SER_PORT 8888
#define SER_IP "192.168.160.129"

int main(int argc, const char *argv[])
{
    // 1、创建用于连接的套接字文件描述符
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    // 参数1:AF_INET表示使用的是ipv4的通信协议
    // 参数2:SOCK_STREAM表示使用的是tcp通信
    // 参数3:由于参数2指定了协议,参数3填0即可
    if (sfd == -1)
    {
        perror("socket error");
        return -1;
    }
    printf("socket success sfd = %d\n", sfd); // 3
    // 2、绑定ip地址和端口号
    // 2.1 填充要绑定的ip地址和端口号结构体
    struct sockaddr_in sin;
    sin.sin_family = AF_INET;                // 通信域
    sin.sin_port = htons(SER_PORT);          // 端口号
    sin.sin_addr.s_addr = inet_addr(SER_IP); // ip地址
    // 2.2 绑定工作
    // 参数1:要被绑定的套接字文件描述符
    // 参数2:要绑定的地址信息结构体,需要进行强制类型转换,防止警告
    // 参数3:参数2的大小
    if (bind(sfd, (struct sockaddr *)&sin, sizeof(sin)) == -1)
    {
        perror("bind error");
        return -1;
    }
    printf("bind success\n");
    // 3、启动监听
    // 参数1:要启动监听的文件描述符
    // 参数2:挂起队列的长度
    if (listen(sfd, 128) == -1)
    {
        perror("listen error");
        return -1;
    }
    printf("listen success\n");
    // 4、阻塞等待客户端的连接请求
    // 定义变量,用于接受客户端地址信息结构体
    struct sockaddr_in cin;          // 用于接收地址信息结构体的
    socklen_t socklen = sizeof(cin); // 用于接收地址信息的长度
    // 定义文件描述符集合
    fd_set readfds, tempfds; // 读文件描述符集合
    // 将该文件描述符集合清空
    FD_ZERO(&readfds);
    // 将0号文件描述符以及sfd文件描述符放入到集合中
    FD_SET(0, &readfds);
    FD_SET(sfd, &readfds);
    // 定义一个变量,用于存储容器中的最大文件描述符
    int maxfd = sfd;
    int newfd = -1; // 接收客户端连接请求后,创建的通信套接字文件描述符
    //定义一个地址信息结构体数组来存储客户端对应的地址信息
    struct sockaddr_in cin_arr[1024];
    while (1)
    {
        // 将reafds备份一份放入tempfds中
        tempfds = readfds;
        // 调用阻塞函数,完成对文件描述符集合的管理工作
        int res = select(maxfd + 1, &tempfds, NULL, NULL, NULL);
        if (res == -1)
        {
            perror("select error");
            return -1;
        }
        else if (res == 0)
        {
            printf("time out !!!\n");
            return -1;
        }
        // 程序执行至此,表示一定有其中至少一个文件描述符产生了事件,只需要判断哪个文件描述符还在集合中
        // 就说明该文件描述符产生了事件
        // 表示sfd文件描述符触发了事件
        if (FD_ISSET(sfd, &tempfds))
        {
            newfd = accept(sfd, (struct sockaddr *)&cin, &socklen);
            // 参数1:服务器套接字文件描述符
            // 参数2:用于接收客户端地址信息结构体的容器,如果不接收,也可以填NULL
            // 参数3:接收参数2的大小,如果参数2为NULL,则参数3也是NULL
            if (newfd == -1)
            {
                perror("accept error");
                return -1;
            }
            printf("[%s:%d]:已连接成功,newfd = %d!!!!\n", 
                    inet_ntoa(cin.sin_addr),
                    ntohs(cin.sin_port), newfd);
            //将该客户端对应的套接字地址信息结构体放入数组对应的位置上
            cin_arr[newfd] = cin;           //newfd文件描述符对应的地址信息结构体未cin_arr[newfd]
            // 将当前的newfd放入到检测文件描述符集合中,以便于检测使用
            FD_SET(newfd, &readfds); // 加入到tempfds中
            // 更新maxfd,如何更新?
            if (maxfd < newfd) // 判断最新的文件描述符是否比当前容器中最大的文件描述符大
            {
                maxfd = newfd;
            }
        }
        // 判断0号文件描述符是否产生了事件
        if (FD_ISSET(0, &tempfds))
        {
            char wbuf[128] = "";              // 字符数组
            fgets(wbuf, sizeof(wbuf), stdin); // 从终端读取数据,阻塞函数
            printf("触发了键盘输入事件:%s\n", wbuf);
            //能不能将输入的数据,全部发送给所有客户端
            for(int i=4; i<=maxfd; i++)
            {
                send(i, wbuf, strlen(wbuf), 0);     //将数据发送给所有客户端
            }
        }
        // 判断是否是newfd产生了事件
        // 循环将所有客户端文件描述符遍历一遍,如果还存在于tempfds中的客户端,表示有数据接收过来
        for (int i = 4; i <= maxfd; i++)
        {
            if (FD_ISSET(i, &tempfds))
            {
                // 5、数据收发
                char rbuf[128] = ""; // 数据容器
                // 清空容器中的内容
                bzero(rbuf, sizeof(rbuf));
                // 从套接字中读取消息
                int res = recv(i, rbuf, sizeof(rbuf), 0);
                if (res == 0)
                {
                    printf("对端已经下线\n");
                    // 将文件描述符进行关闭
                    close(i);
                    //需要将该文件描述符从readfds中删除
                    FD_CLR(i, &readfds);
                    // 更新maxfd
                    for(int k=maxfd; k>=0; k--)
                    {
                        if(FD_ISSET(k, &readfds))
                        {
                            maxfd = k;
                            break;       //结束向下进行的循环
                        }
                    }
                    continue; // 本次循环结束,继续下一次的select的阻塞
                }
                printf("[%s:%d]:%s\n",
                        inet_ntoa(cin_arr[i].sin_addr),
                        ntohs(cin_arr[i].sin_port), rbuf);
                // 对收到的数据处理一下,回给客户端
                strcat(rbuf, "*_*");
                // 将消息发送给客户端
                if (send(i, rbuf, strlen(rbuf), 0) == -1)
                {
                    perror("send error");
                    return -1;
                }
                printf("发送成功\n");
            }
        }
    }
    close(sfd);

    return 0;
}

可以看到我们的服务器依旧是可以一对多的


📖 优缺点

优点 缺点
1. 兼容性强:POSIX 标准,几乎所有 Unix 系统都支持 1. 连接数受限:位图结构硬编码 1024 上限
2. 精度高:超时时间可精确到微秒级 2. 全量扫描:O(n) 遍历所有 fd,无论是否就绪
3. 实现简单:代码直观,适合教学演示 3. 重复拷贝:每次调用两次数据拷贝,且破坏原集合需重置

📖 模型总结

cpp 复制代码
sfd = socket();        //创建用于连接的套接字文件描述符
bind();                //绑定ip和端口号
listen();               //监听

fd_set  readfds, tempfds;    //定义文件描述符集合
FD_ZERO();        //清空容器
FD_SET();         //将文件描述符放入容器
maxfd = sfd;         //记录最大的文件描述符

while(1)
{
    tempfds = readfds;      //备份一份容器
    select(maxfd, &readfds, NULL, NULL, NULL);     //阻塞等待集合中是否有事件产生
    
    //判断相关文件描述符是否在集合中
    if(FD_ISSET(sfd, &tempfds))
    {
        newfd = accept();      //接收客户端请求
        FD_SET(newfd, &readfds);    //将新文件描述符放入集合
        //更新maxfd
    }
    
    //判断是否是客户端发来数据
    for(i=4; i<=maxfd; i++)
    {
        send();
        recv();
        close(i);     //退出客户端
        FD_CLR(i, &readfds);
        //更新maxfd
    }
    
}
//关闭监听
close(sfd);

二、IO多路复用之poll

📖 poll函数

|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 函数原型 | int poll(struct pollfd *fds, nfds_t nfds, int timeout); |
| 头文件 | poll.h |
| 功能 | 阻塞等待文件描述符集合中是否有事件产生,如果有,则解除阻塞,返回本次触发事件的文件 描述符个数 |
| 参数说明 | 参数1:文件描述符集合容器的起始地址,是一个结构体数组,结构体类型如下 struct pollfd { int fd; /* 文件描述符 */ short events; /* 要等待的事件:由用户填写 */ short revents; /* 实际发生的事件 :调用函数结束后,内核会自动设置*/ }; 关于事件对应的位: POLLIN:读事件 POLLOUT:写事件 参数2:集合中文件描述符的个数 参数3:超时时间,负数表示永久等待,0表示非阻塞 |
| 返回值 | >0:表示触发本次解除阻塞事件的文件描述符的个数 =0:表示超时 =-1:出错,置位错误码 |


📖 poll实现TCP客户端

老写服务器也不好,这次我们用poll完成TCP客户端中发送数据和读取数据的并发,再搭配上我们刚才select写的服务器。

cpp 复制代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <poll.h>
#define SER_PORT 8888
#define SER_IP "192.168.160.129"
#define CLI_PORT 9999
#define CLI_IP "192.168.160.129"
int main(int argc, const char *argv[]){
    // 1、创建用于通信的客户端套接字文件描述符
    int cfd = socket(AF_INET, SOCK_STREAM, 0);
    if(cfd == -1){
        perror("socket error");
        return -1;
    }
    printf("socket success cfd = %d\n", cfd);   // 3

    // 2、绑定ip与端口号(可选)

    struct sockaddr_in cin;
    cin.sin_family = AF_INET;
    cin.sin_port = htons(CLI_PORT);
    cin.sin_addr.s_addr = inet_addr(CLI_IP);

    if(bind(cfd, (struct sockaddr*)&cin, sizeof(cin)) == -1){
        perror("bind error");
        return -1;
    }
    printf("绑定成功\n");

    // 3、链接服务器
    struct sockaddr_in sin;
    sin.sin_family = AF_INET;
    sin.sin_port = htons(SER_PORT);
    sin.sin_addr.s_addr = inet_addr(SER_IP);

    if(connect(cfd, (struct sockaddr*)&sin, sizeof(sin)) == -1){
        perror("connect error");
        return -1;
    }
    printf("连接服务器成功\n");
    
    // 使用poll完成终端输入和套接字接收数据的并发执行
    struct pollfd pfds[2];
    pfds[0].fd = 0;          // 表示检测0号
    pfds[0].events = POLLIN; // 表示检测的是读事件

    pfds[1].fd = cfd;        // 检测cfd文件描述符
    pfds[1].events = POLLIN; // 检测读事件
    // 4、数据收发
    char wbuf[128] = "";
    while(1){
        int res = poll(pfds, 2, -1);
        // 功能:阻塞等待文件描述符集合中是否有事件产生
        // 参数1:文件描述符集合起始地址
        // 参数2:文件描述符个数
        // 参数3:表示永久等待
        if(res == -1){
            perror("poll error");
            return -1;
        }

        // 程序执行至此,表示文件描述符容器中,有事件产生
        // 表示0号文件描述符的事件
        if(pfds[0].revents == POLLIN){
            // 清空容器
            bzero(wbuf, sizeof(wbuf));
            fgets(wbuf, sizeof(wbuf), stdin);
            wbuf[strlen(wbuf) - 1] = 0;     // 将换行改掉
            
            if(send(cfd, wbuf, sizeof(wbuf), 0) == -1){
                perror("send error");
                return -1;
            }
        }

        if(pfds[1].revents == POLLIN){
            if(recv(cfd, wbuf, sizeof(wbuf), 0) == 0){
                printf("对端已下线\n");
                break;
            }
            printf("收到服务器消息为:%s\n",wbuf);
        }
    }

    // 5、关闭套接字
    close(cfd);

    return 0;
}

此处我们来验证一波,看图即可


📖 优缺点

优点 缺点
1. 无连接上限:链表结构突破 1024,支持万级 fd 1. 仍是 O(n):保持线性扫描,十万级连接时吃力
2. 接口友好:不破坏输入数据,无需每次重置数组 2. 拷贝开销:每次全量拷贝 pollfd 数组,内存压力大
3. 事件丰富:支持 POLLRDHUP 等更多事件类型 3. 查找低效:返回后仍需遍历数组定位就绪 fd

📖 总结select和poll的区别

1、select管理三个文件描述符集合,poll只管理一个,但是,可以操作很多事件

2、select管理的文件描述符有上限,一般是1024个,而poll管理文件描述符没有这个限制

3、对于效率而言,poll的效率比select的略高


三、IO多路复用之epoll

📖 有关epoll的基础概念

epoll全称为eventpoll,是内核实现IO多路复用的一种实现方式。在其中一个或多个事件的到 满足时,可以解除阻塞。

epoll是select和poll的升级版,相比于select和poll而言,epoll改进了工作方式,效率更高。

1、sellect和poll都是基于线性结构进行检测集合,而epoll是基于树形结构(红黑树)完成管理检测集合的

2、select和poll检测时,随着集合的增大,效率会越来越低。epoll使用的是函数回调机制,效率较高。处理文件描述符的效率也不会随着文件秒数的增大而降低。

3、select和poll在工作过程中,不断的在内核空间与用户空间频繁拷贝文件描述符的数据。epoll在注册新的文件描述符或者修改文件描述符时,只需进行一次,能够有效减少数据在用户空间和内核空间之间的切换

4、和poll一样,epoll没有最大文件描述符的限制,仅仅收到程序能够打开的最大文件描述符数量限制

5、对于select和poll而言,需要对返回的文件描述符集合进行判断后才知道时哪些文件描述符就绪了,而epoll可以直接得到已经就绪的文件描述符,无需再次检测

6、当多路复用比较频发进行、IO流量频繁的时候,一般不使用select和poll,使用epoll比较合适

7、epoll只适用于linux平台,不能跨平台操作


📖 epoll相关API函数

1、创建epoll实例

|----------|-------------------------------------------------------------------|
| 函数原型 | int epoll_create(int size); |
| 头文件 | sys/epoll.h |
| 功能 | 创建一个epoll实例,并返回该实例的句柄,是一个文件描述符 |
| 参数说明 | epoll实例中能够容纳的最大节点个数,自从linux 2.6.8版本后,size可以忽略,但是 必须要是一个大于0的数字 |
| 返回值 | 返回值:成功返回控制epoll实例的文件描述符,失败返回-1并置位错误码 |

2、控制epoll实例

|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 函数原型 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
| 头文件 | sys/epoll.h |
| 功能 | 完成对epoll实例的控制 |
| 参数说明 | 参数1:通过epoll_create创建的epoll实例文件描述符 参数2:op表示要进行的操作 EPOLL_CTL_ADD:向epoll树上添加新的要检测的文件描述符 EPOLL_CTL_MOD:改变epoll树上的文件描述符检测的事件 EPOLL_CTL_DEL:删除epoll树上的要检测的文件描述符,此时参数3可以省略填NULL 参数3:要检测的文件描述符 参数4:要检测的事件,是一个结构体变量地址,属于输入变量 typedef union epoll_data { void *ptr; //提供的解释性数据 int fd; //文件描述符(常用) uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* 要检测的事件 */ epoll_data_t data; /* 用户有效数据,是一个共用体 */ }; 要检测的事件: EPOLLIN:读事件 EPOLLOUT:写事件 EPOLLERR:异常事件 EPOLLET:表示设置epoll的模式为边沿触发模式 |
| 返回值 | 返回值:成功返回0,失败返回-1并置位错误码 |

3、阻塞检测epoll实例

|----------|--------------------------------------------------------------------------------------------------------------------------------|
| 函数原型 | int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); |
| 头文件 | sys/epoll.h |
| 功能 | 阻塞检测epoll实例中是否有文件描述符准备就绪,如果准备就绪了,就解除阻塞 |
| 参数说明 | 参数1:epoll实例对于的文件描述符 参数2:文件描述符集合,当有文件描述符产生事件时,将所有产生事件的文件描述符,放入到该集合中 参数3:参数2的大小 参数4:超时时间,以毫秒为单位的超时时间,如果填-1表示永久阻塞 |
| 返回值 | >0:表示解除本次操作的触发的文件描述符个数 =0:表示超时,但是没有文件描述符产生事件 =-1:失败,置位错误码 |


📖 epoll实现TCP并发服务器

cpp 复制代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <poll.h>
#include <sys/epoll.h>
using namespace std;
#define SER_PORT 8888
#define SER_IP "192.168.160.129"

int main(int argc, const char *argv[]){
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    if(sfd == -1){
        perror("socket error");
        return -1;
    }
    printf("socket success sfd = %d\n", sfd);

    struct sockaddr_in sin;
    sin.sin_family = AF_INET;
    sin.sin_port = htons(SER_PORT);
    sin.sin_addr.s_addr = inet_addr(SER_IP);

    if(bind(sfd, (struct sockaddr*)&sin, sizeof(sin)) == -1){
        perror("bind error");
        return -1;
    }
    printf("binf success \n");
    if(listen(sfd, 128) == -1){
        perror("listen error");
        return -1;
    }
    printf("listen success \n");

    struct sockaddr_in cin;
    socklen_t socklen = sizeof(cin);

    // 创建epoll实例,用于检测文件描述符
    int epfd = epoll_create(1);
    if(epfd == -1){
        perror("epoll_create error");
        return -1;
    }

    // 将sfd放入到检测集合中
    struct epoll_event ev;
    ev.events = EPOLLIN; // 要检测的是读事件
    ev.data.fd = sfd;    // 要检测的文件描述符信息
    epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);
    // 功能:将sfd放入到检测集合中
    // 参数1:epoll实例的文件描述符
    // 参数2:epoll操作,表示要添加文件描述符
    // 参数3:要检测的文件描述符的值
    // 参数4:要检测的事件

    // 定义接收返回的事件集合
    struct epoll_event evs[1024];
    int size = sizeof(evs) / sizeof(evs[0]);

    while(1){
        int num = epoll_wait(epfd, evs, size, -1);
        // 参数1:epoll实例的文件描述符
        // 参数2:返回触发事件的文件事件集合
        // 参数3:集合的大小
        // 参数4:是否阻塞
        printf("num = %d\n", num);  // 输出本次触发的文件描述符个数

        // 循环遍历集合,判断是哪个文件描述符就绪
        for(int i=0;i<num;i++){
            int fd = evs[i].data.fd;  // 获取本次解除阻塞的文件描述符

            // 判断是否为sfd文件描述符就绪
            if(fd == sfd){
                int newfd = accept(sfd, (struct sockaddr*)&cin, &socklen);
                if(newfd == -1){
                    perror("accept error");
                    return -1;
                }
                printf("[%s:%d]:已连接成功,newfd = %d!!!!\n",
                        inet_ntoa(cin.sin_addr),
                        ntohs(cin.sin_port), newfd);

                // 将客户端文件描述符放入到epoll检测集合中
                struct epoll_event ev;
                ev.events = EPOLLIN;     // 要检测的是读事件
                ev.data.fd = newfd;      // 要检测的文件描述符信息
                epoll_ctl(epfd, EPOLL_CTL_ADD, newfd, &ev);
            }
            else{
                char rbuf[128] = "";
                bzero(rbuf, sizeof(rbuf));
                int res = recv(fd, rbuf, sizeof(rbuf), 0);
                if(res == 0){
                    printf("对端已下线!\n");
                    //将客户端从epoll树中删除
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                    break;
                }
                printf("收到数据:%s\n", rbuf);
                
                strcat(rbuf, "*_*");
                if(send(fd, rbuf, sizeof(rbuf), 0) == -1){
                    perror("send error");
                    return -1;
                }
                printf("发送成功\n");
            }
        }
    }
    close(sfd);
    close(epfd);

    return 0;
}

📖 模型总结

cpp 复制代码
1、sfd = socket();        //创建用于连接的文件描述符
2、bind();               //绑定ip地址和端口号
3、listen();           //将套接字设置成被动监听状态
4、创建一个epoll实例,并将用于连接的文件描述符放入到epoll树中
    struct epoll_event ev;
    ev.events = EPOLLIN;      //检测读事件
    ev.data.fd = sfd;         //要检测的文件描述符
    int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);
    
5、检测文件描述符是否就绪(有客户端发来连接请求)
    int num = epoll_wait(epfd, evs, size, -1);
    
6、如果检测到文件描述符就绪,则建立连接,将newfd放入到集合中
    newfd = accept();       //接收客户端连接请求
    struct epoll_event ev;
    ev.event = EPOLLIN;
    ev.data.fd = newfd;
    int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, newfd, &ev);
    
7、如果检测的是客户端文件描述符,则进行通信,如果客户端下线,则将文件描述符进行删除
    int len = recv();
    if(len == 0){
        epoll_ctl(epfd, EPOLL_CTL_DEL, newfd, NULL);   //将newfd从集合中删除
        close(newfd);
    }
    else if(len > 0){
        send();
    }

📖 epoll的两种工作模式

epoll的工作模式有两种,分别是水平触发和边沿触发

1、水平模式:

简称LT模式(level trigered),是默认的一种初始模式,并且该模式支持阻塞和非阻塞的形式

在这种模式下,当文件描述符准备就绪后,内核会通知使用者哪些文件描述符就绪了。使用者可以对就绪的文件描述符进行操作。

如果使用者不做任何操作或者没有全部处理完该文件描述符的信息,内核会继续通知使用者处理数据

特点:

  • 对于读事件:如果该文件描述符中的缓冲区中的数据没有被读取完毕,则内核会继续解除阻塞,让用户继续处理,直到缓冲区中没有数据可以处理为止。

后面的解除阻塞,是自动完成的,无需用户进行对文件描述符的后续操作。

  • 而对于写事件:检测文件描述符缓冲区是否可以,如果可用则解除阻塞,一般写文件描述符的缓冲区都是可以的,一般不对写文件描述符进行检测

2、边缘模式:

简称ET模式(edge trigered),需要手动设置该模式,并且该模式一般支持非阻塞形式

在种模式下,当文件描述符准备就绪后,内核会通知使用者哪些文件描述符就绪了,但是仅仅只通知一次

使用者可以对就绪的文件描述符进行操作

如果使用者不做任何操作,或者没有全部处理该文件描述符的信息,内核不会通知使用者再次处理数据,直到下一次该文件描述符的事件产生

特点:

  • 对于读事件:如果文件描述符中的缓冲区数据没有读取完毕,则内核也不会再次解除阻塞,直到下一次的该文件描述符事件产生,但是下一次的文件描述符的事件,读取的是上一次没有读取完毕的内容

  • 对于写事件:检测文件描述符缓冲区是否可以,如果可用则解除阻塞,一般写文件描述符的缓冲区都是可以的,一般不对写文件描述符进行检测

若凭文字描述还不够直观,那么我们来用代码看一下是怎么个事。


验证水平模式
cpp 复制代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <poll.h>
#include <sys/epoll.h>
using namespace std;
#define SER_PORT 8888        
// 服务器端口号
#define SER_IP "172.20.10.8" // 服务器IP地址
int main(int argc, const char *argv[]){
    // 1、创建用于连接的套接字文件描述符
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    // 参数1:AF_INET表示使用的是ipv4的通信协议
    // 参数2:SOCK_STREAM表示使用的是tcp通信
    // 参数3:由于参数2指定了协议,参数3填0即可
    if (sfd == -1)
    {
        perror("socket error");
        return -1;
    }
    printf("socket success sfd = %d\n", sfd); // 3

    // 2、绑定ip地址和端口号
    // 2.1 填充要绑定的ip地址和端口号结构体
    struct sockaddr_in sin;
    sin.sin_family = AF_INET;                // 通信域
    sin.sin_port = htons(SER_PORT);          // 端口号
    sin.sin_addr.s_addr = inet_addr(SER_IP); // ip地址
    // 2.2 绑定工作
    // 参数1:要被绑定的套接字文件描述符
    // 参数2:要绑定的地址信息结构体,需要进行强制类型转换,防止警告
    // 参数3:参数2的大小
    if (bind(sfd, (struct sockaddr *)&sin, sizeof(sin)) == -1)
    {
        perror("bind error");
        return -1;
    }
    printf("bind success\n");

    // 3、启动监听
    // 参数1:要启动监听的文件描述符
    // 参数2:挂起队列的长度
    if (listen(sfd, 128) == -1)
    {
        perror("listen error");
        return -1;
    }
    printf("listen success\n");

    // 4、阻塞等待客户端的连接请求
    // 定义变量,用于接受客户端地址信息结构体
    struct sockaddr_in cin;          // 用于接收地址信息结构体的
    socklen_t socklen = sizeof(cin); // 用于接收地址信息的长度

    // 创建epoll实例,用于检测文件描述符
    int epfd = epoll_create(1);
    if (epfd == -1)
    {
        perror("epoll_create error");
        return -1;
    }

    // 将sfd放入到检测集合中
    struct epoll_event ev;
    ev.events = EPOLLIN; // 要检测的是读事件
    ev.data.fd = sfd;    // 要检测的文件描述符信息
    epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);
    // 功能:将sfd放入到检测集合中
    // 参数1:epoll实例的文件描述符
    // 参数2:epoll操作,表示要添加文件描述符
    // 参数3:要检测的文件描述符的值
    // 参数4:要检测的事件
    // 定义接收返回的事件集合
    struct epoll_event evs[1024];
    int size = sizeof(evs) / sizeof(evs[0]); // 数组的大小
    while (1)
    {
        // 阻塞检测文件描述符集合中是否有事件产生
        int num = epoll_wait(epfd, evs, size, -1);
        // 参数1:epoll实例的文件描述符
        // 参数2:返回触发事件的文件事件集合
        // 参数3:集合的大小
        // 参数4:是否阻塞
        printf("num = %d\n", num); // 输出本次触发的文件描述符个数
        // 循环遍历集合,判断是哪个文件描述符就绪
        for (int i = 0; i < num; i++)
        {
            int fd = evs[i].data.fd; // 获取本次解除阻塞的文件描述符
            // 判断是否为sfd文件描述符就绪
            if (fd == sfd)
            {
                // 说明有新的客户端发来连接请求
                int newfd = accept(sfd, (struct sockaddr *)&cin, &socklen);
                // 参数1:服务器套接字文件描述符
                // 参数2:用于接收客户端地址信息结构体的容器,如果不接收,也可以填NULL
                // 参数3:接收参数2的大小,如果参数2为NULL,则参数3也是NULL
                if (newfd == -1)
                {
                    perror("accept error");
                    return -1;
                }
                printf("[%s:%d]:已连接成功,newfd = %d!!!!\n",
                        inet_ntoa(cin.sin_addr),
                        ntohs(cin.sin_port), newfd);
                // 将客户端文件描述符放入到epoll检测集合中
                struct epoll_event ev;
                ev.events = EPOLLIN; // 要检测的是读事件
                ev.data.fd = newfd;  // 要检测的文件描述符信息
                epoll_ctl(epfd, EPOLL_CTL_ADD, newfd, &ev);
            }
            else
            {
                // 表示客户端文件描述符就绪,也就是说客户端有数据发来
                // 5、数据收发
                char rbuf[5] = ""; // 数据容器
                // 清空容器中的内容
                bzero(rbuf, sizeof(rbuf));
                // 从套接字中读取消息
                int res = recv(fd, rbuf, sizeof(rbuf)-1, 0);
                if (res == 0)
                {
                    printf("对端已经下线\n");
                    //将客户端从epoll树中删除
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    //关闭套接字
                    close(fd);
                    break;
                }
                printf("收到数据:%s\n",  rbuf);
                // 对收到的数据处理一下,回给客户端边沿触发的测试
                strcat(rbuf, "*_*");
                // 将消息发送给客户端
                if (send(fd, rbuf, strlen(rbuf), 0) == -1)
                {
                    perror("send error");
                    return -1;
                }
                printf("发送成功\n");
            }
        }
    }
    close(sfd);         //关闭监听
    close(epfd);    //关闭epoll实例

    return 0;
}

可以看到我们的buf数组会不断向缓冲区读取数据


验证边缘模式
cpp 复制代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <poll.h>
#include <sys/epoll.h>
using namespace std;
#define SER_PORT 8888        // 服务器端口号
#define SER_IP "192.168.160.129" // 服务器IP地址
int main(int argc, const char *argv[])
{
    // 1、创建用于连接的套接字文件描述符
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    // 参数1:AF_INET表示使用的是ipv4的通信协议
    // 参数2:SOCK_STREAM表示使用的是tcp通信
    // 参数3:由于参数2指定了协议,参数3填0即可
    if (sfd == -1)
    {
        perror("socket error");
        return -1;
    }
    printf("socket success sfd = %d\n", sfd); // 3
    // 2、绑定ip地址和端口号
    // 2.1 填充要绑定的ip地址和端口号结构体
    struct sockaddr_in sin;
    sin.sin_family = AF_INET;                // 通信域
    sin.sin_port = htons(SER_PORT);          // 端口号
    sin.sin_addr.s_addr = inet_addr(SER_IP); // ip地址
    // 2.2 绑定工作
    // 参数1:要被绑定的套接字文件描述符
    // 参数2:要绑定的地址信息结构体,需要进行强制类型转换,防止警告
    // 参数3:参数2的大小
    if (bind(sfd, (struct sockaddr *)&sin, sizeof(sin)) == -1)
    {
        perror("bind error");
        return -1;
    }
    printf("bind success\n");
    // 3、启动监听
    // 参数1:要启动监听的文件描述符
    // 参数2:挂起队列的长度
    if (listen(sfd, 128) == -1)
    {
        perror("listen error");
        return -1;
    }
    printf("listen success\n");
    // 4、阻塞等待客户端的连接请求
    // 定义变量,用于接受客户端地址信息结构体
    struct sockaddr_in cin;          // 用于接收地址信息结构体的
    socklen_t socklen = sizeof(cin); // 用于接收地址信息的长度
    // 创建epoll实例,用于检测文件描述符
    int epfd = epoll_create(1);
    if (epfd == -1)
    {
        perror("epoll_create error");
        return -1;
    }
    // 将sfd放入到检测集合中
    struct epoll_event ev;
    ev.events = EPOLLIN | EPOLLET; // 要检测的是读事件   EPOLLET表示该文件描述符检测是使用边沿触发模式
    ev.data.fd = sfd;    // 要检测的文件描述符信息
    epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);
    // 功能:将sfd放入到检测集合中
    // 参数1:epoll实例的文件描述符
    // 参数2:epoll操作,表示要添加文件描述符
    // 参数3:要检测的文件描述符的值
    // 参数4:要检测的事件
    // 定义接收返回的事件集合
    struct epoll_event evs[1024];
    int size = sizeof(evs) / sizeof(evs[0]); // 数组的大小
    while (1)
    {
        // 阻塞检测文件描述符集合中是否有事件产生
        int num = epoll_wait(epfd, evs, size, -1);
        // 参数1:epoll实例的文件描述符
        // 参数2:返回触发事件的文件事件集合
        // 参数3:集合的大小
        // 参数4:是否阻塞
        printf("num = %d\n", num); // 输出本次触发的文件描述符个数
        // 循环遍历集合,判断是哪个文件描述符就绪
        for (int i = 0; i < num; i++)
        {
            int fd = evs[i].data.fd; // 获取本次解除阻塞的文件描述符
            // 判断是否为sfd文件描述符就绪
            if (fd == sfd)
            {
                // 说明有新的客户端发来连接请求
                int newfd = accept(sfd, (struct sockaddr *)&cin, &socklen);
                // 参数1:服务器套接字文件描述符
                // 参数2:用于接收客户端地址信息结构体的容器,如果不接收,也可以填NULL
                // 参数3:接收参数2的大小,如果参数2为NULL,则参数3也是NULL
                if (newfd == -1)
                {
                    perror("accept error");
                    return -1;
                }
                printf("[%s:%d]:已连接成功,newfd = %d!!!!\n", 
                        inet_ntoa(cin.sin_addr),
                        ntohs(cin.sin_port), newfd);
                // 将客户端文件描述符放入到epoll检测集合中
                struct epoll_event ev;
                ev.events = EPOLLIN | EPOLLET; // 要检测的是读事件
                ev.data.fd = newfd;  // 要检测的文件描述符信息
                epoll_ctl(epfd, EPOLL_CTL_ADD, newfd, &ev);
            }
            else
            {
                // 表示客户端文件描述符就绪,也就是说客户端有数据发来
                // 5、数据收发
                char rbuf[5] = ""; // 数据容器
                // 清空容器中的内容
                bzero(rbuf, sizeof(rbuf));
                // 从套接字中读取消息
                int res = recv(fd, rbuf, sizeof(rbuf)-1, 0);
                if (res == 0)
                {
                    printf("对端已经下线\n");
                    //将客户端从epoll树中删除
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                    //关闭套接字
                    close(fd);
                    break;
                }
                printf("收到数据:%s\n",  rbuf);
                // 对收到的数据处理一下,回给客户端
                strcat(rbuf, "*_*");
                // 将消息发送给客户端
                if (send(fd, rbuf, strlen(rbuf), 0) == -1)
                {
                    perror("send error");
                    return -1;
                }
                printf("发送成功\n");
            }
        }
    }
    close(sfd);         //关闭监听
    close(epfd);    //关闭epoll实例
    return 0;
}

直接看图就OK,想说的都写在图里了


结语

从多进程到多线程,再到IO多路复用,TCP服务器的演进本质上是在寻找"资源消耗"与"并发性能"之间的最佳平衡点。对于初学者来说,理解这三种模型是通往高级后端开发的必经之路。

希望本篇博客能帮你理清思路,写出更高效的网络程序。

我是YYYing, 后面还有更精彩的内容,希望各位能多多关注支持一下主包。

无限进步 ,我们下次再见!


---⭐️ 封面自取 ⭐️---

相关推荐
追光的蜗牛丿1 小时前
C++传递参数时什么情况下传递引用
开发语言·javascript·c++
daemon.qiang1 小时前
麒麟系统v10服务器版本支持intel 12th集成显卡
服务器·图形渲染
sheng42041 小时前
小记近期C++遇到的坑
c++
@迷糊1 小时前
服务器电源扫盲
服务器
wregjru1 小时前
【高并发服务器项目】1.服务器接入层代码详解
c++
森G1 小时前
41、数据库---------事件系统
c++·qt
djBe17esS1 小时前
实战:Java 日志中打印服务器 IP,快速区分多服务器日志归属
java·服务器·tcp/ip
电磁脑机1 小时前
人类分布式大脑架构与文明、技术、安全的底层逻辑——原创大脑架构理论研究
网络·分布式·神经网络·安全·架构
moonsea02032 小时前
2026.4.2
开发语言·c++·算法