文章目录
- [📖 前言](#📖 前言)
- [1. 共享内存](#1. 共享内存)
- [2. 创建共享内存](#2. 创建共享内存)
-
- [2.1 ftok()创建key值:](#2.1 ftok()创建key值:)
- [2.2 shmget()创建共享内存:](#2.2 shmget()创建共享内存:)
- [2.3 ipcs指令:](#2.3 ipcs指令:)
- [2.4 shmctl()接口:](#2.4 shmctl()接口:)
- [2.5 shmat()/shmdt()接口:](#2.5 shmat()/shmdt()接口:)
- [2.6 共享内存没有访问控制:](#2.6 共享内存没有访问控制:)
- [2.7 通过管道对共享内存进行控制:](#2.7 通过管道对共享内存进行控制:)
- [3. 相关概念](#3. 相关概念)
- [4. 信号量](#4. 信号量)
-
- [4.1 原子性说明:](#4.1 原子性说明:)
📖 前言
上一章我们由进程通信,引入并讲述了管道,匿名管道和命名管道和匿名管道。本章我们将继续讲解进程通信的另一种方式,通过共享内存的方式来进行进程间的通信。还要学习几个系统调用接口,并用代码实现两个进程通过共享内存来进行通信。目标已经确定,接下来就要搬好小板凳,准备开讲了...🙆🙆🙆🙆
本章讲的是system V
的共享内存。
1. 共享内存
之前我们学习进程地址空间时就已经对进程地址空间的构成有了相对的认识【进程地址空间 - 复习】:
- 我们知道堆栈相对而生,堆从低地址向高地址生长,栈从高地址向低地址生长,而在这两块空间之间的则是
共享区
。 - 堆栈之间的区域特别大,堆栈之间的区域称为共享区,其中共享库就在这里。
假设有种接口:
- 能在物理内存创建空间。
- 通过两个进程调用接口,然后物理内存中的空间映射到自己的地址空间上,然后将空间的起始地址返回给用户,此时用户就能通过页表找到物理内存的空间。
此时两个进程各自都完成了,第一步创建共享内存,第二步分别将共享内存挂接到各自的进程上下文里面。
- 进程间通信的前提是:先让不同的进程,看到同一份资源!
- 共享内存是一种进程间通信机制,它允许多个进程共享同一块物理内存区域(就能同时看到一份资源)。
- 当一个进程向共享内存写入数据时,实际上是将数据直接写入到共享内存所对应的物理内存中。
- 其他进程可以通过读取相同的共享内存区域来获取已写入的数据。
优点:
- 与其他进程间通信方式(如管道、消息队列等)不同。
- 共享内存避免了数据的复制和传输过程,因此具有较高的效率。
- 它可以提供快速的数据交换,特别适用于需要频繁共享大量数据的进程间通信场景。
2. 创建共享内存
2.1 ftok()创建key值:
- 共享内存存在哪里?
- 内核中 ------ 内核会给我们维护共享内存的结构!
- 共享内存也要被管理起来!一定是先描述,再组织!
-
- 系统中可能有很多对进程都在用共享内存通信,所以操作系统也要将它们管理起来。
- 我怎么知道,这个共享内存是存在还是不存在?
- 先有方法,标识共享内存的唯一性!
- 共享内存,在内核中,让不同的进程看到同一份共享内存。
- 做法是:让他们拥有同一个key即可!
解释:
- 每个共享内存都是由一个唯一的key值来标识的。在操作系统中,共享内存是进程间进行通信的一种方式,它允许多个进程共享同一块物理内存区域。
- 为了实现这种共享,操作系统会给每个共享内存区域分配一个唯一的key值来标识它,其他进程可以通过这个key值来访问并操作该共享内存区域。
- 这样就可以实现多个进程之间的数据交换和共享。
在共享内存里只要保证这个key是唯一的就可以了。至于这个key是多少不重要。
创建key值的函数:
Linux系统给定了ftok接口,将用户提供的pathname
工作路径,以及proj_id
项目编号转换为一个共享内存的key(其实就是int类型)。
- 底层会将第一个参数对应路径文件的
inode
和指定的项目id
这两个数字做组合形成一个唯一值,返回给一个key。 - 底层是一些列的算法设计,相当于是帮我们构建具有唯一性的数字就可以了。
返回值:
成功了返回key值,失败了就返回-1。
2.2 shmget()创建共享内存:
使用前提是要有一个唯一的key值:
- 第二个参数建议设置成页(4KB)的整倍数:
- 操作系统和磁盘lO的基本单位大小是4KB。
- 从磁盘拷贝到内存是以4KB为单位拷贝的。
- 所以共享内存在申请大小时,也一定是4KB的整数倍。
- 操作系统只会按照整4KB来申请共享内存,是向上取整。
如果我们申请的是4097,那么操作系统申请的是8KB,但是我们只能用4097,因为只要了这么多。
-
shmflg
的设置:
IPC_CREAT
:创建共享内存,如果已经存在,就获取之,不存在就创建之。-
获取
:可以获取成功,但是不知道这个共享内存,是本进程创建还是别的进程创建的拿过来的。
IPC_EXCL
:必须配合IPC_CREAT
使用(用按位或|
配合使用),如果不存在指定的共享内存,创建之,如果存在了,出错返回。-
核心作用
: 可以保证,如果shmget
函数调用成功,一定是一个全新的share memory
(共享内存)。
-
- 如果这两个选项合着一起配合使用,一起传, 只要函数调用成功了,得到的一定是个全新的共享内存。
0666或权限值
:指定共享内存的访问权限。这些权限值使用八进制表示,例如0666表示可读可写权限。
返回值:
如果成功的话返回一个标志符号,如果失败的话返回-1,errno
被设置。
- key值是由谁提供:
key值得由用户来提供。
- 如果key值是由操作系统统一提供的话:
-
- 那么调用
shmget
接口,获取共享内存的时候,操作系统给一个key值。
- 那么调用
-
- 另一个进程如何知道该进程key值呢??是不可能知道的。
-
- 那么就不能确定唯一性了,那么不同的应用程序可能会得到相同的key。
-
- 这样一来,就有可能导致两个或多个应用程序之间共享同一个共享内存段。
-
- 这样的共享可能会引发数据混乱、冲突以及安全性问题。
- 如果key值是由用户提供的话:
-
- 那么一个进程就可以提供一个key值让操作系统帮它创建一个共享内存,并且约定好让其他进程也使用同样的key值。
-
- 这二者只要有一个创建了共享内存,
能够将key值设置在内核里
,那另一个进程则可以用同样的key值找,就可以看到同一个共享内存了。
- 这二者只要有一个创建了共享内存,
-
- 这就叫做看到了同一份共享资源了。
2.3 ipcs指令:
通过上述讲解,我们只要获取到唯一的key值,调用shmget
函数就能获得共享内存了,创建好了我们怎样才能知道有哪些IPC
资源呢?
看一下ipcs指令的几个选项:
powershell
ipcs -c #查看消息队列/共享内存/信号量
ipcs -s #单独查看信号量
ipcs -q #单独查看消息队列
ipcs -m #单独查看共享内存
ipcs -m
查看shm list
。
- key值是十六进制,我们创建出来看到的则是十进制。
- nattch是挂接上的进程的数量。
- bytes是申请的共享内存大小。
ipcrm -m
删除共享内存:
- 只要在指令后跟上自己要删除的共享内存的shmid值就可以了
如果不显示删除共享内存那么就只能通过重启云主机的方式来删除共享内存了。
为什么删除共享内存要用shmid而不用key呢?
- key是操作系统中用来标识共享内存唯一性的, 在用户层用的是
shmid
。 - 在用户层访问共享内存只能用数字
shmid
。
2.4 shmctl()接口:
shmctl
这个函数可以用于操作共享内存:
- 第一个参数:想对哪个共享内存做操作。
- 第二个参数:想对这个共享内存做什么操作。
- 第三个参数:如果
IPC_RMID
如果被设置了,shmid_ds
设置成nullptr
就可以了,还有一些其他的属性设置。
第二个参数可以是:
IPC_STAT
:用于获取共享内存段的状态信息,包括共享内存段大小、访问权限等。IPC_SET
:用于设置共享内存段的状态信息,如更改访问权限、起始地址等。IPC_RMID
:用于删除共享内存段,释放相关的资源。
此外,第二个参数还可以与其他标志进行按位或操作,以指定额外的选项或标志,例如:
IPC_INFO
:用于获取当前共享内存资源的统计信息。SHM_INFO
:用于获取当前系统上共享内存段的信息。SHM_STAT
:用于获取当前共享内存段的详细信息。
2.5 shmat()/shmdt()接口:
- at是attach的简写,把一个共享内存附在进程上。
- dt是detach的简写,把一个共享内存从进程上脱离下来(去关联)。
返回值:
- 因为
shmat
函数的返回值是一个void*
类型的指针,所以我们就可以像使用malloc
一样的方式使来挂接共享内存了。 - 随后对这个共享内存的操作就像平时使用数组一样的方式来使用了。
同样的道理另一个进程也需要挂接这个共享内存才能实现两个进程通过共享内存来进行通信。
注意:
- 共享内存是一种特殊的内存区域,可以由多个进程同时访问。
- 尽管共享内存由一个进程创建,但它并不属于任何一个特定的进程。
2.6 共享内存没有访问控制:
在我们之前的学习中知道,管道是有访问控制的进程通信方式,当写端没有写入数据的时候(空管道),读端的read
接口会进行等待,直到有管道中有数据写入。
而共享内存的申请更想是我们直接向操作一个malloc
出来的空间一样,进程只要有权限,就可以直接拿来用,不向管道那样是个文件,还要通过read/write
接口来访问。所以操作系统没有办法帮我们进行访问控制
管道:
- 首先将数据从外设拷贝到进程上下文代码里面。
- 再把代码拷贝到管道里面,再调用
write
把数据拷贝到另一个进程的上下文。 - 再继续还要将数据拷贝到外设里,一共至少要经历四次拷贝。
共享内存:
- 写到共享内存里,对方立马能看到。
- 用共享内存,如果不考虑外设,最多拷贝一次,一方写入,另一方立马能看到。
2.7 通过管道对共享内存进行控制:
Log.hpp:
cpp
#pragma once
#include <iostream>
#include <ctime>
std::ostream &Log()
{
std::cout << "For Debug | " << "timestamp: " << (uint64_t)time(nullptr) << " | ";
return std::cout;
}
Comm.hpp:
cpp
#pragma once
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <cassert>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include "Log.hpp"
using namespace std;
#define PATH_NAME "/home/Zh_Ser/linux"
#define PROJ_ID 0x14
#define MEM_SIZE 4096
#define FIFO_FILE "./.fifo"
// hpp是将头文件和源文件写在一起的方式
// 函数的定义可以放在里面,一般在开源的项目里用
key_t CreateKey()
{
key_t key = ftok(PATH_NAME, PROJ_ID);
if (key < 0)
{
cerr << "ftok: " << strerror(errno) << endl;
exit(1);
}
return key;
}
// 创建命名管道
void CreatFifo()
{
umask(0);
if (mkfifo(FIFO_FILE, 0666) < 0)
{
Log() << strerror(errno) << endl;
exit(2);
}
}
#define READER O_RDONLY
#define WRITER O_WRONLY
int Open(const string& filename, int flags)
{
return open(filename.c_str(), flags);
}
int Wait(int fd)
{
uint32_t values = 0;
ssize_t s = read(fd, &values, sizeof(values));
return s;
}
void Signal(int fd)
{
uint32_t cmd = 1;
int s = write(fd, &cmd, sizeof(cmd));
}
void Close(int fd, const string filename)
{
close(fd);
unlink(filename.c_str());
}
IpcShmCli.cpp:
cpp
#include "Comm.hpp"
#include "Log.hpp"
// 充当使用共享内存的角色
int main()
{
int fd = Open(FIFO_FILE, WRITER);
cout << "Client: " << fd << endl;
// 创建相同的key值
key_t key = CreateKey();
Log() << "key: " << key << endl;
// 等待Server端先创建共享内存
sleep(1);
// 获取共享内存
int shmid = shmget(key, MEM_SIZE, IPC_CREAT);
if (shmid < 0)
{
Log() << "IpcShmCli shmget: " << strerror(errno) << endl;
return 2;
}
// 挂接
char* str = (char*)shmat(shmid, nullptr, 0);
// sleep(5);
// 用它
// 用共享内存,竟然没有使用任何系统调用接口
// 直接向str空间写入
while (true)
{
printf("Please Enter# ");
fflush(stdout);
// 往共享内存写数据
ssize_t s = read(0, str, MEM_SIZE);
if (s > 0)
{
str[s] = '\0';
}
Signal(fd);
}
// 去关联
shmdt(str);
// 不需要删除
return 0;
}
IpcShmSer.cpp:
cpp
#include "Comm.hpp"
#include "Log.hpp"
// 创建全新的共享内存
const int flags = IPC_CREAT | IPC_EXCL;
// 充当创建共享内存的角色
int main()
{
// 创建管道
CreatFifo();
int fd = Open(FIFO_FILE, READER);
cout << "Server: " << fd << endl;
assert(fd >= 0);
// 创建Key
key_t key = CreateKey();
Log() << "key: " << key << endl;
Log() << "create share memory begin" << endl;
int shmid = shmget(key, MEM_SIZE, flags | 0666);
if (shmid < 0)
{
Log() << "IpcShmSer shmget: " << strerror(errno) << endl;
return 2;
}
Log() << "create shm success, shmid: " << shmid << endl;
// 1. 将共享内存和自己的进程产生关联attch
char* str = (char*)shmat(shmid, nullptr, 0);
Log() << "attach shm: " << shmid << "success" << endl;
// 用它
// 服务器端直接用
while (true)
{
sleep(1);
// 在管道当中等,让读端进行等待
if (Wait(fd) <= 0) break;
// 从共享内存里读数据
printf("%s\n", str);
}
// 2. 去关联shmdt的返回值就是shmat的返回值
shmdt(str);
Log() << "detach shm: " << shmid << "success" << endl;
// 删除
shmctl(shmid, IPC_RMID, nullptr);
Log() << "delete shm: " << shmid << "success" << endl;
Close(fd, FIFO_FILE);
return 0;
}
我们先执行服务端,再执行用户端,我们会发现一个现象,服务端会等待,当用户端启动后两边才开始都跑起来。
这是open接口的阻塞等待:(重点)
- 在使用open函数打开一个FIFO(命名管道)时,如果以只
写/读
方式打开,并且没有其他进程以读/写
模式打开相同的FIFO,则会发生阻塞。 - 这是因为FIFO(命名管道)是基于进程间通信的一种机制,要求读和写操作成对出现。
-
- 对于一个命名管道(FIFO),只有在读端和写端同时打开之后,open函数才会返回。
-
- 也就是说,当一个进程以
只读
方式打开FIFO,而另一个进程以只写
方式打开相同的FIFO时,两个进程都完成打开操作后,它们之前的阻塞状态将被解除。
- 也就是说,当一个进程以
解释:(重点)
- 当你以
只读
方式打开FIFO时,如果没有其他进程以写
模式打开相同的FIFO,则读
取操作会一直等待
。 - 同样地,当你以
只写
方式打开FIFO时,如果没有其他进程以读
模式打开相同的FIFO,则写入操作会一直等待
。 - 只有在读端和写端都成功打开之后,两个进程之间的通信才能顺利进行。
- 这种机制确保了读和写操作的同步,保证了数据的正确传输。
- 因此,当你使用open函数打开一个FIFO时,要确保读端和写端都已经打开,才能结束等待并进行进一步的读写操作。
- 如果是同一个
key
,第一个进程用shmget
第三个参数是IPC_CREAT
,第二个进程用shmget
第三个参数是IPC_CREAT | IPC_EXCL
,那么第二个进程会创建共享内存失败。
- 如果第一个进程在创建共享内存时使用了
shmget
函数的第三个参数为IPC_CREAT
,则表示如果共享内存不存在就创建一个新的。 - 而第二个进程在创建共享内存时使用了
IPC_CREAT | IPC_EXCL
(即同时设置了IPC_CREAT和IPC_EXCL),表示如果共享内存已经存在,则返回错误。 - 因此,在这种情况下,如果第一个进程已经创建了共享内存,那么第二个进程会因为
IPC_EXCL
的设置而无法再次创建共享内存,shmget
函数会返回一个错误,第二个进程创建共享内存失败。 - 这样可以确保同一个
key
只能被一个进程创建共享内存,防止重复创建和竞争条件的发生。
所以正因为有shmget函数第三个参数这样的机制,我们必要时要让参数是IPC_CREAT
的进程等一下参数是IPC_CREAT | IPC_EXCL
的进程,让后者先把共享内存创建好了,前者直接获取。
若是前者先创建共享内存,后者一旦判断同一个key
值已经创建好了一块共享内存,就会返回错误。除非两个shmget函数第三个参数都是IPC_CREAT
,这样无论哪个进程先创建共享内存,另一方都可以获取到对方创建的共享内存。
通过共享内存进程通信结果:
基于共享内存 + 管道的一个访问控制的效果:
- 管道本身提供保护机制,我们自己也做了一次保护机制共享内存 + 管道的机制。
- 如果这两个方案都不提供,裸的共享内存,被双方同时看到。
- 我们两个进程在操作,就可能出现一些来回读写交叉的问题。
- 导致数据不一致的问题,或者是访问控制方面的问题。
3. 相关概念
- 临界资源: 被多个进程能够看到的资源叫做临界资源。
- 如果没有对临界资源进行任何保护,对于临界资源的访问。
- 双方进程在进行访问的时候,就都是乱序的。
- 可能会因为读写交叉而导致的各种乱码、废弃数据、访问控制方面的问题!!
- 临界资源有安全的也有不安全的,取决于内部是否做了保护。
- 临界区: 对多个进程而言,访问临界资源的代码。
- 我的进程代码中,有大量的代码,只有一部分代码,会访问临界资源。
- 两个进程分别对共享资源做读写的代码叫做它们俩的临界区。
- 原子性: 我们把一件事情,要不没做,要么做完了,叫原子性。
- 没有中间状态。
- 互斥: 任何时刻,只允许一个进程,访问临界资源。
4. 信号量
信号量(Semaphore)是一种在并发编程中常用的同步机制,用于控制对共享资源的访问。它可以被视为一个整数计数器,用于表示可用资源的数量。
信号量也属于进程通信的一种,只不过不是以传送数据为目的而是控制双方协同步调为目的。
多进程可以访问临界资源的不同区域,就要保证:
- 信号量保证不会有多余的进程连接到这份临界资源。
- 还需要保证每一个进程的能够访问到临界资源的不同位置(根据上层业务决定)
信号量的分类:
二元信号量:
要么为0要么为1,(表现为互斥特性)。多元信号量:
这个信号量可能为十八九等(常规信号量)。
如果一个进程想访问由信号量控制的临界资源,必须先申请信号量。如果申请成功,就一定能访问到这个临界资源中的一部分。
4.1 原子性说明:
每一个进程,要先申请信号量,每一个进程,都必须,先看到这个信号量:
- 这个信号量本身就是临界资源,信号量本身是为了保护临界资源的。
- 可是,现在信号量自己本身也是临界资源,那么问题来了谁来保护信号量呢?
如果我们要对一个变量进行++/- -要做什么工作呢?
- 假设是对100进行 - -操作。
-
- 要先将100从内存拷贝到CPU里面。
-
- 然后在CPU里面做好计算。
-
- 最后从CPU中再拷贝内存中。
问题出现:
- 假设第一个进程将数据
100
拷贝到CPU
中进行- -
操作。 - 此时进程替换,切换到第二个进程。
- 第二个进程再将数据从内存总拷贝到
CPU
,执行其他操作(连续- -
到50
)。 - 若第二个进程将数据从
100
减到50
,再切回原来的进程。 - 原来的进程再继续执行,直接把第二个进程好不容易减到
50
的值又干到了99
。
而我们的信号量为了保证能够正确的控制进程的访问,其就必须维护自身的原子性!不能有中间状态!
共享内存不做访问控制,可以通过信号量进行对资源保护!
信号量对应的操作是PV操作:
- 申请资源:P
- 释放资源:V