进程间通信

进程间通信

  • 1. 进程间通信介绍
    • 1.1 进程间通信目的
    • 1.2 进程间通信本质
    • 1.3 进程间通信分类
  • 2. 管道
    • 2.1 什么是管道
    • 2.2 匿名管道
      • 2.2.1 什么是匿名管道
    • 2.2.2 pipe函数:
    • 2.3 匿名管道的使用
    • 2.4 站在文件描述符的角度深入理解管道
    • 2.5 在内核角度理解管道本质
    • 2.6 管道读写规则
    • 2.7 管道特点
    • 2.8 管道的几种中特殊情况
    • 2.9 管道的大小
  • 3. 命名管道
    • 3.1 基本概念
    • 3.2 创建一个命名管道
    • 3.3 匿名管道与命名管道的区别
    • 3.4 命名管道的打开规则
    • 3.5 命名管道的四个使用示例
    • 3.6 命令行中的管道理解
  • 4. system V进程间通信
  • 5. system V共享内存
    • 5.1 共享内存示意图
    • 5.2 共享内存数据结构
    • 5.3 共享内存的建立和释放
  • 6. 共享内存函数和使用
    • 6.1 共享内存创建
      • 6.1.1 shmget函数
      • 6.1.2 共享内存唯一标识符key需要通过ftok函数获取
      • 6.1.3 ftok函数和shmget函数使用
    • 6.2 共享内存释放
      • 6.2.1 使用命令删除共享内存
      • 6.2.2 shmctl函数删除
    • 6.3 共享内存关联
      • 6.3.1 shmat函数
    • 6.4 共享内存去关联
      • 6.4.1 shmdt函数
    • 6.5 client和serve进行共享内存通信
  • 7. 共享内存和管道的比较
    • 7.1 通信速度比较
    • 7.2 管道的数据拷贝过程
    • 7.3 共享内存的数据拷贝过程
    • 7.4 为什么共享内存是速度最快的IPC方法?
    • 7.5 为什么共享内存的拷贝次数少?
  • 8. system V消息队列
  • 9. system V信号量
    • 9.1 信号量概念
    • 9.2 同步和互斥
    • 9.3 理解信号量

1. 进程间通信介绍

1.1 进程间通信目的

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

1.2 进程间通信本质

进程间通信的本质是让 不同的进程看到同一份资源(内存 , 文件,内核缓冲等)

资源由谁(OS的哪些模块)提供 , 就有了不同的进程间通信方式!

这里的模块可以是:(文件也就是管道) ,(OS内核IPC提供即SystemV IPC) 。(网络–套接字)

  1. 进程运行的时候是具有独立性的!(数据层面) , 因此进程之间要实现通信是非常困难的。
  2. 进程间通信,一般一定要借助第三方(OS)资源。
  3. 通信的本质就是”数据的拷贝“
    进程间通信_第1张图片

1.3 进程间通信分类

管道

  • 匿名管道pipe
  • 命名管道

System V IPC

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

POSIX IPC

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

2. 管道

2.1 什么是管道

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

进程间通信_第2张图片

2.2 匿名管道

2.2.1 什么是匿名管道

只能用于具有亲缘关系的进程之间进行通信的管道称为匿名管道,其常用于父子。

进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行读写操作,进而实现父子进程间通信
进程间通信_第3张图片

  1. 子进程拷贝父进程的fd_array,父子进程看到同一份文件 , 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝(文件并不存在磁盘,只在内存中存在)。
  2. 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一 一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。

2.2.2 pipe函数:

int pipe(int fd[2]);

功能:创建一个无名管道。
参数fd:文件描述符数组 , 其中 fd[0] 表示读端,fd[1] 表示写端(快速记忆方法:0像一张嘴巴:读,1像一根笔:写)。
返回值:成功返回 0 ,失败返回错误代码。

2.3 匿名管道的使用

(1)代码演示:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
using namespace std;

int main()
{
    int fd[2] = {0};
    int p = pipe(fd);
    if (p < 0)
    {
        perror("pipe");
    }

    pid_t id = fork();
    if (id == 0)
    {
        close(fd[0]); // 子进程关闭读端

        // 子进程向管道写入数据
        const char *buf = "hello father, I am child...";
        int count = 5;
        while (count--)
        {
            write(fd[1], buf, strlen(buf));
            sleep(1); // 每隔一秒写一条数据
        }

        close(fd[1]); // 子进程写入完毕,关闭文件
        exit(0);
    }
    else
    {
        close(fd[1]); // 父进程关闭写端
        char buff[64] = {0};

        while (1)
        {
            ssize_t s = read(fd[0], buff, sizeof(buff));
            if (s > 0)
            {
                buff[s] = '\0'; // C语言读写规则
                cout << "child send to father:" << buff << endl;
            }
            else if (s == 0)
            {
                cout << "read file end" << endl;
                break;
            }
            else
            {
                cout << "read error" << endl;
                break;
            }
        }

        close(fd[0]);
        pid_t pid = waitpid(id, NULL, 0);

    }

    return 0;
}

(2)运行结果:

进程间通信_第4张图片
创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:

  1. 父进程调用pipe函数
    进程间通信_第5张图片
  2. fork创建子进程
    进程间通信_第6张图片
  3. 子进程关闭读端,父进程关闭写端

进程间通信_第7张图片

  • 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
  • 从管道写端写入的数据会存在内核缓冲,直到数据从管道中被读取。
  • 父进程读写端都打开本质是为了让子进程继承下来,最后为什么要关闭呢,本质是管道只能进行单向通信
  • 父子进程通信可不可以创建全局缓冲区来完成通信呢? 不可以 ! 进程运行具有独立性,写时拷贝

2.4 站在文件描述符的角度深入理解管道

具体步骤如下:

  1. 父进程调用pipe函数

进程间通信_第8张图片

  1. fork创建子进程

进程间通信_第9张图片

  1. 子进程关闭读端,父进程关闭写端

进程间通信_第10张图片

2.5 在内核角度理解管道本质

如图:
进程间通信_第11张图片
所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了“Linux一切皆文件思想”。

demo版的进程池代码:

//test.cpp
#include "Task.hpp"
#include 
#include 
#include 
#include 
#include 
#include 
#include 
using namespace std;

vector<task_t> tasks;

//先描述
class channel
{
public:
    channel(int cmdfd, int slaverid, const string& processname)
        :_cmdfd(cmdfd)
        , _slaverid(slaverid)
        , _processname(processname)
    {}

    int _cmdfd;               // 发送任务的文件描述符
    pid_t _slaverid;          // 子进程的PID
    std::string _processname; // 子进程的名字 -- 方便我们打印日志
};

void slaver()
{
    while(1)
    {
        int cmdcode = 0;
        int n = read(0, &cmdcode, sizeof(int));
        if(n == sizeof(int))
        {
            //执行cmdcode对应的任务列表
            cout <<"slaver say@ get a command: "<< getpid() << " : cmdcode: " <<  cmdcode << endl;
            if(cmdcode >= 0 && cmdcode < tasks.size())
            {
                tasks[cmdcode]();
            }
        }

        if(n == 0)
        {
            break;
        }
    }
}

void InitProcessPool(vector<channel>* channels)
{

    for(int i = 0; i < 10; i++)
    {
        int pipefd[2];  // 临时空间
        int n = pipe(pipefd);
        assert(!n); // 演示就可以
        (void)n;

        pid_t id = fork();
        if(id == 0)
        {
            close(pipefd[1]);
            dup2(pipefd[0], 0);
            close(pipefd[0]);

            slaver();
            cout << "process : " << getpid() << " quit" << endl;
            exit(0);
        }

        // father
        close(pipefd[0]);

        // 添加channel字段了
        std::string name = "process-" + std::to_string(i);
        channels->push_back(channel(pipefd[1], id, name));
    }
}

void Menu()
{
    cout << "################################################" << endl;
    cout << "# 1. 刷新日志             2. 刷新出来野怪        #" << endl;
    cout << "# 3. 检测软件是否更新      4. 更新用的血量和蓝量  #" << endl;
    cout << "#                         0. 退出               #" << endl;
    cout << "#################################################" << endl;
}

void ctrlSlaver(const vector<channel>& channels)
{
    // 选择几号进程
    int which = 0;

    while(1)
    {
        int select = 0;
        Menu();
        cout << "Please Enter@ ";
        cin >> select;

        while(select < 0 || select >= 5)
        {
            cout << "Enter error" << endl;
            cout << "Please again Enter@ ";
            cin >> select;
        }

        if(select == 0)
        {
            break;
        }

        // 选择任务
        // int cmdcode = rand() % tasks.size(); --> 随机挑选
        int cmdcode = select - 1;
        cout << "father say: " << " cmdcode: " <<
            cmdcode << " already sendto " << channels[which]._slaverid << " process name: " 
                << channels[which]._processname << endl;

        write(channels[which]._cmdfd, &cmdcode, sizeof(cmdcode));

        which++;
        which %= channels.size();
    }

}

void QuitProcess(vector<channel>& channels)
{
    for(auto& e : channels)
    {
        close(e._cmdfd);
    }

    for(auto& e : channels)
    {
        waitpid(e._slaverid, nullptr, 0);
    }
}

int main()
{
    LoadTask(&tasks);
    //srand(time(nullptr) ^ getpid() ^ 1023); // 种一个随机数种子

    vector<channel> channels;
    //初始化创建管道和子进程
    InitProcessPool(&channels);

    // 开始控制子进程
    ctrlSlaver(channels);

    // 清理
    QuitProcess(channels);
    return 0;
}


//Task.hpp
#pragma once

#include 
#include 

typedef void (*task_t)();

void task1()
{
    std::cout << "lol 刷新日志" << std::endl;
}
void task2()
{
    std::cout << "lol 更新野区,刷新出来野怪" << std::endl;
}
void task3()
{
    std::cout << "lol 检测软件是否更新,如果需要,就提示用户" << std::endl;
}
void task4()
{
    std::cout << "lol 用户释放技能,更新用的血量和蓝量" << std::endl;
}

void LoadTask(std::vector<task_t> *tasks)
{
    tasks->push_back(task1);
    tasks->push_back(task2);
    tasks->push_back(task3);
    tasks->push_back(task4);
}

2.6 管道读写规则

pipe2函数与pipe函数类似,用于创建匿名管道,其函数原型如下:

int pipe2(int pipefd[2], int flags);

pipe2函数的第二个参数用于设置选项。

  1. 当没有数据可读时:

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

    • O_NONBLOCK disable:write调用阻塞,直到有进程读走数据。
    • O_NONBLOCK enable:write调用返回-1,errno值为EAGAIN。
  3. 如果所有管道写端对应的文件描述符被关闭,则read返回0。

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

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

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

2.7 管道特点

(1)管道内部自带同步与互斥机制。

  • 我们将一次只允许一个进程使用的资源,称为临界资源。
  • 管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。
  • 临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。
  • 为了避免这些问题,内核会对管道操作进行同步与互斥
  • 同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
  • 互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。
  • 实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。
  • 也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。
  • 子进程往管道里面写入,子进程去读取的时候,有数据就拿上来,没数据就不在读取而是阻塞式的等待管道数据写入,并非父进程sleep了,而是因为子进程写的慢,父进程必须等,而引起好像父进程sleep了,这种—个等另一个的现象叫做同步。

(2)管道的生命周期随进程。

管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。

(3)管道提供的是流式服务。

我们一般所谓的流式概念就是,给你提供一个通信的信道,你的写端就直接写,读端直接读,但是具体写多少,读多少完全有上层决定。底层就只是提供一个数据通信的信道就完了,它不关心数据本身的一些细节格式,这叫做面向字节流。

流式服务: 数据没有明确的分割,一次拿多少数据都行。
数据报服务: 数据有明确的分割,拿数据按报文段拿。

(4)管道是半双工通信的。

在数据通信中,数据在线路上的传送方式可以分为以下三种:

  • 单工通信:单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
  • 半双工通信:半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
  • 全双工通信:全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。
  • 管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。

2.8 管道的几种中特殊情况

  • 写端进程不写,读端进程一直读,那么此时会因为管道里面没有数据可读,对应的读端进程会被阻塞挂起,直到管道里面有数据后,读端进程才会被唤醒。
  • 读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被阻塞挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。
  • 写端进程将数据写完后将写端关闭,那么读端进程将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起。
  • 读端进程将读端关闭,而写端进程还在一直向管道写入数据,没有进程读取,那么写入的数据就没有意义,那么操作系统会将写端进程杀掉。

(1)前面两种情况就能够很好的说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒。

(2)如何理解阻塞挂起和唤醒

进程先立即停止执行,然后将PCB的状态改为阻塞状态,并将PCB插入相应的阻塞队列。
当被阻塞进程所期待的事情发生,将阻塞进程从阻塞队列中移出,将其PCB的状态改为就绪状态®,然后将PCB插入到就绪队列中.

(3)对第三种情况的理解,读端进程已经将管道当中的所有数据都读取出来了(读端就会read返回值0,代表文件结束),而且此后也不会有写端再进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起。

(4) 对第四种情况的理解,既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号。

(5)管道是单向通信,如果读端不读数据且把文件描述符关闭,那么写端做的就没有意义了。
写端相当于废弃的动作,浪费资源,所以OS直接将子进程干掉。为什么?OS不做不做任何浪费空间或者低效的事情,只要发现OS一定要把这个事情修正了。

(6)验证OS发送信号杀掉进程

#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

int main()
{
    int fd[2] = {0};
    int p = pipe(fd);
    if(p < 0)
    {
        perror("pipe");
    }

    pid_t id = fork();
    if (id == 0)
    { 	
        //child
		close(fd[0]);   //子进程关闭读端

		//子进程向管道写入数据
		const char* buf = "I am child...";
		while (1)
        {
			write(fd[1], buf, strlen(buf));
			sleep(1);
		}
 
		close(fd[1]); //子进程写入完毕,关闭文件
		exit(0);
	}
 
	//father
	close(fd[1]); //关闭写端
    int count = 5;
    char buf[64] = {0};
    while(count--)
    {
        buf[0] = 0;
        read(fd[0], buf, 13);
        cout << buf << endl;
        sleep(1);
    }

	close(fd[0]); //关闭读端
 
	int status = 0;
	waitpid(id, &status, 0);
	printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号

    return 0;
}

(7)运行结果:

进程间通信_第12张图片

(8)使用命令查看信号 kill - l

进程间通信_第13张图片

2.9 管道的大小

管道的容量是有限的,如果管道已满,那么写端将阻塞或失败,需要了解一下管道的大小。

(1)方法一:使用man手册
根据man手册,在2.6.11之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux 2.6.11往后,管道的最大容量是65536字节。


查看Linux系统版本

这里使用的是Linux 2.6.11之后的版本,因此管道的最大容量是65536字节。

(2)方法二:使用ulimit命令
可以使用ulimit -a 命令,查看当前资源限制的设定, 管道的最大容量是 512 × 8 = 4096 字节
进程间通信_第14张图片
(3)代码验证管道容量

  • 根据man手册得到的管道容量与使用ulimit命令得到的管道容量不同,测试验证
  • 代码概述: 读进程一直不读,写进程一直写,直到管道被写满
#include 
#include 
#include 
#include 
 
int main()
{
	int fd[2] = { 0 };
 
	if (pipe(fd) < 0)
	{ 
		//使用pipe创建匿名管道
		perror("pipe");
		return 1;
	}
 
	pid_t id = fork(); //使用fork创建子进程
	if (id == 0)
	{ 	
		//child 
		close(fd[0]); //子进程关闭读端
 
		char c = '.';
		int count = 0;
		while (1)
		{
			write(fd[1], &c, 1);
			count++;
			printf("%d\n", count); //打印当前写入的字节数
		}
 
		close(fd[1]);
		exit(0);
	}
	
	//father
	close(fd[1]); //父进程关闭写端
	
    //父进程不读取数据
	waitpid(id, NULL, 0);
	close(fd[0]);
	return 0;
}
  • 在读端进程不进行读取的情况下,写端进程最多写65536字节的数据就被操作系统挂起了,也就是说,我当前Linux版本中管道的最大容量是65536字节

进程间通信_第15张图片

3. 命名管道

3.1 基本概念

  • 匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到
  • 命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。
  • 普通文件是很难做到通信的,即便做到通信也无法解决一些安全问题。
  • 命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,但这个映像的大小永远为0,因为命名管道和匿名管道都不会将通信数据刷新到磁盘当中。

3.2 创建一个命名管道

(1)命名管道从命令行上创建,命令行方法是使用下面这个命令:

mkfifo filename

进程间通信_第16张图片

(2)通过命令使用命名管道:

①一个进程通过shell脚本重定向到管道 ,不断地往 pipe管道里面写数据 ;另一个进程使用cat命令 不断地读数据。这两个毫不相关的进程可以通过命名管道进行数据传输,完成了通信。

进程间通信_第17张图片

②使用cat命令的读进程主动退出,另一个写进程就被杀掉了; 因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。(当管道的读端进程退出后,写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉)

进程间通信_第18张图片

(3)命名管道从程序里创建,相关函数有:

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

mkfifo函数的第一个参数是:pathname,表示要创建的命名管道文件。

  • 若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。
  • 若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。(注意当前路径的含义)

mkfifo函数的第二个参数mode:表示创建命名管道文件的默认权限。

返回值: 命名管道创建成功,返回0;命名管道创建失败,返回-1。

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

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

3.4 命名管道的打开规则

(1)如果当前打开操作是为读而打开FIFO时。

  • O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO。
  • O_NONBLOCK enable:立刻返回成功。

(2)如果当前打开操作是为写而打开FIFO时。

  • O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO。
  • O_NONBLOCK enable:立刻返回失败,错误码为ENXIO。

3.5 命名管道的四个使用示例

(1)通过命名管道 ,client & server进行通信

①comm.hpp


#pragma once

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
using namespace std;

#define FIFO_FILE "./myfifo"
#define MODE 0664

enum
{
    FIFO_CREATE_ERR = 1,
    FIFO_DELETE_ERR,
    FIFO_OPEN_ERR
};

class Init
{
public:
    Init()
    {
        int n = mkfifo(FIFO_FILE, MODE);
        if(n < 0)
        {
            perror("mkfifo");
            exit(FIFO_CREATE_ERR);
        }
    }

    ~Init()
    {
        int m = unlink(FIFO_FILE);
        if (m == -1)
        {
            perror("unlink");
            exit(FIFO_DELETE_ERR);
        }
    }
};

②server.cpp
实现服务端(server)和客户端(client)之间的通信之前,我们需要先让服务端运行起来,我们需要让服务端运行后创建一个命名管道文件,然后再以读的方式打开该命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。

#include "comm.hpp"

int main()
{
    Init init;

    // 打开管道
    int fd = open(FIFO_FILE, O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        exit(FIFO_OPEN_ERR);
    }

    // 开始通信
    while(1)
    {
        char buf[1024];
        int x = read(fd, buf, sizeof(buf));
        if (x > 0)
        {
            buf[x] = 0;
            cout << "client say# " << buf << endl;
        }
        else if (x == 0)
        {
            perror("read");
            break;
        }
        else
            break;
    }

    close(fd);
    return 0;
}

③client.cpp
对于客户端来说,因为服务端运行起来后命名管道文件就已经被创建了,所以客户端只需要以写的方式打开该命名管道文件,之后客户端就可以将通信信息写入到命名管道文件当中,进而实现和服务端的通信。

#include "comm.hpp"

int main()
{
    int fd = open(FIFO_FILE, O_WRONLY);
    if(fd < 0)
    {
        perror("open");
        exit(FIFO_OPEN_ERR);
    }

    cout << "client open file done" << endl;

    string line;
    while(1)
    {
        cout << "Please Enter@ ";
        getline(cin, line);

        write(fd, line.c_str(), line.size());
    }

    close(fd);
    return 0;
}

Makefile快速编译:

.PHONY:all
all:server client

server:server.cpp
	g++ -o $@ $^ -g -std=c++11
client:client.cpp
	g++ -o $@ $^ -g -std=c++11

.PHONY:clean
clean:
	rm -f server client

④运行结果:

进程间通信_第19张图片

⑤client 和 server 谁先退出?

如果客户端先退出,服务端将管道当中的数据读完后就再也读不到数据了,那么此时服务端也就会去执行它的其他代码了(在当前代码中则是直接退出了)。

进程间通信_第20张图片

如果服务端先退出,客户端写入管道的数据就不会被读取了,也就没有意义了,那么当客户端下一次再向管道写入数据时,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端就被操作系统强制杀掉了。

进程间通信_第21张图片

⑥通信是在内存当中进行的

进程间通信_第22张图片

运行程序前后两次查看myfifo管道文件的大小始终为0,说明了双方进程之间的通信依旧是在内存当中进行的,和匿名管道通信是一样的。

(2)通过命名管道,派发计算任务
(3)通过命名管道,进行命令操作
(4)通过命名管道,进行文件拷贝

3.6 命令行中的管道理解

(1)使用cat 和 grep 命令 , 利用 管道 “ | ” 对信息进行过滤

(2)管道“ | ” 是匿名管道还是命名管道 ?

①由于匿名管道只能用于有亲缘关系的进程之间的通信,而命名管道可以用于两个毫不相关的进程之间的通信,因此我们可以先看看命令行当中用管道(“|”)连接起来的各个进程之间是否具有亲缘关系。

②通过管道连接了三个进程,通过ps命令查看这三个进程可以发现,这三个进程的PPID是相同的,也就是说它们是由同一个父进程创建的子进程。

进程间通信_第23张图片

③即这三个sleep进程的父进程是bash ,三个sleep进程互为兄弟

所以若是两个进程之间采用的是命名管道,那么在磁盘上必须有一个对应的命名管道文件名,而实际上我们在使用命令的时候并不存在类似的命名管道文件名,因此命令行上的管道实际上是匿名管道

4. system V进程间通信

  1. 管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而system V IPC是操作系统特地设计的一种通信方式。但是不管怎么样,它们的本质都是一样的,都是让不同的进程看到同一份资源。

  2. system V IPC提供的通信方式有以下三种:

    • system V共享内存
    • system V消息队列
    • system V信号量
  3. 其中,system V共享内存和system V消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然system V信号量和通信好像没有直接关系,但属于通信范畴。

5. system V共享内存

5.1 共享内存示意图

共享内存让不同进程看到同一份资源的方式 : 在物理内存当中申请一块内存空间,然后将这块内存空间分别与需要进行进程间通信的进程的页表之间建立映射,再将虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。

进程间通信_第24张图片
物理内存映射到地址空间中:

①如何实现: 本质就是修改页表,虚拟地址空间中开辟空间
②是谁做的: 开辟物理空间开辟虚拟地址、建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成

5.2 共享内存数据结构

(1)在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。

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

(2)当我们申请了一块共享内存后,为了让要实现通信的进程能够看到同一个共享内存,因此每一个共享内存被申请时都要有一个key值,这个key值用于标识系统中共享内存的唯一性。也就是让两个进程看到的是同一个共享内存。

如上共享内存数据结构的第一个成员是shm_perm,shm_perm是一个ipc_perm类型的结构体变量,每个共享内存的key值都是存储在shm_perm这个结构体变量当中,其中ipc_perm结构体的定义如下:

struct ipc_perm {
	__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;
};

5.3 共享内存的建立和释放

(1)共享内存的建立大致有如下两个过程:

  • 在物理内存当中申请共享内存空间。
  • 将申请到的共享内存挂接到地址空间,即建立映射关系。

(2)共享内存的释放大致有如下两个过程:

  • 将共享内存与地址空间去关联,即取消映射关系。
  • 释放共享内存空间,即将物理内存归还给系统。

6. 共享内存函数和使用

6.1 共享内存创建

6.1.1 shmget函数

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

参数说明:

  • key:表示待创建共享内存在系统当中的唯一标识。
  • size:表示待创建共享内存的大小。
  • shmflg:表示创建共享内存的方式。

返回值说明:

  • shmget调用成功:返回一个有效的共享内存标识符(用户层标识符)。
  • shmget调用失败:返回-1。

shmget函数的第三个参数shmflg,常用的组合方式:

  • IPC_CREAT:如果不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的标识符;如果存在这样的共享内存,则直接返回该共享内存的标识符。
  • IPC_CREAT | IPC_EXCL :如果不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的标识符;如果存在这样的共享内存,则出错返回。

使用结果:

  • 使用组合IPC_CREAT:一定会获得一个共享内存的标识符,调用成功情况下无法确认该共享内存是否是新建的共享内存。
  • 使用组合IPC_CREAT | IPC_EXCL:只有shmget函数调用成功时才会获得共享内存的标识符,并且该共享内存一定是新建的共享内存

6.1.2 共享内存唯一标识符key需要通过ftok函数获取

key_t ftok(const char *pathname, int proj_id);
  • 函数功能:将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取。(将pathname 和 proj_id当成数据通过算法生成了一个序号id)
  • 需要进行通信的各个进程,在使用ftok函数获取key值时,都需要采用同样的路径名和整数标识符,进而生成同一种key值,然后才能找到同一个共享资源。
  • 如果生成key失败,可以通过改变proj_id的值再次尝试

6.1.3 ftok函数和shmget函数使用

(1)ftok函数和shmget函数代码演示:

#include 
#include  
#include  
#include  
#include 
		
#define PATHNAME "./server.cpp" //路径名

#define PROJ_ID 0x1234 //整数标识符
#define SIZE 4096 //共享内存的大小
 
 
int main()
{
    key_t k = ftok(PATHNAME, PROJ_ID);  // 生成唯一key
    if (k < 0)
    {
        perror("ftok error!\n");
        return 1;
    }

    printf("key: %x\n", k);

    int shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0644); // 创建共享内存
    if (shmid < 0)
    {
        perror("shmget");
        return 2;
    }

    printf("shmid: %d\n", shmid);

    return 0;
}

(2)运行结果:

(3)再次运行:

进程间通信_第25张图片这是进程退出后,申请的共享内存依旧存在,并没有被操作系统释放,在次get后就会出错。

(4)查看共享内存信息

①使用ipcs命令查看

单独使用ipcs命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项:

  • -m:列出共享内存相关信息。
  • -s:列出信号量相关信息。
  • -q:列出消息队列相关信息。

进程间通信_第26张图片

②使用ipcs -m查看共享内存

进程间通信_第27张图片

上图各个标题的含义分别是:

标题 含义
key 系统区别共享内存的唯一标识
shmid 共享内存的用户层id
owner 共享内存的拥有者
perms 共享内存的权限
bytes 共享内存的大小
nattch 关联共享内存的进程数
status 共享内存的状态

注意: key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,key和shmid之间的关系相当于fd和FILE*之间的的关系。

(5)第二个参数:size

进程间通信_第28张图片
如果我们把size设置成4097 ,在操作系统底层会给你分配2页(按页对齐),但是你要4097字节那么我就只让你看到4097个字节的空间,绝对不少给你但也不多给你,少给了可能会出问题,多给了也可能出问题,所以最好设置4096的整数倍。

6.2 共享内存释放

  • 由创建共享内存的实验可以发现,进程退出后,申请的共享内存依旧存在,并没有被操作系统释放。
  • 管道是生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。
  • 这就说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源一定是由内核提供并维护的。

6.2.1 使用命令删除共享内存

使用ipcrm -m shmid命令释放指定id的共享内存资源

[xiaomaker@VM-28-13-centos test_11_21]$ ipcrm -m 0

进程间通信_第29张图片

6.2.2 shmctl函数删除

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

参数:

  • shmid:表示所控制共享内存的用户级标识符。
  • cmd:表示具体的控制动作。
  • buf:用于获取或设置所控制共享内存的数据结构。

返回值:

  • shmctl调用成功:返回0。
  • shmctl调用失败:返回-1。

(1)shmctl函数的cmd参数传入的常用的选项:

  • IPC_STAT:获取共享内存的当前关联值,此时参数buf作为输出型参数
  • IPC_SET:在进程有足够权限的前提下,将共享内存的当前关联值设置为buf所指的数据结构中的值
  • IPC_RMID:删除共享内存段

(2)shmctl函数代码演示:

#include 
#include  
#include  
#include  
#include 
		
#define PATHNAME "./server.cpp" //路径名

#define PROJ_ID 0x1234 //整数标识符
#define SIZE 4096 //共享内存的大小
 
 
int main()
{
    key_t k = ftok(PATHNAME, PROJ_ID);  // 生成唯一key
    if (k < 0)
    {
        perror("ftok error!\n");
        return 1;
    }

    printf("key: %x\n", k);

    int shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0644); // 创建共享内存
    if (shmid < 0)
    {
        perror("shmget");
        return 2;
    }

    printf("shmid: %d\n", shmid);

    sleep(5);
    shmctl(shmid, IPC_RMID, NULL); // 释放共享内存

    return 0;
}

(3)运行server.cpp5s后释放共享内存,使用shell脚本监视:

[xiaomaker@VM-28-13-centos test_11_21]$ while :; do ipcs -m; echo "##################" ; sleep 1 ;done

(4)运行结果:

进程间通信_第30张图片

6.3 共享内存关联

6.3.1 shmat函数

void* shmat(int shmid, const void *shmaddr, int shmflg);

参数:

  • shmid:表示待关联共享内存的用户级标识符。
  • shmaddr:指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置。
  • shmflg:表示关联共享内存时设置的某些属性。

返回值:(和malloc很像)

  • shmat调用成功:返回共享内存映射到进程地址空间中的起始地址。
  • shmat调用失败:返回 -1。

参数shmflg传入的常用的选项:

  • SHM_RDONLY:关联共享内存后只进行读取操作
  • SHM_RND:若shmaddr不为NULL,则关联地址自动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA)
  • 0:默认为读写权限

(1)shmat函数代码演示:

#include 
#include  
#include  
#include  
#include 
		
#define PATHNAME "./server.cpp" //路径名
 
#define PROJ_ID 0x1234 //整数标识符
#define SIZE 4096 //共享内存的大小
 
 
int main()
{
    key_t k = ftok(PATHNAME, PROJ_ID); // 生成唯一key
    if (k < 0)
    {
        perror("ftok error!\n");
        return 1;
    }

    printf("key: %x\n", k);

    int shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0664); // 创建共享内存
    if (shmid < 0)
    {
        perror("shmget");
        return 2;
    }

    printf("shmid: %d\n", shmid);

    printf("attach begin!\n");

    char* mem = (char*)shmat(shmid, NULL, 0); // 关联共享内存
    if (mem == "-1")
    {
        perror("shmat");
        return 3;
    }
    printf("attach end!\n");

    sleep(5);

    shmctl(shmid, IPC_RMID, NULL); // 释放共享内存

    return 0;
}

(2)运行结果:

进程间通信_第31张图片

6.4 共享内存去关联

6.4.1 shmdt函数

int shmdt(const void *shmaddr);

参数:

  • shmaddr:由shmat所返回的指针,即调用shmat函数时得到的起始地址。

返回值:

  • shmdt调用成功:返回0。
  • shmdt调用失败:返回-1。

注意:将共享内存段与当前进程脱离不等于删除共享内存,只是取消了当前进程与该共享内存之间的联系。

6.5 client和serve进行共享内存通信

(1)comm.hpp共同包含的头文件
为了让client和server在使用ftok函数获取key值时,能够得到同一种key值,那么服务端和客户端传入ftok函数的路径名和和整数标识符必须相同,这样才能生成同一种key值,进而找到同一个共享内存进行挂接。

#pragma once
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

const int size = 4096; 
#define pathname "/home/xiaomaker"
const int proj_id = 0x6666;

key_t Getkey()   //创建唯一标识符k值
{
    key_t k = ftok(pathname, proj_id);
    if(k < 0)
    {
        perror("ftok");
        exit(1);
    }

    return k;
}

int GetShareMemHelper(int flag)  //创建共享内存
{
    key_t k = Getkey();
    int shmid = shmget(k, size, flag);
    if(shmid < 0)
    {
        perror("shmget");
        exit(1);
    }

    return shmid;
}

int CreateShm()
{
    return GetShareMemHelper(IPC_CREAT | IPC_EXCL | 0666);
}

int GetShm()
{
    return GetShareMemHelper(IPC_CREAT); 
}

#define FIFO_FILE "./myfifo"
#define MODE 0664


class Init
{
public:
    Init()
    {
        int n = mkfifo(FIFO_FILE, MODE);
        if(n < 0)
        {
            perror("mkfifo");
            exit(1);
        }

    }

    ~Init()
    {

        int m = unlink(FIFO_FILE);
        if (m == -1)
        {
            perror("unlink");
            exit(1);
        }
    }

};

(3)server.c 建立共享内存,并建立关联,从共享内存中接收数据

#include "comm.hpp"

int main()
{
    Init init;
    int shmid =  CreateShm();
    char* shaddr = (char*)shmat(shmid, nullptr, 0);

    int fd = open(FIFO_FILE, O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        exit(1);
    }

    //struct shmid_ds shmds;
    while (1)
    {
        char c;
        ssize_t s = read(fd, &c, 1);  //看client是否写入了数据
        if (s == 0)
        {
            break;
        }
        else if (s < 0)
        {
            break;
        }
        std::cout << "client say@ " << shaddr; //直接访问共享内存
        sleep(1);

        shmctl(shmid, IPC_RMID, nullptr);
        //shmctl(shmid, IPC_STAT, &shmds);
        //std::cout << "shm size: " << shmds.shm_segsz << std::endl;
        //std::cout << "shm nattch: " << shmds.shm_nattch << std::endl;
        //printf("shm key: 0x%x\n",  shmds.shm_perm.__key);
        //std::cout << "shm mode: " << shmds.shm_perm.mode << std::endl;

    }

    shmdt(shaddr);  //去关联
    shmctl(shmid , IPC_RMID , nullptr); //删除共享内存
    close(fd);

    return 0;
}

(4)client.c 通过k找到共享内存建立关联,向共享内存中发送数据

#include "comm.hpp"

int main()
{
    int shmid = GetShm();
    char* shaddr = (char*)shmat(shmid, nullptr, 0);

    int fd = open(FIFO_FILE, O_WRONLY); // 等待写入方打开之后,自己才会打开文件,向后执行, open 阻塞了!
    if (fd < 0)
    {
        perror("open");
        exit(1);
    }

    
    while (1)
    {
        std::cout << "Please Enter@ ";
        fgets(shaddr, 4096, stdin);
        write(fd, "c", 1); // 通知对方

        //shaddr[i] = 'A' + i;
        sleep(1);
        //i++;
        //shaddr[i] = '\0';
    }

    shmdt(shaddr); //去关联
    close(fd);

    return 0;
}

(4)运行结果:

进程间通信_第32张图片

7. 共享内存和管道的比较

7.1 通信速度比较

当共享内存创建好后就不再需要调用系统接口进行通信了(直接对地址空间进行操作),而管道创建好后仍需要read、write等系统接口进行通信。实际上,共享内存是所有进程间通信方式中最快的一种通信方式

7.2 管道的数据拷贝过程

read是把数据从内核缓冲区复制到进程缓冲区 , write是把进程缓冲区复制到内核缓冲区
进程间通信_第33张图片

7.3 共享内存的数据拷贝过程

  • 写进程直接将数据写到共享内存中。
  • 读进程从共享内存中读数据

进程间通信_第34张图片
(1)所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。

(2)但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。

7.4 为什么共享内存是速度最快的IPC方法?

  • 共享内存的拷贝次数少

  • 在使用共享内存时不涉及系统调用接口(也就是不会有内核态到用户态之间的转化,因为都是在用户层进行操作的)

  • 不提供任何保护机制(没有同步与互斥)

7.5 为什么共享内存的拷贝次数少?

共享内存的使用方法就和使用堆空间类似,直接向共享内存中写入,另一个进程直接就能看到。它与管道不同,管道需要拷贝数据到管道,另一个进程再从管道中拷贝数据到自己当中。

8. system V消息队列

消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,两个进程向对方发数据时,都在消息队列的队尾添加数据块,两个进程获取数据块时,都在消息队列的队头取数据块。其中消息队列当中的某一个数据块是由谁发送给谁的,取决于数据块的类型。

进程间通信_第35张图片
总结如下:

  • 消息队列提供了一个从一个进程向另一个进程发送数据块的方法。
  • 每个数据块都被认为是有一个类型的,接收者进程接收的数据块可以有不同的类型值。
  • 消息队列和共享内存一样,消息队列的资源也必须自行删除,否则不会自动清除,因为system V IPC资源的生命周期是随内核的。

9. system V信号量

9.1 信号量概念

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

9.2 同步和互斥

  1. 进程间通信通过共享资源来实现,这虽然解决了通信的问题,但是也引入了新的问题,那就是通信进程间共用的临界资源,若是不对临界资源进行保护,就可能产生各个进程从临界资源获取的数据不一致等问题
  2. 保护临界资源的本质是保护临界区,我们把进程代码中访问临界资源的代码称之为临界区,信号量就是用来保护临界区的,信号量分为二元信号量和多元信号量。
  3. 例如现在有一份500字节的资源,平均分成5份,每份100字节,每一份用一个信号量进行标识,总共有5个信号量
  4. 信号量本质是一个计数器,在二元信号量中,信号量的个数为1(相当于将临界资源看成一整块),二元信号量本质解决了临界资源的互斥问题,通过如下图的代码进行理解:
    进程间通信_第36张图片

上面代码的本质是:当进程A申请访问共享内存资源时,如果此时sem为1(sem代表当前信号量个数),则进程A申请资源成功,此时这个资源就被A进程占有,此时需要将sem- -,然后进程A就可以对共享内存进行一系列操作,如果在进程A在访问共享内存时,进程B想要申请访问该共享内存资源,此时sem就为0了,那么这时进程B会被挂起,直到进程A访问共享内存结束后将sem++,此时才会将进程B唤起,然后进程B再对该共享内存进行访问操作。

在这种情况下,无论什么时候都只会有一个进程在对同一份共享内存进行访问操作,也就解决了临界资源的互斥问题。

  1. PV操作,P操作就是申请信号量,而V操作就是释放信号量。计数器在加加或者减减的时候是不安全的,所以需要将它保护起来。
    进程间通信_第37张图片

9.3 理解信号量

  • 我们知道信号量的本质就是计数器,就类似int sem = n;(但不是等于),它是描述临界资源中资源数量的多少!
  • 要访问临界资源就先要申请信号量计时器资源。
  • 申请计数器成功就表示我具有访问资源的权限了。
  • 申请了计数器资源是对资源的预订机制,并没有开始访问资源。
  • 计数器可以有效保证进入共享资源的执行流数量。
  • 所以每一个执行流想要访问共享资源中的一部分时,不是直接访问,而是先申请计数器资源。

你可能感兴趣的:(Linux系统,数据库,运维,c++)