🕺🏻 往事敬秋风 🗓️ 2025-05-04 🗂️ 博客剪藏 🏷️ #网络编程

解锁Linux共享内存:进程间通信的超高速通道

声明

在 Linux 系统的进程间通信 “江湖” 中,众多通信方式各显神通。管道,如同隐秘的地下通道,让有亲缘关系的进程能够悄然传递信息;消息队列则似邮局,进程可投递和接收格式化的消息包裹。然而,有一种通信方式却以其独特的 “高速” 特性脱颖而出,它就是共享内存。想象一下,进程们原本各自生活在独立的 “小天地” 里,有着自己专属的虚拟地址空间。但共享内存却如同神奇的 “任意门”,打破了进程间的隔阂,让多个进程能够直接访问同一块内存区域。这种独特的机制,使得数据在进程间的传递无需繁琐的复制过程,极大地提升了通信效率,堪称进程间通信的超高速通道。

在使用共享内存时,需要注意对于并发访问的控制,如使用锁或其他同步机制来保证数据的一致性和安全性。此外,还需要谨慎处理资源管理问题,确保正确地释放共享内存以避免内存泄漏。接下来,就让我们一同深入探索 Linux 共享内存的奥秘,揭开它神秘的面纱,看看它是如何在 Linux 系统中发挥这一独特且强大的作用 。

一、Linux 内存管理初窥

1.1 虚拟内存与物理内存

Linux 采用虚拟内存管理机制,为每个进程分配独立的虚拟地址空间。这意味着每个进程都可以认为自己拥有 4GB(32 位系统)或更大(64 位系统)的连续内存空间,而不必担心物理内存的实际大小和其他进程的干扰。虚拟内存与物理内存通过内存映射机制建立联系,进程访问的虚拟地址会被转换为实际的物理地址。

举个例子,当你在 Linux 系统上同时运行多个程序时,每个程序都觉得自己独占了大量内存,但实际上物理内存是有限的。通过虚拟内存管理,操作系统可以巧妙地在物理内存和磁盘之间交换数据,使得系统能够运行比物理内存更大的程序集。就好比一个小型图书馆,虽然书架空间有限(物理内存),但通过一个庞大的仓库(磁盘)来存放暂时不用的书籍(数据),当读者需要某本书时,管理员(操作系统)会从仓库中取出并放到书架上供读者使用。

1.2 内存分页

为了更高效地管理内存,Linux 采用内存分页机制。将虚拟内存和物理内存按照固定大小的页(通常为 4KB)进行划分,页是内存管理的最小单位。操作系统通过维护页表来记录虚拟页和物理页之间的映射关系,当进程访问某个虚拟地址时,CPU 会根据页表将其转换为对应的物理地址。

想象一下,内存就像一本巨大的书籍,每一页都有固定的页码(虚拟页号和物理页号)。当你想要查找书中的某个内容(访问内存数据)时,通过目录(页表)可以快速定位到具体的页码,从而找到所需内容。

1.3 内存分配与回收

内存管理包括内存的分配和回收。当进程需要内存时,它会向操作系统请求分配内存,操作系统根据一定的算法从空闲内存中分配相应大小的内存块给进程;当进程不再需要某些内存时,它会将这些内存释放回操作系统,以便操作系统重新分配给其他需要的进程。

例如,当你在 Linux 系统上运行一个新的程序时,程序会向操作系统申请内存来存放代码和数据。操作系统会从空闲内存池中找到合适大小的内存块分配给该程序。当程序运行结束后,它占用的内存会被操作系统回收,重新加入空闲内存池,等待下一个程序的请求。

二、共享内存详解

2.1 共享内存是什么

共享内存是一种高效的进程间通信(IPC,Inter - Process Communication)机制,它允许两个或多个进程直接访问同一块物理内存区域。简单来说,就好比多个房间(进程)都有一扇门可以直接通向同一个储物间(共享内存),大家可以直接在这个储物间里存放和取用物品(数据) 。

在 Linux 系统中,共享内存的实现依赖于操作系统的支持。当一个进程创建共享内存时,操作系统会在物理内存中分配一块区域,并为这块区域生成一个唯一的标识符。其他进程可以通过这个标识符将该共享内存映射到自己的虚拟地址空间中,从而实现对共享内存的访问。

2.2 为什么要用共享内存

在进程间通信的众多方式中,共享内存之所以备受青睐,是因为它具有其他方式难以比拟的优势。

首先,与管道和消息队列等通信方式相比,共享内存的速度极快。管道和消息队列在数据传输时,需要进行多次数据拷贝,数据要在内核空间和用户空间之间来回传递,这会消耗大量的时间和系统资源。而共享内存则不同,多个进程直接访问同一块内存区域,数据不需要在不同进程的地址空间之间拷贝,大大减少了数据传输的开销,提高了通信效率。例如,在一个实时数据处理系统中,多个进程需要频繁地交换大量数据,如果使用管道或消息队列,可能会因为数据传输的延迟而影响系统的实时性;而使用共享内存,就可以快速地传递数据,满足系统对实时性的要求。

其次,共享内存的使用非常灵活。它可以用于任何类型的进程间通信,无论是有亲缘关系的进程(如父子进程)还是毫无关系的进程,都可以通过共享内存进行数据共享和交互。而且,共享内存区域可以存储各种类型的数据结构,开发者可以根据实际需求自定义数据格式,这为复杂应用场景的实现提供了便利。比如,在一个多进程协作的图形处理程序中,不同进程可以通过共享内存共享图像数据和处理参数,各自完成不同的处理任务,如一个进程负责图像的滤波处理,另一个进程负责图像的边缘检测,共享内存使得它们能够高效地协同工作。

此外,共享内存还能有效地节省内存资源。多个进程共享同一块内存区域,而不是每个进程都单独开辟一块内存来存储相同的数据,这在内存资源有限的情况下显得尤为重要。例如,在一个服务器系统中,可能同时有多个进程需要访问一些公共的配置信息或缓存数据,使用共享内存可以避免这些数据在每个进程中重复存储,从而提高内存的利用率。

2.3 共享内存原理

共享内存是 System V 版本的最后一个进程间通信方式。共享内存,顾名思义就是允许两个不相关的进程访问同一个逻辑内存,共享内存是两个正在运行的进程之间共享和传递数据的一种非常有效的方式。不同进程之间共享的内存通常为同一段物理内存。进程可以将同一段物理内存连接到他们自己的地址空间中,所有的进程都可以访问共享内存中的地址。如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。

特别提醒:共享内存并未提供同步机制,也就是说,在第一个进程结束对共享内存的写操作之前,并无自动机制可以阻止第二个进程开始对它进行读取,所以我们通常需要用其他的机制来同步对共享内存的访问,例如信号量。

在 Linux 中,每个进程都有属于自己的进程控制块(PCB)和地址空间(Addr Space),并且都有一个与之对应的页表,负责将进程的虚拟地址与物理地址进行映射,通过内存管理单元(MMU)进行管理。两个不同的虚拟地址通过页表映射到物理空间的同一区域,它们所指向的这块区域即共享内存。

共享内存的通信原理示意图:

对于上图我的理解是:当两个进程通过页表将虚拟地址映射到物理地址时,在物理地址中有一块共同的内存区,即共享内存,这块内存可以被两个进程同时看到。这样当一个进程进行写操作,另一个进程读操作就可以实现进程间通信。但是,我们要确保一个进程在写的时候不能被读,因此我们使用信号量来实现同步与互斥。

对于一个共享内存,实现采用的是引用计数的原理,当进程脱离共享存储区后,计数器减一,挂架成功时,计数器加一,只有当计数器变为零时,才能被删除。当进程终止时,它所附加的共享存储区都会自动脱离。

为什么共享内存速度最快?

借助上图说明:Proc A 进程给内存中写数据, Proc B 进程从内存中读取数据,在此期间一共发生了两次复制

(1)Proc A 到共享内存       
(2)共享内存到 Proc B

因为直接在内存上操作,所以共享内存的速度也就提高了。

三、共享内存使用指南

3.1 关键函数全解析

在 Linux 中使用共享内存,离不开一些关键的系统调用函数,它们是我们操作共享内存的有力工具。

(1)shmget 函数 :用于创建共享内存段或获取已存在的共享内存段的标识符。其函数原型为:

#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);

key:是一个用于标识共享内存段的键值,它就像是共享内存的 “门牌号”。通常可以使用 ftok 函数根据文件路径和项目 ID 生成一个唯一的 key 值。例如:

key_t key = ftok("/tmp/somefile", 1);

这里/tmp/somefile 是一个已存在的文件路径,1 是项目 ID。如果 key 取值为 IPC_PRIVATE,则会创建一个新的私有共享内存段,通常用于父子进程间的通信。

size:指定共享内存段的大小,单位是字节。例如,若要创建一个 1024 字节大小的共享内存段,可以这样设置:

int shmid = shmget(key, 1024, IPC_CREAT | 0666);

(2)shmat 函数 :将共享内存段连接到调用进程的地址空间,使得进程可以访问共享内存中的数据。

其函数原型为:

void *shmat(int shmid, const void *shmaddr, int shmflg);
void *shared_mem = shmat(shmid, NULL, 0);

shmflg:通常为 0,表示默认的连接方式。如果设置了 SHM_RDONLY,则以只读方式连接共享内存。如果 shmat 函数调用成功,会返回一个指向共享内存起始地址的指针;若失败,返回(void *)-1。

(3)shmdt 函数 :用于将共享内存段从当前进程的地址空间中分离。函数原型为:

int shmdt(const void *shmaddr);

shmaddr:是 shmat 函数返回的共享内存起始地址。调用该函数后,进程不再能够访问该共享内存,但共享内存本身并不会被删除。例如:

int result = shmdt(shared_mem);
if (result == -1) {
    perror("shmdt failed");
}

如果分离成功,shmdt 返回 0;若失败,返回 -1。

(4)shmctl 函数 :用于对共享内存进行控制操作,如获取共享内存信息、设置权限、删除共享内存等。函数原型为:

int shmctl(int shmid, int cmd, struct shmid_ds *buf);
int result = shmctl(shmid, IPC_RMID, NULL);
if (result == -1) {
    perror("shmctl IPC_RMID failed");
}

如果操作成功,shmctl 返回 0;若失败,返回 -1。

3.2 代码实战:共享内存的读写操作

下面通过一个完整的代码示例,展示如何在两个进程间使用共享内存进行数据读写。假设我们要在一个进程中写入数据,另一个进程读取这些数据。

首先,定义一个数据结构,用于在共享内存中存储数据。这里我们定义一个简单的结构体,包含一个整数和一个字符数组:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define SHM_SIZE 1024

// 定义共享内存中使用的数据结构
typedef struct {
    int num;
    char text[100];
} SharedData;

int main() {
    int shmid;
    key_t key;
    SharedData *shared_data;

    // 生成唯一的key值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok");
        exit(EXIT_FAILURE);
    }

    // 创建共享内存段
    shmid = shmget(key, sizeof(SharedData), IPC_CREAT | 0666);
    if (shmid == -1) {
        perror("shmget");
        exit(EXIT_FAILURE);
    }

    // 将共享内存连接到当前进程的地址空间
    shared_data = (SharedData *)shmat(shmid, NULL, 0);
    if (shared_data == (SharedData *)-1) {
        perror("shmat");
        exit(EXIT_FAILURE);
    }

    // 写入数据到共享内存
    shared_data->num = 42;
    strcpy(shared_data->text, "Hello, shared memory!");

    printf("Data written to shared memory: num = %d, text = %s\n", shared_data->num, shared_data->text);

    // 分离共享内存
    if (shmdt(shared_data) == -1) {
        perror("shmdt");
        exit(EXIT_FAILURE);
    }

    return 0;
}

上述代码中,首先使用 ftok 函数生成一个 key 值,然后通过 shmget 创建一个共享内存段,其大小为 SharedData 结构体的大小。接着使用 shmat 将共享内存连接到当前进程地址空间,向共享内存中写入数据,最后使用 shmdt 分离共享内存。

下面是读取共享内存数据的代码:

#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#define SHM_SIZE 1024

// 定义共享内存中使用的数据结构
typedef struct {
    int num;
    char text[100];
} SharedData;

int main() {
    int shmid;
    key_t key;
    SharedData *shared_data;

    // 生成唯一的key值,必须与写入进程一致
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok");
        exit(EXIT_FAILURE);
    }

    // 获取已存在的共享内存段
    shmid = shmget(key, sizeof(SharedData), 0666);
    if (shmid == -1) {
        perror("shmget");
        exit(EXIT_FAILURE);
    }

    // 将共享内存连接到当前进程的地址空间
    shared_data = (SharedData *)shmat(shmid, NULL, 0);
    if (shared_data == (SharedData *)-1) {
        perror("shmat");
        exit(EXIT_FAILURE);
    }

    // 从共享内存读取数据
    printf("Data read from shared memory: num = %d, text = %s\n", shared_data->num, shared_data->text);

    // 分离共享内存
    if (shmdt(shared_data) == -1) {
        perror("shmdt");
        exit(EXIT_FAILURE);
    }

    // 删除共享内存段(这里仅演示,实际应用中需谨慎操作)
    if (shmctl(shmid, IPC_RMID, NULL) == -1) {
        perror("shmctl IPC_RMID");
        exit(EXIT_FAILURE);
    }

    return 0;
}

在读取代码中,同样先使用 ftok 生成与写入进程相同的 key 值,然后通过 shmget 获取共享内存段(注意这里没有使用 IPC_CREAT 标志,因为共享内存已经由写入进程创建),接着连接共享内存并读取数据,最后分离共享内存并删除共享内存段(在实际应用中,删除共享内存段的操作需要谨慎考虑,确保没有其他进程再使用该共享内存)。

3.3 模拟共享内存

我们用 server 来创建共享存储段,用 client 获取共享存储段的标识符,二者关联起来之后 server 将数据写入共享存储段,client 从共享区读取数据。通信结束之后 server 与 client 断开与共享区的关联,并由 server 释放共享存储段。

comm.h

//comm.h
#ifndef _COMM_H__
#define _COMM_H__

#include<stdio.h>
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/shm.h>

#define PATHNAME "."
#define PROJ_ID 0x6666

int CreateShm(int size);
int DestroyShm(int shmid);
int GetShm(int size);
#endif

comm.c

//comm.c
#include"comm.h"

static int CommShm(int size,int flags)
{
	key_t key = ftok(PATHNAME,PROJ_ID);
	if(key < 0)
	{
		perror("ftok");
		return -1;
	}
	int shmid = 0;
	if((shmid = shmget(key,size,flags)) < 0)
	{
		perror("shmget");
		return -2;
	}
	return shmid;
}
int DestroyShm(int shmid)
{
	if(shmctl(shmid,IPC_RMID,NULL) < 0)
	{
		perror("shmctl");
		return -1;
	}
	return 0;
}
int CreateShm(int size)
{
	return CommShm(size,IPC_CREAT | IPC_EXCL | 0666);
}
int GetShm(int size)
{
	return CommShm(size,IPC_CREAT);
}

client.c

//client.c
#include"comm.h"

int main()
{
	int shmid = GetShm(4096);
	sleep(1);
	char *addr = shmat(shmid,NULL,0);
	sleep(2);
	int i = 0;
	while(i < 26)
	{
		addr[i] = 'A' + i;
		i++;
		addr[i] = 0;
		sleep(1);
	}
	shmdt(addr);
	sleep(2);
	return 0;
}

server.c

//server.c
#include"comm.h"

int main()
{
	int shmid = CreateShm(4096);

	char *addr = shmat(shmid,NULL,0);
	sleep(2);
	int i = 0;
	while(i++ < 26)
	{
		printf("client# %s\n",addr);
		sleep(1);
	}
	shmdt(addr);
	sleep(2);
	DestroyShm(shmid);
	return 0;
}

Makefile

//Makefile
.PHONY:all
all:server client

client:client.c comm.c
	gcc -o $@ $^
server:server.c comm.c
	gcc -o $@ $^

.PHONY:clean
clean:
rm -f client server

运行结果:

3.4 权限与生命周期管理

权限设置:在创建共享内存时,可以通过 shmget 函数的 shmflg 参数设置共享内存的访问权限。权限设置与文件权限类似,使用三位八进制数表示,分别对应所有者、组和其他用户的读、写、执行权限。例如,0666 表示所有者、组和其他用户都有读写权限;0644 表示所有者有读写权限,组和其他用户只有读权限。合理的权限设置可以保证共享内存的安全性,防止未经授权的进程访问或修改共享内存中的数据。比如,在一个多用户的服务器环境中,如果有一些共享内存用于存储敏感数据,就需要严格设置权限,只允许特定的用户或用户组访问。

生命周期管理:共享内存的生命周期独立于使用它的进程。当最后一个使用共享内存的进程将其分离(调用 shmdt)后,共享内存仍然存在于系统中,直到被显式删除(调用 shmctl 并传入 IPC_RMID 命令)或系统重启。这就需要开发者在使用共享内存时,谨慎管理其生命周期。在程序结束时,应该确保及时删除不再使用的共享内存,以避免内存泄漏和资源浪费。

比如,在一个长期运行的服务器程序中,如果不断创建共享内存而不删除,随着时间的推移,系统中会残留大量无用的共享内存,占用系统资源,影响系统性能。同时,在删除共享内存之前,要确保所有使用该共享内存的进程都已经将其分离,否则可能会导致其他进程访问非法内存地址,引发程序崩溃等问题。

四、深入共享内存的实现原理

4.1 内核视角:共享内存的数据结构

在 Linux 内核中,有几个关键的数据结构用于管理共享内存,其中 struct shmid_kernel 和 struct shmid_ds 起着重要作用。

struct shmid_kernel 是内核中用于表示共享内存对象的内部数据结构,它包含了共享内存的各种属性和状态信息。虽然这个结构体对于普通开发者来说并不直接可见,但了解它有助于深入理解共享内存的工作机制。它记录了共享内存段的大小、所属的进程组、创建时间、最后访问时间等重要信息。例如,通过这个结构体,内核可以跟踪共享内存的使用情况,判断哪些进程正在使用它,以及何时需要回收共享内存资源。

而 struct shmid_ds 则是一个更常用的数据结构,开发者可以通过 shmctl 函数来访问和修改这个结构体中的信息。它的定义如下:

struct shmid_ds {
    struct ipc_perm shm_perm;    /* 所有权和权限相关信息 */
    size_t          shm_segsz;   /* 共享内存段的大小(字节) */
    time_t          shm_atime;   /* 最后一次连接到共享内存的时间 */
    time_t          shm_dtime;   /* 最后一次从共享内存分离的时间 */
    time_t          shm_ctime;   /* 共享内存状态最后一次改变的时间 */
    pid_t           shm_cpid;    /* 创建共享内存的进程ID */
    pid_t           shm_lpid;    /* 最后一次执行shmat或shmdt操作的进程ID */
    shmatt_t        shm_nattch;  /* 当前连接到共享内存的进程数 */
    ...
};

4.2 映射机制:虚拟内存与物理内存的桥梁

共享内存能够实现高效的进程间通信,关键在于其巧妙的内存映射机制,通过页表将虚拟内存映射到物理内存。

在 Linux 系统中,每个进程都有自己独立的虚拟地址空间。当进程创建或连接到共享内存时,操作系统会在进程的虚拟地址空间中分配一段虚拟地址范围,并将这段虚拟地址与共享内存所在的物理内存区域建立映射关系。这个映射关系是通过页表来维护的。

页表是一种数据结构,它记录了虚拟页号(VPN,Virtual Page Number)与物理页号(PPN,Physical Page Number)之间的对应关系。当进程访问共享内存中的数据时,CPU 首先会根据当前进程的页表,将虚拟地址中的虚拟页号转换为物理页号,然后再加上页内偏移量,得到实际的物理内存地址,从而访问到共享内存中的数据。

例如,假设进程 A 和进程 B 共享一块大小为 4KB 的共享内存。当进程 A 创建共享内存时,操作系统会在物理内存中分配一块 4KB 大小的内存区域,并为这块区域分配一个物理页号。然后,操作系统在进程 A 的页表中创建一个页表项,将虚拟页号与该物理页号关联起来,使得进程 A 可以通过虚拟地址访问这块共享内存。当进程 B 连接到该共享内存时,操作系统同样在进程 B 的页表中创建一个页表项,将其虚拟地址空间中的一段虚拟页号也映射到相同的物理页号上。这样,进程 A 和进程 B 就可以通过各自的虚拟地址访问同一块物理内存区域,实现数据共享。

在这个过程中,如果所需的共享内存数据不在物理内存中(例如,由于内存不足,共享内存的部分数据被交换到磁盘上),会发生页面错误(page fault)。此时,操作系统会负责将所需的数据从磁盘读入物理内存,并更新页表,确保进程能够正确访问共享内存。这种动态的内存管理机制使得共享内存能够在有限的物理内存条件下高效运行,同时也保证了进程间通信的稳定性和可靠性。

Linux 提供了内存映射函数 mmap, 它把文件内容映射到一段内存上(准确说是虚拟内存上,运行着进程), 通过对这段内存的读取和修改, 实现对文件的读取和修改。mmap()系统调用使得进程之间可以通过映射一个普通的文件实现共享内存。普通文件映射到进程地址空间后,进程可以像访问内存的方式对文件进行访问,不需要其他内核态的系统调用(read,write)去操作。

这里是讲设备或者硬盘存储的一块空间映射到物理内存,然后操作这块物理内存就是在操作实际的硬盘空间,不需要经过内核态传递。比如你的硬盘上有一个文件,你可以使用 linux 系统提供的 mmap 接口,将这个文件映射到进程一块虚拟地址空间,这块空间会对应一块物理内存,当你读写这块物理空间的时候,就是在读取实际的磁盘文件,就是这么直接高效。通常诸如共享库的加载都是通过内存映射的方式加载到物理内存的。

mmap 系统调用并不完全是为了共享内存来设计的,它本身提供了不同于一般对普通文件的访问的方式,进程可以像读写内存一样对普通文件进行操作,IPC 的共享内存是纯粹为了共享。

内存映射指的是将 :进程中的 1 个虚拟内存区域 & 1 个磁盘上的对象,使得二者存在映射关系。当然,也可以多个进程同时映射到一个对象上面。

实现过程

其函数原型、具体使用 & 内部流程 如下:

/** * 函数原型 */ 
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
 /** 
* 具体使用(用户进程调用mmap()) 
* 下述代码即常见了一片大小 = MAP_SIZE的接收缓存区 & 关联到共享对象中(即建立映射) 
*/ 

mmap(NULL, MAP_SIZE, PROT_READ, MAP_PRIVATE, fd, 0); 
/** 
* 内部原理 
* 步骤1:创建虚拟内存区域 
* 步骤2:实现地址映射关系,即:进程的虚拟地址空间 ->> 共享对象 
* 注:
* a. 此时,该虚拟地址并没有任何数据关联到文件中,仅仅只是建立映射关系 
* b. 当其中1个进程对虚拟内存写入数据时,则真正实现了数据的可见 
*/

优点

进程在读写磁盘的时候,大概的流程是:

以 write 为例:进程(用户空间) -> 系统调用,进入内核 -> 将要写入的数据从用户空间拷贝到内核空间的缓存区 -> 调用磁盘驱动 -> 写在磁盘上面。

使用 mmap 之后进程(用户空间)–> 读写映射的内存 –> 写在磁盘上面。 (这样的优点是 避免了频繁的进入内核空间,进行系统调用,提高了效率)

(1)mmap 系统调用

void *mmap(void *addr, size_t length, int prot, int flags,
                  int fd, off_t offset);

这就是 mmap 系统调用的接口,mmap 函数成功返回指向内存区域的指针,失败返回 MAP_FAILED。

PROT_READ	内存段可读
PROT_WRITE	内存段可写
PROT_EXEC	内存段可执行
PROT_NONE	内存段不能被访问

flags 参数控制内存段内容被修改以后程序的行为。

MAP_SHARED	进程间共享内存,对该内存段修改反映到映射文件中。提供了POSIX共享内存
MAP_PRIVATE	内存段为调用进程所私有。对该内存段的修改不会反映到映射文件
MAP_ANNOYMOUS	这段内存不是从文件映射而来的。内容被初始化为全0
MAP_FIXED	内存段必须位于start参数指定的地址处,start必须是页大小的整数倍(4K整数倍)
MAP_HUGETLB	按照大内存页面来分配内存空间

fd 参数是用来被映射文件对应的文件描述符,通过 open 系统调用得到,offset 设定从何处进行映射。

(2)mmap 用于共享内存的方式

  1. 我们可以使用普通文件进行提供内存映射,例如,open 系统调用打开一个文件,然后进行 mmap 操作,得到共享内存,这种方式适用于任何进程之间。
  2. 可以使用特殊文件进行匿名内存映射,这个相对的是具有血缘关系的进程之间,当父进程调用 mmap,然后进行 fork,这样父进程创建的子进程会继承父进程匿名映射后的地址空间,这样,父子进程之间就可以进行通信了。相当于是 mmap 的返回地址此时是父子进程同时来维护。
  3. 另外 POSIX 版本的共享内存底层也是使用了 mmap。所以,共享内存在在 posix 上一定程度上就是指的内存映射了。

五、Mmap 和 System V 共享内存的比较

共享内存:

这是 System V 版本的共享内存(以下我们统称为 shm),下面看下 mmap 的:

mmap 是在磁盘上建立一个文件,每个进程地址空间中开辟出一块空间进行映射。而 shm 共享内存,每个进程最终会映射到同一块物理内存。shm 保存在物理内存,这样读写的速度肯定要比磁盘要快,但是存储量不是特别大,相对于 shm 来说,mmap 更加简单,调用更加方便,所以这也是大家都喜欢用的原因。

另外 mmap 有一个好处是当机器重启,因为 mmap 把文件保存在磁盘上,这个文件还保存了操作系统同步的映像,所以 mmap 不会丢失,但是 shmget 在内存里面就会丢失,总之,共享内存是在内存中创建空间,每个进程映射到此处。内存映射是创建一个文件,并且映射到每个进程开辟的空间中,但在 posix 中的共享内存就是指这种使用文件的方式“内存映射”。

六、POSIX 共享内存

6.1 IPC 机制

共享内存是最快的可用 IPC 形式。它允许多个不相关(无亲缘关系)的进程去访问同一部分逻辑内存。

如果需要在两个进程之间传输数据,共享内存将是一种效率极高的解决方案。一旦这样的内存区映射到共享它的进程的地址空间,这些进程间数据的传输就不再涉及内核。这样就可以减少系统调用时间,提高程序效率。

共享内存是由 IPC 为一个进程创建的一个特殊的地址范围,它将出现在进程的地址空间中。其他进程可以把同一段共享内存段“连接到”它们自己的地址空间里去。所有进程都可以访问共享内存中的地址。如果一个进程向这段共享内存写了数据,所做的改动会立刻被有访问同一段共享内存的其他进程看到。

要注意的是共享内存本身没有提供任何同步功能。也就是说,在第一个进程结束对共享内存的写操作之前,并没有什么自动功能能够预防第二个进程开始对它进行读操作。共享内存的访问同步问题必须由程序员负责。可选的同步方式有互斥锁、条件变量、读写锁、纪录锁、信号灯。

实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止。

6.2 POSIX 共享内存 API

使用 POSIX 共享内存需要用到下面这些 API:

#include <sys/types.h>
#include <sys/stat.h>        /* For mode constants */
#include <sys/mman.h>
#include <fcntl.h>           /* For O_* constants */
#include <unistd.h>
int shm_open(const char *name, int oflag, mode_t mode);
int shm_unlink(const char *name);
int ftruncate(int fildes, off_t length);
void *mmap(void *addr, size_t len, int prot, int flags, int fildes, off_t off);
int munmap(void *addr, size_t len);
int close(int fildes);
int fstat(int fildes, struct stat *buf);
int fchown(int fildes, uid_t owner, gid_t group);
int fchmod(int fildes, mode_t mode);

七、共享内存的同步问题

虽然共享内存为进程间通信提供了高效的数据共享方式,但由于多个进程可以同时访问同一块内存区域,这就带来了同步和互斥的问题。如果没有合适的同步机制,可能会出现以下情况:

解决方案:信号量与互斥锁的应用

为了解决共享内存带来的同步和互斥问题,通常会使用信号量(Semaphore)和互斥锁(Mutex)等同步机制。

(1)信号量 :信号量是一种计数器,用于控制对共享资源的访问。它可以用来实现进程间的同步和互斥。在共享内存的场景中,信号量可以用来控制对共享内存的访问权限。例如,我们可以创建一个信号量,初始值设为 1,表示共享内存资源可用。当一个进程要访问共享内存时,它首先尝试获取信号量(通过对信号量执行 P 操作,即减 1 操作)。如果信号量的值大于等于 0,说明资源可用,进程可以继续执行对共享内存的访问操作;如果信号量的值小于 0,说明资源已被其他进程占用,该进程会被阻塞,直到信号量的值大于等于 0。当进程完成对共享内存的访问后,它会释放信号量(通过对信号量执行 V 操作,即加 1 操作),通知其他进程可以访问共享内存。在 Linux 中,有 POSIX 有名信号量、POSIX 无名信号量和 System V 信号量等不同类型,开发者可以根据具体需求选择使用。例如,使用 POSIX 有名信号量实现共享内存同步的代码示例如下:

#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>

#define SHM_SIZE 1024
#define SEM_NAME "/my_semaphore"

int main() {
    int shm_fd;
    void *shared_memory;
    sem_t *sem;

    // 创建共享内存对象
    shm_fd = shm_open("/my_shared_memory", O_CREAT | O_RDWR, 0666);
    if (shm_fd == -1) {
        perror("shm_open");
        exit(1);
    }

    // 配置共享内存大小
    if (ftruncate(shm_fd, SHM_SIZE) == -1) {
        perror("ftruncate");
        exit(1);
    }

    // 将共享内存映射到进程地址空间
    shared_memory = mmap(0, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
    if (shared_memory == MAP_FAILED) {
        perror("mmap");
        exit(1);
    }

    // 创建信号量
    sem = sem_open(SEM_NAME, O_CREAT, 0666, 1);
    if (sem == SEM_FAILED) {
        perror("sem_open");
        exit(1);
    }

    // 等待信号量,获取共享内存访问权限
    if (sem_wait(sem) == -1) {
        perror("sem_wait");
        exit(1);
    }

    // 访问共享内存
    printf("Accessed shared memory: %s\n", (char *)shared_memory);

    // 释放信号量,允许其他进程访问共享内存
    if (sem_post(sem) == -1) {
        perror("sem_post");
        exit(1);
    }

    // 取消映射并关闭共享内存
    if (munmap(shared_memory, SHM_SIZE) == -1) {
        perror("munmap");
        exit(1);
    }
    if (close(shm_fd) == -1) {
        perror("close");
        exit(1);
    }

    // 删除共享内存对象
    if (shm_unlink("/my_shared_memory") == -1) {
        perror("shm_unlink");
        exit(1);
    }

    // 关闭并删除信号量
    if (sem_close(sem) == -1) {
        perror("sem_close");
        exit(1);
    }
    if (sem_unlink(SEM_NAME) == -1) {
        perror("sem_unlink");
        exit(1);
    }

    return 0;
}

(2)互斥锁 :互斥锁是一种二元信号量,用于保证在同一时刻只有一个进程能够访问共享资源,即实现对共享内存的互斥访问。当一个进程获取到互斥锁后,其他进程如果试图获取该互斥锁,会被阻塞,直到持有互斥锁的进程释放它。在 Linux 中,使用 pthread 库中的互斥锁相关函数来实现互斥锁的操作。例如,初始化互斥锁可以使用 pthread_mutex_init 函数,获取互斥锁使用 pthread_mutex_lock 函数,释放互斥锁使用 pthread_mutex_unlock 函数,销毁互斥锁使用 pthread_mutex_destroy 函数。以下是使用互斥锁实现共享内存同步的简单代码示例:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>

#define SHM_SIZE 1024

typedef struct {
    pthread_mutex_t mutex;
    char data[SHM_SIZE];
} SharedData;

int main() {
    int shm_fd;
    SharedData *shared_data;

    // 创建共享内存对象
    shm_fd = shm_open("/my_shared_memory", O_CREAT | O_RDWR, 0666);
    if (shm_fd == -1) {
        perror("shm_open");
        exit(1);
    }

    // 配置共享内存大小
    if (ftruncate(shm_fd, sizeof(SharedData)) == -1) {
        perror("ftruncate");
        exit(1);
    }

    // 将共享内存映射到进程地址空间
    shared_data = (SharedData *)mmap(0, sizeof(SharedData), PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
    if (shared_data == MAP_FAILED) {
        perror("mmap");
        exit(1);
    }

    // 初始化互斥锁
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
    if (pthread_mutex_init(&shared_data->mutex, &attr) != 0) {
        perror("pthread_mutex_init");
        exit(1);
    }

    // 获取互斥锁,访问共享内存
    if (pthread_mutex_lock(&shared_data->mutex) != 0) {
        perror("pthread_mutex_lock");
        exit(1);
    }
    printf("Accessed shared memory: %s\n", shared_data->data);
    // 释放互斥锁
    if (pthread_mutex_unlock(&shared_data->mutex) != 0) {
        perror("pthread_mutex_unlock");
        exit(1);
    }

    // 取消映射并关闭共享内存
    if (munmap(shared_data, sizeof(SharedData)) == -1) {
        perror("munmap");
        exit(1);
    }
    if (close(shm_fd) == -1) {
        perror("close");
        exit(1);
    }

    // 删除共享内存对象
    if (shm_unlink("/my_shared_memory") == -1) {
        perror("shm_unlink");
        exit(1);
    }

    return 0;
}

通过合理使用信号量和互斥锁等同步机制,可以有效地解决共享内存带来的同步和互斥问题,确保多个进程能够安全、高效地共享内存数据。

八、实际应用场景及常见问题解答

8.1 实际应用场景

(1)数据库缓存优化

在数据库系统中,共享内存发挥着至关重要的作用,尤其是在缓存优化方面。以 Oracle 数据库为例,它使用共享全局区(SGA,Shared Global Area)来实现共享内存。SGA 是一个共享的内存结构,用于存储数据块、SQL 语句和其他共享信息 。

当数据库接收到查询请求时,首先会在共享内存的缓存中查找相关数据。如果数据存在于缓存中,即命中缓存,数据库可以直接从共享内存中读取数据并返回给用户,这大大减少了磁盘 I/O 操作。因为从磁盘读取数据的速度远远低于从内存读取数据的速度,通过共享内存缓存数据,可以显著提高查询性能。例如,在一个高并发的在线交易系统中,大量用户频繁查询订单信息。如果没有共享内存缓存,每次查询都需要从磁盘读取数据,磁盘 I/O 很快就会成为系统的瓶颈,导致查询响应时间变长。而使用共享内存缓存订单数据后,大部分查询可以直接从内存中获取数据,大大提高了系统的响应速度和吞吐量。

同时,共享内存还可以减少内存的重复使用,提高内存利用率。多个数据库进程可以共享同一块内存区域,避免了每个进程都单独开辟内存来存储相同的数据,从而节省了内存资源。比如,在一个包含多个数据库实例的系统中,这些实例可以共享 SGA 中的数据缓存,减少了内存的浪费,使得系统能够在有限的内存资源下高效运行。

(2)高性能计算中的数据共享

在高性能计算领域,共享内存同样有着广泛的应用。在大规模的科学计算和工程模拟中,往往需要处理海量的数据和复杂的计算任务,这些任务通常需要多个处理器核心或多个计算节点协同工作。

以分子动力学模拟为例,这是一种用于研究分子系统微观行为的计算方法,需要对大量分子的运动轨迹进行模拟计算。在计算过程中,不同的处理器核心需要共享分子的初始位置、速度等数据,以及模拟过程中的中间结果。通过共享内存,这些数据可以被多个处理器核心直接访问,避免了数据在不同处理器之间通过网络或其他方式传输的开销,提高了计算效率。

再比如,在气象预报模型中,需要对全球范围内的气象数据进行分析和预测。这些数据量巨大,计算任务复杂,通常会在分布式计算集群上进行。共享内存可以用于在不同计算节点之间共享气象数据和计算参数,使得各个节点能够协同工作,共同完成气象预报的计算任务。在这种场景下,共享内存不仅提高了数据共享的效率,还减少了节点之间的通信开销,对于提高整个高性能计算系统的性能起着关键作用。

7.2 避坑指南与常见问题解答

在使用 Linux 共享内存的过程中,开发者常常会遇到一些棘手的问题,下面我们就来总结一下这些常见问题,并给出相应的解决方案。

(1)共享内存创建失败

① 问题描述:调用 shmget 函数创建共享内存时,返回值为 -1,导致创建失败。

② 可能原因

③ 解决方案

检查系统参数:通过 cat /proc/sys/kernel/shmmax 和 cat /proc/sys/kernel/shmmni 等命令查看系统的共享内存参数设置。如果需要,可以通过修改/etc/sysctl.conf 文件来调整这些参数,例如:

echo "kernel.shmmax = 2147483648" >> /etc/sysctl.conf
sysctl -p

上述命令将 SHMMAX 设置为 2GB,并使其立即生效。

④ 确认权限:确保当前用户具有创建共享内存的权限,必要时可以切换到具有足够权限的用户(如 root 用户)来创建共享内存,或者通过修改文件权限和用户组等方式来赋予相应权限。

(2)共享内存访问异常

① 问题描述:在进程访问共享内存时,出现段错误(Segmentation Fault)或其他访问异常。

② 可能原因

③ 解决方案

检查映射结果:在调用 shmat 后,仔细检查返回值。如果返回(void *)-1,则根据 errno 变量的值进行错误处理,例如:

void *shared_mem = shmat(shmid, NULL, 0);
if (shared_mem == (void *)-1) {
    perror("shmat failed");
    exit(EXIT_FAILURE);
}

(3)共享内存未及时释放

① 问题描述:共享内存不再被使用,但没有被及时删除,导致系统资源浪费。

② 可能原因

③ 解决方案

优化程序逻辑:在程序设计时,明确共享内存的生命周期,确保在不再需要共享内存时,及时调用 shmctl 函数删除共享内存。例如,在程序结束时,添加如下代码:

if (shmctl(shmid, IPC_RMID, NULL) == -1) {
    perror("shmctl IPC_RMID failed");
    exit(EXIT_FAILURE);
}

捕获异常信号 :在进程中捕获常见的异常信号(如 SIGSEGV、SIGABRT 等),在信号处理函数中添加释放共享内存的操作。例如,使用 signal 函数注册信号处理函数:

#include <signal.h>

void cleanup_shared_memory(int signum) {
    // 释放共享内存相关资源
    if (shmctl(shmid, IPC_RMID, NULL) == -1) {
        perror("shmctl IPC_RMID in signal handler failed");
    }
    exit(EXIT_FAILURE);
}

int main() {
    // 注册信号处理函数
    signal(SIGSEGV, cleanup_shared_memory);
    signal(SIGABRT, cleanup_shared_memory);
    // 其他程序逻辑
}

通过上述方法,可以有效避免共享内存未及时释放的问题,提高系统资源的利用率。

Comment