共享内存让不同进程看到同一份资源的方式就是,在物理内存当中申请一块内存空间,然后将这块内存空间分别与各个进程各自的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。
在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。
共享内存的数据结构如下:
/* Obsolete, used only for backwards compatibility and libc5 compiles */
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 */
};
可以看到上面共享内存数据结构的第一个成员是shm_perm,shm_perm是一个ipc_perm类型的结构体变量,每个共享内存的key值存储在shm_perm这个结构体变量当中,其中ipc_perm结构体的定义如下:
/* Obsolete, used only for backwards compatibility and libc5 compiles */
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;
};
在Linux系统中,key
是一个关键字段,用于标识 System V IPC 对象的唯一性。对于共享内存(Shared Memory),key
的唯一性保证了在系统范围内的IPC对象之间的区分。key
是一个32位的整数值,用于唯一标识一个IPC对象,包括共享内存。开发者在创建IPC对象时可以指定一个 key
值,而系统会根据这个值来唯一地标识该对象。它的特性如下:
唯一性: 每个IPC对象的 key
都应该是唯一的。在系统中,通过不同的 key
值来区分不同的共享内存段。如果两个共享内存段的 key
相同,它们将被视为同一IPC对象。
用户自定义: 开发者可以自己选择 key
的值,通常可以使用 ftok()
函数将文件路径和项目标识符(project identifier)转换为 key
值。这种方法可以确保在不同的程序中使用相同的文件路径和项目标识符生成相同的 key
值,从而在不同的进程间共享同一个IPC对象。
我们待会实现server&client通信就用到了这种方法,pathname和proj_id被server和client进程共用,以便使用 ftok()
来生成出相同的key,唯一确定共享的内存。
const std::string pathname="/home/chen/linux-learning/shm";
const int proj_id = 0x11223344;
// 共享内存的大小,建议设计成4096的整数倍
const int size = 4096;
key_t GetKey()
{
key_t key = ftok(pathname.c_str(), proj_id);
if(key < 0)
{
std::cerr << "errno: " << errno
<< ", errstring: " << strerror(errno)
<< std::endl;
exit(1);
}
}
创建共享内存我们需要用shmget函数,shmget函数的函数原型如下:
shmget的参数说明:
shmget的返回值说明:
注意:
我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作。
说明一下参数key和shmflg:
- 关于传入shmget函数的第一个参数key,需要我们使用ftok函数进行获取
ftok函数的函数原型如下:
ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值。将来在用shm实现进程间通信的时候,不同的进程使用相同的pathname和proj_id,通过ftok得到的key都是相同的,把key给到shmget即可获得相同的共享内存句柄。const std::string pathname="/home/chen/linux-learning/shm"; const int proj_id = 0x11223344; const int size = 4096;// 共享内存的大小,建议设计成4096的整数倍 key_t GetKey() { key_t key = ftok(pathname.c_str(), proj_id); if(key < 0) { std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl; exit(1); } return key; }
[!Question] 为什么要用户自己设置key,而不是操作系统帮我们做?
ftok
函数的作用就是,将一个已存在的路径名pathname
和一个整数标识符proj_id
转换成一个key
值。将来在用shm实现进程间通信的时候,不同的进程使用相同的pathname
和proj_id
,通过ftok
得到的key
都是相同的,而且这个key
只有这些进程自己知道,把key
给到shmget
即可获得相同的共享内存的句柄,以使用约定的那块共享内存。
- 第三个参数shmflg,常用的组合方式有以下两种:
组合方式 作用 IPC_CREAT 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则直接返回该共享内存的句柄 PC_CREAT|IPC_EXCL 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则出错返回 甚至可以设置权限 (int32位中的最低的9位) 指定授予所有者、组和全局的权限。格式和含义与open(2)的mode参数相同。
使用ipcs
命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项:
ipcs命令输出的每列信息的含义如下:
标题 | 含义 |
---|---|
key | 系统区别各个共享内存的唯一标识 |
shmid | 共享内存的用户层id(句柄) |
owner | 共享内存的拥有者 |
perms | 共享内存的权限 |
bytes | 共享内存的大小 |
nattch | 关联共享内存的进程数 |
status | 共享内存的状态 |
[!Attention] 注意:
- key: 不要在应用层使用,key只用来在内核中标识shm的唯一性! - 类比文件描述符
fd
- shmid:应用这个共享内存的时候,我们使用shmid来进行操作共享内存。 - 类比
FILE*
如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由Linux内核提供并维护的。
可以使用ipcrm -m shmid
命令释放指定id的共享内存资源
ipcrm -m [shmid]
shmctl
用于对共享内存段进行控制操作,可以实现共享内存的删除功能。下面是 shmctl
系统调用的函数原型:
#include
#include
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数说明:
shmid
: 共享内存标识符,它是由 shmget
函数返回的标识符,用于唯一标识一个共享内存段。cmd
: 控制命令,表示对共享内存的执行的操作。可以使用以下命令:
IPC_STAT
: 获取共享内存的状态信息,将共享内存的信息填充到 buf
中。IPC_SET
: 设置共享内存的状态信息,使用 buf
中提供的信息。IPC_RMID
: 删除共享内存段,释放资源。buf
: 一个指向 struct shmid_ds
结构的指针,用于传递或接收共享内存的状态信息。返回值说明:
errno
来指示错误的原因。将共享内存连接到进程地址空间我们需要用shmat函数,shmat函数的函数原型如下:
#include
#include
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmat函数的参数说明:
shmat函数的返回值说明:
取消共享内存与进程地址空间之间的关联我们需要用shmdt函数,shmdt函数的函数原型如下:
#include
#include
int shmdt(const void *shmaddr);
shmdt函数的参数说明:
待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址。
shmdt函数的返回值说明:
在知道了共享内存的创建、关联、去关联以及释放后,现在可以尝试让两个进程通过共享内存进行通信了。
为了让服务端和客户端在使用ftok函数获取key值时,能够得到同一种key值,那么服务端和客户端传入ftok函数的路径名和和整数标识符必须相同,这样才能生成同一种key值,进而找到同一个共享资源进行挂接。这里我们可以将这些需要共用的信息放入一个头文件当中,服务端和客户端共用这个头文件即可。
共用的头文件:comm.h
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
const std::string pathname = "/home/chen/linux-learning/shm";
const int proj_id = 0x112233;
const std::string filename = "fifo";
// 共享内存的大小,建议设计成4096的整数倍
const int size = 4096;
key_t GetKey()
{
key_t key = ftok(pathname.c_str(), proj_id);
if (key < 0)
{
std::cerr << "ftok, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
exit(1);
}
return key;
}
std::string ToHex(int id)
{
char buffer[1024];
snprintf(buffer, sizeof(buffer), "0x%x", id);
return buffer;
}
int CreateShmHelper(key_t key, int flag)
{
int shmid = shmget(key, size, flag);
if (shmid < 0)
{
std::cerr << "shmget, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
exit(2);
}
}
int CreateShm(key_t key)
{
// 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;
// 如果存在这样的共享内存,则出错返回
return CreateShmHelper(key, IPC_CREAT | IPC_EXCL | 0666);
}
int GetShm(key_t key)
{
// 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;
// 如果存在这样的共享内存,则直接返回该共享内存的句柄
return CreateShmHelper(key, IPC_CREAT);
}
bool MakeFifo()
{
int n = mkfifo(filename.c_str(), 0666);
if (n < 0)
{
std::cerr << "mkfifo, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
return false;
}
std::cout << "mkfifo success... read" << std::endl;
return true;
}
server端负责创建共享内存,server和client挂接到同一块共享内存之后,client先向共享内存写入‘a’,再通过命名管道fifo来通知server写入完毕,server端的read就读到了管道中的数据,开始打印共享内存中的内容。
server.cpp:
#include "comm.hpp"
class Init
{
public:
Init()
{
// 使用管道通信
bool r = MakeFifo();
if (!r) exit(1);
key_t key = GetKey(); //获取key值
std::cout << "key: " << key << std::endl;
// sleep(3);
// key vs shmid
// key: 不要在应用层使用,只用来在内核中标识shm的唯一性! 类比fd
// shmid:应用这个共享内存的时候,我们使用shmid来进行操作共享内存 类比FILE*
shmid = CreateShm(key);
std::cout << "shmid: " << shmid << std::endl;
// sleep(5);
std::cout << "开始将shm映射到进程的地址空间中" << std::endl;
s = (char*)shmat(shmid, nullptr, 0);
std::cout << "映射完成" << std::endl;
fd = open(filename.c_str(), O_RDONLY); // 打开管道,阻塞等待
std::cout << "fd: " << fd << std::endl;
}
~Init()
{
// sleep(5);
shmdt(s);
std::cout << "开始将shm从进程的地址空间中移除" << std::endl;
// sleep(5);
shmctl(shmid, IPC_RMID, nullptr);
std::cout << "开始将shm从OS中删除" << std::endl;
close(fd);
unlink(filename.c_str());
}
public:
int shmid;
int fd;
char* s;
};
int main()
{
Init init;
// todo
while (true)
{
// wait
int code = 0;
ssize_t n = read(init.fd, &code, sizeof(code));
if (n > 0)
{
std::cout << "共享内存的内容:" << init.s << std::endl;
sleep(1);
}
else if (n == 0)
{
break;
}
}
return 0;
}
client.cpp:
#include
#include
#include
#include
#include
#include "comm.hpp"
int main()
{
key_t key = GetKey();
int shmid = GetShm(key);
char* s = (char*)shmat(shmid, nullptr, 0);
std::cout << "attach shm done" << std::endl;
int fd = open(filename.c_str(), O_WRONLY);
// sleep(10);
char c = 'a';
for (; c <= 'e'; c++)
{
s[c - 'a'] = c;
std::cout << "client write: " << c << " done!" << std::endl;
sleep(1);
// 通知server写入完毕
int code = 1;
write(fd, &code, sizeof(code));
}
shmdt(s);
std::cout << "detach shm done" << std::endl;
//sleep(5);
close(fd);
std::cout << "管道写端关闭!!" << std::endl;
//sleep(5);
return 0;
}
[!Question] 两个问题:
- 为什么server一定会等待client把要写进共享内存中的数据写完?
实际上server端的read系统调用在读取管道数据的时候,是阻塞等待的,就是说server会卡在read来等待client向管道里写数据,管道里有数据write才会读到数据并返回。也就是说管道里没有数据,程序就卡在read这里了。
梳理一下就是:
在创建一个命名管道(FIFO)之后,如果进程1打开了读端,而其他进程迟迟不打>开它的写端,
open
函数在进程1中通常会阻塞,等待直到有其他进程打开了端。在默认情况下,打开一个管道的读端或写端,如果对应的另一端没有被打开,打开操作会一直阻塞,直到另一端被打开为止。这是因为管道的通信是基于两个进程之间的协作的,当一个进程试图打开读端时,它可能期望有其他进程打开相应的写端,并且在没有写端打开的情况下,读端打开可能会被阻塞。
启动了client之后,server正常打印。当client把管道的写端的fd关闭的时候,server这边的read会直接返回0,server中的死循环被break,然后调用init的析构进行资源清理,然后正常退出。
运行之前可以使用以下监控脚本时刻关注共享内存的资源分配情况:
while :; do ipcs -m;echo "###################################";sleep 1;done