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

18 KiB
Raw Blame History

实践02Web服务器的多进程与多线程模型实现

[!info] 实验信息 课程:操作系统实践 实验名称:实验二 — Web服务器的多进程与多线程模型实现 前置实验实践01_Web服务器初步实现

实验目的

  1. 将 Web 服务器改造为多进程、多线程、预线程、线程池四种版本
  2. 掌握并行网络服务器的设计方法
  3. 使用 http_load 进行性能测试与对比分析
  4. 理解不同并发模型的优劣及适用场景

[!tip] 关联理论课程


源代码文件清单

[!note] 本次实验新增文件(在实验一基础上)

文件 说明
togglesp.c 多进程 toggle 服务器
togglest.c 多线程 toggle 服务器
togglest_pre.c 预线程 toggle 服务器
togglest_pool.c 线程池 toggle 服务器

一、四种并发模型总览

模型架构图

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
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
graph TD
    subgraph "模型三:预线程 Pre-threaded"
        C3[客户端] -->|accept| PM[主线程]
        PM -->|放入任务队列| Q[(任务队列)]
        Q -->|取任务| W1[工作线程 1]
        Q -->|取任务| W2[工作线程 2]
        Q -->|取任务| W3[工作线程 3]
        Q -->|取任务| W4[工作线程 N]
    end
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 编译

# 多进程版本
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 分别运行与测试

# ---- 测试多进程版本 ----
./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:8080netstat -tlnp 检查。


三、多进程模型详解 — togglesp.c

3.1 整体流程

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 核心代码解析

信号处理:回收僵尸进程

void sigchld_handler(int sig) {
    while (waitpid(-1, 0, WNOHANG) > 0);
    // WNOHANG: 非阻塞,避免信号处理函数中阻塞
    // while 循环: 一次回收所有已终止的子进程(防止信号丢失)
}

[!important] 为什么用 while 循环? Unix 信号不排队。如果两个子进程几乎同时终止,可能只收到一次 SIGCHLD。循环调用 waitpid 确保所有僵尸进程都被回收。

toggle 函数:大小写转换

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 并发处理

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 核心结构

// 线程参数结构体
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_detachpthread_create 后立即 pthread_detach 可避免此问题。

4.2 多线程 vs 多进程

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 设计思想

预线程模型在服务器启动时就创建固定数量的工作线程,所有线程共享一个任务队列,形成生产者-消费者模型。

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带信号量的共享缓冲区

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。

工作线程函数

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 处理逻辑。

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

// 仿照 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

// 仿照 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

// 仿照 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 测试方法

# 生成 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_createfork 轻量,但仍有创建开销
  • 预线程:启动时创建线程池,运行时零创建开销
  • 线程池:在预线程基础上增加了线程复用和动态管理

八、关键知识点总结

fork 与 pthread_create 的对比

特性 fork (多进程) pthread_create (多线程)
地址空间 复制(写时复制) 共享
创建开销 大(页表复制) 较小
通信方式 进程间通信(管道等) 直接共享内存
崩溃隔离 互相隔离 一个线程崩溃可能影响全部
文件描述符 复制 共享
信号处理 各进程独立 需要协调

信号量在生产者-消费者中的应用

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)


扩展阅读