文章目录
- [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))
- [1. 完整代码示例(`server_malloc.c`)](#1. 完整代码示例(
- [六、 核心对比:全局数组 vs 堆内存 (Malloc)](#六、 核心对比:全局数组 vs 堆内存 (Malloc))
- [七、 进阶避坑指南(必看)](#七、 进阶避坑指南(必看))
- [八、 总结与思考](#八、 总结与思考)
Linux C 多线程 TCP 并发服务器:从 accept() 到资源管理深度剖析
一、 为什么要"并发"?
在传统的单线程TCP服务器中,程序按顺序执行:socket -> bind -> listen -> accept -> recv(阻塞等待)。这种模型存在一个致命缺陷:一旦服务器陷入与某个客户端的阻塞通信(例如等待recv返回),就无法处理其他客户端的连接请求。
为了解决这个问题,业界最直观的方案便是------多线程。我们将任务拆解:
- 主线程 :专职负责调用
accept()接收新连接。 - 工作线程 :每个客户端连接分配一个独立线程,专职负责
recv/send。
这种"一连接一线程"模型虽然简单,却是理解高并发编程的基石。
二、 核心概念:主线程 vs 子线程,监听套接字 vs 通信套接字
在编写代码前,必须厘清四个关键对象及其交互关系。
- 主线程 :管理监听套接字(
listenfd),循环调用accept()。当三次握手成功,accept()会返回一个新的文件描述符(connfd)。 - 工作线程 :接收主线程传递的
connfd,负责与该特定客户端进行数据收发。 - 监听套接字(
listenfd):由主线程独占,用于监听端口,不参与业务数据收发。 - 通信套接字(
connfd) :accept()每次被调用,都会在内核中创建一个全新的文件描述符。
关键结论 :
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。 |
| 错误处理 | 复杂。线程崩溃可能导致槽位永远标记为忙。 | 灵活。即使线程异常,资源释放逻辑集中在局部,更易控制。 |
七、 进阶避坑指南(必看)
无论采用哪种方案,以下几个"雷区"在面试和实战中务必注意:
-
绝对不能传递局部变量的地址:
c// 错误写法!connfd 在主线程栈上,下一轮循环会被覆盖 int connfd = accept(...); pthread_create(..., worker, &connfd); -
recv()返回的是字节流,不是字符串:cchar buf[1024]; recv(fd, buf, sizeof(buf), 0); // 错误!buf 中可能没有 '\0',strlen 会越界 // int len = strlen(buf); // 正确:使用 recv 的返回值作为长度 -
文件描述符的所有权:
- 主线程
accept后,所有权属于主线程。 - 一旦
pthread_create成功,所有权转移给工作线程。 - 主线程绝不能再调用
close(connfd),否则子线程将操作无效描述符。
- 主线程
-
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