fork() 是 Unix 和 Linux 系统中一个非常重要的系统调用,用于创建一个新的进程。新进程被称为子进程,而调用 fork() 的进程被称为父进程。fork() 的设计使得子进程几乎完全复制了父进程的状态,这使得它可以独立地执行任务。

基本概念

父进程和子进程

  • 父进程:调用 fork() 的进程。
  • 子进程:由 fork() 创建的新进程。

返回值

  • 在 父进程 中,fork() 返回子进程的进程 ID(PID)。
  • 在 子进程 中,fork() 返回0。
  • 如果 fork() 失败,返回 -1,并设置 errno 以指示错误类型。

工作原理

  1. 子进程复制父进程的资源
    • 子进程几乎完全复制了父进程的所有资源,包括但不限于:
      • 文件描述符
      • 环境变量
      • 信号处理函数
      • 当前工作目录
      • 用户和组 ID
    • 但是,子进程和父进程有不同的进程 ID(PID)和父进程 ID(PPID)。
  2. 父进程 fork 后的运行
    • fork 函数不是阻塞函数,调用 fork 后继续运行后续代码
    • 也可以通过 wait() 或 waitpid() 用于父进程等待子进程结束。这有助于避免僵尸进程(zombie process)。
  3. 内存管理
    • 在现代操作系统中,fork() 使用 COW 技术来优化内存使用。这意味着在 fork() 刚刚调用后,父进程和子进程共享相同的物理内存页。只有当某个进程尝试修改这些页时,操作系统才会为该进程创建新的内存页副本。

代码示例

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
 
int main() {
    pid_t pid;
 
    // 调用 fork() 创建子进程
    pid = fork();
 
    if (pid < 0) {
        // fork() 失败
        perror("fork failed");
        exit(1);
    } else if (pid == 0) {
        // 子进程
        printf("This is the child process, PID: %d\n", getpid());
        // 子进程可以在这里执行自己的任务
        execlp("/bin/ls", "ls", NULL);  // 执行 ls 命令
    } else {
        // 父进程
        printf("This is the parent process, PID: %d, Child PID: %d\n", getpid(), pid);
        // 父进程可以在这里等待子进程结束
        wait(NULL);
        printf("Child process has finished.\n");
    }
 
    return 0;
}

应用场景

  • 并发处理fork() 常用于创建多个子进程来并行处理任务。
  • 守护进程:创建守护进程时,通常会使用 fork() 来创建一个子进程,然后父进程退出,使子进程成为孤儿进程,由 init 进程接管。
  • 命令执行:在 shell 中执行命令时,通常会使用 fork() 创建子进程,然后在子进程中调用 exec() 系列函数来执行新的程序。

并发处理

Redis AOF 重写机制。

守护进程

实际应用场景

  1. Web 服务器
    • 如 Apache 和 Nginx,这些服务器通常以守护进程的形式运行,处理来自客户端的请求。
  2. 日志记录服务
    • 如 rsyslog 和 syslog-ng,这些服务在后台运行,收集和记录系统日志。
  3. 定时任务
    • 如 cron,它在后台运行,定期执行预定的任务。
  4. 网络服务
    • 如 sshd(SSH 服务器),它在后台监听网络连接请求,并处理登录会话。

创建守护进程的流程

  1. 调用 fork() 创建子进程
    • 子进程将成为守护进程,父进程通常会退出。
  2. 调用 setsid() 创建新的会话
    • 这会使子进程成为新的会话的领导者,并脱离控制终端。
  3. 改变工作目录
    • 通常将工作目录改为根目录(/),以防止守护进程占用当前目录的挂载点。
  4. 重设文件权限掩码
    • 通常将文件权限掩码设置为0,以便守护进程创建的文件具有默认权限。
  5. 关闭不需要的文件描述符
    • 关闭标准输入、输出和错误描述符,以防止守护进程占用这些文件描述符。
  6. 打开日志文件
    • 如果需要,可以打开日志文件来记录守护进程的活动。

实例代码

该守护进程每5秒向一个日志文件写入一条消息:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
#include <syslog.h>
 
void daemonize() {
    pid_t pid, sid;
 
    // 第一次调用 fork(),创建子进程
    pid = fork();
    if (pid < 0) {
        exit(EXIT_FAILURE);
    }
    if (pid > 0) {
        // 父进程退出
        exit(EXIT_SUCCESS);
    }
 
    // 子进程继续执行
    umask(0);  // 重设文件权限掩码
 
    // 创建新的会话,使子进程成为新的会话的领导者,并脱离控制终端。
    sid = setsid();
    if (sid < 0) {
        exit(EXIT_FAILURE);
    }
 
    // 改变工作目录
    if ((chdir("/")) < 0) {
        exit(EXIT_FAILURE);
    }
 
    // 关闭标准输入、输出和错误描述符,防止守护进程占用这些文件描述符。
    close(STDIN_FILENO);
    close(STDOUT_FILENO);
    close(STDERR_FILENO);
 
    // 打开日志文件
    openlog("mydaemon", LOG_PID, LOG_DAEMON);
}
 
int main() {
    daemonize();
 
    while (1) {
        syslog(LOG_NOTICE, "Daemon is running at %d", time(NULL));
        sleep(5);
    }
 
    closelog();
    return 0;
}

注意事项

  1. 资源消耗:虽然 fork() 使用 COW 技术来优化内存使用,但创建大量子进程仍然会消耗较多的系统资源。
  2. 信号处理:子进程继承了父进程的信号处理函数,但需要根据具体需求进行适当的调整。
  3. 文件描述符:子进程继承了父进程的文件描述符,如果不需要,应关闭不必要的文件描述符。