Linux 中的进程间通信:共享存储

了解进程如何在 Linux 中彼此同步。
274 位读者喜欢这个。
Filing papers and documents

这是关于 Linux 中进程间通信 (IPC) 系列文章的第一篇。本系列使用 C 代码示例来阐明以下 IPC 机制

  • 共享文件
  • 共享内存(带信号量)
  • 管道(命名和未命名)
  • 消息队列
  • 套接字
  • 信号

本文回顾了一些核心概念,然后继续介绍前两个机制:共享文件和共享内存。

核心概念

进程是正在执行的程序,每个进程都有自己的地址空间,其中包括进程允许访问的内存位置。一个进程有一个或多个执行线程,它们是可执行指令的序列:单线程进程只有一个线程,而多线程进程有多个线程。进程内的线程共享各种资源,特别是地址空间。因此,进程内的线程可以通过共享内存直接通信,尽管一些现代语言(例如,Go)鼓励更规范的方法,例如使用线程安全的通道。这里感兴趣的是,默认情况下,不同的进程共享内存。

启动随后进行通信的进程有多种方法,以下示例中主要使用两种方法

  • 使用一个终端启动一个进程,也许使用另一个不同的终端启动另一个进程。
  • 在一个进程(父进程)中调用系统函数 fork 以派生另一个进程(子进程)。

第一个示例采用终端方法。代码示例可以在我的网站上的 ZIP 文件中找到。

共享文件

程序员都非常熟悉文件访问,包括程序中使用文件时遇到的许多陷阱(文件不存在、文件权限错误等等)。尽管如此,共享文件可能是最基本的 IPC 机制。考虑一个相对简单的情况,其中一个进程(生产者)创建文件并写入文件,另一个进程(消费者)从同一个文件读取

         writes  +-----------+  reads
producer-------->| disk file |<-------consumer
                 +-----------+

使用此 IPC 机制的明显挑战是可能会出现竞争条件:生产者和消费者可能会在完全相同的时间访问文件,从而使结果不确定。为了避免竞争条件,必须以防止写入操作与任何其他操作(无论是读取还是写入)之间发生冲突的方式锁定文件。标准系统库中的锁定 API 可以总结如下

  • 生产者应在写入文件之前获得文件的独占锁。一个独占锁最多只能由一个进程持有,这排除了竞争条件,因为在锁释放之前,没有其他进程可以访问该文件。
  • 消费者应在从文件读取之前至少获得文件的共享锁。多个读取者可以同时持有共享锁,但即使只有一个读取者持有共享锁,也没有写入者可以访问文件。

共享锁提高了效率。如果一个进程只是读取文件而不更改其内容,则没有理由阻止其他进程执行相同的操作。但是,写入显然需要对文件的独占访问。

标准 I/O 库包含一个名为 fcntl 的实用函数,该函数可用于检查和操作文件上的独占锁和共享锁。该函数通过文件描述符工作,文件描述符是一个非负整数值,在进程中标识一个文件。(不同进程中的不同文件描述符可能标识同一个物理文件。)对于文件锁定,Linux 提供了库函数 flock,它是 fcntl 的一个薄封装。第一个示例使用 fcntl 函数来公开 API 细节。

示例 1. 生产者程序

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

#define FileName "data.dat"
#define DataString "Now is the winter of our discontent\nMade glorious summer by this sun of York\n"

void report_and_exit(const char* msg) {
  perror(msg);
  exit(-1); /* EXIT_FAILURE */
}

int main() {
  struct flock lock;
  lock.l_type = F_WRLCK;    /* read/write (exclusive versus shared) lock */
  lock.l_whence = SEEK_SET; /* base for seek offsets */
  lock.l_start = 0;         /* 1st byte in file */
  lock.l_len = 0;           /* 0 here means 'until EOF' */
  lock.l_pid = getpid();    /* process id */

  int fd; /* file descriptor to identify a file within a process */
  if ((fd = open(FileName, O_RDWR | O_CREAT, 0666)) < 0)  /* -1 signals an error */
    report_and_exit("open failed...");

  if (fcntl(fd, F_SETLK, &lock) < 0) /** F_SETLK doesn't block, F_SETLKW does **/
    report_and_exit("fcntl failed to get lock...");
  else {
    write(fd, DataString, strlen(DataString)); /* populate data file */
    fprintf(stderr, "Process %d has written to data file...\n", lock.l_pid);
  }

  /* Now release the lock explicitly. */
  lock.l_type = F_UNLCK;
  if (fcntl(fd, F_SETLK, &lock) < 0)
    report_and_exit("explicit unlocking failed...");

  close(fd); /* close the file: would unlock if needed */
  return 0;  /* terminating the process would unlock as well */
}

上面生产者程序中的主要步骤可以总结如下

  • 程序声明了一个 struct flock 类型的变量,它表示一个锁,并初始化了结构的五个字段。第一个初始化
    lock.l_type = F_WRLCK; /* exclusive lock */

    使锁成为独占(读写)锁,而不是共享(只读)锁。如果生产者获得锁,那么在生产者显式调用 fcntl 释放锁或隐式关闭文件之前,没有其他进程能够写入或读取文件。(当进程终止时,任何打开的文件都将自动关闭,从而释放锁。)

  • 然后,程序初始化其余字段。主要效果是要锁定整个文件。但是,锁定 API 仅允许锁定指定的字节。例如,如果文件包含多个文本记录,则可以锁定单个记录(甚至记录的一部分),而其余部分保持未锁定状态。
  • fcntl 的第一次调用
    if (fcntl(fd, F_SETLK, &lock) < 0)

    尝试独占锁定文件,检查调用是否成功。通常,fcntl 函数返回 -1(因此,小于零)表示失败。第二个参数 F_SETLK 表示对 fcntl 的调用阻塞:该函数立即返回,要么授予锁,要么指示失败。如果改为使用标志 F_SETLKW(末尾的 W 表示 wait),则对 fcntl 的调用将阻塞,直到可以获得锁为止。在对 fcntl 的调用中,第一个参数 fd 是文件描述符,第二个参数指定要执行的操作(在本例中,是用于设置锁的 F_SETLK),第三个参数是锁结构的地址(在本例中,是 &lock)。

  • 如果生产者获得锁,则程序将两个文本记录写入文件。
  • 在写入文件后,生产者将锁结构的 l_type 字段更改为 解锁
    lock.l_type = F_UNLCK;

    并调用 fcntl 执行解锁操作。程序最后关闭文件并退出。

示例 2. 消费者程序

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

#define FileName "data.dat"

void report_and_exit(const char* msg) {
  perror(msg);
  exit(-1); /* EXIT_FAILURE */
}

int main() {
  struct flock lock;
  lock.l_type = F_WRLCK;    /* read/write (exclusive) lock */
  lock.l_whence = SEEK_SET; /* base for seek offsets */
  lock.l_start = 0;         /* 1st byte in file */
  lock.l_len = 0;           /* 0 here means 'until EOF' */
  lock.l_pid = getpid();    /* process id */

  int fd; /* file descriptor to identify a file within a process */
  if ((fd = open(FileName, O_RDONLY)) < 0)  /* -1 signals an error */
    report_and_exit("open to read failed...");

  /* If the file is write-locked, we can't continue. */
  fcntl(fd, F_GETLK, &lock); /* sets lock.l_type to F_UNLCK if no write lock */
  if (lock.l_type != F_UNLCK)
    report_and_exit("file is still write locked...");

  lock.l_type = F_RDLCK; /* prevents any writing during the reading */
  if (fcntl(fd, F_SETLK, &lock) < 0)
    report_and_exit("can't get a read-only lock...");

  /* Read the bytes (they happen to be ASCII codes) one at a time. */
  int c; /* buffer for read bytes */
  while (read(fd, &c, 1) > 0)    /* 0 signals EOF */
    write(STDOUT_FILENO, &c, 1); /* write one byte to the standard output */

  /* Release the lock explicitly. */
  lock.l_type = F_UNLCK;
  if (fcntl(fd, F_SETLK, &lock) < 0)
    report_and_exit("explicit unlocking failed...");

  close(fd);
  return 0;
}

消费者程序比必要的更复杂,目的是突出显示锁定 API 的功能。特别是,消费者程序首先检查文件是否被独占锁定,然后才尝试获得共享锁。相关代码是

lock.l_type = F_WRLCK;
...
fcntl(fd, F_GETLK, &lock); /* sets lock.l_type to F_UNLCK if no write lock */
if (lock.l_type != F_UNLCK)
  report_and_exit("file is still write locked...");

fcntl 调用中指定的 F_GETLK 操作检查锁,在本例中,是上面第一个语句中给出的独占锁 F_WRLCK。如果指定的锁不存在,则 fcntl 调用会自动将锁类型字段更改为 F_UNLCK 以指示此事实。如果文件被独占锁定,则消费者终止。(更健壮的程序版本可能会让消费者 sleep 一会儿,然后重试几次。)

如果文件当前未锁定,则消费者尝试获得共享(只读)锁(F_RDLCK)。为了缩短程序,可以删除对 fcntlF_GETLK 调用,因为如果某个其他进程已经持有读写锁,则 F_RDLCK 调用将失败。回想一下,只读锁确实会阻止任何其他进程写入文件,但允许其他进程从文件读取。简而言之,共享锁可以由多个进程持有。在获得共享锁后,消费者程序从文件中一次读取一个字节,将字节打印到标准输出,释放锁,关闭文件并终止。

以下是从同一终端启动的两个程序的输出,其中 % 作为命令行提示符

% ./producer
Process 29255 has written to data file...

% ./consumer
Now is the winter of our discontent
Made glorious summer by this sun of York

在第一个代码示例中,通过 IPC 共享的数据是文本:莎士比亚戏剧《理查三世》中的两行。然而,共享文件的内容可能是大量的任意字节(例如,数字电影),这使得文件共享成为一种非常灵活的 IPC 机制。缺点是文件访问相对较慢,无论访问涉及读取还是写入。与往常一样,编程伴随着权衡。下一个示例的优点是通过共享内存(而不是共享文件)进行 IPC,从而提高了性能。

共享内存

Linux 系统为共享内存提供了两个独立的 API:传统的 System V API 和更新的 POSIX API。但是,不应在单个应用程序中混合使用这些 API。POSIX 方法的一个缺点是功能仍在开发中,并且依赖于已安装的内核版本,这会影响代码的可移植性。例如,默认情况下,POSIX API 将共享内存实现为内存映射文件:对于共享内存段,系统维护一个具有相应内容的后备文件。POSIX 下的共享内存可以配置为没有后备文件,但这可能会影响可移植性。我的示例使用带有后备文件的 POSIX API,它结合了内存访问(速度)和文件存储(持久性)的优点。

共享内存示例有两个程序,名为 memwritermemreader,并使用信号量来协调它们对共享内存的访问。每当共享内存与写入者一起出现时,无论是在多进程还是多线程中,都会存在基于内存的竞争条件的风险;因此,信号量用于协调(同步)对共享内存的访问。

memwriter 程序应首先在其自己的终端中启动。然后可以在其自己的终端中启动 memreader 程序(在十几秒钟内)。memreader 的输出是

This is the way the world ends...

每个源文件顶部都有文档,解释了编译期间要包含的链接标志。

让我们首先回顾一下信号量作为同步机制的工作原理。通用信号量也称为计数信号量,因为它有一个值(通常初始化为零),可以递增。考虑一家出租自行车的商店,库存有一百辆自行车,并有一个店员用来进行租赁的程序。每次租出一辆自行车,信号量就递增 1;当自行车归还时,信号量就递减 1。租赁可以继续,直到值达到 100,但然后必须停止,直到至少归还一辆自行车,从而将信号量递减到 99。

二元信号量是一种特殊情况,只需要两个值:0 和 1。在这种情况下,信号量充当 mutex:互斥构造。共享内存示例使用信号量作为互斥锁。当信号量的值为 0 时,只有 memwriter 可以访问共享内存。写入后,此进程将信号量的值递增,从而允许 memreader 读取共享内存。

示例 3. memwriter 进程的源代码

/** Compilation: gcc -o memwriter memwriter.c -lrt -lpthread **/
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <semaphore.h>
#include <string.h>
#include "shmem.h"

void report_and_exit(const char* msg) {
  perror(msg);
  exit(-1);
}

int main() {
  int fd = shm_open(BackingFile,      /* name from smem.h */
                    O_RDWR | O_CREAT, /* read/write, create if needed */
                    AccessPerms);     /* access permissions (0644) */
  if (fd < 0) report_and_exit("Can't open shared mem segment...");

  ftruncate(fd, ByteSize); /* get the bytes */

  caddr_t memptr = mmap(NULL,       /* let system pick where to put segment */
                        ByteSize,   /* how many bytes */
                        PROT_READ | PROT_WRITE, /* access protections */
                        MAP_SHARED, /* mapping visible to other processes */
                        fd,         /* file descriptor */
                        0);         /* offset: start at 1st byte */
  if ((caddr_t) -1  == memptr) report_and_exit("Can't get segment...");

  fprintf(stderr, "shared mem address: %p [0..%d]\n", memptr, ByteSize - 1);
  fprintf(stderr, "backing file:       /dev/shm%s\n", BackingFile );

  /* semaphore code to lock the shared mem */
  sem_t* semptr = sem_open(SemaphoreName, /* name */
                           O_CREAT,       /* create the semaphore */
                           AccessPerms,   /* protection perms */
                           0);            /* initial value */
  if (semptr == (void*) -1) report_and_exit("sem_open");

  strcpy(memptr, MemContents); /* copy some ASCII bytes to the segment */

  /* increment the semaphore so that memreader can read */
  if (sem_post(semptr) < 0) report_and_exit("sem_post");

  sleep(12); /* give reader a chance */

  /* clean up */
  munmap(memptr, ByteSize); /* unmap the storage */
  close(fd);
  sem_close(semptr);
  shm_unlink(BackingFile); /* unlink from the backing file */
  return 0;
}

以下概述了 memwritermemreader 程序如何通过共享内存进行通信

  • 上面显示的 memwriter 程序调用 shm_open 函数以获取系统与共享内存协调的后备文件的文件描述符。此时,尚未分配任何内存。随后调用名称具有误导性的函数 ftruncate
    ftruncate(fd, ByteSize); /* get the bytes */

    分配 ByteSize 字节,在本例中,是适度的 512 字节。memwritermemreader 程序仅访问共享内存,而不访问后备文件。系统负责同步共享内存和后备文件。

  • 然后 memwriter 调用 mmap 函数
    caddr_t memptr = mmap(NULL,       /* let system pick where to put segment */
                          ByteSize,   /* how many bytes */
                          PROT_READ | PROT_WRITE, /* access protections */
                          MAP_SHARED, /* mapping visible to other processes */
                          fd,         /* file descriptor */
                          0);         /* offset: start at 1st byte */

    以获取指向共享内存的指针。(memreader 进行类似的调用。)指针类型 caddr_tc 开头,表示 calloc,这是一个将动态分配的存储初始化为零的系统函数。memwritermemptr 用于后续的写入操作,使用库 strcpy(字符串复制)函数。

  • 此时,memwriter 已准备好写入,但它首先创建一个信号量以确保对共享内存的独占访问。如果在 memreader 正在读取时 memwriter 正在写入,则会发生竞争条件。如果对 sem_open 的调用成功
    sem_t* semptr = sem_open(SemaphoreName, /* name */
                             O_CREAT,       /* create the semaphore */
                             AccessPerms,   /* protection perms */
                             0);            /* initial value */

    则可以继续写入。SemaphoreName(任何唯一的非空名称都可以)在 memwritermemreader 中标识信号量。零的初始值使信号量的创建者(在本例中为 memwriter)有权继续,在本例中,是写入操作。

  • 写入后,memwriter 将信号量值递增到 1
    if (sem_post(semptr) < 0) ..

    通过调用 sem_post 函数。递增信号量会释放互斥锁,并使 memreader 能够执行其读取操作。为了安全起见,memwriter 还从 memwriter 地址空间取消映射共享内存

    munmap(memptr, ByteSize); /* unmap the storage *

    这阻止 memwriter 进一步访问共享内存。

示例 4. memreader 进程的源代码

/** Compilation: gcc -o memreader memreader.c -lrt -lpthread **/
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <semaphore.h>
#include <string.h>
#include "shmem.h"

void report_and_exit(const char* msg) {
  perror(msg);
  exit(-1);
}

int main() {
  int fd = shm_open(BackingFile, O_RDWR, AccessPerms);  /* empty to begin */
  if (fd < 0) report_and_exit("Can't get file descriptor...");

  /* get a pointer to memory */
  caddr_t memptr = mmap(NULL,       /* let system pick where to put segment */
                        ByteSize,   /* how many bytes */
                        PROT_READ | PROT_WRITE, /* access protections */
                        MAP_SHARED, /* mapping visible to other processes */
                        fd,         /* file descriptor */
                        0);         /* offset: start at 1st byte */
  if ((caddr_t) -1 == memptr) report_and_exit("Can't access segment...");

  /* create a semaphore for mutual exclusion */
  sem_t* semptr = sem_open(SemaphoreName, /* name */
                           O_CREAT,       /* create the semaphore */
                           AccessPerms,   /* protection perms */
                           0);            /* initial value */
  if (semptr == (void*) -1) report_and_exit("sem_open");

  /* use semaphore as a mutex (lock) by waiting for writer to increment it */
  if (!sem_wait(semptr)) { /* wait until semaphore != 0 */
    int i;
    for (i = 0; i < strlen(MemContents); i++)
      write(STDOUT_FILENO, memptr + i, 1); /* one byte at a time */
    sem_post(semptr);
  }

  /* cleanup */
  munmap(memptr, ByteSize);
  close(fd);
  sem_close(semptr);
  unlink(BackingFile);
  return 0;
}

memwritermemreader 程序中,主要关注的共享内存函数是 shm_openmmap:成功后,第一次调用返回后备文件的文件描述符,第二次调用然后使用该文件描述符来获取指向共享内存段的指针。对 shm_open 的调用在两个程序中类似,除了 memwriter 程序创建共享内存,而 memreader 仅访问此已创建的内存

int fd = shm_open(BackingFile, O_RDWR | O_CREAT, AccessPerms); /* memwriter */
int fd = shm_open(BackingFile, O_RDWR, AccessPerms);           /* memreader */

有了文件描述符后,对 mmap 的调用是相同的

caddr_t memptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

mmap 的第一个参数是 NULL,这意味着系统确定在虚拟地址空间中分配内存的位置。也可以(但很棘手)指定地址。MAP_SHARED 标志指示分配的内存在进程之间是可共享的,最后一个参数(在本例中为零)表示共享内存的偏移量应为第一个字节。size 参数指定要分配的字节数(在本例中为 512),保护参数指示共享内存可以写入和读取。

memwriter 程序成功执行时,系统会创建并维护后备文件;在我的系统上,该文件是 /dev/shm/shMemEx,其中 shMemEx 是我在共享存储的头文件 shmem.h 中给出的名称。在当前版本的 memwritermemreader 程序中,语句

shm_unlink(BackingFile); /* removes backing file */

删除后备文件。如果省略 unlink 语句,则后备文件会在程序终止后持续存在。

memreadermemwriter 一样,通过其名称在对 sem_open 的调用中访问信号量。但是 memreader 然后进入等待状态,直到 memwriter 递增信号量,其初始值为 0

if (!sem_wait(semptr)) { /* wait until semaphore != 0 */

一旦等待结束,memreader 从共享内存读取 ASCII 字节,清理并终止。

共享内存 API 包括显式同步共享内存段和后备文件的操作。这些操作已从示例中省略,以减少混乱,并将重点放在内存共享和信号量代码上。

即使删除了信号量代码,memwritermemreader 程序也可能在不引起竞争条件的情况下执行:memwriter 创建共享内存段并立即写入;memreader 甚至在创建共享内存之前都无法访问它。但是,最佳实践要求每当混合使用写入操作时,都应同步共享内存访问,并且信号量 API 非常重要,因此需要在代码示例中突出显示。

总结

共享文件和共享内存示例展示了进程如何通过共享存储进行通信,一种情况是文件,另一种情况是内存段。这两种方法的 API 都相对简单。这些方法是否有共同的缺点?现代应用程序通常处理流数据,实际上是处理海量数据流。共享文件和共享内存方法都不太适合海量数据流。一种或另一种类型的通道更适合。因此,第 2 部分介绍了通道和消息队列,同样使用 C 代码示例。

[下载 Linux 进程间通信完整指南]

标签
User profile image.
我是计算机科学领域的学者(德保罗大学计算与数字媒体学院),在软件开发方面拥有广泛的经验,主要是在生产计划和调度(钢铁行业)以及产品配置(卡车和公共汽车制造)方面。有关书籍和其他出版物的详细信息,请访问

1 条评论

信号量自行车示例的小修正
初始值应初始化为 100。
每次租用自行车时,该值都会递减,因此达到零时就不能再租用自行车了
每次归还自行车时,该值都会递增
在 0 时,您的商店里没有自行车,在 100 时,您拥有所有自行车

© . All rights reserved.