Linux进程间通信

进程间通信

本文已收录至《Linux知识与编程》专栏!
作者:ARMCSKGT
演示环境:CentOS 7

CSDN

目录

  • 前言
  • 正文
    • 进程间通信概念
    • 管道
      • 管道概念
      • 管道原理
      • 匿名管道
      • 管道规则和特点
      • 管道的四种特殊场景
      • 关于管道的大小
      • 命名管道
      • 匿名管道实现进程控制
      • 命名管道实现模拟打电话
    • 共享内存
      • 什么是共享内存?
      • 共享内存相关接口
      • 共享内存的综合使用
      • 共享内存相关特点
    • 消息队列
      • 什么是消息队列?
      • 消息队列相关接口
    • 信号量
      • 什么是信号量?
      • 信号量的相关接口
      • 关于信号量
    • 关于SystemV标准通信设计
  • 最后

前言

进程间通信(IPC)是指不同进程之间的数据交换和通信。在多进程环境下,不同的进程需要共享内存、文件等资源,但是每个进程都有自己独立的地址空间,因此需要通过进程间通信来实现进程之间的数据交换和共享。进程间通信使得进程间可以进行数据传输、资源共享、通知事件等。例如,一个进程需要将它的数据发送给另一个进程,或者多个进程之间共享同样的资源,或者一个进程需要向另一个或一组进程发送消息,通知它们发生了某种事件等。本节我们将为大家介绍进程间通信的相关知识!

正文

进程间通信概念

在开始学习进程间通信前,我们需要知道一些概念!

进程间通信主要有四大目的:

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

其实进程间通信的最终目的就是 打破各个独立进程之前的壁垒,进行任务协同!


因为进程间具有独立性原则(增加了进程间通信成本),而我们需要进程间通使得进程能够更好的协同工作,所以进程间通信需要解决两个问题:

  • 1.想办法让不同的进程看到同一份资源,系统中大部分接口都是为了解决这个问题的,这也是进程间通信的本质。
  • 2.进程间只有一方可以写,另一方可以读,完成通信,关于通信后的后续工作,结合场景具体分析!

所以进程间通信的本质是让不同的进程看到同一份资源,为了让进程间能够通信,操作系统会在内存中创建一块公共的空间两个进程共享。


进程间通信的发展经历了三个时期的变化:

  • 管道: 最早最古老的本地进程间通信方式,尽管管道比较老,但是对于我们学习进程间通信,探究其原理和流程还是很有帮助的!
  • System V标准 进程间通信: 本地化进程间通信方式的发展,拓宽本地进程间通信的方式,但是本地进程间通信的使用场景有限,使用非常少,不过其效率极高的共享内存还是可以研究的!
  • POSIX标准 进程间通信: 网络进程间通信的发展,满足网络中的进程间通信,POSIX标准是由Unix 系统设计的一个标准,该标准支持跨平台,就连Windows等大众系统都支持POSIX标准,我们后面学习的进程的同步与互斥以及Socket套接字编程都是POSIX标准的产物!

关于以上三种进程间通信标准,各自的解决方案:

  • 管道
    – 匿名管道
    – 命名管道
  • System V 标准
    – System V 信号量
    – System V 共享内存
    – System V 消息队列
  • POSIX 标准
    – POSIX 信号量
    – POSIX 共享内存
    – POSIX 消息队列
    – POSIX 互斥量(互斥锁)
    – POSIX 条件变量
    – POSIX 读写锁

可以看出,随着技术的进步,进程间通信的方式也不断在迭代更新以适应这个大时代!

管道

管道概念

管道是 Unix 系统进程间通信(IPC)中最古老的方式,其历史最早可追溯至 “1964年10月11日”。
我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。
在Linux中我们有一个命令 | 叫做管道,其底层使用的确实是管道的原理!

#注意:在命令后 加上 & 表示该命令的将在后台执行 成为后台进程
$ sleep 658 | sleep 678 &


在创建两个由管道连接的sleep休眠较长的进程后,我们查询进程状态,发现有两个已经创建的后台sleep进程且PPID相同,PID相近,sleep 658和sleep 678为兄弟进程。

管道中,分为供父子进程使用的匿名管道(当然也可以使用命名管道)供不同进程使用的命名管道
当然,管道本身就是文件,这也符合Linux中一切皆文件的特性!

管道原理

管道的工作原理很简单,打开一个文件让两个进程一个掌握读端一个掌握写端即可!

匿名管道的功能比较局限,只能让父子进程进行通信,而命名管道可以让父子进程或不相干的进程间进行通信,覆盖范围更大!
我们前面介绍过,管道的本质也是文件,其区别在于管道属于内存级文件,不会将数据写入磁盘,那么我们必定要使用文件的思想去对待管道,我们使用管道就是打开一个文件!
对于任意一个进程,我们都能以读方式和写方式打开文件,管道也不然,但是匿名管道和命名管道的区别在于,我们打开匿名管道时,操作系统会一次性帮我们既打开管道文件的写端也打开管道文件的读端(实际是分配给我们两个文件描述符),我们在开启子进程后,对各自不需要的一端进行关闭,例如父进程写子进程读,那么开启子进程后父进程应该关闭管道的读端( 使用close(fd) ),子进程应该关闭管道的写端,此时父子进程可以看到同一份资源,数据可以从父进程通过管道流向子进程!

匿名管道

通过上面的介绍,匿名管道的操作流程可以简单分为三步:

  • 1.父进程申请一个匿名管道文件,获得读写端两个文件描述符。
  • 2.父进程fork创建子进程,子进程继承父进程的进程相关数据结构,不会复制父进程曾经打开的文件对象,父子进程看到同一个管道文件(也就是父进程打开的文件对象并非子进程新打开的文件对象)。
  • 3.在创建子进程后,父子进程都有管道读写的文件描述符,此时需要确定数据流向,父进程关闭读(写)端,子进程关闭写(读)端,构成单向流通。



补充:

  1. 父进程fork创建子进程后,子进程和父进程向屏幕和文件输入信息都会同时打印到屏幕和写入文件中,因为此时父子进程操作的是同一个文件。
  2. 父进程申请管道文件时,在管道流向确定前必须有读写端文件描述符,不能提前关闭任意一个,否则子进程继承后权限丢失。
    匿名管道文件在概念上属于文件系统,但管道文件是一个由操作系统提供的特殊文件,该文件不需要写入磁盘,只需要满足进程间通信即可。
    管道是一种半双工的通信方式,因为管道只有一个缓冲区,所以形象称为管道。


匿名管道在Linux系统中是pipe,其系统调用函数也是pipe。

关于匿名管道的手册介绍:
管道man手册介绍

pipe函数介绍

int pipe(int pipefd[2]);

参数: 一个int类型的二维数组,下标 0 中存放的是读文件描述符,下标 1 中存放的是写文件描述符。
返回值: 申请成功返回 0,失败返回 -1 (且错误码errno被设置)。

使用pipe的难点在于数组中读写文件描述符是哪一个下标,这里有一个简单的记忆方法,那就是可以把下标 0 想象成一张嘴巴也就是读,把下标 1 想象成一支笔也就是写!


实例代码演示:

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

//实现一个父进程写子进程读的通信

int main()
{
   int fdarr[2];
   int ret = pipe(fdarr);
   if(ret)
   {
       cerr<<"pipe failed!"<<endl;
       return 1;
   }

   int id = fork();
   if(id == 0) //子进程部分
   {
       //子进程只读 关闭写端
       close(fdarr[1]);
       int rfd = fdarr[0]; //获取读fd
       while(true)
       {
           char buf[64]; //定义一个缓冲区进行读
           int n = read(rfd,buf,sizeof(buf)-1); //读取缓冲区大小的数据但预留一个 \0 位置
           if(n > 0)
           {
               //如果是quit则结束通信
               if(strcmp(buf,"quit") == 0) 
               {
                   cout<<"子进程退出!"<<endl;
                   break;
               }
               //父进程写入了信息且成功读取
               cout<<"子进程接收为:"<<buf<<endl;
               memset(buf,0,sizeof(buf)); //重置缓冲区 方便下次使用
           }
       }
       close(rfd); //关闭读文件描述符
       exit(0);
   }

   //父进程关闭读文件描述符
   close(fdarr[0]);
   int wfd = fdarr[1];
   string s;
   while(true)
   {
       //写入缓冲区
       cout<<"请输入发送内容> ";
       cin>>s; //手动输入写入信息
       int n = write(wfd,s.c_str(),s.size());
       if(s == "quit") //父进程先给子进程发送退出信息 再自己退出
       {
           cout<<"父进程退出!"<<endl;
           break;
       }
       usleep(100); //休眠100毫米避免与子进程输出冲突
   }
   close(wfd); //关闭写端

   //开始等待子进程退出
   int status = 0;
   waitpid(id, &status, 0);
   //判断返回值低七位是否存在异常信号
   if((status & 0x7F)) cout<<"子进程异常退出!退出信号:"<<(status&0x7F)<<endl;
   else cout<<"子进程正常退出!退出码:"<<((status>>8)&0xFF)<<endl;

   return 0;
}


管道规则和特点

首先,管道是一种半双工、单向流 的通信方式,因此在成功创建匿名管道后,需要两个待通信的进程都能获得同一个 pipefd 数组!

匿名管道比较特殊的地方在于,只支持具有血缘关系的进程通信,如 父子进程、兄弟进程等(而命名管道则支持不同的进程进行通信),因为只有继承了,才能共享到同一个 pipefd 数组,当通信双方都获得 pipefd 数组后,需要根据情况关闭不需要的fd,确保单流向的原则。

在匿名管道函数中还有一个pipe2函数,该函数相对于pipe函数多了一个flags,该标志位可以在管道发生特殊情况时有不同的处理动作,当flags为0时作用与pipe相同!

管道文件的大小(PIPE_BUF)一般为 4096 字节,当写入数据量大于 4096 字节时Linux系统可以保证其原子性,而超过 4096 字节Linux系统将不在保证其原子性,所以在管道通信中,写入的次数和读取的次数不是严格匹配的,读写(次数的多少)没有强相关!

注意:管道中数据被读取了,管道数据就失效了,相当于被删除了。

总的来说,管道的特点有以下几点:

  • 1.管道是单向通信,只支持数据朝一个方向流动。
  • 2.管道的本质是文件,因为文件描述符fd的生命周期随进程,管道的生命周期是随进程的,当进程终止运行时,管道资源会被操作系统回收。
  • 3.匿名管道通信,通常用来进行具有“血缘关系”的进程,进行进程间通信,常用于父子通信(因为子进程会继承父进程的fd)。简而言之,pipe打开管道,并不清楚管道的名字,这样的管道就是匿名管道。
  • 4.在管道通信中,写入的次数和读取的次数不是严格匹配的,读写(次数的多少)没有强相关。表现 为(面向字节流)字节流(读取的字节数量,而不是读写次数),不论写端写入了多少数据,只要写端停止写入,读端都可以将数据读取。
  • 5.管道自带同步机制,能够让写端和读端有一定规律的进行通信。当读端进行从管道中读取数据时,如果没有数据,则会阻塞,等待写端写入数据;如果读端正在读取,那么写端将会阻塞等待读端,因此管道自带同步与互斥机制。

管道的四种特殊场景

管道中有四种特殊场景:

  • 读时管道为空
  • 读时写端关闭
  • 写时管道为满
  • 写时读端关闭

接下来我们来分析这四种场景。

读时管道为空
这里我们使父进程不写,子进程读!

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

int main()
{
   int fdarr[2];
   int ret = pipe(fdarr);
   if(ret)
   {
       cerr<<"pipe failed!"<<endl;
       return 1;
   }

   int id = fork();
   if(id == 0) //子进程部分
   {
       //子进程只读 关闭写端
       close(fdarr[1]);
       int rfd = fdarr[0]; //获取读fd
       while(true)
       {
           char buf[64]; //定义一个缓冲区进行读
           int n = read(rfd,buf,sizeof(buf)-1); //读取缓冲区大小的数据但预留一个 \0 位置
           if(n > 0)
           {
               //父进程写入了信息且成功读取
               cout<<"子进程接收为:"<<buf<<endl;
               memset(buf,0,sizeof(buf)); //重置缓冲区 方便下次使用
           }
           else cout<<"子进程接收到了 0 Byte数据"<<endl;
       }
       close(rfd); //关闭读文件描述符
       exit(0);
   }

   //父进程关闭读文件描述符
   close(fdarr[0]);
   while(true) {} //父进程进行死循环
   close(fdarr[1]); //关闭写端

   //开始等待子进程退出
   int status = 0;
   waitpid(id, &status, 0);
   //判断返回值低七位是否存在异常信号
   if((status & 0x7F)) cout<<"子进程异常退出!退出信号:"<<(status&0x7F)<<endl;
   else cout<<"子进程正常退出!退出码:"<<((status>>8)&0xFF)<<endl;

   return 0;
}

管道为空,子进程再次读取就会阻塞在read,而不是一直读取 0 Byte数据!

读时写端关闭
这里父进程写端关闭,子进程读!

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

int main()
{
   int fdarr[2];
   int ret = pipe(fdarr);
   if(ret)
   {
       cerr<<"pipe failed!"<<endl;
       return 1;
   }

   int id = fork();
   if(id == 0) //子进程部分
   {
       //子进程只读 关闭写端
       close(fdarr[1]);
       char buf[64];
       while(true) //子进程死循环读取
       {
           int n = read(fdarr[0],buf,63);
           if(n>0) cout<<"子进程读取到了 "<<n<<" Byte数据"<<endl;
           else if(n == 0) cout<<"子进程没有读取到任何数据"<<endl;
           sleep(1);
       } 
       close(fdarr[0]); //关闭读文件描述符
       exit(0);
   }

   //父进程关闭读文件描述符
   close(fdarr[0]); //关闭读端
   close(fdarr[1]); //关闭写端

   //父进程进行死循环 观察子进程情况
   while(true) {} 

   //开始等待子进程退出
   int status = 0;
   waitpid(id, &status, 0);
   //判断返回值低七位是否存在异常信号
   if((status & 0x7F)) cout<<"子进程异常退出!退出信号:"<<(status&0x7F)<<endl;
   else cout<<"子进程正常退出!退出码:"<<((status>>8)&0xFF)<<endl;

   return 0;
}


我们可以发现,当父进程读端关闭,子进程读取n一直为 0 且非阻塞了!
注意:这里要与管道为空时读取区分开,管道为空时读取会阻塞,因为管道是单流向通信,写端都关闭了,证明不会再有数据写入,因此当读端把剩余数据都读取后,read会一直返回0,所以当read读取管道返回 0 时表示已经读取到文件末尾且写端关闭,此时读端就可以退出了!


写时管道为满
这里让父进程一直写,子进程不读!

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

int main()
{
   int fdarr[2];
   int ret = pipe(fdarr);
   if(ret)
   {
       cerr<<"pipe failed!"<<endl;
       return 1;
   }

   int id = fork();
   if(id == 0) //子进程部分
   {
       //子进程只读 关闭写端
       close(fdarr[1]);
       while(true) {} //子进程死循环
       close(fdarr[0]); //关闭读文件描述符
       exit(0);
   }

   //父进程关闭读文件描述符
   close(fdarr[0]);
   string s(256,'A');
   int count = 0;
   while(true) //父进程进行死循环写入
   {
       int n = write(fdarr[1],s.c_str(),256); //一次写入256字节数据
       count += n;
       cout<<"父进程写入 "<<count<<" Byte数据"<<endl;
   } 
   close(fdarr[1]); //关闭写端

   //开始等待子进程退出
   int status = 0;
   waitpid(id, &status, 0);
   //判断返回值低七位是否存在异常信号
   if((status & 0x7F)) cout<<"子进程异常退出!退出信号:"<<(status&0x7F)<<endl;
   else cout<<"子进程正常退出!退出码:"<<((status>>8)&0xFF)<<endl;

   return 0;
}


此时我们发现,当父进程向管道累计写入 65536Byte(64KB) 数据时,而子进程一直不读取时,父进程写入阻塞在了write,此时只有当子进程读取一部分数据父进程才能继续写入!


写时读端关闭
这里我们让子进程一直写,但是父进程关闭读端,这样以便观察进程退出信息!

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

int main()
{
   int fdarr[2];
   int ret = pipe(fdarr);
   if(ret)
   {
       cerr<<"pipe failed!"<<endl;
       return 1;
   }

   int id = fork();
   if(id == 0) //子进程部分
   {
       //子进程关闭读文件描述符
       close(fdarr[0]);
       while(true) //子进程进行死循环写入
       {
           int n = write(fdarr[1],"AAAAA",5); //一次写入5字节数据
           sleep(1); //子进程一秒写入一次数据
       } 
       close(fdarr[1]); //关闭写端
       exit(0);
   }

   //父进程先关闭读写端 然后进入死循环
   close(fdarr[1]);

   //父进程读取3次后退出
   char buf[16];
   for(int i = 0;i<3;++i)
   {
       int n = read(fdarr[0],buf,sizeof(buf)-1);
       if(n>0) cout<<"父进程读取到了 "<<n<<" Byte数据:"<<buf<<endl;
       else if(n == 0) cout<<"父进程没有读取到数据"<<endl;
       memset(buf,0,sizeof(buf)-1);
   }
   close(fdarr[0]); 

   //开始等待子进程退出
   int status = 0;
   waitpid(id, &status, 0);
   //判断返回值低七位是否存在异常信号
   if((status & 0x7F)) cout<<"子进程异常退出!退出信号:"<<(status&0x7F)<<endl;
   else cout<<"子进程正常退出!退出码:"<<((status>>8)&0xFF)<<endl;

   return 0;
}


这里可以发现,父进程在读取三次关闭读端后,子进程写端再写入直接被 13 号信号终止了!

我们使用 kill -l 查看信号表:

我们发现被SIGPIPE信号终止,原因在于,操作系统不允许任何浪费资源的行为存在,如果关闭了读端,那么证明写端写了也不需要再写了,即没有存在的意义,于是操作系统会发出 13 号信号,终止写端进程!

这四种特殊场景在匿名管道和命名管道中都存在!

关于管道的大小

前面我们通过实验发现管道最大可以被写入 64KB 的数据!
我们通过man手册(man 7 pipe命令)查看pipe管道的信息:

文档中解释:在Linux2.6.11之前,管道大小为一个系统页的大小(比如在i386平台中,管道大小为 4096 字节,即4kb),从Linux2.6.11开始,管道大小的容量统一为 65536 字节,即64kb!

我们可以通过命令查看内核版本,以对应文档中的说明:

$ uname -a
# 查看内核信息


我演示的系统版本为3.10.0所以管道大小为 64 KB!

我们继续通过指令查看当前系统资源的限制情况:

$ ulimit -a
#查看当前系统资源限制


系统限制单条管道大小为:512*8=4096字节!

接着我们可以通过 /usr/src/kernels/内核版本信息(前面我们通过uname -a查询出来的内核版本信息)/include/linux/pipe_fs_i.h 文件查看当前系统管道的条数:

通过查询,我们发现系统默认16条管道,也就是总大小为:16*4096=65536字节!

此时,我们可以猜测新管道方案使得16条管道共用一块定额的65536字节的空间,每一条管道可以根据字节的需要进行扩大和缩小,极大的提高的了效率!


毕竟我们在前面已经对一条管道直接一直写满,发现可以写入 64KB 的数据!
总之,从Linux 2.6.11版本开始,管道大小上限为 64KB!

命名管道

命名管道与匿名管道原理相差不大,可以说命名管道就是给匿名管道取一个名字!
什么是给匿名管道取名字?也就是给匿名管道这个内存文件分配一个inode使其与文件名产生关联,但是不会给该命名管道文件分配实际的DataBlock,因为其不需要写入磁盘,是一个纯内存文件,可以理解为单纯将内存中的一部分空间拿出来在文件系统中挂一个名字让所有进程都可以看见和使用(进程对于文件遵循文件系统的规则),因为命名管道文件没有DataBlock,所以命名管道在系统中显示的文件大小始终是0!

创建命名管道有两种方式,一种是命令另一种是系统调用,但是他们的名字却是一样的!

# 命令创建 命名管道
$ mkfifo 管道名


我们可以发现管道的大小为0且文件类型为p,也就是管道文件!

我们一般不会手动创建,一般使用系统调用,而系统调用的名字也是mkfifo


关于mkfifo系统调用

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

int mkfifo(const char *pathname, mode_t mode);

关于参数:

  • pathname:命名管道的路径+管道名,路径可以使用绝对路径(例如 /home/TMP/mypipe)也可以使用相对路径(例如 ./mypipe)。
  • mode:命名管道文件的权限(与普通文件权限相同),mode_t其实就是对 unsigned int 的封装(相当于uint32_t),而 mode 就是创建命名管道时的初始权限,实际权限需要经过 umask 掩码计算。

关于返回值:成功返回0,失败返回-1且错误码被设置。


注意:命名管道遵循管道的规则,但是可以使两个毫不相干的进程进行通信!
这里我们使用已经创建的命名管道mypipe使cat和echo两个程序进行通信。

接下来我们通过代码实现一个客户端和服务端通信的小程序,客户端和服务器建立管道通信,客户端读取本地文件tmp.txt的内容并通过管道发送给服务端,服务端读取并输出,客户端读取!

注意:

  • 一开始不存在管道文件,我们需要使用stat系统调用获取管道文件属性,如果获取失败则管道文件不存在,此时我们主动创建管道文件,最后由谁创建的就由谁删除!
  • 当管道通信,双方没有建立成功时,另一方会进入阻塞等待状态
  • 使用 unlink删除管道文件,unlink是一个指令也是一个系统调用,可以减少该文件的引用计数,当引用计数为0时,文件会被删除!

客户端代码

//client
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
using namespace std;

#define PIPENAME "./mypipe"
#define FILENAME "./tmp.txt"

int main()
{
   bool createflag = false; //标记是否为该进程创建的管道文件
   struct stat st; //创建stat文件属性对象
   if(stat(PIPENAME,&st) == -1) //如果返回-1则获取文件属性失败 此时没有文件 我们主动创建文件
   {
       int ret = mkfifo(PIPENAME,0666);
       if(ret != 0)
       {
           cerr<<"mkfifo failed!"<<endl;
           return 1;
       }
       createflag = true;
   }
   //此时创建成功
   //客户端以写方式打开管道文件
   int wfd = open(PIPENAME,O_WRONLY);
   if(wfd < 0)
   {
       cerr<<"pipe open failed!"<<endl;
       return 2;
   }
   //此时打开管道文件成功 获取到管道的写fd

   //使用open打开待读取文件 获得该文件的读fd
   int filefd = open(FILENAME,O_RDONLY);
   if(filefd < 0)
   {
       cerr<<"file open failed!"<<endl;
       close(wfd);
       return 3;
   }

   //缓冲区
   char buf[1024] = {0};
   //将文件内容读入缓冲区 留最后一个位置作为 \0
   int n = read(filefd,buf,sizeof(buf)-1);
   //向服务器发送五次
   for(int i = 0;i<5;++i)
   {
       //读取了n字节数据 就写n字节数据
       write(wfd,buf,n);

       //客户端没一秒写入一次
       sleep(1);
   }

   //关闭对应的文件描述符
   close(wfd);
   close(filefd);
   //如果是客户端创建的管道文件 则由客户端清除
   if(createflag) unlink(PIPENAME);
   return 0;
}

服务端代码

//server
#include <iostream>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
using namespace std;

#define PIPENAME "./mypipe"

int main()
{
   bool createflag = false; //标记是否为该进程创建的管道文件
   struct stat st; //创建stat文件属性对象
   if(stat(PIPENAME,&st) == -1) //如果返回-1则获取文件属性失败 此时没有文件 我们主动创建文件
   {
       int ret = mkfifo(PIPENAME,0666);
       if(ret != 0)
       {
           cerr<<"mkfifo failed!"<<endl;
           return 1;
       }
       createflag = true;
   }
   //此时创建成功
   //服务端以读方式打开管道文件
   int rfd = open(PIPENAME,O_RDONLY);
   if(rfd < 0)
   {
       cerr<<"pipe open failed!"<<endl;
       return 2;
   }


   //缓冲区
   char buf[1024] = {0};
   while(true)
   {
       //读取内容写入缓冲区
       int n = read(rfd,buf,sizeof(buf));
       if(n == 0) break; //如果读取到0 则退出(如果不加 则客户端退出后 服务端会陷入死循环)
       cout<<"服务端收到:\n"<<buf<<endl;
       memset(buf,0,1024); //清空缓冲区
   }

   //关闭对应的文件描述符
   close(rfd);
   //如果是服务端创建的管道文件 则由服务端清除
   if(createflag) unlink(PIPENAME);
   return 0;
}

运行结果动图:

我们可以发现,在启动client后,client会阻塞,一旦启动server,则两个进程会立刻开始通信,通信结束后双方会同时结束!



关于命名管道
命名管道的工作原理得从文件系统开始解释:如果两个进程打开同一个文件,则操作系统并不会在内存中创建两个该文件的对象,而是让两个进程的fd表都指向该文件对象,所以对于同一个文件不同的进程打开了,看到的也是同一个文件!
例如我们平时多个进程都是向显示器打印,而显示器只有一个,在Linux下是一个文件角色stdout,但是所有进程却可以同时使用!

因为命名管道适用于独立的进程间 IPC,所以无论是读端和写端,只要管道文件在client进程和server进程是第一个被打开的文件,一般为其分配的 fd 是一致的,都是3;而匿名管道,因为是依靠继承才看到同一文件的,所以读端和写端 fd 不一样

关于匿名管道和命名管道的区别:

  • 匿名管道只能用于具有血缘关系的进程间通信,而命名管道可以让两个不相干的进程进行通信。
  • 出现多条匿名管道时,可能会出现写端 fd 重复继承的情况(例如一个父进程要与两个子进程使用匿名管道通信,那么父进程处理两个子进程的匿名通道fd将比直接使用命名管道复杂的多),而命名管道不会出现这种情况。
  • 匿名管道直接通过 pipe 函数创建使用,进程结束后自行释放,而命名管道需要先通过 mkfifo 函数创建,然后再通过open打开使用,必要时需要手动删除管道文件!

在其他方面,匿名管道与命名管道几乎一致,两个都属于管道家族,都是最古老的进程间通信方式,都自带同步与互斥机制,提供的都是流式数据传输,所以我们上面介绍的关于管道的规则和机制,在命名管道中同样会约束!

匿名管道实现进程控制

我们使用匿名管道进行进程控制,从而实现简易进程池,进行多进程处理。

目标:父进程创建多个子进程和管道,父进程的每一个管道与子进程单独连接,通过匿名管道与每一个子进程通信,将任务函数进行编号,父进程选择执行的子进程并告知任务函数编号子进程执行对应任务!


模块设计

首先我们需要实现一个任务类方便子进程去调用任务!
本次演示我们设置三个任务调度函数,模拟平时我们使用多进程处理不同业务。

//以下头文件是本次代码中会涉及的所有头文件
#include<iostream>
#include<vector>
#include <errno.h>
#include <cstring>
#include <string>
#include <cstdio>
#include <cassert>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
using namespace std;

typedef void(*func)();

void Logfunc()
{
   cout<<"正在执行日志任务!"<<endl;
}

void MySQLfunc()
{
   cout<<"正在执行数据库任务!"<<endl;
}

void Interfunc()
{
   cout<<"正在执行网络任务!"<<endl;
}

而在任务类的设计上,我们需要将现有任务函数地址写入一个任务数组中通过下标与宏编号绑定,子进程直接通过仿函数方式进行调用即可!

class Func
{
public:
   Func()
   {
       //向数组中写入函数
       f.push_back(Logfunc);
       f.push_back(MySQLfunc);
       f.push_back(Interfunc);
   }

   //仿函数调用 编号合法则调用
   void operator()(int command)
   {
       if(command >= 0 && command < f.size()) f[command]();
   }

   std::vector<func> f;
};

到这里,简单的任务类就设计完成了,

接下来我们需要涉及一个进程类,这个进程类用于存储子进程的pid和通信管道文件描述符,后期将使用数组管理子进程的信息,这样方便管理和派发任务。

//进程对象
class Task 
{
public:
   Task(pid_t id,int fd)
       :_id(id)
       ,_writefd(fd)
   {}
   ~Task(){}
public:
   pid_t _id; //进程pid
   int _writefd; //进程通信管道写fd
};

接下来我们需要设计子进程的业务逻辑,子进程只需要获取父进程发送的任务编号,从func类中调用对应的任务即可,如果父进程关闭写端则该子进程自行退出。
后面我们会将子进程的管道读端重定向到0文件描述符,这样业务函数只需要在0文件描述符读取即可,不需要传递其他参数。

//子进程执行流
void WaitCommand()
{
   Func func; //创建任务对象
   while(true)
   {
       int command = 0;
       int n = read(0,&command,4);//读取四字节指令
       if(n == -1) //读取出错也退出
       {
           cerr<<"read error"<<endl;
           break;
       }
       else if(n == sizeof(int))
       {
           cout<<"进程"<<getpid()<<"执行任务:";//显示执行的进程pid
           func(command); //仿函数调用任务
       }
       else break; //n==0直接退出
   }
}

子进程业务函数比较简单,接下来就是模块中最复杂进程池创建模块。
父进程在创建子进程时,需要与每一个子进程创建管道并进行连接,但是此时会发生子进程继承无效文件描述符的情况。

按照这样下去,越往后创建的子进程会继承越来越多的无效文件描述符,这还不是问题最大的地方,我们前面在管道的四种场景中介绍,子进程在读端情况下,只有写端进程关闭文件描述符,子进程才能使用read函数获得0返回值并退出,此时父进程和子进程2都有子进程1的写文件描述符,当父进程关闭子进程1写端文件描述符后,由于子进程2也掌握着子进程1的写文件描述符,此时写端没有完全关闭管道,子进程1会阻塞在read,而按顺序释放子进程时,也会卡在子进程1,子进程2也无法释放,造成类似于死锁一样的程序卡死!

为了解决这个问题,我们需要在创建函数中定义一个子进程写端文件描述符数组,父进程每创建一个子进程构建管道后,将该写端文件描述符写入数组,下一个子进程创建时,先关闭父进程中的其他子进程写入文件描述符再进行其他操作,这样就避免了多进程下匿名管道的多继承问题!

这里我们多进行一步重定向操作,将子进程读文件描述符重定向到0文件描述符,方便业务函数的读取。

而创建进程池函数只需要两个参数,一个输出型参数接收创建的每个子进程对象,还有一个TaskNum整形参数告诉该函数需要创建几个子进程,最大64个!

void CreatTask(vector<Task>& t,int TaskNum)
{
   if(TaskNum>64) TaskNum = 64; //禁止创建过多进程
   vector<int> nfd; //存储父进程已获取的管道写文件描述符
   for(int i = 0;i<TaskNum;++i)
   {
       //创建子进程前 先构建专属管道
       int fd[2] = {0};
       int n = pipe(fd);
       assert(n != -1);

       //创建子进程
       pid_t id = fork();
       assert(id != -1);
       if(id == 0) //子进程
       {
           //关闭冗余的fd
           for(auto& d:nfd) close(d); 
           //关闭写端
           close(fd[1]); 
           //将写文件描述符重定向读取端到标准输出
           dup2(fd[0],0); 
           //执行业务函数
           WaitCommand();
           //关闭读文件描述符
           close(fd[0]);
           exit(0);//退出
       }

       //父进程
       close(fd[0]); //关闭读
       t.push_back(Task(id,fd[1])); //写入进程对象 记录该子进程
       nfd.push_back(fd[1]); //写入父进程冗余的fd防止被其他子进程继承
   }
}

当然,言多无用,还需要大家自己理解代码!

后面的设计就比较简单了,首先设计一个可交互的菜单,方便用户选择。

int menu()
{
   cout<<"**********************************"<<endl;
   cout<<"*0.执行日志任务  1.执行数据库任务*"<<endl;
   cout<<"*2.执行网络任务  3.退出          *"<<endl;
   cout<<"**********************************"<<endl;
   cout<<"请选择业务> ";
   int input = 0;
   cin >> input;
   cout<<endl;
   return input;
}

接着设计一个任务派发函数,调用菜单回去任务号,然后选择子进程派发,这里我们选择按子进程创建顺序逐一派发,关于任务派发函数的参数,只需要一个进程池数组即可。

//任务派发
void ExecProc(const vector<Task>& t)
{
   int num = 0;
   //分配任务
   while(true)
   {
       //选择任务
       int command = menu(); 
       //如果任务号不正确 则不处理
       if(command < 0 && command > 3) {continue;}
       if(command == 3) break;
       write(t[num]._writefd,&command,sizeof(int));
       ++num;
       num %= 3;
       usleep(500);
   }
}

接下来就是子进程回收函数,依次关闭每个子进程的管道写端,使用waitpid函数等待子进程退出即可!

//关闭和回收子进程
void clean(const vector<Task>& t)
{
   for(const auto& n:t)
   {
       close(n._writefd); //关闭写端
       cout<<"子进程"<<n._id<<"退出"<<endl;
       waitpid(n._id,NULL,0);
       cout<<"子进程"<<n._id<<"被回收"<<endl;
   }
}

最后就是主函数了,主函数只需要创建一个进程类数组,然后依次调用上面的函数即可!

int main()
{
   //实现父进程与多个子课程进行通信
   vector<Task> t;

   //创建TaskNum个进程池
   CreatTask(t,3); 

   //派发任务
   ExecProc(t); 

   //执行清理任务
   clean(t); 
   return 0;
}

到这里,所有的模块就设计完成了!


效果展示

从模块设计中我们可以发现匿名管道的一些局限性,很多细节需要我们注意!

完整代码链接:匿名管道进程控制代码

命名管道实现模拟打电话

两个进程实现模拟打电话,进程在启动时从命令行输入程序附带参数自己的通信码和对方进程的特殊通信码(实际上为自己的命名管道和对方的命名管道名),然后双方开始通信,程序通过父子进程实现消息打印和消息发送的分离,从而实现简单的打电话通信。

两个进程通过特殊码创建各自的命名管道,而自己的命名管道写入自己的信息,对方的命名管道读取对方的信息。

#include <iostream>
#include <unistd.h>
#include <string.h>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>
using namespace std;

void Usage() //用户手册 如果用户输入错误 则提示用户
{
   cout<<"Usage:\n";
   cout<<"\tcall 自己的通信码 对方通信码\n\n";
}

int main(int argc,char* argv[]) //接收命令行参数
{
   if(argc < 3) //用户输入有误 提示用户再次输入
   {
       Usage();
       return 1;
   }
   const char* mycall = argv[1];  //记录自己通信码
   const char* othcall = argv[2]; //记录对方通信码
   struct stat st;
   int wfd = 0;
   int rfd = 0;
   if(stat(othcall,&st)==-1) //查看对方是否已经创建了管道文件 如果没有创建则自己先创建
   {
       int n = mkfifo(mycall,0666);
       if(n == -1)
       {
           cerr<<"mkfifo failed!"<<endl;
           return 2;
       }
       //自己的管道以写方式打开
       wfd = open(mycall,O_WRONLY);
       if(wfd == -1)
       {
           cerr<<"open \""<<mycall<<"\" failed!"<<endl;
           return 3;
       }

       while(stat(othcall,&st)==-1){} //反复检查对方是否已经创建管道 没有就等待
       //对方的管道以读方式打开
       rfd = open(othcall,O_RDONLY);
       if(wfd == -1)
       {
           cerr<<"open \""<<othcall<<"\" failed!"<<endl;
           return 3;
       }
   }
   else //对方先创建的-先链接对方管道 再创建自己管道
   {
       //对方的管道以读方式打开
       rfd = open(othcall,O_RDONLY);        
       if(wfd == -1)
       {
           cerr<<"open \""<<othcall<<"\" failed!"<<endl;
           return 3;
       }

       //创建自己的管道
       int n = mkfifo(mycall,0666);
       if(n == -1)
       {
           cerr<<"mkfifo failed!"<<endl;
           return 2;
       }

       //自己的管道以写方式打开
       wfd = open(mycall,O_WRONLY);
       if(wfd == -1)
       {
           cerr<<"open \""<<mycall<<"\" failed!"<<endl;
           return 3;
       }
   }

   //此时双方管道构建完毕 开始接收和发送

   int pid = fork();
   if(pid == 0) //子进程打印收到的消息
   {
       //子进程关闭写fd
       close(wfd);
       char buf[1024] = {0};
       while(true)
       {
           int n = read(rfd,buf,1023);
           if(n==0) break; //对方关闭管道
           cout<<"对方: "<<buf<<endl; //输出对方发送的消息
           memset(buf,0,1024);
       }
       close(rfd);
       exit(0);
   }

   //父进程关闭读fd
   cout<<"连接成功,通话开始!\n\n";
   close(rfd);
   string str;
   while(true)
   {
       cin>>str;
       //如果自己退出 或者子进程因为对方关闭管道而退出 则自己将在下一次发送消息时退出
       if(str=="quit" || waitpid(pid,nullptr,WNOHANG)==pid)
       {
           if(str=="quit") cout<<"|-已挂断-|"<<endl;
           else cout<<"|-对方已挂断-|"<<endl;
           break;
       }
       write(wfd,str.c_str(),str.size());
   }
   close(wfd);//关闭写fd
   unlink(mycall); //删除自己的管道文件
   return 0;
}

细节都在代码中,不过相对于进程控制来说,这段代码还是比较简单的!
改程序仅供参考,存在一些小bug,例如异常退出时,如果没有清理创建的命名管道,再次打开程序使用已有的命名管道通信时,会卡死!

运行演示:


管道的学习到这里就告一段落了,管道非常简单,稍加练习就能理解!

共享内存

前面介绍了管道这种比较古老且简单的通信方式,我们发现管道的通信操作虽然不难,但是跟文件系统挂钩其效率并不会很理想,所以我们这里介绍最快的进程间通信方式,那就是共享内存!

什么是共享内存?

共享内存(全称:System V 共享内存),是一种解决进程间通信的方案,是进程间通信方式中效率最高的一种方案,共享内存是 System V 标准中比较成功的一种通信方式。

为什么共享内存是进程间通信效率最快的方案,因为其原理是在物理内存中开辟一段空间,将空间的起始地址映射进“进程的页表”,从进程页表映射进“进程地址空间”,当多个进程对同一块空间进行这样的操作时,因为内存地址已经被映射进入进程地址空间,所以多个进程都能看到这段空间在这段空间中进行读写(拥有权限的情况下),也满足进程间通信的要求多个进程看到同一块空间,这块多进程都可以看到的共享空间便是共享内存。
通过其原理,我们可以知道,一旦这种共享空间映射进入进程地址空间,我们对空间的读写操作将不再需要内核接口干预(write和read),直接对虚拟地址读写即可,就像C语言malloc申请的堆空间一样,所以共享内存也需要释放,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。

从这里也可以看出,共享内存的初衷也是让不同的进程看到同一份空间。

关于共享区: 共享区作为虚拟地址空间中一块缓冲区域,既可作为堆栈生长扩展的区域,也可用来存储各种进程间的公共资源,比如这里的共享内存,以及之前学习的动态库,相关信息都是存储在共享区中。

但是关于建立页表映射和创建划分共享内存等操作,都是操作系统去完成的。

在正式介绍共享内存相关接口前,我们需要介绍一些前置知识!
首先,在System V标准中,共享内存,信号量和消息队列之间接口风格相似。

共享内存中操作系统中可能不止一块,可能有很多进程都在通过不同的共享内存进行通信,所以操作系统需要通过“先描述,再组织”将这些共享内存管理起来,落实到操作系统中就是通过一个一个的“struct shmid_ds”将每一个共享内存管理起来。

共享内存不仅仅可以进行两个进程间的通信,多进程都可以映射一个共享内存,从而实现一个共享内存多个进程通信的效果,所以共享内存必须确保能持续存在,这也就意味着共享内存的生命周期不随进程,而是随操作系统,一旦共享内存被创建,除非被删除,否则将会一直存在且被操作系统管理。

关于共享内存的相关结构体代码
共享内存中代码中名为shm,接口前也带shm。

struct shmid_ds 结构体

//共享内存结构体描述
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 */
};

其中struct ipc_perm结构体存储了共享内存中的基本信息!

struct ipc_perm 结构体

struct ipc_perm //IPC对象描述
{
   __kernel_key_t key; 
   __kernel_uid_t uid;
   __kernel_gid_t gid;
   __kernel_uid_t cuid;
   __kernel_gid_t cgid;
   __kernel_mode_t mode; 
   unsigned short seq;
};

共享内存虽然属于文件系统,但它的结构是经过特殊设计的,与文件系统中的 inode 那一套结构逻辑不一样。
以上代码仅供参考!

共享内存相关接口

这里介绍常用的共享内存系统调用接口!

创建共享内存
创建共享内存使用shmget函数,其man手册介绍:

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

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

关于shmget函数:

  • 函数参数:
    – key:共享内存中内核中的唯一标识,相对于命名管道的名字,一般通过ftok函数计算获取。

    – size:申请共享内存的大小(字节为单位),一般设置为4096字节(4KB),与一个page大小相同,有利于提高IO效率。

    – shmflg:当做位图使用,设置创建共享内存的方式和权限,以 “|” 连接起来。
    其中,常用的设置标记位为 IPC_CREAT 和 IPC_EXCLIPC_CREAT 表示创建共享内存,如果存在,则使用已经存在的,如果我们想总是使用最新的共享内存(非已存在的),那么我们可以加上 IPC_EXCL 标记位,表示当创建共享内存时,如果共享内存已经存在,则创建失败返回-1IPC_EXCL 这个标记位一般不单独使用,而是配合 IPC_CREAT 一起使用,关于权限,与文件相同,共享内存可以设置访问权限也受权限掩码(可以使用umask进行修改)的影响!

  • 返回值:创建成功返回 shmid,创建失败返回 -1。shmid类似于打开文件时open返回的文件描述符fd,用来对不同的共享内存进行操作。

而参数key比较特殊,key_t 实际就是对 int 进行了封装,表示一个数字,用来标识不同的共享内存块(可以理解为文件inode),因为必须确保标识值的唯一性,建议使用函数 ftok 生成一个重复率低的标识值,供操作系统对共享内存进行区分和调用,ftok不是系统调用,仅仅是一种算法!

ftok函数
ftok函数man手册介绍

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

key_t ftok(const char *pathname, int proj_id);

关于ftok函数:

  • 函数参数
    – pathname:项目路径,可以使用绝对或相对路径。
    – proj_id:项目ID,这个根据实际情况填写即可,也是为了降低重复率的设计。
  • 返回值:成功则返回 key_t 类型的标识值,失败返回-1。

之所以shmget会有key这个参数,是因为我们能够让两个不同的进程通过一些手段得到同一个共享内存,当两个进程通过ftok计算的key相同,打开的共享内存相同,则两个进程看到了同一份资源就能进行通信,就像两个进程会事先知道同一个命名管道的名字一样,事先约定好的!

下面我们通过一段代码展示shmget接口的使用:
这里我们有客户端和服务端,服务端创建共享内存,客户端申请共享内存!
我们将客户端和服务端公共使用的代码资源在common.hpp中实现:

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

#define PATHNAME "../"
#define PROJ_ID 0x668
const size_t shmSize = 4096;

key_t getKey(const char* pathname,int proj_id) //获取key
{
   key_t key = ftok(pathname,proj_id);
   if (key == -1) //失败则退出
   {
       std::cerr << "ftok fail!"<<std::endl;
       exit(1);
   }
   return key;
}

int FetchShm(key_t key, size_t size, int shmflg) //创建或获取共享内存
{
   int shmid = shmget(key, size, shmflg);
   if(shmid == -1) //失败则退出
   {
       std::cerr<<"shmget failed!"<<std::endl;
       exit(2);
   }
   return shmid;
}

int createShm(key_t key,size_t size) //创建共享内存
{
   return FetchShm(key,size, IPC_CREAT | IPC_EXCL | 0664);
}

int getShm(key_t key,size_t size) //获取共享内存
{
   return FetchShm(key,size, IPC_CREAT);
}

客户端shmclient.cc

#include <iostream>
#include "common.hpp"
using namespace std;

int main()
{
   key_t key = getKey(PATHNAME,PROG_ID);
   int shmid = getShm(key,shmSize); //获取shm

   printf("client key:0x%x\n",key); //16进制输出key
   cout<<"client shmid:"<<shmid<<endl;

   return 0;
}

服务端shmserver.cc

#include <iostream>
#include "common.hpp"
using namespace std;

int main()
{
   key_t key = getKey(PATHNAME,PROG_ID);
   int shmid = createShm(key,shmSize); //创建shm

   printf("server key:0x%x\n",key); //16进制输出key
   cout<<"server shmid:"<<shmid<<endl;

   return 0;
}


当我们再次启动服务端和客户端时:


我们发现服务端无法再创建共享内存,而客户端则正常获取shmid为6的共享内存,这说明共享内存并没有随着进程的销毁而释放,此时我们需要释放共享内存!


释放共享内存
我们可以使用命令 ipcs -m 查看共享内存情况:

关于 ipcs -m 指令显示的条目意思:

我们发现共享内存并没有销毁,操作系统还帮我们维护着,此时根据代码继续创建共享内存必定会失败,而获取则没事,此时我们需要使用 ipcrm -m shmid 释放该共享内存!

不过使用命令释放共享内存太挫了,我们可以使用代码进行释放!

我们一般使用shmctl函数标记共享内存可释放(并不是调用就立即释放),此时当时机合适时操作系统会将这个共享内存释放!
注意:标记释放共享内存只是这个函数的功能之一,这个函数还能进行获取共享内存的struct信息等其他操作!

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

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

关于shmctl函数:

  • 函数参数:
    – shmid:要操作的共享内存ID。
    – cmd:操作方式,位图,一般使用 IPC_RMID 表示要标记该共享内存可以释。
    – buf:这个参数辅助IPC_STAT和IPC_SET去使用,如果要获取或设置共享内存struct信息,则可以传入一个struct shmid_ds变量作为输入输出型参数,否则传入nullptr(C语言为NULL)!
  • 返回值:在使用IPC_RMID释放操作的情况下,成功返回 0,失败返回 -1。


关于shmctl函数的详细介绍:Linux手册翻译 – shmctl(2)
我们改造修改一下 服务端shmserver.cc 代码,添加释放共享内存的函数。

#include <iostream>
#include "common.hpp"
using namespace std;

int main()
{
   key_t key = getKey(PATHNAME,PROG_ID);
   int shmid = createShm(key,shmSize); //创建shm

   printf("server key:0x%x\n",key); //16进制输出key
   cout<<"server shmid:"<<shmid<<endl;

   shmctl(shmid,IPC_RMID,nullptr);

   return 0;
}


此时我们可以反复启动服务端,且shmid随着不同的申请而变化!


当然,我们也可以通过改变cmd参数转而获取共享内存的一些属性信息:

int main()
{
   key_t key = getKey(PATHNAME,PROG_ID);
   int shmid = createShm(key,shmSize); //创建shm
   
   shmid_ds buf;
   shmctl(shmid,IPC_STAT,&buf); //获取共享内存属性信息
   cout<<"进程PID:"<<getpid()<<endl;
   printf("key:0x%x\n",key); //16进制输出key
   cout<<"---------------------------"<<endl;
   cout<<"创建共享内存的进程PID:"<<buf.shm_cpid<<endl;
   printf("创建共享内存key:0x%x\n",buf.shm_perm.__key); //16进制输出key
   shmctl(shmid,IPC_RMID,nullptr); //销毁共享内存
   return 0;
}


这里也证明了共享内存不仅仅只有空间,也有属于自己的struct数据结构。
所以 共享内存 = 共享内存的内核数据结构(struct shmid_ds)+ 共享内存空间

将进程与共享内存 关联 和 取消关联
如果我们想使用共享内存,仅仅是创建共享内存还不够,我们必须进行类似于打开管道文件一样的操作,将共享内存与进程进行关联才能使用,而关联函数则是 shmat ,当多个进程关联同一个共享内存时,那么独立的进程间就看到了同一份资源便可以进行通信,在使用完毕后,我们在调用销毁共享内存只是标记共享内存可以删除,如果还有进程关联着共享内存,则该共享内存只有在关联进程为0时才会被释放,所以我们还需要取消和共享内存的关联,这才是一套完整的使用共享内存的过程,取消关联函数为 shmdt

shmat函数的基础作用,讲共享内存的物理地址映射到调用进程的页表,从页表映射到进程地址空间的共享区,进程直接在自己的进程地址空间中使用即可,就像malloc出来的堆空间一样,并对共享内存的属性进行修改(例如关联数+1等)。

shdat函数则与shmat相反,将共享内存从进程地址空间中移除,并对共享内存属性进行修改。

关于shmat和shmdt的man手册描述

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

void *shmat(int shmid, const void *shmaddr, int shmflg); //关联
int shmdt(const void *shmaddr);                          //取消关联

关于shmat函数:

  • 参数:
    – shmid:操作的共享内存ID(通过shmget获取)。
    – shmaddr:关联至进程地址空间共享区的指定位置,一般不用设置,传递nullptr/NULL即可!
    – shmflg:设置关联时与共享内存的读写属性,一般设置为0,即拥有读写权限。
    也可以设置为 SHM_RDONLY 表示关联的共享内存只读;如果设置为SHM_RND 且 shmaddr 不为 NULL,则关联地址自动向下调整为 SHMLBA 的整数倍,SHMLBA 的值为 PAGE_SIZE,具体调整公式:shmaddr – (shmaddr % SHMLBA)。
  • 返回值:关联成功返回关联的起始地址(void*),失败返回 (void*)-1。
    这个返回值,与malloc也类似,malloc返回的地址类型也是 (void*),具体我们要把这段空间当作什么类型去用,强转即可!

关于shmdt函数:

  • 参数:
    – shmaddr:共享内存在地址空间的起始地址(我们关联时获得的地址)。
  • 返回值:成功返回0,失败返回-1且错误码errno被设置。

接下来我们演示一段代码,同时监视系统共享内存的变化,服务端先创建共享内存,过一会再关联共享内存,此时共享内存关联数由0变为1,然后客户端启动连接共享内存,此时共享内存连接数由1变为2,然后服务端先退出并且为共享内存标记为可以销毁,此时关联数由2变为1且status被标记,最后客户端退出,关联数变为0且马上被销毁。

//shmserver.cpp
#include <iostream>
#include <unistd.h>
#include <cassert>
#include "common.hpp"
using namespace std;

int main()
{
   key_t key = getKey(PATHNAME,PROG_ID);
   int shmid = createShm(key,shmSize); //创建shm
   cout<<"服务端创建共享内存"<<endl;
   sleep(3);
   
   void* ptr = shmat(shmid,nullptr,0); //关联共享内存
   cout<<"服务端关联共享内存"<<endl;
   assert(ptr!=(void*)-1);
   sleep(5);

   shmdt(ptr); //取消关联
   cout<<"服务端取消关联共享内存"<<endl;

   shmctl(shmid,IPC_RMID,nullptr);
   cout<<"服务端标记共享内存可销毁并退出"<<endl;

   return 0;
}
//shmclient.cc
#include <iostream>
#include <unistd.h>
#include <cassert>
#include "common.hpp"
using namespace std;

int main()
{
   key_t key = getKey(PATHNAME,PROG_ID);
   int shmid = getShm(key,shmSize); //获取shm
   cout<<"客户端获取共享内存"<<endl;

   void* ptr = shmat(shmid,nullptr,0); //关联
   cout<<"客户端关联共享内存"<<endl;
   assert(ptr!=(void*)-1);
   sleep(5);

   shmdt(ptr); //取消关联
   cout<<"客户端取消关联共享内存并退出"<<endl;

   return 0;
}

由于监视记录过长,这里简单截取了一些重要变化,以辅助大家观察过程!

这里可以说明:共享内存在被删除后,已成功挂接的进程仍然可以进行正常通信,不过此时无法再挂接其他进程且共享内存被标记可释放后,状态 status 变为 销毁 dest。
这里就体现了取消关联共享内存的重要性,如果不取消关联,就会影响共享内存的释放!

共享内存的综合使用

接下来我们将共享内存封装成为一个类进行使用,并使用枚举变量区分客户端和服务端,服务端负责创建共享内存并关联,客户端直接获取同一个共享内存并关联,最后客户端先取消关联共享内存,服务器随后取消关联,随后销毁共享内。

类的封装common.hpp

//类的封装common.hpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/stat.h>

enum 
{
   SERVER = 0,
   CLIENT = 1
};

class shmemory
{
   const mode_t defmode = 0664;
   const char* defpname = "./";
   const int defprid = 0x668;
private:
   //获取key
   key_t getKey(const char* pathname,int proj_id)
   {
       key_t k = ftok(pathname,proj_id);
       if (k == -1) //失败则退出
       {
           std::cerr << "ftok fail!"<<std::endl;
           exit(1);
       }
       return k;
   }

   //创建或获取共享内存,只在本文件中使用 外界不可调用
   int FetchShm(int flag)
   {
       umask(0);
       int id = shmget(_key, _size, flag);
       if(id == -1) //失败则退出
       {
           std::cerr<<"shmget failed!"<<std::endl;
           exit(2);
       }
       return id;
   }

   //取消关联
   void shmdetach() 
   {
       if(shmdt(_shmptr) == -1)
       {
           std::cerr<<"shmdt failed!"<<std::endl;
           exit(5);
       }
   }

   //关联
   void shmaccept() 
   {
       _shmptr = shmat(_shmid,nullptr,0);
       if(_shmptr == (void*)-1)
       {
           std::cerr<<"shmat failed!"<<std::endl;
           exit(4);
       }
   }

   //销毁共享内存
   void delshm() 
   {
       if(shmctl(_shmid,IPC_RMID,nullptr) == -1)
       {
           std::cerr<<"shmctl del shm failed!"<<std::endl;
           exit(3);
       }
   }
public:
   shmemory(int type,size_t size = 4096,std::string pathname = defpname,int proj_id = defprid)
   :_type(type),_key(0),_size(size),_shmid(0),_shmptr(nullptr)
   {
       _key = getKey(pathname.c_str(),proj_id);

       mode_t flag = IPC_CREAT; //设置flag 客户端默认只获取
       if(_type == SERVER) flag |= (IPC_EXCL| defmode);  //服务端创建共享内存

       _shmid = FetchShm(flag); 

       shmaccept(); //关联共享内存
   }

   void destroy()
   {
       shmdetach(); //取消关联
       if(_type == SERVER) delshm(); //服务端删除共享内存
   }

   ~shmemory() { destroy(); }

   void* GetShmPtr() { return _shmptr; } //获取共享内存地址
   size_t GetShmSize() { return _size; } //获取共享内存大小
   key_t GetKey() { return _key; }       //获取key
   int GetType() { return _type; }       //获取类型 服务端/客户端

private:
   int _type; //身份类型 客户端/服务端
   key_t _key; //申请共享内存的key
   size_t _size; //共享内存大小
   int _shmid; //共享内存ID
   void* _shmptr; //共享内存地址
};

客户端shmclient.cc和服务端shmserver.cc

//客户端shmclient.cc
#include <iostream>
#include <unistd.h>
#include "common.hpp"
using namespace std;

int main()
{
   shmemory shm(CLIENT); //使用默认大小 项目名称和项目编号
   char* shmptr = (char*)shm.GetShmPtr();
   cout<<"客户端:";
   for(int i = 0;i<26;++i) //一共写入26个大写字母
   {
       //客户端写入一个字符就打印一个字符
       char c = (i+'A');
       shmptr[i] = c;
       shmptr[i+1]='\0';
       cout<<c;
       fflush(stdout); //这里没有加\n需要刷新标准输出缓冲区才能显示 这里是细节
       sleep(1); 
   }
   cout<<endl;
   //完成后客户端退出
   return 0;
}
//服务端shmserver.cc
#include <iostream>
#include <unistd.h>
#include <string.h>
#include "common.hpp"
using namespace std;

int main()
{
   shmemory shm(SERVER);
   char* shmptr = (char*)shm.GetShmPtr();

   while(true) //服务器默认读取此后退出
   {
       cout<<"服务端:"<<shmptr<<endl;
       if(strlen(shmptr) == 26) break;
       sleep(1); 
   }
   return 0;
}

运行结果:

共享内存不区分读端与写端,只要关联了,两者都可以进行读写,而且我们写入共享内存的数据,读取后也不会清除,需要我们自己刷新,且共享内存没有互斥机制!

这里大家可能没有注意,实际上,我们使用sleep和字符串检测对读写进行了限制,也变相的进行了互斥,所以使用共享内存是必须要有互斥和同步的!

共享内存相关特点

关于共享内存的特点,需要小结一下,有以下几点!

关于共享内存的大小


我们在申请共享内存大小时,系统并不会为我们分配我们指定的大小,而是按照PAGE页(4KB)的整数倍去申请,即如果申请的空间不足4096字节,系统也会分配4096字节,而如果我们申请4100字节,则系统会分配8192字节,在4KB的基础上翻一倍。

但是我们能使用的,仍然只有我们指定的大小空间,操作系统之所以这么做,是防止我们越界访问。


共享内存为什么快
共享内存之所以快是因为共享内存在数据传输上减少了数据的拷贝(减少IO)!

对于管道来说,管道的通信需要经历四步:

  • 写方将数据拷贝到用户空间缓冲区(我们自己定义的buf)
  • 写放调用write将数据拷贝到管道文件
  • 读方调用read将数据从管道文件拷贝到用户空间缓冲区
  • 读方将数据打印出来(拷贝到显示器文件上)

毕竟管道在宏观上是文件,需要按文件系统的方式去处理。



而共享内存则只需要两步:

  • 写端将数据写入共享内存
  • 读端从共享内存中读取数据


共享内存通过减少数据在通信时的拷贝次数从而提高效率!


共享内存的缺点
共享内存确实快,但是缺点也很明显!
共享内存没有同步与互斥机制,可能某个进程的数据还没有写完就被读取和覆盖了,所以当多个进程无限制的使用共享内存时,共享内存无法确保数据的安全性!


总的来说,如果没有限制,是不推荐使用共享内存的,可以借助管道进行控制共享内存的读取和写入。


总的来说: 一旦共享内存映射到了进程的地址空间,该共享内存就直接被所有的进程直接看到了,可以直接使用,不需要通过操作系统去写入和读取,这样就提升了通信速度,因为共享内存的这种特性,可以让进程通信的时候,减少拷贝次数,所以共享内存是所有进程间通信速度最快的,共享内存没有任何的保护机制(同步互斥),而管道通过系统接口通信(所以操作系统可以监视管道就行同步和互斥)在一定条件下可以保证数据的安全性,而共享内存则是直接通信,在没有限制的情况下无法保证数据的安全性。

消息队列

什么是消息队列?


消息队列(Message Queuing) 是一种比较特殊的通信方式,它不同于管道与共享内存那样借助一块空间进行数据读写,而是在系统中创建了一个队列,这个队列的每个节点就是数据块,包含类型和信息。

当两个进程(例如A,B进程)使用消息队列进行通信时,首先需要创建一个消息队列,然后A进程将要发送给B进程的数据打包push入队列,B进程发送给A也是如此,然后A进程通过类型判断读取自己需要的消息,B进程也是如此,而类型就是判断是否是自己的数据的依据,遍历消息队列时,取数据块取决于数据块中的类型,例如A发送数据给B,此时A打包数据可以把数据的类型定为B,表示此数据是B进程的数据!

与共享内存一样,消息队列跟共享内存一样,是由操作系统创建的,其生命周期不随进程,因此在使用结束后需要删除。
因为消息队列是很早的产物,本节只简单介绍,如果想要了解更多可以看看:消息队列详解

这里介绍的消息队列也是System V标准,所以无论是接口还是结构体都与System V 共享内存有几分相似,在System V 标准中的消息队列一般前缀都是 Msg !

System V 消息队列 结构体信息:

struct msqid_ds
{
	struct ipc_perm msg_perm;	/* Ownership and permissions */
	time_t msg_stime;			/* Time of last msgsnd(2) */
	time_t msg_rtime;			/* Time of last msgrcv(2) */
	time_t msg_ctime;			/* Time of last change */
	unsigned long __msg_cbytes; /* Current number of bytes in queue (nonstandard) */
	msgqnum_t msg_qnum;			/* Current number of messages in queue */
	msglen_t msg_qbytes;		/* Maximum number of bytes allowed in queue */
	pid_t msg_lspid;			/* PID of last msgsnd(2) */
	pid_t msg_lrpid;			/* PID of last msgrcv(2) */
};

和共享内存一样,其中 struct ipc_perm 中存储了 消息队列的基本信息,具体包含内容如下:

struct ipc_perm //IPC对象描述
{
	key_t __key;		  /* Key supplied to msgget(2) */
	uid_t uid;			  /* Effective UID of owner */
	gid_t gid;			  /* Effective GID of owner */
	uid_t cuid;			  /* Effective UID of creator */
	gid_t cgid;			  /* Effective GID of creator */
	unsigned short mode;  /* Permissions */
	unsigned short __seq; /* Sequence number */
};

消息队列相关接口

论标准的重要性,消息队列的大小接口风格与共享内存一致,都是出自 System V 标准,在这里我们介绍常用的接口。

创建消息队列

使用msgget函数创建消息队列,关于msgget的man手册介绍:

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

int msgget(key_t key, int msgflg);

关于msgget函数:

  • 参数:
    – key:创建消息队列时的唯一 key 值(与共享内存shmget保持一致),一般也是通过ftok获取。
    – msgflg:设置参数,也与shmget保持一致,使用 IPC_CREAT、IPC_EXCL、权限 等信息设置创建的消息队列。
  • 返回值:成功返回消息队列ID,失败返回 -1 且错误码被设置。

代码演示:

#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
using namespace std;

int main()
{
   int key = ftok("./",0x668);
   if(key == -1)
   {
       cerr<<"ftok failed!"<<endl;
       return 1;
   }

   //创建消息队列 且 消息队列是最新创建的 否则退出
   int msqid= msgget(key,IPC_CREAT|IPC_EXCL|0666);
   if(msqid== -1)
   {
       cerr<<"msgget failed!"<<endl;
       return 2;
   }

   printf("key:0x%x\n",key);
   cout<<"msqid:"<<msgid<<endl;
   return 0;
}


运行后,第一次运行正常显示msgid和key,但是第二次就无法创建了,与共享内存相同,消息队列的生命周期不随进程,而是随操作系统,我们创建后需要释放。


消息队列的释放
同样的,消息队列的释放可以使用指令,我们释放共享内存使用的是”ipcrm -m shmid”,而命令删除消息队列则是使用 ipcrm -q msgid,释放消息队列。
当然,既然查看共享内存的命令是 “ipcs -m” ,那么查看消息队列的命令是ipcs -q,也与共享内存十分相似。


消息队列的释放使用msgctl函数,与共享内存的shmctl函数也类似。msgctl的man手册介绍:

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

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

关于msgctl函数:

  • 参数:
    – msqid:要操作的消息队列id(msgid)。
    – cmd:要进行什么操作。
    与共享内存保持一致,IPC_RMID 表示删除共享内存,IPC_STAT 用于获取或设置所控制共享内存的属性数据结构,IPC_SET 在进程有足够权限的前提下,将共享内存的当前关联值设置为 buf 数据结构中的值。

    – buf:如果要设置或者获取消息队列的属性信息,则传入我们定义和设置的buf,否则传入nullptr/NULL。
  • 返回值:成功返回0,失败返回-1且错误码被设置。

可以发现,使用上与共享内存几乎相同。
同时也发现,“消息队列 = 消息队列的内核数据结构(struct msqid_ds) + 真正开辟的空间”。

代码演示:

#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
using namespace std;

int main()
{
   int key = ftok("./",0x668);
   if(key == -1)
   {
       cerr<<"ftok failed!"<<endl;
       return 1;
   }

   int msqid= msgget(key,IPC_CREAT|IPC_EXCL|0666);
   if(msqid== -1)
   {
       cerr<<"msgget failed!"<<endl;
       return 2;
   }

   printf("key:0x%x\n",key);
   cout<<"msqid:"<<msqid<<endl;
   msgctl(msqid, IPC_RMID,nullptr); //释放共享内存
   return 0;
}


当我们加入销毁后,我们可以正常的连续启动创建了!


向消息队列发送消息和接收消息
我们使用 msgsnd 和 msgrcv 函数,向消息队列发送和接收消息。msgsnd和msgrcv 的man手册介绍:

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

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

关于msgsnd函数:

  • 参数:
    – msqid:发送的消息队列id(msqid)。
    – msgp:待发送的数据块。
    – msgsz:待发送数据块大小。
    – msgflg:表示发送数据块的方式,一般默认为0。
  • 返回值:成功返回0,失败返回-1且错误码被设置。

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

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg);

关于msgrcv函数:

  • 参数:
    – msqid:接收的消息队列id(msqid)。
    – msgp:接收到的数据块,是一个输出型参数。
    – msgsz:要接收数据块的大小。
    – msgtyp:要接收数据块的类型。
    – msgflg:表示接收数据块的方式,一般默认为0。
  • 返回值:成功返回实际从消息队列中读取的数据字节数,失败返回 -1。


这里关于 msgp 这个参数,其实是一个柔性数组:

struct msgbuf
{
   long mtype;    /* message type, must be > 0 */
   char mtext[1]; /* message data */
};

mtype就是传说中数据块类型,据发送方而设定;mtext是一个 柔性数组 ,其中存储待发送或接收的信息,因为是柔性数组,所以可以根据信息的大小灵活调整数组的大小。

按照队列的性质,向消息队列发送消息一般就是将数据push到队尾,接收数据则是从队头获取再出队。


消息队列中大部分接口与共享内存相似,毕竟都是出自System V标准,但是因为是比较早的技术,已经被现在更加高级的通信技术替代,所以很少会使用。

信号量

什么是信号量?

信号量(semaphore)又称信号灯,是一种特殊的工具,通过 P / V 操作实现线程或进程的同步和互斥。

关于同步与互斥
多进程访问共享空间和多线程访问公共资源会产生并发访问问题,例如两个进程或线程同时对共享资源写入则会使写入的内容失效。我们把进程和线程都能看到的资源称为临界资源(共享资源)

互斥: 任何一个时刻,都只允许一个进程或线程(一个执行流)在进行共享资源的访问(对资源加锁)。

临界资源和临界区: 我们把任何一个时刻都只允许一个执行流访问的共享资源叫做临界资源(例如管道),所以要保护访问,保护内存,则需要通过代码进行保护。临界资源是要通过代码访问的(需要介质),凡是访问临界资源的代码,叫做临界区。例如在多执行流环境中,全局变量就是临界资源,而每个执行流访问全局变量的代码段就是临界区。

原子性: 一件事的执行,要么成功,要么失败,没有其他情况,这种只有两种确定状态的属性叫做原子性。例如对变量的修改,要么修改成功,要么修改失败,不存在修改一半的情况。

并发: 并发是指系统中同时存在多个独立的活动单元,比如在多线程中,多个执行流可以同时执行代码,可能访问同一份共享资源。

所以互斥是为了解决临界资源在多执行流环境中的并发访问问题,需要借助互斥锁或信号量等工具实现原子操作,实现互斥。

关于锁(互斥锁mutex)的内容我们后续会介绍,在此之前我们先了解信号量,学习它是如何实现互斥 的。

关于信号量的理解
我们引入生活中的坐火车抢票的例子,帮助大家理解信号量。
此时,火车票就是临界资源,而购买火车票就是访问临界资源,所以我们去购买火车票就算是临界区。
当我们成功购买一张火车票时,对于这趟火车来说,其票数减1,当没有票时,我们要么等待有票,要么查看其他火车。无论怎么样,当火车票卖完时,其他人是没办法乘坐这趟火车的!


关于上面的说明,我们可以总结为三点:
    1. 当有票时,我们可以成功购买到火车票,此时总票数计时器减1,而且我们可以乘坐这趟火车,因为火车上有一个位置必定是暂时属于我的。
    1. 如果我们去买票时,票已经卖完了,那么我们就无法乘坐这趟火车,要么进行候补等待其他人退票,要么换其他火车,总之目前是无法乘坐这趟火车的。
    1. 因为有票数计时器,在火车的乘坐上,有效划分了火车票(或乘坐该火车)这个临界资源的所属权限,从而保证在火车发车时绝对不会发生位置冲突等意外情况!


信号量的设计初衷也是如此,就是为了避免因多执行流对临界资源的并发访问,而导致程序运行出现问题。

我们再举一个例子,假设在路边有一间公共厕所,一次只能供一个人使用,当该厕所无人使用时计时器为1,当一群人来使用时,一次只能一个人使用,此时计时器为0,而剩下的人什么时候能使用取决于正在使用的人什么时候出来,当厕所中的人方便完后出来,此时计时器就加1了!

透过现象看本质,在这一间公共厕所的使用中,就是代码中多个执行流对同一个临界资源的互斥访问。此时的 信号量 可以设为 1,确保只允许一个执行流进行访问,这种信号量被称为 二元信号量,常用来实现互斥。

所以,信号量本质上就是一个资源计数器,所谓的 P 操作(申请)就是在对计时器减1,V 操作(归还)则是在对 计数器加1。


同样的,我们研究的是 SystemV标准 的 信号量,通过手册我们可以查看信号量的相关结构体属性:

struct semid_ds
{
   struct ipc_perm sem_perm; /* Ownership and permissions */
   time_t sem_otime;         /* Last semop time */
   time_t sem_ctime;         /* Last change time */
   unsigned long sem_nsems;  /* No. of semaphores in set */
};

System V 标准故有的 struct ipc_perm 中存储了 信号量的基本信息,具体包含内容如下:

struct ipc_perm
{
   key_t __key;          /* Key supplied to semget(2) */
   uid_t uid;            /* Effective UID of owner */
   gid_t gid;            /* Effective GID of owner */
   uid_t cuid;           /* Effective UID of creator */
   gid_t cgid;           /* Effective GID of creator */
   unsigned short mode;  /* Permissions */
   unsigned short __seq; /* Sequence number */
};

显然,无论是 共享内存、消息队列、信号量,它们的 ipc_perm 结构体中的内容都是一模一样的,结构上的统一可以带来管理上的便利,具体的细节,我们后面稍加介绍!

信号量的相关接口

在systemV标准中,信号量简称为sem。

创建信号量
创建信号量,我们使用semget,信号量的申请比较特殊,一次可以申请多个信息量,官方称此为 信号量集,关于 semget 的man手册介绍:

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

int semget(key_t key, int nsems, int semflg);

关于 semget 函数:

  • 参数:
    – key:创建信号量集时的唯一 key 标识值(可以通过函数 ftok 计算获取)。
    – nsems:待创建的信号量个数(相当于计数器的格式),这也正是集的来源,一般为1,表示创建一个信号量,这个参数一般只在信号量中有效。
    – semflg:标志位,位图形式,可以与上面其他SystemV标准通信的创建方式及创建权限作为参考(IPC_CREAT、IPC_EXCL、权限等)。
  • 返回值:成功返回semid(信号量id),失败返回-1且错误码被设置。

除了nsems参数,其他与共享内存和消息队列基本上差不多!

示例代码:

#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
using namespace std;

int main()
{
   int key = ftok("./",0x668);
   if(key == -1)
   {
       cerr<<"ftok failed!"<<endl;
       return 1;
   }

   int semid = semget(key, 1, IPC_CREAT|IPC_EXCL|0666);
   if(semid == -1)
   {
       cerr<<"semget failed!"<<endl;
       return 2;
   }

   printf("key:0x%x\n",key);
   cout<<"semid:"<<semid<<endl;
   return 0;
}

运行结果:

同样的,我们第二次启动就失败了,信号量的生命周期随操作系统,如果我们不手动释放,在操作系统关闭和重启前,该信号量将一直存在!


信号量的释放
同样的,我们可以使用指令 ipcs -s 查看当前信号量资源情况,也可以使用 ipcrm -s semid(信号量id) 删除对应的信号量。


当然,我们也可以使用函数 semctl 释放。semctl函数man手册介绍:

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

int semctl(int semid, int semnum, int cmd, ...);

关于 semctl 函数:

  • 参数:
    – semid:要操作的信号量id。
    – semnum:为集合中信号量的编号。如果标识某个信号量,此值为信号量下标(0~n-1),如果标识整个信号量集合,则设置为0,这个具体看cmd的操作是什么。
    – cmd:位图结构,对信号量和信号量集进行操作。基本操作与共享内存和消息队列一致。
    – …:可变参数列表,获取信号量的数据结构等其他信息。
  • 返回值:操作成功返回0,失败返回-1且错误码被设置。

关于 semctl 这里只进行简单介绍,如果想要了解更多,可以了解:semctl

代码演示:

#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
using namespace std;

int main()
{
   int key = ftok("./",0x668);
   if(key == -1)
   {
       cerr<<"ftok failed!"<<endl;
       return 1;
   }

   int semid = semget(key, 1, IPC_CREAT|IPC_EXCL|0666);
   if(semid == -1)
   {
       cerr<<"semget failed!"<<endl;
       return 2;
   }

   printf("key:0x%x\n",key);
   cout<<"semid:"<<semid<<endl;

   semctl(semid, 0, IPC_RMID); //这里semnum设为0 表示对信号量集做操作
   return 0;
}

运行结果:

同样的,可以自动释放信号量后,程序可以正常启动创建和释放,不会出现运行失败的情况。


对信号量操作(P/V)
使用 semop 函数对 信号量 进行诸如 +1、-1 的基本操作。semop函数man手册介绍:

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

int semop(int semid, struct sembuf *sops, unsigned nsops);

关于 semop 函数:

  • 参数:
    – semid:要操作的信号量id。
    – sops:一个需要自己设计的结构体sembuf,表示操作哪一个信号量,进行什么操作(例如加减操作)。
    – nsops:要操作的信号量数量。


    关于第二个参数,有以下结构体:
struct sembuf {
   unsigned short  sem_num;  // 数组中的信号量下标(标记操作信号量的起始位置)
   short        sem_op;      // 信号量操作    
   short        sem_flg;     // 操作标识 
};

semop函数通过sembuf中设置的sem_num起始下标位置开始设置nsops个信号量。

sembuf 成员:

  • sembuf为信号量的下标(编号)
  • sem_op:要进行的操作(PV操作),如果为正整数,表示增加信号量的值(若为3,则加上3),如果为负整数,表示减小信号量的值,如果为0,表示对信号量当前值进行是否为0的测试。
  • sem_flg:位图结构,IPC_NOWAIT标记,表示如果不能对信号量集合进行操作,则立即返回;SEM_UNDO标记,表示为当进程退出后,该进程对sem进行的操作将撤销。

关于P / V操作:

  • P原语操作的动作是:sem减1,若sem减1后仍大于或等于零,则进程继续执行,若sem减1后小于零,则该进程被阻塞后进入与该信号相对应的等待队列中,然后转进程调度。
  • V原语操作的动作是:sem加1,若相加结果大于零,则进程继续执行,若相加结果小于或等于零,则从该信号的等待队列中唤醒一等待进程,然后再返回原进程继续执行或转进程调度。

这个函数这里仅仅进行介绍,因为其使用比较复杂,且现在有更好更方便的类信号量技术使用,我们了解即可。

关于信号量

信号量 是实现 互斥 的其中一种方法,具体表现为:资源申请时,计数器减1,资源归还时,计数器 加1,只有在计数器不为 0 的情况下,才能进行资源申请,可以设计二元信号量实现互斥。

SystemV标准 中的信号量操作比较复杂,但信号量的思想还是值得了解的,后面学习多线程时也会用到,会使用 POSIX 中的信号量实现互斥,相比之下,POSIX 版的信号量操作要简单得多,同时应用也更为广泛。

因为信号量需要被多个独立进程看到,所以信号量本身也是临界资源,不过它是原子的,所以可以用于互斥。

因为多个独立进程看到同一份资源,这就是IPC的目标,所以信号量被划分至进程间通信中。

关于SystemV标准通信设计

不难发现,共享内存、消息队列、信号量的数据结构基本一致,并且都有同一个成员 struct ipc_perm。

所以实际对于 操作系统 来说,对 System V 中各种方式的描述管理只需要这样做:

  • 将 共享内存、消息队列、信号量对象描述后,统一存入struct ipc_perm数组中;
  • 再进行指定对象创建时,只需要根据 ipc_id_arr[n]->__key 进行比对,即可得知当前对象是否被创建!
  • 因为 struct shmid_ds首地址就是其成员struct ipc_perm shm_perm结构体的首地址(其他对象也一样),所以可以对当前位置的指针进行强转:((struct shmid_ds)ipc_id_arr[n]) 即可访问 shmid_ds 中的成员,这个思想,有点像C++中多态虚表。
    这样一来,操作系统可以只根据一个地址,灵活访问两个结构体中的内容,比如 struct ipc_perm shm_perm 和 struct shmid_ds,并且操作系统还把多种不同的对象,描述融合入了一个 ipc_id_arr 指针数组中,真正做到了高效管理。

而在操作系统中,实际通过 struct ipc_perm *ipc_id_arr[] 数组,将进程间通信的结构属性地址保持起来,统一管理。

注意:该图并非操作系统中实际原理,仅仅帮助理解的简单抽象图。

因为数组是ipc_prem类型,为了知道和识别数组中每一个进程通信对象,ipc_id_arr 中还会存储对象的相应类型信息。

通过下标(id) 访问对象,这与文件系统中的机制不谋而合,不过实现上略有差异,间接导致 System V 的管理系统被边缘化(历史选择了文件系统)。

既然这些进程通信对象都在一个数组中,为什么其id都不连续?
这是因为在进行查找时,会将这些 下标id % 数组大小 进行转换,确保不会发生越界,事实上,这个值与开机时间有关,开机越长,值越大,当然到了一定程度后,会重新轮回。

将内核中的所有ipc资源统一以数组的方式进行管理,假设想访问具体ipc中的资源,可以通过 ipc_id_arr[n] 强转为对应类型指针,再通过 -> 访问其中的其他资源以上方法就是多态,通过父类指针,访问子类重写的函数成员。

最后

进程间通信到这里就介绍的差不多了,本节我们介绍了进程间通信的几种标准,进程间通信的几种方式,诸如管道、共享内存、信号量等等,部分通信方式我们进行了深度解析,了解其通信方式,虽然都是本地通信方案,但是对于后面网络的学习具有一定意义,也学习了可以让进程同步和互斥的信号量,发现进程的通信不仅仅局限于数据通信,相信本节的学习一定会让大家对进程间通信有一定的了解。

本次 <Linux进程间通信> 就先介绍到这里啦,希望能够尽可能帮助到大家。

如果文章中有瑕疵,还请各位大佬细心点评和留言,我将立即修补错误,谢谢!
结尾

🌟其他文章阅读推荐🌟
Linux软硬链接和动静态库
Linux文件系统概述
Linux重定向和缓冲区理解
Linux文件理解和系统调用
Linux进程控制
Linux进程地址空间
🌹欢迎读者多多浏览多多支持!🌹

文章出处登录后可见!

已经登录?立即刷新

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
乘风的头像乘风管理团队
上一篇 2023年12月8日
下一篇 2023年12月8日

相关推荐