进程间基于共享存储区的通信_Linux 下的进程间通信:共享存储

dd315042b7fac01323f806152a62fc07.png

学习在 Linux 中进程是如何与其他进程进行同步的。

-- Marty Kalin

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

  • 共享文件
  • 共享内存(使用信号量)
  • 管道(命名的或非命名的管道)
  • 消息队列
  • 套接字
  • 信号

在聚焦上面提到的共享文件和共享内存这两个机制之前,这篇文章将带你回顾一些核心的概念。

核心概念

进程是运行着的程序,每个进程都有着它自己的地址空间,这些空间由进程被允许访问的内存地址组成。进程有一个或多个执行线程,而线程是一系列执行指令的集合:单线程进程就只有一个线程,而多线程的进程则有多个线程。一个进程中的线程共享各种资源,特别是地址空间。另外,一个进程中的线程可以直接通过共享内存来进行通信,尽管某些现代语言(例如 Go)鼓励一种更有序的方式,例如使用线程安全的通道。当然对于不同的进程,默认情况下,它们不能共享内存。

有多种方法启动之后要进行通信的进程,下面所举的例子中主要使用了下面的两种方法:

  • 一个终端被用来启动一个进程,另外一个不同的终端被用来启动另一个。
  • 在一个进程(父进程)中调用系统函数 fork,以此生发另一个进程(子进程)。

第一个例子采用了上面使用终端的方法。这些 代码示例 的 ZIP 压缩包可以从我的网站下载到。

共享文件

程序员对文件访问应该都已经很熟识了,包括许多坑(不存在的文件、文件权限损坏等等),这些问题困扰着程序对文件的使用。尽管如此,共享文件可能是最为基础的 IPC 机制了。考虑一下下面这样一个相对简单的例子,其中一个进程(生产者 producer)创建和写入一个文件,然后另一个进程(消费者 consumer)从这个相同的文件中进行读取:

writes +-----------+ reads

producer-------->| disk file |

+-----------+

在使用这个 IPC 机制时最明显的挑战是竞争条件可能会发生:生产者和消费者可能恰好在同一时间访问该文件,从而使得输出结果不确定。为了避免竞争条件的发生,该文件在处于读或写状态时必须以某种方式处于被锁状态,从而阻止在写操作执行时和其他操作的冲突。在标准系统库中与锁相关的 API 可以被总结如下:

  • 生产者应该在写入文件时获得一个文件的排斥锁。一个排斥锁最多被一个进程所拥有。这样就可以排除掉竞争条件的发生,因为在锁被释放之前没有其他的进程可以访问这个文件。
  • 消费者应该在从文件中读取内容时得到至少一个共享锁。多个读取者可以同时保有一个共享锁,但是没有写入者可以获取到文件内容,甚至在当只有一个读取者保有一个共享锁时。

共享锁可以提升效率。假如一个进程只是读入一个文件的内容,而不去改变它的内容,就没有什么原因阻止其他进程来做同样的事。但如果需要写入内容,则很显然需要文件有排斥锁。

标准的 I/O 库中包含一个名为 fcntl 的实用函数,它可以被用来检查或者操作一个文件上的排斥锁和共享锁。该函数通过一个文件描述符(一个在进程中的非负整数值)来标记一个文件(在不同的进程中不同的文件描述符可能标记同一个物理文件)。对于文件的锁定, Linux 提供了名为 flock 的库函数,它是 fcntl 的一个精简包装。第一个例子中使用 fcntl 函数来暴露这些 API 细节。

示例 1. 生产者程序

#include

#include

#include

#include

#define FileName "data.dat"

void report_and_exit(const char* msg) {

[perror][4](msg);

[exit][5](-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;

}

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

  • 这个程序首先声明了一个类型为 struct flock 的变量,它代表一个锁,并对它的 5 个域做了初始化。第一个初始化

lock.l_type = F_WRLCK; /* exclusive lock */

  • 使得这个锁为排斥锁(read-write)而不是一个共享锁(read-only)。假如生产者获得了这个锁,则其他的进程将不能够对文件做读或者写操作,直到生产者释放了这个锁,或者显式地调用 fcntl,又或者隐式地关闭这个文件。(当进程终止时,所有被它打开的文件都会被自动关闭,从而释放了锁)
  • 上面的程序接着初始化其他的域。主要的效果是整个文件都将被锁上。但是,有关锁的 API 允许特别指定的字节被上锁。例如,假如文件包含多个文本记录,则单个记录(或者甚至一个记录的一部分)可以被锁,而其余部分不被锁。
  • 第一次调用 fcntl

if (fcntl(fd, F_SETLK, &lock) < 0)

  • 尝试排斥性地将文件锁住,并检查调用是否成功。一般来说, fcntl 函数返回 -1 (因此小于 0)意味着失败。第二个参数 F_SETLK 意味着 fcntl 的调用不是堵塞的;函数立即做返回,要么获得锁,要么显示失败了。假如替换地使用 F_SETLKW(末尾的 W 代指等待),那么对 fcntl 的调用将是阻塞的,直到有可能获得锁的时候。在调用 fcntl 函数时,它的第一个参数 fd 指的是文件描述符,第二个参数指定了将要采取的动作(在这个例子中,F_SETLK 指代设置锁),第三个参数为锁结构的地址(在本例中,指的是 &lock)。
  • 假如生产者获得了锁,这个程序将向文件写入两个文本记录。
  • 在向文件写入内容后,生产者改变锁结构中的 l_type 域为 unlock 值:

lock.l_type = F_UNLCK;

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

示例 2. 消费者程序

#include

#include

#include

#include

#define FileName "data.dat"

void report_and_exit(const char* msg) {

[perror][4](msg);

[exit][5](-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 以此来显示当前的状态。假如文件是排斥性地被锁,那么消费者将会终止。(一个更健壮的程序版本或许应该让消费者睡会儿,然后再尝试几次。)

假如当前文件没有被锁,那么消费者将尝试获取一个共享(read-only)锁(F_RDLCK)。为了缩短程序,fcntl 中的 F_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,这既结合了内存获取的速度优势,又获得了文件存储的持久性。

下面的共享内存例子中包含两个程序,分别名为 memwriter 和 memreader,并使用信号量来调整它们对共享内存的获取。在任何时候当共享内存进入一个写入者场景时,无论是多进程还是多线程,都有遇到基于内存的竞争条件的风险,所以,需要引入信号量来协调(同步)对共享内存的获取。

memwriter 程序应当在它自己所处的终端首先启动,然后 memreader 程序才可以在它自己所处的终端启动(在接着的十几秒内)。memreader 的输出如下:

This is the way the world ends...

在每个源程序的最上方注释部分都解释了在编译它们时需要添加的链接参数。

首先让我们复习一下信号量是如何作为一个同步机制工作的。一般的信号量也被叫做一个计数信号量,因为带有一个可以增加的值(通常初始化为 0)。考虑一家租用自行车的商店,在它的库存中有 100 辆自行车,还有一个供职员用于租赁的程序。每当一辆自行车被租出去,信号量就增加 1;当一辆自行车被还回来,信号量就减 1。在信号量的值为 100 之前都还可以进行租赁业务,但如果等于 100 时,就必须停止业务,直到至少有一辆自行车被还回来,从而信号量减为 99。

二元信号量是一个特例,它只有两个值:0 和 1。在这种情况下,信号量的表现为互斥量(一个互斥的构造)。下面的共享内存示例将把信号量用作互斥量。当信号量的值为 0 时,只有 memwriter 可以获取共享内存,在写操作完成后,这个进程将增加信号量的值,从而允许 memreader 来读取共享内存。

示例 3. memwriter 进程的源程序

/** Compilation: gcc -o memwriter memwriter.c -lrt -lpthread **/

#include

#include

#include

#include

#include

#include

#include

#include

#include "shmem.h"

void report_and_exit(const char* msg) {

[perror][4](msg);

[exit][5](-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][7](stderr, "shared mem address: %p [0..%d]

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值