【Linux】进程间通信 —— 匿名管道 | 命名管道 | System V | 共享内存

简介: 本文重点:进程间通信宏观认识;匿名管道;命名管道;共享内存;信号量(多线程)

本文重点:进程间通信宏观认识;匿名管道;命名管道;共享内存;信号量(多线程)

:black_heart: people change.

正文开始@呀小边同学

进程是具有独立性的,一个进程看不到另一个的资源,那么交互数据成本一定很高。操作系统要设计特定通信方式。

两个进程要相互通信,必须先看到一份“公共资源”。所谓通信,就是一个人儿往里放,一个人儿从中取。那这里所谓的资源就要有“暂存”的功能,事实上,它就是一段内存!至于这段内存是以什么结构组织的并不重要,它可能以文件方式提供(管道),也可能以队列方式(消息队列)提供,也可能提供的就是原始的内存块(共享内存)。因此通信方式有很多种

这个公共资源应该属于谁呢?为了维持进程独立性,它一定不属于进程A或B,它属于操作系统

综上,进程间通信的前提就是:由OS参与,提供一份所有通信进程都能看到的公共资源

接下来我们学习的所有接口,都是为了解决如何让不同进程看到同一份资源,至于传输些什么数据是上层业务的事儿,不是我们今天进程间通信关心的重点。

0. 进程间通信

进程之间会存在特定的协同工作的场景:

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

进程间通信的发展

  • 管道

    • 匿名管道pipe
    • 命名管道pipe
  • System V标准 进程间通信

    • System V 消息队列
    • System V 共享内存
    • System V 信号量
  • POSIX标准 进程间通信(多线程详谈)

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

1. 管道

1.1 匿名管道

众所周知,父子进程是两个独立进程,父子通信也是进程间通信的一种,基于父子间进程通信就是匿名管道。我们首先要对匿名管道有一个宏观的认识。

父进程创建子进程,子进程需要以父进程为模板创建自己的files_struct ,而不是与父进程共用;但是struct file这个结构体就不会拷贝,因为打开文件也与创建进程无关。

write这个“写入”的系统调用函数实际上干了两件事儿:1. 拷贝数据到内核缓冲区 2. 触发底层的写入函数在合适的时机刷新到外设,如write_disk到磁盘上

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rXvdprnG-1658926593273)(image/image-20220718222600939.png)]

嘘~ 现在父子进程就看到了“公共资源”:同一个文件(注意上图的红色剪头)。只要不触发底层写入函数,就可以通过fd找到同一个struct file结构,从而找到文件缓冲区,向它写&读数据,这种基于文件的通信方式叫做管道

1.1.1 匿名管道原理

  1. 父进程创建管道,对同一文件分别以读&写方式打开

    在这里插入图片描述

  2. 父进程fork创建子进程

    <img src=" title="image-20220719094255392">

  3. 因为管道是一个只能单向通信的信道,父子进程需要关闭对应读写端,至于谁关闭谁,取决于通信方向。

    <img src=" title="image-20220719094720557">

于是,通过子进程继承父进程资源的特性,双方进程看到了同一份资源。

1.1.2 创建匿名管道pipe

创建匿名管道

#include <unistd.h>
int pipe(int pipefd[2]);
  • 参数pipefd输出型参数!通过这个参数拿到两个打开的fd
  • 返回值:建成功返回0;失败返回-1

浅浅的贴一下一会儿要用到的函数 ——

[bts@VM-24-5-centos pipe]$ man 2 fork
#include <unistd.h>
pid_t fork(void);

[bts@VM-24-5-centos pipe]$ man 2 close
#include <unistd.h>
int close(int fd);

[bts@VM-24-5-centos pipe]$ man 3 exit
#include <stdlib.h>
void exit(int status);

那么我们就按照1.1.1小节的原理进行操作:创建管道 父进程创建子进程 关闭对应的读写端,形成单向信道

①②都很简单,③现在我们想让父进程读取,子进程写入,那么问题来了,pipefd[0]pipefd[1]哪一个是读,哪一个是写呢?

0(嘴):读取端,1(笔):写入端。

至此我们就实现了双方进程看到同一份资源 ——

<img src=" title="image-20220719114035629">

在此基础上我们就要“通信”了,那我们用什么测试写入呢?你说你也没学过怎么向管道中写入呀~ 事实上这和向某个fd对应文件写入没有区别 。浅浅的贴一下一会而要用到的函数 ——

#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
返回值:
- the number of bytes written is returned
- zero indicates nothing was written,这里意味着对端进程关闭文件描述符

#include <unistd.h>
unsigned int sleep(unsigned int seconds);

读取时的返回值需要你特别注意。

1.1.3 基于匿名管道通信的4种情况5个特点

我们以父进程读取,子进程写入为例(其实是别有用意@1.1.3.4),演示4种场景,探究匿名管道的特点。

这些场景的代码每个只做了小小的修改,所以你乍一看眼晕但不要害怕,因为我会好好给你解释~ 你最好,哦不,你也应该自己动手验证一下。

1.1.3.1 读阻塞

父进程读取,子进程写入:现在我们只让子进程sleep隔一秒一写,父进程暴风吸入~ 会怎样呢?

#include<stdio.h>   
#include<string.h>
#include<unistd.h>    
#include<stdlib.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)    
  {    
    //child    
    close(pipefd[0]);    
    const char* msg = "余下的路还有好长啊";    
    while(1)    
    {    
      write(pipefd[1], msg, strlen(msg));    
      sleep(1);    
    }    
    exit(0);    
  }    
  //father    
  close(pipefd[1]);    
  while(1)    
  {    
    char buffer[64] = {0}; //清空缓冲区~
    ssize_t s = read(pipefd[0], buffer, sizeof(buffer)-1);
    if(s == 0)
    {
      printf("child quit...\n");
      break;
    }
    else if(s > 0)
    {
      buffer[s]=0; //字符串儿结束
      printf("child said to father# %s\n",buffer);
    }
    else 
    {
      printf("read error...\n");
      break;
    }
  }
  return 0;
}  

//ps: 读入时少读取一个,以避免buffer读满时,字符串儿末尾置0时发生越界访问

也就是写的慢读的快的情况下,读端就会等写端 ——

在这里插入图片描述

1.1.3.2 写阻塞

父进程读取,子进程写入:现在我不让子进程sleep疯狂地写,而父进程隔一秒读一下~

#include<stdio.h>   
#include<string.h>
#include<unistd.h>    
#include<stdlib.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)    
  {    
    //child    
    close(pipefd[0]);    
    const char* msg = "余下的路还有好长啊";    
    while(1)    
    {    
      write(pipefd[1], msg, strlen(msg));    
      //sleep(1);    
    }    
    exit(0);    
  }    
  //father    
  close(pipefd[1]);    
  while(1)    
  {  
    sleep(1);
    char buffer[64] = {0}; //清空缓冲区~
    ssize_t s = read(pipefd[0], buffer, sizeof(buffer)-1);
    if(s == 0)
    {
      printf("child quit...\n");
      break;
    }
    else if(s > 0)
    {
      buffer[s]=0; //字符串儿结束
      printf("child said to father# %s\n",buffer);
    }
    else 
    {
      printf("read error...\n");
      break;
    }
  }
  return 0;
}  

为什么一下子读出来这么多呢?事实上,pipe里只要有缓冲区就一直写入,read只要有东西就会一直读取,管道是面向字节流的,也就是只有字节的概念,究竟读成什么样也无法保证,甚至可能读出乱码,所以父子进程通信也是需要制定协议的,但这个我们网络再细说。。
在这里插入图片描述

(父进程读取,子进程写入):如果我们子进程一个字符一个字符写入,并定义一个计数器计数;父进程摆烂,啥也不读。。

#include<stdio.h>   
#include<string.h>
#include<unistd.h>    
#include<stdlib.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)    
  {    
    //child    
    close(pipefd[0]);
    int count = 0;
    const char* msg = "a";    
    while(1)    
    {    
      write(pipefd[1], msg, strlen(msg)); 
      count++;
      printf("count: %d\n", count);
      //sleep(1);    
    }    
    exit(0);    
  }    
  //father    
  close(pipefd[1]);    
  while(1)    
  {  
    sleep(1);
    //摆烂...
  }
  return 0;
}  

最终程序卡在了65536这个数,也就是说写端就不再写入了,这说明管道是有大小的,事实证明我云服务器上管道容量是64KB ——

<img src=" title="072001">

那为什么writer写满的时候就不写了?难道不可以覆盖呀? 这是为了等待对方来读,覆盖等其它做法都是违背进程通信的初衷的。事实上,管道是自带同步机制的,也就是父子读写会相互等待合适的时机,这种机制很好地保障了数据的安全。

那我就想了,如果我读走一些,是不是写端会继续写入?测试代码如下:

#include<stdio.h>   
#include<string.h>
#include<unistd.h>    
#include<stdlib.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)    
  {    
    //child    
    close(pipefd[0]);
    int count = 0;
    const char* msg = "a";    
    while(1)    
    {    
      write(pipefd[1], msg, strlen(msg)); 
      count++;
      printf("count: %d\n", count);
      //sleep(1);    
    }    
    exit(0);    
  }    
  //father    
  close(pipefd[1]);    
  while(1)    
  {  
    sleep(5);
    char buffer[4*1024] = {0}; //4KB
    ssize_t s = read(pipefd[0], buffer, sizeof(buffer));
      printf("well, child is taking 4KB data......");
  }
  return 0;
}  

事实证明,读的较少的字节时时候,是不会触发对端来写的;而是要读走一批数据(经测试我这儿是4KB),才能唤醒,如果你换成一次读走2KB,经验证则需要读两次,严谨 ——

在这里插入图片描述

这是为了保证写入的原子性 ——

在这里插入图片描述

1.1.3.3 写端关闭

父进程读取,子进程写入:5s后写端把自己的文件描述符关了,读端会怎样?测试代码如下:

#include<stdio.h>   
#include<string.h>
#include<unistd.h>    
#include<stdlib.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)    
  {    
    //child    
    close(pipefd[0]);    
    const char* msg = "余下的路还有好长啊";    
    while(1)    
    {    
      write(pipefd[1], msg, strlen(msg));
      sleep(5);
      break;
    }    
    close(pipefd[1]); /*写端关闭写端...*/
    exit(0);    
  }    
  //father    
  close(pipefd[1]);    
  while(1)    
  {  
    sleep(1);
    char buffer[64] = {0}; //清空缓冲区~
    ssize_t s = read(pipefd[0], buffer, sizeof(buffer)-1);
    if(s == 0)
    {
      printf("writer quit... \n");
      break;
    }
    else if(s > 0)
    {
      buffer[s]=0; //字符串儿结束
      printf("child said to father# %s\n",buffer);
    }
    else 
    {
      printf("read error...\n");
      break;
    }
  }
  return 0;
}  

读端拿到返回值0后退出 ——

<img src=" title="072004">

1.1.3.4 读端关闭

父进程读取,子进程写入:写端疯狂地写,5s后读端退出,这时写端会怎样?

#include<stdio.h>   
#include<string.h>
#include<unistd.h>    
#include<stdlib.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)    
  {    
    //child    
    close(pipefd[0]);    
    const char* msg = "余下的路还有好长啊";    
    while(1)    
    {    
      write(pipefd[1], msg, strlen(msg));    
    }    
    exit(0);    
  }    
  //father    
  close(pipefd[1]);    
  while(1)    
  {
    char buffer[64] = {0}; //清空缓冲区~
    ssize_t s = read(pipefd[0], buffer, sizeof(buffer)-1);
    if(s == 0)
    {
      printf("child quit...\n");
      break;
    }
    else if(s > 0)
    {
      buffer[s]=0; //字符串儿结束
      printf("child said to father# %s\n",buffer);
    }
    else 
    {
      printf("read error...\n");
      break;
    }
    printf("reader is going to leave...")
      sleep(5);
    break; //读一条就退出~
  }
  close(pipefd[0]); //读端关闭读端
  return 0;
}  

我们复制SSH渠道监视,发现读端退出后,写端也随之退出 ——

  while :; do ps axj | grep pipe_communicate | grep -v grep; sleep 1; echo "===================================================================="; done

<img src=" title="072006">

当我们读端关闭,已经没有人读了,写端还在写入,此时站在OS层面,是严重不合理的!本质是在浪费OS的资源,OS会直接终止写入进程,操作系统会发送SIGPIPE信号杀掉进程 ——

<img src=" title="image-20220720171725835">

我们在 进程控制@进程退出一节中说过,进程异常终止会设置status的退出信号,我们可以通过waitpid使父进程获取子进程的退出信息。这就是为什么咱们非要让父进程来读,让子进程来写,是别有用心的。

我们通过增加如下代码来查看子进程如何退出(忘了的宝子们快去复习 ——

    int status = 0;
    waitpid(-1, &status, 0);
    printf("exit code: %d\n", (status>>8)&0xFF); 
    printf("signal: %d\n", status&0x7F)
    
//ps: waitpid头文件 #include <sys/wait.h>

<img src=" title="image-20220720173521627">

:purple_heart: ​总结上述4种场景 ——

  • 写端不写或写得慢,读端就会等写端
  • 读端不读或者读得慢,写端要等读端,且保证原子性
  • 写端关闭,读端读完pipe数据,再读会读到0,表示读到文件结尾!
  • 读端关闭,写端收到SIGPIPE信号直接终止

由此我们总结出匿名管道的5个特点 ——

  • 管道是一个单向通信的通信管道
  • 管道是面向字节流的 (tcp详谈)
  • 只在具有血缘关系的进程进行通信,其中常用于父子通信
  • 管道自带互斥同步机制,且原子性写入
  • 管道的生命周期是随进程的。管道是文件,如果一个文件只被一些进程打开,相关进程都退出了,被打开的文件会被OS自动关闭,即使我忘记close... 也只是影响刷新罢了。。

1.2 命名管道

为了解决匿名管道只能父子通信,咱们引入命名管道,可以在任意不相关进程进行通信。

1.2.1 创建命名管道

:yellow_heart: make FIFOs 在命令行上创建named pipes

[bts@VM-24-5-centos fifo]$ mkfifo (named pipes)
  • FIFO:好熟悉吧~ First In First Out 队列呀

<img src=" title="image-20220720211807647">

众所周知,命令行上执行的命令echo和cat都是进程,所以这就是通过管道文件进行的进程间通信 ——

<img src=" title="072008">

:yellow_heart: 那么如何用代码实现命名管道进程间通信的呢?

[bts@VM-24-5-centos fifo]$ man 3 mkfifo
#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);
  • pathname:管道文件路径
  • mode:管道文件权限
  • 返回值:创建成功返回0;创建失败返回-1,并设置错误码

首先我们要让A, B进程看到同一份资源,这里就是一个加载到内存的文件,但是不要把数据刷新到磁盘,这样情况下两进程分别再以读或写方式打开文件;另外进程毫不相关又如何打开同一个文件呢?匿名管道是借助了子进程对父进程的继承性,那命名管道就是通过路径/文件名的方式定位唯一磁盘文件的。

我touch了server.c和client.c,最终希望在serverclient两个进程之间相互通信,先写一个Makefile ——

.PHONY:all    
all:client server    
    
client:client.c    
  gcc -o $@ $^    
    
server:server.c    
  gcc -o $@ $^    
    
.PHONY:clean    
clean:    
  rm -rf client server fifo 
  • Makefile自顶向下扫描,会把第一个目标文件作为最终的目标文件。所以要一次性生成两个可执行程序,需要定义伪目标.PHONY: all,并添加依赖关系。
  • 别忘了删掉fifo哦~ 否则再次./server会创建失败

    <img src=" title="image-20220723164722884">

我们发现设置权限时,并不是预想的0666,这是因为还受到系统默认的权限掩码umask的影响 ——

<img src=" title="image-20220721225559989">

我们可以通过一个系统调用,设置该程序上下文环境的umask,那我们把umask清为0 ——

#include <sys/types.h>
#include <sys/stat.h>

mode_t umask(mode_t mask);

一旦我们有了一个命名管道,我们只需要通信双方进行文件操作进行通信即可,推荐使用系统调用接口,因为语言层的文件操作会有缓冲区干扰。

1.2.2 基于命名管道通信

comm.h

我们创建一个共用的头文件,这只是为了两个程序能有看到同一个资源的能力了。你看这一坨眼晕就直接往后看好啦~

#pragma once     
    
#include<stdio.h>                                                                               
#include<sys/stat.h>    
#include<sys/types.h>    
#include<fcntl.h>    
#include<unistd.h>    
    
#define MY_FIFO "./fifo"  

server.c

  • 创建命名管道
  • 读信息,并实现相应业务逻辑
#include"comm.h"                                                     
    
int main()    
{    
  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)    
    {    
      //success    
      buffer[s] = 0;   
      printf("client# %s\n", buffer);    
    }    
    else if(s == 0)    
    {
      //peer close...                 
      printf("client quit...\n");
      break;
    }                         
    else                                               
    {            
      //error
      perror("read");
      break;                                             
    }                                    
  }      
  close(fd);
  return 0;              
}          

client.c

此时不需要再创建命名管道,只需要获取已打开的命名管道文件

  • 从键盘拿到了待发送数据
  • 发送数据,也就是向管道中写入
#include"comm.h"    
#include<string.h>    
    
int main()    
{    
  /*不需要创建fifo,只需获取即可*/    
  int fd = open(MY_FIFO, O_WRONLY);    
  if(fd < 0)    
  {    
    perror("open");    
    return 1;    
  }    
    
  //业务逻辑    
  while(1)    
  {    
    char buffer[64] = {0};    
    /*先把数据从标准输入拿到client进程内部*/    
    printf("Plz enter message:");    
    fflush(stdout);    
    ssize_t s = read(0, buffer, sizeof(buffer)-1);    
    if(s > 0)    
    {    
      //从键盘拿到了待发送数据    
      buffer[s-1]=0;   //当做字符串儿,并覆盖读入的'\n'   
      printf("%s\n",buffer);                                         
      //发送数据    
      write(fd, buffer, strlen(buffer));    
    }    
  }    
  close(fd);    
  return 0;    
}    

注:语言层的键盘输入接口,回显时都自动过滤掉了\n,但是系统接口write不同,会把回车也作为读到的内容,因此我们可以(24行)在设置字符串儿结束标志时把它抹掉。

效果展示

一定要先运行服务端创建命名管道,再运行客户端,实现了不相关进程通信 ——

<img src=" title="072301">

我们还可以让client控制server执行一些任务,这也是进程通信的目的之一 ——

<img src=" title="072302">

当然我们需要补充一点server.c的业务逻辑:

#include"comm.h"                                                     
#include<string.h>    
#include<stdlib.h> //exit                            
#include<sys/types.h>    
#include<sys/wait.h> //waitpid 

int main()    
{    
  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}; 
    //sleep(50);
    ssize_t s = read(fd, buffer, sizeof(buffer)-1);    
    if(s > 0)    
    {    
      //success    
      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, "wait for me") == 0)                      
      {                                                                
        if(fork() == 0)                                                
        {                                                              
          execl("/usr/bin/sl", "sl", NULL);                            
          exit(1);                                                     
        }                                                              
        waitpid(-1, NULL, 0);                                          
      }                                                                
      else                                                             
      {                                                                
        printf("client# %s\n", buffer);                                
      }      
    }    
    else if(s == 0)    
    {
      //peer close...                 
      printf("client quit...\n");
      break;
    }                         
    else                                               
    {            
      //error
      perror("read");
      break;                                             
    }                                    
  }      
  close(fd);
  return 0;              
}          

下面我们server睡上50s,把匿名管道中内容读走,来验证一下管道的数据会不会刷新到硬盘 ——

<img src=" title="image-20220723202225799">

为了效率,并不会把内容刷新到磁盘上,命名管道文件真好~

1.3 pipe vs fifo

为什么pipe叫做匿名管道和和fifo叫做命名管道?

  • 匿名管道文件不需要名字,因为它是通过父子继承的方式看到同一份资源
  • 命名管道一定要有名字,从而使不相关进程定位同一个文件

2. System V标准下的进程间通信方式

以上都是基于文件的通信方式,下面我们要学习System V标准,是在同一主机内的进程间通信方案,是站在OS层面,专门为进程间通信设计的方案。

OS不相信任何人,于是给用户提供功能就一定要通过系统调用接口,于是就存在专门用来通信的接口system call.

进程通信的本质是先让不同进程看到同一份资源,System V提供了这三个主流方案 ——

  • 共享内存 - 传递数据
  • 消息队列(有点落伍) - 传递数据
  • 信号量 (今天只渗透一部分理论,多线程讲POSIX标准) - 实现进程同步&控制详谈

2.1 共享内存

基于共享内存进行进程间通信原理 ——

  1. 通过某种调用,在内存中创建一份内存空间
  2. 通过某种调用,让参与通信的进程“挂接”到这份新开辟的内存空间上。于是我们就让不同的进程看到了同一份资源。
  3. 去关联(去挂接)
  4. 释放共享内存

<img src=" title="image-20220724101611657">

  • OS中可能存在多个进程,使用不同的共享内存区域进行各自的进程间通信,因此共享内存在系统中可能存在很多,操作系统当然要管理这些共享内存,以实现创建删除挂接去关联一系列复杂的操作。那如何管理呢?先描述再组织。
  • 那如何保证不同进程看到的是同一共享内存呢?共享内存一定要有唯一标识它的ID,使不同进程识别同一个共享内存资源。你看你看这和我们管理进程特别像,那这个“ID”存在于哪里呢?我们勇敢推知,这应该在描述共享内存的struct结构体中。

2.1.1 一系列系统调用接口

:yellow_heart: 创建共享内存 allocates a System V shared memory segment

//[bts@VM-24-5-centos shared_memory]$ man shmget
#include <sys/ipc.h>
#include <sys/shm.h>

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

参数:

  • key:为了使不同进程看到同一段共享内存,即让不同进程拿到同一个ID,需要由用户自己设定,但如何设定的与众不同好难啊,就要借助下面这个函数。

    只要我们 [形成key的算法+输入key算法的原始数据] 是一样的,就能保证不同进程看到同一段共享内存 (ID),and这个key也会被设置进内核描述共享内存的结构体中。

    #include <sys/types.h>
    #include <sys/ipc.h>
    
    key_t ftok(const char *pathname, int proj_id);
    • pathname:自定义路径名
    • proj_id:自定义项目ID
    • 返回值:On success, the generated key_t value is returned. On failure -1 is returned
  • size共享内存的大小,建议是4KB的整数倍,因为共享内存在内核中申请的基本单位是页(内存页),4KB。
  • shmflg标记位,这一看就是宏,都是只有一个比特位是1且相互不重复的数据,这样|在一起,就能传递多个标志位,这我们早就知道了~

    • IPC_CREAT:如果单独使用IPC_CREAT或者flg为0,表示创建一个共享内存。若不存在,则创建;若已存在,则直接返回当前已存在的共享内存,也就是说基本不会空手而归。
    • IPC_EXCL:单独使用没有意义,通常要搭配起来IPC_CREAT | IPC_EXCL。若不存在,则创建;若已存在,则返回出错。这样的意义在于如果调用成功,得到的一定是一个全新的,没被别人使用过的共享内存。

返回值: On success, a valid shared memory identifier is returned. On errir, -1 is returned, and errno is set to indicate the error.

:yellow_heart: 控制共享内存 System V shared memory control

//[bts@VM-24-5-centos shared_memory]$ man shmctl
#include <sys/ipc.h>
#include <sys/shm.h>

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

参数:(我们只关心删除,获取属性先不管)

  • cmd:设置IPC_RMID就行啦~
  • buf:置空吧~ 喂喂,这就是个数据结构啊!这就是描述共享内存的数据结构啊

    <img src=" title="image-20220726093954135">

:yellow_heart: 关联&去关联:shmat, shmdt - System V shared memory operations

attach 挂接 ——

#include <sys/types.h>
#include <sys/shm.h>

void *shmat(int shmid, const void *shmaddr, int shmflg);
  • shmaddr:挂接到什么位置,我们也不知道,给NULL
  • shmflg: 给0

返回值:(重要) 这个地址一定是虚拟地址,类似malloc返回申请到的起始地址。

On success shmat() returns the address of the attached shared memory segment; 
on error (void *) -1 is returned, and errno is set to indicate the cause of the error.

detach 去关联 ——

int shmdt(const void *shmaddr);
  • shmaddrshmat返回的地址

注意去关联,不是释放共性内存,而是取消当前进程和共享内存的关系,本质是去掉进程和物理内存构建映射关系的页表项去掉。

返回值:

On success shmdt() returns 0; on error -1 is returned, and errno is set to indicate the cause of the error.

2.1.2 基于共享内存的进程间通信

comm.h

#pragma once     
    
#include<stdio.h>    
#include<sys/types.h>    
#include<sys/ipc.h>    
#include<sys/shm.h>    
    
#define PATH_NAME "./"     
#define PROJ_ID 0x6666    
#define SIZE 4097

不知所云往下读就好了~

server.c

请搭配代码和脑子食用 ——

  1. 生成key,并把这段代码重定向到client.c,以使两进程看到同一段共享内存。
  2. 创建全新的shm,带选项IPC_CREAT | IPC_EXCL若和系统中已经存在的ID冲突,则出错返回。

    注意到其中权限perm是0,那也可以设置一下,和设置文件权限类似,进一步体现一切皆文件的思想。

    int shmid = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); 

    <img src=" title="image-20220726100918315">

第一次./server执行完后,进程也就运行结束了,那为什么再次运行时,还告诉我这段共享内存还存在?

<img src=" title="image-20220725215105027">

我们可以通过如下命令查看共享内存:

ipcs -m      查看ipc资源,不带选项默认查看消息队列(-q)、共享内存(-m)、信号量(-s)

显而易见,该进程曾经创建的共享内存并没有被释放 ——

<img src=" title="image-20220725212501031">

system V的IPC资源,生命周期是随内核的。只能通过程序员显式的释放(命令/system call)或者是OS重启。

ipcrm -m [shmid] 通过命令释放ipc资源

这两个都用来标定唯一性的 key vs shmid有什么区别呢?

  • key:是用来在系统层面上标识唯一性,不用来管理shm
  • shmid:是OS给用户返回的ID,用来在用户层进行shm管理

命令行是属于用户层的,那么删除时一定使用的是shmid.

<img src=" title="image-20220725214625385">

经过多次试验,不停的删除有申请,发现申请到的shmid也是连续的01234... 大胆猜测描述共享内存的数据结构是用数组组织的,这个到2.2小节详谈。

#include"comm.h"                                                                                 
#include<unistd.h>
    
int main()                                                       
{                  
  key_t key = ftok(PATH_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:0x%x, shmid:%u\n",key, shmid);
                 
  //sleep(2);               
  char* mem = (char*)shmat(shmid, NULL, 0);
  printf("attach shm  done...\n");
  //sleep(10);   
                                     
  /*通信*/       
  while(1)
  {                                 
    sleep(1);
    printf("%s\n", mem);
  }             
               
  shmdt(mem);
  printf("detach shm done...\n");    
  //sleep(5);    
          
  shmctl(shmid, IPC_RMID, NULL);    
  printf("shm delete success...");
                        
  sleep(10);    
  return 0;    
} 

关于申请共享内存的大小size,我们说建议是4KB的整数倍,因为共享内存在内核中申请的基本单位是页(内存页),4KB。如果我申请4097Byte大小的空间,内核会向上取整给我4096 2Byte,诶?那我监视到的↑怎么还是4097啊!虽然在底层申请到的是40962,但不会多给你,这样也可能引起错误~

client.c

  • 只需获取共享内存;不用删除
#include"comm.h"                                                                                             
#include<unistd.h>    
    
int main()    
{    
  key_t key = ftok(PATH_NAME, PROJ_ID);    
  if(key < 0)    
  {    
    perror("ftok");    
    return 1;    
  }    
    
  //client只需要获取    
  int shmid = shmget(key, SIZE, IPC_CREAT);    
  if(shmid < 0)    
  {    
    perror("shmget");    
    return 2;    
  }    
  printf("key:0x%x, shmid:%u\n",key, shmid);    
      
  char* mem = (char*)shmat(shmid, NULL, 0);    
  //sleep(5);    
  printf("client attach shm done...\n");    
    
  /*通信*/    
  char c = 'a';    
  while(c <= 'z')    
  {    
    sleep(1);    
    mem[c-'a'] = c;    
    c++;    
    mem[c-'a'] = 0;    
  }

  shmdt(mem);
  printf("client detach shm done...\n");
  //sleep(5);

  //不需要删除共享内存
  return 0;
}       

效果展示

写一个命令行脚本来监视共享内存 ——

while :; do ipcs -m; echo "_________________________________________________________________"; sleep 1; done

我们首先观察申请挂接去关联删除共享内存的过程,注意观察nattch这个参数的变化:0->1->2->1->0.

<img src=" title="072602">

测试通信部分: server不停直接读取共享内存内容(按照字符串儿读取),client不停向共享内存写入 ——

<img src=" title="072701">

当client没有写入时(甚至没有启动时),server端并没有等待,而是不停读入。

通信过程中,printf写入我们根本就没有像pipe或fifo这样调用write/read这样的接口,一旦建立好并映射进自己的进程地址空间,该进程就可以直接看到共享内存,如同malloc的空间一般,不需要任何系统调用接口。而管道需要这些read/write接口,是因为需要将数据从内核拷贝到用户;或者从用户拷贝到内核。

通过这些现象得出 ——

2.1.3 共享内存特征

  • 共享内存的生命周期随内核
  • 共享内存是所有进程中速度最快的,只需要经过页表映射,不需来回拷贝
  • 共享内存没有任何同步或互斥机制 (但这并不代表它不需要),需要程序员自行保证数据安全。

2.2 消息队列

消息队列了解即可。

创建消息队列,与创建共享内存极其相似:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgget(key_t key, int msgflg);

删除消息队列:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

查看描述消息队列的结构体 ——

<img src=" title="image-20220727100856905">

诶我们再看一下信号量的 ——

<img src=" title="image-20220727100856905">

用户层描述共享内存属性的数据结构,可能只是内核层数据结构的子集,只需要暴露用户关心的属性 ——

<img src=" title="image-20220726220442201">

我们可以通过key找到同一个共享内存。

我们发现共享内存、消息队列、信号量的 ——

  1. 接口都类似
  2. 数据结构的第一个结构类型struct ipc_perm是完全一致的!

我们由shmid申请到的都是01234... 大胆推测,在内核中,所有的ipc资源都是通过数组组织起来的。可是描述它们的结构体类型并不相同啊?但是~ System V标准的IPC资源,xxxid_ds结构体的第一个成员都是ipc_perm都是一样的。

事实上,这个数组就是按照ipc_perm*类型存储的,把各种类型的结构体切片放进去,是通过强转做到的,要访问结构体中其它成员,再强转回来就行了~ (请自行脑补

2.3 信号量

今天我们只是简单认识信号量,多线程再详谈。

我们刚才详谈的匿名&命名管道,共享内存,消息队列,都是以传输数据为目的,而信号量是通过共享资源的方式来达到多个进程的同步和互斥。

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

  1. 什么临界资源?

    能被多个执行流同时访问的资源都是临界资源。比如,显示器文件、管道、共享内存、消息队列都是临界资源,因为进程间通信,需要引入能被多个进程看到的资源,但这也同时带来了临界资源的数据不安全的问题。count是用来保护临界资源的,前提是每个人得先看到count,因此信号量本身就是临界资源,那谁保护它呢?就是通过PV操作保证原子性。

  2. 什么是临界区?

    进程代码有很多,其中用来访问临界资源的代码叫做临界区。比如我们刚刚的通信部分。

  3. 什么是原子性?

    一件事儿,要么不做,要么就做完,没有中间态。

  4. 什么是互斥?

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

  5. 什么是同步?
相关文章
|
10天前
|
缓存 Java Linux
如何解决 Linux 系统中内存使用量耗尽的问题?
如何解决 Linux 系统中内存使用量耗尽的问题?
|
22天前
|
缓存 监控 Linux
linux进程管理万字详解!!!
本文档介绍了Linux系统中进程管理、系统负载监控、内存监控和磁盘监控的基本概念和常用命令。主要内容包括: 1. **进程管理**: - **进程介绍**:程序与进程的关系、进程的生命周期、查看进程号和父进程号的方法。 - **进程监控命令**:`ps`、`pstree`、`pidof`、`top`、`htop`、`lsof`等命令的使用方法和案例。 - **进程管理命令**:控制信号、`kill`、`pkill`、`killall`、前台和后台运行、`screen`、`nohup`等命令的使用方法和案例。
88 4
linux进程管理万字详解!!!
|
5天前
|
算法 调度 开发者
深入理解操作系统:从进程管理到内存分配
本文旨在为读者提供一个深入浅出的操作系统知识之旅,从进程管理的基础概念出发,探索内存分配的策略与技巧。我们将通过实际代码示例,揭示操作系统背后的逻辑与奥秘,帮助读者构建起对操作系统工作原理的直观理解。文章不仅涵盖理论知识,还提供实践操作的指导,使读者能够将抽象的概念转化为具体的技能。无论你是初学者还是有一定基础的开发者,都能在这篇文章中找到有价值的信息和启发。
|
12天前
|
存储 运维 监控
深入Linux基础:文件系统与进程管理详解
深入Linux基础:文件系统与进程管理详解
55 8
|
10天前
|
Linux
如何在 Linux 系统中查看进程占用的内存?
如何在 Linux 系统中查看进程占用的内存?
|
10天前
|
缓存 Linux
如何检查 Linux 内存使用量是否耗尽?
何检查 Linux 内存使用量是否耗尽?
|
19天前
|
算法 Linux 开发者
深入探究Linux内核中的内存管理机制
本文旨在对Linux操作系统的内存管理机制进行深入分析,探讨其如何通过高效的内存分配和回收策略来优化系统性能。文章将详细介绍Linux内核中内存管理的关键技术点,包括物理内存与虚拟内存的映射、页面置换算法、以及内存碎片的处理方法等。通过对这些技术点的解析,本文旨在为读者提供一个清晰的Linux内存管理框架,帮助理解其在现代计算环境中的重要性和应用。
|
22天前
|
存储 Unix Linux
进程间通信方式-----管道通信
【10月更文挑战第29天】管道通信是一种重要的进程间通信机制,它为进程间的数据传输和同步提供了一种简单有效的方法。通过合理地使用管道通信,可以实现不同进程之间的协作,提高系统的整体性能和效率。
|
21天前
|
算法 Linux 定位技术
Linux内核中的进程调度算法解析####
【10月更文挑战第29天】 本文深入剖析了Linux操作系统的心脏——内核中至关重要的组成部分之一,即进程调度机制。不同于传统的摘要概述,我们将通过一段引人入胜的故事线来揭开进程调度算法的神秘面纱,展现其背后的精妙设计与复杂逻辑,让读者仿佛跟随一位虚拟的“进程侦探”,一步步探索Linux如何高效、公平地管理众多进程,确保系统资源的最优分配与利用。 ####
61 4
|
22天前
|
缓存 负载均衡 算法
Linux内核中的进程调度算法解析####
本文深入探讨了Linux操作系统核心组件之一——进程调度器,着重分析了其采用的CFS(完全公平调度器)算法。不同于传统摘要对研究背景、方法、结果和结论的概述,本文摘要将直接揭示CFS算法的核心优势及其在现代多核处理器环境下如何实现高效、公平的资源分配,同时简要提及该算法如何优化系统响应时间和吞吐量,为读者快速构建对Linux进程调度机制的认知框架。 ####
下一篇
无影云桌面