C 语言编程 — fork 进程操作

Posted 范桂飓

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C 语言编程 — fork 进程操作相关的知识,希望对你有一定的参考价值。

目录

文章目录

用户进程

User Process 由 Kernel 创建、调度和销毁,运行在 User Space 中,是系统资源分配的单元。

在 Kernel 中 User Process 使用一一对应的 task_struct 结构体表示。task_struct 是一个非常庞大的数据结构,存储了 User Process 的所有信息,Kernel 以此来对 User Process 进行管理,所以也称为 PCB(Process Control Block,进程控制块),或进程描述符(Process Descriptor)。

// include/linux/sched.h

struct task_struct 
    volatile long state;               // 进程状态
    void   *stack;                     // 进程堆栈指针
    struct list_head tasks;            // 进程链表指针
    pid_t  pid;                        // 进程 ID
    pid_t  tgid;                       // 进程组 ID
    struct task_struct *parent;        // 父进程指针
    struct list_head children;         // 子进程链表指针
    struct mm_struct *mm;              // 进程地址空间指针
    struct files_struct *files;        // 进程文件描述符表指针
    struct signal_struct *signal;      // 进程信号表指针
    struct sighand_struct *sighand;    // 进程信号处理函数表指针
    struct task_struct *group_leader;  // 进程组领导指针
    struct completion *vfork_done;     // vfork 子进程完成事件
    int vfork_mode;                    // vfork 子进程标志

    ...
;

进程调度的状态机

  • 创建状态(New):进程正在被创建中,尚未到就绪状态。
  • 就绪状态(Ready):进程已处于准备运行状态,即:进程获得了除了 CPU 之外所需的一切资源,一旦得到 CPU,即可运行。
  • 运行状态(Running):进程正在 CPU 上运行。
  • 阻塞状态(Waiting):进程正在等待某一事件而暂停运行,此时不会占用 CPU,例如:I/O 场景中,在等待 Buffer 就绪。
  • 结束状态(terminated):释放进程资源。

子进程

  1. Parent 调用 fork() 指示创建 Child:Kernel 首先会新建 Child PCB,设置 PID,并关联 Parent PCB。
#include <unistd.h>

pid_t fork(void);
  1. fork() 会特殊的返回 2 次:程序需要通过 if/else 语句来判断 2 个不同的 pid 数值,以此区分 Parent 和 Child 的处理逻辑。

    • pid > 0:是返回给 Parent 的 Child PID。
    • pid = 0:表示当前代码块运行在 Child。
    • pid < 0:创建失败,可以通过 errno 变量获取错误码。失败的原因可能是系统资源不足,或者是 Parent 已经创建了太多的 Childs。
  2. Parent 调用了 exec() 后,Child 才正式启动:Kernel 为 Child 分配 VAS(Virtual Address Space,虚拟地址空间),并将 Parent VAS 的数据 Copy 到 Child VAS,然后初始化 Child VAS 中的 PC(Process Count,程序计数器)值,开始 CPU 执行。可见,Parent 和 Child 的唯一区别就是 PID 不同而已。

NOTE:exec() 实现了一种 COW(Copy On Write,写时复制)机制,即:只有在需要的时候才开始 Copy 数据,避免不必要的开销,因为 Parent VAS 中的数据量可能非常庞大。

  1. Child 完成 exec() 后,跟着调用 exit() 指示开始退出:Child 需要向 Parent 返回执行状态。此时的 Child 首先会进入僵尸进程状态,然后等待 Parent 切实接收到返回。

  2. Parent 调用 wait() 等待 Child 退出,并获取 Child 的执行状态:切实收到 Child 的返回后,才开始释放 Child 的所有资源。以此来避免了 Child 执行状态的丢失。

示例代码:通过 pid 数值判断,在 if/else 代码块中实现父子进程各自的逻辑。

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() 
    pid_t pid;
    int status;

    pid = fork();
    if (pid == 0) 
        // 子进程中执行 ls 命令
        execlp("ls", "ls", "-l", NULL);
        printf("exec failed\\n");
     else if (pid > 0) 
        // 父进程中等待子进程结束
        wait(&status);
        printf("child process exited with status %d\\n", status);
     else 
        printf("fork failed\\n");
    

    return 0;

Child exec 函数族

这些函数都会在当前的 Parent 中执行一个新的 Child 程序。

  • execl 函数
    • path 参数:可执行程序的文件名(绝对路径);
    • arg 可变长参数:传递给可执行程序的参数,最后一个参数必须是 NULL。
int execl(const char *path, const char *arg, ...);
  • execle 函数
    • path 参数:可执行程序的文件名(绝对路径);
    • arg 可变长参数:传递给可执行程序的参数,最后一个参数必须是 NULL。
    • envp 参数:环境变量数组,最后一个元素必须是 NULL。
int execle(const char *path, const char *arg, ..., char *const envp[]);
  • execlp 函数
    • file 参数:可执行程序的文件名(仅文件名),它会在 PATH 系统环境变量路径中搜索。
    • arg 可变长参数:传递给可执行程序的参数,最后一个参数必须是 NULL。
int execlp(const char *file, const char *arg, ...);
  • execv 函数
    • path 参数:可执行程序的文件名(绝对路径);
    • argv 参数:传递给可执行程序的参数,指针数组类型,最后一个元素必须是 NULL。
int execv(const char *path, char *const argv[]);
  • execvp 函数
    • file 参数:可执行程序的文件名(仅文件名),它会在 PATH 系统环境变量路径中搜索。
    • argv 参数:传递给可执行程序的参数,指针数组类型,最后一个元素必须是 NULL。
int execvp(const char *file, char *const argv[]);
  • execvpe 函数
    • file 参数:可执行程序的文件名(仅文件名),它会在 PATH 系统环境变量路径中搜索。
    • argv 参数:传递给可执行程序的参数,指针数组类型,最后一个元素必须是 NULL。
    • envp 参数:环境变量数组,最后一个元素必须是 NULL。
int execvpe(const char *file, char *const argv[], char *const envp[]);

Parent wait 函数族

这些函数都是阻塞调用,即 Parent 会一直等待直到 Child 结束才会返回。

  • wait 函数:等待一个 Child 退出。
    • status 参数:指针类型,如果不为 NULL,则存储 Child 退出状态。
    • 函数返回值
      • 成功:返回退出的 Child PID。
      • 失败:返回 -1。错误码包括 ECHILD(没有子进程)、EINTR(等待被信号中断)、EINVAL(pid 参数非法)等。
pid_t wait(int *status);
  • waitpid 函数:等待一个指定 PID 的 Child 退出。
    • pid 参数:指定 Child。如果 pid 为 -1,则等待任意一个 Child 退出。
    • status 参数:指针类型,如果不为 NULL,则存储 Child 退出状态。
    • options 参数:指定等待选项,例如:WNOHANG、WUNTRACED 等。
    • 函数返回值
      • 成功:返回退出的 Child PID。
      • 失败:返回 -1。错误码包括 ECHILD(没有子进程)、EINTR(等待被信号中断)、EINVAL(pid 参数非法)等。
pid_t waitpid(pid_t pid, int *status, int options);
  • wait3 函数:等待一个 Child 退出,同时返回 Child 的资源使用情况,如 CPU 占用时间、内存使用情况等。
    • status 参数:指针类型,如果不为 NULL,则存储 Child 退出状态。
    • options 参数:指定等待选项,例如:WNOHANG、WUNTRACED 等。
    • rusage 参数:存储 Child 的资源使用情况。
    • 函数返回值
      • 成功:返回退出的 Child PID。
      • 失败:返回 -1。错误码包括 ECHILD(没有子进程)、EINTR(等待被信号中断)、EINVAL(pid 参数非法)等。
pid_t wait3(int *status, int options, struct rusage *rusage);
  • wait4 函数:等待一个指定 PID 的 Child 退出,同时返回 Child 的资源使用情况,如 CPU 占用时间、内存使用情况等。
    • pid 参数:指定 Child。如果 pid 为 -1,则等待任意一个 Child 退出。
    • status 参数:指针类型,如果不为 NULL,则存储 Child 退出状态。
    • options 参数:指定等待选项,例如:WNOHANG、WUNTRACED 等。
    • rusage 参数:存储 Child 的资源使用情况。
    • 函数返回值
      • 成功:返回退出的 Child PID。
      • 失败:返回 -1。错误码包括 ECHILD(没有子进程)、EINTR(等待被信号中断)、EINVAL(pid 参数非法)等。
pid_t wait4(pid_t pid, int *status, int options, struct rusage *rusage);

如果关心 Child 的退出状态,可以使用 WIFEXITED()、WEXITSTATUS()、WIFSIGNALED()、WTERMSIG()、WIFSTOPPED() 和 WSTOPSIG() 宏来解析 status 参数。

多进程

IPC(多进程间通信)

Linux 操作系统中提供了多种不同的 IPC(进程间通讯)方式,来支持 Multi-Processes 之间的数据共享和通信。

  • 匿名管道(pipe):用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。

  • 命名管道(named pipe): 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了命名管道。命名管道以普通文件的形式存在,严格遵循 FIFO,可以实现本机任意两个进程间通信。

  • 共享内存(shared memory):是一种高效的进程间通信方式。使得多个进程可以访问同一块物理内存空间,多个进程间可以互相看见对方对共享数据的更新。Linux 提供了多种共享内存方式,例如:mmap 共享内存、XSI 共享内存、POSIX 共享内存等。这种方式需要依赖同步原语,如:互斥锁、信号量等。

  • 套接字(UNIX Socket):一个进程作为服务器监听 UNIX Socket,并接收客户端的请求;另外的进程作为客户端连接到 UNIX Socket,并向服务器发送请求。

  • 消息队列(Message Queuing):消息队列是一个存放在内存中的链表结构,由 Kernel 管理,具有特定的格式。

  • 信号(Signal):信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

pipe() 匿名管道

C 语言的匿名管道 pipe() 定义在 unistd.h(Unix standard)中。

函数作用:创建一个管道,本质是一个 Kernel Byte Steam Buffer(字节流缓冲区),大小为 4KB,支持 FIFO 队列,数据写入管道的一端,可以从另一端读取出来。

函数原型

  • pipefd[2] 参数:是一个长度为 2 的整数数组,它包含了两个 fd(文件描述符),一个用于读取数据,另一个用于写入数据。
    • pipefd[0]:读端口,从队头(Front)读。
    • pipefd[1]:写管道,从犯队尾(Rear)写。
  • 函数返回值
    • 成功:0,并会代表管道两端的 2 个 fd 存储在数组中。
    • 失败:-1,并设置了 errno。
#include <unistd.h>

int pipe(int pipefd[2]);

示例程序:程序创建一个子进程,并创建了一个管道,子进程向管道写入一条消息,父进程从管道读取这条消息并输出到终端上。

由于子进程和父进程拥有完全相同的变量,因此子进程也有对应 pipefd[2](管道读端和写端)的两个 fd。之后,只需要关闭一侧的读端和另一侧的写端,就可以实现进程间的通信。

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define BUF_SIZE 256

int main() 
    int pipefd[2];
    char buf[BUF_SIZE];
    pid_t pid;

    if (pipe(pipefd) == -1) 
        perror("pipe");
        exit(EXIT_FAILURE);
    

    pid = fork();
    if (pid == -1) 
        perror("fork");
        exit(EXIT_FAILURE);
    

    if (pid == 0)  // child process
        close(pipefd[0]); // close the read end of the pipe
        strcpy(buf, "Hello, parent process!\\n");
        write(pipefd[1], buf, strlen(buf));
        close(pipefd[1]); // close the write end of the pipe
        exit(EXIT_SUCCESS);
     else  // parent process
        close(pipefd[1]); // close the write end of the pipe
        while (read(pipefd[0], buf, BUF_SIZE) > 0) 
            printf("Received message: %s", buf);
        
        close(pipefd[0]); // close the read end of the pipe
        exit(EXIT_SUCCESS);
    

dup2() 管道重定向

dup2() 函数,可以用于将一个文件描述符复制到另一个文件描述符上。在管道场景中,可通过 dup2 修改 fds,继而用于实现管道读写端的重定向。

函数原型

  • oldfd 参数:是需要被复制的 fd;
  • newfd 参数:是重定向的目标 fd。如果 newfd 已经被打开了,那么 dup2() 会先关闭它,然后将 oldfd 复制到 newfd 上。
#include <unistd.h>

int dup2(int oldfd, int newfd);

程序示例:将 STDOUT_FILENO(标准输出文件描述符)重定向到一个文件中。首先 open() 一个文件,并指定了写入权限。然后,使用 dup2() 函数将 fd 复制到 STDOUT_FILENO 上。这样,所有输出 STDOUT_FILENO 的数据都会最终重定向到 output.txt 文件中。

#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>

int main() 
    int fd;

    fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR);
    if (fd == -1) 
        perror("open");
        return -1;
    

    if (dup2(fd, STDOUT_FILENO) == -1) 
        perror("dup2");
        return -1;
    

    printf("Hello, world!\\n"); // this will be written to "output.txt"

    close(fd);
    return 0;

命名管道

匿名函数使用简单,但问题是只能用于父子进程之间,因为在父进程创建的 pipe,可以通过 fork() 的方式复制到子进程,然后两者使用同一个 pipe 进行通信。

而对于非相关进程而言,则需要使用命名管道。为了保证数据的安全,同样采用了阻塞的 FIFO,让写操作变成原子操作,行为与匿名管道类似。

函数原型:命名管道,在 Linux 文件系统中以文件的形式存在,由 filename 指定名称,而 mode 则指定了文件的读写权限。

#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *filename, mode_t mode);
int mknod(const char *filename, mode_t mode | S_IFIFO, (dev_t)0);

由于是一个真实的文件,所以命名管道也可以使用 CLI 来进行创建:

$ mkfifo fifo_file
$ mknod fifo_file p

以上是关于C 语言编程 — fork 进程操作的主要内容,如果未能解决你的问题,请参考以下文章

linux C语言 clone() 和 fork() 的区别,fork函数的用法,主要应用场景(clone是fork的升级版本,可以将创建出来的进程变成父进程的兄弟进程)

子进程是否从 Fork 函数 后 开始执行,执行函数后的代码。Fork函数之前的不执行?

c/c++ 多进程 fork函数

在VC中调用FORK()文件应该加啥头文件?fork()本来是在linux环境下调用的

实验六 进程基础

一起talk C栗子吧(第一百三十三回:C语言实例--创建进程时的内存细节)