复杂的应用编程模型可能包括许多进程,每个进程都被实现来处理特定的作业,这些进程作为一个整体对应用的最终功能做出了贡献。 根据托管此类应用的目标、设计和环境,涉及的进程可能是相关的(父子、兄弟),也可能不相关。 通常,这样的进程需要各种资源来通信、共享数据和同步其执行,以实现所需的结果。 这些服务由操作系统内核提供,称为进程间通信(IPC)。 我们已经讨论了信号作为 IPC 机制的用法;在本章中,我们将开始探索可用于进程通信和数据共享的各种其他资源。
在本章中,我们将介绍以下主题:
- 管道和 FIFO 作为消息传递资源
- SysV IPC 资源
- POSX IPC 机制
管道形成了进程之间单向、自同步的基本通信方式。 顾名思义,它们有两个端点:一个进程写入数据,另一个进程从另一个进程读取数据。 在这种设置中,可能会先读出最先进入的内容。 管道由于其有限的容量,天生就会导致通信同步:如果写入进程的写入速度远远快于读取进程的读取速度,则管道的容量将无法容纳多余的数据,并且在读取器读取并释放数据之前总是阻塞写入进程。 同样,如果读取器读取数据的速度快于写入器,则它将没有数据可读,从而被阻塞,直到数据可用。
管道可以用作两种通信情况的消息传递资源:相关进程之间和不相关进程之间。 当在相关进程之间应用时,管道被称为未命名管道,因为它们不作为rootfs
树下的文件枚举。 可以通过pipe()
API 分配未命名管道。
int pipe2(int pipefd[2], int flags);
API 调用相应的系统调用,该系统调用分配适当的数据结构并设置管道缓冲区。 它映射一对文件描述符,一个用于在管道缓冲区上读取,另一个用于在管道缓冲区上写入。 这些描述符会返回给调用方。 调用方进程通常会派生子进程,子进程继承可用于消息传递的管道文件描述符。
以下代码摘录显示了管道系统调用实现:
SYSCALL_DEFINE2(pipe2, int __user *, fildes, int, flags)
{
struct file *files[2];
int fd[2];
int error;
error = __do_pipe_flags(fd, files, flags);
if (!error) {
if (unlikely(copy_to_user(fildes, fd, sizeof(fd)))) {
fput(files[0]);
fput(files[1]);
put_unused_fd(fd[0]);
put_unused_fd(fd[1]);
error = -EFAULT;
} else {
fd_install(fd[0], files[0]);
fd_install(fd[1], files[1]);
}
}
return error;
}
无关进程之间的通信需要将管道文件枚举到rootfs中。 这样的管道通常称为命名管道*,,可以从命令行(mkfifo
)或使用mkfifo
API的进程创建。*
int mkfifo(const char *pathname, mode_t mode);
使用模式参数指定的名称和适当的权限创建命名管道。 调用mknod
系统调用以创建 FIFO,该 FIFO 在内部调用 VFS 例程来设置命名管道。 具有访问权限的进程可以通过常见的 VFS 文件 APIopen
、read
、write
和close
启动对 FIFO 的操作。
管道和 FIFO 由名为pipefs
的特殊文件系统创建和管理。 它将作为特殊文件系统注册到 VFS。 以下是fs/pipe.c
中的代码摘录:
static struct file_system_type pipe_fs_type = {
.name = "pipefs",
.mount = pipefs_mount,
.kill_sb = kill_anon_super,
};
static int __init init_pipe_fs(void)
{
int err = register_filesystem(&pipe_fs_type);
if (!err) {
pipe_mnt = kern_mount(&pipe_fs_type);
if (IS_ERR(pipe_mnt)) {
err = PTR_ERR(pipe_mnt);
unregister_filesystem(&pipe_fs_type);
}
}
return err;
}
fs_initcall(init_pipe_fs);
它通过枚举表示每个管道的inode
实例将管道文件集成到 VFS 中;这允许应用使用公共文件 APIread
和write
。 inode
结构包含与特殊文件(如管道和设备文件)相关的指针的联合。 对于管道文件inodes
,其中一个指针i_pipe
被初始化为pipefs
,定义为类型pipe_inode_info
的实例:
struct inode {
umode_t i_mode;
unsigned short i_opflags;
kuid_t i_uid;
kgid_t i_gid;
unsigned int i_flags;
...
...
...
union {
struct pipe_inode_info *i_pipe;
struct block_device *i_bdev;
struct cdev *i_cdev;
char *i_link;
unsigned i_dir_seq;
};
...
...
...
};
struct pipe_inode_info
包含由pipefs
定义的所有与管道相关的元数据,其中包括管道缓冲区的信息和其他重要的管理数据。 此结构在<linux/pipe_fs_i.h>
中定义:
struct pipe_inode_info {
struct mutex mutex;
wait_queue_head_t wait;
unsigned int nrbufs, curbuf, buffers;
unsigned int readers;
unsigned int writers;
unsigned int files;
unsigned int waiting_writers;
unsigned int r_counter;
unsigned int w_counter;
struct page *tmp_page;
struct fasync_struct *fasync_readers;
struct fasync_struct *fasync_writers;
struct pipe_buffer *bufs;
struct user_struct *user;
};
bufs
指针引用管道缓冲区;默认情况下,每个管道被分配一个 65,535 字节(64k)的总缓冲区,排列为 16 页的循环数组。 用户进程可以通过对管道描述符执行fcntl()
操作来更改管道缓冲区的总大小。 管道缓冲区的默认最大限制是 1,048,576 字节,特权进程可以通过/proc/sys/fs/pipe-max-size
文件接口更改该值。 下表汇总描述了其余重要元素:
| 名称 | 说明 |
| mutex
| 保护管道的隔离锁 |
| wait
| 等待阅读器和写入器的队列 |
| nrbufs
| 此管道的非空管道缓冲区计数 |
| curbuf
| 当前管道缓冲区 |
| buffers
| 缓冲区总数 |
| readers
| 当前读者数量 |
| writers
| 当前写入者数量 |
| files
| 当前引用此管道的结构文件实例数 |
| waiting_writers
| 管道上当前阻止的写入程序数 |
| r_coutner
| 读卡器计数器(与 FIFO 相关) |
| w_counter
| 写入器计数器(与 FIFO 相关) |
| *fasync_readers
| 读卡器端标签同步 |
| *fasync_writers
| 编写器端标签同步 |
| *bufs
| 指向管道缓冲区循环数组的指针 |
| *user
| 指向表示创建此管道的用户的user_struct
实例的指针 |
对管道缓冲区每页的引用被包装到类型struct pipe_buffer
实例的循环数组中。 此结构在<linux/pipe_fs_i.h>
中定义:
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};
*page
是指向页缓冲区的页描述符的指针,offset
和len
字段包含页缓冲区中包含的数据的偏移量及其长度。 *ops
是指向类型为pipe_buf_operations
的结构的指针,该结构封装了由pipefs
实现的管道缓冲区操作。 它还实现绑定到管道和 FIFO 索引节点的文件操作:
const struct file_operations pipefifo_fops = {
.open = fifo_open,
.llseek = no_llseek,
.read_iter = pipe_read,
.write_iter = pipe_write,
.poll = pipe_poll,
.unlocked_ioctl = pipe_ioctl,
.release = pipe_release,
.fasync = pipe_fasync,
};
消息队列是消息缓冲区列表,任意数量的进程都可以通过这些消息缓冲区进行通信。 与管道不同,写入器不必等待读取器打开管道并侦听数据。 与邮箱类似,写入者可以将包装在缓冲区中的固定长度消息放入队列中,读取器可以在准备就绪时拾取该消息。 消息队列在读取器挑选消息包后不会保留该消息包,这意味着确保每个消息包都是进程持久化的。 Linux 支持两种不同的消息队列实现:传统 Unix SYSV 消息队列和当代 POSIX 消息队列。
这是经典的 AT&T 消息队列实现,适用于任意数量的无关进程之间的消息传递。 发送者进程将每条消息包装成包含消息数据和消息编号的数据包。 消息队列实现没有定义消息编号的含义,它留给应用设计人员为消息编号定义适当的含义,并由程序读取器和编写器对其进行解释。 此机制为程序员提供了将消息编号用作消息 ID 或接收方 ID 的灵活性。 它使读取器进程能够有选择地读取与特定 ID 匹配的消息。 但是,具有相同 ID 的邮件始终按 FIFO 顺序读取(先进先出)。
进程可以使用以下命令创建和打开 SysV 消息队列:
int msgget(key_t key, int msgflg);
key
参数是唯一的常量,用作标识消息队列的幻数。 访问此消息队列所需的所有程序都需要使用相同的幻数;此数通常在编译时硬编码到相关进程中。 但是,应用需要确保每个消息队列的键值是唯一的,并且可以使用其他库函数来动态生成唯一键。
唯一键和msgflag
参数值如果设置为IPC_CREATE
,将导致建立新的消息队列。 有权访问队列的有效进程可以使用msgsnd
和msgrcv
例程将消息读或写到队列中(我们在这里不会详细讨论它们;请参阅 Linux 系统编程手册):
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,
int msgflg);
每个消息队列都是通过底层 SysV IPC 子系统枚举一组数据结构来创建的。 struct msg_queue
是核心数据结构,并且为每个消息队列枚举了它的一个实例:
struct msg_queue {
struct kern_ipc_perm q_perm;
time_t q_stime; /* last msgsnd time */
time_t q_rtime; /* last msgrcv time */
time_t q_ctime; /* last change time */
unsigned long q_cbytes; /* current number of bytes on queue */
unsigned long q_qnum; /* number of messages in queue */
unsigned long q_qbytes; /* max number of bytes on queue */
pid_t q_lspid; /* pid of last msgsnd */
pid_t q_lrpid; /* last receive pid */
struct list_head q_messages; /* message list */
struct list_head q_receivers;/* reader process list */
struct list_head q_senders; /*writer process list */
};
q_messages
字段表示包含队列中当前所有消息的双向链接循环列表的头节点。 每条消息都以报头开头,后跟消息数据;根据消息数据的长度,每条消息可以使用一个或多个页面。 消息标题始终位于第一页的开头,并由struct msg_msg
的实例表示:
/* one msg_msg structure for each message */
struct msg_msg {
struct list_head m_list;
long m_type;
size_t m_ts; /* message text size */
struct msg_msgseg *next;
void *security;
/* the actual message follows immediately */
};
m_list
字段包含指向队列中前一条消息和下一条消息的指针。 *next
指针引用类型为struct msg_msgseg
的实例,该实例包含下一页消息数据的地址。 仅当消息数据超过第一页时,此指针才相关。 第二页帧以描述符msg_msgseg
开始,描述符msg_msgseg
进一步包含指向后续页的指针,并且该顺序继续,直到到达消息数据的最后一页:
struct msg_msgseg {
struct msg_msgseg *next;
/* the next part of the message follows immediately */
};
POSIX 消息队列实现按优先级排序的消息。 发送者进程写入的每条消息都与一个整数相关联,该整数被解释为消息优先级;数字越大的消息被认为优先级越高。 消息队列按照优先级对当前消息进行排序,并按降序(最高优先级优先)将它们传递给读取器进程。 该实现还支持更广泛的 API 接口,包括有界等待、发送和接收操作以及通过信号或线程向接收方发送异步消息到达通知。
此实现为create
、open
、read
、write
和destroy
消息队列提供了不同的 API 接口。 以下是 API 的概要描述(这里我们不讨论使用语义,更多详细信息请参考系统编程手册):
| 接口接口 | 说明 |
| mq_open()
| 创建或打开 POSIX 消息队列 |
| mq_send()
| 将消息写入队列 |
| mq_timedsend()
| 类似于mq_send
,但具有用于有界操作的超时参数 |
| mq_receive()
| 从队列中获取消息;此操作可以在无界阻塞调用上执行 |
| mq_timedreceive()
| 类似于mq_receive()
,但有一个超时参数,可在有限时间内限制可能的阻塞 |
| mq_close()
| 关闭消息队列 |
| mq_unlink()
| 销毁消息队列 |
| mq_notify()
| 自定义和设置邮件到达通知 |
| mq_getattr()
| 获取与消息队列关联的属性 |
| mq_setattr()
| 设置在消息队列上指定的属性 |
POSIX 消息队列由名为mqueue
的特殊文件系统管理。 每个消息队列由一个文件名标识。 每个队列的元数据由 structmqueue_inode_info
的实例描述,该实例表示与mqueue
文件系统中的消息队列文件相关联的 inode 对象:
struct mqueue_inode_info {
spinlock_t lock;
struct inode vfs_inode;
wait_queue_head_t wait_q;
struct rb_root msg_tree;
struct posix_msg_tree_node *node_cache;
struct mq_attr attr;
struct sigevent notify;
struct pid *notify_owner;
struct user_namespace *notify_user_ns;
struct user_struct *user; /* user who created, for accounting */
struct sock *notify_sock;
struct sk_buff *notify_cookie;
/* for tasks waiting for free space and messages, respectively */
struct ext_wait_queue e_wait_q[2];
unsigned long qsize; /* size of queue in memory (sum of all msgs) */
};
*node_cache
指针引用posix_msg_tree_node
描述符,该描述符包含指向消息节点链接列表的标题,其中每条消息由类型为msg_msg
的描述符表示:
struct posix_msg_tree_node {
struct rb_node rb_node;
struct list_head msg_list;
int priority;
};
与提供进程持久化消息传递基础结构的消息队列不同,IPC 的共享内存服务提供内核持久化内存,可以由任意数量的共享公共数据的进程附加。 共享内存基础设施提供了分配、附加、分离和销毁共享内存区域的操作接口。 需要访问共享数据的进程将附加或将共享内存区域映射到其地址空间;然后,它可以通过映射例程返回的地址访问共享内存中的数据。 这使得共享内存成为 IPC 最快的方式之一,因为从进程的角度来看,它类似于访问本地内存,这不涉及切换到内核模式。
Linux 支持 IPC 子系统下的遗留 SysV 共享内存实现。 与 SysV 消息队列类似,每个共享内存区域都由唯一的 IPC 标识符标识。
内核为启动共享内存操作提供了不同的系统调用接口,如下所示:
shmget()
进程调用系统调用来获取共享内存区域的 IPC 标识符;如果该区域不存在,它将创建一个:
int shmget(key_t key, size_t size, int shmflg);
此函数返回与键参数中包含的值对应的共享内存段的标识符。 如果其他进程打算使用现有段,它们可以在查找其标识符时使用该段的键值。 但是,如果键参数唯一或具有值IPC_PRIVATE
,则会创建新段。size
表示需要分配的字节数,因为段被分配为内存页。 要分配的页数是通过将大小值舍入到页面大小的最接近倍数来获得的。
shmflg
标志指定需要如何创建段。 它可以包含两个值:
IPC_CREATE
:表示创建新段。 如果此标志未使用,则查找与密钥值相关联的段,如果用户具有访问权限,则返回该段的标识符。IPC_EXCL
:此标志始终与IPC_CREAT
一起使用,以确保在存在键值时呼叫失败。
共享内存区必须附加到其地址空间,进程才能访问它。 shmat()
被调用以将共享内存附加到调用进程的地址空间:
void *shmat(int shmid, const void *shmaddr, int shmflg);
由shmid
表示的段由该函数附加。 shmaddr
指定指示进程地址空间中要映射段的位置的指针。 第三个参数shmflg
是一个标志,可以是以下之一:
SHM_RND
:当shmaddr
不是空值时指定,表示将段附加到地址的函数,通过将shmaddr
值舍入到页面大小的最接近倍数来计算;否则,用户必须注意shmaddr
与页对齐,以便正确地附加段。SHM_RDONLY
:这是为了指定只有在用户拥有必要的读取权限时才会读取该段。 否则,将同时授予该段的读写访问权限(该进程必须具有相应的权限)。SHM_REMAP
:这是特定于 Linux 的标志,指示shmaddr
指定的地址处的任何现有映射都将替换为新映射。
同样,要将共享内存从进程地址空间中分离出来,需要调用shmdt()
。 由于 IPC 共享内存区在内核中是持久存在的,因此即使在进程分离之后,它们也会继续存在:
int shmdt(const void *shmaddr);
位于shmaddr
指定地址的段从调用进程的地址空间分离。
这些接口操作中的每一个都调用在<ipc/shm.c>
源文件中实现的相关系统调用。
每个共享内存段由struct shmid_kernel
描述符表示。 此结构包含与 SysV 共享内存管理相关的所有元数据:
struct shmid_kernel /* private to the kernel */
{
struct kern_ipc_perm shm_perm;
struct file *shm_file; /* pointer to shared memory file */
unsigned long shm_nattch; /* no of attached process */
unsigned long shm_segsz; /* index into the segment */
time_t shm_atim; /* last access time */
time_t shm_dtim; /* last detach time */
time_t shm_ctim; /* last change time */
pid_t shm_cprid; /* pid of creating process */
pid_t shm_lprid; /* pid of last access */
struct user_struct *mlock_user;
/* The task created the shm object. NULL if the task is dead. */
struct task_struct *shm_creator;
struct list_head shm_clist; /* list by creator */
};
为了可靠和易于管理,内核的 IPC 子系统通过名为shmfs
的特殊文件系统管理共享内存段。 这个文件系统没有挂载到 rootfs 树上;它的操作只能通过 SysV 共享内存系统调用来访问。 *shm_file
指针指向表示共享内存块的shmfs
的struct file
对象。 当进程启动附加操作时,底层系统调用调用do_mmap()
以创建到调用方地址空间的相关映射(通过struct vm_area_struct
),并进入定义的*shmfs-*``shm_mmap()
操作以映射相应的共享内存:
Linux 内核通过一个名为tmpfs
*,*的特殊文件系统支持 POSIX 共享内存,该文件系统安装在rootfs
的/dev/shm
上。 此实现提供了与 Unix 文件模型一致的独特 API,导致每个共享内存分配由唯一的文件名和索引节点表示。 应用编程人员认为该接口更加灵活,因为它允许标准 POSIX 文件映射例程mmap()
和unmap()
将内存段附加和分离到调用方进程地址空间。
以下是接口例程的汇总说明:
| 加入时间:清华大学 2007 年 01 月 25 日下午 3:33 | 说明 |
| shm_open()
| 创建并打开由文件名标识的共享内存段 |
| mmap()
| 用于将共享内存附加到调用者地址空间的 POSIX 标准文件映射接口 |
| sh_unlink()
| 销毁指定的共享内存块 |
| unmap()
| 从调用方地址空间分离指定的共享内存映射 |
底层实现类似于 SysV 共享内存,不同之处在于映射实现由tmpfs
文件系统处理。
尽管共享内存是共享公共数据或资源的最简单方式,但它省去了在进程上实现同步的负担,因为共享内存基础结构不为共享内存区域中的数据或资源提供任何同步或保护机制。 应用设计人员必须考虑竞争进程之间共享内存访问的同步,以确保共享数据的可靠性和有效性,例如,防止两个进程同时在同一区域进行写入,限制读取进程等待另一个进程完成写入,等等。 通常,要同步此类争用条件,需要使用另一个称为信号量的 IPC 资源。
信号量是由 IPC 子系统提供的同步原语。 它们为共享数据结构或资源提供了一种保护机制,以防止多线程环境中的进程进行并发访问。 在其核心,每个信号量都由一个整数计数器组成,调用者进程可以自动访问该计数器。 信号量实现提供了两个操作,一个用于等待信号量变量,另一个用于向信号量变量发送信号。 换言之,等待信号量使计数器减少 1,并向信号量发送信号使计数器增加 1。通常,当进程想要访问共享资源时,它会尝试减少信号量计数器。 但是,内核会处理此尝试,因为它会阻止尝试的进程,直到计数器产生正值。 类似地,当进程放弃资源时,它会增加信号量计数器,从而唤醒正在等待该资源的任何进程。
信号量版本
传统上,所有*nix
系统都实现 System V 信号量机制;然而,POSIX 有自己的信号量实现,目的是为了提高可移植性,并调整 System V 版本所携带的一些笨拙的问题。 让我们从查看 System V 信号量开始。
System V 中的信号量并不像您想象的那样只是一个计数器,而是一组计数器。 这意味着一个信号量集合可以包含具有相同信号量 ID 的单个或多个计数器(0 到 n),集合中的每个计数器可以保护一个共享资源,单个信号量集合可以保护多个资源。 帮助创建此类信号量的系统调用如下所示:
int semget(key_t key, int nsems, int semflg)
key
用于标识信号量。 如果键值为IPC_PRIVATE
,则创建一组新的信号量。nsems
表示信号量集合中需要的计数器数量semflg
规定应如何创建信号量。 它可以包含两个值:IPC_CREATE:
如果键不存在,它会创建一个新的信号量IPC_EXCL
:如果键存在,则抛出错误并失败
如果成功,调用将返回信号量集标识符(正值)。
这样创建的信号量包含未初始化的值,需要使用semctl()
函数执行初始化。 初始化后,进程可以使用信号量集:
int semop(int semid, struct sembuf *sops, unsigned nsops);
函数Semop()
允许进程启动对信号量集的操作。 此函数通过名为SEM_UNDO
的特殊标志提供 SysV 信号量实现所独有的工具,称为可撤消操作。 当设置此标志时,如果进程在完成相关的共享数据访问操作之前中止,则内核允许信号量恢复到一致状态。 例如,考虑这样一种情况,其中一个进程锁定信号量并开始对共享数据的访问操作;在此期间,如果该进程在完成共享数据访问之前中止,则信号量将处于不一致的状态,使其不可用于其他争用的进程。 但是,如果进程通过使用semop()
设置SEM_UNDO
标志来获得信号量上的锁,则其终止将允许内核将信号量恢复到一致状态(解锁状态),从而使其可供等待中的其他争用进程使用。
每个 SysV 信号量集在内核中由类型为struct sem_array
的描述符表示:
/* One sem_array data structure for each set of semaphores in the system. */
struct sem_array {
struct kern_ipc_perm ____cacheline_aligned_in_smp sem_perm;
time_t sem_ctime; /* last change time */
struct sem *sem_base; /*ptr to first semaphore in array */
struct list_head pending_alter; /* pending operations */
/* that alter the array */
struct list_head pending_const; /* pending complex operations */
/* that do not alter semvals */
struct list_head list_id; /* undo requests on this array */
int sem_nsems; /* no. of semaphores in array */
int complex_count; /* pending complex operations */
bool complex_mode; /* no parallel simple ops */
};
数组中的每个信号量都被枚举为<ipc/sem.c>
中定义的struct sem
的实例;*sem_base
指针指向集合中的第一个信号量对象。 ;每个信号量集包含每个正在等待的进程的挂起队列列表;pending_alter
是类型为struct sem_queue
的挂起队列的头节点。 每个信号量集还包含每个信号量的可撤销操作。 list_id
是struct sem_undo
实例列表的头节点;对于集合中的每个信号量,列表中都有一个实例。 下图总结了信号量集数据结构及其列表:
与 system V 相比,POSIX 信号量语义相当简单。每个信号量都是一个简单的计数器,永远不能小于零。 该实现提供了用于初始化、递增和递减操作的函数接口。 通过在所有线程可访问的内存中分配信号量实例,它们可用于同步线程。 它们还可以通过将信号量放在共享内存中来同步进程。 对 POSIX 信号量的 Linux 实现进行了优化,以便为非争用同步场景提供更好的性能。
POSIX 信号量有两种变体:命名信号量和未命名信号量。 命名信号量由文件名标识,适合在不相关的进程之间使用。 未命名信号量只是sem_t
类型的全局实例;这种形式通常更适合在线程之间使用。 POSIX 信号量接口操作是 POSIX 线程库实现的一部分。
| 功能接口 | 说明 |
| sem_open()
| 打开现有的命名信号量文件或创建新的命名信号量并返回其描述符 |
| sem_init()
| 未命名信号量的初始值设定项例程 |
| sem_post()
| 用于递增信号量的操作 |
| sem_wait()
| 用于递减信号量的操作,当信号量值为零时调用块 |
| sem_timedwait()
| 使用有界等待的超时参数扩展sem_wait()
|
| sem_getvalue()
| 返回信号量计数器的当前值 |
| sem_unlink()
| 删除由文件标识的命名信号量 |
在本章中,我们讨论了内核提供的各种 IPC 机制。 我们探讨了每种机制的各种数据结构之间的布局和关系,还研究了 SysV 和 POSIX IPC 机制。
在下一章中,我们将进一步讨论锁定和内核同步机制。