Files
obsidian/操作系统/操作系统实践/实践02_多进程多线程服务器.md

629 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 实践02Web服务器的多进程与多线程模型实现
> [!info] 实验信息
> **课程**:操作系统实践
> **实验名称**:实验二 — Web服务器的多进程与多线程模型实现
> **前置实验**[[实践01_Web服务器初步实现]]
## 实验目的
1. 将 Web 服务器改造为多进程、多线程、预线程、线程池四种版本
2. 掌握并行网络服务器的设计方法
3. 使用 `http_load` 进行性能测试与对比分析
4. 理解不同并发模型的优劣及适用场景
> [!tip] 关联理论课程
> - [[06_进程控制]] — `fork`、信号处理、僵尸进程回收
> - [[07_多线程编程]] — `pthread_create`、线程同步
> - [[10_并发服务器]] — 并发模型概述与对比
---
## 源代码文件清单
> [!note] 本次实验新增文件(在实验一基础上)
> | 文件 | 说明 |
> |------|------|
> | `togglesp.c` | 多进程 toggle 服务器 |
> | `togglest.c` | 多线程 toggle 服务器 |
> | `togglest_pre.c` | 预线程 toggle 服务器 |
> | `togglest_pool.c` | 线程池 toggle 服务器 |
---
## 一、四种并发模型总览
### 模型架构图
```mermaid
graph TD
subgraph "模型一:多进程 Process-per-Connection"
C1[客户端 1] -->|accept| S1[主进程]
S1 -->|fork| P1[子进程 1]
S1 -->|fork| P2[子进程 2]
S1 -->|fork| P3[子进程 3]
P1 --> R1[处理请求]
P2 --> R2[处理请求]
P3 --> R3[处理请求]
end
```
```mermaid
graph TD
subgraph "模型二:多线程 Thread-per-Connection"
C2[客户端] -->|accept| SM[主线程]
SM -->|pthread_create| T1[线程 1]
SM -->|pthread_create| T2[线程 2]
SM -->|pthread_create| T3[线程 3]
T1 --> H1[处理请求]
T2 --> H2[处理请求]
T3 --> H3[处理请求]
end
```
```mermaid
graph TD
subgraph "模型三:预线程 Pre-threaded"
C3[客户端] -->|accept| PM[主线程]
PM -->|放入任务队列| Q[(任务队列)]
Q -->|取任务| W1[工作线程 1]
Q -->|取任务| W2[工作线程 2]
Q -->|取任务| W3[工作线程 3]
Q -->|取任务| W4[工作线程 N]
end
```
```mermaid
graph TD
subgraph "模型四:线程池 Thread Pool"
C4[客户端] -->|submit| POOL[线程池管理器]
POOL -->|dispatch| Q2[(任务队列)]
Q2 -->|worker| WT1[工作线程 1]
Q2 -->|worker| WT2[工作线程 2]
Q2 -->|worker| WT3[工作线程 N]
WT1 -->|完成| POOL
WT2 -->|完成| POOL
end
```
### 四种模型对比
| 模型 | 源文件 | 创建时机 | 优点 | 缺点 |
|------|--------|----------|------|------|
| 多进程 | `togglesp.c` | 按需 `fork` | 简单、进程隔离 | `fork` 开销大 |
| 多线程 | `togglest.c` | 按需 `pthread_create` | 比 `fork` 轻量 | 线程创建仍有开销 |
| 预线程 | `togglest_pre.c` | 启动时创建线程池 | 无运行时创建开销 | 需要任务队列同步 |
| 线程池 | `togglest_pool.c` | 启动时创建线程池 | 最优性能、可复用 | 实现复杂度最高 |
---
## 二、任务一:验证 Toggle 服务器的四种版本
### 2.1 编译
```bash
# 多进程版本
gcc -o togglesp togglesp.c csapp.c -lpthread
# 多线程版本
gcc -o togglest togglest.c csapp.c -lpthread
# 预线程版本
gcc -o togglest_pre togglest_pre.c csapp.c -lpthread
# 线程池版本
gcc -o togglest_pool togglest_pool.c csapp.c -lpthread
```
### 2.2 分别运行与测试
```bash
# ---- 测试多进程版本 ----
./togglesp 8080 &
./togglec localhost 8080
# 输入: Hello World
# 输出: hELLO wORLD
# ---- 测试多线程版本 ----
./togglest 8081 &
./togglec localhost 8081
# ---- 测试预线程版本 ----
./togglest_pre 8082 &
./togglec localhost 8082
# ---- 测试线程池版本 ----
./togglest_pool 8083 &
./togglec localhost 8083
```
> [!warning] 注意
> 测试前确保端口未被占用,可用 `lsof -i:8080` 或 `netstat -tlnp` 检查。
---
## 三、多进程模型详解 — togglesp.c
### 3.1 整体流程
```mermaid
sequenceDiagram
participant Main as 主进程
participant Child as 子进程
participant Client as 客户端
Main->>Main: Signal(SIGCHLD, handler)
Main->>Main: listen_sock = open_listen_sock(port)
loop 持续接受连接
Main->>Main: conn_sock = accept(...)
Main->>Main: fork()
alt 子进程
Main->>Child: 进入子进程
Child->>Main: close(listen_sock)
Child->>Client: toggle(conn_sock, hit)
Child->>Child: exit(0)
else 父进程
Main->>Main: close(conn_sock)
end
end
```
### 3.2 核心代码解析
#### 信号处理:回收僵尸进程
```c
void sigchld_handler(int sig) {
while (waitpid(-1, 0, WNOHANG) > 0);
// WNOHANG: 非阻塞,避免信号处理函数中阻塞
// while 循环: 一次回收所有已终止的子进程(防止信号丢失)
}
```
> [!important] 为什么用 `while` 循环?
> Unix 信号不排队。如果两个子进程几乎同时终止,可能只收到一次 `SIGCHLD`。循环调用 `waitpid` 确保所有僵尸进程都被回收。
#### toggle 函数:大小写转换
```c
void toggle(int conn_sock, int hit) {
int n;
char buf[MAXLINE];
while ((n = recv(conn_sock, buf, MAXLINE, 0)) > 0) {
// 逐字节进行大小写转换
for (int i = 0; i < n; i++) {
if (isupper(buf[i]))
buf[i] = tolower(buf[i]);
else if (islower(buf[i]))
buf[i] = toupper(buf[i]);
}
send(conn_sock, buf, n, 0); // 原样发回转换后的数据
}
}
```
#### 主循环fork 并发处理
```c
int main(int argc, char **argv) {
int listen_sock, conn_sock, hit;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
Signal(SIGCHLD, sigchld_handler); // 注册 SIGCHLD 处理函数
listen_sock = open_listen_sock(argv[1]); // 创建监听套接字
for (hit = 1; ; hit++) {
clientlen = sizeof(clientaddr);
conn_sock = accept(listen_sock,
(SA *)&clientaddr, &clientlen);
if (Fork() == 0) { // 子进程
close(listen_sock); // 关闭监听套接字(子进程不需要)
toggle(conn_sock, hit); // 处理请求
close(conn_sock); // 关闭连接套接字
exit(0); // 子进程退出
}
close(conn_sock); // 父进程关闭连接套接字
}
}
```
> [!note] 父子进程的套接字处理
> - **父进程**`close(conn_sock)` — 父进程不处理该连接
> - **子进程**`close(listen_sock)` — 子进程不接受新连接
> - 这是多进程服务器的标准模式,避免文件描述符泄漏
### 3.3 多进程模型的优缺点
```
优点 缺点
+----------------------------------+----------------------------------+
| 进程间地址空间隔离 | fork() 系统调用开销大 |
| 一个进程崩溃不影响其他进程 | 每个进程占用独立内存空间 |
| 编程模型简单直观 | 进程数受系统限制 |
| 天然线程安全(无共享数据) | 上下文切换开销大 |
+----------------------------------+----------------------------------+
```
---
## 四、多线程模型详解 — togglest.c
### 4.1 核心结构
```c
// 线程参数结构体
typedef struct {
int conn_sock;
int hit;
} thread_args_t;
void *thread_toggle(void *vargp) {
thread_args_t *args = (thread_args_t *)vargp;
int conn_sock = args->conn_sock;
int hit = args->hit;
Free(vargp); // 释放参数内存
toggle(conn_sock, hit); // 处理请求(复用 toggle 函数)
Close(conn_sock); // 关闭连接
return NULL;
}
int main(int argc, char **argv) {
listen_sock = open_listen_sock(port);
for (hit = 1; ; hit++) {
conn_sock = accept(listen_sock, ...);
// 为每个连接分配参数
thread_args_t *args = Malloc(sizeof(thread_args_t));
args->conn_sock = conn_sock;
args->hit = hit;
// 创建分离线程(自动回收,无需 join
Pthread_create(&tid, NULL, thread_toggle, args);
}
}
```
> [!tip] 为什么要用 `pthread_detach`
> 如果不分离线程,线程终止后其资源不会自动释放,造成资源泄漏(类似僵尸进程)。使用 `pthread_detach` 或 `pthread_create` 后立即 `pthread_detach` 可避免此问题。
### 4.2 多线程 vs 多进程
```mermaid
graph LR
subgraph 多进程
A1[主进程] -->|fork| A2[子进程]
A2 -->|独立地址空间| A3[处理请求]
end
subgraph 多线程
B1[主线程] -->|pthread_create| B2[新线程]
B2 -->|共享地址空间| B3[处理请求]
end
style A1 fill:#ffcdd2
style A2 fill:#ffcdd2
style A3 fill:#ffcdd2
style B1 fill:#c8e6c9
style B2 fill:#c8e6c9
style B3 fill:#c8e6c9
```
---
## 五、预线程模型详解 — togglest_pre.c
### 5.1 设计思想
预线程模型在服务器启动时就创建固定数量的工作线程,所有线程共享一个任务队列,形成**生产者-消费者**模型。
```mermaid
graph LR
P[主线程/生产者] -->|sbuf_insert| Q[(共享任务队列<br/>conn_sock)]
Q -->|sbuf_remove| W1[工作线程 1]
Q -->|sbuf_remove| W2[工作线程 2]
Q -->|sbuf_remove| W3[工作线程 3]
Q -->|sbuf_remove| WN[工作线程 N]
style P fill:#bbdefb
style Q fill:#fff9c4
style W1 fill:#c8e6c9
style W2 fill:#c8e6c9
style W3 fill:#c8e6c9
style WN fill:#c8e6c9
```
### 5.2 核心代码解析
#### Sbuf带信号量的共享缓冲区
```c
typedef struct {
int *buf; // 缓冲区数组
int n; // 缓冲区容量
int front; // 队头索引
int rear; // 队尾索引
sem_t mutex; // 互斥信号量
sem_t slots; // 空槽数量信号量
sem_t items; // 已有项目数信号量
} sbuf_t;
void sbuf_insert(sbuf_t *sp, int item) {
P(&sp->slots); // 等待空槽
P(&sp->mutex); // 加锁
sp->buf[(++sp->rear) % sp->n] = item; // 入队
V(&sp->mutex); // 解锁
V(&sp->items); // 增加项目数
}
int sbuf_remove(sbuf_t *sp) {
P(&sp->items); // 等待有项目
P(&sp->mutex); // 加锁
int item = sp->buf[(++sp->front) % sp->n]; // 出队
V(&sp->mutex); // 解锁
V(&sp->slots); // 增加空槽数
return item;
}
```
> [!important] 信号量的顺序不能颠倒!
> `sbuf_insert` 中先 `P(slots)` 再 `P(mutex)`,如果反过来,可能导致死锁:线程持有 mutex 但等待 slots而其他线程无法获取 mutex 来释放 slots。
#### 工作线程函数
```c
void *thread_toggle(void *vargp) {
sbuf_t *sp = (sbuf_t *)vargp;
Pthread_detach(pthread_self());
while (1) {
int conn_sock = sbuf_remove(sp); // 阻塞等待任务
toggle(conn_sock, -1); // 处理请求
Close(conn_sock); // 关闭连接
}
return NULL;
}
int main(int argc, char **argv) {
sbuf_t sbuf;
sbuf_init(&sbuf, SBUFSIZE); // 初始化缓冲区
// 启动时创建 N 个工作线程
for (int i = 0; i < NTHREADS; i++)
Pthread_create(&tid, NULL, thread_toggle, &sbuf);
listen_sock = open_listen_sock(port);
while (1) {
conn_sock = accept(listen_sock, ...);
sbuf_insert(&sbuf, conn_sock); // 主线程生产
}
}
```
### 5.3 预线程模型的优势
```
传统模型(按需创建) 预线程模型(预先创建)
accept --> fork/create accept --> 入队
| |
+--> [创建开销] --> 处理 +--> [零开销] --> 工作线程直接处理
```
- **消除运行时创建开销**:线程在启动时一次性创建完毕
- **控制并发度**:线程数固定,避免系统过载
- **生产者-消费者解耦**:主线程只负责 accept工作线程只负责处理
---
## 六、任务二~四:将 Toggle 服务器改造为 Weblet 服务器
### 6.1 改造思路
Toggle 服务器的核心逻辑是**大小写转换**Weblet 服务器的核心逻辑是**HTTP 请求解析与响应**。改造的关键在于将 `toggle()` 函数替换为 HTTP 处理逻辑。
```mermaid
graph TD
A[Toggle 服务器] -->|替换核心处理函数| B[Weblet 服务器]
A --> A1["toggle(): 大小写转换"]
B --> B1["doit(): HTTP 请求处理"]
B1 --> B2[解析请求行 GET /path HTTP/1.1]
B1 --> B3[解析请求头]
B1 --> B4[构建响应: 静态/动态内容]
B1 --> B5[发送响应给客户端]
```
### 6.2 多进程 Weblet
```c
// 仿照 togglesp.c 的结构
void sigchld_handler(int sig) {
while (waitpid(-1, 0, WNOHANG) > 0);
}
int main(int argc, char **argv) {
Signal(SIGCHLD, sigchld_handler);
listen_sock = open_listen_sock(port);
for (hit = 1; ; hit++) {
conn_sock = accept(listen_sock, ...);
if (Fork() == 0) {
close(listen_sock);
doit(conn_sock, hit); // 替换为 HTTP 处理
close(conn_sock);
exit(0);
}
close(conn_sock);
}
}
```
### 6.3 多线程 Weblet
```c
// 仿照 togglest.c 的结构
void *thread_doit(void *vargp) {
int conn_sock = *((int *)vargp);
Free(vargp);
Pthread_detach(pthread_self());
doit(conn_sock, -1); // HTTP 处理
Close(conn_sock);
return NULL;
}
int main(int argc, char **argv) {
listen_sock = open_listen_sock(port);
while (1) {
clientlen = sizeof(clientaddr);
conn_sock = accept(listen_sock, ...);
int *fdp = Malloc(sizeof(int));
*fdp = conn_sock;
Pthread_create(&tid, NULL, thread_doit, fdp);
}
}
```
### 6.4 预线程 Weblet
```c
// 仿照 togglest_pre.c 的结构
void *thread_doit(void *vargp) {
sbuf_t *sp = (sbuf_t *)vargp;
Pthread_detach(pthread_self());
while (1) {
int conn_sock = sbuf_remove(sp);
doit(conn_sock, -1); // HTTP 处理
Close(conn_sock);
}
}
int main(int argc, char **argv) {
sbuf_t sbuf;
sbuf_init(&sbuf, SBUFSIZE);
for (int i = 0; i < NTHREADS; i++)
Pthread_create(&tid, NULL, thread_doit, &sbuf);
listen_sock = open_listen_sock(port);
while (1) {
conn_sock = accept(listen_sock, ...);
sbuf_insert(&sbuf, conn_sock);
}
}
```
---
## 七、任务五性能对比测试http_load
### 7.1 测试方法
```bash
# 生成 URL 文件
echo "http://localhost:8080/index.html" > urls.txt
# 使用 http_load 进行压力测试
# -p 并发数,-s 测试时间(秒)
http_load -p 10 -s 10 urls.txt # 10 并发,持续 10 秒
http_load -p 50 -s 10 urls.txt # 50 并发,持续 10 秒
http_load -p 100 -s 10 urls.txt # 100 并发,持续 10 秒
http_load -p 200 -s 10 urls.txt # 200 并发,持续 10 秒
```
### 7.2 测试结果记录模板
| 并发数 | 多进程 (req/s) | 多线程 (req/s) | 预线程 (req/s) | 线程池 (req/s) |
|--------|---------------|---------------|---------------|---------------|
| 10 | | | | |
| 50 | | | | |
| 100 | | | | |
| 200 | | | | |
### 7.3 预期结论
> [!abstract] 性能排序
> **线程池 >= 预线程 > 多线程 > 多进程**
>
> - **多进程**`fork()` 开销随并发数增加显著上升
> - **多线程**`pthread_create` 比 `fork` 轻量,但仍有创建开销
> - **预线程**:启动时创建线程池,运行时零创建开销
> - **线程池**:在预线程基础上增加了线程复用和动态管理
---
## 八、关键知识点总结
### fork 与 pthread_create 的对比
| 特性 | fork (多进程) | pthread_create (多线程) |
|------|--------------|------------------------|
| 地址空间 | 复制(写时复制) | 共享 |
| 创建开销 | 大(页表复制) | 较小 |
| 通信方式 | 进程间通信(管道等) | 直接共享内存 |
| 崩溃隔离 | 互相隔离 | 一个线程崩溃可能影响全部 |
| 文件描述符 | 复制 | 共享 |
| 信号处理 | 各进程独立 | 需要协调 |
### 信号量在生产者-消费者中的应用
```mermaid
sequenceDiagram
participant P as 生产者(主线程)
participant M as mutex
participant S as slots 信号量
participant I as items 信号量
participant C as 消费者(工作线程)
P->>S: P(slots) — 等待空槽
P->>M: P(mutex) — 加锁
P->>P: 插入任务到队列
P->>M: V(mutex) — 解锁
P->>I: V(items) — 通知有新任务
C->>I: P(items) — 等待任务
C->>M: P(mutex) — 加锁
C->>C: 从队列取出任务
C->>M: V(mutex) — 解锁
C->>S: V(slots) — 释放空槽
C->>C: 处理请求
```
---
## 常见问题
> [!failure] 问题:多进程版本出现僵尸进程
> **原因**:未正确处理 `SIGCHLD` 信号,或信号处理函数中未使用 `WNOHANG` 循环回收。
> **解决**:确保 `sigchld_handler` 中使用 `while (waitpid(-1, 0, WNOHANG) > 0);`
> [!failure] 问题多线程版本出现段错误Segmentation Fault
> **原因**:传递给线程的参数在主线程中被覆盖。
> **解决**:为每个线程 `malloc` 独立的参数结构体,不要传递局部变量的地址。
> [!failure] 问题:预线程版本程序卡死不响应
> **原因**:信号量使用顺序错误导致死锁。
> **解决**`sbuf_insert` 中先 `P(slots)` 后 `P(mutex)``sbuf_remove` 中先 `P(items)` 后 `P(mutex)`。
---
## 扩展阅读
- [[06_进程控制]] — fork、waitpid、信号处理详细原理
- [[07_多线程编程]] — pthread 线程编程详解
- [[10_并发服务器]] — 三种并发模型的理论对比
- [[实践01_Web服务器初步实现]] — 实验一基础 Web 服务器