• 【Linux操作系统】进程间通信(管道、共享内存、信号量)



    秃头侠们好呀,今天来说 进程间通信

    首先为什么要存在进程间通信?怎么做到的?

    进程之间可能会存在特定的协同工作的场景,一个进程要把自己的数据交付给另一个进程,让其进行处理,此时我们就需要进程间通信了,那么我们的操作系统就要为此设计通信方式。
    但是之前讲到,进程是有独立性的,一个进程是看不到另一个进程的资源的,所以交互数据的成本一定很高!因此两个进程要互相通信,因为进程具有独立性,必须得先看到同一份公共资源(A放进去,B去拿),这里就相当于一段内存(可能以文件方式提供、队列形式、也可能提供的就是原始内存块,这就是为什么通信方式有很多种的原因)。那这个公共资源应该属于谁呢?肯定不能是A或B,它一定属于操作系统。
    进程间通信的本质:由操作系统参与,提供一份所有通信进程能看到的公共资源!

    进程间通信的目的

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

    进程间通信分类

    管道
    匿名管道pipe
    命名管道

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

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

    匿名管道

    站在内核角度理解管道
    在这里插入图片描述

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

    在这里插入图片描述
    管道是一个只能单向通信的半双工通信。

    #include <unistd.h>
    功能:创建一无名管道
    原型
    int pipe(int pipefd[2]);
    参数
    pipefd:文件描述符数组,其中pipefd[0]表示读端, pipefd[1]表示写端
    返回值:成功返回0,失败返回错误代码
    
    pipefd[2]:是一个输出型参数
    我们想通过这个参数读取到打开的两个fd
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    #include<stdio.h>
    #include<stdlib.h>
    #include<unistd.h>
    #include<string.h>
    
    int main()
    {
      int pipefd[2]={0};
      if(pipe(pipefd)!=0)
      {
        perror("pipe error:");
        return 1;
      }
    
      printf("pipefd[0]:%d\n",pipefd[0]);//3
      printf("pipefd[1]:%d\n",pipefd[1]);//4
    
      //我们让父进程读取,子进程写入
      if(fork()==0)
      {
        //子进程
        close(pipefd[0]);
    
        const char*msg="hello world!";
        while(1)
        {
          write(pipefd[1],msg,strlen(msg));
          sleep(1);
        }
        exit(0);
      }
    
      //父进程
      close(pipefd[1]);
    
      while(1)
      {
        char buffer[64]={0};
        ssize_t s=read(pipefd[0],buffer,sizeof(buffer)-1);
        if(s==0)
        {
          //如果返回0,说明子进程关闭文件描述符了,
          //子进程已经不再写了,父进程读取完就结束了
          break;
        }
        else if(s>0)
        {
          //成功读取,返回读取字节数
          buffer[s]=0;
          printf("child say to parent:%s\n",buffer);
        }
        else 
        {
          //返回-1,读取失败
          break;
        }
      }
      return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60

    如果让子进程不sleep,而让父进程sleep(1),此时,一下读一堆。
    子进程:只要有缓冲区,我就一直写
    父进程:只要有缓冲区,我就一直读

    这个就叫字节流,管道是面向字节流的。

    有一个现象:

    让子进程:
    int count=0;
    while(1)
    {
    	write(pipefd[1],"1",1);
    	count++;
    	printf("count:%d\n",count);
    }
    
    父进程:
    啥也不干只
    while(1)
    {
    	sleep(1);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    最终当count=65536==64KB的时候停止了,不再写了,说明管道缓冲区有大小。
    为什么写满,不覆盖之前的继续写呢?
    因为需要让reader来读呀,如果你一直写覆盖,当我想读的时候一些数据不就丢失了。

    管道自带同步机制(父子要互相等待),原子性写入。

    如果子进程写满了,现在让父进程读,当读了一点,子进程还没有写,当读取4KB左右,子进程才继续写。

    另一种情况:当读端关闭,写端还在写入,我们发现父子进程都没了。
    为什么?
    此时我们站在OS层面,如果父子进程不没,合理吗?很不合理!此时已经没人读取了,你还写个啥?你这不就是在浪费OS的资源?OS会让你这样干?所以OS直接给你终止写入(子进程)了,OS给目标进程发信号SIGPIPE13。
    怎么证明?

    父进程读关闭后,OS把子进程()杀死
    然后让父进程获取子进程退出码、退出信号
    int status=0;
    waitpid(-1,&status,0);
    printf("exit code:%d\n",(status>>8)&0xff);
    printf("exit signal:%d\n",status&0x7f);
    return 0;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    答案:
    0
    13(SIGPIPE)
    证明刚才说的果真如此。

    匿名管道的4种情况:

    1. 读端不读或者读的慢,写端要等待读端
    2. 读端关闭,写端收到SIGPIPE信号直接终止
    3. 写端不写或者写的慢,读端要等待写端
    4. 写端关闭,读端读完pipe内部的数据再读到0,表明读到文件结尾

    匿名管道的几个特点:

    1. 管道是一个只能单向通信的半双工信道
    2. 管道是面向字节流的
    3. 必须是具有血缘关系的进程才能进程间通信(一般是父子)
    4. 管道自带同步机制(读写会互相等待)
    5. 4kb的原子性写入(保证数据完整互斥不影响)
    6. 管道的生命周期是随进程的

    管道是文件吗?
    是!一切接文件!
    如果一个文件只被当前进程打开,相关进程退出了,被打开的文件会被OS自动关闭。

    命名管道

    为了解决匿名管道只能父子间通信,引入了命名管道。

    mkfifo myfifo 创建一个管道文件(命名管道)

    匿名管道应用限制就是只能在有共同祖先(且有亲缘关系)的进程间通信。如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道

    进程具有独立性,进程间通信的成本比较高,必须先解决一个问题,让不同进程先看到同一份资源(内存文件、共享内存、队列)[一定要让OS来提供],匿名管道的本质:是通过子进程继承父进程资源的特性,达到让不同进程看到同一份资源。

    我们通常标识一个磁盘文件,用什么方案?
    A、B两个进程如何看到同一份资源?
    A、B两个进程如何看到并打开同一个文件?

    都通过:路径/文件名(唯一性)

    在这里插入图片描述
    下面通过一段代码应用一下命名管道
    目的:我们创建两个进程,一个client为客户进程,一个server为服务员进程,客户给服务员提要求,服务员满足客户的要求。

    server.c
    
    #include<stdio.h>
    #include<unistd.h>
    #include<sys/stat.h>
    #include<sys/types.h>
    #include<fcntl.h>
    #include<string.h>
    #include<sys/wait.h>
    #include<stdlib.h>
    #define MY_FIFO "./fifo"
    int main()
    {
      umask(0);
      if(mkfifo(MY_FIFO,0666)<0)
      {
        perror("mkfifo");
        return 1;
      }
    
    //正常的文件操作
      int fd=open(MY_FIFO,O_RDONLY);
      if(fd<0)
      {
        perror("open");
        return 2;
      }
    
    //业务逻辑
      while(1)
      {
        char buffer[64]={0};
        ssize_t s=read(fd,buffer,sizeof(buffer)-1);
        if(s>0)
        {
          buffer[s]=0;
          if(strcmp(buffer,"show")==0)
          {
            if(fork()==0)
            {
              execl("/usr/bin/ls","ls","-l",NULL);
              exit(1);
            }
            waitpid(-1,NULL,0);
          }
          else if(strcmp(buffer,"run")==0)
          {
            if(fork()==0)
            {
              execl("/usr/bin/sl","sl",NULL);
              exit(1);
            }
            waitpid(-1,NULL,0);
          }
          else 
          {
            printf("client says to you: %s\n",buffer);
          }
        }
        else if(s==0)
        {
          printf("client quit...\n");
          break;
        }
        else 
        {
          perror("read");
          break;
        }
      }
      close(fd);
      return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    client.c
    
    #include<stdio.h>
    #include<unistd.h>
    #include<sys/stat.h>
    #include<sys/types.h>
    #include<fcntl.h>
    #include<string.h>
    #include<sys/wait.h>
    #include<stdlib.h>
    #define MY_FIFO "./fifo"
    
    int main()
    {
      int fd=open(MY_FIFO,O_WRONLY);
      if(fd<0)
      {
        perror("open");
        return 1;
      }
    
      while(1)
      {
      //我们在client进程给server发送不同的字符串也就是要求,
      //然后server进程读取到进行回馈
        printf("请输入:");
        fflush(stdout);
        char buffer[64]={0};
        ssize_t s=read(0,buffer,sizeof(buffer)-1);
        if(s>0)
        {
          buffer[s-1]=0;
          printf("%s\n",buffer);
          write(fd,buffer,strlen(buffer));
        }
      }
      close(fd);
      return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40

    有个现象:
    当我们让server等待,client一直写,发现MYFIFO管道文件的大小没有变,说明啥?
    命名管道的数据不会刷新回磁盘,为了效率。

    为什么我们之前的pipe叫匿名管道,而fifo叫命名管道?
    因为命名管道人家得保证不同进程看到同一个文件,得有名字呀;而匿名管道没有名字,因为它是通过父子继承的方式,看到同一份资源,不需要名字来标识同一份资源。

    共享内存

    在这里插入图片描述
    ①通过某种调用,在内存中创建一份内存空间。
    ②通过某种调用,让进程(参与通信的几个进程) "挂接"到这份新开辟的内存空间上。

    这样以来不同进程就看到了同一份资源,这就是共享内存!

    1、OS内可能存在多个进程同时使用不同的共享内存来进程间通信,共享内存在系统中存在多份!那OS要管理吗?当然需要,管理就是那6个大字:先描述、再组织!

    2、你怎么保证,两个或多个进程通信,看到的是同一块共享内存呢?
    一定要有一个标识ID,方便不同进程能识别到一个共享内存资源,这个ID在哪呢?一定在描述共享内存的数据结构中。

    如果不用共享内存了,需要去关联(去挂接),释放共享内存。

    shmget函数

    功能:用来创建共享内存
    原型
    int shmget(key_t key, size_t size, int shmflg);
    参数
    key:这个共享内存段名字
    size:共享内存大小
    shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的
    IPC_CREAT:如果单独使用或者shmflg为0,创建一个共享内存,
    不存在就创建,如果创建的共享内存已存在,则直接返回当前已经存在的共享内存。
    
    IPC_EXCL:它不单独使用,IPC_EXCL|IPC_CREAT组合使用,
    如果不存在则创建共享内存,如果存在也返回-1(出错)
    
    返回值:成功返回一个非负整数,即该共享内存段的标识码;失败返回-1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    shmctl函数

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

    shmat函数

    功能:将共享内存段连接到进程地址空间
    原型
    void *shmat(int shmid, const void *shmaddr, int shmflg);
    参数
    shmid: 共享内存标识
    shmaddr:指定连接的地址一般设为NULL
    shmflg:一般设为0
    返回值:成功返回一个指针,指向共享内存第一个节;失败返回-1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    shmdt函数

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

    应用场景:

    key_t key 唯一标识符,用来让不同进程看到同一个ID
    用来进行进程间通信的,让不同进程能看到同一份资源
    
    • 1
    • 2
    key_t ftok(const char*pathname,int proj_id);
    第一个参数:自定义路径名
    第二个参数:自定义项目ID
    
    • 1
    • 2
    • 3

    这里的key会设置进内核的关于shm在内核中的数据结构中。
    只要我们形成key的算法+原始数据相同,则形成一样的ID。

    key VS shmid

    key:只是用来在系统层面进行标识唯一性的,不能用来管理shm。
    shmid:是OS给用户返回的ID,用来在用户层进行shm的管理。
    命令行属于用户层,所以用shmid管理共享内存。

    server.c

    #include<stdio.h>
    #include<stdlib.h>
    #include<unistd.h>
    #include<sys/ipc.h>
    #include<sys/shm.h>
    #include<sys/types.h>
    
    #define PATN_NAME "./"
    #define PROJ_ID 0x6666
    #define SIZE 4096
    
    int main()
    {
      key_t key=ftok(PATN_NAME,PROJ_ID);
      if(key<0)
      {
        perror("ftok");
        return 1;
      }
      int shmid=shmget(key,SIZE,IPC_CREAT|IPC_EXCL|0666);
      if(shmid<0)
      {
        perror("shmget");
        return 2;
      }
      printf("key:%u,shmid:%d\n",key,shmid);
      //sleep(1);
      char*mem=(char*)shmat(shmid,NULL,0);
      printf("attach shm success\n");
      //sleep(15);
      while(1)
      {
        sleep(1);
        printf("%s\n",mem);
      }
    
      shmdt(mem);
      printf("delete shm success\n");
    
      sleep(5);
      shmctl(shmid,IPC_RMID,NULL);
      printf("key:%x,shmid:%d->shm delete success\n",key,shmid);
      return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    client.c

    #include<stdio.h>
    #include<stdlib.h>
    #include<unistd.h>
    #include<sys/ipc.h>
    #include<sys/shm.h>
    #include<sys/types.h>
    
    #define PATN_NAME "./"
    #define PROJ_ID 0x6666
    #define SIZE 4096
    
    int main()
    {
      key_t key=ftok(PATN_NAME,PROJ_ID);
      if(key<0)
      {
        perror("ftok");
        return 1;
      }
      int shmid=shmget(key,SIZE,IPC_CREAT|IPC_EXCL|0666);
      if(shmid<0)
      {
        perror("shmget");
        return 2;
      }
      printf("key:%u,shmid:%d\n",key,shmid);
      //sleep(1);
      char*mem=(char*)shmat(shmid,NULL,0);
      printf("attach shm success\n");
      //sleep(15);
      while(1)
      {
        sleep(1);
        printf("%s\n",mem);
      }
    
      shmdt(mem);
      printf("delete shm success\n");
    
      sleep(5);
      shmctl(shmid,IPC_RMID,NULL);
      printf("key:%x,shmid:%d->shm delete success\n",key,shmid);
      return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    说明:
    1、共享内存是随内核的,不是随进程的,程序员必须显示的释放。
    可以通过此命令:ipcrm -m shmid删除共享内存。
    2、ipcs -m看一下存在的共享内存。
    3、当client没有写入,甚至没有启动的时候,server没有等client,说明共享内存不提供任何同步/互斥机制,需要程序员自己保证数据安全。

    为什么共享内存是最快的进程间通信方式?

    比如对比一下管道,因为管道是基于文件方式的,需要调用readwrite的系统调用函数,数据要经过从用户层缓冲区到内核缓冲区再到内存,然后又从内存拷贝到内核缓冲区再到用户层缓冲区;而共享内存是在物理内存创建一个共享内存块,然后再映射到多个进程的进程虚拟地址空间则该进程就可以直接看到共享内存,这样数据可以直接从用户空间用户层直接到内存,直接通过页表的映射向内存写拿数据,且不用其余的系统调用。显然共享内存更优。

    int shmget(key_t key,size_t size,int shmflg);
    size的大小建议为4096字节的整数倍
    
    • 1
    • 2

    共享内存在内核中申请的基本单位是页,内存页(4kb),如果我申请4097个字节,内核会给你40962byte,但是我们看到的还是4097byte,因为OS底层确实开了40962,但是就给你4097。

    int msgget(key_t key,int msgflg);创建消息队列
    int msgctl(int msgid,int cmd,struct msgid_ds*buf);删除
    
    • 1
    • 2
    int semget(key_t key,int msgflg);创建信号量
    int semctl(int semid,int semnum,int cmd.....);删除
    
    • 1
    • 2

    我们发现共享内存、消息队列、信号量的接口相似。

    信号量

    管道、共享内存、消息队列都是以传输数据为目的的。

    信号量不是以传输数据为目的,而是通过共享“资源”的方式,来达到多个进程的同步和互斥的目的!

    信号量的本质:是一个计数器,类似int count,衡量临界资源中资源数目的。

    什么是临界资源?
    :凡是被多个执行流同时能够访问的资源就是临界资源。
    比如同时向显示器打印,显示器就是临界资源。
    进程间通信的时候,管道、共享内存、消息队列等都是临界资源。

    凡是要进程间通信,必定要引入被多个进程看到同一份资源,这就引入了一个新问题,临界资源问题。

    进程的代码可能有很多,其中,用来访问临界资源的代码,就叫做临界区

    举个例子:
    电影院的某一个放映厅是不是就是一个临界资源,是不是我屁股坐在座位上,这个座位才属于我?不是的,在我买票的时候,它就属于我了。
    买票的本质:对临界资源的预定机制。
    一个放映厅最怕,有100个座位,卖了123张票,最多只能卖100张票,这个100就是信号量。

    int count=100;
    count--;//一个人买走一张票
    //看完
    count++;//看电影的人看完离开了
    
    • 1
    • 2
    • 3
    • 4

    每个人想进入电影院必须对count–(如果count为0了就不能让人进了),前提是每个人都得看到count,count本身就是临界资源,信号量本身就是临界资源。
    那么信号量内部就要保证count是原子性的。

    什么是原子性:
    一件事要么不做,要么做完,没有中间态。

    if(count>0)
    {count--;    //P()
    //进去看电影}
    
    else
    {//等待}
    
    //看完了
    count++;		//V()     
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    信号量要保证临界资源的安全,那么它本身就要保证自己的安全,所以信号量的PV操作是原子性的。

    什么是互斥?

    :在任意一个时刻,只能允许一个执行流进入临界资源,执行他自己的临界区。

    sem=1;
    if(sem>0)
    {//
    sem--;
    }
    else
    {//等待}
    
    sem++;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    如果进来sem为1,则你可以进入访问临界资源,执行你的临界区,如果sem为0,你就不能进,等着,别人出来sem++。


    ⭐感谢阅读,我们下期再见
    如有错 欢迎提出一起交流

  • 相关阅读:
    ElasticSearch集群shard均衡策略
    java字符集(编码与解码)--计算机翻译官
    Lange电桥的设计
    首次去中心化抢劫?近2亿美元损失:跨链桥Nomad 被攻击事件分析
    Java面向对象高级
    LLM(大语言模型)常用评测指标-MAP@R
    【无标题】
    rust函数
    计算机网络——16概述和传输层服务
    Ansible学习笔记14
  • 原文地址:https://blog.csdn.net/m0_57018588/article/details/125322253