侧边栏壁纸
博主头像
如此肤浅博主等级

但行好事,莫问前程!

  • 累计撰写 20 篇文章
  • 累计创建 10 个标签
  • 累计收到 4 条评论

目 录CONTENT

文章目录

多进程编程

如此肤浅
2022-10-18 / 0 评论 / 0 点赞 / 78 阅读 / 6,957 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2022-11-27,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

fork 系统调用

#include <unistd.h>

pid_t fork(void);

功能

  • fork 函数将创建调用的进程副本,并非根据完全不同的程序创建进程,而是复制正在运行的、调用 fork 函数的进程。
  • 另外,两个进程都将执行 fork 函数调用后的语句(准确地说是在fork函数返回后)。

返回值

  • 父进程:返回子进程 ID
  • 子进程:返回 0
  • 调用失败:返回 -1,并设置 errno

注意事项

  • fork 函数复制当前进程,在内核进程表中创建一个新的进程表项。新的进程表项有很多属性和原进程相同,比如堆指针、栈指针和标志寄存器的值。但也有许多属性被赋予了新的值,比如该进程的 PPID 被设置成原进程的 PID,信号位图被清除(原进程设置的信号处理函数不再对新进程起作用)。
  • 子进程的代码与父进程完全相同,同时它还会复制父进程的数据(堆数据、栈数据和静态数据)。
  • 数据的复制采用的是所谓的写时复制(copy on writte),即只有在任一进程(父进程或子进程)对数据执行了写操作时,复制才会发生(先是缺页中断,然后操作系统给子进程分配内存并复制父进程的数据)。
  • 创建子进程后,父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加 1。不仅如此,父进程的用户根目录、当前工作目录等变量的引用计数均会加 1。

image-1666057636163

exec 系列系统调用

在 Windows 平台下,我们可以通过双击运行可执行程序,让这个可执行程序成为一个进程;而在 Linux 平台,我们可以通过 ./ 运行,让一个可执行程序成为一个进程。

但是,如果我们本来就运行着一个程序(进程),我们如何在这个进程内部启动一个外部程序(即替换当前进程映像)由内核将这个外部程序读入内存,使其执行起来成为一个进程呢?这里我们通过 exec 系列函数之一实现:

#include <unistd.h>
extern char** environ;

int execl(const char* path, const char* arg, ... );
int execlp(const char* file, const char* arg, ... );
int execle(const char* path, const char* arg, ... , char* const envp[]);
int execv(const char* path, char* const argv[]);
int execvp(const char* file, char* const argv[]);
int execve(const char* path, char* const argv[], char* const envp[]);

path/file

  • path:指定可执行文件的完整路径
  • file:指定文件名,该文件的具体位置则在环境变量PATH中搜寻

arg/argv

  • arg:接受可变参数
  • argv:接受参数数组
  • arg 和 argv 都会被传递给新程序(path 或 file 指定的程序)的 main 函数

envp

  • 用于设置新程序的环境变量。如果未设置它,则新程序将使用由全局变量 environ 指定的环境变量。

注意事项

  • 一般情况下,exec 函数是不返回的,除非出错。它出错时返回 -1,并设置 errno。
  • 如果没出错,则原程序中 exec 调用之后的代码都不会执行,因为此时原程序已经被 exec 的参数指定的程序完全替换(包括代码和数据)。
  • exec 函数不会关闭原程序打开的文件描述符,除非该文件描述符被设置了类似 SOCK_CLOEXEC 的属性。

处理僵尸进程

对于多进程程序而言,父进程一般需要跟踪子进程的退出状态。因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)。在子进程结束运行之后,父进程读取其退出状态之前,我们称该子进程处于僵尸态

另外一种使子进程进人僵尸态的情况是:父进程结束或者异常终止,而子进程继续运行。此时子进程的 PPID 将被操作系统设置为 1,即 init 进程。init 进程接管了该子进程,并等待它结束。在父进程退出之后,子进程退出之前,该子进程处于僵尸态

为了避免子进程停留在僵尸态占据着内核资源,我们需要在父进程中主动获取子进程的返回信息,从而避免了僵尸进程的产生,或者使子进程的僵尸态立即结束。

wait()

#include <sys/wait.h>

pid_t wait(int *stat_loc);

功能

  • wait 函数将阻塞进程,直到该进程的某个子进程结束运行为止。

stat_loc

  • 将结束运行的子进程的退出状态信息(exit 函数的参数值、main 函数的 return 返回值)存储在 stat_loc 指向的内存中。
  • 但函数参数指向的单元中还包含其他信息,因此需要通过下列宏进行分离:
    • WIFEXITED 子进程正常终止时返回 true;
    • WEXITSTATUS 返回子进程的返回值。

返回值

  • 返回结束运行的子进程的 PID。
/* 宏的使用 */
int status;
wait(&status);
if(WIFEXITED(status)) { // 是正常终止的吗?
	puts("Normal termination!");
    printf("Chile pass num: %d", WEXITSTATUS(status));  // 返回值是多少?
}

waitpid()

wait 函数会引起程序阻塞,因此可以使用 waitpid 防止阻塞。

#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *stat_loc, int options);

功能

  • 等待指定子进程的结束。

pid

  • 等待终止的目标子进程的 ID。
  • 若传 -1,则与 wait 相同,可等待任意子进程终止。

stat_loc

  • 同 wait 函数。

options

  • 通过该参数控制 waitpid 函数的行为。
  • 传递 WNOHANG 时 waitpid 将是非阻塞的。

返回值

  • 传递 WNOHANG 时 waitpid 将是非阻塞的,即使没有终止的子进程也不会进入阻塞状态,而是返回 0 并退出函数。
  • 如果目标子进程确实正常退出了,则 waitpid 返回该子进程的 PID。
  • waitpid 调用失败时返回 -1 并设置 errno。

要在事件已经发生的情况下执行非阻塞调用才能提高程序的效率。对 waitpid 函数而言,我们最好在某个子进程退出之后再调用它。那么父进程从何得知某个子进程已经退出了呢?这正是 SIGCHLD 信号的用途。当一个进程结束时,它将给其父进程发送一个 SIGCHLD 信号。因此,我们可以在父进程中捕获 SIGCHLD 信号,并在信号处理函数中调用 waitpid 函数以“彻底结束”一个子进程,如下所示:

static void handle_child(int sig) {
	pid_t pid;
    int stat;
    while((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
    	/* 对结束的子进程进行善后处理 */
    }
}

进程间通信

管道

#include<unistd.h>

int pipe(int fds[2]);

功能

  • 创建管道。

fds

  • fd[0]:通过管道接收数据时使用的文件描述符,即管道出口。
  • fd[1]:通过管道传输数据时使用的文件描述符,即管道入口。

1 个管道无法完成双向通信任务,因此需要创建 2 个管道,各自负责不同的数据流动即可。
image-1666063202192

#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char *argv[]) {
	int fds1[2], fds2[2];
    char str1[] = "Who are you?";
    char str2[] = "Thand you.";
    char buf[BUF_SIZE];
    pid_t pid;
    
    pipe(fds1);
    pipe(fds2);
    pid = fork();
    if(pid == 0) { // 子进程通过fds1指向的管道向父进程传输数据
    	write(fds[1], str1, sizeof(str1));
        read(fds[0], buf, BUF_SIZE);
        printf("Child proc output: %s \n", buf);
    } else { // 父进程通过fds2指向的管道向子进程发送数据
    	read(fds[0], buf, BUF_SIZE);
        printf("Parent proc output: %s \n", buf);
        write(fds[1], str2, sizeof(str2));
        sleep(3);
    }
    return 0;
}

补充:

  • 也可用系统调用 socketpair 创建全双工的管道!
  • 本节所介绍的管道只能用于有关联的两个进程(比如父、子进程)间的通信。
  • 一种特殊的管道 FIFO,也叫有名管道,能用于无关联进程间的通信。在网络编程中使用不多。

信号量

信号量原语

信号量本质上是一个计数器,用于协调多个进程(包括但不限于父子进程)对共享数据对象的读/写。它不以传送数据为目的,主要是用来保护共享资源(共享内存、消息队列、socket 连接池、数据库连接池等),保证共享资源在一个时刻只有一个进程独享。

通常,程序对共享资源的访问的代码只是很短的一段,我们称这段代码为临界区。对进程同步,也就是确保任一时刻只有一个进程能进人临界区。

信号量是一种特殊的变量,它只能取自然数值并且只支持两种操作:等待(wait)和信号(signal)。不过在 Linux/UNIX 中,“等待”和“信号”都已经具有特殊的含义,所以对信号量的这两种操作更常用的称呼是 P、V 操作。这两个字母来自于荷兰语单词 passeren(传递)和 vrijgeven(释放)。假设有信号量 SV,则对它的 P、V 操作含义如下:

  • P(SV):如果 SV 的值大于 0,就将它减 1;如果 SV 的值为 0,则挂起进程的执行。
  • V(SV):如果有其他进程因为等待 SV 而挂起,则唤醒之;如果没有,则将 SV 加 1。

信号量的取值可以是任何自然数。但最常用的、最简单的信号量是二进制信号量,它只能取 0 和 1 这两个值。

注意: 使用一个普通变量来模拟二进制信号量是行不通的,因为所有高级语言都没有一个原子操作可以同时完成如下两步操作:检测变量是否为 true/false,如果是则再将它设置为 false/true。

semget 系统调用

#include <sys/sem.h>

int semget(key_t key, int num_sems, int sem_flags);

功能

  • 创建一个新的信号量集,或者获取一个已经存在的信号量集。

key

  • 是一个键值,用来标识一个全局唯一的信号量集,就像文件名全局唯一地标识一个文件一样。(用16进制比较好)
  • 要通过信号量通信的进程需要使用相同的键值来创建/获取该信号量。

num_sems

  • 指定要创建/获取的信号量集中信号量的数目。
  • 如果是创建信号量,则该值必须被指定;如果是获取已经存在的信号量,则可以把它设置为 0。

sem_flags

  • 指定一组标志。
  • 如果希望信号量不存在时创建一个新的信号量,可以和值 IPC_CREAT 做按位或操作。
  • 如果没有设置 IPC_CREAT 标志并且信号量不存在,就会返错误(errno 的值为 2,No such file or directory)。

返回值

  • 成功时返回信号量集的标识符,失败返回 -1,并设置 errno。
/* 例如:获取键值为0x5000的信号量,如果该信号量不存在,就创建它。 */
int semid = semget(0x5000, 1, 0640 | IPC_CREAT);

semop 系统调用

#include <sys/sem.h>

int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);

功能

  • 修改信号量的值,即执行 P、V 操作。

sem_id

  • 由 semget 函数返回的信号量集标识符,用以指定被操作的目标信号量集。

sem_ops

  • 指向一个 sembuf 结构体类型的数组

num_sem_ops

  • 指定要执行的操作个数,即 sem_ops 数组中元素的个数。
  • semop 对数组 sem_ops 中的每个成员按照数组顺序依次执行操作,并且该过程是原子操作,以避免别的进程在同一时刻按照不同的顺序对该信号集中的信号量执行 semop 操作导致的竞态条件。

返回值

  • 成功返回 0,失败返回 -1,并设置 errno。
  • 失败时,sem_ops 数组中指定的操作都不被执行。
/* sembuf 结构体 */
struct sembuf {
	unsigned short int sem_num;
    short int sem_op;
    short int sem_flg;
};

sem_num

  • 信号量集中信号量的编号,0 表示信号量集中的第一个信号量。

sem_op

  • 指定操作类型,其可选值为正整数、0 和负整数。
  • 每种类型的操作的行为又受到 sem_fig 成员的影响。

sem_flg

  • sem_flg 的可选值是 IPC_NOWAIT 和 SEM_UNDO。
  • IPC_NOWAIT:无论信号量操作是否成功,semop 调用都将立即返回,这类似于非阻塞 I/O 操作。
  • SEM_UNDO:当进程退出时取消正在进行的 semop 操作。

sem_op 和 sem_flg 将按如下方式来影响 semop 的行为:

  • 如果 sem_op 大于 0,则 semop 将被操作的信号量的值 semval 增加 sem_op。该操作要求调用进程对被操作信号量集拥有写权限。此时若设置了 SEM_UNDO 标志,则系统将更新进程的 semadj 变量(用以跟踪进程对信号量的修改情况)。

  • 如果 sem_op 等于 0,则表示这是一个“等待 0”操作。该操作要求调用进程对被操作信号量集拥有读权限。如果此时信号量的值是 0,则调用立即成功返回。如果信号量的值不是 0,则 semop 失败返回或者阻塞进程以等待信号量变为0。在这种情况下,当 IPC_NOWAIT 标志被指定时,semop 立即返回一个错误,并设置 errno 为 EAGAIN。如果未指定 IPC_NOWAIT 标志,则信号量的 semzcnt 值加 1,进程被投入睡眠直到下列 3 个条件之一发生:信号量的值 semval 变为 0,此时系统将该信号量的 semzcnt 值减1;被操作信号量所在的信号量集被进程移除,此时 semop 调用失败返回,errno 被设置为 EIDRM ;调用被信号中断,此时 semop 调用失败返回,crrno 被设置为 EINTR,同时系统将该信号量的 semzcnt 值减 1。

  • 如果 sem_op 小于 0,则表示对信号量值进行减操作,即期望获得信号量。该操作要求调用进程对被操作信号量集拥有写权限。如果信号量的值 semval 大于或等于 sem_op 的绝对值,则 semop 操作成功,调用进程立即获得信号量,并且系统将该信号量的 semval 值减去 sem_op 的绝对值。此时如果设置了 SEM_UNDO 标志,则系统将更新进程的 semadj 变量。如果信号量的值 semval 小于 sem_op 的绝对值,则 semop 失败返回或者阻塞进程以等待信号量可用。在这种情况下,当 IPC_NOWAIT 标志被指定时,semop 立即返回一个错误,并设置 errno 为 EAGAIN。如果未指定 IPC_NOWAIT 标志,则信号量的 semncnt 值加 1,进程被投人睡眠直到下列 3 个条件之一发生:信号量的值 semval 变得大于或等于 sem_op 的绝对值,此时系统将该信号量的 semncnt 值减 1,并将 semval 减去 sem_op 的绝对值,同时,如果 SEM_UNDO 标志被设置,则系统更新 semadj 变量;被操作信号量所在的信号量集被进程移除,此时 semop 调用失败返回,errno 被设置为 EIDRM;调用被信号中断,此时 semop 调用失败返回,errno 被设置为 EINTR,同时系统将该信号量的 semncnt 值减 1。

semctl 系统调用

#include <sys/sem.h>

// 有的命令需要传递第4个参数
int semctl(int sem_id, int sem_num, int command, ...);

sem_id

  • 是由 semget 调用返回的信号量集标识符,用以指定被操作的信号量集。

sem_num

  • sem num 参数指定被操作的信号量在信号量集中的编号。

command

  • 指定要执行的命令,详情见文档,常用的两个如下。
  • IPC_RMID:立即移除信号量集,唤醒所有等待该信号最集的进程(scmop 返回错误,并设置 crmmo 为 EIDRM)。
  • SETVAL:初始化信号量的值(信号量成功创建后,需要设置初始值),这个值由第四个参数决定。第四参数是一个自定义的共同体。

返回值

  • 失败返回 -1,成功时的返回值取决于 command 的值。

共享内存

共享内存是最高效的 IPC 机制,因为它不涉及进程之间的任何数据传输。这种高效率带来的问题是,我们必须用其他辅助手段来同步进程对共享内存的访问,否则会产生多个进程同时操作共享内存的情况。因此,共享内存通常和其他进程间通信方式一起使用。

#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);

消息队列

IPC 命令

在进程间传递文件描述符

0

评论区