一、System V共享内存
在Linux系统中,System V共享内存通信是一种常见的进程间通信方式,它允许多个进程共享同一块物理内存区域,从而实现高效的数据交换和共享。它使用了一系列的系统调用函数来实现进程间的共享内存区域。
共享内存和动态库都被映射到进程地址空间的共享区:
1.1 通信原理
System V共享内存通信的实现原理如下:
-
创建共享内存块:使用
shmget
函数在物理内存中创建一个共享内存块,该函数返回一个唯一的标识符,用于标识共享内存块。 -
将共享内存块映射到进程地址空间(attach):使用
shmat
函数将共享内存块映射到进程的地址空间中,该函数返回共享内存块的起始地址。 -
进程间通信:进程可以通过读取或写入共享内存块中的数据来进行通信。
-
解除映射(detach):使用
shmdt
函数解除共享内存块的映射关系。 -
删除共享内存块:使用
shmctl
函数删除共享内存块。
注意:
- 共享内存块的大小应该在创建时指定,并且所有进程都应该使用相同的标识符来访问共享内存块。
- 此外,由于共享内存块是被多个进程共享的,因此需要进行同步和互斥操作(访问控制),以避免数据竞争和死锁等问题。
- 操作系统需要统一管理系统中所有的共享内存;管理方法:“先描述,在组织”。因此,操作系统在创建共享内存时除了会创建内存块,还会创建描述该共享内存属性的内核数据结构。(类比进程:内存块+PCB)
共享内存的生命周期
- 系统中的共享内存的生命周期是随内核的。需要显式地调用shmctl函数并指定IPC_RMID命令来删除共享内存。
- 否则即使所有的通信进程退出,共享内存也将一直存在于系统中,直到系统重启或者手动删除(ipcrm -m命令)为止。
- 系统中的其他IPC资源,包括消息队列和信号量,他们的生命周期都是随内核的。
System V共享内存通信和管道通信都是Linux系统中常见的进程间通信方式,它们有以下区别:
- 实现方式不同:System V共享内存通信通过共享内存块与通信双方建立映射实现进程间通信,而管道通信则通过通信双方打开同一个管道文件实现进程间通信。
- 通信方向不同:System V共享内存通信是一种双向通信方式,多个进程可以同时读写共享内存区域中的数据,而管道通信是一种单向通信方式,只能在一个方向上传递数据。
- 同步机制不同:System V共享内存通信需要使用同步和互斥机制(访问控制)来避免数据竞争和死锁等问题,而管道通信则自带同步机制但本身并不提供互斥。
对比匿名管道,命名管道,共享内存通信的通信原理:
- 匿名管道:通过子进程继承父进程文件描述符的方案,使父子进程打开同一个匿名管道。
- 命名管道:利用文件路径的唯一性,使不同的进程打开同一个命名管道。
- 共享内存:通过给定唯一的shmkey,使不同的进程关联到同一个共享内存块。
1.2 相关函数
1.2.1 shmget
shmget
是Linux系统中用于创建共享内存区域的系统调用函数,其函数原型如下:
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
参数:
- key:共享内存区域的键值(shmkey),用于唯一标识共享内存区域,系统中不同的共享内存区域应该使用不同的键值。 在进行进程间通信时,只要通信双方获取到同样的key值,就能确保访问的是同一个共享内存区域。一般我们使用ftok函数生成key值。
- size:共享内存区域的大小,以字节为单位。
- shmflg: shmflg参数是一个整数值,可以通过按位或运算符(|)来组合多个标志位,以此来确定共享内存区域的访问权限和创建标志,可以使用IPC_CREAT标志来创建共享内存区域,使用IPC_EXCL标志来检查共享内存区域是否已经存在。
IPC_CREAT
:如果该共享内存已经存在,返回标识符;如果不存在,创建共享内存并返回标识符;(通常用于获取已经存在的共享内存标识符)IPC_EXCL
:单独使用没有任何意义IPC_CREAT | IPC_EXCL
:如果该共享内存已经存在,则创建失败并返回-1;如果不存在,创建共享内存并返回标识符;(通常用于创建一个全新的共享内存)- shmflg参数的最低9位用于指定共享内存的访问权限,其中最高位表示文件拥有者的权限,中间位表示文件所属组的权限,最低位表示其他用户的权限。这些权限可以用8进制数表示。
返回值:
-
成功:返回共享内存区域的标识符(shmid),该标识符可以用于后续的共享内存操作,例如将共享内存区域映射到进程的地址空间中。共享内存标识符也能唯一标识系统中的共享内存区域。
-
失败:返回-1,并设置errno错误码。
共享内存的大小
- 共享内存的大小最好是页大小的整数倍,是因为操作系统在管理内存时是以页为单位进行分配和管理的。每个页的大小通常为4KB或者8KB,具体大小取决于硬件和操作系统的实现。
- 如果共享内存的大小不是页大小的整数倍,那么操作系统可能需要额外的内存来填充不足一页的部分,这样会浪费一些内存资源。此外,如果共享内存的大小不是页大小的整数倍,那么在访问共享内存时可能会出现一些问题,例如访问越界、内存对齐等问题,这些问题可能会导致程序崩溃或者产生不可预测的结果。
- 因此,为了最大化地利用内存资源并保证程序的正确性,建议将共享内存的大小设置为页大小的整数倍。这样可以避免浪费内存资源,同时也可以保证程序的正确性和稳定性。
shmkey和shmid
- shmkey和shmid都可以唯一标识系统中的一个共享内存区域,但他们的用处不同:shmkey是共享内存的系统层标识符,而shmid是共享内存的用户层标识符。
1.2.2 ftok
ftok函数是Linux系统中用于生成key值的函数,其函数原型如下:
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
ftok函数的参数说明如下:
- pathname:一个文件名,用于生成key值。需要确保pathname指定的文件存在,并且当前进程有访问该文件的权限。
- proj_id:一个整数值,用于生成key值。
返回值:
- 成功:返回一个key值,该值可以用于标识共享内存区域、消息队列和信号量等系统资源。
- 失败:返回-1,并设置errno错误码。
提示:
- ftok函数并不是系统调用,而是一个生成键值的算法,其工作原理是将pathname和proj_id组合成一个唯一的key值。
- 在进行共享内存通信时,只要通信双方传入同样的pathname和proj_id,就能获取到同样的key值,进而确保访问的是同一个共享内存区域。
1.2.3 shmat
shmat函数是Linux系统中用于将共享内存区域映射到进程地址空间中的系统调用函数,其函数原型如下:
#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数:
- shmid:共享内存区域的标识符,由shmget函数返回。
- shmaddr:指定共享内存区域映射到进程地址空间中的地址,通常设置为NULL,表示由操作系统自动分配一个合适的地址。
- shmflg:共享内存区域的访问权限和映射标志,通常设置为0,表示使用默认的访问权限和映射标志。
返回值:
- 成功:返回一个指向共享内存区域的指针,该指针可以用于后续的共享内存操作,例如读写共享内存区域中的数据。
- 失败:返回(void*)-1,并设置errno错误码。
提示:
- shmat函数的主要作用是将共享内存区域映射到进程地址空间中,使得进程可以访问共享内存区域中的数据。
- 在使用shmat函数时,应该确保共享内存区域已经被创建,并且已经初始化为合适的值,否则可能会导致程序出现不可预测的结果。
1.2.4 shmdt
shmdt函数是Linux系统中用于解除共享内存区域映射的系统调用函数,其函数原型如下:
#include <sys/types.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr);
参数:
- shmaddr:指向共享内存区域映射到进程地址空间中的地址。
返回值:
- 成功:返回0。
- 失败:返回-1,并设置errno错误码。
提示:
- shmdt函数的主要作用是解除共享内存区域映射,使得进程不再能够访问共享内存区域中的数据。具体来说,shmdt函数会将共享内存区域从进程地址空间中分离,但不会删除共享内存区域本身。如果需要删除共享内存区域,可以使用shmctl函数并指定IPC_RMID命令来删除共享内存区域。
- 需要注意的是,由于共享内存区域是被多个进程共享的,因此在解除共享内存区域映射时需要确保所有进程都已经完成对共享内存区域的操作,否则可能会导致数据丢失或者不一致的问题。此外,在使用shmdt函数时,应该确保共享内存区域已经不再需要访问,否则可能会导致程序出现不可预测的结果。
1.2.5 shmctl
shmctl函数是Linux系统中用于控制共享内存区域的系统调用函数,其函数原型如下:
#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数:
- shmid:共享内存区域的标识符,由shmget函数返回。
- cmd:控制命令,用于指定shmctl函数的操作类型,常用的命令包括IPC_RMID(删除共享内存区域)、IPC_STAT(获取共享内存区域的状态信息)和IPC_SET(设置共享内存区域的状态信息)等。
- buf:指向共享内存区域状态信息的结构体指针,用于存储获取到的共享内存区域状态信息或者设置共享内存区域状态信息。如果只是删除共享内存,该参数传NULL即可。
返回值:
- 成功:返回0。
- 失败:返回-1,并设置errno错误码。
提示:删除共享内存区域会导致共享内存区域中的数据被清空,因此在删除共享内存区域之前应该确保所有进程都已经完成对共享内存区域的操作。同时,在使用shmctl函数时,应该确保对共享内存区域的访问是同步和互斥的,以避免数据竞争和死锁等问题。
1.3 用共享内存实现server&client通信
使用共享内存通信需要注意的两点:
-
共享内存通信是速度最快的IPC方式
-
下图是管道通信过程中的数据拷贝情况:
-
因为地址空间中的共享区属于用户空间,所以共享内存一旦被映射到通信双方进程的共享区中,就可以通过向该共享内存的直接读、写完成进程间通信了。不再需要经过任何的系统调用,也不需要将数据拷贝给内核空间。如果上图中的情况使用共享内存通信只需要拷贝两次。因此共享内存通信是所有IPC方式中速度最快的。
-
-
共享内存缺乏访问控制,可能会出现数据竞争和死锁等并发问题,导致程序的不稳定和不可预测性。
共享内存的访问控制和数据一致性是一个复杂的问题,我们之后会专门研究。下面的代码实现是利用管道为共享内存添加了简单的访问控制。
common.hpp & log.hpp
//common.hpp
#pragma once
#include ...
using namespace std;
//server和client端ftok函数的两个参数相同,进而确保访问的是同一个共享内存区域。
#define PATH_NAME "/home/zty" //需要确保pathname指定的文件存在,并且当前进程有访问该文件的权限。
#define PROJ_ID 1
#define SHM_SIZE 4096 //共享内存的大小必须是页大小(4096)的整数倍!
#define FIFO_PATH "./fifo" //管道文件的路径
#define MODE 0666 //文件的访问权限
//利用构造和析构创建和删除管道文件
struct Init{
Init(){
umask(0);
int ret = mkfifo(FIFO_PATH, MODE);
if(ret == -1)
{
perror("mkfifo");
exit(errno);
}
PrintLog("creat fifo success!", DEBUG);
}
~Init(){
unlink(FIFO_PATH);
PrintLog("remove fifo success!", DEBUG);
}
};
//打开管道文件
int Openfifo(const char* path, int mode){
int fd = open(path, mode);
if(fd == -1)
{
perror("open");
exit(errno);
}
PrintLog("open fifo success!", DEBUG);
return fd;
}
//利用管道为共享内存添加访问控制
//读端阻塞等待,直到写端唤醒读端
void Wait(int fd){
uint32_t temp = 0;
ssize_t sz = read(fd, &temp, sizeof(uint32_t));
assert(sz == sizeof(uint32_t));
(void)sz;
}
//写端唤醒读端
void Signal(int fd){
uint32_t temp = 1;
ssize_t sz = write(fd, &temp, sizeof(uint32_t));
assert(sz == sizeof(uint32_t));
(void)sz;
}
//关闭管道文件
void Closefifo(int fd){
close(fd);
PrintLog("close fifo success!", DEBUG);
}
//log.hpp
#ifndef _LOG_HPP_
#define _LOG_HPP_
#include ...
using namespace std;
enum MsgTypeID{
DEBUG,
NOTICE,
WORNING,
ERROR
};
const char* MsgTypeName[] = {"DEBUG", "NOTICE", "WORNING", "ERROR"};
void PrintLog(string msg, MsgTypeID id){
printf("%u | %s: %s\n", (unsigned)time(nullptr), MsgTypeName[id], msg.c_str());
}
#endif
shm_server.cc
#include "common.hpp"
#include "log.hpp"
Init init;
int main(){
//1.生成共享内存键值
key_t shmkey = ftok(PATH_NAME, PROJ_ID);
if(shmkey == -1)
{
perror("ftok");
exit(errno);
}
PrintLog("create shmkey sucess!", DEBUG);
printf("shmkey: %#x\n", shmkey); //16进制带前缀输出
//server端作为通信的发起者,需要负责共享内存的创建和删除工作
//2.创建共享内存
int shmid = shmget(shmkey, SHM_SIZE, IPC_CREAT | IPC_EXCL | MODE);
if(shmid == -1)
{
perror("shmget");
exit(errno);
}
PrintLog("create share memory sucess!", DEBUG);
printf("shmid: %d\n", shmid);
//3.将共享内存区域映射到进程地址空间中
char* shmaddr = (char*)shmat(shmid, nullptr, 0);
if(shmaddr == (char*)-1)
{
perror("shmat");
exit(errno);
}
PrintLog("attach share memory sucess!", DEBUG);
printf("shmaddr: %p\n", shmaddr);
//4.进程间通信
// sleep(10);
int fd = Openfifo(FIFO_PATH, O_RDONLY);
while(true)
{
//server阻塞等待,直到client端向共享内存中写完数据,并调用Signal函数唤醒进程。
Wait(fd);
//直接使用printf打印共享内存中的数据
printf("%s\n", shmaddr);
if(strcmp(shmaddr, "quit") == 0)
{
break;
}
// sleep(1);
}
Closefifo(fd);
//5.解除共享内存区域映射
int ret = shmdt(shmaddr);
if(ret == -1)
{
perror("shmdt");
exit(errno);
}
PrintLog("detach share memory sucess!", DEBUG);
// sleep(10);
//6.删除共享内存
ret = shmctl(shmid, IPC_RMID, nullptr);
if(ret == -1)
{
perror("shmctl");
exit(errno);
}
PrintLog("delete share memory sucess!", DEBUG);
return 0;
}
shm_client.cc
#include "common.hpp"
#include "log.hpp"
int main(){
//1.生成共享内存的键值
key_t shmkey = ftok(PATH_NAME, PROJ_ID);
if(shmkey == -1)
{
perror("ftok");
exit(errno);
}
PrintLog("create shmkey success!", DEBUG);
printf("shmkey: %#x\n", shmkey);
//client端不需要负责创建和删除共享内存
//2.获取共享内存标识符
int shmid = shmget(shmkey, SHM_SIZE, IPC_CREAT);
if(shmid == -1)
{
perror("shmget");
exit(errno);
}
PrintLog("get shmid success!", DEBUG);
printf("shmid: %d\n", shmid);
//3.建立映射
char* shmaddr = (char*)shmat(shmid, nullptr, 0);
if(shmaddr == (char*)-1)
{
perror("shmat");
exit(errno);
}
PrintLog("attach share memory success!", DEBUG);
printf("shmaddr: %p\n", shmaddr);
//4.进程间通信
// sleep(10);
int fd = Openfifo(FIFO_PATH, O_WRONLY);
while(true)
{
//直接将从标准输入读取的数据写入到共享内存
ssize_t sz = read(0, shmaddr, SHM_SIZE-1);
if(sz > 0)
{
shmaddr[sz-1] = 0;
}
//client端将数据写入到共享内存后,需要调用Signal函数唤醒Server端读取数据。
Signal(fd);
if(strcmp(shmaddr, "quit") == 0)
{
break;
}
}
Closefifo(fd);
//5.解除映射
int ret = shmdt(shmaddr);
if(ret == -1)
{
perror("shmdt");
exit(errno);
}
PrintLog("detach share memory success!", DEBUG);
return 0;
}
运行结果:
二、System V消息队列
System V消息队列是一种进程间通信(IPC)的方式,它是由AT&T公司开发的一种IPC机制,可以在不同进程之间传递消息。System V消息队列的主要特点包括:
-
消息队列是一个消息链表,每个消息都有一个类型和一个数据部分。
-
消息队列可以被多个进程同时访问,进程可以向队列中写入消息,也可以从队列中读取消息。
-
消息队列可以设置不同的权限,以控制进程对队列的访问。
-
消息队列可以设置不同的优先级,以控制消息的发送和接收顺序。
-
消息队列可以设置不同的大小,以适应不同的应用场景。
System V消息队列的使用需要以下步骤:
-
创建消息队列:使用msgget函数创建一个新的消息队列,返回一个消息队列的标识符。
-
发送消息:使用msgsnd函数向消息队列中发送消息,指定消息类型和数据部分。
-
接收消息:使用msgrcv函数从消息队列中接收消息,指定消息类型和数据部分。
-
删除消息队列:使用msgctl函数删除消息队列,释放系统资源。
需要注意的是,System V消息队列也存在一些问题,如同步和互斥、数据一致性和安全性等问题,需要采取相应的措施来保证程序的正确性和稳定性。此外,System V消息队列在Linux系统中已经被POSIX消息队列所取代,因此在使用时需要注意系统兼容性问题。
三、System V信号量(了解)
3.1 概念补充
- 临界资源:是指在多个进程之间共享的资源,例如共享内存、文件、网络连接等。由于多个进程同时访问临界资源可能会导致数据竞争和冲突以及数据不一致问题,因此需要使用同步和互斥机制来保护临界资源的访问。
- 临界区:是指访问临界资源的代码段,也就是需要使用同步和互斥机制来保护的代码段。在临界区内,进程需要获取互斥锁或信号量等同步机制,以保证多个进程之间对临界资源的访问的正确性和一致性。临界区通常是指一段代码,而不是指某个具体的资源。
- 同步:是指在多个进程之间协调执行的过程,保证它们按照一定的顺序执行,避免出现竞争和冲突。例如,当一个进程需要等待另一个进程完成某个操作后才能继续执行时,可以使用信号量来实现同步。
- 互斥:是指在多个进程之间共享资源时,保证它们不会同时访问同一个资源,避免出现数据竞争和冲突。例如,当多个进程需要访问同一个共享内存区域时,可以使用信号量来实现互斥。
3.2 信号量的基本概念
System V信号量和共享内存一样,也是一种临界资源。用于实现进程之间的同步和互斥。信号量是一个整数,用于表示某个资源的可用数量或状态(计数器)。
为了避免出现数据竞争和冲突,在多个进程之间共享资源时,它们不能同时访问同一个资源(互斥)。因此每个进程想访问临界资源时,都不能直接访问,而是必须申请信号量。只要申请信号量成功,临界资源的内部一定会预留相应的资源。此时才能访问申请到的资源。访问结束后,必须释放信号量。申请信号量的本质是对临界资源的一种预定机制。
进程可以通过对信号量进行P操作和V操作来实现信号量的申请和释放:
-
P操作:申请信号量,会将信号量的值-1,如果信号量的值小于0,则进程会被阻塞,直到信号量的值变为正数。
-
V操作:释放信号量,会将信号量的值+1,如果有进程因为等待信号量而被阻塞,则会唤醒其中一个进程。
信号量可以设置不同的初始值,以适应不同的应用场景。信号量可以设置不同的权限,以控制进程对信号量的访问。
System V信号量的使用需要以下步骤:
-
创建信号量:使用semget函数创建一个新的信号量,返回一个信号量的标识符。
-
初始化信号量:使用semctl函数初始化信号量的值和权限。
-
进程间同步和互斥:使用semop函数对信号量进行P操作和V操作,实现进程间的同步和互斥。
-
删除信号量:使用semctl函数删除信号量,释放系统资源。
需要注意的是,System V信号量在使用时需要注意同步和互斥、数据一致性和安全性等问题,需要采取相应的措施来保证程序的正确性和稳定性。此外,System V信号量在Linux系统中已经被POSIX信号量所取代,因此在使用时需要注意系统兼容性问题。
3.3 信号量的P, V操作是原子性操作
信号量用于实现进程之间的同步和互斥。但是信号量也是一种临界资源,在使用时同样存在数据冲突、一致性等问题。谁又来保证信号量的安全性呢?答案是原子性操作!
在进程间通信中,原子性操作是指一个操作不可被中断地执行,要么全部执行成功,要么全部不执行。原子性操作通常用于保证多个进程之间的数据访问的正确性和一致性。
常见的原子性操作包括:
-
原子性读写操作:例如原子性读取和写入一个整数值,可以使用原子性操作来保证多个进程之间对该整数值的读写操作的正确性。
-
原子性加减操作:例如原子性地对一个计数器进行加减操作,可以使用原子性操作来保证多个进程之间对该计数器的访问的正确性。
-
原子性比较交换操作:例如原子性地比较两个整数值的大小,并根据比较结果交换它们的值,可以使用原子性操作来保证多个进程之间对这两个整数值的访问的正确性。
需要注意的是,原子性操作通常是由硬件提供支持的,例如CPU提供的原子性指令。
信号量的P, V操作是原子性操作,具体的说是原子性加减操作。它们可以保证多个进程之间对信号量的访问的正确性和一致性。具体来说,当多个进程同时对信号量进行P、V操作时,操作会被依次执行,保证了操作的顺序性(同步)。同时,P、V操作是不可中断的,要么全部执行成功,要么全部不执行(互斥),保证了操作的原子性。
如果信号量的P, V操作不是原子性的,就会导致数据不一致的问题:
以上的内容只是概念的提前铺垫,具体的解释和原理请阅读:
四、IPC相关命令
Linux下常用的IPC命令包括:
-
ipcs命令:用于显示系统中的IPC资源,包括共享内存、消息队列和信号量等。
-
ipcs -m:显示共享内存信息
-
ipcs -q:显示消息队列信息
-
ipcs -s:显示信号量信息
-
-
ipcrm命令:用于删除系统中的IPC资源,包括共享内存、消息队列和信号量等。
-
ipcrm -m:删除共享内存
-
ipcrm -q:删除消息队列
-
ipcrm -s:删除信号量
-
-
msgctl命令:用于控制消息队列,包括设置消息队列的属性、获取消息队列的状态和删除消息队列等。
-
msgctl -q:获取消息队列的状态
-
msgctl -Q:列出所有消息队列的状态
-
msgctl -r:删除消息队列
-
-
shmctl命令:用于控制共享内存,包括设置共享内存的属性、获取共享内存的状态和删除共享内存等。
-
shmctl -m:获取共享内存的状态
-
shmctl -a:列出所有共享内存的状态
-
shmctl -r:删除共享内存
-
-
semctl命令:用于控制信号量,包括设置信号量的属性、获取信号量的状态和删除信号量等。
-
semctl -s:获取信号量的状态
-
semctl -a:列出所有信号量的状态
-
semctl -r:删除信号量
-
需要注意的是,IPC命令需要以root权限运行否则可能会出现权限不足的错误。