上篇Linux的博客是有关管道的,今日就让我们继续康康进程间通信的另外一种方法:共享内存

完整代码详见我的gitee仓库 👇

https://gitee.com/musnow/raspberry-practice/tree/master/code/22-11-12_systemV

[TOC]

1.啥是共享内存?

进程间通信的基本方式,就是让两个进程看到同一份资源。

共享内存(shm)实现进程间通信的方式,通过系统接口开辟一段内存,再让多个进程去访问这块内存,就能同时看到一份资源。

image-20221112090042941

这里贴出之前动态库博客中的图,共享内存的方式和该图展示的方式类似。进程需要调用系统接口,将已经开辟好的共享内存映射到自己的页表中,以实现访问。

这里就出现了一个问题:

  • 操作系统的接口怎么知道进程要的是那一块共享内存?即共享内存是怎么标识的?

要知道,之前我们打开文件、开辟管道等等,都是具有唯一的文件路径来标识文件的。如果按以前的想法:打开文件->系统返回文件的文件描述符,共享内存则应该是开辟共享内存->系统返回共享内存的编号

  • 这就出现了问题!

假设进程A开辟了一段共享内存,系统返回了编号123,那么进程A要怎么让其他想使用这块共享内存进行通信的进程,知道它开辟的共享内存编号是123呢?总不能开个管道告诉它吧?那岂不是多此一举😂

QQ图片20220502222002

所以,共享内存的编号其实和命名管道一样,是由用户手动在代码中指定的。只要进程使用这个编号去获取共享内存,他们就能获取到同一份!


2.相关接口

说完了基本概念,现在让我们来康康它的使用

2.1 ftok

ftok - convert a pathname and a project identifier to a System V IPC key

1
2
3
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);

前面提到了,共享内存的key是我们自己指定的。Linux系统给定了ftok接口,将用户提供的pathname工作路径,以及proj_id项目编号转换为一个共享内存的key(其实就是int类型)

image-20221112132307366

只要我们的工作路径和项目编号传的是一样的,那么它返回的key就是一样的!

这个函数也能用于信号量的key的创建!

2.2 shmget

shmget - allocates a System V shared memory segment

1
2
3
4
#include <sys/ipc.h>
#include <sys/shm.h>

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

参数分别为key值,共享内存的大小,以及创建共享内存的方式。

key值需要通过ftok函数获取;

其中共享内存的大小最好设置为4kb的整数倍,因为操作系统IO的基本单位是4KB。如果你申请了不是4的整数倍的字节,比如15个字节,其还是会申请16个字节(4个页)交给你,而其中有1kb的内存你是无法使用的,即造成了内存浪费😥

创建共享内存的shmflg:

  • IPC_CREAT:创建共享内存。如果存在则获取,如果不存在则创建后获取
  • IPC_EXCL:必须配合IPC_CREAT使用,如果不存在指定的共享内存,就进行创建;如果该共享内存存在,则出错返回(即保证获取到的共享内存一定是当前进程创建的,是一个新的共享内存)

返回值是一个共享内存的标识符

1
2
RETURN VALUE
On success, a valid shared memory identifier is returned. On errir, -1 is returned, and errno is set to indicate the error.

这些工作都是操作系统做的。其内核中有专门的管理单元来判断一个共享内存是否存在,以及何时被创建、被使用、被什么进程绑定等等…

命令行键入man shmctl,可以看到下面的内核结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct shmid_ds {
struct ipc_perm shm_perm; /* Ownership and permissions */
size_t shm_segsz; /* Size of segment (bytes) */
time_t shm_atime; /* Last attach time */
time_t shm_dtime; /* Last detach time */
time_t shm_ctime; /* Last change time */
pid_t shm_cpid; /* PID of creator */
pid_t shm_lpid; /* PID of last shmat(2)/shmdt(2) */
shmatt_t shm_nattch; /* No. of current attaches */
...
};

struct ipc_perm {
key_t __key; /* Key supplied to shmget(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 + SHM_DEST and
SHM_LOCKED flags */
unsigned short __seq; /* Sequence number */
};

共享内存要被管理,其内核结构中一定有一个唯一的key值来标识该共享内存,即和文件的inode一样

1
key_t     __key; //共享内存的唯一标识符,由用户在shmget中提供

关于key为何要让用户提供,已经在上面做出过解释👉 回顾一下


2.3 shmat/shmdt

at其实是attach绑定的缩写,这个接口的作用是将一个共享内存和我们当前的进程绑定。

其实就是将这个共享内存映射到进程的页表中(堆栈之间)

shmat, shmdt - System V shared memory operations

1
2
3
4
5
#include <sys/types.h>
#include <sys/shm.h>

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

一共有两个函数,分别为at和dt,用于绑定/解绑共享内存

shmat的三个参数如下

  • shmid:为shmget的返回值
  • shmaddr:指定共享内存连接到当前进程中的地址位置。通常为空,表示让系统来选择共享内存的地址。
  • shmflg:如果指定了SHM_RDONLY位,则以只读方式连接此段;否则以读写的方式连接此段;通常设置为0

调用成功的时候,返回指向共享内存第一个字节的指针;出错返回-1

  • shmdt的参数为shmat正确调用时的返回值

以下是man手册中对这两个函数返回值的描述👇

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

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

2.4 shmctl

这个函数可以用于操作我们的共享内存

1
2
3
4
#include <sys/ipc.h>
#include <sys/shm.h>

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

其中cmd的参数有下面几种

  • IPC_RMID 删除该共享内存

  • IPC_STATshmid_ds结构中的数据设置为共享内存的当前关联值,即用共享内存的当前关联值覆盖shmid_ds的值

  • IPC_SET 如果进程有足够的权限,就把共享内存的当前关联值设置为shmid_ds结构中给出的值

最后一个buf参数是一个指向shmid_ds结构的指针,一般设为NULL

1
The buf argument is a pointer to a shmid_ds structure

shmid_ds的基本结构如下

1
2
3
4
5
6
struct shmid_ds
{
uid_t shm_perm.uid;
uid_t shm_perm.gid;
mode_t shm_perm.mode;
};

以删除为例,其操作如下

1
shmctl(shmid, IPC_RMID, NULL);//删除shmid的共享内存

2.5 ipcs命令

先来康康几个ipcs命令的选项,这些命令可以帮助我们查看共享资源。其中我们要用到的是-m查看共享内存

1
2
3
4
ipcs -c #查看消息队列/共享内存/信号量
ipcs -s #单独查看信号量
ipcs -q #单独查看消息队列
ipcs -m #单独查看共享内存

执行了之后,会列出当前操作系统中开辟的共享内存,以及它们的基本信息

1
2
3
4
5
6
[muxue@bt-7274:~/git/linux/code/22-11-12_systemV]$ ipcs -m 

------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00005feb 0 root 666 12000 1
0x20011ac8 1 muxue 0 1024 0

这里的key和我们使用ftok获取到的key值是一样的,只不过我们打印的时候是十进制,操作系统列出来的为十六进制。

ipcrm 删除进程通信资源

这个命令可以用与删除ipc资源,包括共享内存

1
ipcrm -m shmid #删除共享内存

我们可以使用ipcrm -m 共享内存的shmid来删除共享内存

1
2
3
4
5
6
7
8
9
10
11
12
13
[muxue@bt-7274:~/git/linux/code/22-11-12_systemV]$ ipcs -m

------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00005feb 0 root 666 12000 1
0x20011ac8 1 muxue 0 1024 0

[muxue@bt-7274:~/git/linux/code/22-11-12_systemV]$ ipcrm -m 1
[muxue@bt-7274:~/git/linux/code/22-11-12_systemV]$ ipcs -m

------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00005feb 0 root 666 12000 1

可以看到我们自己创建的共享内存已经被删除了。


但是,当我们尝试用该命令删除一个正在被使用的共享内存时,它并不会被立即删除(立即删除会影响进程运行)

此时执行删除,在共享内存的status列会出现dest;观察结果,当进程结束的时候,这个共享内存会被直接删除(进程内部并没有调用shmctl接口)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[muxue@bt-7274:~/git]$ ipcs -m

------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00005feb 0 root 666 12000 1
0x20011ac8 21 muxue 666 1024 2

[muxue@bt-7274:~/git]$ ipcrm -m 21
[muxue@bt-7274:~/git]$ ipcs -m

------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00005feb 0 root 666 12000 1
0x00000000 21 muxue 666 1024 2 dest

[muxue@bt-7274:~/git]$ ipcs -m

------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00005feb 0 root 666 12000 1

相比之下,如果不执行ipcrm命令+进程内部不调用shmctl接口,这个共享内存就会一直存在

1
2
3
4
5
6
[muxue@bt-7274:~/git]$ ipcs -m

------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00005feb 0 root 666 12000 1
0x20011ac8 22 muxue 666 1024 0

结论:使用ipcrm -m命令删除共享内存之后,其共享内存不一定会立即释放。如果有进程关联了该共享内存,则会在进程去关联之后释放

2.6 共享内存和管道的对比

面试的时候问道了这个问题!

image-20230913212808024

2.7 消息队列mq/信号量的接口

消息队列和信号量的接口和共享内存很相似

消息队列用的不多,信号量的难度很高!😂 后文会介绍信号量。

1
2
3
4
5
6
7
8
9
10
//消息队列相关接口
msgget //获取
msgctl //操作
msgsnd //发送信息
msgrcv

//信号量
semget
semctl
semop

3.使用

3.1 创建并获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//头文件实在太多,为了博客篇幅,这里省略了
#define NUM 1024
#define PROJ_ID 0x20
#define PATH_NAME "/home/muxue/git/linux/code/22-11-12_systemV"

key_t CreateKey()
{
key_t key = ftok(PATH_NAME, PROJ_ID);
if(key < 0)
{
cerr <<"ftok: "<< strerror(errno) << endl;
exit(1);//key获取错误直接退出程序
}
return key;
}

int main()
{
key_t key = CreateKey();

int id = shmget(key, NUM, IPC_CREAT | IPC_EXCL);
if(id<0)
{
cerr<< "shmget err: " << strerror(errno) << endl;
return 1;
}
cout << "shmget success: " << id << endl;

return 0;
}

File exists

这里会发现,第一次运行代码的时候,程序成功获取了共享内存;但是第二次运行的时候,却报错说File exists(文件存在)

1
2
3
4
[muxue@bt-7274:~/git/linux/code/22-11-12_systemV]$ ./test
shmget: 1
[muxue@bt-7274:~/git/linux/code/22-11-12_systemV]$ ./test
shmget err: File exists

这是因为共享内存的声明周期是随内核的。即只要这个共享内存不被删除,他就会一直存在,直到内核因为某种原因释放掉它,亦或者操作系统关机

通过上面提到的ipcrm -m shmid 命令删除共享内存,才能重新运行代码获取新的共享内存

为了避免这个问题,应该在进程结束后使用shmctl接口删除共享内存

1
2
3
4
5
6
7
8
[muxue@bt-7274:~/git/linux/code/22-11-12_systemV]$ ./test
shmget success: 2
[muxue@bt-7274:~/git/linux/code/22-11-12_systemV]$ ipcs -m

------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00005feb 0 root 666 12000 1
0x20011ac8 2 muxue 0 1024 0

设置权限值

默认情况下,我们创建的共享内存的perms是0,代表没有用户能访问这个共享内存。所以在创建的时候,我们需要在flag里面直接或上这个共享内存的权限值

代码如下👇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main()
{
key_t key = CreateKey();

int id = shmget(key, NUM, IPC_CREAT | IPC_EXCL | 0666);
if(id<0)
{
cerr<< "shmget err: " << strerror(errno) << endl;
return 1;
}
cout << "shmget success: " << id << endl;

sleep(5);

shmctl(id,IPC_RMID,nullptr);
return 0;
}

这时候创建的共享内存就有正确的权限值了

1
2
3
4
5
6
[muxue@bt-7274:~/git]$ ipcs -m

------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00005feb 0 root 666 12000 1
0x20011ac8 4 muxue 666 1024 0

3.2 挂接/取消挂接

1
2
//关联共享内存
char *str = (char*)shmat(id, nullptr, 0);

因为shmat函数的返回值是一个void*指针,我们可以以使用malloc一样的方式使来挂接共享内存。随后对这个内存的操作就是正常的指针操作了!

同样的,另外一个进程也需要用同样的方式挂接共享内存,才能读取到相同的数据

1
2
3
4
5
6
[muxue@bt-7274:~/git]$ ipcs -m

------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00005feb 0 root 666 12000 1
0x20011ac8 4 muxue 666 1024 1

挂接成功后,可以发现nattch的值从0变为1

取消/删除

取消挂接的方式很简单,直接把shmat的返回值传入即可

1
shmdt(str);//取消挂接

如果是服务端,则还需要在取消挂接之后,删除共享内存。避免下次程序运行的时候,无法通过key获取到新的共享内存

1
shmctl(id,IPC_RMID,nullptr);//删除共享内存

3.3 写入内容

因为共享内存本质就是一个内存,其和malloc出来的内存都是一样的,直接使用即可

这里还是用一个服务端和一个客户端来进行演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
//server.cpp
#include "Mykey.hpp"

int main()
{
//获取key值
key_t key = CreateKey();
//创建共享内存
int id = shmget(key, NUM, IPC_CREAT | IPC_EXCL | 0666);
if(id<0)
{
cerr<< "shmget err: " << strerror(errno) << endl;
return 1;
}
cout << "shmget success: " << id << endl;

sleep(2);
//关联共享内存
char *str = (char*)shmat(id, nullptr, 0);
printf("[server] shmat success\n");
//读取数据,sleep(1)
int i=0;
while(i<=40)
{
printf("[%03d] %s\n",i,str);
i++;
sleep(1);
}

//去关联
shmdt(str);//shmat的返回值
printf("[server] shmdt(str)\n");
//删除共享内存
shmctl(id,IPC_RMID,nullptr);
printf("[server] exit\n");
return 0;
}

//client.cpp
#include "Mykey.hpp"

int main()
{
//获取key值
key_t key = CreateKey();
//获取共享内存
int id = shmget(key, NUM, IPC_CREAT);
if(id<0)
{
cerr<< "shmget err: " << strerror(errno) << endl;
return 1;
}
cout << "shmget success: " << id << endl;

sleep(2);
//关联共享内存
char *str = (char*)shmat(id, nullptr, 0);
printf("[client] shmat success\n");
//写入数据
int i=0;
while(i<26)
{
char base = 'A';
str[i] = base+i;
str[i+1] = '\0';
printf("write times: %02d\n",i);
i++;
sleep(1);
}


//去关联
shmdt(str);//shmat的返回值
printf("[client] shmdt & exit\n");
return 0;
}

跑起来之后,客户端向共享内存中写入数据(注意控制\0)服务端进行读取。这便实现了我们进程之间的通信

image-20221113091741600

不过我们发现,客户端已经停止写入之后,服务端还是在不停的读取。如果我们不控制while循环的话,其会一直这么读取下去

image-20221113091847579

这便牵扯出共享内存的一个特性了

共享内存没有访问控制

在管道的博客中提到,管道是有访问控制的进程通信方式,写端没有写入数据的时候,读端会在read中进行等待。

而共享内存因为我们是直接像操作一个malloc出来的空间一样访问,没有使用任何系统接口(相比之下管道需要使用read/write)所以操作系统没有办法帮我们进行访问控制!

也正是因为没有阻塞等待就能直接访问这块内存空间,共享内存是进程中通信中最快的一种方式。

通过管道进行共享内存的控制

既然共享内存没有访问控制,那么我们可以利用管道来让控制共享内存的读写

  • 写端写完后,将完成信号写入管道,由读端读取
  • 读端从管道中获取到信号后,访问共享内存读出内容
  • 如果写端没有写好,读端就会在管道read内部等待

你可能会说,那为何不直接用管道通信呢?

  • 管道仅作访问控制,只需要一个int乃至一个char类型即可;
  • 相比直接管道通信,共享内存的方式更好控制(毕竟使用内存的方式和使用指针一样,我们比较熟悉,管道还需要文件操作;)
  • 读取很长一串数据的时候,共享内存的速度优势能体现出来

以下是完整代码👇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
//mykey.hpp
#pragma once

#include <iostream>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <cstdlib>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <fcntl.h>
#include <unistd.h>
#include <cassert>
using namespace std;

#define NUM 1024
#define PROJ_ID 0x20
#define PATH_NAME "/home/muxue/git/linux/code/22-11-12_systemV"
#define FIFO_FILE "sc.pipe"

key_t CreateKey()
{
key_t key = ftok(PATH_NAME, PROJ_ID);
if(key < 0)
{
cerr <<"ftok: "<< strerror(errno) << endl;
exit(1);//key获取错误直接退出程序
}
return key;
}

void CreateFifo()
{
umask(0);
if(mkfifo(FIFO_FILE, 0666) < 0)
{
cerr << "fifo: " << strerror(errno) << endl;
exit(2);
}
}
//打开管道文件
int Open(int flags)
{
return open(FIFO_FILE, flags);
}
//让读端通过管道等待
ssize_t Wait(int fd)
{
char val = 0;
//如果写端没有写入,其就会在read中等待
ssize_t s = read(fd, &val, sizeof(val));
return s;
}
//发送完成信息
int Signal(int fd)
{
char sig = 'g';
write(fd, &sig, sizeof(sig));
}

//server.cpp
#include "Mykey.hpp"

int main()
{
//创建管道
CreateFifo();
//获取key值
key_t key = CreateKey();
//创建共享内存
int id = shmget(key, NUM, IPC_CREAT | IPC_EXCL | 0666);
if(id<0)
{
cerr<< "shmget err: " << strerror(errno) << endl;
return 1;
}
cout << "shmget success: " << id << endl;
//获取管道
int fd = Open(O_RDONLY);
cout << "open fifo success: " << fd << endl;
sleep(2);
//关联共享内存
char *str = (char*)shmat(id, nullptr, 0);
printf("[server] shmat success\n");
//读取数据
int i=0;
while(i<=40)
{
ssize_t ret = Wait(fd);//通过管道等待
if(ret!=0)
{
printf("[%03d] %s\n",i,str);
i++;
sleep(1);
}
else
{
cout<<"[server] wait finish, break" << endl;
break;
}
}

//去关联
shmdt(str);//shmat的返回值
printf("[server] shmdt(str)\n");
//删除共享内存
shmctl(id,IPC_RMID,nullptr);
close(fd);
unlink(FIFO_FILE);
printf("[server] exit\n");
return 0;
}

//client.cpp
#include "Mykey.hpp"

int main()
{
//获取key值
key_t key = CreateKey();
//获取共享内存
int id = shmget(key, NUM, IPC_CREAT);
if(id<0)
{
cerr<< "shmget err: " << strerror(errno) << endl;
return 1;
}
cout << "shmget success: " << id << endl;

//获取管道
int fd = Open(O_WRONLY);
cout << "open fifo success: " << fd << endl;
sleep(2);
//关联共享内存
char *str = (char*)shmat(id, nullptr, 0);
printf("[client] shmat success\n");
//写入数据
int i=0;
while(i<26)
{
char base = 'A';
str[i] = base+i;
str[i+1] = '\0';
printf("write times: %02d\n",i);
i++;
Signal(fd);
sleep(1);
}


//去关联
shmdt(str);//shmat的返回值
printf("[client] shmdt & exit\n");
close(fd);
printf("[client] close fifo\n");
return 0;
}

运行结果

管道控制了之后,当客户端退出的时候,管道也不会继续读取,而是在read内等待

image-20221113104949379

如果客户端最后关闭了管道的写段,服务器端就会直接退出。这样我们就实现了通过管道控制共享内存的读写👍

image-20221113111731418


4.相关概念

4.0 临界资源

能被多个进程看到的资源,被称为临界资源

如果不对临界资源进行访问控制,进程对该资源的访问就是乱序的(比如父子进程向显示器打印内容)可能会因为数据交叉导致乱码、数据不可用等情况;

以此可见,显示器、管道、共享内存都是临界资源。

  • 管道是有访问控制的临界资源

进程访问临界资源的代码,称为临界区

  • 一个进程中,并不是所有的代码都在访问临界资源。如管道中,其实只有read/write接口在访问临界资源。

互斥:任何时刻,只允许一个进程访问临界资源。

原子性:一件事情只有做完/没做两种状态,没有中间状态。

下面对信号量的概念进行讲解~ 只用基本理解即可;

4.1 信号量

4.1.1 概念

信号量是对临界资源的控制方式之一,其本质是一个计数器;准确来说,是一个拥有原子性的计数器。

  • 信号量保证不会有多余的进程连接到这份临界资源
  • 还需要保证每一个进程的能够访问到临界资源的不同位置(根据上层业务决定)

信号量根据情况的不同分为两种:

  • 二元信号量(互斥状态,当进程使用的时候为1,没有进程使用的时候为0)
  • 多元信号量(常规的计数器)

如果一个进程想访问由信号量控制的临界资源,必须先申请信号量才能进行访问。但是只要我申请成功了,就一定能访问到这个临界资源中的一部分(或者全部)

4.1.2 原子性的说明

先来想想,我们对一个变量+1/-1需要做什么工作:

  • 将这个变量从内存中拿到CPU的寄存器中
  • 在寄存器中完成加减操作
  • 放回内存

这其中是有很多个中间状态的,设该变量初始值为100

  • 假设一个进程A拿走了这个变量,放入CPU的寄存器
  • 另外一个进程B也来拿走了这个变量
  • 此时A和B拿到的都是100
  • A对该变量进行了循环--操作,最终该变量变成了50,将其放回内存
  • B对该变量-1,将其放回内存
  • 最终导致A对变量的操作被B覆盖,出现了变量不统一的情况

而我们的信号量为了保证能够正确的控制进程的访问,其就必须维护自身的原子性!不能有中间状态

QQ图片20220424132543

说人话就是,如果进程A在访问信号量,进程B来了,信号量应该拒绝B的访问,直到A访问结束。不能让B中途插入访问,从而导致可能的数据不统一

共享内存同样可以通过信号量进行访问控制

4.1.3 接口

创建信号量 semget

使用如下函数获取一个信号量,或者创建一个新的信号量;

调用这个函数之前,我们需要使用 ftok 函数创建一个 key_t 值作为信号量的标识符。

1
int semget(key_t key, int nsems, int semflg);
  • key 是一个唯一标识符,用于标识信号量集。
  • nsems 是信号量集中信号量的数量。
  • semflg 是标志位,用于指定信号量的权限。

一般情况下,我们将semflg写为 IPC_CREAT | 权限值,这里的权限值和linux中文件权限值是相同的,比如需要所有人都有一切权限,就可以写 777;一般写成 666就行了,这代表所有用户,所属组和其他用户都拥有读写权限。

指定了 IPC_CREAT 标志位,则表示如果该信号量不存在,则创建它;如果存在,则返回已存在的信号量的标识符。

初始化信号量 semctl

1
int semctl(int semid, int semnum, int cmd, ...);
  • semid 是信号量集的标识符。
  • semnum 是信号量在信号量集中的索引。
  • cmd 是操作指令,可以是 SETVALGETVAL 等。
  • 对于 SETVAL 操作指令,需要通过可变参数 ... 来设置初始化的值。

对于该函数的第三个参数cmd,有如下类型的选项

  1. IPC_STAT:获取信号量的状态信息,包括信号量的当前值、最后一次修改时间等。
  2. IPC_SET:设置信号量的状态信息,比如设置信号量的权限、所有者等。
  3. IPC_RMID:删除信号量,释放占用的系统资源。
  4. GETVAL:获取信号量的当前值。
  5. SETVAL:设置信号量的当前值。

除了上述常见的操作类型,还有其他一些操作类型用于更具体的操作,例如:

  • GETPID:获取最后一次操作信号量的进程 ID。
  • GETNCNT:获取当前正在等待信号量解锁的进程数量。
  • GETZCNT:获取当前等待信号量解锁的进程数量。

根据我们的需要选择对应的操作符来进行信号量的操作即可;

如下是一个获取信号量当前值的操作。

1
2
3
4
5
6
7
int semid;  // 信号量集标识符
// 需要初始化
int sem_num = 0; // 信号量索引,如果信号量集里面只有一个信号量,就用0
int cmd = GETVAL; // 获取信号量的命令,GETVAL表示获取当前值
// 执行这个函数时,返回值就是信号量当前值
// 如果返回-1就代表获取失败了
int sem_value = semctl(semid, sem_num, cmd);

修改信号量 semop

1
int semop(int semid, struct sembuf *sops, size_t nops);

功能: 操作信号量,P V 操作

参数:

  • semid为信号量集的标识符;
  • sops 指向进行操作的结构体数组的首地址;
  • nsops 指出将要进行操作的信号的个数;

返回值: 成功返回0,出错返回-1

1
2
RETURN VALUE
If successful semop() and semtimedop() return 0; otherwise they return -1 with errno indicating the error.

结构体 sembuf

这里我们会用到 struct sembuf这个结构体来操作信号量,在系统中这个结构体的声明如下

1
2
3
4
5
6
struct sembuf
{
unsigned short int sem_num; /* semaphore number */
short int sem_op; /* semaphore operation */
short int sem_flg; /* operation flag */
};
  • sem_num:表示要操作的信号量在信号量集中的索引,如果信号量集只有一个信号量,则为 0。
  • sem_op:表示进行的操作类型,可以是正数、负数或零。正数表示增加(释放)信号量的值,负数表示减少(获取)信号量的值,零表示检查信号量的值。
  • sem_flg:表示操作的标志位,用于指定额外的操作选项。一般用的是两个:
    • SEM_UNDO 表示异常时撤回对信号量的操作
    • IPC_NOWAIT 表示如果信号量没有就绪,不进行阻塞等待,直接错误退出

比如如下操作,就是给信号量新增值的处理(如果sem_op为负数,那就是减去值)需要注意的是,信号量的值不能为负数。如果你想减去值,请保证信号量里面有足够的值给你减,否则进程会在semop中阻塞等待。

1
2
3
4
5
struct sembuf semaphore;
semaphore.sem_num = 0;
semaphore.sem_op = 1; // 新增信号量1
semaphore.sem_flg = SEM_UNDO;
// SEM_UNDO 标记位代表异常的时候还原(撤销)操作

如下操作是等待信号量变成0

1
2
3
4
5
6
7
8
9
struct sembuf semaphore;

semaphore.sem_num = 0; // 操作的信号量索引
semaphore.sem_op = 0; // 检查信号量的值
semaphore.sem_flg = 0; // 操作标志位为0

semop(semid, &semaphore, 1); // 等待信号量变成0
// 如果没有变成0,就会阻塞等待
// 如果sem_flg设置了IPC_NOWAIT,那就不会阻塞等待,而是直接返回EAGAIN

在man手册里面是这么描述的

1
2
3
4
5
6
7
8
If  sem_op is zero, the process must have read permission on the semaphore set.  This is a "wait-for-zero" operation: if semval is zero,
the operation can immediately proceed. Otherwise, if IPC_NOWAIT is specified in sem_flg, semop() fails with errno set to EAGAIN (and
none of the operations in sops is performed). Otherwise, semzcnt (the count of threads waiting until this semaphore's value becomes
zero) is incremented by one and the thread sleeps until one of the following occurs:

· semval becomes 0, at which time the value of semzcnt is decremented.
· The semaphore set is removed: semop() fails, with errno set to EIDRM.
· The calling thread catches a signal: the value of semzcnt is decremented and semop() fails, with errno set to EINTR.

翻译一下,如果 sem_op设置为了0,且没有设置 IPC_NOWAIT,那么进程就会在 semop函数中等待,直到出现下面的情况:

  • 信号量变成0,等待成功
  • 信号量被销毁
  • 执行流接收到信号退出(信号是下一章要学习的内容,和信号量没关系)

示例

如下是单个进程操作信号量的示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/unistd.h>

int main() {
int semid;
key_t key;
struct sembuf semaphore;

// 创建或获取信号量集
key = ftok(".", 'S');
semid = semget(key, 1, IPC_CREAT | 0666);
if (semid == -1) {
perror("Failed to create semaphore\n");
exit(1);
}
printf("1\n");

// 初始化信号量的值为1
if (semctl(semid, 0, SETVAL, 1) == -1) {
perror("Failed to initialize semaphore value\n");
exit(1);
}
printf("12\n");


// 对信号量进行操作
semaphore.sem_num = 0;
semaphore.sem_op = 34; // 新增信号量值
semaphore.sem_flg = SEM_UNDO;
if (semop(semid, &semaphore, 1) == -1) {
perror("Failed to perform semaphore operation\n");
exit(1);
}
printf("13\n");

// 获取信号量的当前值
int cmd = GETVAL; // 获取信号量的命令,GETVAL表示获取当前值
int sem_value = semctl(semid, 0, cmd);
if(sem_value == -1)
{
perror("Failed to perform semaphore operation\n");
exit(1);
}
else{
printf("current val for sem: %d\n",sem_value);
}
printf("14\n");


// 释放信号量
semaphore.sem_op = -34; // 减少信号量值
if (semop(semid, &semaphore, 1) == -1) {
perror("Failed to release semaphore\n");
exit(1);
}
printf("15\n");

// 删除信号量集
if (semctl(semid, 0, IPC_RMID) == -1) {
perror("Failed to remove semaphore\n");
exit(1);
}
printf("16\n");

return 0;
}

运行结果如下,这里的printf是我用来标识进程跑到那个阶段的,没有啥实际意义。

1
2
3
4
5
6
7
1
12
13
current val for sem: 35
14
15
16

4.2 扩展: mmap

这部分仅供参考,可能有错误😥部分资料参考

前面贴出过IPC资源的内核结构,它们都有一个共同的特点:第一个成员都相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
struct shmid_ds {
struct ipc_perm shm_perm; /* Ownership and permissions */
size_t shm_segsz; /* Size of segment (bytes) */
time_t shm_atime; /* Last attach time */
time_t shm_dtime; /* Last detach time */
time_t shm_ctime; /* Last change time */
pid_t shm_cpid; /* PID of creator */
pid_t shm_lpid; /* PID of last shmat(2)/shmdt(2) */
shmatt_t shm_nattch; /* No. of current attaches */
...
};

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

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,其中包含了一个信号量的基本信息

1
2
3
4
5
6
7
8
9
10
struct ipc_perm {
key_t __key; /* Key supplied to shmget(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 + SHM_DEST and
SHM_LOCKED flags */
unsigned short __seq; /* Sequence number */
};

而内核中对IPC资源的管理,是通过一个数组进行的。我们所获取的shmid,和文件描述符一样,都是一个数组的下标

其中我在测试的时候,便发现了一点:我们每一次获取的新的共享内存,它的编号都会+1,而不像文件描述符一样,提供第一个没有被使用的下标

1
2
3
4
5
6
7
8
9
struct ipc_ids {
int in_use;//说明已分配的资源个数
int max_id;//在使用的最大的位置索引
unsigned short seq;//下一个分配的位置序列号
unsigned short seq_max;//最大位置使用序列
struct semaphore sem; //保护 ipc_ids的信号量
struct ipc_id_ary nullentry;//如果IPC资源无法初始化,则entries字段指向伪数据结构
struct ipc_id_ary* entries;//指向资源ipc_id_ary数据结构的指针
};

在内核中,struct ipc_id_ary* entries是一个指向所有ipc_perm指针数组。其能够通过该数组找到我们对于id(下标)的资源,对其进行访问

1
2
3
4
5
struct ipc_id_ary
{
int size;
struct kern_ipc_perm *p[0];//指针数组
};

image-20221113163918931

那你可能想问了,这里只是第一个元素啊?那如果我想访问shmid_ds结构的其他成员,岂不是没有办法访问了?

要是这么想,就还是太年轻了😂

1
(strcut shmid_ds*)

我们只需要对这个指针进行强转,就能直接访问其他成员!

这是因为:C语言中,结构体第一个元素的地址,和结构体整体的地址是一样的!

指针的类型会限制这个指针访问元素的能力,只要我们进行强转,其就能直接访问父结构体的其他成员!

这是一种切片的思想

用这种办法,可以用统一的规则在内核中管理不同的IPC资源,没有必要再为每一个IPC资源建立一个单独的数组来管理。

QQ图片20220419103136

不得不说,linus大佬是真的牛逼!


4.3 多进程共享锁 mutex

共享内存因为缺少访问控制,常常需要借助其他具有访问控制的进程通信手段来间接实现访问控制。

但实际上有一个更加符合我们使用习惯的写法,那就是使用 pthread_mutex 锁的 PTHREAD_PROCESS_SHARED 多进程共享属性,让这把锁可以在多个进程中被使用。在每个进程中,我们都可以像使用自己的锁一样使用它,以此实现了一把能同时管多个进程的一把互斥锁!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
// 共享内存的名字必须以/开头
int shared_mem_fd = shm_open("/my_shared_memory", O_CREAT | O_RDWR, 0666);
// 创建了一个mutex大小的共享内存(这个函数的作用是将fd文件给截断/扩展为第二个参数的大小)
ftruncate(shared_mem_fd, sizeof(pthread_mutex_t));
// 用mmap挂载到本地
// addr:指定映射的虚拟地址,通常设置为NULL,让系统自动分配。
// length:指定映射的长度,以字节为单位。
// prot:指定映射区域的保护权限,可选值为PROT_READ(可读权限)、PROT_WRITE(可写权限)、PROT_EXEC(可执行权限)以及它们的组合。
// flags:指定映射的类型和其他标志位,常见的标志位有MAP_SHARED(共享映射)、MAP_PRIVATE(私有映射)、MAP_ANONYMOUS(匿名映射)等。
// fd:如果要映射文件,则为文件描述符;如果映射的是匿名内存区域,则传入-1。
// offset:从文件开始处的偏移量,通常设置为0。
//
// 返回值:成功时,返回映射区域的起始地址指针;
// 失败时,返回MAP_FAILED,并设置errno来指示错误类型。
void *shared_mem_ptr = mmap(NULL, sizeof(pthread_mutex_t), PROT_READ | PROT_WRITE, MAP_SHARED, shared_mem_fd, 0);

pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED); // 设置锁的属性为共享锁
// 如果删除上面这一行,那么当下这个锁就是父进程的私有锁,不具有公有属性
// 即便我们使用共享内存将锁映射到了父子进程的页表中
// 观察到的现象是,即便父进程已经释放锁了,但是子进程依旧是在阻塞状态中
// 个人猜测:因为这个锁是父进程独有的,所以子进程在调用lock函数访问mutex的时候,实际上是将mutex进行了一次写时拷贝;
// 拷贝的时候,这个锁是被占用的,拷贝过去之后也是一个被占用中的锁。
// 但实际上压根没有进程在占用这个被子进程拷贝出去的独立的锁,父进程的解锁操作也不会在进程间同步,这就是一种死锁。

pthread_mutex_t *mutex = (pthread_mutex_t *)shared_mem_ptr;
pthread_mutex_init(mutex, &attr); // 指定使用共享内存的地址来初始化锁

pid_t pid = fork();
if (pid < 0)
{
fprintf(stderr, "Fork failed.\n");
return 1;
}
else if (pid == 0)
{
// Child process
sleep(1); // 子进程先休眠1秒,等待夫进程获取锁
printf("Child trying to acquire the mutex... %p\n", mutex);
pthread_mutex_lock(mutex); // 子进程获取锁,这时候父进程在休眠,无法获取
printf("Child acquired the mutex.\n");
// Do some work...
sleep(2);
pthread_mutex_unlock(mutex);
printf("Child released the mutex.\n");
}
else
{
// Parent process
printf("Parent trying to acquire the mutex... %p\n", mutex);
pthread_mutex_lock(mutex);
printf("Parent acquired the mutex.\n");
// Do some work...
sleep(2);
pthread_mutex_unlock(mutex); // 父进程释放锁后,观察到的情况是子进程成功获取锁
printf("Parent released the mutex.\n");
wait(NULL); // 等待子进程执行完毕

pthread_mutexattr_destroy(&attr); // 父进程来销毁相关资源
pthread_mutex_destroy(mutex);
munmap(shared_mem_ptr, sizeof(pthread_mutex_t));
shm_unlink("/my_shared_memory");
}

return 0;
}

结语

关于共享内存的操作到这里就OVER了!

最后还了解了一些内核设计上的小妙招,不得不说,真的牛批~

如果本文有什么问题,欢迎在评论区提出

QQ图片20220527185356