C 中的多进程编程 —— Advanced Operating Systems
原文件为 PPT,这里是分页翻译的
目录
多进程编程
- Fork 进程
- 进程间同步
- 执行其他程序
进程间通信
- 信号
- 管道和有名管道
- 消息队列
- 共享内存
- 同步
多进程编程
为什么要多进程编程
- 多进程意味着每个任务都有自己的地址空间
- 与多线程相比,任务隔离和独立性更高
- 可靠性:一个进程崩溃不会影响整个程序
- 对于任务对资源有重大要求的多任务应用程序是有用选择
- 需要“较长”处理时间的任务
- 处理大数据结构的任务
实例 1 :fork 一个进程
|
fork()
创建一个新的进程来复制调用进程
$ gcc example1.c o fork_ex1 |
Main process id = 9075 (parent PID = 32146) |
- 主线程 PID = 9075。它的父进程(PID=32146)是 shell,它是从 shell 程序中启动的
- 在
fork()
之后,程序并发执行两个进程 - 在父进程的地址空间中,将
child_pid
变量设置为fork
的返回值(子进程 PID) - 在子进程的地址空间中未设置
child_pid
变量(0) getpid()
返回当前进程标识号
- 子进程复制父进程虚拟地址空间
- 包括变量,互斥量,条件变量,POSIX 对象的状态
- 子进程继承父进程已打开的文件描述符集的副本
- 以及状态标志和当前文件偏移
实例 2a
- 两个进程将一些内容写入标准输出
|
$ gcc forkme_sync1.cpp o forkme |
- 并发导致不可预测的进程执行顺序
- 应用程序可能需要同步两个或多个进程的执行
- 父进程可能需要等待子进程完成
- 父进程派生一个子进程来执行计算,并行进行,然后到达执行点,在此需要使用子进程的输出数据
- 考虑我们的示例,假设这是我们想要的输出:
.............||||||||||||| |
同步
- 父进程可以阻塞自己,直到其中一个子进程发生状态更改为止
- 子进程终止或停止
- 子进程通过信号恢复(请参阅下文)
- 以指针方式传递的整数参数中检索状态
|
waitpid(...)
等待特定子进程的状态更改wait/waitpid(...)
允许系统释放与子进程相关的资源(例如,打开的文件,分配的内存等)
实例 2b :以同步方式 fork 一个进程
- 通过使用
wait(...)
函数可以获得结果
|
僵尸进程
- 如果子进程在父进程没有执行
wait()
的情况下终止,则它将保持“僵尸”状态 - Linux 内核维护有关僵尸进程的部分信息
- (PID,终止状态,资源使用信息,…)
- 父进程稍后可以 wait 以获得有关子进程的信息
- 僵尸进程会占用内核进程表中的一个表项
- 如果表格已填满,将无法创建进程
- 如果父进程终止,则
init
进程将接管父进程的僵尸子进程(如果有)init
自动执行wait
以删除僵尸
生成执行程序进程
- 进程派生基本上是“克隆”父进程的镜像
- 相同的代码和相同的变量
- 在多进程应用程序中,我们可能需要生成一个进程来执行完全不同的任务(程序)
- 加载并运行另一个可执行文件
exec()
系列函数允许我们在另一个程序中启动一个程序exec()
系列函数通过加载新程序来替换当前进程映像
|
- 所有函数均以可执行路径作为第一个参数
"l"
函数接受可变数量的以null
终止的char *
"v"
函数接受可执行路径和以null
终止的char *
数组- 将两个前置参数都提交给可执行文件(必须将
arg0
设置为可执行文件名称)
- 将两个前置参数都提交给可执行文件(必须将
"p"
函数访问 PATH 环境变量以查找可执行文件"e"
函数还接受以null
终止的char *
存储环境变量的数组
例子 3
|
进程间通信
总览
- 每个进程都有自己的地址空间 -> 如何在两个不同的进程间交换信息
- 操作系统在构建通信机制和 API 的基础上提供系统调用
POSIX vs System V
- 两者提供了相同的机制
- POSIX 在 System V 之后诞生,用于标准化
- POSIX 旨在简化和改进 System V
- POSIX 函数是线程安全的
- 下一张幻灯片中的代码基于POSIX IPC
信号
特点
- 一位(bit)长的“消息”
- 没有数据交换
- 信号类型隐式提供信息内容
- 异步事件通知机制
例子
- Elapsed timer
- I/O operation completion
- 程序异常
- 用户定义的事件
同步化
- 发送方和接收方之间的异步交互
- 信号是事件的通知
- 操作系统定义了一组绑定到信号编号的宏
- 进程可以接收信号以异步响应来自软件或意外硬件事件的请求
- 其他进程调用
kill()
之类的函数 - 进程本身会调用类似
abort()
的函数 - 子进程正在退出(
SIGCHLD
) - 用户从键盘中断程序(
SIGINT
) - 程序行为不正确(
SIGILL
,SIGFPE
,SIGSEGV
) - 程序访问不可用的映射内存(
SIGSEV
) - 程序通过
write()
发送数据,没有人接收(SIGPIPE
) - …
- 其他进程调用
最常见的 POSIX 信号
信号处理程序注册
- 操作系统为每个进程管理信号向量表
- 进程可以为每个信号注册一个自定义信号处理程序
- 在 Linux OS 中,大多数情况下默认的处理程序行为是终止进程
sigaction(...)
函数
int sigaction( int signum, const struct sigaction *act, struct sigaction *oldact);- signum: 要处理的信号数
- act: 适用于注册处理函数的新设置
- oldact: 先前的设置
- 如果不希望保存它们,请设置为
NULL
- 如果不希望保存它们,请设置为
sigaction 结构体
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};- sa_handler: 指向处理函数的指针
- sa_sigaction: 具有两个附加参数的替代处理函数
- 提供有关接收信号的更多信息…
- sa_mask: 设置要在处理程序执行期间被阻止的信号
- sa_flags: 允许修改信号处理过程的行为
- 设置为
SA_SIGINFO
以便使用sa_sigaction
作为处理程序
- 设置为
例子 4:用户定义信号处理
|
- 包含
<signal.h>
头文件 - 声明数据类型为
sigaction
的数据结构 - 清除
sigaction
数据结构,然后将sa_handler
字段设置为指向handler()
的函数 - 通过调用
sigaction()
函数为信号SIGUSR1
注册信号处理程序
$> gcc example4.cpp o sig_example |
SIGUSR1 was raised 1 times |
$> kill SIGUSR1 16151 |
发送信号
函数
kill(..)
和sigqueue(..)
int kill(pid_t pid, int sig);- pid: 目标地进程 ID
- sig: 信号编号
int sigqueue(pid_t pid, int sig, const union sigval value);- value: 要追加的数据项
union sigval {
int sival_int; // Integer value
void *sival_ptr; // Pointer to other type value
};
例子 4b:信号发送器程序(SIGUSR1)
SIGUSR1
信号发送到目标进程,并添加整数数据
|
信号信息
- 当指定
SA_SIGINFO
标志时,数据结构被定义为传递给信号处理程序
struct siginfo_t { |
例 4c:访问信号信息
... |
阻塞信号
信号可以在进程级别或线程级别被阻止
- 除了
SIGKILL
和SIGSTOP
(尝试将被忽略) - 当进程/线程 “unmask” 信号时,稍后入队并进行管理
...
int main() {
sigset_t curr, old;
sigemptyset(&set);
sigaddset(&set, SIGINT);
int ret = sigprocmask(SIG_BLOCK, &set, &old); // set new mask
...
ret = sigprocmask(SIG_UNBLOCK, &set, NULL);
// alternative: sigprocmask(SIG_SETMASK, &old, NULL);
return 0;
}- 对于线程,请使用
POSIX
线程库中的函数pthread_sigmask()
- 除了
管道
无名管道
- 基于生产者/消费者模式
- 生产者写,消费者读
- 数据以先进先出(FIFO)的方式写入/读取
- 在 Linux 中,操作系统保证每次只能有一个进程访问管道
生产者(发送者)写入的数据由操作系统存储到缓冲区中,直到消费者(接收者)读取数据为止
POSIX 提供函数调用
pipe(...)
,以在相关进程之间创建单向FIFO 通信通道
int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags);- pipefd: 用两个文件描述符填充的整数数组
- pipefd[0] : 管道读端
- pipefd[1] : 管道写段
- flags: if =0 same of pipe()
O_CLOEXEC
: 在执行exec(...)
调用的情况下关闭文件描述符O_DIRECT
: 在 “packet” 模式下执行I / OO_NONBLOCK
: 避免在管道为空/满的情况下阻止读/写
- 然后,我们可以使用普通函数来访问文件
- pipefd: 用两个文件描述符填充的整数数组
例5:简单的基于无名管道的消息传递
|
- 使用
pipe()
调用创建管道并初始化文件描述符“fds”
的数组 - fork 一个子进程作为消费者
- 关闭管道文件描述符数组的写端
- 打开管道文件描述符数组的读端
- 调用
reader()
函数从管道读取数据
父进程作为生产者
- 关闭管道文件描述符数组的读端
- 打开管道文件描述符数组的写端
- 调用
writer()
函数编写 3 次“Hello,world”
Hello, world.
Hello, world.
Hello, world.
有名管道 (FIFO)
- 和无名管道的行为相同,但用于不相关的进程间通信
- 通过文件系统可访问的基于管道的机制
- 管道显示为特殊的 FIFO 文件
- 管道的两端都必须打开(读取和写入)
- 操作系统在进程之间传递数据而无需执行真正的 I/O
POSIX 提供函数
mkfifo()
来创建可通过文件系统作为特殊文件访问的命名管道
int mkfifo(const char *pathname, mode_t mode);- pathname: 创建的特殊文件的路径
- mode: R/W/X permissions (.e.g.
S_IRWXU
,S_IRUSR
,S_IWUSR
,…)- 可从手册页中获取更多信息 :
$ man 2 open
- 可从手册页中获取更多信息 :
创建
FIFO
后,我们就可以作为普通文件打开并访问它了- 在进行 I/O 操作之前,必须将两端打开
- 打开 FIFO 以读取普通块,直到其他进程打开相同的 FIFO 进行写为止,反之亦然
- 在进行 I/O 操作之前,必须将两端打开
示例 6a:通过命名管道的外部接口
fifo_writer.c
int main () {
struct datatype data;
char * myfifo = "/tmp/myfifo";
if (mkfifo(myfifo, S_IRUSR | S_IWUSR) != 0)
perror("Cannot create fifo. Already existing?");
int fd = open(myfifo, O_RDWR);
if (fd == 0) {
perror("Cannot open fifo");
unlink(myfifo);
exit(1);
}
int nb = write(fd, &data, sizeof(struct datatype));
if (nb == 0)
fprintf(stderr, “Write error\n”);
close(fd);
unlink(myfifo);
return 0;
}fifo_reader.c
int main () {
struct datatype data;
char * myfifo = "/tmp/myfifo";
int fd = open(myfifo, O_RDONLY);
if (fd == 0) {
perror("Cannot open fifo");
unlink(myfifo);exit(1);
}
read(fd, &data, sizeof(struct datatype));
...
close(fd);
unlink(myfifo);
return 0;
}The writer
- 创建有名管道(
mkfifo
) - 在读取/写入模式下,将有名管道作为普通文件打开
- 写入与数据结构大小一样多的字节
- reader 必须处于执行状态(否则数据将发送给任何人)
- 关闭文件 (
close
),然后释放有名管道(unlink
)
- 创建有名管道(
The reader
- 在只读模式下将有名管道作为普通文件打开(open)
read()
函数阻塞等待来自 writer 进程的字节- 关闭文件 (
close
),然后释放有名管道(unlink
)
message-reader.c
- message-writer: 用户从 shell 发送字符串
int main () {
char data = ' ';
char * myfifo = "/tmp/myfifo";
int fd = open(myfifo, O_RDWR);
if (fd == 0) {
perror("Cannot open fifo");
unlink(myfifo);
exit(1);
}
while (data != '#') {
while (read(fd, &data, 1) && (data != '#'))
fprintf(stderr, "%c", data);
}
close(fd);
unlink(myfifo);
return 0;
}message reader
- 有名管道路径作为常规文件 (
open
)打开以进行读写- 在读取管道中的数据时需要具有写许可权
- 执行阻塞
read()
调用以从管道中获取数据 - 文本字符串的长度是未知的
'#'
用作特殊的 END 字符
- 终止时关闭(
close
)并释放管道(unlink
)
- 有名管道路径作为常规文件 (
无名管道 vs 有名管道
Pros
- 低开销
- 简单
- 解决内核空间中的相互访问
Cons
- 没有广播
- 单向
- 无消息界限,数据作为流进行管理
- 可扩展性差
消息队列
POSIX 消息队列
- 适合多个 reader 和多个 writer
- 优先级驱动的数据交换
- 消息队列和消息大小由程序员管理
- 消息队列的状态可以观察到
- 链接到 POSIX 实时扩展库以进行构建(
gcc ... -lrt
)
消息队列生命周期
|
name
: POSIX 对象名称oflag
: 打开标志(O_RDONLY
,O_WRONLY
,O_RDWR
,O_CREAT
,O_EXCL
, …)mode
: R/W/X 权限 (.e.g.S_IRWXU
,S_IRUSR
,S_IWUSR
,…)attr
: 属性集(e.g., message size, queue length, etc…)- 该函数返回消息队列描述符
int mq_close(mqd_t mqdes); |
mq_close
: 关闭由mq_open()
返回的描述符所引用的队列mq_link
: 删除消息队列并在被所有进程关闭时销毁它
消息队列属性
属性数据结构
struct mq_attr {
long mq_flags; /* Flags: 0 or O_NONBLOCK */
long mq_maxmsg; /* Max nr of messages on queue */
long mq_msgsize; /* Max message size (bytes) */
long mq_curmsgs; /* Nr of messages currently in queue */
};操作函数
int mq_getattr(mqd_t mqdes, struct mq_attr *attr);
int mq_setattr(mqd_t mqdes, struct mq_attr *newattr, struct mq_attr *oldattr);mqdes
: 消息队列描述符newattr
: 新的属性集oldattr
: 先前的属性集
消息队列输入/输出
发送消息
int mq_send(mqd_t mqdes, char * msg_ptr, size_t msg_len, unsigned int msg_prio);
mqdes
: 消息队列描述符msg_ptr
: 指向要发送的消息的指针msg_len
: 消息长度msg_prio
: 非负优先级值(in POSIX: 0..31)
消息按优先级从高到低的顺序排队
- 优先级相同的消息 —— 新消息放置在旧消息之后
队列满 —— 函数调用块
- 如果在属性中将
O_NONBLOCK
指定为标志,则返回错误
- 如果在属性中将
接收消息
int mq_receive(mqd_t mqdes, char * msg_ptr, size_t msg_len, unsigned int * msg_prio);
mqdes
: 消息队列描述符msg_ptr
: 消息缓冲区msg_len
: 消息缓冲区长度msg_prio
: 与接收到的消息相关联的非负优先级值(in POSIX: 0..31)- 返回接收到的消息中的字节数,如果出错则返回
-1
函数调用将被阻塞,直到队列中有消息可用为止
- 如果在属性中将
O_NONBLOCK
指定为标志,则返回错误
- 如果在属性中将
Pros
- API 的简单性
- 消息打包
- 数据可以具有不同的优先级
- 解决内核空间中的相互访问
- 可以利用通知机制(未见)
Cons
- 与管道相比性能低下
- 发送消息实际上涉及写入文件
- 单向
共享内存
内存映射
- Linux / UNIX OS 中的共享内存基于内存映射
- 一个内存段可以映射到多个进程的地址空间中
- POSIX 实现:链接到实时扩展(
gcc ... -lrt
)
通过名称引用打开/创建共享内存对象
- 提供名称的特殊文件出现在
“/dev/shm/<name>”
中- 这是 POSIX 对象,可以由不相关的进程使用的句柄
int shm_open(const char *name, int oflag, mode_t mode);name
: POSIX 对象名称oflag
: 打开标志 (O_RDONLY
,O_RDWR
,O_CREAT
,O_EXCL
,O_TRUNC
)mode
: R/W/X 权限 (.e.g.S_IRWXU
,S_IRUSR
,S_IWUSR
,…)- 该函数返回一个文件描述符
- 提供名称的特殊文件出现在
分配内存对象后,在实际分配内存区域之前,我们必须指定特殊文件的大小
int truncate(const char *path, off_t length);
int ftruncate(int fd, off_t length);- path/fd : 文件路径或文件描述符
length : 特殊文件的大小
由于
shm_open(...)
返回文件描述符,因此我们将使用ftruncate(...)
调用进程的(虚拟)地址空间中的新映射
void * mmap(void *addr, size_t length, int prot, int flags, int fd,off_t offset);addr
: 起始地址。如果为NULL
,Linux 内核将选择length
: 映射的内容使用length
字节初始化,在 “文件映射” 的情况下,它从引用文件fd
中的offset
开始prot
: 映射的内存保护,即进程可以做什么PROT_EXEC
,PROT_READ
,PROT_WRITE
,PROT_NONE
flags
: 来自其他进程的更新可见性MAP_SHARED
: 映射的内存是共享的。更新对其他进程可见,并通过基础文件进行MAP_PRIVATE
: 私有写时复制。更新不提供给其他人,也不通过基础文件进行
- 它返回一个指向映射区域的指针
取消映射区域
int munmap(void *addr, size_t length);
- 取消给定范围内映射的所有内存页
- addr 必须是内存页面大小的倍数(在 Linux 中通常为 4K )
- 执行后,对该区域的后续访问将生成
SIGSEV
- 取消给定范围内映射的所有内存页
释放共享内存对象
int shm_unlink(const char *name);
- 删除 POSIX 对象
- 一旦所有进程都取消了对象的映射,便取消分配并销毁关联内存区域的内容
示例 7:简单的共享内存映射
posix-shm-server.c
int main (int argc, char *argv[]) {
const char * shm_name = "/AOS";
const int SIZE = 4096;
const char * message[] = {"This ","is ","about ","shared ","memory"};
int i, shm_fd;
void * ptr;
shm_fd = shm_open(shm_name, O_CREAT | O_RDWR, 0666);
if (shm_fd == -1) {
printf("Shared memory segment failed\n");
exit(-1);
}
ftruncate(shm_fd, sizeof(message));
ptr = mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
if (ptr == MAP_FAILED) {
printf("Map failed\n");
return -1;
}
/* Write into the memory segment */
for (i = 0; i < strlen(*message); ++i) {
sprintf(ptr, "%s", message[i]);
ptr += strlen(message[i]);
}
mummap(ptr, SIZE);
return 0;
}- 服务器创建由
“/AOS”
引用的共享内存 - 服务器将一些数据(字符串)写入内存段
- 每次写入 char 字符串后,指针 ptr 都会增加
- 服务器创建由
posix-shm-client.c
int main (int argc, char *argv[]) {
const char * shm_name = "/AOS";
const int SIZE = 4096;
int i, shm_fd;
void * ptr;
shm_fd = shm_open(shm_name, O_RDONLY, 0666);
if (shm_fd == -1) {
printf("Shared memory segment failed\n");
exit(-1);
}
ptr = mmap(0, SIZE, PROT_READ, MAP_SHARED, shm_fd, 0);
if (ptr == MAP_FAILED) {
printf("Map failed\n");
return -1;
}
printf("%s", (char *) ptr);
if (shm_unlink(shm_name) == -1) {
printf("Error removing %s\n", shm_name);
exit(-1);
}
return 0;
}- 客户端以只读模式打开内存段 “AOS”
- 客户端以只读模式映射内存段
- 客户端将内存段内容写入控制台
内存映射文件
- 内存映射使我们能够在逻辑上将部分或全部命名二进制文件插入进程地址空间
示例 8:简单的 I/O 映射
- 通过命令行(
argv[1]
)传递的文件被打开,然后使用mmap()
系统调用映射内存 mmap()
需要地址,区域大小(文件长度),权限,范围标志,文件描述符和偏移量
|
Pros
- 可以减少内存使用
- 可以映射和共享大的数据结构,以将相同的输入集提供给多个进程
- I/O映射能非常有效
- 内存访问,而不是 I/O 读/写
- 仅当内容已修改时,才由 OS 回写到内存页
- 使用指针算法而不是
“lseek”
执行文件查找
Cons
- 具有内存页大小的 Linux 映射内存
- Linux 内存页大小通常为 4KB
- 使用内存映射来映射大文件或共享大数据结构
- 可能导致内存碎片
- 特别是在 32 位架构上
- 多个小的映射可能操作系统开销大
同步
信号量
- 多任务应用程序中的并发性可能会引入竞态条件,我们需要保护共享资源
- 信号量通常是由 OS 内核管理的系统对象
- 信号量充当计数器,我们可以通过执行以下两个操作来操纵它们:
increment (wait)
和decrement (post)
- 如果计数器值 > 0,则
wait
计数器递减并允许任务进入临界区 - 如果计数器值 = 0,则
wait
将阻塞任务在等待列表中 post
增加计数器值- 如果先前的值为 0,则从等待列表中唤醒任务
- 二进制信号量(
value
=0
or1
)实现相同的互斥行为 - 不是二进制信号量(值
0..n
)适用于保护对资源池的访问
命名信号量的打开/创建和释放
函数
sem_open(...)
sem_t * sem_open(const char *name, int oflags);
sem_t * sem_open(const char *name, int oflags, mode_t mode, unsigned int value);name
: POSIX(信号量)对象名称(under“/dev/shm/sem.<name>”
)oflag
: 打开标志(O_CREAT
,O_EXCL
)mode
:R/W/X
权限 (.e.g.S_IRWXU
,S_IRUSR
,S_IWUSR
,…)value
: 初始值- 它将返回新信号量的地址或出现错误时返回
SEM_FAILED
释放指定的信号量
int sem_unlink(const char *name);
未命名的信号量初始化和销毁
函数
sem_init(...)
int sem_init(sem_t *sem, int pshared, unsigned int value);- 此函数可用于多进程或多线程应用程序
sem
: 初始化信号量数据结构pshared
:如果为0
,则线程之间共享,如果不是0
,则进程之间共享value
: 初始值- 该函数返回
0
表示成功,返回-1
表示错误
完成后可以销毁已初始化的未命名信号量
int sem_destroy(sem_t *sem);
加锁和解锁函数
减量/加锁函数
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *timeout);sem
: 递减(加锁)的信号量的数据结构timeout
: 设置value == 0
时调用应被阻塞的时间限制try_wait(...)
在value == 0
,则返回错误而不是阻塞
增量/解锁函数
int sem_post(sem_t *sem);
所有函数成功时返回
0
,错误时返回-1
示例 9:对共享内存使用信号量
posix-shm-sem-writer.c
|
写进程
- 映射一个内存区域
- 创建一个命名信号量并将其初始化为
1
(sem_open
) - 减少信号量计数器以获得对共享内存区域的独占访问 (
sem_wait
) - 写入内存区域 (
memcpy
) - 增加信号量计数器并释放对内存区域的访问 (
sem_post
) - 释放共享内存区域 (
shm_unlink
) - 关闭并释放信号量对象 (
sem_unlink
)
posix-shm-sem-reader.c
|
- 读进程
- 映射一个内存区域 (read-only access)
- 打开已初始化的信号量对象 (
sem_open
) - 减少信号量计数器以获得对共享内存区域的独占访问 (
sem_wait
) - 将内存区域中的数据复制到本地变量 (
memcpy
) - 增加信号量计数器并释放对内存区域的访问 (
sem_post
) - 处理数据
- 释放共享内存区域 (
shm_unlink
) - 关闭并释放信号量对象 (
sem_unlink
)
选择 IPC 机制
因素
- “IPC 性能是一个复杂且多元的问题”
- 机器架构
- 数据大小和数据位置
- 虚拟化的存在
- 原语实现
- 当前系统工作量
Benchmarks
- 剑桥大学开发了 ipc-bench 基准,并提供了结果的公共数据库
资源
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Busyboxs!