共享内存进阶指南:深入学习mmap和shm*的用法与技巧

举报
Lion Long 发表于 2023/09/29 12:50:26 2023/09/29
【摘要】 本文将详细介绍mmap和shm的工作原理,包括它们在内存映射和共享内存方面的优势和适用场景。同时,文章还会分享一些使用mmap和shm的技巧和经验,以帮助读者优化并提高程序性能。无论你是新手还是有一定经验的开发者,本文都将为你提供有关共享内存和mmap、shm*的深入知识,使你能够在实际项目中更好地利用这些技术来加速数据共享和多线程应用。

一、背景

共享内存使用场景:当有一个超大的文件,如何能快速的读写?

文件是存储在磁盘上的,要快速的读写一个大文件,可以通过共享内存的方式(mmap等)。mmap内部是使用的DMA技术,DMA是内存和磁盘之间的传输方式,有自己的指令,不需要CPU的参与。

零拷贝技术:我们常说的拷贝,是需要CPU参与的,通过CPU指令将文件内容复制一份到内存中。所谓的零拷贝,就是不需要CPU的参与,而不是其他的意思。零拷贝有mmap和shm*接口这些方式实现。

二、内存映射mmap

应用程序和内核或磁盘直接数据交互,可以通过映射内存块的方式。
mmap():将文件或设备映射到内存。
munmap():将文件或设备取消映射到内存。
函数原型:

#include <sys/mman.h>

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

mmap的内存即不在堆也不在栈上,是一块独立的空间。

2.1、mmap()

mmap()在调用进程的虚拟地址空间中创建一个新的映射。新映射的起始地址在addr中指定。length参数指定映射的长度。

如果addr为空,则内核选择创建映射的地址;这是创建新映射的最可移植方法。 如果addr不为空,则内核将其作为一个提示,提示将映射放置在何处;在Linux上,映射将在附近的页面边界处创建。新映射的地址作为调用的结果返回。

文件映射的内容(与匿名映射相反;参见下面的MAP_MAP_ANONYMOUS)使用文件描述符fd所引用的文件(或其他对象)中从偏移量offset开始的length字节进行初始化。offset必须是sysconf(_SC_PAGE_SIZE)返回的页面大小的倍数。

prot参数描述了映射所需的内存保护(不得与文件的打开模式冲突)。它是PROT_NONE或以下一个或多个标志的位OR:

标志 含义
PROT_EXEC 可以执行页面。
PROT_READ 可以读取页面。
PROT_WRITE 可以写入页面。
PROT_NONE 可能无法访问页面。

flags参数确定映射的更新是否对映射相同区域的其他进程可见,以及更新是否传递到基础文件。通过在标志中包含以下值中的一个来确定此行为:

标志 含义
MAP_SHARED 共享此映射。对映射的更新对映射此文件的其他进程可见,并会传递到基础文件。(要精确控制对底层文件进行更新的时间,需要使用msync())
MAP_PRIVATE 创建写时私有副本映射。映射的更新对于映射同一文件的其他进程不可见,并且不会传递到基础文件。未指定在mmap()调用后对文件所做的更改是否在映射区域中可见。

此外,以下值中的零个或多个可以在flag中进行“或”运算:

标志 含义
MAP_32B5IT (自Linux 2.4.20、2.6起)将映射放入进程地址空间的前2千兆字节。对于64位程序,此标志仅在x86-64上受支持。添加它是为了允许在第一个2GB内存中的某个位置分配线程堆栈,从而提高早期64位处理器上的上下文切换性能。现代x86-64处理器不再存在此性能问题,因此在这些系统上不需要使用此标志。当设置MAP_ FIXED时,MAP_32BIT标志被忽略。
MAP_ANON MAP_ANONYMOUS的同义词。不赞成。
MAP_ANONYMOUS 映射没有任何文件支持;其内容被初始化为零。忽略fd和offset参数;然而,如果指定了MAP_ANONYMOUS(或MAP_ANON),则某些实现要求fd为-1,可移植应用程序应确保这一点。只有从内核2.4开始,Linux才支持将MAP_ANONYMOUS与MAP_SHARED结合使用。
MAP_DENYWRITE 忽略此标志。(很久以前,它发出了一个信号,表示尝试写入底层文件时,ETXTBUSY会失败。但这是拒绝服务攻击的一个来源。)
MAP_EXECUTABLE 忽略此标志。
MAP_FILE 兼容性标志。忽略。

返回值:
成功后,mmap()返回指向映射区域的指针。错误时,返回值MAP_FAILED(即,(void*)-1),并设置errno以指示错误原因。

2.2、munmap()

munmap()系统调用删除指定地址范围的映射,并导致对该范围内地址的进一步引用生成无效内存引用。当进程终止时,区域也会自动取消映射。另一方面,关闭文件描述符不会取消区域映射。

地址addr必须是页面大小的倍数(但长度不必是)。包含指定范围一部分的所有页面均未映射,对这些页面的后续引用将生成SIGSEGV。如果指示的范围不包含任何映射页,则不是错误。

返回值:
成功时,munmap()返回0。失败时,它返回-1,errno被设置为指示错误原因(可能是EINVAL)。

错误代码

错误代码 含义
EACCES 文件描述符指的是非常规文件。或者请求了文件映射,但fd未打开读取。或者请求MAP_SHARED并且设置PROT_WRITE,但fd在读/写(O_RDWR)模式下未打开。或者设置了PROT_WRITE,但该文件仅为append。
EAGAIN 文件已锁定,或已锁定过多内存【请参阅setrlimit()】。
EBADF fd不是有效的文件描述符(并且未设置MAP_ANONYMOUS)。
EINVAL 我们不喜欢addr、length或offset(例如,它们太大,或者在页面边界上没有对齐)。(自Linux 2.6.12起)length为0。
EINVAL 标志既不包含MAP_PRIVATE也不包含MAP_SHARED,或者同时包含这两个值。
ENFILE 已达到系统范围内打开文件总数的限制。
ENODEV 指定文件的底层文件系统不支持内存映射。
ENOMEM 没有可用的内存。
ENOMEM 进程的最大映射数将被超过。当在现有映射的中间取消映射区域时,munmap()也会出现此错误,因为这会导致在未映射区域的任一侧出现两个较小的映射。
EPERM prot参数要求PORT_EXEC,但映射区域属于未安装EXEC的文件系统上的文件。
EPERM 文件封条阻止了该操作;见fcntl()。
ETXTBSY MAP_DENYWRITE已设置,但fd指定的对象已打开写入。
EOVERFLOW 在32位体系结构和大文件扩展名(即使用64位off_t)上:用于长度的页数加上用于偏移量的页数将溢出无符号长(32位)。

使用映射区域可产生以下信号:

信号 含义
SIGSEGV 试图写入映射为只读的区域。
SIGBUS 试图访问缓冲区中与文件不对应的部分(例如,超出文件末尾,包括另一个进程截断文件的情况)。

2.3、流程

(1)打开文件
(2)取文件大小
(3)把文件映射成虚拟内存
(4)通过对内存的读写来实现对文件的读写
(5)卸载映射
(6)关闭文件






2.4、示例代码

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

#define handle_error(msg) \
    do { perror(msg); exit(EXIT_FAILURE); } while (0)

int main(int argc, char *argv[])
{
    char *addr;
    int fd;
    struct stat sb;
    off_t offset, pa_offset;
    size_t length;
    ssize_t s;

    if (argc < 3 || argc > 4) {
        fprintf(stderr, "%s file offset [length]\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    fd = open(argv[1], O_RDONLY);
    if (fd == -1)
        handle_error("open");

    if (fstat(fd, &sb) == -1)           /* To obtain file size */
        handle_error("fstat");

    offset = atoi(argv[2]);
    pa_offset = offset & ~(sysconf(_SC_PAGE_SIZE) - 1);
        /* offset for mmap() must be page aligned */
    if (offset >= sb.st_size) {
        fprintf(stderr, "offset is past end of file\n");
        exit(EXIT_FAILURE);
    }

    if (argc == 4) {
        length = atoi(argv[3]);
        if (offset + length > sb.st_size)
            length = sb.st_size - offset;
                /* Can't display bytes past end of file */

    } else {    /* No length arg ==> display to end of file */
        length = sb.st_size - offset;
    }

    addr = mmap(NULL, length + offset - pa_offset, PROT_READ,
                MAP_PRIVATE, fd, pa_offset);
    if (addr == MAP_FAILED)
        handle_error("mmap");

    s = write(STDOUT_FILENO, addr + offset - pa_offset, length);
    if (s != length) {
        if (s == -1)
            handle_error("write");

        fprintf(stderr, "partial write");
        exit(EXIT_FAILURE);
    }

    exit(EXIT_SUCCESS);
}

三、shm*接口

共享内存就是允许两个不相关的进程访问同一个内存块。共享内存是在两个正在运行的进程之间共享和传递数据的一种非常有效的方式。进程可以将同一段共享内存连接到它们自己的地址空间中,所有进程都可以访问共享内存中的地址。而如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。

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

3.1、shmget()

创建共享内存。函数原型:

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

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

描述:
shmget()返回与参数key的值关联的System V共享内存段的标识符。如果key的值为IPC_PRIVATE或key不是IPC_PRIVATE,不存在与key对应的共享内存段,并且在shmflg中指定了IPC_CREAT,则会创建一个大小等于size值的新共享内存段(向上舍入为PAGE_SIZE的倍数)。

如果shmflg同时指定IPC_CREAT和IPC_ EXCL,并且key已经存在共享内存段,则shmget()将失败,错误号设置为EEXIST。【这类似于open()的组合O_CREAT|O_EXCL的效果。】

值shmflg由以下组成:

标志 含义
IPC_CREAT 创建新段。如果未使用此标志,则shmget()将查找与键关联的段,并检查用户是否有访问该段的权限。
IPC_EXCL 此标志与IPC_ CREAT一起使用,以确保此调用创建段。如果段已经存在,则调用失败。
SHM_HUGETLB (自Linux 2.6起)使用“巨大页面”分配段。
SHM_HUGE_2MB、SHM_ HUGE _1GB (自Linux 3.8起)与SHM_HUGETLB结合使用,在支持多种HUGETLB页面大小的系统上选择可选的HUGETLB页大小(分别为2 MB和1 GB)。更一般地,可以通过在偏移SHM_SHAGE_SHIFT处对六位中的期望页面大小的以2为底的对数进行编码来配置期望的巨大页面大小。因此,上述两个常数定义为:#define SHM_HUGE_2MB (21 << SHM_HUGE_SHIFT) 和 #define SHM_HUGE_1GB (30 << SHM_HUGE_SHIFT)
SHM_NORESERVE (自Linux 2.6.15起)该标志的作用与mmap() MAP_NORESERVE标志相同。不要为此段保留交换空间。当保留交换空间时,可以保证可以修改段。当交换空间未保留时,如果没有可用的物理内存,则在写入时可能会得到SIGSEGV。

除上述标志外,shmflg的最低有效9位指定授予所有者、组和其他人的权限。这些位的格式和含义与open()的模式参数相同。目前,系统不使用执行权限。

返回值:
成功后,将返回有效的共享内存标识符。出现错误时,返回-1,并设置errno以指示错误。

错误:
失败时,错误号设置为以下之一:

错误代码 含义
EACCES 用户没有访问共享内存段的权限,并且没有CAP_IPC_OWNER功能。
EEXIST 在shmflg中指定了IPC_CREAT和IPC_ EXCL,但密钥的共享内存段已经存在。
EINVAL 将创建一个新的段,其大小小于SHMMIN或大于SHMMAX。
EINVAL 给定键的段存在,但大小大于该段的大小。
ENFILE 已达到系统范围内打开文件总数的限制。
ENOENT 给定密钥不存在任何段,并且未指定IPC_CREAT。
ENOMEM 无法为段开销分配内存。
ENOSPC 已获取所有可能的共享内存ID(SHMMNI),或者分配请求大小的段将导致系统超过系统范围内的限制共享内存(SHMALL)。
EPERM 指定了SHM_HUGETLB标志,但调用方没有特权(没有CAP_IPC_LOCK功能)。

3.2、shmat()

启动对该共享内存的访问,并把共享内存连接到当前进程的地址空间,函数原型:

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

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

描述:
shmat()将由shmid标识的System V共享内存段附加到调用进程的地址空间。附加地址由shmaddr根据以下标准之一指定:
(1)如果shmaddr为空,系统将选择一个合适的(未使用的)地址来连接段。
(2)如果shmaddr不为空,并且在shmflg中指定了SHM_RND,则附加发生在等于shmaddr的地址处,向下舍入到SHMLBA的最近倍数。
(3)否则,shmaddr必须是发生附加的页对齐地址。

除了SHM_RND,还可以在shmflg位掩码参数中指定以下标志:

标志 含义
SHM_EXEC (特定于Linux;自Linux 2.6.9起)允许执行段的内容。调用者必须对段具有执行权限。
SHM_RDONLY 附加段以进行只读访问。进程必须具有段的读取权限。如果未指定此标志,则附加该段以进行读写访问,并且进程必须具有该段的读写权限。不存在只写共享内存段的概念。
SHM_REMAP (特定于Linux)此标志指定线段的映射应替换范围内从shmaddr开始并持续到线段大小的任何现有映射。(通常,如果此地址范围中已存在映射,则会导致EINVAL错误。)在这种情况下,shmaddr不能为空。

呼叫进程的brk()值不被附加改变。该段将在进程退出时自动分离。同一段可以作为读写段附加在进程的地址空间中,并且可以多次附加。
成功的shmat()调用更新与共享内存段相关联的shmid_ds结构的成员【参见shmctl()】,如下所示:
shm_ atime被设置为当前时间。
shm_ lpid被设置为调用进程的进程ID。
shm_natch递增1。

返回值:
成功时,shmat()返回附加共享内存段的地址;错误时,返回(void*)-1,并设置errno以指示错误原因。

错误:
当shmat()失败时,errno设置为以下之一:

错误代码 含义
EACCES 调用进程不具有请求的附加类型所需的权限,并且不具有CAP_IPC_OWNER功能。
EIDRM shmid指向已删除的标识符。
EINVAL 无效的shmid值,未对齐(即,未页面对齐且未指定SHM_RND)或无效的shmaddr值,或无法在shmaddr处附加段,或指定了SHM_ REMAP且shmaddr为空。
ENOMEM 无法为描述符或页表分配内存。

3.3、shmdt()

将共享内存从当前进程中分离。注意,将共享内存分离并不是删除它,只是使该共享内存对当前进程不再可用。函数原型:

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

int shmdt(const void *shmaddr);

描述:
shmdt()将位于shmaddr指定地址的共享内存段从调用进程的地址空间中分离。要分离的段当前附加的shmaddr必须等于附加的shmat()调用返回的值。

参数shmaddr是shmat()函数返回的地址指针。

在成功调用shmdt()时,系统更新与共享内存段关联的shmid_ds结构的成员,如下所示:
shm_ atime被设置为当前时间。
shm_ lpid被设置为调用进程的进程ID。
shm_natch减1。

返回值:
成功时,shmdt()返回0;在出现错误时,返回-1,并设置errno以指示错误原因。

错误:
当shmdt()失败时,errno设置如下:

错误代码 含义
EINVAL 在shmaddr没有附加共享内存段;或者,shmaddr不在页面边界上对齐。

3.4、shmctl()

控制共享内存。函数原型:

#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

描述:
shmctl()对系统V共享内存段执行cmd指定的控制操作,该段的标识符在shmid中给出。
buf参数是指向shmid_ds结构的指针,如下:

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

ipc_perm结构定义如下:

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_INFO或SHM_INFO操作将返回内核内部数组中记录所有共享内存段信息的最高使用项的索引。(此信息可与重复的SHM_STAT操作一起使用,以获得有关系统上所有共享内存段的信息。)成功的SHM_STAT操作返回其索引在shmid中给出的共享内存段标识符。其他操作成功时返回0。
出现错误时,返回-1,并适当设置errno。

3.5、流程


总结

共享内存,可以大大加快对文件或设备的读写操作。共享内存的方式有mmap和shmget 、 shmat。
所谓的零拷贝,就是不需要CPU的参与,而不是其他的意思。
mmap内部其实是一个DMA技术。

欢迎关注公众号《Lion 莱恩呀》学习技术,每日推送文章。

image.png

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。