深入理解进程管理与进程间通信
1. 进程基础
进程是线程运行的环境,它包含内存映射、文件描述符、用户和组 ID 等。系统的第一个进程是
init
进程,由内核在启动时创建,其 PID 为 1。之后的进程通过
fork
操作创建。
1.1 创建新进程
使用 POSIX 函数
fork(2)
来创建新进程。每次成功调用
fork
会有两个返回值:一个在调用该函数的父进程中,另一个在新创建的子进程中。调用后,子进程是父进程的精确副本,拥有相同的栈、堆、文件描述符,并从
fork
之后的代码行开始执行。区分父子进程的方法是查看
fork
的返回值,子进程返回 0,父进程返回新创建子进程的 PID,若返回值为负则表示
fork
调用失败。
实际上,内核不会立即物理复制父进程的内存,而是采用写时复制(CoW)技术。内存初始时是共享的,但标记了 CoW 标志。当父进程或子进程修改内存时,内核先复制一份再进行写入,这样既保证了
fork
函数的高效性,又实现了进程地址空间的逻辑分离。
以下是一个创建和终止进程的示例代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
int pid;
int status;
pid = fork();
if (pid == 0) {
printf("I am the child, PID %d\n", getpid());
sleep(10);
exit(42);
} else if (pid > 0) {
printf("I am the parent, PID %d\n", getpid());
wait(&status);
printf("Child terminated, status %d\n", WEXITSTATUS(status));
} else
perror("fork:");
return 0;
}
1.2 终止进程
进程可以通过调用
exit(3)
函数自愿终止,也可能因接收到未处理的信号而被动终止,例如
SIGKILL
信号会直接杀死进程。进程终止时,会停止所有线程、关闭所有文件描述符并释放所有内存。系统会向父进程发送
SIGCHLD
信号,告知子进程已终止。
进程有返回值,正常终止时为
exit
的参数,被信号杀死时为信号编号。父进程可以使用
wait(2)
或
waitpid(2)
函数收集子进程的返回值。在子进程终止到父进程收集返回值之间会有延迟,此时子进程处于僵尸状态,在
ps
和
top
命令中显示为状态
Z
。若父进程未能及时收集返回值,僵尸进程会占用资源,最终可能导致无法创建新进程。
1.3 运行不同程序
fork
函数只是复制一个正在运行的程序,若要运行不同的程序,需要使用
exec
系列函数,例如:
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,
..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
..., char *const envp[]);
这些函数会加载并运行指定路径的程序文件。若函数调用成功,内核会丢弃当前进程的所有资源,为新程序分配内存。调用
exec*
的线程返回时,会返回到新程序的
main()
函数。
以下是一个命令启动器的示例代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
char command_str[128];
int pid;
int child_status;
int wait_for = 1;
while (1) {
printf("sh> ");
scanf("%s", command_str);
pid = fork();
if (pid == 0) {
/* child */
printf("cmd '%s'\n", command_str);
execl(command_str, command_str, (char *)NULL);
/* We should not return from execl, so only get
to this line if it failed */
perror("exec");
exit(1);
}
if (wait_for) {
waitpid(pid, &child_status, 0);
printf("Done, status %d\n", child_status);
}
}
return 0;
}
使用
fork
和
exec
组合的方式虽然看似奇怪,但有明显优势,例如便于在 shell 中实现重定向和管道。以获取目录列表为例,正常情况下的操作流程如下:
1. 在 shell 提示符下输入
ls
。
2. shell 复制自身。
3. 子进程执行
/bin/ls
。
4.
ls
程序将目录列表输出到标准输出(文件描述符 1),显示在终端上。
5.
ls
程序终止,shell 重新获得控制权。
若要将目录列表重定向到文件,操作流程如下:
1. 输入
ls > listing.txt
。
2. shell 复制自身。
3. 子进程打开并截断
listing.txt
文件,使用
dup2(2)
将文件描述符复制到标准输出(文件描述符 1)。
4. 子进程执行
/bin/ls
。
5.
ls
程序像之前一样输出列表,但这次写入到
listing.txt
文件。
6.
ls
程序终止,shell 重新获得控制权。
2. 守护进程
守护进程是在后台运行的进程,由
init
进程管理,且不与控制终端相连。创建守护进程的步骤如下:
1. 调用
fork
创建新进程,父进程退出,使子进程成为孤儿进程并被
init
进程接管。
2. 子进程调用
setsid(2)
创建新会话和进程组,使其与控制终端隔离。
3. 将工作目录更改为根目录。
4. 关闭所有文件描述符,并将标准输入、标准输出和标准错误重定向到
/dev/null
,以隐藏所有输入输出。
幸运的是,这些步骤可以通过调用
daemon(3)
函数一次性完成。
3. 进程间通信
每个进程都有独立的内存空间,进程间通信有两种方式:一是将数据从一个地址空间复制到另一个地址空间,通常结合队列或缓冲区,例如使用套接字、管道和消息队列;二是创建一个可被多个进程访问的共享内存区域,并使用信号量或互斥锁等机制同步对该内存的访问。
3.1 基于消息的进程间通信
基于消息的进程间通信有多种选择,不同方式的区别在于消息流的方向(单向或双向)、数据是无边界的字节流还是有边界的离散消息(若为离散消息,最大消息大小很重要)以及消息是否带有优先级。
以下是 FIFO、Unix 套接字和 POSIX 消息队列的特性对比表格:
| 属性 | FIFO | Unix 套接字:流 | Unix 套接字:数据报 | POSIX 消息队列 |
| ---- | ---- | ---- | ---- | ---- |
| 消息边界 | 字节流 | 字节流 | 离散 | 离散 |
| 单向/双向 | 单向 | 双向 | 单向 | 单向 |
| 最大消息大小 | 无限制 | 无限制 | 100 KiB 到 250 KiB | 默认 8 KiB,绝对最大 1 MiB |
| 优先级级别 | 无 | 无 | 无 | 0 到 32767 |
-
Unix 套接字
:使用
AF_UNIX地址族创建,绑定到路径名。套接字的访问权限由套接字文件决定。套接字类型可以是SOCK_STREAM(提供双向字节流)或SOCK_DGRAM(提供有边界的离散消息)。Unix 套接字数据报可靠,最大数据报大小与系统有关,通常为 100 KiB 或更大,但不支持消息优先级。 -
FIFO 和命名管道
:FIFO 和命名管道本质相同,是匿名管道的扩展,用于 shell 中父子进程间的通信。通过
mkfifo(1)命令创建特殊文件,文件访问权限决定读写权限。数据是纯字节流,但对于小于管道缓冲区大小的消息保证原子性。现代内核中 FIFO 缓冲区默认大小为 64 KiB,可使用fcntl(2)结合F_SETPIPE_SZ增大到/proc/sys/fs/pipe-max-size(通常为 1 MiB),不支持优先级。 -
POSIX 消息队列
:消息队列通过以
/开头且只包含一个/的名称来标识,实际存储在mqueue类型的伪文件系统中。使用mq_open(3)函数创建或获取队列的文件描述符。每个消息有优先级,按优先级和消息年龄顺序读取。消息最大长度为/proc/sys/kernel/msgmax字节,默认 8 KiB,可在 128 字节到 1 MiB 之间设置。由于队列引用是文件描述符,可使用select(2)、poll(2)等函数等待队列活动。
总体而言,Unix 套接字使用最广泛,因为它能满足大部分需求,且具有良好的可移植性;FIFO 使用较少,主要是因为缺乏类似数据报的功能,但 API 简单;消息队列使用最少,内核代码路径未像套接字和 FIFO 那样优化。此外,还有更高级的抽象,如 D-Bus,它在主流 Linux 和嵌入式设备中逐渐得到应用,底层使用 Unix 套接字和共享内存。
以下是基于消息的进程间通信方式的选择流程图:
graph TD;
A[进程间通信需求] --> B{消息优先级需求};
B -- 是 --> C[POSIX 消息队列];
B -- 否 --> D{数据是否需要离散消息边界};
D -- 是 --> E{是否需要双向通信};
E -- 是 --> F[Unix 套接字:流];
E -- 否 --> G[Unix 套接字:数据报];
D -- 否 --> H{是否需要简单 API};
H -- 是 --> I[FIFO];
H -- 否 --> F;
3.2 基于共享内存的进程间通信
共享内存可以避免在不同进程的地址空间之间复制数据,但会引入对共享内存访问的同步问题。进程间的同步通常使用信号量来实现。
3.2.1 POSIX 共享内存
要在进程间共享内存,首先需要创建一个新的内存区域,然后将其映射到每个需要访问它的进程的地址空间。POSIX 共享内存段的命名规则与消息队列类似,名称以
/
开头且只包含一个
/
。使用
shm_open(3)
函数获取共享内存段的文件描述符,如果该内存段不存在且设置了
O_CREAT
标志,则会创建一个新的内存段,初始大小为 0,可使用
ftruncate(2)
函数将其扩展到所需大小。
以下是一个使用共享内存段在进程间通信的示例代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h> /* For mode constants */
#include <fcntl.h>
#include <sys/types.h>
#include <errno.h>
#include <semaphore.h>
#define SHM_SEGMENT_SIZE 65536
#define SHM_SEGMENT_NAME "/demo-shm"
#define SEMA_NAME "/demo-sem"
static sem_t *demo_sem;
/*
* If the shared memory segment does not exist already, create it
* Returns a pointer to the segment or NULL if there is an error
*/
static void *get_shared_memory(void)
{
int shm_fd;
struct shared_data *shm_p;
/* Attempt to create the shared memory segment */
shm_fd = shm_open(SHM_SEGMENT_NAME, O_CREAT | O_EXCL | O_RDWR,
0666);
if (shm_fd > 0) {
/* succeeded: expand it to the desired size (Note: dont't do
"this every time because ftruncate fills it with zeros) */
printf ("Creating shared memory and setting size=%d\n",
SHM_SEGMENT_SIZE);
if (ftruncate(shm_fd, SHM_SEGMENT_SIZE) < 0) {
perror("ftruncate");
exit(1);
}
/* Create a semaphore as well */
demo_sem = sem_open(SEMA_NAME, O_RDWR | O_CREAT, 0666, 1);
if (demo_sem == SEM_FAILED)
perror("sem_open failed\n");
}
else if (shm_fd == -1 && errno == EEXIST) {
/* Already exists: open again without O_CREAT */
shm_fd = shm_open(SHM_SEGMENT_NAME, O_RDWR, 0);
demo_sem = sem_open(SEMA_NAME, O_RDWR);
if (demo_sem == SEM_FAILED)
perror("sem_open failed\n");
}
if (shm_fd == -1) {
perror("shm_open " SHM_SEGMENT_NAME);
exit(1);
}
/* Map the shared memory */
shm_p = mmap(NULL, SHM_SEGMENT_SIZE, PROT_READ | PROT_WRITE,
MAP_SHARED, shm_fd, 0);
if (shm_p == NULL) {
perror("mmap");
exit(1);
}
return shm_p;
}
int main(int argc, char *argv[])
{
char *shm_p;
printf("%s PID=%d\n", argv[0], getpid());
shm_p = get_shared_memory();
while (1) {
printf("Press enter to see the current contents of shm\n");
getchar();
sem_wait(demo_sem);
printf("%s\n", shm_p);
/* Write our signature to the shared memory */
sprintf(shm_p, "Hello from process %d\n", getpid());
sem_post(demo_sem);
}
return 0;
}
该程序使用共享内存段在不同进程间传递消息,消息内容为
Hello from process
加上进程的 PID。
get_shared_memory
函数负责创建共享内存段(如果不存在)或获取其文件描述符,并返回指向该内存段的指针。在
main
函数中,使用信号量来同步对共享内存的访问,避免一个进程覆盖另一个进程的消息。
要运行这个程序,需要在两个不同的终端会话中分别启动程序实例。第一次运行时,程序会创建共享内存段,初始消息区域为空,循环一次后会写入当前进程的 PID。后续运行的实例不会创建共享内存段,而是显示已有消息,并在用户按下回车键后写入自己的 PID,从而实现进程间的通信。
编译该程序时,由于 POSIX IPC 函数属于 POSIX 实时扩展,需要链接
librt
库,同时 POSIX 信号量在 POSIX 线程库中实现,还需要链接
pthread
库,编译命令如下:
$ arm-cortex_a8-linux-gnueabihf-gcc shared-mem-demo.c -lrt -pthread \
-o arm-cortex_a8-linux-gnueabihf-gcc
总结
本文详细介绍了进程管理和进程间通信的相关知识,包括进程的创建、终止、运行不同程序,守护进程的创建,以及基于消息和共享内存的进程间通信方式。以下是对这些内容的总结:
-
进程管理
:
- 使用
fork(2)
函数创建新进程,通过返回值区分父进程和子进程。
- 进程可以通过
exit(3)
自愿终止或因信号被动终止,父进程使用
wait(2)
或
waitpid(2)
收集子进程返回值。
- 使用
exec
系列函数运行不同程序,
fork
和
exec
组合便于实现 shell 中的重定向和管道。
-
守护进程
:通过一系列步骤或调用
daemon(3)
函数创建守护进程,使其在后台运行且与控制终端隔离。
-
进程间通信
:
-
基于消息
:有 FIFO、Unix 套接字和 POSIX 消息队列等方式,各有特点,Unix 套接字使用最广泛。
-
基于共享内存
:使用 POSIX 共享内存和信号量实现进程间的高效通信。
在实际应用中,需要根据具体需求选择合适的进程管理和进程间通信方式,以提高系统的性能和可维护性。例如,对于需要频繁交换大量数据的场景,共享内存可能是更好的选择;而对于简单的消息传递,基于消息的通信方式可能更合适。
通过本文的介绍,希望读者能够对进程管理和进程间通信有更深入的理解,并在实际开发中灵活运用这些知识。
以下是不同进程间通信方式的优缺点对比表格:
| 通信方式 | 优点 | 缺点 |
| ---- | ---- | ---- |
| Unix 套接字 | 功能全面,可移植性好 | 缺乏消息优先级 |
| FIFO | API 简单 | 缺乏类似数据报功能 |
| POSIX 消息队列 | 支持消息优先级 | 内核代码路径未优化 |
| POSIX 共享内存 | 避免数据复制,高效 | 需要同步机制,实现复杂 |
以下是选择进程间通信方式的决策流程图:
graph TD;
A[选择通信方式] --> B{数据量大小};
B -- 小 --> C{是否需要优先级};
C -- 是 --> D[POSIX 消息队列];
C -- 否 --> E{是否需要简单实现};
E -- 是 --> F[FIFO];
E -- 否 --> G[Unix 套接字];
B -- 大 --> H{是否能接受同步开销};
H -- 是 --> I[POSIX 共享内存];
H -- 否 --> G;
通过这个流程图,开发者可以根据数据量大小、是否需要优先级和是否能接受同步开销等因素,快速选择合适的进程间通信方式。
超级会员免费看
1468

被折叠的 条评论
为什么被折叠?



