TCP 并发服务器与 IO 多路复用详解
一、服务器基础架构
(一)单循环服务器深度解析
单循环服务器采用最简单的工作模式,其核心流程为:
- 创建监听套接字并绑定端口
- 进入无限循环:
- 调用
accept()
阻塞等待客户端连接 - 连接建立后,在同一循环中处理该客户端的所有请求
- 完成后关闭连接,再回到循环等待下一个连接
- 调用
局限性:
- 同一时间只能处理一个客户端,其他客户端必须排队等待
- 对于长时间连接或处理耗时的请求,会导致服务响应严重延迟
- 仅适用于测试环境或非常简单的应用场景
(二)并发服务器设计理念
并发服务器的核心目标是打破单循环的限制,实现多客户端的并行处理。其设计需考虑:
- 连接管理:如何高效接收和管理多个客户端连接
- 资源分配:如何为每个连接分配适当的系统资源
- 同步机制:当多个处理单元共享资源时的同步问题
- 性能优化:减少上下文切换和资源消耗
二、TCP 并发服务器实现模型
(一)多进程并发模型
工作原理
主进程负责监听和接收新连接,每建立一个新连接就创建一个子进程专门处理该连接的所有交互。
完整实现流程
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <signal.h>
#include <sys/wait.h>
// 信号处理函数,回收僵尸进程
void handle_zombie(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0);
}
// 子进程处理客户端请求
void handle_client(int connfd) {
char buffer[1024];
ssize_t n;
while ((n = read(connfd, buffer, sizeof(buffer)-1)) > 0) {
buffer[n] = '\0';
printf("Received: %s\n", buffer);
// 简单回显服务
write(connfd, buffer, n);
}
if (n < 0) {
perror("read error");
}
close(connfd);
exit(0);
}
int main() {
int listenfd, connfd;
struct sockaddr_in servaddr, cliaddr;
socklen_t clilen;
pid_t childpid;
// 注册信号处理函数,防止僵尸进程
signal(SIGCHLD, handle_zombie);
// 创建TCP套接字
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket error");
exit(1);
}
// 初始化服务器地址结构
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8080);
// 绑定套接字到端口
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("bind error");
exit(1);
}
// 开始监听,最大等待队列长度为10
if (listen(listenfd, 10) < 0) {
perror("listen error");
exit(1);
}
printf("Server listening on port 8080...\n");
while (1) {
clilen = sizeof(cliaddr);
// 接受客户端连接
if ((connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen)) < 0) {
perror("accept error");
continue;
}
// 创建子进程处理客户端
if ((childpid = fork()) == 0) {
// 子进程关闭监听套接字
close(listenfd);
// 处理客户端请求
handle_client(connfd);
}
// 父进程关闭连接套接字
close(connfd);
}
return 0;
}
优缺点分析
优点:
- 进程间完全隔离,一个客户端的处理崩溃不会影响其他客户端和主进程
- 可以充分利用多CPU系统的并行处理能力
缺点:
- 进程创建和销毁的开销大,消耗系统资源多
- 进程间通信(IPC)复杂,需要使用管道、共享内存等机制
- 并发量受限,系统能创建的进程数量有限
适用场景
- 客户端连接数不多,但每个连接的处理逻辑复杂
- 对安全性和隔离性要求高的场景
- 需要利用多个CPU核心进行计算的服务
(二)多线程并发模型
工作原理
主线程负责监听和接收新连接,每建立一个新连接就创建一个新线程专门处理该连接的交互。与多进程模型相比,线程共享进程的地址空间,资源消耗更少。
关键实现代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <pthread.h>
// 线程处理函数的参数结构体
typedef struct {
int connfd;
struct sockaddr_in cliaddr;
} ThreadArgs;
// 线程处理函数,处理客户端请求
void *handle_client(void *arg) {
ThreadArgs *args = (ThreadArgs *)arg;
int connfd = args->connfd;
char buffer[1024];
ssize_t n;
// 分离线程,自动回收资源
pthread_detach(pthread_self());
free(args); // 释放动态分配的参数
while ((n = read(connfd, buffer, sizeof(buffer)-1)) > 0) {
buffer[n] = '\0';
printf("Received: %s\n", buffer);
// 简单回显服务
write(connfd, buffer, n);
}
if (n < 0) {
perror("read error");
}
close(connfd);
return NULL;
}
int main() {
int listenfd, connfd;
struct sockaddr_in servaddr, cliaddr;
socklen_t clilen;
pthread_t tid;
ThreadArgs *args;
// 创建TCP套接字
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket error");
exit(1);
}
// 初始化服务器地址结构
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8080);
// 绑定套接字到端口
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("bind error");
exit(1);
}
// 开始监听
if (listen(listenfd, 10) < 0) {
perror("listen error");
exit(1);
}
printf("Server listening on port 8080...\n");
while (1) {
clilen = sizeof(cliaddr);
// 接受客户端连接
if ((connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen)) < 0) {
perror("accept error");
continue;
}
// 为线程分配参数
args = malloc(sizeof(ThreadArgs));
args->connfd = connfd;
args->cliaddr = cliaddr;
// 创建线程处理客户端
if (pthread_create(&tid, NULL, handle_client, args) != 0) {
perror("pthread_create error");
close(connfd);
free(args);
}
}
close(listenfd);
return 0;
}
优缺点分析
优点:
- 线程创建和切换的开销比进程小很多
- 线程间共享地址空间,数据共享方便
- 可以支持比多进程模型更高的并发量
缺点:
- 线程安全问题:多个线程共享资源时需要同步机制(互斥锁、条件变量等)
- 一个线程崩溃可能导致整个进程崩溃
- 受限于进程的资源限制
适用场景
- 中等并发量的网络服务
- 线程间需要频繁数据交换的场景
- 对响应速度要求较高的服务
(三)线程池模型
工作原理
线程池是一种池化技术,预先创建一定数量的线程,放在"池"中等待任务。当新的客户端连接到来时,主线程将连接作为任务放入任务队列,线程池中的空闲线程会从队列中取出任务进行处理。
实现关键点
- 固定数量的工作线程
- 线程安全的任务队列
- 任务添加和取出的同步机制
- 线程池的创建、销毁和管理接口
优缺点分析
优点:
- 避免了频繁创建和销毁线程的开销
- 控制并发线程数量,防止资源耗尽
- 任务处理响应更快,因为线程已经预先创建
缺点:
- 实现相对复杂
- 线程池大小需要根据应用场景调优
- 长时间运行的任务可能导致线程池阻塞
适用场景
- 高并发、短任务的网络服务
- Web服务器、数据库连接池等
- 需要限制并发线程数量的场景
三、IO多路复用模型
(一)基本概念
IO多路复用允许一个进程同时监视多个文件描述符,一旦某个描述符就绪(可读、可写或异常),能够通知程序进行相应的处理。这种模型不需要创建大量进程或线程,而是通过一个进程处理多个IO操作。
(二)select模型
工作原理
select函数允许程序监视多个文件描述符,等待其中一个或多个变为就绪状态。它使用三个文件描述符集合分别表示可读、可写和异常事件。
完整实现代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024
int main() {
int listenfd, connfd, maxfd;
struct sockaddr_in servaddr, cliaddr;
socklen_t clilen;
char buffer[BUFFER_SIZE];
fd_set readfds, allfds;
int clientfds[MAX_CLIENTS];
int i, nready, n;
// 初始化客户端文件描述符数组
for (i = 0; i < MAX_CLIENTS; i++) {
clientfds[i] = -1;
}
// 创建TCP套接字
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket error");
exit(1);
}
// 初始化服务器地址结构
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8080);
// 绑定套接字到端口
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("bind error");
exit(1);
}
// 开始监听
if (listen(listenfd, 10) < 0) {
perror("listen error");
exit(1);
}
printf("Server listening on port 8080...\n");
// 初始化文件描述符集合
FD_ZERO(&allfds);
FD_SET(listenfd, &allfds);
maxfd = listenfd;
while (1) {
// 每次调用select前都需要重置readfds
readfds = allfds;
// 调用select等待就绪事件
nready = select(maxfd + 1, &readfds, NULL, NULL, NULL);
if (nready < 0) {
perror("select error");
continue;
}
// 检查监听套接字是否有新连接
if (FD_ISSET(listenfd, &readfds)) {
clilen = sizeof(cliaddr);
if ((connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen)) < 0) {
perror("accept error");
continue;
}
printf("New client connected\n");
// 将新连接添加到客户端数组和文件描述符集合
for (i = 0; i < MAX_CLIENTS; i++) {
if (clientfds[i] == -1) {
clientfds[i] = connfd;
FD_SET(connfd, &allfds);
if (connfd > maxfd) {
maxfd = connfd;
}
break;
}
}
// 如果客户端数量达到上限
if (i == MAX_CLIENTS) {
printf("Too many clients\n");
close(connfd);
}
// 如果没有其他就绪事件,继续循环
if (--nready <= 0) {
continue;
}
}
// 检查客户端套接字的读写事件
for (i = 0; i < MAX_CLIENTS; i++) {
if ((connfd = clientfds[i]) == -1) {
continue;
}
if (FD_ISSET(connfd, &readfds)) {
// 读取客户端数据
if ((n = read(connfd, buffer, BUFFER_SIZE - 1)) <= 0) {
// 客户端关闭连接
if (n < 0) {
perror("read error");
} else {
printf("Client disconnected\n");
}
close(connfd);
FD_CLR(connfd, &allfds);
clientfds[i] = -1;
} else {
// 处理数据,这里简单回显
buffer[n] = '\0';
printf("Received: %s\n", buffer);
write(connfd, buffer, n);
}
if (--nready <= 0) {
break;
}
}
}
}
close(listenfd);
return 0;
}
优缺点分析
优点:
- 跨平台支持好,在Linux、Windows等系统都有实现
- 实现相对简单,容易理解和使用
缺点:
- 每次调用select都需要将文件描述符集合从用户空间复制到内核空间
- 文件描述符数量有限制(通常是1024)
- 需要遍历所有文件描述符来检查就绪状态,效率随描述符数量增加而下降