【Linux操作系统】简学深悟启示录:进程间通信

文章目录

  • 1.进程地址空间第二讲
  • 2.进程间通信
    • 2.1 匿名管道
      • 2.1.1 引入
      • 2.1.2 pipe
      • 2.1.3 原理
      • 2.1.4 代码实现
      • 2.1.5 管道属性
      • 2.1.6 管道的应用------进程池
    • 2.2 命名管道
      • 2.2.1 创建管道文件
      • 2.2.2 匿名管道与命名管道的区别
      • 2.2.3 代码实现
    • 2.3 System V 共享内存
      • 2.3.1 共享内存函数
      • 2.3.2 代码实现
  • 希望读者们多多三连支持
  • 小编会继续更新
  • 你们的鼓励就是我前进的动力!

1.进程地址空间第二讲

由于上一篇文章学完动静态库加载,遂对该部分进行知识点补充

在程序还未加载到内存之前,系统会提前在内存以平坦模式分布好,平坦模式就是按照进程地址空间里的分布将代码的机器指令的逻辑地址排布好,逻辑地址本质上就是虚拟地址,只不过在磁盘里是这么称呼而已。当需要执行时会去查找对应可执行程序的 entry 地址(入口)

首先进程 PCB(task_struct)根据 exe 可执行程序找到当前工作的 cwd(工作目录),获取当前的 entry(入口地址),放入 cpu 的 eip 寄存器,也叫 pc(程序计数器),这里面的地址是虚拟地址,虽然 cpu 不知道指令的执行起始地点,但是操作系统知道,他会把 entry 放入 eip,此时在执行时 eip 里会存入下一条指令的虚拟地址,在进程启动时,将 exe 的 entry 写入 eip 一次,之后 eip 的更新完全由 cpu 自动完成,操作系统不再干预,根据顺序执行时 + 指令长度跳转到下一个地址

然后通过进程地址空间找到对应进程的执行位置,简单理解就是 eip 是导航,进程地址空间就是地图,通过进程地址空间还能判断执行的内容是否合法,此时才能去访问实际的物理地址,通常物理内存是按需加载,第一次执行该条指令就会遇到缺页中断,那么此时操作系统才会将磁盘的机器指令加载到物理内存(此时的逻辑地址也就转变成物理地址了),填入对应的页表形成映射,那么就可以根据映射获取到机器指令,返回给 cpu 执行

以此形成一个圈,循环往复就形成了一个执行流程

回到动态库的加载,动态库内的代码逻辑地址不是绝对地址,而是相对地址,因为动态库被加载到固定地址位置是不可能的,不仅麻烦还难映射,相对地址简单来说就是相对动态库起始地址而变化的,这样不管动态库放在共享区哪里,只要知道起始地址都能找到想要的代码,这也是为什么动态库编译时要加上 -fPIC(产生与位置无关码)

2.进程间通信

🤔为什么要进程间通信?

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

2.1 匿名管道

2.1.1 引入

当一个父进程创建一个文件时,自然他的 inode 为属性,内容放在缓冲区里,子进程自然而然会复制父进程的文件描述符表,此时他们看见的是同一份资源,这里要是加以处理就能形成一方读一方写的通信,那么此时就能推出如果想要进行通信就必须让不同进程看见同一份资源

其实 | 就是一种管道原理,这么简单理解就好

那这个共同资源由谁提供呢?我们都知道进程具有独立性,所以任何一个进程来提供,另一方都没有权限访问,最终这个重任还是由操作系统来承担

管道分为匿名管道和命名管道,这里我们介绍的是匿名管道,匿名管道必须有血缘关系,根源在于它没有全局可见的标识(如文件名),只能通过继承文件描述符的方式,让父子 / 兄弟进程共享这根管道,匿名管道设计为单向通信,核心是遵循 "生产者 - 消费者" 模型,并通过内核中固定的 "读端" 和 "写端" 文件描述符实现,强制数据从写端流向读端,无法反向传输

2.1.2 pipe

pipe 是个系统调用,也是个输出型参数,创建的管道是内存级的,最终会返回管道的读写端,所以需要传入一个数组接收读写端的文件描述符,通常 pipefd[0] 为读端, pipefd[1] 为写端

🔥值得注意的是: pipe2 函数可以对管道增加属性

2.1.3 原理

创建好管道并传入数组,子进程继承父进程,父进程关闭读端,子进程关闭写端,就形成了单向通信的管道,所以看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了 Linux 一切皆文件思想

2.1.4 代码实现

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string>
#include <cstring>
#include <cstdlib>
using namespace std;

#define N 2
#define NUM 1024

void Writer(int wfd)
{
    string s = "I am child";
    pid_t self = getpid();
    int number = 0;

    char buffer[NUM];
    while(true)
    {
        sleep(1);
        buffer[0] = 0;
        snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);
        write(wfd, buffer, strlen(buffer));
    }
}

void Reader(int rfd)
{
    char buffer[NUM];
    int cnt = 0;
    while(true)
    {
        buffer[0] = 0; 
        // system call
        ssize_t n = read(rfd, buffer, sizeof(buffer)); //sizeof != strlen
        if(n > 0)
        {
            buffer[n] = 0; // 0 == '\0'
            cout << "father get a message[" << getpid() << "]# " << buffer << endl;
        }
        else if(n == 0) 
        {
            printf("father read file done!\n");
            break;
        }
        else break;

        cnt++;
        if(cnt>5) break;
    }
}

int main()
{
    int pipefd[N] = {0};
    int n = pipe(pipefd);
    if(n < 0)
    {
        return 1;
    }

    pid_t id = fork();
    if(id < 0)
    {
        return 2;
    }
    if(id == 0)
    {
        close(pipefd[0]);
        Writer(pipefd[1]);
        close(pipefd[1]);
        exit(0);
    }
    close(pipefd[1]);
    Reader(pipefd[0]);
    close(pipefd[0]);
    cout << "father close read fd: " << pipefd[0] << endl;
    sleep(5);

    int status = 0;
    pid_t ret = waitpid(id, &status, 0);
    if(ret < 0)
    {
        return 3;
    }

    cout << "wait child success: " << ret << " exit code: " << WEXITSTATUS(status) << " exit signal: " << WTERMSIG(status) << endl; 
    sleep(5);
    cout << "father quit" << endl;
    return 0;
}

程序启动后,父进程创建管道和子进程,子进程关闭读端并每秒向管道写端循环写入带自身 PID 和序号的消息(如"I am child-xxx-0"),父进程关闭写端并从读端读取消息,累计读 5 条后退出读取循环,关闭读端并等待子进程;此时子进程继续写入会因管道无读端触发 SIGPIPE 信号而终止,父进程捕获子进程退出状态(信号 13 ),最终打印信息后退出

2.1.5 管道属性

  1. 对于匿名管道来说,具有血缘关系的进程才能进行进程间通信,因为文件描述符表只有继承下来才能看到管道文件
  2. 管道只能单向通信
  3. 父子进程会进程协同,互斥和同步
  4. 管道是面向字节流的
  5. 管道是基于文件的,而文件的生命周期是随进程的

管道的4种情况:

  1. 读写端正常,管道如果为空,读端就要阻塞
  2. 读写端正常,管道如果被写满,写端就要阻塞
  3. 读端正常读,写端关闭,读端就会读到 0,表明读到了文件(pipe)结尾,不会被阻塞
  4. 写端是正常写入,读端关闭了。操作系统就要杀掉正在写入的进程。如何干掉?通过信号杀掉

操作系统是不会做,低效,浪费等类似的工作的。如果做了,就是操作系统的 bug

2.1.6 管道的应用------进程池

Task.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <vector>

typedef void (*task_t)();

void task1()
{
    std::cout << "lol 刷新日志" << std::endl;
}
void task2()
{
    std::cout << "lol 更新野区,刷新出来野怪" << std::endl;
}
void task3()
{
    std::cout << "lol 检测软件是否更新,如果需要,就提示用户" << std::endl;
}
void task4()
{
    std::cout << "lol 用户释放技能,更新用的血量和蓝量" << std::endl;
}

void LoadTask(std::vector<task_t> *tasks)
{
    tasks->push_back(task1);
    tasks->push_back(task2);
    tasks->push_back(task3);
    tasks->push_back(task4);
}

ProcessPool.cc

cpp 复制代码
#include "Task.hpp"
#include <string>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/wait.h>

const int processnum = 10;
std::vector<task_t> tasks;

class channel
{
public:
    channel(int cmdfd, int slaverid, const std::string &processname)
    :_cmdfd(cmdfd)
    ,_slaverid(slaverid)
    ,_processname(processname)
    {}

    int _cmdfd;
    pid_t _slaverid;
    std::string _processname;
};

void slaver()
{
    while(true)
    {
        int cmdcode = 0;
        int n = read(0, &cmdcode, sizeof(int));
        if(n == sizeof(int))
        {
            std::cout << "slaver say@ get a command: " << getpid() << " : cmdcode: " << cmdcode << std::endl;
            if(cmdcode >= 0 && cmdcode < tasks.size()) 
            {
                tasks[cmdcode]();
            }
        }
        if(n == 0)
        {
            break;
        }
    }
}

// 输入:const &
// 输出:*
// 输入输出:&

void InitProcessPool(std::vector<channel> *channels)
{
    std::vector<int> oldfds;
    for(int i = 0; i < processnum; ++i)
    {
        int pipefd[2] = {0};
        int n = pipe(pipefd);
        if(n < 0)
        {
            exit(1);
        }

        pid_t id = fork();
        if(id < 0)
        {
            exit(2);
        }
        if(id == 0)
        {
            std::cout << "child: " << getpid() << " close history fd: ";
            for(auto fd : oldfds) 
            {
                std::cout << fd << " ";
                close(fd);
            }
            std::cout << std::endl;
            close(pipefd[1]);
            dup2(pipefd[0], 0);
            close(pipefd[0]);
            slaver();
            std::cout << "process : " << getpid() << " quit" << std::endl;
            exit(0);
        }
        close(pipefd[0]);

        std::string name = "process-" + std::to_string(i);
        channels->push_back(channel(pipefd[1], id, name));
        oldfds.push_back(pipefd[1]);
        
        sleep(1);
    }
}

void Menu()
{
    std::cout << "################################################" << std::endl;
    std::cout << "# 1. 刷新日志             2. 刷新出来野怪        #" << std::endl;
    std::cout << "# 3. 检测软件是否更新      4. 更新用的血量和蓝量  #" << std::endl;
    std::cout << "#                         0. 退出               #" << std::endl;
    std::cout << "#################################################" << std::endl;
}

void ctrlSlaver(const std::vector<channel> &channels)
{
    int which = 0;
    while(true)
    {
        int select = 0;
        Menu();
        std::cout << "Please Enter@";
        std::cin >> select;
        if(select <= 0 || select >= 5)
        {
            break;
        }
        int cmdcode = select - 1;
        std::cout << "father say: " << " cmdcode: " <<
        cmdcode << " already sendto " << channels[which]._slaverid << " process name: " 
        << channels[which]._processname << std::endl;
        write(channels[which]._cmdfd, &cmdcode, sizeof(cmdcode));
        which++;
        which %= channels.size();
        sleep(1);
    }
}

void QuitProcess(const std::vector<channel> &channels)
{
    for(const auto &c : channels)
    {
        close(c._cmdfd);
        waitpid(c._slaverid, nullptr, 0);
    }
}

int main()
{
    LoadTask(&tasks);

    std::vector<channel> channels;

    InitProcessPool(&channels);

    ctrlSlaver(channels);

    QuitProcess(channels);
    return 0;
}
  1. LoadTask 设置 tasks 指针数组,设置对应的任务
  2. channels 是元素为 channel 管道的 vector 数组,_cmdfd 表示文件描述符、_slaverid 表示管道 pid、_processname 表示进程序号名
  3. InitProcessPool 初始化 channel,创建 10 个子进程设置读写端并优化其文件描述符表,子进程进入读端时刻准备读内容
  4. ctrlSlaver 挑选内容进行写入,按顺序写入每一个子进程
  5. QuitProcess 清理管道,回收子进程

🔥值得注意的是:

  • 读端只有 3 号文件描述符一个,当 3、4 为读写端时,子进程继承关掉 4,剩下 3 作为读端父进程关掉 3,剩下 4 作为写端;同理,那么下一次就是 3、5 作为读写端,父进程关掉 3,剩下 5 作为写端,子进程 3 依旧为读端,依次往下推即可
  • dup2(pipefd[0], 0) 将本来要从键盘读取的,重定向为从 pipefd[0] 读取
  • 子进程每次都会把父进程的文件描述符表给继承下来,所以新的子进程可能也会指向之前的管道,如果是一次性 close 所有的文件再 waitpid 倒是没什么问题,这样能够确保管道的写端能够被全部被关闭,进而子进程也能被关掉,但是如果关一个回收一个子进程就会有问题,此时管道的一个与父进程对应的写端虽然关掉了,但是还有别的子进程写端指向该管道,这会导致该子进程无法关闭,也就无法回收。可以使用倒着关闭回收的方法,但是这有点麻烦,不如在初始化阶段就处理好, InitProcessPool 中的 vector< int > oldfds 用于存储已经创建的写端,当创建新的写端时,就可以依次把继承下来的旧的写端全部关闭

2.2 命名管道

命名管道和匿名管道其实差不多,只不过管道文件有名字,就不需要通过血缘关系获得文件描述符表了

2.2.1 创建管道文件

cpp 复制代码
int mkfifo(const char *filename,mode_t mode);

mkfifo 用于创建命名管道,filename 表示创建的文件名,mode 表示文件权限一般为 0664,创建之后正常用 open、write、read 等当文件使用即可

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

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

2.2.3 代码实现

comm.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>

#define FIFO_FILE "./myfifo"
#define MODE 0664

enum
{
    FIFO_CREATE_ERR = 1,
    FIFO_DELETE_ERR,
    FIFO_OPEN_ERR
};

class Init
{
public:
    Init()
    {
        // 创建管道
        int n = mkfifo(FIFO_FILE, MODE);
        if (n == -1)
        {
            perror("mkfifo");
            exit(FIFO_CREATE_ERR);
        }
    }
    ~Init()
    {

        int m = unlink(FIFO_FILE);
        if (m == -1)
        {
            perror("unlink");
            exit(FIFO_DELETE_ERR);
        }
    }
};

client.cc

cpp 复制代码
#include <iostream>
#include "comm.hpp"

using namespace std;

int main()
{
    int fd = open(FIFO_FILE, O_WRONLY);
    if(fd < 0)
    {
        perror("open");
        exit(FIFO_OPEN_ERR);
    }

    cout << "client open file done" << endl;

    string line;
    while(true)
    {
        cout << "Please Enter@ ";
        getline(cin, line);

        write(fd, line.c_str(), line.size());
    }

    close(fd);
    return 0;
}

server.cc

cpp 复制代码
#include "comm.hpp"

using namespace std;

// 管理管道文件
int main()
{
    // 打开管道
    int fd = open(FIFO_FILE, O_RDONLY); // 等待写入方打开之后,自己才会打开文件,向后执行, open 阻塞了!
    if (fd < 0)
    {
        exit(FIFO_OPEN_ERR);
    }

    

    // 开始通信
    while (true)
    {
        char buffer[1024] = {0};
        int x = read(fd, buffer, sizeof(buffer));
        if (x > 0)
        {
            buffer[x] = 0;
            cout << "client say# " << buffer << endl;
        }
        else if (x == 0)
        {
            break;
        }
        else
            break;
    }

    close(fd);
    return 0;
}

2.3 System V 共享内存

System V 包括信号量、消息队列、共享内存,这里只讲共享内存,另外两个后续再补充

共享内存: 多个程序(进程)可以同时读写的一块公共内存,目的是快速交换数据,比如 A 进程把数据丢进去,B 进程直接取,不用传文件

共享内存相比管道的核心优点是速度极快,这源于二者数据传输的本质差异:共享内存是 直接读写 ,管道是拷贝传递

2.3.1 共享内存函数

✏️shmget

cpp 复制代码
功能:⽤来创建共享内存

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

参数
key:这个共享内存段id
size:共享内存⼤⼩
shmflg:由九个权限标志构成,它们的⽤法和创建⽂件时使⽤的mode模式标志是⼀样的
取值为IPC_CREAT:共享内存不存在,创建并返回;共享内存已存在,获取并返回。
取值为IPC_CREAT | IPC_EXCL:共享内存不存在,创建并返回;共享内存已存在,
出错返回。

返回值:成功返回⼀个⾮负整数,即该共享内存段的标识码shmid;失败返回-1

key 是一个供进程识别的 id,进程都可以根据这个 id 找到该共享内存,需要使用 key_t ftok(const char *pathname, int proj_id) 函数获取 key 值,pathname 指向一个已存在且可访问的文件路径,必须是真实存在的文件,不能是目录;进程需有该文件的读权限 proj_id 项目标识符,通常取 0-255 的整数,这两个参数通过特定计算方式就能生成一个 key 值

谈谈key:

  1. key 是一个数字,这个数字是几,不重要。关键在于它必须在内核中具有唯一性,能够让不同的进程进行唯一性标识
  2. 第一个进程可以通过 key 创建共享内存,第二个之后的进程,只要拿着同一个 key 就可以和第一个进程看到同一个共享内存了!
  3. 对于一个已经创建好的共享内存,key 在哪?key 在共享内存的描述对象中!
  4. 第一次创建的时候,必须有一个 key 了
  5. key 和 shmid 的区别在于,key 相当于共享内存地址,shmind 相当于共享内存使用权

✏️shmat

cpp 复制代码
功能:将共享内存段连接到进程地址空间

原型
void *shmat(int shmid, const void *shmaddr, int shmflg);

参数
shmid: 共享内存标识
shmaddr:指定连接的地址
shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY

返回值:成功返回⼀个指针,指向共享内存初始地址;失败返回-1

shmaddr

指定共享内存附加到进程地址空间的起始地址:

  • 若为 NULL:由系统自动选择合适的地址(推荐使用)
  • 若为非 NULL:需结合 shmflg 中的 SHM_RND 标志使用(见下文)

shmflg

附加方式标志,常用取值:

  • 0:默认方式,可读可写(需共享内存本身允许)
  • SHM_RDONLY:以只读方式附加(需共享内存权限允许)
  • SHM_RND:若 shmaddr 非 NULL,则将 shmaddr 向下取整为共享内存页大小的整数倍(仅与非 NULL 的 shmaddr 配合使用)

✏️shmdt

cpp 复制代码
功能:将共享内存段与当前进程脱离

原型
int shmdt(const void *shmaddr);

参数
shmaddr: 由shmat所返回的指针

返回值:成功返回0;失败返回-1
注意:将共享内存段与当前进程脱离不等于删除共享内存段

与 shmat 相反,用于断开共享内存与进程的链接,但是不代表释放共享内存

✏️shmclt

cpp 复制代码
功能:⽤于控制共享内存

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

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

返回值:成功返回0;失败返回-1

对于 cmd 参数有三个取值:

IPC_STAT 通常用于查看共享内存属性,IPC_RMID 用于释放共享内存通常都要在最后使用 shmctl(shmid, IPC_RMID, nullptr)

内置了一个结构体 shmid_ds,可以创建结构体对象,传入 shmctl 的第三个参数,用于查看共享内存属性

🔥值得注意的是:

ipcs -m 用于查看共享内存,ipcrm -m <shmid> 用于手动删除释放共享内存

2.3.2 代码实现

comm.hpp

cpp 复制代码
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/types.h>
#include <sys/stat.h>

using namespace std;

const int size = 4096;
const string pathname = "/home/zzh";
const int proj_id = 0x666;

key_t GetKey()
{
    key_t k = ftok(pathname.c_str(), proj_id);
    if(k < 0)
    {
        exit(1);
    }
    return k;
}

int GetShareMemHelper(int flag)
{
    key_t k = GetKey();
    int shmid = shmget(k, size, flag);
    if(shmid < 0)
    {
        exit(2);
    }

    return shmid;
}

int CreateShm()
{
    return GetShareMemHelper(IPC_CREAT | IPC_EXCL | 0666);
}

int GetShm()
{
    return GetShareMemHelper(IPC_CREAT); 
}

processa.cc

cpp 复制代码
#include "comm.hpp"


int main()
{
    int shmid = CreateShm();
    char *shmaddr = (char*)shmat(shmid, nullptr, 0);

    // ipc code 在这里!!
    // 一旦有人把数据写入到共享内存,其实我们立马能看到了!!
    // 不需要经过系统调用,直接就能看到数据了!
    struct shmid_ds shmds;
    while(true)
    {
        cout << "client say@ " << shmaddr << endl; //直接访问共享内存
        sleep(1);

        shmctl(shmid, IPC_STAT, &shmds);
        cout << "shm size: " << shmds.shm_segsz << endl;
        cout << "shm nattch: " << shmds.shm_nattch << endl;
        printf("shm key: 0x%x\n",  shmds.shm_perm.__key);
        cout << "shm mode: " << shmds.shm_perm.mode << endl;
    }

    shmdt(shmaddr);
    shmctl(shmid, IPC_RMID, nullptr);
    return 0;
}

processb.cc

cpp 复制代码
#include "comm.hpp"

int main()
{
    int shmid = GetShm();
    char *shmaddr = (char*)shmat(shmid, nullptr, 0);
    // 一旦有了共享内存,挂接到自己的地址空间中,你直接把他当成你的内存空间来用即可!
    // 不需要调用系统调用
    // ipc code
    while(true)
    {
        cout << "Please Enter@ ";
        fgets(shmaddr, 4096, stdin);
    }

    shmdt(shmaddr);
    return 0;
}

希望读者们多多三连支持

小编会继续更新

你们的鼓励就是我前进的动力!

相关推荐
墨家前任巨子7 分钟前
定时关机脚本
运维
云计算练习生7 分钟前
什么是上下文切换?进程切换时 CPU 到底在保存什么
linux·服务器·操作系统·进程切换
用户0510122572968 分钟前
RGA(二)——RK3588 的核拓扑与能力差异
linux·嵌入式
ESDWAN9 分钟前
企业组网后网络频繁掉线,常见故障排查指南
运维·网络·架构
用户05101225729610 分钟前
RGA(一)——基础知识
linux·嵌入式
qetfw13 分钟前
Debian 综合服务实训:Apache2、Samba、LDAP、vsftpd 与 OpenVPN
linux·服务器·debian
用户05101225729614 分钟前
RGA(三)——实宽高、虚宽高与对齐约束
linux·嵌入式
fastjson_24 分钟前
帆软看板 - 问题收集
linux·前端·javascript
jianpeng的工程笔记27 分钟前
EL8 / EL9 安装 Slurm 26.05.4:主备控制器、MariaDB 记账与作业验收
linux·数据库·mariadb·高性能计算·slurm
程序员-Benothing30 分钟前
Linux系统时间管理:date、timedatectl、NTP、Chrony配置
linux·运维·服务器