Linux:进程间通信

文章目录

  • 前言
  • 1.进程间通信介绍
    • [1.1 目的](#1.1 目的)
    • [1.2 发展](#1.2 发展)
    • [1.3 分类](#1.3 分类)
  • 2.管道
    • [2.1 匿名管道](#2.1 匿名管道)
    • [2.2 命名管道](#2.2 命名管道)
    • [2.3 共享内存](#2.3 共享内存)
    • [2.4 信号量](#2.4 信号量)
    • [2.5 IPC](#2.5 IPC)
  • 总结

前言

1.进程间通信介绍

1.1 目的

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

前面其实我们也接触过进程间通信,那就是 | 这个符号。

进程间通信的本质是:让不同的进程先看到同一份资源。

1.2 发展

  • 管道
  • System V进程间通信
  • POSIX进程间通信

1.3 分类

管道:

  • 匿名管道pipe
  • 命名管道

System V IPC:

  • System V 消息队列
  • System V 共享内存
  • System V 信号量

POSIX IPC:

  • 消息队列
  • 共享内存
  • 信号量
  • 互斥量
  • 条件变量
  • 读写锁

2.管道

什么是管道?

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

2.1 匿名管道

#include <unistd.h>

功能:创建一无名管道

原型

int pipe(int fd[2]);

参数

fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端

返回值:成功返回0,失败返回错误代码

管道是只能单向通信的,也就是说一个文件只能读,另一个文件只能写。想要情景再现也很简单,让一个文件即以读的方式打开,也以写的方式打开。

那么问题来了,如果一个文件即以读打开,也以写打开,那么它的数据到底有几份呢???

光看概念没啥意思,我们直接上图看事例:

对于同一份文件同时读写,其实数据是只有一份的,只是会有两个struct file结构体,一个管理读,一个管理写。进程通信都是两个不同的进程进行通信的,现在肯定不行,我们可以通过fork一个子进程。

struct file_struct 还是属于进程的部分,再往后的 struct file 就是文件系统的部分了,所以fork子进程会有自己的struct file_struct,同时与父进程共享 struct file。

这种情况下,父进程关闭读端只留下写端,子进程与之相反,关闭写端只留下读端。那么就有问题了,关闭之后,按理说所对应的 struct file 也应该关闭,但是子进程还要用呢,这要怎么办呢?其实在struct file 中是有一个整形变量的,用来作引用计数,每有一个文件指向它,它的计数就加一,只有当计数为0时,才会真正的关闭 struct file。因此父进程关闭读端,它指向的struct file 并不会关闭,并不会影响子进程,子进程会依旧指向这个struct file。

这样就是一个通信过程了,父进程只负责向文件缓冲区中写数据,子进程只负责向文件缓冲区中读数据。而实现两个不同进程之间的进程通信。

再拿代码检验一下:

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

#define MAX 1024
int main()
{
    int pipefd[2] = {0};
    int n  = pipe(pipefd);
    assert(n == 0);

    cout << "pipefd[0]: " << pipefd[0] << ",pipefd[1]: " << pipefd[1] << endl;

    pid_t id = fork();
    if(id < 0)
    {
        perror("fork");
    }
    
    //字写,父读
    if(id == 0)
    {
        //子进程
        close(pipefd[0]);
        int cnt = 10;
        while(cnt)
        {
            char message[MAX];
            snprintf(message,sizeof(message),"hello father ! ! !  I am a child process, pid: %d, cnt: %d",getpid(),cnt--);
            write(pipefd[1],message,strlen(message));
            sleep(1);
        }
        exit(0);
    }
    //父进程
    close(pipefd[1]);
    char buffer[MAX];

    while(1)
    {
        ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1);
        if(n > 0)
        {
            buffer[n] = '\0';
            cout << "child say: " << buffer << " to me !" << endl;
        }
    }

    pid_t rid = waitpid(id,nullptr,0);
    if(rid == id)
    {
        cout << "wait success" << endl;
    }
    return 0; 
}

这样就可以看到子进程写入,父进程进行读取的现象。在这里我们是写了一个进程池,大概原理就是提前创建多个进程和管道,通过向管道中输入不同的内容,让子进程执行不同的内容,这部份代码较多,我就放到文章最后面了,有兴趣的小伙伴可以自己尝试理解。

匿名管道的特性:

  1. 匿名管道库可以允许具有 " 血缘关系 " 的进程之间的进程通信,常用于父子。如果是两个不同的进程是无法使用匿名管道进行通信的。
  2. 匿名管道,默认要给读写段提供同步机制。(参考匿名管道的第一个情况,一个写入了,另一个才能读取,而不是你写你的,我读我的。后面多线程会讲)
  3. 面向字节流。意思就是读和写并没有什么关系,写可以一次写1000字节的数据,读可以一次读一个字节,也可以一次全读,它们之间并没有什么关系。(后面的网络部分会讲)
  4. 管道的生命周期是随进程的。
  5. 管道是单向通信的,半双工通信的一种特殊情况。(一个说一个听,就是半双工。全双工是指一个在说的同时还在听,另一个也是,就比如两个人在互相问候对方时,就是全双工。)

匿名管道的四种情况:

  1. 当子进程写入比较慢时,也就是子进程写入一个数据后休眠100s,父进程读取完数据后,管道里此时是没有数据了,但是子进程还在休眠没有进行写入数据,此时父进程必须等待,直到有数据为止。(可以将上面子进程代码中的休眠时间改为100s来复现场景)
  2. 如果管道被写满 了,写端必须等待,直到有空间为止。(子进程死循环一直写,父进程休眠不读,会发现写上一部分就不写了)通过指令可以查看管道的大侠。
  3. 写端关闭,读端一直读,读端就会读到read的返回值为0,表示读到文件的结尾,通常用它结束父进程读端的读取。
  4. 读端关闭,写端一直写。OS会直接杀掉写端进程,通过向目标进程发送SIGPIPE(13)信号,终止目标进程。

当没有数据可读时

  • O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
  • O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。

当管道满的时候

  • O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
  • O_NONBLOCK enable:调用返回-1,errno值为EAGAIN

如果所有管道写端对应的文件描述符被关闭,则read返回0

如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出

当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。

当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。

2.2 命名管道

具有" 血缘关系 " 的进程才能使用匿名管道进行通信,那么如何让两个毫不相干的进程进行通信的呢?

我们知道想进行进程通信,必须先让两个进程看到同一份资源,那么两个不同的进程是如何看到同一份资源的呢?路径,因为路径是唯一的,所以我们可以通过路径+文件名的方式,来让不同进程看到同一份资源。

其原理何匿名管道相差无几,我们来通过代码来看:

cpp 复制代码
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<cerrno>
#include<cstring>
#include<fcntl.h>
#include<unistd.h>
#include"comm.h"
using namespace std;

bool Makefifo()
{
     //1.创建一个管道文件
    int n = mkfifo(FILENAME,0666);
    if(n < 0)
    {
        cerr << "errno: " << errno << ", errstring: " << strerror(errno) << endl;
        return false;
    }
    cout << "mkfifo success..." << endl;
    return true;
}

int main()
{
Start:
    //2.以读的方式打开管道文件
    int rfd = open(FILENAME, O_RDONLY);
    if(rfd < 0)
    {
        cerr << "errno: " << errno << ", errstring: " << strerror(errno) << endl;
        if(Makefifo()) goto Start;
        else return 1;
    }
    cout << "open fifo success..." << endl;

    //3.读取管道文件中的数据
    char buffer[1024];
    while(true)
    {
        ssize_t s = read(rfd, buffer, sizeof(buffer) - 1);
        if(s > 0)
        {
            buffer[s] = 0;
            cout << "Client say: " << buffer << endl;
        }
        else if(s == 0)
        {
            cout << "client quit, server quit too!" << endl;
            break;
        }
    }
    cout << "read fifo success..." << endl;

    //4.关闭管道文件
    close(rfd);
    cout << "close success...";
    return 0;
}
cpp 复制代码
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<cerrno>
#include<cstring>
#include<fcntl.h>
#include<unistd.h>
#include"comm.h"

using namespace std;

int main()
{
    int wfd = open(FILENAME,O_WRONLY);
    if(wfd < 0)
    {
        cerr << "errno: " << errno << ", errstring: " << strerror(errno) << endl;
        return 1;
    }

    string message;
    while(true)
    {
        cout << "Please enter: ";
        getline(cin, message);

        ssize_t s = write(wfd, message.c_str(), message.size());
        if(s < 0)
        {
            cerr << "errno: " << errno << ", errstring: " << strerror(errno) << endl;
            break;
        }
    }

    close(wfd);
    return 0;
}
cpp 复制代码
#pragma once

#define FILENAME ".fifo"

这部分更多的还是使用文件系统中的内容,只是多加一个新的系统调用,mkfifo。

它的作用就是创建一个管道文件,用来辅助两个不同进程之间的通信。代码也没有什么复杂的地方,这里就不多解释了。

2.3 共享内存

上面讲的是一种通信方式,是通过复用文件方面的知识来实现的,在操作系统内容,其实也规定了一种通信方式,那就是system V,它只是一个统称,意思就是系统种的通信方式,它包含共享内存、信号量、消息队列等等,我们先来看共享内存。

共享内存是进程地址空间中属于共享区的一部分,我们来看图解,比较清晰明了:

要进行通信的第一要点,就是先让不同的进程看到同一份资源,而共享内存就是以上面的这种方式来实现的。

补充:我们平时开辟空间时,并不是直接在物理内存上开辟,而是先在虚拟地址上划分一块空间,用页表映射起来,当写入数据的时候才会通过页表来判断是否在物理空间中开辟了空间,如果没有开辟,再发生缺页中断,开辟空间。而释放空间,其实也就是将页表中的映射关系删除掉,让它无法访问到物理地址就可以了。在计算机中,删除其实并不是将数据真的也删除了,而是你没有权限进行访问了,也就是数据其实还在,但是你无法访问了,等到下一次使用的时候直接将里面不用的数据直接覆盖掉就好了。删除并不是我们想的那样将数据清零,仅仅只是无法访问了,所以才会有数据恢复技术。

对于共享内存,是先在物理空间中开辟好一块空间,再用页表建立映射。

OS中有那么多的进程,难度是只有一块共享内存吗?肯定不是的,所以OS一定会允许系统中同时存在多个共享内存。那么这么多的共享内存,必然是要管理起来的,怎么管理呢?先描述再组织。所以在内存中肯定是存在一个管理共享内存的struct结构体。

那么问题就来了,有那么多的共享内存,如果保证两个进程看到是同一份共享内存呢???要知道进程之间是具有独立性的噢。

共享内存必须要有唯一的标识,就像用进程pid区分进程类似。但是还有问题,怎么给另一个进程呢?一会我们来通过代码来看。

我们先来看几个系统调用接口:

创建共享内存:

这是创建一个共享内存的接口,其中的key就是用来标识一个共享内存的,两个进程通过确认管理共享内存struct结构体中的key是否一致,就可以区分这两个进程是否是使用了同一个共享内存。这个数字可以随便给,但是非常容易与系统中其他共享内存中的标识数重合,因此一般采用ftok函数来随机取一个数字。

参数一个是路径,另一个是随机一个数字,路径就是字符串,也是数据,它会在内部通过一定的算法将两个参数整合成一个值进行返回。这样可以大大降低共享内存的标识数出现重复的概率。
  通过这样的方式,两个不同的进程就可以在众多共享内存中,找到独属于他们俩个的那一个共享内存。

这个接口的返回值是int,它采用的是一种映射关系,与文件的fd相似,可以通过判断返回值来进行一系列的操作。

size就是创建多大的共享内存。(注意:共享内存的大小强烈建议为4096的整数倍,因为在分配的时候都是以4096为单位分配的,如果是4097,还是会给你分配4096*2 的大小,但是你只能用4097)

shmflg 与打开文件的传参形式差不多,就是通过传入许多选择再进行异或。

IPC_CREAT表示如果共享内存不存在就创建,存在就获取。如何判断存在不存在呢?那就是前面说的共享内存的标识数key了,如果key存在就表示存在,不存在就创建,然后将key给了管理共享内存的struct结构体。

IPC_EXCL不单独使用,一般都是和IPC_CREAT一起使用(IPC_CREAT | IPC_EXCL),表示如果存在就获取,如果不存在就出错返回。(可以保证创建的共享内存是全新的)
  我们可以通过ipcs -m 指令来查看我们当前的共享内存,我们输出的shmid是0,查看的也是0,说明这个就是我们创建出来的一个共享内存。注意:它和文件不一样,文件的生命周期是随进程的,进程退出文件就会被释放,但是共享内存是需要被手动释放的,它的生命周期是随内核的。

key 和 shmid 的区别:

  • key是被放进struct结构体的,是在内核中的,所以它不会在应用层被使用,只在内核中标识共享内存的唯一性。
  • shmid 是返回给外部的,我们在应用共享内存的时候,就是通过shmid来操作共享内存的。

为什么要有这个key呢?OS在内部不会自己生成一个吗?为什么要由我们用户来传递呢?

通信那肯定是两个进程,OS生成一个key给了一个进程,这个过程不难,但是如何让第二个进程拿到相同的key呢?我们知道这个key是用来标识共享内存的,是需要被存进结构体中的。OS通知第二个进程来与共享内存链接,第二个进程过来探个脑袋,发现在OS中有一堆共享内存,那么它怎么才知道那个是属于自己的呢?所以才需要由用户来传递,这样更方便两个进程找到同一个共享内存。

所以在删除共享内存的时候,是通过shmid来删除的。通过ipcrm -m shmid 来删除。

而在Linux下一切皆文件,所以在创建时也可以带上权限:

这个接口是解决如何将两个进程和同一块共享内存相联系的问题 (shm是共享内存的意思,at ->attach的意思):  后两个参数不用考虑,设置为nullptr和0就可以了,前面的就是shmid。返回值:成功返回该共享内存的起始地址,失败返回 -1。

由于不是动态的,不太好展示,其实nattch的意思是目前有几个进程与整个共享内存相挂接。

上面那个是进程退出了它的nattch才会减一,也就是引用计数减一,那么我在进程没有退出时就想让它进行减一,这就需要下面这个接口了:

它的参数就是上面那个函数返回的,共享内存的起始地址。

共享内存的生命周期是随内核的,前面我的是通过指令删除的,如何在代码中删除呢?

第三个参数暂时不说,它其实就是一个结构体,是内核中描述共享内存的结构体的一个子集,可以通过它来获得或者修改共享内存中的一些属性。

第二个参数就是上图中的一些选项,其中IPC_RMID就是用来删除共享内存的。

共享内存的通信方式,是没有同步机制的。也就是说你干你的,我干我的,就是说哪一个进程是读还是写,完全由用户自己管理,即使写端还没写完数据,读端也可以读。(现象:匿名管道有同步机制,写端没有写入数据时,读端必须等段。共享内存没有同步机制,你写你的,我读我的。现象区别:匿名管道对于写入的数据只会读取一次,共享内存会读取多次)

那么共享内存需要有同步机制呢?是需要的,那么如何完成呢?可以通过我们前面学的命名管道,如果写端不写,读端就必须等待这一特性来完成同步机制。

共享内存的特点:

  1. 共享内存的通信方式,不会提供同步机制,共享内存是直接裸露给使用者的,一定要注意使用安全的问题。
  2. 共享内存是所有进程通信中,速度最快的。
  3. 共享内存可以提供比较大的空间。

2.4 信号量

信号量主要用于同步和互斥的,下面先来看看什么是同步和互斥。

进程互斥:

  • 由于各进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系为进程的互斥
  • 系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
  • 在进程中涉及到互斥资源的程序段叫临界区。
  • 维护临界资源,其实就是维护临界区。
  • 特性方面:IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核

同步:

  • 多个执行流执行时,按照一定的顺序执行,参考管道。

信号量的本质是计数器,是用来保护共享资源的。

多个执行流看到同一份资源,公共资源 ---------> 并发访问,我写你读,我读你写,我写你也写,而当我没写完时,你读取的数据是无效的,这就可能会导致数据不一致问题,比如我写了hello,你只读了he,就会出问题。所以就需要将资源保护起来 --------> 互斥和同步。就比如上面学习的共享内存就没有同步机制,需要用户自己保护,否则就是出现读取数据不一致的现象。

如何理解信号量?

我们以电影院举例,我们去看电影,是买了票那个位置就属于你了,还是当你坐到那个位置,才属于你呢?明显是买了票那个位置就属于你了。而在其中,电影院中的座位,就是多个人共享的资源,就是公共资源。所以在电影院买票的本质,就是对资源的预定!!我预定了,接下来就只有我能使用这个资源(座位),其他人不能使用。

每一个座位都是一份共享资源,如何才能确保共享资源不会被超出范围使用呢?也就是只有100座位,如何确保不会有101个人去使用?这就只需要一个计数器count = 100;每有一个座位被卖出,count就减一。只要减一后的count合法,就说明你成功的预定了,你就可以使用该资源了,反之亦然。

这种用来维护公共资源的数量的计数器,就叫做信号量!!!信号量就是表示资源数目的计数器,每一个执行流想访问公共资源内的某一份资源,不应该让执行流直接访问,而是先申请信号量资源,其实就是对

信号量计数器进行减一操作,本质上只要减一操作成功,就是完成了对资源的预定。

也就是说,信号量就像是一道门槛,只有通过了信号量的检测,才能去公共资源中使用资源,从而达到保护资源的目的。

如果申请失败,执行流就会被挂起阻塞。

而当共享资源中只有一份资源,也就是 count = 1只能提供一个人使用,那么它就带有互斥功能。它也叫做二元信号量,也就是互斥锁,能够完成互斥功能。

那么再来看看细节问题:

申请资源就需要先访问信号量,那么也就是多个进程需要访问到同一个信号量资源。所以信号量必然是需要由OS系统来提供的,它是被OS纳入了进程的IPC结构中去了。

信号量本质也是公共资源。那么问题就来了,我们本来就是要访问共享资源才来访问信号量的,结果你自己也变成了共享资源,这不搞笑吗???其实并不是的,这是规定了信号量的访问是原子的。原子的意思就是成功或者不做,对于信号量--的操作要么成功了,要么不申请,并不会出现其他情况。也就是说,只要访问了信号量要申请资源,一定会使count- -,只不过如果count为 0 的话,会将执行流挂起。这里的--操作就是P操作,++操作就是V操作。

2.5 IPC

内核是如何看待IPC的:

  1. 它是单独设计的。
  2. 理清楚IPC资源在内核中的管理方式。

总结

本章主要将了进程间通信的几种方法和过程,有个进程间通信的基础知识,相信大家对于系统的工作能有一个更加清楚的认识。

如果大家发现有什么错误的地方,可以私信或者评论区指出喔。我会继续深入学习Linux,希望能与大家共同进步,那么本期就到此结束,让我们下期再见!!觉得不错可以点个赞以示鼓励!!

相关推荐
量子网络1 分钟前
debian 如何进入root
linux·服务器·debian
我们的五年9 分钟前
【Linux课程学习】:进程描述---PCB(Process Control Block)
linux·运维·c++
我言秋日胜春朝★1 小时前
【Linux】进程地址空间
linux·运维·服务器
C-cat.1 小时前
Linux|环境变量
linux·运维·服务器
yunfanleo2 小时前
docker run m3e 配置网络,自动重启,GPU等 配置渠道要点
linux·运维·docker
糖豆豆今天也要努力鸭2 小时前
torch.__version__的torch版本和conda list的torch版本不一致
linux·pytorch·python·深度学习·conda·torch
烦躁的大鼻嘎2 小时前
【Linux】深入理解GCC/G++编译流程及库文件管理
linux·运维·服务器
ac.char2 小时前
在 Ubuntu 上安装 Yarn 环境
linux·运维·服务器·ubuntu
敲上瘾2 小时前
操作系统的理解
linux·运维·服务器·c++·大模型·操作系统·aigc
长弓聊编程3 小时前
Linux系统使用valgrind分析C++程序内存资源使用情况
linux·c++