嵌入式从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 实战,实现词典程序。

相关推荐
OpenAnolis小助手3 小时前
一句话看透 JVM,SysOM 诊断 Skill 新增 Java 应用诊断能力
java·开发语言·jvm·阿里云·操作系统控制台·sysom
小玮看世界3 小时前
[Python]OD算法在OD实际运用转化参考清单
开发语言·python·算法
天道jimmy3 小时前
VulnHub 系列:HA, Wordy
linux·服务器·web安全
ForteScarlet3 小时前
Kotlin 2.4.20 现已发布,新特性多不多?
android·java·开发语言·开源·kotlin·jetbrains
萧瑟余晖3 小时前
Java深入解析篇六十二之安全机制详解
java·开发语言
菜鸟~noob2334 小时前
【电子战】 第01篇:噪声、门限与虚警/检测概率(PFA/PD)【含matlab代码】
开发语言·matlab
奇牙coding1234 小时前
GPT-6-Astra API 接入教程:OpenRouter 路由配置 + Python/curl 示例 + 静默降级踩坑
开发语言·python·gpt·ai
j7~4 小时前
【Linux网络加餐(二)】《进程组,会话,控制终端,作业控制和守护进程》---详解
linux·运维·服务器·守护进程·会话·进程组·控制终端
天道jimmy4 小时前
VulnHub 系列:Prime,1
linux·运维·服务器
lzx_0025 小时前
C++11(一)
开发语言·c++·算法