# 实践03 Web服务器的线程池和业务分割模型 > [!abstract] 实验概要 > - **实验名称**: 实验三. Web服务器的线程池和业务分割模型 > - **实验目的**: 将webserver改造为预线程版本和业务分割模型,并进行性能测试,掌握并行网络服务器的设计、性能测试和优化方法 > - **前置知识**: [[07_多线程编程]]、[[10_并发服务器]]、[[实践02_多进程多线程服务器]] > - **核心概念**: 线程池、生产者-消费者模型、业务分割、性能测试 --- ## 一、实验目的 1. 理解预线程化(prethreading)服务器的工作原理 2. 掌握线程池的设计与实现方法 3. 理解业务分割模型的架构思想 4. 学会使用 `http_load` 进行服务器性能测试 5. 掌握使用 `vmstat`、`iostat`、`gprof` 等工具监测系统性能 6. 通过对比分析,理解不同并发模型的性能差异 --- ## 二、涉及知识点 - POSIX 线程池设计(详见 [[07_多线程编程]]) - 生产者-消费者模型与信号量同步 - 预线程化服务器模型(详见 [[10_并发服务器]]) - 业务分割(Pipeline)并行模型 - 消息队列设计与实现 - 性能测试工具:`http_load`、`vmstat`、`iostat`、`gprof` - 并行服务器的性能瓶颈分析与优化 --- ## 三、源代码文件清单 | 文件名 | 说明 | |--------|------| | `taskline.c` / `taskline.h` | 预线程服务器任务管理程序 | | `pool.c` / `pool.h` | 线程池服务器的线程管理和任务管理程序 | | `urls` | `http_load` 测试 URL 列表 | > [!tip] 编译命令 > 使用调试模式编译: > ```bash > make M="-DDEBUG" rebuild > ``` --- ## 四、实验任务 ### 任务一:准备工作 将源代码复制到 Ubuntu 环境并编译: ```bash # 复制源代码到工作目录 cp -r /path/to/source/* ./ # 使用调试模式编译 make M="-DDEBUG" rebuild ``` > [!warning] 注意事项 > 确保系统已安装 `http_load` 工具,如未安装: > ```bash > sudo apt-get install http-load > ``` ### 任务二:阅读和理解线程池实现 阅读并理解 `togglest_pool.c` 的线程池实现,编译运行验证其正确性。 #### 线程池核心数据结构 ```c /* pool.h - 线程池定义 */ typedef struct { pthread_t *threads; /* 线程数组 */ int thread_count; /* 线程数量 */ int *task_queue; /* 任务队列(socket fd) */ int queue_size; /* 队列容量 */ int front; /* 队头指针 */ int rear; /* 队尾指针 */ int count; /* 当前队列中任务数 */ pthread_mutex_t mutex; /* 互斥锁 */ sem_t slots; /* 空闲槽位信号量 */ sem_t items; /* 可用任务信号量 */ int shutdown; /* 关闭标志 */ } thread_pool_t; ``` #### 线程池核心操作 ```c /* pool.c - 线程池操作实现 */ /* 初始化线程池 */ void pool_init(thread_pool_t *pool, int threads, int queue_size) { pool->threads = (pthread_t *)malloc(threads * sizeof(pthread_t)); pool->task_queue = (int *)malloc(queue_size * sizeof(int)); pool->thread_count = threads; pool->queue_size = queue_size; pool->front = pool->rear = pool->count = 0; pool->shutdown = 0; pthread_mutex_init(&pool->mutex, NULL); sem_init(&pool->slots, 0, queue_size); sem_init(&pool->items, 0, 0); /* 预创建所有工作线程 */ for (int i = 0; i < threads; i++) { pthread_create(&pool->threads[i], NULL, worker, (void *)pool); } } /* 向任务队列添加任务(生产者) */ void pool_enqueue(thread_pool_t *pool, int conn_fd) { sem_wait(&pool->slots); /* 等待空闲槽位 */ pthread_mutex_lock(&pool->mutex); /* 进入临界区 */ pool->task_queue[pool->rear] = conn_fd; pool->rear = (pool->rear + 1) % pool->queue_size; pool->count++; pthread_mutex_unlock(&pool->mutex); sem_post(&pool->items); /* 通知有新任务 */ } /* 从任务队列取出任务(消费者) */ int pool_dequeue(thread_pool_t *pool) { sem_wait(&pool->items); /* 等待可用任务 */ pthread_mutex_lock(&pool->mutex); /* 进入临界区 */ int conn_fd = pool->task_queue[pool->front]; pool->front = (pool->front + 1) % pool->queue_size; pool->count--; pthread_mutex_unlock(&pool->mutex); sem_post(&pool->slots); /* 释放槽位 */ return conn_fd; } /* 工作线程主函数 */ void *worker(void *arg) { thread_pool_t *pool = (thread_pool_t *)arg; while (1) { int conn_fd = pool_dequeue(pool); /* 取出任务 */ if (conn_fd < 0) break; /* 收到关闭信号 */ handle_request(conn_fd); /* 处理 HTTP 请求 */ close(conn_fd); /* 关闭连接 */ } return NULL; } ``` #### 线程池工作流程 ``` 主线程(Accept) --> 任务队列 --> 工作线程1 (处理请求) [conn_fd] --> 工作线程2 (处理请求) --> 工作线程N (处理请求) ``` > [!info] 生产者-消费者模型 > 线程池本质上是一个 **多生产者-多消费者** 模型: > - **生产者**:主线程不断 `accept` 新连接,将 `conn_fd` 放入队列 > - **消费者**:工作线程从队列取出 `conn_fd`,处理 HTTP 请求 > - 信号量 `slots` 和 `items` 实现了线程间的同步 > > 该模型在 [[07_多线程编程]] 中已有详细讨论,此处是其在网络服务器中的实际应用。 ### 任务三:实现 Web 服务器线程池版本 仿照 `togglest_pool.c` 的设计,将 webserver 改造为线程池版本,并使用 `http_load` 测试性能。 #### 编译与运行 ```bash # 编译线程池版 webserver make M="-DDEBUG" rebuild # 启动服务器 ./webserver 8080 # 另一终端:http_load 性能测试 http_load -parallel 5 -fetches 50 -seconds 20 urls ``` #### urls 文件示例 ``` http://localhost:8080/index.html http://localhost:8080/test.html http://localhost:8080/image.jpg ``` > [!example] http_load 参数说明 > | 参数 | 含义 | > |------|------| > | `-parallel N` | 并发连接数为 N | > | `-fetches N` | 总共发起 N 次请求 | > | `-seconds N` | 测试持续 N 秒 | > > 两参数同时指定时,先满足的条件生效。 ### 任务四:设计实现业务分割模型 将 webserver 改造为业务分割模型,将 HTTP 请求处理拆分为三个阶段,每个阶段由独立的线程池负责。 #### 业务分割架构图 ```mermaid graph LR C[客户端请求] --> A[read msg
threadpool] A -->|文件名 + socket| Q1[filename
queue] Q1 --> B[read file
threadpool] B -->|内容 + socket| Q2[msg
queue] Q2 --> D[send msg
threadpool] D --> R[客户端响应] style A fill:#4CAF50,color:#fff style B fill:#2196F3,color:#fff style D fill:#FF9800,color:#fff style Q1 fill:#E91E63,color:#fff style Q2 fill:#9C27B0,color:#fff ``` #### 业务分割详细流程 ```mermaid graph TB subgraph "阶段一:消息读取与解析" A1[线程从 socket 读取 HTTP 请求] --> A2[解析请求行
GET /path HTTP/1.1] A2 --> A3[提取文件名] A3 --> A4[将 filename + conn_fd
加入 filename queue] end subgraph "阶段二:文件读取" B1[线程从 filename queue
取出 filename + conn_fd] --> B2[打开并读取文件内容] B2 --> B3[构造 HTTP 响应] B3 --> B4[将 response + conn_fd
加入 msg queue] end subgraph "阶段三:响应发送" C1[线程从 msg queue
取出 response + conn_fd] --> C2[通过 socket
发送 HTTP 响应] C2 --> C3[关闭连接] end A4 --> B1 B4 --> C1 style A1 fill:#4CAF50,color:#fff style B1 fill:#2196F3,color:#fff style C1 fill:#FF9800,color:#fff ``` #### 消息传递时序图 ```mermaid sequenceDiagram participant Client as 客户端 participant RM as read msg
threadpool participant FQ as filename
queue participant RF as read file
threadpool participant MQ as msg
queue participant SM as send msg
threadpool Client->>RM: HTTP 请求 (socket) activate RM RM->>RM: 读取 socket 数据 RM->>RM: 解析 HTTP 请求
提取 filename RM->>FQ: enqueue(filename, socket) deactivate RM FQ->>RF: dequeue(filename, socket) activate RF RF->>RF: 打开文件 RF->>RF: 读取文件内容 RF->>RF: 构造 HTTP 响应 RF->>MQ: enqueue(response, socket) deactivate RF MQ->>SM: dequeue(response, socket) activate SM SM->>Client: 发送 HTTP 响应 SM->>SM: 关闭连接 deactivate SM ``` #### 三个线程池的职责 > [!note] 线程池一:read msg threadpool > - **职责**: 从 socket 读取 HTTP 请求消息并解析 > - **输入**: 客户端的 socket 连接 > - **处理**: 调用 `recv()` 读取数据,解析请求行获取文件名 > - **输出**: 将 `filename + conn_fd` 加入 filename queue > - **关键点**: I/O 密集型操作,需要处理不完整读取 > [!note] 线程池二:read file threadpool > - **职责**: 根据文件名读取文件内容 > - **输入**: 从 filename queue 取出的 `filename + conn_fd` > - **处理**: 打开文件、读取内容、构造 HTTP 响应头和正文 > - **输出**: 将 `response + conn_fd` 加入 msg queue > - **关键点**: 磁盘 I/O 密集型操作,是性能瓶颈之一 > [!note] 线程池三:send msg threadpool > - **职责**: 将 HTTP 响应发送回客户端 > - **输入**: 从 msg queue 取出的 `response + conn_fd` > - **处理**: 调用 `send()` 发送响应数据 > - **输出**: 关闭 socket 连接 > - **关键点**: 网络 I/O 密集型操作 #### 两个消息队列 > [!important] 消息队列设计 > 消息队列是线程池之间通信的桥梁,采用生产者-消费者模型实现: > > **filename queue**: > - 生产者: read msg threadpool > - 消费者: read file threadpool > - 数据: `{char filename[256], int conn_fd}` > > **msg queue**: > - 生产者: read file threadpool > - 消费者: send msg threadpool > - 数据: `{char *response, int length, int conn_fd}` #### 消息队列核心实现 ```c /* 消息队列数据结构 */ typedef struct { int conn_fd; char filename[256]; /* filename queue 使用 */ char *response; /* msg queue 使用 */ int response_len; /* 响应长度 */ } queue_item_t; typedef struct { queue_item_t *items; int capacity; int front; int rear; int count; pthread_mutex_t mutex; sem_t slots; sem_t items_sem; } message_queue_t; /* 初始化消息队列 */ void mq_init(message_queue_t *mq, int capacity) { mq->items = (queue_item_t *)calloc(capacity, sizeof(queue_item_t)); mq->capacity = capacity; mq->front = mq->rear = mq->count = 0; pthread_mutex_init(&mq->mutex, NULL); sem_init(&mq->slots, 0, capacity); sem_init(&mq->items_sem, 0, 0); } /* 入队操作 */ void mq_enqueue(message_queue_t *mq, queue_item_t *item) { sem_wait(&mq->slots); pthread_mutex_lock(&mq->mutex); mq->items[mq->rear] = *item; mq->rear = (mq->rear + 1) % mq->capacity; mq->count++; pthread_mutex_unlock(&mq->mutex); sem_post(&mq->items_sem); } /* 出队操作 */ void mq_dequeue(message_queue_t *mq, queue_item_t *item) { sem_wait(&mq->items_sem); pthread_mutex_lock(&mq->mutex); *item = mq->items[mq->front]; mq->front = (mq->front + 1) % mq->capacity; mq->count--; pthread_mutex_unlock(&mq->mutex); sem_post(&mq->slots); } ``` #### 业务分割服务器主框架 ```c /* taskline.c - 业务分割 webserver 主框架 */ #include "taskline.h" #include "pool.h" #define READ_MSG_THREADS 4 /* 读消息线程池大小 */ #define READ_FILE_THREADS 4 /* 读文件线程池大小 */ #define SEND_MSG_THREADS 4 /* 发送消息线程池大小 */ #define QUEUE_CAPACITY 64 /* 消息队列容量 */ /* 两个消息队列 */ message_queue_t filename_queue; /* 文件名队列 */ message_queue_t msg_queue; /* 响应消息队列 */ /* 阶段一:读取并解析 HTTP 请求 */ void *read_msg_worker(void *arg) { while (1) { int conn_fd = pool_dequeue(&read_msg_pool); char buf[8192]; int n = recv(conn_fd, buf, sizeof(buf) - 1, 0); if (n <= 0) { close(conn_fd); continue; } buf[n] = '\0'; /* 解析 HTTP 请求 */ char method[16], path[256], version[16]; sscanf(buf, "%s %s %s", method, path, version); /* 构造文件路径 */ char filename[256]; snprintf(filename, sizeof(filename), ".%s", path); /* 将 filename + conn_fd 加入 filename queue */ queue_item_t item; item.conn_fd = conn_fd; strncpy(item.filename, filename, sizeof(item.filename)); mq_enqueue(&filename_queue, &item); } return NULL; } /* 阶段二:读取文件内容 */ void *read_file_worker(void *arg) { while (1) { queue_item_t item; mq_dequeue(&filename_queue, &item); /* 打开并读取文件 */ int fd = open(item.filename, O_RDONLY); if (fd < 0) { /* 文件不存在,构造 404 响应 */ item.response = build_404_response(&item.response_len); } else { /* 读取文件,构造 200 响应 */ item.response = build_200_response(fd, &item.response_len); close(fd); } /* 将 response + conn_fd 加入 msg queue */ mq_enqueue(&msg_queue, &item); } return NULL; } /* 阶段三:发送 HTTP 响应 */ void *send_msg_worker(void *arg) { while (1) { queue_item_t item; mq_dequeue(&msg_queue, &item); /* 发送响应 */ send(item.conn_fd, item.response, item.response_len, 0); free(item.response); close(item.conn_fd); } return NULL; } int main(int argc, char **argv) { if (argc != 2) { fprintf(stderr, "Usage: %s \n", argv[0]); exit(1); } /* 初始化两个消息队列 */ mq_init(&filename_queue, QUEUE_CAPACITY); mq_init(&msg_queue, QUEUE_CAPACITY); /* 初始化三个线程池 */ pool_init(&read_msg_pool, READ_MSG_THREADS, QUEUE_CAPACITY); pool_init(&read_file_pool, READ_FILE_THREADS, QUEUE_CAPACITY); pool_init(&send_msg_pool, SEND_MSG_THREADS, QUEUE_CAPACITY); /* 启动三个阶段的工作线程 */ pthread_t tid; for (int i = 0; i < READ_MSG_THREADS; i++) pthread_create(&tid, NULL, read_msg_worker, NULL); for (int i = 0; i < READ_FILE_THREADS; i++) pthread_create(&tid, NULL, read_file_worker, NULL); for (int i = 0; i < SEND_MSG_THREADS; i++) pthread_create(&tid, NULL, send_msg_worker, NULL); /* 主线程接受连接 */ int listen_fd = open_listen_sock(atoi(argv[1])); printf("业务分割 webserver 启动,端口 %s\n", argv[1]); while (1) { struct sockaddr_in cliaddr; socklen_t clien = sizeof(cliaddr); int conn_fd = accept(listen_fd, (struct sockaddr *)&cliaddr, &clien); if (conn_fd < 0) continue; /* 将新连接放入 read msg 线程池的任务队列 */ pool_enqueue(&read_msg_pool, conn_fd); } return 0; } ``` > [!tip] 与 [[实践02_多进程多线程服务器]] 的对比 > - 实践02 中每个请求由单个线程串行完成「读消息 -> 读文件 -> 发送响应」全部工作 > - 本实验将三个阶段拆分到不同的线程池,实现了 **流水线并行** > - 当一个线程在发送响应时,其他线程已经在处理新请求的文件读取,提高了吞吐量 --- ## 五、性能测试 ### 5.1 http_load 测试 ```bash # 低并发测试 http_load -parallel 5 -fetches 50 -seconds 20 urls # 中并发测试 http_load -parallel 20 -fetches 200 -seconds 20 urls # 高并发测试 http_load -parallel 50 -fetches 500 -seconds 20 urls ``` > [!example] http_load 输出指标 > | 指标 | 含义 | > |------|------| > | fetches | 成功完成的请求总数 | > | elapsed | 测试耗时(秒) | > | mean bytes/transfer | 平均每次传输字节数 | > | fetches/sec | 每秒完成的请求数(吞吐量) | > | msecs/connect | 平均连接建立时间 | > | msecs/first-response | 平均首字节响应时间 | > | HTTP response codes | 各状态码的出现次数 | ### 5.2 系统性能监测 #### vmstat 监控 ```bash # 每 2 秒采集一次,共 10 次 vmstat 2 10 ``` > [!info] vmstat 关键字段 > | 字段 | 含义 | > |------|------| > | `r` | 运行队列中的进程数 | > | `b` | 阻塞等待 I/O 的进程数 | > | `us` | 用户态 CPU 占用百分比 | > | `sy` | 内核态 CPU 占用百分比 | > | `wa` | I/O 等待 CPU 时间百分比 | > | `free` | 空闲内存(KB) | > | `buff` | 缓冲区大小(KB) | #### iostat 监控 ```bash # 每 2 秒采集一次,共 10 次 iostat -k 2 10 ``` > [!info] iostat 关键字段 > | 字段 | 含义 | > |------|------| > | `tps` | 每秒传输次数 | > | `kB_read/s` | 每秒读取数据量(KB) | > | `kB_wrtn/s` | 每秒写入数据量(KB) | > | `await` | 平均 I/O 等待时间(ms) | > | `util` | 设备利用率百分比 | #### gprof 性能分析 ```bash # 编译时加入性能分析选项 gcc -pg -o webserver webserver.c pool.c taskline.c -lpthread # 运行服务器并进行测试 ./webserver 8080 & http_load -parallel 20 -fetches 200 -seconds 20 urls # 生成性能分析报告 gprof ./webserver gmon.out > perf.txt cat perf.txt ``` > [!warning] gprof 使用注意 > 1. 编译时必须加 `-pg` 选项 > 2. 程序需要正常退出(`Ctrl+C` 终止可能无法生成 `gmon.out`) > 3. 分析结果包括各函数的调用次数和执行时间占比 ### 5.3 性能监测指标 > [!important] 实验需要监测的关键指标 > > **线程池指标**: > - 线程池中线程的 **平均活跃时间** 及 **阻塞时间** > - 线程 **最高/最低/平均活跃数量** > > **消息队列指标**: > - filename queue 中的消息长度(积压情况) > - msg queue 中的消息长度(积压情况) > > **系统资源指标**: > - 系统 I/O 使用率(`iostat`) > - 内存使用情况(`vmstat`) > - CPU 使用率和等待率(`vmstat`) --- ## 六、性能对比分析 ### 不同模型的性能对比 | 模型 | 优点 | 缺点 | 适用场景 | |------|------|------|----------| | 迭代式服务器 | 实现简单 | 无法并发处理 | 学习演示 | | 多进程服务器([[实践02_多进程多线程服务器]]) | 进程隔离,稳定性好 | fork 开销大 | 连接数少 | | 多线程服务器([[实践02_多进程多线程服务器]]) | 共享内存,开销小 | 线程数爆炸风险 | 通用场景 | | 预线程化服务器 | 线程复用,无创建开销 | 固定线程数 | 高并发 | | 业务分割模型 | 流水线并行,吞吐量高 | 复杂度高,队列延迟 | 极高并发 | ### 业务分割模型的优缺点 > [!success] 优势 > 1. **流水线并行**: 三个阶段可以同时处理不同的请求 > 2. **资源隔离**: I/O 密集操作和计算操作分别由不同线程池处理 > 3. **可独立扩展**: 每个阶段的线程数可以根据瓶颈独立调整 > 4. **缓存友好**: 文件读取线程可以实现文件内容缓存 > [!failure] 劣势 > 1. **复杂度高**: 需要管理多个线程池和消息队列 > 2. **队列延迟**: 消息在队列中排队等待会产生额外延迟 > 3. **内存开销**: 消息队列和中间数据结构占用额外内存 > 4. **调试困难**: 多线程池间的数据流转增加了调试难度 --- ## 七、常见问题与解决 | 问题 | 原因 | 解决方法 | |------|------|----------| | 编译报错 `undefined reference to pthread` | 未链接 pthread 库 | 编译时加 `-lpthread` | | 队列满导致线程阻塞 | 消息队列容量不足 | 增大 `QUEUE_CAPACITY` 或增加下游线程数 | | 文件读取阶段成为瓶颈 | 磁盘 I/O 速度限制 | 增加 read file 线程数,或引入文件缓存 | | 内存泄漏 | response 未 `free` | 确保 send 线程发送后释放内存 | | 连接泄漏 | 异常路径未 `close(conn_fd)` | 在每个异常处理分支都关闭 socket | | 线程安全问题 | 共享变量未加锁 | 使用互斥锁保护所有共享数据 | | http_load 测试连接被拒 | 服务器未启动或端口错误 | 确认服务器运行状态和端口号 | --- ## 八、实验总结 通过本实验,应掌握以下能力: 1. **线程池设计与实现**: 理解预线程化技术,掌握基于生产者-消费者模型的线程池实现方法 2. **业务分割架构**: 理解将复杂业务拆分为多个阶段并行处理的设计思想 3. **消息队列设计**: 掌握线程间安全通信的消息队列实现 4. **性能测试方法**: 学会使用 `http_load`、`vmstat`、`iostat`、`gprof` 等工具进行全面的性能测试 5. **性能优化思路**: 通过对比分析不同模型的性能指标,理解并发服务器的优化方向 > [!question] 思考题 > 1. 如果文件读取阶段成为瓶颈,除了增加线程数外,还有哪些优化策略? > 2. 业务分割模型中,如何确定每个线程池的最佳线程数量? > 3. 与 [[10_并发服务器]] 中的预线程化模型相比,业务分割模型在什么场景下优势更明显? > 4. 如果需要支持动态内容(如 CGI),业务分割模型应如何调整? --- ## 相关链接 - [[07_多线程编程]] - pthread 编程基础、生产者-消费者模型 - [[10_并发服务器]] - 预线程化服务器模型 - [[实践02_多进程多线程服务器]] - 多进程和多线程服务器实现