Linux网络最终篇:TCP并发服务器

在开发TCP服务器时,一个核心挑战是如何同时服务多个客户端。一个基础的、依次处理客户端请求的服务器(迭代服务器)在任一时刻只能与一个客户端通信,这显然无法满足大多数真实场景的需求。本文将深入探讨TCP并发服务器的核心问题、主流解决方案,并手把手带你用C语言实现高效的并发服务器模型。

TCP并发服务器的根本问题在于阻塞。参考文档中指出,服务器需要同时处理accept(等待新连接)和recv(等待客户端数据)两个可能阻塞的操作。在基础的服务器模型中,程序流程会卡在其中一个调用上,导致无法及时响应另一个事件。例如,如果服务器正在recv等待某个客户端的数据,那么此时即使有新客户端尝试连接,服务器也无法立即处理。

为了解决这个问题,主要发展出了两种模型:

1. 多进程/多线程模型

这是最直观的解决方案。核心思想是:主线程/进程只负责accept新连接。每当建立一个新的TCP连接(获得一个新的通信套接字),就创建一个新的子进程或子线程,专门负责与该客户端进行通信(send/recv)。这样,主流程永远不会阻塞在数据读写上,可以持续接收新连接;而每个客户端的通信在独立的执行流中完成,互不干扰。

  • 优点:实现简单,逻辑清晰,符合直觉。

  • 缺点:资源消耗大。每个连接都会占用一个进程或线程的资源。当并发连接数很高(如成千上万)时,创建、调度、销毁这么多执行单元会带来巨大的CPU和内存开销,性能会急剧下降。文档中也明确指出了其"资源消耗大"的缺点。

2. 多路复用I/O模型

这是构建高性能并发服务器的核心。其核心思想是:用一个特殊的函数(select/poll/epoll)来同时监听多个文件描述符(包括监听套接字和所有通信套接字)的状态。当这个函数返回时,它会告诉我们哪些描述符"就绪"了(例如,监听套接字有新连接可接受,或某个通信套接字有数据可读)。服务器随后只需处理这些就绪的事件即可。

Linux提供了三种主要的I/O多路复用机制,文档中对它们的区别进行了精辟的总结:

  • select:最早期的接口。有描述符数量上限(通常1024);每次调用需要在用户态和内核态之间拷贝整个描述符集合;返回后需要遍历整个集合来查找就绪的描述符。

  • poll :使用链表存储描述符,解决了数量上限问题,但保留了select的其他缺点。

  • epoll:Linux下性能最高的多路复用机制,也是当今高并发服务器的首选。它通过以下方式解决了前两者的缺陷:

    1. 无数量上限

    2. 内核事件表:维护一张内核中的表来管理描述符,避免了每次调用的数据拷贝开销。

    3. 事件驱动:返回时只告诉我们哪些描述符就绪,无需遍历。

    4. 支持边沿触发(ET)模式:仅在描述符状态发生变化时通知,比默认的水平触发(LT)模式效率更高,可以减少系统调用次数。

下面,我们将使用效率最高的epoll来实现一个TCP并发服务器。文档中提供了epoll_create, epoll_ctl, epoll_wait等函数接口的说明,我们将据此编写代码。

cpp 复制代码
#include "head.h"

int CreateListenSocket(const char *pip, int port)
{
    int ret = 0;
    int sockfd = 0;
    struct sockaddr_in seraddr;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == sockfd)
    {
        perror("fail to socket");
        return -1;
    }

    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(port);
    seraddr.sin_addr.s_addr = inet_addr(pip);
    ret = bind(sockfd, (struct sockaddr *)&seraddr, sizeof(seraddr));
    if (-1 == ret)
    {
        perror("fail to bind");
        return -1;
    }

    ret = listen(sockfd, 10);
    if (-1 == ret)
    {
        perror("fail to listen");
        return -1;
    }

    return sockfd;
}

int main(void)
{
    int sockfd = 0;
    int confd = 0;
    int epfd = 0;
    int nready = 0;
    int ret = 0;
    int i = 0;
    ssize_t nret = 0;
    char tmpbuff[4096] = {0};
    struct pollfd fds[1024];
    struct epoll_event env;
    struct epoll_event retenv[1024];

    epfd = epoll_create(1024);
    if(-1 == epfd)
    {
        perror("fail to epoll_create");
        return -1;
    }

    sockfd = CreateListenSocket("192.168.0.144", 50000);

    env.events = EPOLLIN;
    env.data.fd = sockfd;
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &env);
    if (-1 == ret)
    {
        perror("fail to epoll_ctl");
        return -1;
    }

    while (1)
    {
        nready = epoll_wait(epfd, retenv, 1024, -1);
        if(-1 == nready)
        {
            perror("fail to epoll_wait");
            return 0;
        }    

        for(i = 0; nready > i; i++)
        {
            if (retenv[i].data.fd == sockfd)
            {
                confd = accept(sockfd, NULL, NULL);
                if (-1 == confd)
                {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);
                    close(sockfd);
                    continue;
                }
            env.events = EPOLLIN;
                env.data.fd = confd;
                ret = epoll_ctl(epfd, EPOLL_CTL_ADD, confd, &env);
                if (-1 == ret)
                {
                    perror("fail to epoll_ctl");
                    return -1;
                }
            }
            else
            {
                memset(tmpbuff, 0, sizeof(tmpbuff));
                nret = recv(retenv[i].data.fd, tmpbuff, sizeof(tmpbuff), 0);
                if(-1 == nret)
                {
                    perror("fail to recv");
                    return -1;
                }
                else if(0 == nret)
                {
                    printf("关闭链接");
                    epoll_ctl(epfd, EPOLL_CTL_DEL, retenv[i].data.fd, NULL);
                    close(retenv[i].data.fd);
                    continue;
                }
                 printf("RECV:%s\n", tmpbuff);

                sprintf(tmpbuff, "%s --- echo", tmpbuff);
                nret = send(retenv[i].data.fd, tmpbuff, strlen(tmpbuff), 0);
                if (-1 == nret)
                {
                    perror("fail to send");
                    epoll_ctl(epfd, EPOLL_CTL_DEL, retenv[i].data.fd, NULL);
                    close(retenv[i].data.fd);
                    continue;
                }
            }
        }

    }

    close(sockfd);

    return 0;
}

代码与模型解析

  1. 核心结构 :这个服务器是单进程、单线程 的。它所有的并发处理能力都依赖于epoll这个"事件分发器"。

  2. epoll三部曲

    • epoll_create1: 创建epoll实例,获得一个文件描述符,用于管理事件表。

    • epoll_ctl: 用于管理事件表。我们将server_fd(监听)和所有client_fd(通信)通过此函数加入监控列表,并指定我们关心的事件(如EPOLLIN可读事件)。

    • epoll_wait: 这是核心等待函数。程序会阻塞在这里,直到一个或多个被监控的描述符上发生了我们关心的事件。它返回就绪的事件数量及对应描述符的数组。

  3. 事件循环 : 程序主体是一个无限循环,每次循环调用epoll_wait等待事件,然后遍历处理所有就绪的事件。这完美解决了本文开头提到的"阻塞困境":无论是新连接到达还是旧连接发来数据,都会作为一个"就绪事件"被epoll_wait返回,服务器得以在一个线程内有序处理所有I/O。

  4. 高性能根源epoll的高效源于其设计。与多线程模型为每个连接分配独立执行单元的巨大开销相比,epoll模型仅在活跃连接有事件时才进行工作,极大地节省了资源。这正是文档中将其作为TCP并发服务器高效解决方案的原因

Linux系统的4种IO模型:

1. 阻塞IO

  1. 效率高,数据没来时,CPU阻塞等待,不占用CPU资源

2. 非阻塞IO

  1. 效率低,需要轮询是否有IO事件发生

3. 异步IO

  1. 当内核监测到有IO事件发生时,主动向应用层上报信号事件

4. 多路复用IO

  1. 用一个函数接口监听多个文件描述符是否产生IO事件,只要其中一个产生事件,则不再
    阻塞,用户可以处理对应的事件

总结

构建TCP并发服务器的演进之路,是从"为每个连接创建资源"的多进程/线程模型,走向"用事件驱动管理所有连接"的多路复用模型。而epoll以其无上限、低开销、高效的事件通知机制,成为在Linux下实现高性能、高并发网络服务的基石。理解并掌握epoll模型,是每一位C语言后端/嵌入式网络开发者的必备技能。

相关推荐
无心水2 小时前
【OpenClaw:进阶开发】11、OpenClaw插件开发入门——从零编写“文件统计与报表生成”Skill
linux·运维·ubuntu
sbjdhjd2 小时前
RHCE | Linux 例行性工作(定时任务)从入门到精通
linux·运维·服务器·华为·云计算
枷锁—sha2 小时前
【CTFshow-pwn系列】03_栈溢出【pwn 056-057】详解:32位 与64位Shellcode 与 Linux 系统调用底层原理剖析
linux·运维·网络·笔记·安全·网络安全·系统安全
李昊哲小课2 小时前
Python OS模块详细教程
服务器·人工智能·python·microsoft·机器学习
酷酷的崽7982 小时前
Ansible解锁便捷运维新方式,内网 NAS 也能远程管
运维·服务器·ansible
wanhengidc2 小时前
服务器 科技生活
服务器·科技·生活
haluhalu.2 小时前
Socket编程踩坑记:为什么accept返回的socket fd总是0?
linux·服务器·网络
WJ.Polar2 小时前
Ansible Ad-Hoc命令
linux·运维·网络·ansible
小吴编程之路2 小时前
Linux基础命令大全
linux·运维·服务器