Files
obsidian/操作系统/10_并发服务器/10_并发服务器.md

10 KiB
Raw Blame History

第10讲并发网络服务器

🎯 本节目标掌握多进程、多线程、I/O 多路复用三种并发服务器模型

📋 前置知识


🤔 为什么需要这个?

上一讲的服务器一次只能服务一个客户端。如果有 100 个用户同时访问网站,第 100 个用户必须等前 99 个都处理完才能得到响应。

并发服务器就是解决这个问题的——让服务器能够同时服务多个客户端。

生活比喻

  • 迭代服务器 = 一个服务员一次只服务一桌客人
  • 多进程服务器 = 每来一桌客人就招一个新服务员
  • 多线程服务器 = 一个服务员同时照看多桌客人
  • I/O 多路复用 = 服务员轮流查看哪桌客人需要服务

📖 核心概念

1. 三种并发模型

graph TD
    A[并发服务器模型] --> B[多进程模型]
    A --> C[多线程模型]
    A --> D[I/O 多路复用]

    B --> B1[每连接一个进程]
    B --> B2[进程间隔离]
    B --> B3[开销大]

    C --> C1[每连接一个线程]
    C --> C2[共享内存]
    C --> C3[开销较小]

    D --> D1[单线程处理多连接]
    D --> D2[select/poll/epoll]
    D --> D3[开销最小]

    style B fill:#ffcdd2
    style C fill:#fff3e0
    style D fill:#e8f5e9

对比

模型 优点 缺点 适用场景
多进程 隔离性好 开销大 连接数少
多线程 开销较小 需要同步 连接数中等
I/O 多路复用 开销最小 编程复杂 连接数多

2. 多进程模型

sequenceDiagram
    participant 主进程
    participant 子进程1
    participant 子进程2
    participant 客户端

    主进程->>主进程: accept() 等待连接
    客户端->>主进程: 连接请求
    主进程->>子进程1: fork() 创建子进程
    子进程1->>客户端: 处理请求
    主进程->>主进程: 继续 accept()

    客户端->>主进程: 新连接请求
    主进程->>子进程2: fork() 创建子进程
    子进程2->>客户端: 处理请求

特点

  • 每个连接一个独立进程
  • 进程间完全隔离
  • 进程创建和销毁开销大

3. 多线程模型

sequenceDiagram
    participant 主线程
    participant 工作线程1
    participant 工作线程2
    participant 客户端

    主线程->>主线程: accept() 等待连接
    客户端->>主线程: 连接请求
    主线程->>工作线程1: pthread_create()
    工作线程1->>客户端: 处理请求
    主线程->>主线程: 继续 accept()

    客户端->>主线程: 新连接请求
    主线程->>工作线程2: pthread_create()
    工作线程2->>客户端: 处理请求

特点

  • 每个连接一个线程
  • 线程共享进程资源
  • 需要注意线程安全

4. I/O 多路复用select

graph TD
    A[主循环] --> B[select 监听所有 fd]
    B --> C{哪个 fd 就绪?}
    C -->|监听套接字| D[accept 新连接]
    C -->|客户端套接字| E[处理请求]
    D --> A
    E --> A

    style B fill:#fff3e0

select 的工作原理

  1. 将所有需要监听的文件描述符放入集合
  2. 调用 select() 等待任意一个就绪
  3. 遍历集合,处理就绪的描述符
  4. 重复步骤 1

fd_set 操作

fd_set read_set;
FD_ZERO(&read_set);           // 清空集合
FD_SET(fd1, &read_set);       // 添加 fd1
FD_SET(fd2, &read_set);       // 添加 fd2
select(maxfd+1, &read_set, NULL, NULL, NULL);  // 等待
if (FD_ISSET(fd1, &read_set)) // 检查 fd1 是否就绪
    // 处理 fd1

5. 线程池模型

graph TD
    A[主线程] -->|accept| B[任务队列]
    B --> C[工作线程1]
    B --> D[工作线程2]
    B --> E[工作线程3]
    C -->|处理完毕| B
    D -->|处理完毕| B
    E -->|处理完毕| B

    style B fill:#fff3e0

优势

  • 避免频繁创建销毁线程
  • 控制并发数量
  • 提高资源利用率

💻 动手实践

示例1多线程并发服务器

// togglest.c - 多线程并发服务器
#include "wrapper.h"

void toggle(int conn_sock);
void *serve_client(void *vargp);

int main(int argc, char **argv) {
    int listen_sock, conn_sock, port, *conn_sock_p;
    struct sockaddr_in clientaddr;
    struct hostent *hp;
    char *haddrp;
    socklen_t clientlen = sizeof(struct sockaddr_in);
    pthread_t tid;

    if (argc != 2) {
        fprintf(stderr, "usage: %s <port>\n", argv[0]);
        exit(1);
    }
    port = atoi(argv[1]);

    listen_sock = open_listen_sock(port);

    while (1) {
        conn_sock_p = malloc(sizeof(int));
        *conn_sock_p = accept(listen_sock, (SA *)&clientaddr, &clientlen);

        // 获取客户端信息
        hp = Gethostbyaddr((const char *)&clientaddr.sin_addr.s_addr,
                           sizeof(clientaddr.sin_addr.s_addr), AF_INET);
        haddrp = inet_ntoa(clientaddr.sin_addr);
        printf("server connected to %s (%s)\n", hp->h_name, haddrp);

        // 创建新线程处理客户端
        pthread_create(&tid, NULL, serve_client, conn_sock_p);
    }
}

void *serve_client(void *vargp) {
    int conn_sock = *((int *)vargp);
    pthread_detach(pthread_self());  // 分离线程
    free(vargp);
    toggle(conn_sock);
    close(conn_sock);
    return NULL;
}

编译运行

gcc -o togglest togglest.c -L. -lwrapper -lpthread

# 终端1启动服务器
./togglest 8080

# 终端2-4启动多个客户端
./togglec localhost 8080

示例2I/O 多路复用服务器

// toggless1.c - select 多路复用服务器
#include "wrapper.h"

void toggle(int conn_sock);
void read_input(void);

int main(int argc, char **argv) {
    int listen_sock, conn_sock, port;
    socklen_t clientlen = sizeof(struct sockaddr_in);
    struct sockaddr_in clientaddr;
    fd_set read_set, ready_set;

    if (argc != 2) {
        fprintf(stderr, "usage: %s <port>\n", argv[0]);
        exit(1);
    }
    port = atoi(argv[1]);
    listen_sock = open_listen_sock(port);

    // 初始化 fd_set
    FD_ZERO(&read_set);
    FD_SET(STDIN_FILENO, &read_set);  // 监听标准输入
    FD_SET(listen_sock, &read_set);   // 监听套接字

    while (1) {
        ready_set = read_set;
        select(listen_sock + 1, &ready_set, NULL, NULL, NULL);

        // 检查标准输入
        if (FD_ISSET(STDIN_FILENO, &ready_set))
            read_input();

        // 检查新连接
        if (FD_ISSET(listen_sock, &ready_set)) {
            conn_sock = accept(listen_sock, (SA *)&clientaddr, &clientlen);
            toggle(conn_sock);
            close(conn_sock);
        }
    }
}

void read_input(void) {
    char buf[MAXLINE];
    if (!fgets(buf, MAXLINE, stdin))
        exit(0);
    printf("%s", buf);
}

示例3连接池服务器

// toggless2.c - 连接池服务器
#include "wrapper.h"

typedef struct {
    int maxfd;
    fd_set read_set;
    fd_set ready_set;
    int nready;
    int maxi;
    int client_sock[FD_SETSIZE];
} sock_pool;

void init_sock_pool(int listen_sock, sock_pool *pool);
void add_sock(int conn_sock, sock_pool *pool);
void serve_clients(sock_pool *pool);

int main(int argc, char **argv) {
    int listen_sock, conn_sock, port;
    socklen_t clientlen = sizeof(struct sockaddr_in);
    struct sockaddr_in clientaddr;
    static sock_pool pool;

    if (argc != 2) {
        fprintf(stderr, "usage: %s <port>\n", argv[0]);
        exit(1);
    }
    port = atoi(argv[1]);

    listen_sock = open_listen_sock(port);
    init_sock_pool(listen_sock, &pool);

    while (1) {
        pool.ready_set = pool.read_set;
        pool.nready = select(pool.maxfd + 1, &pool.ready_set, NULL, NULL, NULL);

        // 新连接
        if (FD_ISSET(listen_sock, &pool.ready_set)) {
            conn_sock = accept(listen_sock, (SA *)&clientaddr, &clientlen);
            add_sock(conn_sock, &pool);
        }

        // 处理客户端请求
        serve_clients(&pool);
    }
}

void init_sock_pool(int listen_sock, sock_pool *p) {
    int i;
    p->maxi = -1;
    for (i = 0; i < FD_SETSIZE; i++)
        p->client_sock[i] = -1;
    p->maxfd = listen_sock;
    FD_ZERO(&p->read_set);
    FD_SET(listen_sock, &p->read_set);
}

void add_sock(int conn_sock, sock_pool *p) {
    int i;
    p->nready--;
    for (i = 0; i < FD_SETSIZE; i++)
        if (p->client_sock[i] < 0) {
            p->client_sock[i] = conn_sock;
            FD_SET(conn_sock, &p->read_set);
            if (conn_sock > p->maxfd)
                p->maxfd = conn_sock;
            if (i > p->maxi)
                p->maxi = i;
            break;
        }
    if (i == FD_SETSIZE)
        perror("add_sock error: Too many clients");
}

void serve_clients(sock_pool *p) {
    int i, conn_sock, n;
    char buf[MAXLINE];

    for (i = 0; (i <= p->maxi) && (p->nready > 0); i++) {
        conn_sock = p->client_sock[i];
        if ((conn_sock > 0) && (FD_ISSET(conn_sock, &p->ready_set))) {
            p->nready--;
            if ((n = recv(conn_sock, buf, MAXLINE, 0)) != 0) {
                printf("Server received %d bytes on fd %d\n", n, conn_sock);
                send(conn_sock, buf, n, 0);
            } else {
                close(conn_sock);
                FD_CLR(conn_sock, &p->read_set);
                p->client_sock[i] = -1;
            }
        }
    }
}

🔗 知识关联


📝 思考题

  1. 为什么 select 有 FD_SETSIZE 限制? 如何突破这个限制?
  2. 多进程 vs 多线程:在什么情况下多进程比多线程更合适?
  3. epoll 的优势:为什么 Linux 推荐使用 epoll 而不是 select

📚 扩展阅读