Linux系统编程:进程间通信

目录

一、进程间通信的背景

进程间通信方式

进程间通信目的(为什么要进程间通信)

二、管道

管道的特点

匿名管道

命名管道

匿名管道与命名管道的区别

三、System V共享内存

1.shmget函数

2.shmctl函数

3.shmat函数和shmdt函数

借助管道实现访问控制版的共享内存


一、进程间通信的背景

进程是具有独立性的,即使是父子进程之间,父进程的数据子进程能够看得见,但这是在没有发生写时拷贝的前提下,一旦发生了写时拷贝,父子进程之间的数据是不能相互看见的,这是因为进程是具有独立性的。正是因为进程具有独立性,所以进程之间想要交互数据,成本是非常高的。所以我们要有支持进程间通信的方法。除此之外,如果我们想要多进程协同完成一件事情,也需要实现进程间通信。

进程之间实现通信的的前提是我们要让不同的进程看到同一份资源。这个同一份资源可以是文件、内存块等等,只有不同的进程看到了同一份资源,才可以实现一个进程向资源内写入内容,其它进程可以从资源内读取内容获取信息,从而完成通信。不同的资源种类,决定了不同的进程间通信方式。

进程间通信方式

常见的方式有:

  • 管道
  • SystemV进程间通信
  • POSIX进程间通信

进程间通信目的(为什么要进程间通信)

  1. 数据传输:一个进程需要将它的数据发送给另一个进程。
  2. 资源共享:多个进程之间共享同样的资源。
  3. 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)
  4. 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

二、管道

管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道“。

管道本质上就是一个内存级文件。当我们创建一个进程的时候,操作系统会维护一个task_struct结构以及files_struct结构,files_struct结构里有一个结构体数组struct file*fd_array[],其中保存着该进程打开的文件的地址。我们用该进程创建并打开一个管道文件,然后创建一个子进程。当子进程被创建成功的时候,会将父进程的task_struct结构和files_struct结构拷贝下来。因此,父进程打开的文件子进程也可以找得到。此时的父进程打开的文件就是能够被子进程看到的同一份资源,我们可以在管道文件中实现进程间的通信,这就是管道的原理。

管道的特点

  1. 管道是用来传输数据的文件,这份文件可以被多个进程同时看到
  2. 管道是半双工的,数据只能从一个方向流动。即进行通信的两个进程只能由一个进程向管道写入内容,由另一个进程从管道中读取内容。不能两个进程同时向管道写入和读取内容。
  3. 一般而言,进程退出,管道释放,所以管道的生命周期是随进程的。
  4. 管道是自带同步机制的,它会自带访问控制,当管道满了的时候,写端进程不能再写入数据,必须阻塞式等待读端进程读取走数据才可以接着写入;当管道空了的时候,读端进程不能再读取数据,必须阻塞式等待写端进程写入数据才可以接着读取。
  5. 管道是面向字节流的。首先管道是一块固定大小的缓冲区,管道中先写入的字符一定是先被读取的。其次,管道内的内容是没有格式边界的,需要我们使用管道的用户来规定内容的边界。//比如说如果我们没有规定格式边界,写端进程一直在写入数据但读端进程暂时就是不读取,等到写端进程写入完毕以后,读端进程就会一次性地从头到尾将数据全部读取。但可能出现以下情况:无消息边界:比如你写100个字节是连续的,但是由于读端有限制,每30个字节进行读取。如果我们没有规定格式边界,但是写端进程写入之后读端进程也在读取数据,那么写端进程就会向管道内一个字节一个字节地写入数据,读端进程就会从管道内一个字节一个字节地读取数据。所以我们可以规定管道的格式边界,比如我们控制写端进程在写入的时候,调用write接口的时候规定每次写入的大小是sizeof()多少,比如每次写入sizeof(int)大小的数据,那么读端进程每次也会读取sizeof(int)大小的数据。

匿名管道

#include <unistd.h>
功能:创建⼀⽆名管道
原型
int pipe(int fd[2]);
参数
fd:⽂件描述符数组,其中fd[0]表⽰读端, fd[1]表⽰写端
返回值:成功返回0,失败返回错误代码

使用匿名管道进行进程间通信限制在具有亲缘关系的进程之间,所以使用匿名管道的步骤一般分为下面几步:

  1. 首先由父进程创建匿名管道文件,该文件的读端和写端文件都会被打开。
  2. 父进程创建子进程,子进程继承了父进程的匿名管道文件。
  3. 根据需求,在父子进程中分别将读端文件和写端文件关闭,以此来满足只有一个进程进行写入操作,另一个进程进行读取操作。

接下来我们就来演示一下

#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

#define NUM 1024

using namespace std;

int main()
{
    // 1.创建匿名管道
    int pipefd[2]; // 用来获取匿名管道的读写端文件描述符
    // 如果匿名管道文件创建失败
    if (pipe(pipefd) != 0)
    {
        cerr << "pipe error" << endl;
        return 1;
    }

    // 2.创建子进程
    pid_t id = fork();
    if (id == 0)
    {
        // 子进程
        // 我们让子进程进行读取操作,所以就要关闭写端文件
        close(pipefd[1]);

        // 子进程开始执行读取操作
        while (true)
        {
            char buf[NUM];
            memset(buf, 0, sizeof(buf));
            ssize_t readRes = read(pipefd[0], buf, sizeof(buf) - 1);
            // 读取成功,打印读取到的内容
            if (readRes > 0)
            {
                buf[readRes] = '\0';
                cout << buf << endl;
            }
            // 父进程关闭写端文件,停止读取
            else if (readRes == 0)
            {
                cout << "父进程退出了,子进程也可以退出了" << endl;
                break;
            }
            // 读取失败
            else
            {
                cerr << "read error" << endl;
                return 4;
            }
        }

        // 子进程读取完毕,关闭读端文件
        close(pipefd[0]);
        cout << "子进程读取完毕,可以退出了" << endl;
        exit(0);
    }
    else if (id > 0)
    {
        // 父进程
        // 我们让父进程进行写入操作,所以就要关闭读端文件
        close(pipefd[0]);

        // 父进程开始执行写入操作
        string msg = "你好子进程,我是父进程!";
        int cnt = 0;
        while (cnt < 5)
        {
            ssize_t writeRes = write(pipefd[1], msg.c_str(), msg.size());
            // 写入失败
            if (writeRes < 0)
            {
                cerr << "write error" << endl;
                return 3;
            }
            cnt++;
            sleep(1);
        }

        // 父进程写入完毕,关闭写端文件
        close(pipefd[1]);
        cout << "父进程写入完毕,可以退出了" << endl;
    }
    else
    {
        cerr << "fork error" << endl;
        return 2;
    }

    // 父进程最后需要回收子进程
    pid_t waitRes = waitpid(id, nullptr, 0);
    // 等待失败
    if (waitRes != id)
    {
        cerr << "wait error" << endl;
        return 5;
    }
    return 0;
}

命名管道

命名管道和匿名管道的特征几乎一致,不同的地方在于匿名管道只能够让父子进程或者是兄弟进程之间通信,而命名管道是让两个毫无亲缘关系的进程通信。

和匿名管道一样,命名管道的使用首先得要创建一个命名管道文件。我们用 mkfifo 指令创建命名管道文件,输入指令man mkfifo查看一下 mkfifo 指令的介绍:

我们在写代码的时候需要使用操作系统为我们提供的 mkfifo 接口来创建命名管道:

mkfifo:

形参:
(1)const char *pathname:指定在什么路径下创建命名管道文件
(2)mode_t mode:指定命名管道文件的权限
返回值:如果创建命名管道文件成功,则返回0,否则返回-1

所以,创建一个命名管道:

int main(int argc, char *argv[])
{
 mkfifo("p2", 0644);
 return 0;
}

匿名管道与命名管道的区别

  • 匿名管道由pipe函数创建并打开。
  • 命名管道由mkfifo函数创建,打开用open
  • FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。

三、System V共享内存

System V是一套正常进行通信时的标准,进程间通信的本质是要让不同的进程能够看到同一份资源,共享内存的原理就是在物理内存上创建一个共享内存能让不同的进程都可以访问这块共享内存。每一个进程都有进程地址空间和页表,页表维护的是进程地址空间和物理内存之间的映射关系。共享内存机制的通信方式,首先要在物理内存上创建一块共享内存,然后通过不同进程的页表将这块内存的地址映射到对应进程的共享区中,这样每个进程都能拿到物理内存上的这一块共享内存,也就是不同的进程可以看到同一份资源,从而可以实现共享内存式的进程间通信。

共享内存区是最快的IPC形式。⼀旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递 不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据

我们再来看一下接口函数,注意:共享内存的接口函数使用成本很高,注意辨别。

1.shmget函数

shmget函数是用来创建一个共享内存的,它需要传入三个参数,分别是 key_t key 、 size_t size 、 int shmflg。下面首先介绍一下这三个参数的含义以及使用方法。

1.size_t size:这个参数是用来设置共享内存的空间大小的,这个参数建议设置为页的整数倍,一页的大小是4KB,也就是建议设置成4KB的整数倍。原因是假设我们的内存是4GB的大小,一页的大小是4KB那么4GB的内存就等于1048576页,也就是2^20页,所以操作系统是将内存看作一个一个的页,操作系统会为一个页维护一个数据结构 struct page{} ,然后将这些页组织起来成为一个页数组 struct page mem[2^20],最终操作系统对内存的管理就变成了对页数组的管理。所以我们向物理内存中申请共享内存最好是以页为单位。

2.int shmflg:在物理内存中申请共享内存会有两种情况:如果该共享内存不存在、如果该共享内存存在。shmflg这个参数需要用户传递选项,由用户来规定在创建共享内存时如果该共享内存存在要怎么做,如果该共享内存不存在又要怎么做。

它的常见选项有以下两个:

  1. IPC_CREAT: 创建共享内存时,如果该共享内存已经存在就获取,如果该共享内存不存在就创建。
  2. IPC_EXECL: 这个选项不单独使用,必须和IPC_CREAT配合使用(位图结构,按位或即可配合使用),创建共享内存时,如果该共享内存不存在就创建,如果该共享内存已经存在就出错返回。

IPC_EXECL可以保证如果用shmget函数创建共享内存成功了,那么该共享内存一定是一个全新的共享内存。

3.key_t ket:我们先来看共享内存的数据结构

struct shmid_ds {
 struct ipc_perm shm_perm; /* operation perms */
 int shm_segsz; /* size of segment 
(bytes) */
 __kernel_time_t shm_atime; /* last attach time 
*/
 __kernel_time_t shm_dtime; /* last detach time 
*/
 __kernel_time_t shm_ctime; /* last change time 
*/
 __kernel_ipc_pid_t shm_cpid; /* pid of creator */
 __kernel_ipc_pid_t shm_lpid; /* pid of last operator */
 unsigned short shm_nattch; /* no. of current 
attaches */
 unsigned short shm_unused; /* compatibility */
 void shm_unused2; / ditto - used by 
DIPC */
 void shm_unused3; / unused */
};

在 struct shmid_ds{} 中有一个结构是 struct ipc_perm shm_perm ,是共享内存里与权限相关的信息。我们在再看一下 struct ipc_perm{} 这个结构,该结构里有一个变量 key_t _key ,该变量后面的描述说这是由shmget函数提供的key值。

#include <sys/ipc.h>

struct ipc_perm {
    key_t          __key;    // 核心:共享内存的键值(ftok生成,用户态可见为key)
    uid_t          uid;      // 共享内存所有者的UID(用户ID)
    gid_t          gid;      // 共享内存所有者的GID(组ID)
    uid_t          cuid;     // 共享内存创建者的UID
    gid_t          cgid;     // 共享内存创建者的GID
    unsigned short mode;     // 访问权限(类似文件权限,如0666)
    unsigned short __seq;    // 内核内部序列号(避免shmid重复)
};

这个key值就是shmget函数接口中需要传入的参数 key_t ket ,它标定了共享内存在内核中的唯一值。这个key值是由用户提供的而不是由操作系统生成的,原因是如果key值是由操作系统生成的,那么我们一个进程调用shmget函数以后获取到了这个key值,它是没有办法让其它进程也获得该key值得。由于key值是标识共享内存的唯一性的,所以如果我们想让通信的两个进程看到同一份共享内存,只需要让他们拥有同一个key值即可。

理论上来说key值的设定我们可以自己给值,但需要注意的是不能与操作系统中已有的共享内存的key值起冲突。所以方便起见,操作系统为我们提供了ftok接口来生成key值:我们只需要传递对应的文件路径和项目id(项目id可以自定义设置,一般在0-255之间就够了),它会根据文件路径找到对应的文件,拿到该文件的inode(因为每个文件的inode具有唯一性)和我们传入的项目id进行组合,生成一个具有唯一性的key值。

2.shmctl函数

shmctl是一个控制共享内存的接口,它可以控制删除共享内存(就不用在命令行删除那么麻烦了,可以直接写代码删除共享内存)、设置共享内存属性以及获取共享内存的属性。它需要传递三个参数,指定要操作的共享内存的shmid,int cmd 和 struct shmid_ds * buf 。

  • shmid:由shmget返回的共享内存标识码
  • cmd:将要采取的动作(有三个可取值)
  • buf:指向⼀个保存着共享内存的模式状态和访问权限的数据结构

其中cmd可取:

  1. IPC_STAT:这个命令选项是用来获取共享内存信息的,共享内存的信息保存在 struct shmid_ds {} 这个结构中,shmctl函数的第三个参数 struct shmid_ds *buf 可以用来获取内核中的共享内存的信息。
  2. IPC_SET:这个命令选项是用来设置共享内存信息的,我们可以定义变量 struct shmid_ds *buf 来写入共享内存的信息,再将这个变量通过shmctl函数传递进去设置内核中的共享内存的信息。
  3. IPC_RMID:这个命令选项可以删除共享内存,如果使用这个选项的话,第三个参数可以设置为nullptr。

3.shmat函数和shmdt函数

功能:将共享内存段连接到进程地址空间
原型
 void *shmat(int shmid, const void *shmaddr, int shmflg);
参数
 shmid: 共享内存标识
 shmaddr:指定连接的地址
 shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY
返回值:成功返回⼀个指针,指向共享内存第⼀个节;失败返回-1
  • 注意:
  • shmaddr为NULL,核⼼⾃动选择⼀个地址
  • shmaddr不为NULL且shmflg⽆SHM_RND标记,则以shmaddr为连接地址。
  • shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会⾃动向下调整为SHMLBA的整数 倍。公式:shmaddr - (shmaddr % SHMLBA)
  • shmflg=SHM_RDONLY,表⽰连接操作⽤来只读共享内存

功能:将共享内存段与当前进程脱离
原型
 int shmdt(const void *shmaddr);
参数
 shmaddr: 由shmat所返回的指针
返回值:成功返回0;失败返回-1
注意:将共享内存段与当前进程脱离不等于删除共享内存段

借助管道实现访问控制版的共享内存

共享内存由于本身的特性它是不具有访问控制的,读端进程不会阻塞式地等待写端进程写入数据,即使共享内存为空读端进程也会读取。管道是具有访问控制的,所以我们可以实现一份代码,利用管道的特性让共享内存具有访问控制。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/wait.h>
#include <errno.h>

// 共享内存大小(存储字符串)
#define SHM_SIZE 1024
// 管道控制令牌(用单个字符表示"访问权限")
#define TOKEN '1'

// 管道文件描述符(全局,父子进程共享)
int ctrl_pipe[2];

// 初始化共享内存
int init_shm(key_t key) {
    int shmid = shmget(key, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666);
    if (shmid == -1) {
        // 若共享内存已存在,直接获取
        if (errno == EEXIST) {
            shmid = shmget(key, SHM_SIZE, 0666);
        } else {
            perror("shmget error");
            exit(1);
        }
    }
    return shmid;
}

// 获取共享内存访问权限(从管道读令牌)
void get_access() {
    char token;
    // 阻塞读取令牌(无令牌则等待)
    if (read(ctrl_pipe[0], &token, 1) != 1) {
        perror("read pipe error (get access)");
        exit(1);
    }
    printf("[%d] 获取到共享内存访问权限\n", getpid());
}

// 释放共享内存访问权限(写令牌回管道)
void release_access() {
    char token = TOKEN;
    // 写令牌回管道(释放权限)
    if (write(ctrl_pipe[1], &token, 1) != 1) {
        perror("write pipe error (release access)");
        exit(1);
    }
    printf("[%d] 释放共享内存访问权限\n", getpid());
}

// 写进程逻辑:向共享内存写入数据
void write_process(int shmid) {
    // 挂载共享内存到进程地址空间
    char *shm_addr = (char *)shmat(shmid, NULL, 0);
    if (shm_addr == (char *)-1) {
        perror("shmat error (write)");
        exit(1);
    }

    // 循环写入3次数据(每次先获取权限)
    for (int i = 0; i < 3; i++) {
        // 1. 获取访问权限(管道同步)
        get_access();

        // 2. 操作共享内存(写数据)
        char msg[SHM_SIZE];
        snprintf(msg, SHM_SIZE, "这是第%d次写入的数据(PID:%d)", i+1, getpid());
        strcpy(shm_addr, msg);
        printf("[%d] 写入共享内存:%s\n", getpid(), shm_addr);
        sleep(1); // 模拟写操作耗时

        // 3. 释放访问权限
        release_access();
        sleep(1); // 给读进程留出读取时间
    }

    // 解除共享内存挂载
    if (shmdt(shm_addr) == -1) {
        perror("shmdt error (write)");
        exit(1);
    }
}

// 读进程逻辑:从共享内存读取数据
void read_process(int shmid) {
    // 挂载共享内存到进程地址空间
    char *shm_addr = (char *)shmat(shmid, NULL, 0);
    if (shm_addr == (char *)-1) {
        perror("shmat error (read)");
        exit(1);
    }

    // 循环读取3次数据(每次先获取权限)
    for (int i = 0; i < 3; i++) {
        // 1. 获取访问权限(管道同步)
        get_access();

        // 2. 操作共享内存(读数据)
        printf("[%d] 读取共享内存:%s\n", getpid(), shm_addr);
        sleep(1); // 模拟读操作耗时

        // 3. 释放访问权限
        release_access();
        sleep(1); // 给写进程留出写入时间
    }

    // 解除共享内存挂载
    if (shmdt(shm_addr) == -1) {
        perror("shmdt error (read)");
        exit(1);
    }
}

int main() {
    // 1. 创建控制管道(用于访问权限控制)
    if (pipe(ctrl_pipe) == -1) {
        perror("pipe error");
        exit(1);
    }

    // 2. 初始化管道令牌(先写入1个令牌,表示初始可访问)
    char init_token = TOKEN;
    if (write(ctrl_pipe[1], &init_token, 1) != 1) {
        perror("init pipe token error");
        exit(1);
    }

    // 3. 创建共享内存(key基于当前文件+项目ID生成)
    key_t shm_key = ftok("./shm_pipe_demo.c", 1);
    if (shm_key == -1) {
        perror("ftok error");
        exit(1);
    }
    int shmid = init_shm(shm_key);
    printf("共享内存创建成功,shmid:%d\n", shmid);

    // 4. 创建子进程(读进程)
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork error");
        exit(1);
    } else if (pid == 0) {
        // 子进程:读共享内存
        printf("子进程(PID:%d)启动,准备读取共享内存\n", getpid());
        read_process(shmid);
        exit(0);
    } else {
        // 父进程:写共享内存
        printf("父进程(PID:%d)启动,准备写入共享内存\n", getpid());
        write_process(shmid);

        // 等待子进程退出
        waitpid(pid, NULL, 0);
        printf("子进程已退出\n");

        // 删除共享内存
        if (shmctl(shmid, IPC_RMID, NULL) == -1) {
            perror("shmctl delete error");
            exit(1);
        }
        printf("共享内存已删除\n");
    }

    // 关闭管道
    close(ctrl_pipe[0]);
    close(ctrl_pipe[1]);
    return 0;
}

流程思路如下:

我们这里先创建命名管道文件,父进程作为向共享内存写入的一端,所以当向共享内存写入完毕以后,再向命名管道写入一个信号代表写端进程写入完毕了,子进程作为读端进程在读取共享内存的数据之前,先读取命名管道的信号,由于命名管道是有访问控制的,所以如果写端进程没有发送信号过来,就意味着写端进程还没有向共享内存写入数据,此时读端进程就会阻塞式等待读取命名管道的内容,当读取到信号以后再从共享内存读取信息,这样就可以利用管道的访问控制从而实现共享内存的访问控制。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值