如何用C语言实现fork多进程服务器?

99ANYc3cd6
预计阅读时长 26 分钟
位置: 首页 C语言 正文

为什么需要 fork() 来写服务器?

一个简单的网络服务器通常是这样的流程:

fork c语言 server
(图片来源网络,侵删)
  1. socket(): 创建套接字。
  2. bind(): 绑定 IP 和端口。
  3. listen(): 开始监听,等待连接。
  4. accept(): 阻塞等待客户端连接。
  5. 读取/写入数据。
  6. 关闭连接,返回第 4 步,等待下一个客户端。

这种模型的核心问题在于 accept() 是一个阻塞函数,当一个客户端连接后,服务器必须处理完这个客户端的所有请求后,才能通过 accept() 的返回去接受下一个客户端的连接,如果某个客户端请求非常耗时(比如下载一个大文件),那么后续的所有客户端都必须排队等待,服务器的并发能力非常差。

fork() 解决了这个瓶颈,其核心思想是:

主进程(父进程)只负责监听和接受新的连接,一旦有新连接到来,就创建一个子进程来专门处理这个客户端的所有通信,父进程则立即返回,继续监听,准备接受下一个连接。

这样,每个客户端连接都有自己独立的子进程来服务,互不干扰,实现了真正的并发。

fork c语言 server
(图片来源网络,侵删)

fork() 的工作原理

fork() 是一个 Unix/Linux 系统调用,它会复制当前进程(称为父进程),创建一个几乎完全相同的新进程(称为子进程)。

  • 返回值
    • 父进程中,fork() 返回子进程的 PID (Process ID),这是一个大于 0 的整数。
    • 子进程中,fork() 返回 0
    • 如果创建失败,fork() 返回 -1

这个返回值是父子进程执行后续不同逻辑的关键,我们可以用 if 语句来区分它们:

pid_t pid = fork();
if (pid < 0) {
    // fork 失败
    perror("fork failed");
    exit(EXIT_FAILURE);
} else if (pid == 0) {
    // 这是子进程
    // 执行处理客户端的代码
    handle_client(client_socket);
    // 子任务完成,退出
    exit(EXIT_SUCCESS);
} else {
    // 这是父进程
    // pid 是子进程的 ID
    // 父进程继续执行,比如返回 accept 循环
}

使用 fork() 的并发服务器代码示例

下面是一个完整的、可运行的并发 Echo 服务器代码,它会将客户端发送的任何消息原样返回。

代码:server.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/wait.h> // 用于 waitpid
#define PORT 8080
#define BUFFER_SIZE 1024
// 信号处理函数,用于处理子进程结束(僵尸进程)
void sigchld_handler(int sig) {
    // 使用 WNOHANG 选项,waitpid 不会阻塞,如果子进程已经结束则回收,否则直接返回
    while (waitpid(-1, NULL, WNOHANG) > 0);
}
void handle_client(int client_socket) {
    char buffer[BUFFER_SIZE];
    int read_size;
    printf("Child process %d handling new client.\n", getpid());
    // 从客户端读取数据
    while ((read_size = recv(client_socket, buffer, BUFFER_SIZE, 0)) > 0) {
        buffer[read_size] = '\0'; // 确保字符串以 null 
        printf("Received from client %d: %s", getpid(), buffer);
        // 将数据发回给客户端
        send(client_socket, buffer, read_size, 0);
    }
    if (read_size == 0) {
        printf("Client disconnected.\n");
    } else if (read_size == -1) {
        perror("recv failed");
    }
    // 关闭客户端套接字
    close(client_socket);
    printf("Child process %d finished.\n", getpid());
    exit(EXIT_SUCCESS);
}
int main() {
    int server_fd, client_socket;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    pid_t pid;
    // 1. 创建套接字
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }
    // 2. 设置套接字选项,允许地址重用
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY; // 监听所有网络接口
    address.sin_port = htons(PORT);
    // 3. 绑定地址和端口
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    // 4. 开始监听
    if (listen(server_fd, 3) < 0) { // backlog 设为 3
        perror("listen");
        exit(EXIT_FAILURE);
    }
    printf("Server listening on port %d...\n", PORT);
    // 5. 设置信号处理,防止子进程成为僵尸进程
    // 当子进程结束时,内核会发送 SIGCHLD 信号给父进程
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; // SA_NOCLDSTOP 表示只在子进程终止时通知,而不是暂停时
    if (sigaction(SIGCHLD, &sa, NULL) == -1) {
        perror("sigaction");
        exit(EXIT_FAILURE);
    }
    // 6. 主循环,接受客户端连接
    while (1) {
        if ((client_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
            // accept 可能被信号中断,比如子进程结束触发的 SIGCHLD
            // 如果错误是 EINTR,说明是被信号中断,可以继续循环
            if (errno == EINTR) {
                continue;
            }
            perror("accept");
            exit(EXIT_FAILURE);
        }
        printf("Parent process %d accepted a new connection.\n", getpid());
        // 7. 创建子进程来处理这个客户端
        pid = fork();
        if (pid < 0) {
            perror("fork failed");
            close(client_socket); // 关闭新连接的套接字
        } else if (pid == 0) {
            // 子进程
            close(server_fd); // 子进程不需要监听套接字,关闭它以节省资源
            handle_client(client_socket);
        } else {
            // 父进程
            close(client_socket); // 父进程不需要与客户端通信的套接字,关闭它
            // 父进程继续循环,准备接受下一个连接
        }
    }
    // 代码不会执行到这里,因为 while(1) 是无限循环
    // 如果要优雅退出,可以在这里加一个信号处理来终止循环
    close(server_fd);
    return 0;
}

代码解释

  1. main() 函数:

    fork c语言 server
    (图片来源网络,侵删)
    • 标准服务器初始化: 创建套接字、绑定端口、开始监听。
    • sigaction(SIGCHLD, ...): 这是 fork() 模式下至关重要的一步,子进程结束后,其父进程如果没有正确处理,它会变成一个“僵尸进程”(Zombie Process),仅仅在进程表中占一个位置。sigaction 注册了一个信号处理函数 sigchld_handler,当子进程结束时,内核会发送 SIGCHLD 信号,该函数会调用 waitpid() 来回收子进程,避免僵尸进程的产生。
    • accept() 循环: 父进程的核心工作就是不断调用 accept()
    • fork(): 在 accept() 成功后立即调用。
    • 父子进程分工:
      • 子进程 (pid == 0):
        • 调用 handle_client() 函数来处理具体的客户端逻辑。
        • 关键: close(server_fd),子进程不需要监听套接字,关闭它可以避免资源浪费和潜在的错误。
        • 任务完成后,调用 exit() 终止自己。
      • 父进程 (pid > 0):
        • 关键: close(client_socket),父进程不需要与客户端通信的套接字,关闭它,这个套接字已经被复制到子进程中,子进程会负责关闭它。
        • 父进程立即回到 while(1) 循环的顶部,准备调用 accept() 接受下一个连接。
  2. handle_client() 函数:

    • 这是一个标准的客户端处理逻辑:循环 recv() 读取数据,send() 回发数据。
    • recv() 返回 0 时,表示客户端正常关闭了连接。
    • 函数执行完毕后,子进程调用 exit() 退出。

如何编译和运行

  1. 保存代码: 将上面的代码保存为 server.c

  2. 编译: 打开终端,使用 gcc 进行编译。

    gcc server.c -o server
  3. 运行: 执行生成的可执行文件。

    ./server

    你会看到输出:

    Server listening on port 8080...
  4. 测试: 你可以使用 telnet 或者 nc (netcat) 来测试服务器。

    • 打开另一个终端,使用 telnet:
      telnet localhost 8080
    • 输入任何文本,"hello",然后按回车,服务器会返回 "hello"。
    • 再打开一个终端,再启动一个 telnet 连接,你会发现两个连接可以同时工作,互不干扰。

fork() 模式的优缺点

优点

  1. 逻辑清晰: 父子进程分工明确,代码结构相对简单。
  2. 资源隔离: 每个客户端进程都有独立的内存空间,一个客户端的崩溃或错误(如段错误)通常不会影响其他客户端和主服务器进程。
  3. 兼容性好: 是一种非常经典和成熟的模型,在所有 Unix-like 系统上都可用。

缺点

  1. 资源消耗: 创建进程是一个重量级操作,它会复制父进程的整个地址空间(包括代码、数据段等),这会消耗大量的 CPU 时间和内存,如果并发连接数非常大(比如数万),创建和管理大量子进程会非常消耗资源,可能导致系统性能下降。
  2. 进程间通信 复杂: 如果父进程需要向子进程传递复杂的数据,或者子进程需要将结果返回给父进程,实现起来比较麻烦(需要使用管道、共享内存、消息队列等 IPC 机制)。
  3. 僵尸进程风险: 如果没有正确处理 SIGCHLD 信号,子进程结束后会成为僵尸进程,消耗系统资源。

现代替代方案:poll(), select(), epoll()

由于 fork() 模型在高并发场景下的资源消耗问题,现代高性能服务器通常采用I/O 多路复用技术。

  • select()poll(): 允许你监视多个文件描述符(套接字),任何一个就绪(可读、可写、出错)时,select()poll() 会返回,它们用一个进程或线程就可以处理成千上万的连接。
  • epoll() (Linux 特有): 是 select()poll() 的升级版,性能更高,尤其在连接数非常多时,它采用事件驱动的模型,效率极高,Nginx、Redis 等高性能服务器都基于 epoll
模型 优点 缺点 适用场景
fork() 逻辑简单,隔离性好 资源消耗大,不适合高并发 连接数不多(几百到几千),对逻辑清晰度要求高的场景,教学示例。
I/O 多路复用 (epoll等) 资源消耗极小,并发能力极强 编程模型相对复杂,有学习曲线 高并发服务器,如 Web 服务器、数据库代理等。

对于学习 C 语言网络编程,fork() 是一个必须掌握的经典模型,它能帮助你深刻理解操作系统进程和并发的基本概念,但在实际生产环境中,尤其是在追求极致性能的场景下,epoll 等现代技术是更优的选择。

-- 展开阅读全文 --
头像
织梦CMS上传Linux空间步骤有哪些?
« 上一篇 前天
typedef与define有何本质区别?
下一篇 » 前天

相关文章

取消
微信二维码
支付宝二维码

目录[+]