IO模型与并发服务器

一、阻塞 IO(Blocking IO)

定义 :调用 IO 函数(recv)时,如果内核缓冲区没有数据,进程直接阻塞休眠,直到数据到达才唤醒、拷贝数据、函数返回。没数据就睡觉,等内核喊醒我

  1. 用户层代码:recv(fd, buf, sizeof(buf), 0);
  2. 内核执行流程
    ① 检查 socket 接收缓冲区是否存在数据
    ② 无数据:进程状态置为 TASK_INTERRUPTIBLE(可中断睡眠) ,移出 CPU 运行队列,CPU 调度其他进程运行(不占用 CPU )
    ③ 数据到来:网卡触发硬件中断 → 内核把数据拷贝到内核接收缓冲区 → 唤醒进程,进程重新进入运行队列,把数据拷贝到用户空间 buf,recv 返回
  3. 优缺点
    • 优点:代码简单,空闲时进程休眠,不浪费 CPU 资源
    • 缺点:函数会卡住等待;单进程下同一时间只能等待一个 socket,无法同时处理多个连接

二、非阻塞 IO(Non-Blocking IO)

定义 :IO 函数(recv)无论缓冲区有没有数据,都立刻返回,不会休眠 ;没有数据时返回特定错误,由用户层循环不断询问内核(轮询 )。问内核有没有数据,内核说没有,程序继续跑,一会再来问

  1. 用户层
    • 设置非阻塞:fcntl(fd, F_SETFL, O_NONBLOCK);
    • 循环调用 recv 轮询,判断返回值和错误码
  2. 内核执行流程 ① 收到 recv 调用,立刻检查接收缓冲区 ② 有数据:正常读取数据,返回读到字节数 ③ 无数据:直接返回,设置 errno = EAGAIN ( EAGAIN 不是故障,代表暂时没有数据),进程继续向下执行,不会进入睡眠
  3. 优缺点
    • 优点:不会卡死在单个 fd 上,单进程可以轮流处理多个 socket
    • 缺点:需要循环轮询;不加延时会空跑,CPU 占用飙升;大部分轮询都是无效查询,效率低
    • 优化写法:循环里加usleep()降低 CPU 占用
对比项 阻塞 IO------内核主动通知(唤醒) 非阻塞 IO------用户程序主动反复轮询内核
无数据时进程状态 进入可中断睡眠 TASK_INTERRUPTIBLE(可被信号唤醒),让出 CPU 不睡眠,继续运行,返回 EAGAIN(不等于出错! 代表数据还没就绪,重试即可)
recv 行为 卡住等待,不返回 立刻返回
CPU 消耗 低,休眠不占 CPU 高,轮询空跑占用 CPU
多连接处理 单进程很难同时监听多个 socket 可以循环轮询多个 fd,但麻烦低效
开启方式 socket 默认就是阻塞模式 fcntl 文件控制函数设置 O_NONBLOCK 非阻塞标志

三、IO多路复用

使用非阻塞 IO需要用户自己写 while 循环挨个问 fd,所以可以使用IO多路复用--交给内核同时监视一堆 fd,哪个 fd 数据准备好了再通知应用,不用用户不停循环查询,解决非阻塞轮询浪费 CPU 的问题。

select 函数详解

cpp 复制代码
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数含义

  1. nfds:要监视 fd 里最大 fd 值 + 1(内核需要知道遍历到哪里为止)
  2. readfds:读集合,关心哪些 fd 可读(新连接、收到数据)
  3. writefds:写集合,关心哪些 fd 缓冲区可写
  4. exceptfds:异常集合,关心 fd 异常事件
  5. timeout:超时时间
    • NULL:永久阻塞,直到有 fd 就绪
    • 填 0:不阻塞,轮询
    • 设置时间:等待一段时间,超时直接返回 0

返回值

  • 0:超时,没有 fd 就绪
  • -1:出错

select 缺点

  1. fd_set 是位图,默认有最大 fd 上限(一般 1024),能监视的 fd 数量有限
  2. 每次调用 select,都要把 fd 集合从用户区拷贝到内核;内核返回时会修改 fd_set,清空未就绪 fd → 每次循环调用前,用户必须重新设置 fd 集合
  3. 内核内部是轮询扫描全部 fd;返回就绪数量之后,用户程序也要遍历所有 fd,挨个判断谁就绪了(O (n))

poll 函数详解

cpp 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds:pollfd 结构体数组,每一项记录一个 fd + 要监听的事件(events)+ 内核返回的就绪事件(revents)
  • nfds:数组里面有效 fd 的个数
  • timeout:等待时间,单位毫秒;-1 永久阻塞,0 不阻塞

pollfd 结构体

cpp 复制代码
struct pollfd{
    int fd;         // 要监视的文件描述符
    short events;   // 用户设置:想要监听什么事件(读POLLIN)
    short revents;  // 内核返回:实际发生了什么就绪事件
};

返回值

  • 0:超时无事件
  • -1:失败

poll 改进点

  1. 没有 1024 的硬性上限,理论上可以监视很多 fd
  2. 使用结构体数组,不会修改用户传入的监视事件 events,不用每次循环重新设置监听事件

同样缺点:内核还是轮询扫描所有 fd;返回后应用程序依旧要遍历全部 fd 找就绪 fd(O (n))。

select 与 poll 对比

对比项 select poll
存储 fd 方式 fd_set 位图 pollfd 结构体数组
最大连接限制 有上限(默认 1024) 无硬性上限
参数是否被内核修改 是,每次返回会改写 fd_set,每次循环要重置 只修改 revents,events 不变,不用重置监听事件
效率 内核轮询所有 fd,用户也要遍历 内核轮询所有 fd,用户也要遍历
超时参数 timeval 结构体 int,单位 ms

易错点

  1. IO 多路复用只是等事件、通知就绪,不会帮你 recv 读取数据;就绪之后自己调用 recv
  2. 一般搭配非阻塞 IO使用:如果用阻塞 recv,有可能一次读不完缓冲区数据,recv 卡住,整个主循环卡死
  3. select/poll 本身是阻塞调用:在没有 fd 就绪的时候,进程休眠,不占 CPU,这是和非阻塞 while 轮询的本质区别
  4. 三者递进:阻塞 IO → 非阻塞轮询 → select/poll 多路复用 → epoll(epoll 是事件通知,不用全量遍历,性能更强)

四、两大服务器基础模型

1. 循环服务器(迭代服务器)

特点:同一时刻只能处理一个客户端请求,处理完一个,才接待下一个

  • TCP 默认单进程阻塞写法就是循环服务器 原因:代码顺序是 accept → recv;卡在accept就不能读已有连接的数据,卡在recv就不能接收新客户端连接,两个阻塞函数互相阻塞、抢占执行流
  • UDP 默认是循环,但UDP 的循环服务器特殊 :只有一个阻塞函数recvfrom,收到哪个客户端的数据就回复谁,看起来可以交替响应多个客户端(PPT 说法:UDP 默认并发,这个是考点原话)

通俗区分:TCP 循环是串行排队;UDP 不需要维持连接,单 recvfrom 可以收到任意客户端发来报文

2. 并发服务器

特点:同一时刻,同时响应、处理多个客户端请求 三大经典实现方案:多进程 fork、多线程、IO 多路复用(select/poll/epoll)


多进程并发服务器(fork)

原理:主进程专门循环 accept 接收新连接;每 accept 拿到 connfd,调用 fork 创建子进程,子进程单独负责这条连接收发数据

流程

  1. 主进程:socket→bind→listen
  2. 循环:accept得到新连接 → fork()
  3. 子进程:
    • close (监听 listenfd)(子进程不需要监听,继承来的 listenfd 关掉,引用计数减 1)
    • recv/send 和客户端通信
    • 通信结束,close (connfd)、exit 退出
  4. 主进程:继续循环 accept,等待下一个客户端

优点:进程之间地址空间隔离,一个子进程崩溃,不会影响主进程和其他连接,稳定性高

缺点:fork 创建进程开销大;进程数量多的时候占用内存高;进程间通信麻烦;要处理僵尸进程(wait 回收)

拓展:预 fork 优化(Apache 模型):服务器启动的时候一次性预先创建一批子进程,多个子进程共享同一个监听 socket,内核负载均衡分发新连接,减少运行时 fork 开销

相关代码

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <wait.h>

#define PORT 8888
void handle_client(int connfd)
{
    char buf[1024];
    while(1)
    {
        int n = recv(connfd, buf, sizeof(buf)-1,0);
        if(n <=0) break;
        printf("收到:%s\n",buf);
        send(connfd, buf, n,0);
    }
    close(connfd);
    exit(0);
}

int main()
{
    int listenfd = socket(AF_INET,SOCK_STREAM,0);
    int opt=1;
    setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
    struct sockaddr_in serv={AF_INET,htons(PORT),htonl(INADDR_ANY)};
    bind(listenfd,(struct sockaddr*)&serv,sizeof(serv));
    listen(listenfd,10);
    printf("多进程服务器启动\n");

    while(1)
    {
        int connfd = accept(listenfd,NULL,NULL);
        pid_t pid = fork();
        if(pid > 0)
        {
            //父进程:继续accept,关闭connfd(父进程不用它)
            close(connfd);
            waitpid(-1,NULL,WNOHANG); // 非阻塞回收僵尸子进程
        }
        else if(pid == 0)
        {
            //子进程:关闭监听fd,专心处理客户端
            close(listenfd);
            handle_client(connfd);
        }
    }
    close(listenfd);
    return 0;
}

多线程并发服务器

原理:主线程负责 accept;每来一个新连接,创建子线程,子线程专门处理这一条连接读写

流程

  1. 主线程:socket→bind→listen
  2. while 循环:accept拿到 connfd → pthread_create 创建子线程
  3. 子线程函数:recv/send通信;结束 close (connfd,线程退出)

优点:线程创建、切换开销远小于进程;同一个进程内,线程共享地址空间,数据传递简单

缺点:线程共享资源,一个线程崩溃,整个进程直接挂,全部连接全部断开;需要加锁处理共享资源

对比:进程隔离、稳、重;线程共享、轻、风险高

相关代码

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <pthread.h>

#define PORT 8888
void *client_thread(void *arg)
{
    int connfd = *(int*)arg;
    free(arg);
    char buf[1024];
    while(1)
    {
        int n = recv(connfd, buf, sizeof(buf)-1,0);
        if(n <=0) break;
        printf("收到:%s\n",buf);
        send(connfd, buf, n,0);
    }
    close(connfd);
    return NULL;
}

int main()
{
    int listenfd = socket(AF_INET,SOCK_STREAM,0);
    int opt=1;
    setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
    struct sockaddr_in serv={AF_INET,htons(PORT),htonl(INADDR_ANY)};
    bind(listenfd,(struct sockaddr*)&serv,sizeof(serv));
    listen(listenfd,10);
    printf("多线程服务器启动\n");

    while(1)
    {
        int *pconn = malloc(sizeof(int));
        *pconn = accept(listenfd,NULL,NULL);
        pthread_t tid;
        pthread_create(&tid, NULL, client_thread, pconn);
        pthread_detach(tid); // 线程结束自动回收资源
    }
    close(listenfd);
    return 0;
}

五、IO 多路复用并发服务器

三种IO多路复用方法以及各自的特点

特性 select--轮询式多路复用 poll--轮询式多路复用 epoll--回调通知式
存储结构 位图 fd_set pollfd 数组 内核红黑树 + 就绪链表
fd 上限 默认 1024(硬限制) 无内置上限 无内置上限
每次调用传参 每次全部 fd 重新传入 每次整个数组传入 只等事件,fd 提前注册
就绪查找 遍历全部 fd 遍历全部 fd 只遍历就绪 fd
触发模式 仅 LT 水平触发 仅 LT 水平触发 LT 默认,支持 ET 边缘触发
跨平台 好 一般 Linux 独有
适合场景 少量连接、兼容老平台 中小连接 高并发海量连接

select代码

cpp 复制代码
// 标准输入输出头文件,printf、perror 使用
#include <stdio.h>
// UNIX标准头文件,close()、read、write等系统调用
#include <unistd.h>
// socket、bind、listen、accept 套接字相关函数和结构体
#include <sys/socket.h>
// 网络地址转换:htons、htonl、inet_ntoa,sockaddr_in
#include <arpa/inet.h>
// select、fd_set、FD_ZERO/FD_SET/FD_ISSET 多路复用
#include <sys/select.h>
// memset 内存初始化函数
#include <string.h>

// 宏定义服务器端口号
#define PORT 8888
// select的限制:默认最多监听1024个fd,这是select经典短板
#define MAX_FD 1024

int main()
{
    // ====================== 1. 创建监听套接字 listenfd ======================
    // socket(协议族, 套接字类型, 协议)
    // 参数1 AF_INET:IPv4协议
    // 参数2 SOCK_STREAM:TCP流式套接字
    // 参数3 0:自动匹配TCP协议
    // 返回值:成功返回文件描述符fd;失败返回-1
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if(listenfd < 0)  // 判断socket是否创建失败
    {
        perror("socket");  // 打印socket对应的错误原因
        return -1;        // 程序异常退出
    }

    // ====================== 设置端口复用选项 SO_REUSEADDR ======================
    int opt = 1;   // 打开这个选项,1代表启用
    // setsockopt(fd, 协议层, 选项名, 选项值地址, 值大小)
    // 参数1 listenfd:要设置的套接字
    // 参数2 SOL_SOCKET:套接字通用层(不是IP层/TCP层)
    // 参数3 SO_REUSEADDR:端口复用,服务器关闭后可以立刻重启绑定同一个端口,避免Address already in use
    // 参数4 &opt:保存选项值的变量地址
    // 参数5 sizeof(opt):opt变量所占字节大小
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // sockaddr_in:IPv4专用地址结构体
    struct sockaddr_in serv_addr;
    serv_addr.sin_family = AF_INET;         // 地址族,固定AF_INET代表IPv4
    serv_addr.sin_port = htons(PORT);       // 端口号,htons:主机字节序转网络字节序(大端)
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    // INADDR_ANY = 0.0.0.0,代表监听本机所有网卡IP;htonl把主机序转网络长整型

    // ====================== bind绑定:套接字 和 IP+端口绑定 ======================
    // bind(fd, 通用地址指针, 地址结构体长度)
    if(bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0)
    {
        perror("bind");
        close(listenfd);  // 失败要关闭已经创建的套接字,释放资源
        return -1;
    }

    // ====================== listen开启监听,变成被动套接字,可以接收连接 ======================
    // listen(fd, 等待连接队列长度(backlog))
    // backlog:半连接+全连接队列上限,这里填10
    if(listen(listenfd, 10) < 0)
    {
        perror("listen");
        close(listenfd);
        return -1;
    }
    printf("服务器启动,端口%d,等待客户端...\n", PORT);

    fd_set readfds;         // fd集合:select用来监视【读事件】的集合
    int fd_arr[MAX_FD];     // 自定义数组:保存所有需要被select监视的fd
                            // 为什么额外数组?因为select会修改fd_set,我们需要保存一份完整fd列表
    memset(fd_arr, -1, sizeof(fd_arr)); // 全部初始化为-1,-1代表该位置没有有效fd
    fd_arr[0] = listenfd;   // 数组第0号位置放监听fd,它一直都要被监视

    // 死循环:服务器持续运行,不断处理事件
    while(1)
    {
        // select会修改readfds!每次循环必须清空集合,重新添加fd
        FD_ZERO(&readfds);  // FD_ZERO:清空fd集合,全部标记为不监听

        // 遍历fd_arr,把里面所有有效fd,加入select监听集合readfds
        for(int i = 0; i < MAX_FD; i++)
        {
            if(fd_arr[i] != -1) // 如果这个位置存了有效的fd
            {
                FD_SET(fd_arr[i], &readfds); // FD_SET:把fd加入集合,让select监视它的读事件
            }
        }

        // ====================== select 核心多路复用函数 ======================
        // select(nfds, readfds, writefds, exceptfds, timeout)
        // 参数1 nfds:要监听的最大fd+1(这里直接写MAX_FD,简单写法)
        // 参数2 &readfds:监视读事件的fd集合(新连接到来、客户端发数据都属于读事件)
        // 参数3 NULL:不监视写事件集合
        // 参数4 NULL:不监视异常事件集合
        // 参数5 NULL:阻塞模式,没有事件就一直等,不超时
        // 返回值ret:就绪fd的个数;-1出错;0超时
        int ret = select(MAX_FD, &readfds, NULL, NULL, NULL);
        if(ret < 0)
        {
            perror("select");
            break;
        }

        // 遍历所有fd,挨个判断谁就绪了
        for(int i = 0; i < MAX_FD; i++)
        {
            int fd = fd_arr[i];
            if(fd == -1) continue;  // 无效fd直接跳过

            // FD_ISSET(fd, &readfds):判断该fd是不是就绪(内核标记有读事件)
            if(FD_ISSET(fd, &readfds))
            {
                if(fd == listenfd)
                {
                    // ==========分支1:监听fd就绪 = 有新客户端发起连接 ==========
                    struct sockaddr_in cli_addr;  // 保存客户端地址信息
                    socklen_t cli_len = sizeof(cli_addr); // 地址结构体长度,accept需要
                    // accept(监听fd, 输出客户端地址, 地址长度指针)
                    // 返回connfd:和这个客户端通信的套接字(数据fd)
                    int connfd = accept(listenfd, (struct sockaddr*)&cli_addr, &cli_len);
                    if(connfd < 0)
                    {
                        perror("accept");
                        continue;
                    }
                    // inet_ntoa:网络字节序IP转字符串;ntohs:网络端口转回主机字节序
                    printf("新客户端接入:%s:%d, connfd=%d\n",
                           inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), connfd);

                    // 把新拿到的connfd存入fd_arr,后续select监视这个客户端
                    for(int j = 0; j < MAX_FD; j++)
                    {
                        if(fd_arr[j] == -1)
                        {
                            fd_arr[j] = connfd;
                            break;
                        }
                    }
                }
                else
                {
                    // ==========分支2:普通connfd就绪 = 客户端发来数据 ==========
                    char buf[1024] = {0};  // 接收缓冲区,初始清零
                    // recv(fd, buf, 长度, flag)
                    // 参数1 fd:客户端通信fd
                    // 参数2 buf:数据存放缓冲区
                    // 参数3 sizeof(buf)-1:预留一个位置放'\0',方便打印字符串
                    // 参数4 0:默认阻塞模式
                    // 返回n:读到的字节数;0=对方正常关闭;<0=出错
                    int n = recv(fd, buf, sizeof(buf)-1, 0);
                    if(n > 0)
                    {
                        printf("fd=%d收到消息:%s\n", fd, buf);
                        // send(fd, 数据首地址, 发送长度, flag)
                        send(fd, buf, n, 0); // 回显服务:原样把数据发回客户端
                    }
                    else if(n == 0)
                    {
                        // recv返回0:客户端调用close,四次挥手断开连接
                        printf("fd=%d 客户端断开连接\n", fd);
                        close(fd);        // 关闭通信fd,释放资源
                        fd_arr[i] = -1;   // 数组里面标记为无效,不再交给select监视
                    }
                    else
                    {
                        perror("recv");
                        close(fd);
                        fd_arr[i] = -1;
                    }
                }
            }
        }
    }
    close(listenfd); // 正常退出关闭监听fd(本程序理论跑不到这里)
    return 0;
}

poll代码

cpp 复制代码
// 标准输入输出,printf perror
#include <stdio.h>
// close()
#include <unistd.h>
// socket bind listen accept
#include <sys/socket.h>
// htons inet_ntoa sockaddr_in
#include <arpa/inet.h>
// poll函数、struct pollfd、POLLIN等宏
#include <poll.h>
// memset
#include <string.h>

// 服务器端口
#define PORT 8888
// poll数组最大容量,自己设定,不像select是硬限制
#define MAX_POLL_FD 1024

int main()
{
    // ========== 1. 创建监听套接字 ==========
    // 参数1 AF_INET:IPv4
    // 参数2 SOCK_STREAM:TCP流式套接字
    // 参数3 0:默认TCP协议
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0)
    {
        perror("socket");
        return -1;
    }

    // ========== 设置端口复用,解决重启时地址被占用 ==========
    int opt = 1;
    // 参数1:要设置的fd
    // 参数2 SOL_SOCKET:套接字层选项
    // 参数3 SO_REUSEADDR:端口复用
    // 参数4:选项值地址
    // 参数5:选项值大小
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // ========== 填充IPv4地址结构体 ==========
    struct sockaddr_in serv_addr;
    serv_addr.sin_family = AF_INET;                 // IPv4地址族
    serv_addr.sin_port = htons(PORT);               // 端口转网络字节序(大端)
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);   // INADDR_ANY=0.0.0.0,监听本机所有网卡

    // ========== bind:套接字绑定IP+端口 ==========
    // 参数1 fd,参数2地址指针,参数3地址结构体长度
    if (bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0)
    {
        perror("bind");
        close(listenfd);
        return -1;
    }

    // ========== listen开启监听,变成被动套接字 ==========
    // 参数2 backlog:全连接队列上限
    if (listen(listenfd, 10) < 0)
    {
        perror("listen");
        close(listenfd);
        return -1;
    }
    printf("poll多路复用服务器启动,端口:%d\n", PORT);


    // ========== poll核心:pollfd结构体数组 ==========
    // struct pollfd {
    //     int fd;         // 要监听的文件描述符,-1代表这个位置无效,poll自动跳过
    //     short events;   // 用户设置:想要监听什么事件(POLLIN读事件)
    //     short revents;  // 内核输出:实际发生的事件,由内核填充,只读
    // };
    struct pollfd pfd[MAX_POLL_FD];
    // 全部初始化为0
    memset(pfd, 0, sizeof(pfd));
    // 把数组里所有fd先置为-1,表示空位无效
    for (int i = 0; i < MAX_POLL_FD; i++)
    {
        pfd[i].fd = -1;
    }

    // 第一个位置放监听套接字
    pfd[0].fd = listenfd;
    pfd[0].events = POLLIN;     // POLLIN:监听【读事件】:新连接到来就是读事件

    // 服务器循环
    while (1)
    {
        // ================= poll 函数 =================
        // poll( pollfd数组首地址, 数组元素个数, 超时时间ms )
        // 参数1:pollfd数组
        // 参数2:数组里面有效元素数量上限
        // 参数3 timeout:-1 代表永久阻塞,直到有事件发生;0非阻塞;>0等待毫秒数
        // 返回值:>0 就绪fd数量;0超时;-1出错
        int ret = poll(pfd, MAX_POLL_FD, -1);
        if (ret < 0)
        {
            perror("poll");
            break;
        }

        // 遍历poll数组,检查哪一个fd有事件
        for (int i = 0; i < MAX_POLL_FD; i++)
        {
            // fd=-1,是空位,直接跳过
            if (pfd[i].fd == -1)
                continue;

            // 判断revents里面有没有POLLIN读事件
            // revents是内核写进去的,events是我们告诉内核要监听什么
            if (pfd[i].revents & POLLIN)
            {
                if (pfd[i].fd == listenfd)
                {
                    // --------分支1:监听fd就绪 → 新客户端连接 --------
                    struct sockaddr_in cli_addr;
                    socklen_t cli_len = sizeof(cli_addr);
                    // accept参数:监听fd,输出客户端地址,地址长度指针
                    int connfd = accept(listenfd, (struct sockaddr*)&cli_addr, &cli_len);
                    if (connfd < 0)
                    {
                        perror("accept");
                        continue;
                    }
                    printf("新客户端接入:%s:%d, connfd=%d\n",
                           inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), connfd);

                    // 找数组里面一个空位,存入新的客户端fd
                    for (int j = 0; j < MAX_POLL_FD; j++)
                    {
                        if (pfd[j].fd == -1)
                        {
                            pfd[j].fd = connfd;
                            pfd[j].events = POLLIN;  // 监听这个客户端的读事件(发数据过来)
                            break;
                        }
                    }
                }
                else
                {
                    // --------分支2:客户端connfd就绪 → 收到客户端数据 --------
                    char buf[1024] = {0};
                    // recv(fd,缓冲区,长度,flag=0默认阻塞)
                    int n = recv(pfd[i].fd, buf, sizeof(buf) - 1, 0);
                    if (n > 0)
                    {
                        printf("fd=%d 收到消息:%s\n", pfd[i].fd, buf);
                        send(pfd[i].fd, buf, n, 0); // 回显,原样发回去
                    }
                    else if (n == 0)
                    {
                        // recv返回0:客户端正常关闭连接(四次挥手)
                        printf("fd=%d 客户端断开连接\n", pfd[i].fd);
                        close(pfd[i].fd);    // 关闭套接字释放资源
                        pfd[i].fd = -1;      // 标记为空位,poll下一轮自动忽略
                    }
                    else
                    {
                        perror("recv");
                        close(pfd[i].fd);
                        pfd[i].fd = -1;
                    }
                }
            }
        }
    }

    close(listenfd);
    return 0;
}

epoll代码

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>

#define DEF_VAL      0          //socket 第三个参数,TCP 默认 0
#define ERR_VAL      -1         // 函数出错返回值标记
// 错误处理宏:打印错误原因,直接退出程序;do while (0) 是宏标准写法,保证语法安全
#define ERR_LOG (VAL) do {perror (VAL);exit (1);} while (0)

#define MAX_EVENTS  2           //epoll 一次最多返回就绪事件数量
#define BUF_SIZE    1024        // 缓冲区大小
#define SERV_PORT   8888        // 服务器绑定端口号

int main (int argc, const char *argv []){
    // ========== 1. 创建 TCP 监听套接字 listenfd ==========
    // AF_INET:IPv4;SOCK_STREAM:TCP 流式套接字;DEF_VAL=0,默认协议
    int listenfd = socket (AF_INET, SOCK_STREAM, DEF_VAL);
    if (listenfd == ERR_VAL){    //socket 创建失败
        ERR_LOG ("socket");
    }
    printf ("创建监听套接字成功!fd = % d\n", listenfd);

    // 设置端口复用,解决服务器重启时 TIME_WAIT 导致端口无法绑定的问题
    int opt = 1;
    setsockopt (listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof (opt));

    // ========== 2. 填充服务器地址结构体 ==========
    struct sockaddr_in serv_addr;
    memset (&serv_addr, 0, sizeof (serv_addr));     // 清空结构体内存,防止脏数据
    serv_addr.sin_family = AF_INET;               // 指定地址族为 IPv4
    serv_addr.sin_port = htons (SERV_PORT);        // 端口:主机字节序转为网络大端序
    serv_addr.sin_addr.s_addr = htonl (INADDR_ANY);// INADDR_ANY:监听本机所有网卡 IP

    // ========== 3. bind 绑定:套接字 和 IP + 端口绑定 ==========
    int ret = bind (listenfd, (struct sockaddr *)&serv_addr, sizeof (serv_addr));
    if (ret == ERR_VAL){
        ERR_LOG ("bind");
    }
    printf ("bind 成功!端口:% d\n", SERV_PORT);

    // ========== 4. listen 开启监听 ==========
    // 第二个参数 backlog:全连接队列最大长度,存放已经完成三次握手的连接
    ret = listen (listenfd, 10);
    if (ret == ERR_VAL){
        ERR_LOG ("listen");
    }
    printf ("服务器开始监听,等待客户端连接...\n");

    // ========== 5. 创建 epoll 实例 ==========
    // EPOLL_CLOEXEC:进程 exec 执行新程序时,自动关闭这个 epollfd,防止文件描述符泄露
    int epfd = epoll_create1 (EPOLL_CLOEXEC);
    if (epfd == ERR_VAL){
        ERR_LOG ("epoll_create1");
    }

    struct epoll_event ev1, res [MAX_EVENTS]; //ev1:用来配置事件;res:存放 epoll_wait 返回的就绪事件
    // 将 listenfd 加入 epoll,监听可读事件 EPOLLIN
    ev1.data.fd = listenfd;                  // 绑定要监听的文件描述符
    ev1.events = EPOLLIN;                    // 监听可读事件:有新连接到来
    if (epoll_ctl (epfd, EPOLL_CTL_ADD, listenfd, &ev1) == ERR_VAL){
        ERR_LOG ("epoll_ctl listenfd");
    }

    char buf [BUF_SIZE] = {0};                // 数据缓冲区,用来收发数据

    // ========== epoll 主循环,持续等待事件 ==========
    while (1){
        //epoll_wait:阻塞等待就绪事件
        // 参数:epoll 句柄、就绪事件数组、数组最大长度、超时时间 (-1 = 永久阻塞)
        int num = epoll_wait (epfd, res, MAX_EVENTS, -1);
        if (num == ERR_VAL){
            ERR_LOG ("epoll_wait");
        }

        // 遍历本次所有就绪的 fd
        for (int i = 0; i < num; ++i){
            // 分支 1:就绪的是 listenfd,代表有新客户端发起连接
            if (res [i].data.fd == listenfd){
                struct sockaddr_in cli_addr;    // 存放客户端地址信息
                socklen_t cli_len = sizeof (cli_addr);
                //accept:从全连接队列取出已经完成三次握手的连接,返回通信 fd
                int connfd = accept (listenfd, (struct sockaddr *)&cli_addr, &cli_len);
                if (connfd == ERR_VAL){
                    perror ("accept");
                    continue;
                }
                printf ("新客户端接入:% s:% d,connfd=% d\n", inet_ntoa (cli_addr.sin_addr), ntohs (cli_addr.sin_port), connfd);

                // 把新的通信套接字 connfd 加入 epoll,监听可读事件
                ev1.data.fd = connfd;
                ev1.events = EPOLLIN;
                if (epoll_ctl (epfd, EPOLL_CTL_ADD, connfd, &ev1) == ERR_VAL){
                    ERR_LOG ("epoll_ctl connfd");
                }
            }
            // 分支 2:就绪的是普通通信 connfd,客户端发来数据
            else {
                int fd = res [i].data.fd;
                ret = read (fd, buf, sizeof (buf)); // 从内核缓冲区读取客户端发来的数据
                if (ret == 0){
                    //read 返回 0:收到对端 FIN 报文,客户端关闭连接
                    printf ("客户端 fd=% d 主动退出 \n", fd);
                    epoll_ctl (epfd, EPOLL_CTL_DEL, fd, NULL); // 先从 epoll 移除监听
                    close (fd);                                // 关闭通信套接字
                    memset (buf, 0, sizeof (buf));              // 清空缓冲区
                    continue;
                }
                else if (ret == ERR_VAL){
                    ERR_LOG ("read");
                }
                // 打印收到的消息
                printf ("收到客户端 fd=% d 消息:% s", fd, buf);
                write (fd, buf, strlen (buf));  // 回显:把收到的数据原样发回客户端
                memset (buf, 0, sizeof (buf));  // 清空缓冲区,避免下次数据残留
            }
        }
    }

    close (listenfd);   // 关闭监听套接字
    close (epfd);       // 关闭 epoll 句柄
    return 0;
}
相关推荐
梅孔立2 小时前
Docker 容器网络不通:Tomcat 无法解析 MySQL、Nginx 无法反代 Tomcat 的生产级排查与治理
网络·docker·tomcat
YWamy9 小时前
如何降低RTC SDK在复杂网络环境下的运行成本
网络·音视频·实时音视频
北京盛世宏博9 小时前
信创国产化智慧档案馆管库系统:十二防一体化管控平台落地笔记
网络·方案·温湿度·智慧档案馆建设
w-w0w-w10 小时前
SQL注入
网络·数据库·sql
一条泥憨鱼10 小时前
【计算机网络】短连接、长连接与连接池:从 TCP 握手到池化复用的完整链路
网络·数据库·网络协议·tcp/ip·计算机网络
北京盛世宏博10 小时前
数字孪生 3D 可视化实战:档案十防体系温湿度数据联动与告警定位实现
网络·档案八防·十防
Lynne30911 小时前
EtherCAT网络中,多个节点如何配置才能互不干扰?
网络·网络配置·ethercat·工业网络
Yang961112 小时前
实验室里的多面手:鼎讯信通TM-150信号发生器技术拆解与实测思考
网络
知已难寻12 小时前
03 · OpenStack的计算管理 Nova
运维·服务器·开发语言·云原生·云计算·openstack·开源软件