Linux C 多线程 TCP 并发服务器:从 `accept()` 到资源管理深度剖析

文章目录

  • [Linux C 多线程 TCP 并发服务器:从 `accept()` 到资源管理深度剖析](#Linux C 多线程 TCP 并发服务器:从 accept() 到资源管理深度剖析)
    • [一、 为什么要"并发"?](#一、 为什么要“并发”?)
    • [二、 核心概念:主线程 vs 子线程,监听套接字 vs 通信套接字](#二、 核心概念:主线程 vs 子线程,监听套接字 vs 通信套接字)
    • [三、 线程间的资源:共享与独占](#三、 线程间的资源:共享与独占)
    • [四、 初代方案:全局数组(固定槽位)](#四、 初代方案:全局数组(固定槽位))
      • [1. 运行流程](#1. 运行流程)
      • [2. 完整代码示例(`server_array.c`)](#2. 完整代码示例(server_array.c))
      • [3. 全局数组方案的缺陷](#3. 全局数组方案的缺陷)
    • [五、 进阶方案:堆内存动态分配(Malloc)](#五、 进阶方案:堆内存动态分配(Malloc))
      • [1. 完整代码示例(`server_malloc.c`)](#1. 完整代码示例(server_malloc.c))
    • [六、 核心对比:全局数组 vs 堆内存 (Malloc)](#六、 核心对比:全局数组 vs 堆内存 (Malloc))
    • [七、 进阶避坑指南(必看)](#七、 进阶避坑指南(必看))
    • [八、 总结与思考](#八、 总结与思考)

Linux C 多线程 TCP 并发服务器:从 accept() 到资源管理深度剖析

一、 为什么要"并发"?

在传统的单线程TCP服务器中,程序按顺序执行:socket -> bind -> listen -> accept -> recv(阻塞等待)。这种模型存在一个致命缺陷:一旦服务器陷入与某个客户端的阻塞通信(例如等待recv返回),就无法处理其他客户端的连接请求。

为了解决这个问题,业界最直观的方案便是------多线程。我们将任务拆解:

  • 主线程 :专职负责调用 accept() 接收新连接。
  • 工作线程 :每个客户端连接分配一个独立线程,专职负责 recv/send

这种"一连接一线程"模型虽然简单,却是理解高并发编程的基石。


二、 核心概念:主线程 vs 子线程,监听套接字 vs 通信套接字

在编写代码前,必须厘清四个关键对象及其交互关系。

  1. 主线程 :管理监听套接字(listenfd),循环调用 accept()。当三次握手成功,accept()返回一个新的文件描述符(connfd
  2. 工作线程 :接收主线程传递的 connfd,负责与该特定客户端进行数据收发。
  3. 监听套接字(listenfd:由主线程独占,用于监听端口,不参与业务数据收发。
  4. 通信套接字(connfdaccept() 每次被调用,都会在内核中创建一个全新的文件描述符。

关键结论accept() 并不是把 listenfd 变成 connfd,而是保留 listenfd 的同时,创建并返回一个代表新连接的新描述符


三、 线程间的资源:共享与独占

在同一个进程地址空间中,多线程的资源分配如下:

  • 线程独占(安全) :线程栈、寄存器上下文、程序计数器。因此,各线程内部的局部变量(如 buf[1024])互不干扰。
  • 线程共享(需注意) :全局数据区、堆区、文件描述符表

这里最重要的知识点是文件描述符表是共享的 。这意味着,文件描述符 connfd=4 在进程中只有一个,主线程和工作线程都能通过这个数值访问到内核中的同一个TCP控制块。

但共享也带来了隐患 :如果主线程在创建子线程后马上 close(connfd),子线程中的 connfd 也会立刻失效。


四、 初代方案:全局数组(固定槽位)

这是很多初学者(以及你提供的参考代码)所采用的方式。核心思想是在全局区预置一个结构体数组,用来存放每个连接的 connfd 和线程ID。

1. 运行流程

主线程循环遍历数组找到空闲槽位,将 accept() 返回的 connfd 存入该槽位,然后创建子线程,并将该槽位的地址传给线程函数。

2. 完整代码示例(server_array.c

注:本代码保留了基础逻辑,并修正了原代码中的 strlen 陷阱和资源泄漏问题,但保留了全局数组的竞争特性以便后续对比。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <errno.h>

#define MAX_CLIENTS 128
#define PORT 8989
#define BUFFER_SIZE 1024

// 全局共享数组
typedef struct {
    int fd;
    pthread_t tid;
    struct sockaddr_in addr;
} ClientInfo;

ClientInfo g_clients[MAX_CLIENTS];

// 工作线程函数
void* worker_thread(void* arg) {
    ClientInfo* info = (ClientInfo*)arg;
    int connfd = info->fd; // 获取通信描述符
    char buf[BUFFER_SIZE];

    printf("[线程 %lu] 开始处理客户端 fd=%d\n", pthread_self(), connfd);

    while (1) {
        ssize_t ret = recv(connfd, buf, sizeof(buf) - 1, 0);
        if (ret > 0) {
            buf[ret] = '\0'; // 安全处理字符串
            printf("收到 fd=%d 数据: %s\n", connfd, buf);
            // 回射数据(简化版,未处理部分发送)
            send(connfd, buf, ret, 0); 
        } else if (ret == 0) {
            printf("客户端 fd=%d 主动断开\n", connfd);
            break;
        } else {
            if (errno != EINTR) {
                perror("recv error");
            }
            break;
        }
    }

    close(connfd);
    // 标记槽位空闲(存在数据竞争!)
    info->fd = -1; 
    printf("[线程 %lu] 退出\n", pthread_self());
    return NULL;
}

int main() {
    // 初始化数组
    for (int i = 0; i < MAX_CLIENTS; i++) {
        g_clients[i].fd = -1;
    }

    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) { perror("socket"); exit(1); }

    int opt = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(listenfd, (struct sockaddr*)&addr, sizeof(addr));
    listen(listenfd, 128);

    printf("服务器启动,监听端口: %d\n", PORT);

    while (1) {
        ClientInfo* info = NULL;
        // 1. 寻找空闲槽位(无锁扫描)
        for (int i = 0; i < MAX_CLIENTS; i++) {
            if (g_clients[i].fd == -1) {
                info = &g_clients[i];
                break;
            }
        }
        if (!info) {
            printf("连接数已满,拒绝服务\n");
            sleep(1);
            continue;
        }

        socklen_t len = sizeof(info->addr);
        int connfd = accept(listenfd, (struct sockaddr*)&info->addr, &len);
        if (connfd < 0) {
            if (errno != EINTR) perror("accept");
            continue;
        }

        // 2. 存入全局数组并创建线程
        info->fd = connfd;
        pthread_create(&info->tid, NULL, worker_thread, info);
        pthread_detach(info->tid); // 分离线程
    }

    close(listenfd);
    return 0;
}

3. 全局数组方案的缺陷

  • 存在数据竞争(Data Race) :主线程在 for 循环中判断 fd == -1 时,某个工作线程可能刚好退出并设置 fd = -1,导致未定义行为。修复需要加互斥锁(Mutex),增加了代码复杂度。
  • 最大连接数硬编码MAX_CLIENTS 限制了并发数,无法应对突发流量。
  • 槽位复用复杂:需要额外逻辑来管理哪个槽位是空闲的。

五、 进阶方案:堆内存动态分配(Malloc)

为了解决全局数组的痛点,我们可以利用堆内存 。每次 accept() 成功,就使用 malloc 动态创建一个 ClientInfo 结构体,将 connfd 封装进去,然后把这个堆内存的指针传给线程。

原理 :堆内存由程序员手动管理生命周期,只要不 free,数据就永远有效。由于每次 malloc 返回的是不同的内存地址,因此主线程的赋值和新连接的接受互不干扰,工作线程也能安全地持有自己的独立内存块。

1. 完整代码示例(server_malloc.c

这是一个更健壮、更符合生产级思路的实现,包含了正确的错误处理和资源所有权转移。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <errno.h>

#define PORT 8989
#define BUFFER_SIZE 4096

// 每个客户端的上下文信息
typedef struct {
    int connfd;
    struct sockaddr_in addr;
} ClientContext;

// 安全的全部发送函数(处理部分发送)
static int send_all(int fd, const void* data, size_t len) {
    const char* ptr = (const char*)data;
    size_t total = 0;
    while (total < len) {
        ssize_t sent = send(fd, ptr + total, len - total, MSG_NOSIGNAL);
        if (sent > 0) {
            total += sent;
        } else if (sent == -1 && errno == EINTR) {
            continue; // 被信号中断,重试
        } else {
            return -1; // 出错
        }
    }
    return 0;
}

// 工作线程函数
void* worker_thread(void* arg) {
    // 1. 接收所有权:从主线程手中接管这块堆内存
    ClientContext* ctx = (ClientContext*)arg;
    int connfd = ctx->connfd;
    struct sockaddr_in addr = ctx->addr; // 复制一份到栈上

    // 2. 立即释放堆内存!因为数据我们已经复制/保存下来了
    free(ctx);
    ctx = NULL; 

    char ip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &addr.sin_addr, ip, sizeof(ip));
    printf("[线程 %lu] 新连接来自 %s:%d, fd=%d\n", 
            pthread_self(), ip, ntohs(addr.sin_port), connfd);

    char buf[BUFFER_SIZE];
    while (1) {
        ssize_t ret = recv(connfd, buf, sizeof(buf), 0);
        if (ret > 0) {
            // recv返回多少字节,就回写多少字节(绝不能直接用strlen)
            if (send_all(connfd, buf, ret) == -1) {
                perror("send failed");
                break;
            }
        } else if (ret == 0) {
            printf("客户端 fd=%d 正常关闭\n", connfd);
            break;
        } else {
            if (errno != EINTR) {
                perror("recv failed");
            }
            break;
        }
    }

    // 3. 线程退出前关闭属于自己的通信套接字
    close(connfd);
    printf("[线程 %lu] 退出,fd=%d 已释放\n", pthread_self(), connfd);
    return NULL;
}

int main() {
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) { perror("socket"); exit(1); }

    int reuse = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));

    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;
    bind(listenfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
    listen(listenfd, 128);

    printf("多线程回射服务器启动,端口: %d\n", PORT);

    // 设置线程为分离属性,无需主线程 join
    pthread_attr_t attr;
    pthread_attr_init(&attr);
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

    while (1) {
        // 1. 为即将到来的新连接分配堆内存
        ClientContext* ctx = (ClientContext*)malloc(sizeof(ClientContext));
        if (!ctx) {
            perror("malloc failed");
            sleep(1);
            continue;
        }

        socklen_t addr_len = sizeof(ctx->addr);
        int connfd = accept(listenfd, (struct sockaddr*)&ctx->addr, &addr_len);
        
        if (connfd == -1) {
            if (errno == EINTR) continue;
            perror("accept failed");
            free(ctx); // accept失败,释放刚分配的堆内存
            continue;
        }

        // 2. 将 connfd 存入堆内存
        ctx->connfd = connfd;

        // 3. 创建线程,将堆内存指针传递进去
        pthread_t tid;
        int err = pthread_create(&tid, &attr, worker_thread, ctx);
        if (err != 0) {
            fprintf(stderr, "pthread_create failed: %s\n", strerror(err));
            close(connfd);  // 线程创建失败,由主线程关闭套接字
            free(ctx);      // 释放堆内存
            continue;
        }

        // 注意:这里主线程不再触碰 ctx 和 connfd,所有权已完全转移给子线程!
    }

    pthread_attr_destroy(&attr);
    close(listenfd);
    return 0;
}

六、 核心对比:全局数组 vs 堆内存 (Malloc)

对比维度 方案一:全局数组 (方案) 方案二:堆内存 Malloc (推荐)
内存位置 全局数据区(静态存储) 堆区(动态分配)
并发安全性 需加锁。主线程扫描槽位,子线程释放槽位,存在读-写竞争。 天然无锁。每个线程操作自己独立的内存地址,无共享冲突。
最大连接数 硬编码上限(如 128),无法突破。 受限于系统内存,理论上可支持更多连接。
内存生命周期 贯穿整个进程生命周期,即使槽位空闲,内存仍被占用。 精确控制malloc 分配,子线程读完数据后立即 free,随用随还。
代码维护性 差。需要维护空闲链表、槽位状态机。 好。所有权清晰:主线程 malloc,子线程 free
错误处理 复杂。线程崩溃可能导致槽位永远标记为忙。 灵活。即使线程异常,资源释放逻辑集中在局部,更易控制。

七、 进阶避坑指南(必看)

无论采用哪种方案,以下几个"雷区"在面试和实战中务必注意:

  1. 绝对不能传递局部变量的地址

    c 复制代码
    // 错误写法!connfd 在主线程栈上,下一轮循环会被覆盖
    int connfd = accept(...);
    pthread_create(..., worker, &connfd); 
  2. recv() 返回的是字节流,不是字符串

    c 复制代码
    char buf[1024];
    recv(fd, buf, sizeof(buf), 0);
    // 错误!buf 中可能没有 '\0',strlen 会越界
    // int len = strlen(buf); 
    // 正确:使用 recv 的返回值作为长度
  3. 文件描述符的所有权

    • 主线程 accept 后,所有权属于主线程。
    • 一旦 pthread_create 成功,所有权转移给工作线程。
    • 主线程绝不能再调用 close(connfd),否则子线程将操作无效描述符。
  4. write/send 不一定一次发送完

    必须使用循环发送(如上面代码中的 send_all 函数),直到所有字节被发送或出错为止。


八、 总结与思考

多线程TCP服务器的核心本质是主线程负责负载均衡(Accept),工作线程负责业务处理(IO)

  • 使用全局数组解决并发,虽然直观,但本质是"用空间换逻辑",引入了同步复杂性,属于"能用但不好用"的过渡方案。
  • 使用**堆内存(Malloc)**传递参数,本质是利用了"堆内存独立且持久"的特性,将并发任务的数据彻底隔离开来,实现了无锁化设计,是更健壮的工程实践。

掌握从 accept() 到线程参数传递的细节,不仅是为了写出能运行的代码,更是为了理解操作系统中虚拟内存文件描述符表的核心机制。希望这篇博客能帮你彻底攻克多线程TCP服务器的第一道难关!


编译与测试命令

bash 复制代码
gcc server_array.c -o server_array -pthread
gcc server_malloc.c -o server_malloc -pthread

# 使用 nc 命令测试
nc 127.0.0.1 8989
相关推荐
运维大师1 小时前
【Linux运维极简教程】10-日志管理与故障排查
linux·运维·服务器
聚焦前沿1 小时前
2026服务器热管理阀门选型白皮书
运维·服务器
明亮 Blog1 小时前
Wi-Fi 6/7的物联网“芯”机遇:高带宽低延时如何重塑智慧工厂的AGV物流革命
服务器·网络·数据库
杜子不疼.1 小时前
N5105小主机只装NAS太浪费?我把它改成了All-in-One家庭服务器
运维·服务器
笑锝没心没肺2 小时前
提升CPU平均使用率(自用)
linux·运维
宵时待雨2 小时前
linux笔记归纳9:进程间通信
linux·服务器·笔记
Zhang~Ling2 小时前
深入学习Linux: 命令行参数与环境变量详解
linux·服务器
十月的皮皮2 小时前
C语言学习笔记20260716-编译与链接
c语言·笔记·学习
会飞的小新2 小时前
《深入理解 DDR Memory Margin》系列之第四章 从 Bit Error 到 ECC——服务器为什么能够发现内存错误?
运维·服务器