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

但行好事,莫问前程!

  • 累计撰写 24 篇文章
  • 累计创建 12 个标签
  • 累计收到 6 条评论

目 录CONTENT

文章目录

多进程编程

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

进程是正在运行的程序的实例。是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

可以用一个程序来创建多个进程,进程是由内核定义的抽象实体,并为该实体分配用以执行程序的各项系统资源。从内核的角度看,进程由用户内存空间和一系列内核数据结构组成。其中用户内存空间包含了程序代码及代码所使用的变量,而内核数据结构则用于维护进程状态信息。记录在内核数据结构中的信息包括许多与进程相关的标识号、虚拟内存表、打开文件的描述符表、信号传递及处理的有关信息、进程资源使用及限制、当前工作目录和大量的其他信息。

进程相关概念

进程控制块

  • 为了管理进程,内核必须对每个进程所做的事情进行清楚的描述。内核为每个进程分配一个 PCB(Processing Control Block)进程控制块,维护进程相关的信息,Linux 内核的进程控制块是 task_struct 结构体。

  • /usr/src/linux-headers-xxx/include/linux/sched.h文件中可以查看struct task_struct结构体定义。主要有:

    • 进程 id:系统中每个进程有唯一的 id,用 pid_t 类型表示,其实就是一个非负整数
    • 进程的状态:有就绪、运行、挂起、停止等状态
    • 进程切换时需要保存和恢复的一些 CPU 寄存器描述虚拟地址空间的信息
    • 描述控制终端的信息
    • 当前工作目录(Current working Directory)
    • umask 掩码
    • 文件描述符表,包含很多指向 file 结构体的指针
    • 和信号相关的信息
    • 用户 id 和组 id
    • 会话(Session)和进程组
    • 进程可以使用的资源上限(Resource Limit)

进程的状态

进程状态反映进程执行过程的变化。这些状态随着进程的执行和外界条件的变化而转换。在三态模型中,进程状态分为三个基本状态,即就绪态,运行态,阻塞态。在五态模型中,进程分为新建态、就绪态,运行态,阻塞态,终止态。

image-1677935673115

  • 运行态:进程占有处理器正在运行。
  • 就绪态:进程具备运行条件,等待系统分配处理器以便运行。邹进程已分配到除 CPU 以外的所有必要资源后,只要再获得 CPU,便可立即执行。在一个系统中处于就绪状态的进程可能有多个,通常将它们排成一个队列,称为就绪队列
  • 阻塞态:又称为等待(wait)态或睡眠(sleep)态,指进程不具备运行条件,正在等待某个事件的完成。
  • 新建态:进程刚被创建时的状态,尚未进入就绪队列。
  • 终止态:进程完成任务到达正常结束点,或出现无法克服的错误而异常终止,或被操作系统及有终止权的进程所终止时所处的状态。进入终止态的进程以后不再执行,但依然保留在操作系统中等待善后。一旦其他进程完成了对终止态进程的信息抽取之后,操作系统将删除该进程。

进程相关命令

  • 查看进程信息

    ps aux
    或
    ps ajx
    
    a:显示终端上的所有进程,包括其他用户的进程;
    u:显示进程的详细信息;
    x:显示没有控制终端的进程;
    j:列出与作业控制相关的信息。
    
  • 进程状态 STAT 的含义:
    | 符号 | 含义 |
    | --------- | -------------------------- |
    | D | 不可终端 |
    | R | 正在运行,或在队列中的进程 |
    | S(大写) | 处于休眠状态 |
    | T | 停止或被追踪 |
    | Z | 僵尸进程 |
    | W | 进入内存交换 |
    | X | 死掉的进程 |
    | < | 高优先级 |
    | N | 低优先级 |
    | s(小写) | 包含子进程 |
    | + | 位于前台的进程组 |

  • 实时显示进程动态

    top
    

进程号和相关函数

每个进程都由进程号来标识,其类型为 pid_t(整型),进程号的范围:0~32767。进程号总是唯一的,但可以重用。当一个进程终止后,其进程号就可以再次使用。

任何进程(除 int 进程)都是由另一个进程创建,该进程称为被创建进程的父进程。对应的进程号称为父讲程号(PPID)

进程组是一个或多个进程的集合。他们之间相互关联,进程组可以接收同一终端的各种信号,关联的进程有一个进程组号(PGID)。默认情况下,当前的进程号会当做当前的进程组号。

  • 进程号和进程组相关函数
    // 获取当前进程的进程号
    pid_t getpid();
    // 获取当前进程的父进程号
    pid_t getppid();
    // 获取pid所在进程组的组id,传空为获取当前进程的进程组id
    pid_t getpgid(pid_t pid);
    

fork 系统调用

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

pid_t fork(void);

功能

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

返回值

  • 父进程:返回子进程 ID
  • 子进程:返回 0
  • 调用失败:返回 -1,并设置 errno。失败有两个主要原因:
    • 当前系统的进程数已经达到了系统规定的上限,这时 errno 的值被设置为 EAGAIN
    • 系统内存不足,这时 errno 的值被设置为 ENOMEM

注意事项

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

image-1666057636163

exec 函数族

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

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

exec 函数族的函数执行成功后不会返回,因为调用进程的实体,包括代码段。数据段和堆栈等都已经被新的内容取代,只留下进程 ID 等一些表面上的信息仍保持原样。只有调用失败了,它们才会返回 -1,从原程序的调用点接着往下执行。

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

// execl 和 execlp 用得比较多
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[]);

// l(list):参数地址列表,以空指针结尾
// v(vector):存有各参数地址的指针数组的地址
// p(path):按 PATH 环境变量指定的目录搜索可执行文件
// e(environment):存有环境变量字符串地址的指针数组的地址

path/file

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

arg/argv

  • arg:可执行文件所需要的参数列表。(第一个参数一般没有什么作用,为了方便,一般写的是可执行文件的名称。从第二个参数开始往后,就是程序执行所需要的参数列表。参数最后需要以 NULL 结束。)
  • argv:接受参数数组
  • arg 和 argv 都会被传递给新程序(path 或 file 指定的程序)的 main 函数

envp

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

注意事项

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

    int main() {
        // 通常创建一个子进程,在子进程中执行 exec 函数族中的函数
        pid_t pid = fork();
    
        if(pid > 0) {
            // 父进程
            printf("I am parent process, pid = %d\n", getpid());
        } else {
            // 在子进程中,将进程镜像替换为可执行程序 hello 的镜像
            execl("hello", "hello", NULL);
    
            // 由于进程镜像被替换,下面这行代码不会被执行
            printf("I am child process, pid = %d\n", getpid());
        }
    
        for(int i = 0; i < 5; ++i) {
            printf("i = %d, pid = %d\n", i, getpid());
        }
    
        return 0;
    }
    

    执行结果如图:
    image-1678003500032

进程退出(exit、_exit)

#include <stdlib.h>

// C 库函数
// status 是退出的状态信息,父进程回收子进程资源的时候可以获取到
void exit(int status);
#include <unistd.h>

// 系统调用,status 是退出的状态
void _exit(int status);

image-1678004326599

  • 案例

    int main() {
       printf("hello\n");
       printf("world");
       exit(0);
    
       return 0;
    
        /* 输出:
        hello
        world
        */
    }
    
    int main() {
       printf("hello\n");
       printf("world");
       _exit(0); // 不会刷新IO缓冲
    
       return 0;
    
        /* 输出:
        hello
        */
    }
    

孤儿进程

父进程运行结束,但子进程还在运行(未运行结束),这样的子进程就称为孤儿进程(orphan Process)。

每当出现一个孤儿进程的时候。内核就把孤儿进程的父进程设置为 init,而 init 进程会循环地 wait() 它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候。init 进程就会代表党和政府出面处理它的一切善后工作。

因此孤儿进程并不会有什么危害。

处理僵尸进程(wait、waitpid)

每个进程结束之后,都会释放自己地址空间中的用户区数据,内核区的 PCB 没有办法自己释放掉,需要父进程去释放。对于多进程程序而言,父进程一般需要跟踪子进程的退出状态。因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)。在子进程结束运行之后,父进程读取其退出状态之前,我们称该子进程处于僵尸态

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

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

僵尸进程不能被kill -9杀死。

父进程可以通过调用 wait 或 waitpid 得到它的退出状态同时彻底清除掉这个进程。

wait() 和 waitpid() 函数的功能一样,区别在于,wait() 函数会阻塞,waitpid() 可以设置不阻塞,waitpid() 还可以指定等待哪个子进程结束。

注意:一次 wait 或 waitpid 调用只能清理一个子进程,清理多个子进程应使用循环。

wait()

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

pid_t wait(int *stat_loc);

功能

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

stat_loc

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

返回值

  • 成功:返回结束运行的子进程的 PID
  • 失败:-1(所有的子进程都结束,调用失败)
/* 宏的使用 */
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

  • pid>0:某个子进程的 pid
  • pid=0:等待当前进程组的任意子进程
  • pid=-1:与 wait 相同,可等待任意子进程终止(最常用)
  • pid<-1:等待进程组 id 为 【pid 绝对值】的这个组中任意子进程终止

stat_loc

  • 同 wait 函数。

options

  • 0:阻塞
  • WNOHANG:非阻塞

返回值

  • 大于0:子进程的 id
  • 等于0:options=WNOHANG时才有可能返回 0,表示还有子进程没退出
  • -1:表示所有子进程都结束,调用函数失败

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

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

用SIGCHLD处理僵尸进程问题

  • SIGCHLD 信号产生的条件
    子进程终止时;子进程接受到 SIGSTOP 信号时;子进程处在停止状态,接收到 SIGCONT 后唤醒时。

    这3种条件都会给父进程发送 SIGCHLD 信号,父进程默认会忽略该信号。

进程间通信⭐⭐⭐

进程是一个独立的资源分配单元,不同进程(这里所说的进程通常指的是用户进程)之间的资源是独立的,没有关联,不能在一个进程中直接访问另一个进程的资源。

但是,进程不是孤立的,不同的进程需要进行信息的交互和状态的传递等,因此需要进程间通信(IPC:Inter Processes Communication)。

  • 进程间通信的目的:

    • 数据传输:一个进程需要将它的数据发送给另一个进程。
    • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
    • 资源共享:多个进程之间共享同样的资源。为了做到这一点,需要内核提供互斥和同步机制。
    • 进程控制:有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
  • Linux 进程间通信的方式
    image-1678017397633

管道

UNIX 系统中最古老的进程间通信形式。

  • 例如:
    统计一个目录中文件的数目命令:ls | wc -l,为了执行该命令,shell 创建了两个进程来分别执行lswc
    image-1678017786879

管道的特点⭐

  • 管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的,不同的操作系统大小不一定相同。
  • 管道拥有文件的特质:读操作、写操作,匿名管道没有文件实体,有名管道有文件实体但不存储数据。可以按照操作文件的方式对管道进行操作。
  • 一个管道是一个字节流,使用管道时不存在消息或者消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是多少。
  • 通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序是完全一样的。
  • 在管道中的数据的传递方向是单向的,一端用于写入,一端用于读取,管道是半双工的。
  • 从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据,在管道中无法使用 lseek() 来随机的访问数据。
  • 匿名管道只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用。
  • 匿名管道只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用
  • 匿名管道默认是阻塞的。

image-1678018427624

匿名管道

#include<unistd.h>

int pipe(int fds[2]);

功能

  • 创建管道。

fds

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

返回值

  • 成功:0
  • 失败:-1,设置errno

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(fds1[1], str1, sizeof(str1));
        read(fds2[0], buf, BUF_SIZE);
        printf("Child proc output: %s \n", buf);
    } else { // 父进程通过fds2指向的管道向子进程发送数据
    	read(fds1[0], buf, BUF_SIZE);
        printf("Parent proc output: %s \n", buf);
        write(fds2[1], str2, sizeof(str2));
        sleep(3);
    }
    return 0;
}

补充:

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

有名管道

匿名管道,由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道(FIFO),也叫命名管道、FIFO文件。

有名管道(FIFO)不同于匿名管道之处在于它提供了一个路径名与之关联,以 FIFO 的文件形式存在于文件系统中,并且其打开方式与打开一个普通文件是一样的,这样即使与 FIFO 的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过 FIFO 相互通信,因此,通过 FIFO 不相关的进程也能交换数据。

一旦打开了 FIFO,就能在它上面使用与操作匿名管道和其他文件的系统调用一样的 I/O 系统调用了(如 read()、write() 和 close())。与管道一样,FIFO 也有一个写入端和读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。FIFO的名称也由此而来:先入先出。

FlFO 在文件系统中作为一个特殊文件存在。但上 FlFO 中的内容却存放在内存中。

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

// 创建有名管道
int mkfifo(const char *pathname, mode_t mode);

内存映射

内存映射(Memory-mapped I/O)是将磁盘文件的数据映射到内存,用户通过修改内存就能修改磁盘文件。

image-1678107809895

#include <sys/mman.h>

// 内存映射
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

// 释放内存映射(addr:要释放的内存的首地址;length:和mmap函数中length参数的值一样)
int munmap(void *addr, size_t length);

功能

  • 将一个文件或设备映射到内存中。

参数

  • addr:映射到内存中具体位置的首地址,传 NULL 由内核指定即可。
  • length:要映射的数据的长度,这个值不能为0,建议使用文件的长度。获取文件长度可使用statlseek
  • prot:对申请的内存映射区的操作权限。(要操作内存,必须要有读权限)
    • PROT_EXEC:可执行权限
    • PROT_READ:读权限
    • PROT_WRITE:写权限
    • PROT_NONE:没有权限
  • flags:
    • MAP_SHARED:映射区的数据会自动和磁盘文件进行同步,进程间通信,必须要设置这个选项
    • MAP_PRIVATE:不同步,内存映射区的数据改变了,对原来的文件不会修改,会重新创建一个新的文件
  • fd:需要映射的那个文件的文件描述符(通过open得到,注意文件的大小不能为0,且open指定的权限和prot参数不能有冲突)
  • offset:偏移量,一般不用。必须指定的是4K的整数倍,0表示不偏移

返回值

  • 成功:映射到的内存的地址
  • 失败:MAP_FAILED,设置errno
/*
使用内存映射实现进程间通信;
1. 有关系的进程(父子进程)
    - 还没有子进程的时候
        - 通过唯一的父进程,先创建内存映射区
        - 有了内存映射区以后,创建子进程
        - 父子进程共享创建的内存映射区

2. 没有关系的进程间通信:
    - 准备一个大小不是0的磁盘文件
    - 进程1:通过磁盘文件创建内存映射区
        - 得到一个操作这块内存的指针
    -进程2:通过磁盘文件创建内存映射区
        - 得到一个操作这块内存的指针
    - 使用内存映射区通信

注意:内存映射区通信是非阻塞的!
*/

int main()
{
    // 1. 打开一个文件
    int fd = open("test.txt", O_RDWR);
    off_t size = lseek(fd, 0, SEEK_END); // 获取文件大小

    // 2. 创建内存映射区
    void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if(ptr == MAP_FAILED) {
        perror("mmap");
        exit(0);
    }

    // 3. 创建子进程
    pid_t pid = fork();
    if(pid > 0) { // 父进程
        wait(0);
        char buf[64];
        strcpy(buf, (char *)ptr);
        printf("read data : %s", buf);
    } else if(pid == 0) { // 子进程
        strcpy((char *)ptr, "hello ~~~~~~!!!");
    } 

    return 0;
}

信号

发送信号kill、信号处理、Linux信号、signal()、sigaction()、信号集

信号量

信号量原语

信号量本质上是一个计数器,用于协调多个进程(包括但不限于父子进程)对共享数据对象的读/写。它不以传送数据为目的,主要是用来保护共享资源(共享内存、消息队列、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/ipc.h>
#include <sys/shm.h>

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

功能

  • 创建一个新的共享内存段,或者获取一个已有的共存内存段标识,新创建的内存段中的数据都会被初始化为 0。

参数

  • key:通过key找到或创建一个共享内存,一般用十六进制表示,非0值
  • size:共享内存的大小
  • shmflg:共享内存的属性
    • 访问权限
    • 附加属性:创建/判断共享内存是不是存在
      • 创建:IPC_CREAT
      • 判断共享内存是否存在:IPC_EXCL,需要和IPC_CREAT一起使用

返回值

  • 成功:>0,返回共享内存的引用ID,后面操作共享内存都是通过这个值
  • 失败:-1,设置errno
#include <sys/types.h>
#include <sys/shm.h>

# 功能:将共享内存段和当前的进程进行关联
# 返回值:成功->返回共享内存的首地址;失败:(void *) -1
void *shmat(int shmid, const void *shmaddr, int shmflg);

# 解除当前进程和共享内存的关联
# 返回值:成功->0;失败->-1
int shmdt(const void *shmaddr);

参数

  • shmid:共享内存的标识,是shmget()的返回值
  • shmaddr:申请的共享内存的起始地址,指定为NULL,由内核指定这个地址
  • shmflg:对共享内存的操作
    • 读:SHM_RDONNLY,必须有读权限
    • 读写:0
#include <sys/ipc.h>
#include <sys/shm.h>

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

功能
删除共享内存,共享内存要删除才会消失,创建共享内存的进程被销毁了,对共享内存没有影响。

参数

  • shmid:共享内存的ID
  • cmd:要做的操作
    • IPC_STAT:获取共享内存的当前状态
    • IPC_SET:设置共享内存的状态
    • IPC_RMID:标记共享内存被销毁
  • buf:要设置的一些属性(共享内存大小、创建时间、谁创建的、…)
#include <sys/types.h>
#include <sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);

功能

  • 根据指定的路径名和int值,生成一个共享内存的Key

参数

  • pathname:指定一个存在的路径
  • proj_id:int类型的值,该系统调用只会使用其中 1个字节

共享内存的使用步骤:

  1. 调用 shmget() 创建一个新共享内存段或取得一个既有共享内存段的标识符(即由其他进程创建的共享内存段)。这个调用将返回后续调用中需要用到的共享内存标识符。
  2. 使用 shmat() 来附上共享内存段。即使该段成为调用进程的虚拟内存的一部分。
  3. 此刻在程序中可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由 shmat() 调用返回的 addr 值,它是一个指向进程的虚拟地址空间中该共享内存段的起点的指针。
  4. 调用 shmdt() 来分离共享内存段。在这个调用之后。进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步。
  5. 调用 shmctl() 来删除共享内存段。只有当当前所有附加内存段的进程都与之分离之后内存段才会销毁。只有一个进程需要执行这一步。

消息队列

0

评论区