Bootstrap

Linux学习笔记——进程间通信

进程间通信介绍

进程间通信的目的

    单进程无法使用并发的能力,更无法实现多进程协同工作,所以进程间通信时一种手段,目的是要实现多进程协同。


进程间通信的技术背景

    进程运行具有独立性,采用虚拟地址空间加页表的方式来保证进程运行的独立性(包含进程内核数据结构和进程的代码数据),所以进程通信的成本会比较高。


进程间通信的本质

    进程间通信简称IPC,进程间通信的前提是让不同的进程看到同一块“内存”。这个“内存”资源不能属于任何一个进程,而是共享的。


进程间通信的一些标准

  1. Linux原生提供的管道
  2. System V 标准 ——多进程——单机通信
  3. POSIX 标准——多线程——网络通信

    标准在使用者看来,都是接口上具有一定的规律。


进程间通信意义

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

进程间通信分类

  1. 管道

    匿名管道pipe
    命名管道

  2. System V进程间通信

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

  3. POSIX进程间通信
    消息队列
    共享内存
    信号量
    互斥量
    条件变量
    读写锁


管道

管道的概念

    管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。管道是单向传输内容的,且管道传输的都是资源是“数据”。

在这里插入图片描述
    一般在linux命令中|(管道)之前的命令会输出大量的结果,|(管道)之后的命令一般就是带有条件的,只将|前满足条件的结果显示出来。举例:who | wc -l 就是把前一个命令的结果当成后一个命令的输入。结合本例就是先显示所有用户,然后再用wc命令在who的结果中列出查找用户。
在这里插入图片描述


管道的原理

    当父进程创建子进程时,会把pcb以及这个进程所对应的文件描述符表等内容于进程相关的都会拷贝一份,而与文件相关的不变。文件描述符表指向的文件指针是没变的,父子进程的文件描述符表指向相同文件,父进程和子进程就可以看到同一份公共资源称为管道文件,简称为管道。父子进程再各自关闭自己不需要的文件描述符,就可以做到父进程进行写入,子进程进行读取,从而进行进程之间通信。

在这里插入图片描述


管道本质

在这里插入图片描述


匿名管道

#include <unistd.h>
功能:创建一无名管道

原型
int pipe(int fd[2]);

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

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

在这里插入图片描述


代码演示

#include<iostream>
#include<unistd.h>
#include<string>
#include<cstdio>
#include<cstring>
#include<assert.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;

int main()
{
    //1.创建管道
    int pipefd[2] = {0};    //pipefd[0]:读端, pipefd[1]:写端
    int n = pipe(pipefd);   //pipefd是输出型参数,期望通过调用它获得被打开的文件fd
    assert(n != -1);
    (void)n;                //防止release版本下报警

    cout<<"pipefd[0]: "<<pipefd[0]<<endl; //3
    cout<<"pipefd[1]: "<<pipefd[1]<<endl; //4

    //2.创建子进程
    pid_t id = fork();
    assert(id != -1);
    if(id == 0)
    {
        //子进程
        //3.构建单项通信的信道,假设父进程写入,子进程读取
        //3.1 关闭子进程不需要的fd
        close(pipefd[1]);
        char buffer[1024];
        while(1)
        {
            ssize_t s = read(pipefd[0],buffer,sizeof(buffer)-1);
            if(s > 0)
            {
                buffer[s] = 0;
                cout<<"child get a message["<<getpid()<<"] Father#"<<buffer<<endl;
            }
        }  
        close(pipefd[0]); 
        exit(0);
    }
    else
    {
        //父进程
        //3.构建单项通信的信道,假设父进程写入,子进程读取 
        //3.1 关闭该进程不需要的fd
        close(pipefd[0]);
        string message = "我是父进程,我正在给你发消息";
        int count = 0;
        char send_buffer[1024];
        while (1)
        {
            //3.2构建一个变化的字符串
            snprintf(send_buffer,sizeof(send_buffer),"%s[%d]:%d",message.c_str(),getpid(),count++);
            //3.3写入/发送消息
            write(pipefd[1],send_buffer,strlen(send_buffer));
            //3.4故意sleep
            sleep(1);
        }
    }
    pid_t ret = waitpid(id,nullptr,0);  //等待子进程
    assert(ret > 0);
    (void) ret;
    close(pipefd[1]);
    return 0;
}

为什么不能定义全局的buffer来进行通信?

    因为写时拷贝的存在,无法更改通信。


站在文件描述符角度-深度理解管道

在这里插入图片描述


站在内核角度-管道本质

   在 Linux 中,管道的实现并没有使用专门的数据结构,而是借助了文件系统的file结构和VFS的索引节点inode。通过将两个 file 结构体指向同一个临时的 VFS 索引节点,而这个 VFS 索引节点又指向一个物理页面而实现的。

如下图:

在这里插入图片描述


管道的特点:

  1. 管道是具有血缘关系的进程进行进程间通信,常用于父子进程通信。
  2. 管道具有通过让进程间协同,提供了访问控制。
  3. 管道提供的是面向字节流式的通信服务
  4. 管道是基于文件的,文件的生命周期是随进程的,所以管道的生命周期是随进程的。
  5. 管道是单向通信的,就是半双工通信的一种特殊情况,需要双方通信时,需要建立起两个管道。
  6. 管道的大小是64K。
  7. 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性(原子性:非黑击败,不存在中间状态)。
  8. 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。

管道通信的四种情况:

  1. 写快,读慢,写满了就不能再写了。
  2. 写慢,读快,管道没有数据的时候,就必须等待。
  3. 写关,读0,标识读到了文件结尾。
  4. 读关,继续写,OS会终止写进程。

命名管道

    管道应用的一个限制就是只能在具有共同祖先的进程间通信。如果想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道,命名管道是一种特殊类型的文件。

    在操作系统内,多个进程打开同一个文件时,OS不会创建新的struct file并且加载数据,直接把文件的file指针告诉进程的文件描述表即可。OS创建了管道文件,此文件可以被打开,但是不会将内容数据进行刷新到磁盘,且该文件一定在系统路径中,因为路径具有唯一性,双方进程可以通过看见同一份资源


创建一个命名管道

  • 命名管道可以从命令行上创建,命令行方法是使用下面这个命令:
$ mkfifo pipe //当当前目录下创建命名管道,命名为pipe

read.cpp

include<iostream>
#include<fcntl.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
int main()
{
    int fd=open("./pipe",O_RDONLY);
    if(fd<0)
    {
        std::cout<<"打开管道文件失败!"<<std::endl;
        return 1;
    }
    char buf[64]={0};
    read(fd,buf,sizeof(buf));
    std::cout<<buf<<std::endl;
    return 0;
}

write.cpp

 1	#include<iostream>
 2	#include<unistd.h>
 3	#include<sys/stat.h>
 4	#include<sys/types.h>
 5	#include<fcntl.h>
 6	int main()
 7	{
 8	    int fd=open("./pipe",O_WRONLY);
 9	    if(fd<0)
10	    {
11	        std::cout<<"打开管道文件失败!"<<std::endl;
12	        return 1;
13	    }
14	    char str[]="hello world!";
15	    write(fd,str,sizeof(str)-1);
16	    return 0;
17	}

  • 命名管道也可以从程序里创建,相关函数有:
int mkfifo(const char *filename,mode_t mode); //filename表示文件名,mode表示该文件的权限
1	#include<stdio.h>
2	#include<sys/types.h>
3	#include<sys/stat.h>
4	int main()
5	{
6	    mkfifo("pipe",0664);
7	    return 0;
8	}

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

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

命名管道案例:

https://gitee.com/jared612/linux/tree/master/FIFO


system V 共享内存

   共享内存区是最快的IPC形式(但是没有访问控制)。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。键盘输入的数据直接写入共享内存,打印数据也是从共享内存中直接打印出来,不需要将数据给操作系统,不需要过多的拷贝。

在这里插入图片描述


共享内存示意图
在这里插入图片描述


共享内存数据结构

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 */
};

共享内存的建立

    共享内存的提供者是操作系统,它是操作系统专门为了进程之间通信而设计的。系统中会存在着大量的共享内存,操作系统先描述再组织来管理共享内存。共享内存 = 共享内存块 + 对应的共享内存的内核数据结构。


共享内存函数

shmget函数

头文件: <sys/ipc.h>  <sys/shm.h>
功能:用来创建共享内存
原型
int shmget(key_t key, size_t size, int shmflg);
参数
key:这个共享内存段名字
size:共享内存大小
shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的
返回值:成功返回一个非负整数,即该共享内存段的标识码;失败返回-1

在这里插入图片描述
    创建出来的共享内存属于用户空间,不经过系统调用就可以直接访问,返回值int是共享内存的用户层标识符,来标识唯一的共享内存。进程如果通过共享内存进行通信,直接进行内存级别的读写即可,而管道则属于系统空间,必须调用系统调用接口,这也正是为什么返回不是fd,因为它是系统层面上的。

   创建出来的共享内存生命周期是随内核的,可以手动删除/代码中删除。在命令行上,可以使用ipcs -m 查看共享内存,如果要删除共享内存使用ipcrm -m shmid就可以。

   ftok函数可以标识操作系统上ipc资源的唯一性,使不同的进程要看到同一份资源,proj_id是可以根据自己的约定,随意设置。
在这里插入图片描述


shmat函数

功能:将共享内存段连接到进程地址空间
原型:
void shmat(int shmid, const void shmaddr, int shmflg);
参数:
shmid: 共享内存标识
shmaddr:指定连接的地址
shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY
返回值:成功返回一个指针,指向共享内存第一个节;失败返回-1

在这里插入图片描述


shmdt函数

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

shmctl函数

功能:用于控制共享内存
原型:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数:
shmid:由shmget返回的共享内存标识码
cmd:将要采取的动作(有三个可取值)
buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
返回值:成功返回0;失败返回-1

在这里插入图片描述


共享内存案例

https://gitee.com/jared612/linux/tree/master/Shm


互斥的理解

   为了让进程间进行通信,首先要让不同的进程看到同一份资源。多个进程/执行流看到的公共的一份资源称为临界资源,并且把自己的进程放到临界资源的代码叫做临界区。所以,多个执行流互相运行时候互相干扰,主要是用户不加保护的访问了同样的临界资源,而在非临界区的多个执行流互相不影响。为了更好的进行临界去的保护,可以让多执行流在任何时刻,都只有一个进程进入临界区,这个就叫做互斥。


system V消息队列

  • 消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法。
  • 每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值。
  • 特性方面:IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核。

system V信号量

信号量主要用于同步和互斥的。

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

   信号量本质上是一个计数器,每一个进程想要进入临界资源,访问临界资源的数据,不能让进程直接去使用临界资源,要先申请信号量。申请信号量的本质就是让信号量计数器–,释放信号量是让信号量计数器++,主动申请信号量成功,临界资源内部一定给你预留了你想要的资源,申请信号量的本质其实就是对于临界资源的一种预定机制。


;