【Linux开发】 05 Linux 多进程并发服务器

一、问题:为什么需要并发服务器?

1.1 顺序服务器的"排队"困境

回忆我们之前实现的迭代回声服务器,它一次只能服务一个客户端,等这个客户端断开后才能服务下一个。假设每个客户端平均服务时间为 0.5 秒,那么第 100 个客户端要等待 50 秒才能被受理。这显然让人无法接受。

1.2 理想的服务器模式

有两种改进思路:

  1. "排队受理快" :受理时间短,但服务时间长。

    例如:第一个连接请求受理时间为 0 秒,第 50 个为 50 秒,但只要受理,服务只需 1 秒。

    → 这种其实还是顺序处理,只是受理快,对后面用户依然不公平。

  2. "并发服务" :所有连接请求的受理时间不超过 1 秒,平均服务时间 2~3 秒。

    → 同时服务多个客户端,让每个客户端都感觉自己被"秒接"。

    这才是我们想要的目标。

1.3 并发服务器的实现方法

常见的并发服务器模型有三种:

模型 说明
多进程服务器 为每个客户端创建一个子进程,父进程继续等待新连接。
多路复用服务器 使用 select/epoll 统一管理多个 I/O 对象。
多线程服务器 为每个客户端创建一个线程,共享进程资源。

本文讲解第一种:多进程服务器


二、什么是进程?

2.1 程序 vs 进程

  • 程序 :存储在硬盘上的可执行文件,如 a.out,它只是静态的指令集合。
  • 进程程序被加载到内存中运行后,就变成了进程,它是操作系统调度和资源分配的基本单位

类比:

  • 程序 = 乐谱(写在纸上)
  • 进程 = 正在演奏的音乐(占用舞台、乐器)

一个程序可以同时运行多个实例,产生多个进程。例如打开两个记事本,就会有两个 notepad.exe 进程。

2.2 CPU 核数与进程

CPU 有多少个核,就能同时执行多少个进程。如果进程数超过核数,操作系统会通过分时调度让进程轮流使用 CPU,由于切换极快,用户感觉所有进程在"同时"运行。

2.3 进程 ID(PID)

每个进程都有一个唯一的整数标识符,称为进程 ID(PID)。

  • PID 从 2 开始(PID 1 是系统启动的第一个进程 init/systemd)。
  • 使用命令 ps au 可以查看当前所有进程的 PID。

三、创建进程:fork() 函数

3.1 fork 的作用

fork() 是创建新进程的唯一系统调用(在 Linux 中)。它会复制 调用它的进程,产生一个几乎完全相同的子进程。

c 复制代码
#include <unistd.h>
pid_t fork(void);
  • 成功时,父进程返回子进程的 PID,子进程返回 0。
  • 失败时返回 -1。

3.2 fork 后的内存独立性

调用 fork 后,父子进程拥有独立的地址空间 ,修改各自的内存互不影响。

看下面的例子:

c 复制代码
#include <stdio.h>
#include <unistd.h>

int gval = 10;

int main() {
    int lval = 20;
    pid_t pid;

    gval++; lval += 5;          // 父进程修改
    pid = fork();               // 复制进程

    if (pid == 0)               // 子进程
        gval += 2, lval += 2;
    else                        // 父进程
        gval -= 2, lval -= 2;

    if (pid == 0)
        printf("Child Proc: [%d, %d]\n", gval, lval);
    else
        printf("Parent Proc: [%d, %d]\n", gval, lval);

    return 0;
}

输出(可能因系统而异):

复制代码
Parent Proc: [9, 23]
Child Proc: [13, 27]

可见父子进程的 gvallval 互不影响,各自独立。


四、僵尸进程:死而不僵的孩子

4.1 什么是僵尸进程?

子进程终止后,操作系统会保留其一些信息(如退出状态),直到父进程主动读取。如果父进程一直不读取,子进程就会变成 僵尸进程(Zombie),占用系统资源。

4.2 为什么会产生僵尸进程?

子进程结束时会向父进程发送 SIGCHLD 信号,并将退出状态保存在内核中。父进程需要通过 waitwaitpid 来获取这些信息,操作系统才会彻底销毁子进程。如果父进程从不调用这些函数,子进程就会永远以僵尸状态存在。

下面的程序故意让父进程 sleep 30 秒,期间子进程已结束,成为僵尸进程:

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        puts("Hi I'm a child process");
    } else {
        printf("Child Process ID: %d\n", pid);
        sleep(30);      // 父进程休眠30秒,期间子进程已结束
    }

    if (pid == 0)
        puts("End child process");
    else
        puts("End parent process");
    return 0;
}

在父进程 sleep 期间,用 ps au 查看,会看到子进程状态为 Z(僵尸)。

4.3 如何消灭僵尸进程?

父进程必须调用 waitwaitpid 来回收子进程的退出状态。但是,如果父进程很忙,不可能一直阻塞等待子进程结束,就需要信号处理机制。


五、信号处理:让操作系统通知我们

5.1 信号的概念

信号是操作系统向进程发送的异步通知。我们可以注册一个函数,当特定信号发生时,由操作系统自动调用该函数。

常用的信号:

  • SIGALRM:定时器到时
  • SIGINT:用户按 Ctrl+C
  • SIGCHLD:子进程终止(最关键)

5.2 注册信号:signal 函数

c 复制代码
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);

参数 signo 是信号编号,func 是处理函数的指针。

示例:每隔 2 秒打印一次 "Time out!",并按 Ctrl+C 时打印 "CTRL+C pressed"。

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void timeout(int sig) {
    if (sig == SIGALRM) {
        puts("Time out!");
        alarm(2);          // 再次预约 2 秒
    }
}

void keycontrol(int sig) {
    if (sig == SIGINT) {
        puts("CTRL+C pressed");
    }
}

int main() {
    signal(SIGALRM, timeout);
    signal(SIGINT, keycontrol);
    alarm(2);               // 2 秒后产生 SIGALRM

    for (int i = 0; i < 3; i++) {
        puts("wait...");
        sleep(100);         // 等待 100 秒
    }
    return 0;
}

注意sleep 会被信号中断,因此实际程序很快结束(约 6 秒),因为每隔 2 秒信号唤醒进程一次。

5.3 更稳定的 sigaction 函数

sigactionsignal 的现代替代品,在不同 UNIX 系统上行为一致。

c 复制代码
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);

sigaction 结构体:

c 复制代码
struct sigaction {
    void (*sa_handler)(int);   // 处理函数指针
    sigset_t sa_mask;          // 信号掩码(一般初始化为0)
    int sa_flags;              // 选项(一般设为0)
};

使用示例(与上面功能相同):

c 复制代码
struct sigaction act;
act.sa_handler = timeout;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGALRM, &act, 0);

5.4 用信号处理回收子进程

子进程终止时会发送 SIGCHLD 信号,我们可以在处理函数中调用 waitpid 回收。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

void read_childproc(int sig) {
    int status;
    pid_t id = waitpid(-1, &status, WNOHANG);  // 非阻塞等待任意子进程
    if (WIFEXITED(status)) {                   // 子进程正常退出
        printf("Removed proc id: %d\n", id);
        printf("Child send: %d\n", WEXITSTATUS(status));
    }
}

int main() {
    struct sigaction act;
    act.sa_handler = read_childproc;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    sigaction(SIGCHLD, &act, 0);               // 注册 SIGCHLD 处理函数

    pid_t pid = fork();
    if (pid == 0) {
        puts("Hi! I'm child process");
        sleep(10);
        return 12;        // 子进程结束,返回 12
    } else {
        printf("Child proc id: %d\n", pid);
        pid = fork();
        if (pid == 0) {
            puts("Hi! I'm child process");
            sleep(10);
            exit(24);     // 子进程结束,返回 24
        } else {
            printf("Child proc id: %d\n", pid);
            for (int i = 0; i < 5; i++) {
                puts("wait...");
                sleep(5); // 父进程每 5 秒被信号唤醒一次
            }
        }
    }
    return 0;
}

关键点

  • waitpid(-1, &status, WNOHANG) 表示不阻塞,如果有已结束的子进程就处理,否则立即返回。
  • WIFEXITED(status) 判断子进程是否正常退出。
  • WEXITSTATUS(status) 获取退出码。

这样,子进程结束后,父进程的 SIGCHLD 处理函数会立刻回收,避免了僵尸进程。


六、完整示例:多进程回声服务器

结合上述知识,我们可以实现一个简单的多进程回声服务器:父进程负责监听,每当有新连接,就 fork 一个子进程来处理该连接,父进程继续等待下一个连接。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);
void read_childproc(int sig);

int main(int argc, char *argv[]) {
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t adr_sz;
    int str_len;
    char buf[BUF_SIZE];

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

    // 注册 SIGCHLD 信号处理函数,防止僵尸进程
    struct sigaction act;
    act.sa_handler = read_childproc;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    sigaction(SIGCHLD, &act, 0);

    // 创建套接字
    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1)
        error_handling("socket() error");

    // 绑定地址
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));
    if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("bind() error");

    // 监听
    if (listen(serv_sock, 5) == -1)
        error_handling("listen() error");

    while (1) {
        adr_sz = sizeof(clnt_adr);
        clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
        if (clnt_sock == -1)
            continue;
        else
            puts("New client connected...");

        // 创建子进程处理这个客户端
        pid_t pid = fork();
        if (pid == 0) {               // 子进程
            close(serv_sock);         // 子进程不需要监听套接字
            while ((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
                write(clnt_sock, buf, str_len);
            close(clnt_sock);
            puts("Client disconnected");
            return 0;                 // 子进程结束
        } else {
            close(clnt_sock);         // 父进程不需要客户端套接字
        }
    }
    close(serv_sock);
    return 0;
}

void read_childproc(int sig) {
    int status;
    pid_t id = waitpid(-1, &status, WNOHANG);
    if (WIFEXITED(status)) {
        printf("Removed proc id: %d\n", id);
        printf("Child send: %d\n", WEXITSTATUS(status));
    }
}

void error_handling(char *message) {
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

代码解析

  • 父进程调用 accept 后立即 fork 出子进程,子进程处理该客户端,父进程继续 accept 下一个连接。
  • 子进程中要关闭监听套接字serv_sock),父进程中要关闭客户端套接字clnt_sock),避免资源浪费。
  • 信号处理函数 read_childproc 负责回收子进程,防止僵尸进程。

七、总结

7.1 进程相关函数速查

函数 作用
fork() 创建子进程
waitpid() 等待子进程终止,回收资源
signal() / sigaction() 注册信号处理函数
alarm() 设置定时器,到时发送 SIGALRM

7.2 多进程服务器的优缺点

优点 缺点
实现简单,易于理解 进程创建开销较大
进程间隔离性好,一个崩溃不影响其他 需要处理信号回收子进程
充分利用多核 CPU 进程间通信较复杂

7.3 下一步拓展

  • 多线程服务器:用线程代替进程,减少资源开销。
  • I/O 多路复用 :使用 selectepoll 在一个进程中管理多个连接。
  • 进程池:预先创建一定数量进程,避免频繁 fork。
相关推荐
minji...2 小时前
Linux 进程间通信(四)System V共享内存
linux·运维·服务器
艾莉丝努力练剑2 小时前
【Linux信号】Linux进程信号(中):信号保存、信号处理(含“OS是如何运行的?”)
大数据·linux·运维·服务器·数据库·c++·mysql
山峰哥2 小时前
《解锁SQL高效查询:从索引设计到执行计划优化》
服务器·数据库·sql·oracle·性能优化
汪海游龙2 小时前
03.26 AI 精选:让 Claude 像人一样操作电脑执行任务
github
Du_chong_huan2 小时前
《Linux 是怎样工作的》第 2 章:用户模式实现的功能
linux·运维·服务器
学电子她就能回来吗2 小时前
【无标题】
linux·运维·服务器
Laurence2 小时前
GitHub 1.2 万星 Qt 项目 VNote 源码解读(一):核心类与主流程
qt·github·源码·代码·介绍·解读·vnote
wefg12 小时前
【计算机网络】套接字编程(套接字API/UDP和TCP服务器)
服务器·网络·计算机网络
有毒的教程2 小时前
Ubuntu 安装完成后网络配置教程
linux·网络·ubuntu