嵌入式从0到精通——Linux C|TCP 并发服务器实现方案详解

摘要:在 Linux 网络编程中,TCP 服务端默认是单循环阻塞服务器,同一时间只能服务一个客户端。本文讲解 5 种主流 TCP 并发模型:多进程、多线程、select、poll、epoll,分析各自优缺点,附带核心示例代码,适合 Linux 网络编程入门学习。

前言

在学习 Linux 网络 socket 编程的时候,最简单的 TCP 服务器是单循环服务器:socket→bind→listen→accept→recv/send→close。这种服务器有致命缺陷:同一时刻只能处理一个客户端连接 。当一个客户端连接上来之后,服务端阻塞在recv,其他客户端无法接入,这在实际项目完全不可用。

为了解决同时服务多个客户端的需求,就需要实现TCP 并发服务器。常见实现分为两大类:

  1. 多任务模型:多进程、多线程(创建子进程 / 子线程处理客户端业务,主进程继续等待新连接)
  2. IO 多路复用模型:select、poll、epoll(单进程,监听多个文件描述符 IO 事件,无频繁创建销毁进程线程开销)

学习前提:掌握 TCP 三次握手、四次挥手、socket 基础 API,理解文件描述符。

一、多进程版 TCP 并发服务器

原理

  1. 主进程调用accept()阻塞等待新客户端连接;
  2. 一旦获取到通信套接字connfd,调用fork()创建子进程;
  3. 子进程负责和该客户端收发数据,业务处理完毕关闭套接字,子进程退出;
  4. 父进程 关闭通信套接字(父子进程文件描述符拷贝,父不需要使用 connfd),继续循环调用accept等待下一个客户端。

注意:子进程退出会产生僵尸进程,父进程需要回收子进程资源;可以使用SIGCHLD信号捕获,在信号处理函数内部调用waitpid(WNOHANG)非阻塞回收僵尸子进程。

核心伪代码

复制代码
int main(int argc, char *argv[])
{
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    bind(sockfd, ...);
    listen(sockfd, 10);

    while(1)
    {
        int connfd = accept(sockfd, NULL, NULL);
        pid_t pid = fork();
        if(pid > 0)
        {
            //父进程:不需要通信套接字,关闭,继续等待新连接
            close(connfd);
        }
        else if(pid == 0)
        {
            //子进程:处理客户端业务
            close(sockfd); //子进程不需要监听套接字
            char buf[1024]={0};
            recv(connfd, buf, sizeof(buf), 0);
            send(connfd, buf, strlen(buf),0);
            close(connfd);
            exit(0); //子进程结束
        }
        else
        {
            perror("fork");
            close(connfd);
        }
    }
    close(sockfd);
    return 0;
}

优缺点

✅优点:

  1. 进程之间资源完全隔离,一个客户端异常崩溃,不会影响其他客户端和主服务;
  2. 逻辑简单,代码容易写。

❌缺点:

  1. fork()开销大,每来一个客户端就要创建一个完整进程;进程数量受系统限制,不适合高并发上万连接场景;
  2. 需要处理僵尸进程回收,增加信号处理代码;
  3. 进程间通信复杂,如果多个客户端之间要交换数据,需要使用 IPC(管道、共享内存、消息队列)。

二、多线程版 TCP 并发服务器

原理

进程开销大,线程是轻量级进程,共享进程堆、全局数据区,只拥有独立栈。

  1. 主线程accept获取新连接connfd;
  2. 调用pthread_create创建子线程,把通信套接字传入线程函数;
  3. 子线程专门处理这个客户端读写业务;
  4. 线程设置为分离属性pthread_detach ,线程结束后操作系统自动回收资源,不需要主线程调用pthread_join阻塞等待;
  5. 主线程继续循环等待下一个客户端。

注意:传参坑点:不要直接把局部变量connfd地址传给线程,多个线程会竞争读取同一个变量,出现值错乱;可以动态 malloc 申请堆内存存放 fd,线程内部用完 free 释放。

核心伪代码

复制代码
void *client_task(void *arg)
{
    int connfd = *(int *)arg;
    free(arg);
    char buf[1024]={0};
    recv(connfd, buf, sizeof(buf),0);
    send(connfd, buf, strlen(buf),0);
    close(connfd);
    return NULL;
}

int main(int argc, char *argv[])
{
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    bind(sockfd,...);
    listen(sockfd,10);

    while(1)
    {
        int *p_fd = malloc(sizeof(int));
        *p_fd = accept(sockfd, NULL, NULL);

        pthread_t tid;
        pthread_create(&tid, NULL, client_task, p_fd);
        pthread_detach(tid); //设置分离属性,自动回收线程资源
    }
    close(sockfd);
    return 0;
}

编译需要增加链接库:gcc server.c -o server -pthread

✅优点:

  1. 线程创建销毁开销远小于进程;
  2. 线程共享进程全局资源,线程间数据交互简单,直接读写全局变量(需要互斥锁保护临界资源)。

❌缺点:

  1. 一个线程发生段错误崩溃,整个进程全部崩溃,所有客户端全部断开;
  2. 大量线程会消耗内存(每个线程默认 8M 栈),线程数量上限受限;
  3. 多线程访问共享资源必须加互斥锁,锁处理不当会产生死锁。

三、IO 多路复用 select 模型

IO 多路复用:单进程,同时监听多个文件描述符是否可读 / 可写事件,当某个 fd 事件就绪,才去执行读写操作。避免阻塞在某一个 fd 上。

select 核心特点

  1. 使用位图fd_set存储要监听的文件描述符,最大监听 1024 个 fd;
  2. 用户态的 fd 集合每次调用 select 都会拷贝到内核;
  3. 内核返回整个位图集合,应用程序必须循环遍历所有 fd 判断哪个事件就绪;
  4. 只支持水平触发。

核心工作流程

  1. FD_ZERO清空文件描述符集合;
  2. FD_SET把监听套接字sockfd加入集合;
  3. 调用select()阻塞等待 IO 事件;
  4. 如果sockfd就绪:代表有新客户端连接,调用accept拿到connfd,把 connfd 加入监听集合;
  5. 如果普通connfd就绪:代表客户端发来数据,执行recv/send;客户端断开则调用 close,并且从 fd 集合清除该 fd。

核心伪代码

复制代码
fd_set rdfds;
int maxfd = sockfd;
while(1)
{
    FD_ZERO(&rdfds);
    FD_SET(sockfd, &rdfds);

    //遍历保存所有已经存在的connfd,加入rdfds
    //......

    select(maxfd+1, &rdfds, NULL, NULL, NULL);

    if(FD_ISSET(sockfd, &rdfds))
    {
        //有新客户端连接
        int connfd = accept(sockfd,NULL,NULL);
        FD_SET(connfd, &rdfds);
        if(connfd>maxfd) maxfd = connfd;
    }

    //遍历判断各个connfd是否就绪
    for(int i=sockfd+1; i<=maxfd;i++)
    {
        if(FD_ISSET(i,&rdfds))
        {
            //读写业务逻辑
            int ret = recv(i, buf, sizeof(buf),0);
            if(ret <=0)
            {
                close(i);
                FD_CLR(i,&rdfds);
            }
        }
    }
}

✅优点:单进程,没有进程 / 线程创建销毁开销,相比多进程多线程,支持更多并发连接。 ❌缺点:最大 1024 个 fd 上限;每次调用 select 都拷贝集合;返回之后必须遍历全部 fd,效率随着连接数上升下降。

四、poll 模型

poll 解决 select 位图 1024 的数量限制,使用结构体数组保存需要监听的 fd 事件。

  • 无文件描述符数量硬编码上限;
  • 依然需要把数组拷贝到内核,返回后需要遍历数组查找就绪事件;
  • 只支持水平触发。

在高连接场景性能依然有瓶颈,工业开发中优先 epoll。

五、epoll 模型(Linux 高性能 IO 多路复用)

epoll 是 Linux 独有的 IO 多路复用,高性能网络编程主流方案。

三大核心函数

  1. epoll_create(size):创建 epoll 对象,返回 epoll 文件描述符;
  2. epoll_ctl(epfd, op, fd, event):向内核添加、修改、删除要监听的 fd 和事件;
  3. epoll_wait()阻塞等待事件,直接返回已经就绪的事件数组,不需要遍历全部 fd。

epoll 特点

  1. 使用内核红黑树保存监听 fd,没有 1024 上限;
  2. 用户态和内核只拷贝就绪事件,不需要拷贝全部 fd 集合;
  3. 直接返回就绪的 fd 集合,程序只处理就绪的 fd;
  4. 支持水平触发 LT 和边沿触发 ET两种模式;边沿触发效率更高。

✅优点:海量并发连接场景,性能不会随着连接数量增加明显下降;Nginx 底层就是 epoll。 ❌缺点:Linux 平台专有 API,跨平台性差。

六、五种并发模型对比总结

模型 资源开销 最大连接 稳定性 跨平台 适用场景
多进程 很高 少,几百 高,进程隔离 跨平台 客户端数量少,安全性优先
多线程 中等 几千 低,线程崩溃整个进程挂 跨平台 中等并发,业务逻辑简单
select 低 最大 1024 高 跨平台 连接数小于 1024,简单小程序
poll 低 无硬上限 高 跨平台 中等并发,兼容多系统
epoll 很低 十万百万级别 高 仅 Linux 高并发网络服务器 (Nginx)

七、拓展:TCP 粘包问题

只要是 TCP 流式套接字,就会存在粘包现象:多条业务数据在内核缓冲区粘连到一起,应用层读取无法区分数据包边界。

解决粘包三种方案:

  1. 固定结构体收发:直接发送固定大小结构体;
  2. 特殊分隔符 :数据包末尾加换行符\n等标记;
  3. 自定义帧协议:帧头 + 数据长度 + 业务数据 + 校验 + 帧尾(工业项目最常用)。

下一篇博客我们讲解 SQLite3 数据库 C 语言 API 实战,实现词典程序。

相关推荐
QYRdata10 小时前
2026-2032年图形处理云服务器CAGR预计达18.7%
运维·服务器·服务发现
神仙别闹10 小时前
基于C++实现的(控制台)学校开课查询系统
开发语言·c++
只睡四小时10 小时前
JS 手写 D-pad 空间导航:电视端焦点引擎实战
android·开发语言·前端·javascript·ecmascript·hls·空间导航
2601_9622186110 小时前
C++的几种编译器的实现
开发语言·c++
多弗朗皮卡丘11 小时前
C++ stack和queue
开发语言·c++·deque·queue·stack·容器适配器
SDWAN_Cheap11 小时前
TCP滑动窗口与拥塞控制是什么?从流量控制到网络稳定性的完整解析
网络·tcp/ip·php
极光通讯11 小时前
呆滞库存回收定价逻辑:如何评估DDR4内存和SATA SSD的回收价值
服务器·ssd
技术人生00111 小时前
VMware Workstation 安装 Ubuntu 24.04 LTS 完整教程
运维·服务器·ubuntu
维克兜率天11 小时前
【维克】弹性策略:用“乖离率“捕捉超跌反弹
android·开发语言·python·深度学习·kotlin·量化
TOOLS指南11 小时前
Chatshare 域名乱象:如何辨别仿冒站点,避坑指南
开发语言·信息可视化