六大进程间通信 IPC

ZaynPei Lv6

进程(Process)是资源分配的基本单位。为了安全和稳定,操作系统会为每个进程分配独立的虚拟地址空间。IPC 是操作系统提供的一套机制,用于跨越这些进程的独立地址空间,让它们能够交换数据和同步状态。

六大主要 IPC 机制:

共享内存

共享内存(Shared Memory)是一种高效的 IPC 机制,允许多个进程直接访问同一块内存区域。操作系统在物理内存中划出一块区域,然后将这块相同的物理内存区域,同时映射(Map)到多个进程各自的虚拟地址空间中。这块内存区域一旦被映射,进程就可以像访问自己的“私有内存”(如堆栈)一样去读写它,而不需要内核(Kernel)的介入

这是所有IPC方法中速度最快的一种,因为它几乎达到了“零拷贝”的效率。

使用共享内存通常涉及以下几个标准步骤(以POSIX标准为例):

  1. 创建/打开共享内存段 (Create / Open): 一个进程(通常是“服务端”)首先向操作系统请求创建一块共享内存。

    • 示例 (POSIX C/C++): 使用 shm_open() 函数创建一个共享内存“对象”,它看起来像一个文件,但实际存在于内存中。
  2. 设置大小 (Set Size): 创建进程需要指定这块共享内存的大小

    • 示例 (POSIX C/C++): 使用 ftruncate() 函数设置 shm_open() 返回的文件描述符的大小。
    • 它通过 shm_open() 创建的“对象”看起来像文件(有文件描述符, 通过fd操作),但实际存储在内存中,而不是磁盘上。
  3. 映射 (Map / Attach): 需要通信的所有进程(包括创建者自己)都必须将这个共享内存段“附加”或“映射”到自己的虚拟地址空间中

    • 示例 (POSIX C/C++): 使用 mmap() 函数将共享内存对象映射到进程的地址空间,返回一个指向该内存的指针。
  4. 读/写数据 (Read / Write): 一旦映射完成,进程就得到了一个指向共享内存的本地指针(例如 void* ptr)。进程可以通过这个指针,像操作普通 C/C++ 指针一样,直接读写数据。

    • 例如: strcpy(ptr, “Hello from Process A”);
    • 另一个进程可以通过它自己的指针(ptr_B)立即读到这个数据:printf(“%s”, (char*)ptr_B);
  5. 解除映射 (Unmap / Detach): 当进程不再需要这块共享内存时,它应该解除映射。

    • 示例 (POSIX C/C++): 使用 munmap() 函数解除映射。
    • 解除映射之后, 进程将无法再通过该指针访问共享内存, 这个指针变为悬空指针. 但这只针对当前进程, 其他进程仍然可以用他们的指针访问该共享内存.
  6. 删除共享内存段 (Delete): 当所有进程都使用完毕后,必须由一个进程(通常是创建者)显式地通知操作系统删除这个共享内存段,否则它会一直残留在系统中,造成资源泄漏。

    • 示例 (POSIX C/C++): 使用 shm_unlink() 函数删除共享内存段。

优点: - 速度极快: 这是共享内存最大的优点。数据交换完全不需要经过内核。一旦映射建立,数据读写就只是CPU的内存访问指令,不涉及系统调用(System Call)的开销,也避免了数据在内核缓冲区和用户缓冲区之间的来回复制。 - 数据量大: 适合传输大量、结构化的数据(例如,视频帧、大型数据集)。

缺点与挑战: - 必须手动同步: 这是共享内存最大的缺点,也是其复杂性的根源。操作系统只负责提供“共享空间”,但不负责管理“访问秩序”。 - 竞态条件 (Race Condition): 如果进程A正在向共享内存写入数据(比如一个复杂的结构体,需要写多次),写到一半时;进程B突然开始读取,那么B就会读到“一半新、一半旧”的垃圾数据。 - 解决方案: 程序员必须自己使用其他的IPC机制(如信号量 (Semaphores) 或 互斥锁 (Mutexes))来确保访问的互斥性。即在写入前“加锁”,写入后“解锁”;读取前“加锁”,读取后“解锁”。

总结: 共享内存提供了“最快的路”,但也要求“驾驶员”自己管理交通(同步)。它通常不单独使用,而是与信号量等同步工具配合使用。

下面是一个写者-读者模型的共享内存示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
/* writer.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h> // For O_* constants
#include <sys/stat.h> // For mode constants
#include <sys/mman.h> // For mmap()
#include <unistd.h> // For ftruncate(), close(), sleep()
#include <semaphore.h> // For semaphores

#define SHM_NAME "/my_shm" // 共享内存的名称
#define SEM_WRITER "/sem_writer" // 写者信号量名称
#define SEM_READER "/sem_reader" // 读者信号量名称
#define BUFFER_SIZE 1024 // 共享内存大小

int main() {
int shm_fd; // 共享内存文件描述符, 共享内存是通过文件描述符操作的
void *shm_ptr; // 指向映射的共享内存区域的指针
sem_t *sem_writer; // 写者信号量
sem_t *sem_reader; // 读者信号量

printf("Writer: 启动...\n");

/* 步骤 1: 创建共享内存对象 */
// O_CREAT: 如果不存在则创建
// O_RDWR: 以读写模式打开
// 0666: 权限 (允许所有用户读写)
// 函数签名: int shm_open(const char *name, int oflag, mode_t mode);
shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
if (shm_fd == -1) {
perror("shm_open 失败");
exit(1);
}

/* 步骤 2: 设置共享内存大小 */
// 函数签名: int ftruncate(int fd, off_t length);
ftruncate(shm_fd, BUFFER_SIZE);

/* 步骤 3: 映射共享内存到进程地址空间 */
// PROT_WRITE: 页面可以被写入
// MAP_SHARED: 更改会同步到共享对象
// 函数签名: void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
shm_ptr = mmap(0, BUFFER_SIZE, PROT_WRITE, MAP_SHARED, shm_fd, 0);
if (shm_ptr == MAP_FAILED) {
perror("mmap 失败");
exit(1);
}

/* 步骤 4: 创建/打开信号量 */
// O_CREAT: 如果不存在则创建
// 0666: 权限
// 1: 初始值 (写者信号量初始为1,表示可写)
sem_writer = sem_open(SEM_WRITER, O_CREAT, 0666, 1);
if (sem_writer == SEM_FAILED) {
perror("sem_open(writer) 失败");
exit(1);
}

// 0: 初始值 (读者信号量初始为0,表示不可读)
sem_reader = sem_open(SEM_READER, O_CREAT, 0666, 0);
if (sem_reader == SEM_FAILED) {
perror("sem_open(reader) 失败");
exit(1);
}

/* 步骤 5: 开始写入 (同步) */

// 5.1: P操作 (wait) - 等待写者信号量 (等待变为1)
// 这是为了确保 Reader 已经读完了上一轮数据,并将写者信号量 V操作(post) 成了 1。
printf("Writer: 正在等待写入权限 (sem_wait on writer)...\n");
sem_wait(sem_writer);

// 5.2: 获得权限,执行写入
printf("Writer: 获得权限,正在写入数据...\n");
const char *message = "你好,这是来自 Writer 进程的消息!";
sprintf((char *)shm_ptr, "%s", message);
sleep(1); // 模拟写入耗时

// 5.3: V操作 (post) - 释放读者信号量 (将其+1)
// 通知 Reader:数据已经写好了,你可以读了。
printf("Writer: 写入完毕,通知 Reader (sem_post on reader)...\n");
sem_post(sem_reader);

/* 步骤 6: 清理资源 */
sem_close(sem_writer);
sem_close(sem_reader);
munmap(shm_ptr, BUFFER_SIZE);
close(shm_fd);

// 注意:共享内存和信号量的 "删除" 操作应该在 Reader 也完成后进行
// 这里为了演示方便,Writer 不立即删除它们

printf("Writer: 退出。\n");
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
/* reader.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/mman.h>
#include <unistd.h>
#include <semaphore.h>

#define SHM_NAME "/my_shm"
#define SEM_WRITER "/sem_writer"
#define SEM_READER "/sem_reader"
#define BUFFER_SIZE 1024

int main() {
int shm_fd;
void *shm_ptr;
sem_t *sem_writer;
sem_t *sem_reader;
char read_buffer[BUFFER_SIZE];

printf("Reader: 启动...\n");

/* 步骤 1: 打开已存在的共享内存对象 */
// 注意:这里不使用 O_CREAT,因为我们期望 Writer 已经创建了它
// O_RDONLY: 只读模式
shm_fd = shm_open(SHM_NAME, O_RDONLY, 0666);
if (shm_fd == -1) {
perror("shm_open 失败 (Writer是否已运行?)");
exit(1);
}

/* 步骤 2: 映射共享内存 */
// PROT_READ: 页面可以被读取
shm_ptr = mmap(0, BUFFER_SIZE, PROT_READ, MAP_SHARED, shm_fd, 0);
if (shm_ptr == MAP_FAILED) {
perror("mmap 失败");
exit(1);
}

/* 步骤 3: 打开已存在的信号量 */
// 注意:这里也不使用 O_CREAT
sem_writer = sem_open(SEM_WRITER, 0);
if (sem_writer == SEM_FAILED) {
perror("sem_open(writer) 失败");
exit(1);
}
sem_reader = sem_open(SEM_READER, 0);
if (sem_reader == SEM_FAILED) {
perror("sem_open(reader) 失败");
exit(1);
}

/* 步骤 4: 开始读取 (同步) */

// 4.1: P操作 (wait) - 等待读者信号量
// 检查是否有新数据可读 (等待 sem_reader 变为 1)。
// 如果 Writer 还没写完 (sem_reader 仍为 0),则在此处阻塞。
printf("Reader: 正在等待新数据 (sem_wait on reader)...\n");
sem_wait(sem_reader);

// 4.2: 获得信号,执行读取
printf("Reader: 信号抵达,正在读取数据...\n");
strcpy(read_buffer, (char *)shm_ptr);

// 4.3: V操作 (post) - 释放写者信号量 (将其+1)
// 通知 Writer:数据我已经读完了,你可以写新数据了。
printf("Reader: 读取完毕,通知 Writer (sem_post on writer)...\n");
sem_post(sem_writer);

printf("\n>>> Reader 读取到的数据: [%s] <<<\n\n", read_buffer);

/* 步骤 5: 清理资源 */
sem_close(sem_writer);
sem_close(sem_reader);
munmap(shm_ptr, BUFFER_SIZE);
close(shm_fd);

/* 步骤 6: 删除共享内存和信号量 */
// 作为一个好的实践,Reader (最后退出的进程) 负责清理
shm_unlink(SHM_NAME);
sem_unlink(SEM_WRITER);
sem_unlink(SEM_READER);

printf("Reader: 清理资源并退出。\n");
return 0;
}

管道

管道(Pipe)是一种简单的 IPC 机制,允许一个进程将数据写入管道的“写端”,另一个进程从管道的“读端”读取数据。管道提供了一个单向的数据流,适用于父子进程之间的通信。 > 可以将管道想象成一个单向的“水管”,一端的进程往里“写入”(write)数据,另一端的进程从管口“读取”(read)数据。

  • 字节流 (Byte Stream): 管道传输的是无格式的字节流。它不关心“消息”的边界。如果进程A写入 “Hello” 再写入 “World”,进程B一次性读取10个字节,它就会读到 “HelloWorld”。
  • 先进先出 (FIFO): 这是一个严格的 FIFO 队列。先被写入的数据会先被读出。
  • 内核缓冲区: 管道的实体是内核中的一块缓冲区。数据并不会直接从进程A的内存复制到进程B的内存,而是:
    • 进程A (Writer) 调用 write(),数据从用户空间复制到内核缓冲区
    • 进程B (Reader) 调用 read(),数据从内核缓冲区复制到用户空间
  • 阻塞性 (Blocking): 管道默认是阻塞的。
    • 当进程尝试从空管道读取时,read() 会阻塞,直到有数据被写入。
    • 当进程尝试向满管道(内核缓冲区已满)写入时,write() 会阻塞,直到有数据被读走。

管道主要分为两种:匿名管道 和 命名管道。

匿名管道 (Anonymous Pipes)

这是管道最常见的形式,也是在 Shell 中使用 | 符号时实际发生的情况

  • 亲缘关系限制: 这是匿名管道最大的特点。它只能用于具有“亲缘关系”的进程之间,最常见的就是父子进程
  • 半双工 (Half-Duplex): 数据在一个管道中只能单向流动。如果需要双向通信,必须创建两个管道
  • 随进程生命周期: 管道存在于内核中,它没有文件系统中的实体名称。当使用它的最后一个进程关闭(或退出)时,该管道就会被系统自动销毁

匿名管道通常在 fork()(创建子进程)之前被创建。

工作流程如下: 1. 创建管道 (Create): 父进程调用 pipe(int fd[2]) 系统调用。 - 内核会创建一个管道,并返回两个文件描述符(File Descriptors)给父进程,存放在 fd 数组中(fd数组是传出参数)。 - fd[0]:管道的读取端 (Read End)。 - fd[1]:管道的写入端 (Write End)。

  1. 创建子进程 (Fork): 父进程调用 fork(), 子进程会继承父进程所有打开的文件描述符,因此子进程也拥有 fd[0] 和 fd[1]
  2. 关闭不需要的端口 (Close Ends): 这是最关键的步骤,用于确立单向通信。
    • 情况A:父进程 -> 子进程. 父进程要写入,子进程要读取。
      • 父进程关闭它的读取端:close(fd[0]);
      • 子进程关闭它的写入端:close(fd[1]);
      • 之后,父进程使用 write(fd[1], …),子进程使用 read(fd[0], …)。
    • 情况B:子进程 -> 父进程. 子进程要写入,父进程要读取。
      • 父进程关闭它的写入端:close(fd[1]);
      • 子进程关闭它的读取端:close(fd[0]);
      • 之后,子进程使用 write(fd[1], …),父进程使用 read(fd[0], …)。

为什么必须关闭?

  • 功能上: 保持清晰的单向数据流。
  • EOF 信号: read() 操作如何知道数据已经读完了?只有当所有指向管道“写入端”(fd[1])的文件描述符都已关闭时,read() 才会读到 0(表示 EOF - End Of File),从而知道通信结束。
    • 如果你不关闭不用的端口(例如,在“父->子”通信中,子进程没有 close(fd[1])),那么即使父进程关闭了 fd[1] 并退出了,子进程的 read(fd[0]) 也会永远阻塞,因为它(子进程)自己还持有着一个有效的写入端,内核认为“可能还有数据会写入”。
    • 因为管道在内核中是共享的,只有当所有进程都关闭了写入端,内核才会发送 EOF 信号给读取端。

当你在 shell 输入:ls -l | grep ".cpp" 时,实际上发生了以下操作:

步骤 执行者 说明
Shell 进程 解析命令行,发现有管道符
Shell 关闭 fd[0] 和 fd[1],自己不读不写,等待两个子进程结束

这样,ls -l 的输出通过管道直接传递给 grep .cpp,实现了进程间的数据流转。

命名管道 (Named Pipes / FIFO)

匿名管道最大的缺点是它没有名字,只能用于有亲缘关系的进程。命名管道就是为了解决这个问题而生的

  • 无亲缘关系: 命名管道允许任何两个(或多个)不相关的进程进行通信。
  • 文件系统实体: 命名管道在文件系统中有一个可见的路径名(例如 /tmp/my_fifo)。它是一种特殊的文件类型(文件类型为 p)。
  • 生命周期独立: 命名管道一旦被创建(使用 mkfifo() 命令或函数),它就会一直存在于文件系统中,直到被显式删除(使用 unlink() 命令或函数),与创建它的进程是否退出无关。

工作流程: 1. 创建管道 (Create): 一个进程(或在 Shell 中)使用 mkfifo(“/tmp/my_fifo”) 来创建这个特殊文件。这只需要执行一次。 - mkfifo() 函数的签名: int mkfifo(const char *pathname, mode_t mode); - pathname: 命名管道的路径。 - mode: 权限设置 (例如 0666 表示所有用户可读写)。

  1. 打开管道 (Open)

    • 写入进程:使用 open(“/tmp/my_fifo”, O_WRONLY) 打开管道。
    • 读取进程:使用 open(“/tmp/my_fifo”, O_RDONLY) 打开管道。
    • 进程使用标准的 open()、read()、write()、close() 文件 I/O 函数来操作命名管道,就像操作普通文件一样
  2. 同步机制: 命名管道的 open() 调用本身就带有一个隐式的同步机制。如果一个进程以 O_RDONLY (只读) 方式 open() 命名管道,它将会阻塞,直到另一个进程以 O_WRONLY (只写) 方式打开了同一个管道。反之亦然。

  3. 通信: 进程像操作文件一样使用 write() 和 read() 进行通信。读写规则(FIFO、字节流、阻塞)与匿名管道完全相同。

  4. 关闭与删除: 进程通信完毕后,各自 close() 文件描述符。当不再需要这个管道时,必须由某个进程(或手动)调用 unlink(“/tmp/my_fifo”) 来删除这个文件,否则它会一直留在文件系统中。

总之, 管道的优点:

  • 简单易用: 管道的接口非常简单,使用标准的文件 I/O 函数即可。
  • 自动同步: 命名管道的 open() 调用自带阻塞同步机制,简化了进程间的协调工作。

缺点:

  • 数据格式限制: 管道传输的是无格式的字节流,不支持复杂数据结构。
  • 单向通信: 每个管道只能单向传输数据,双向通信需要创建两个管道。
  • 性能开销: 由于数据需要在用户空间和内核空间之间复制,管道的性能不如共享内存高效。
  • 缓冲区有限: 管道有固定大小的内核缓冲区,过多的数据写入可能导致写入进程阻塞,影响性能。

消息队列

消息队列(Message Queue)是一种更高级的 IPC 机制,允许进程以消息为单位进行通信。它是一个保存在内核中的消息链表。它允许一个或多个进程向其写入“消息包”(Message),一个或多个进程从其读取“消息包”。

与管道不同,消息队列支持有结构的数据传输。消息队列可以被看作是管道的一种“升级版”。它克服了管道的许多限制,提供了更灵活、更强大的通信方式。

  • 面向消息 (Message-Oriented): 这是消息队列与管道(面向字节流)的根本区别。

    • 管道 (Pipe): 写入者写入 “Hello” (5字节) 和 “World” (5字节)。读取者如果调用 read(10),会一次性读到 “HelloWorld”。
    • 消息队列 (MQ): 写入者发送消息 “Hello” 和消息 “World”。读取者必须一次读取一个完整的消息。receive() 第一次会得到 “Hello”,第二次才会得到 “World”。它保留了消息的边界
  • 异步与解耦 (Asynchronous & Decoupled): 这是它最大的优点。

    • 异步: 进程A发送消息后,send() 操作会立即返回,进程A可以继续执行其他任务,无需等待进程B接收。
    • 解耦: 进程A和进程B不需要同时运行。进程A可以发送几条消息然后退出,进程B可以在几小时后启动,依然可以从队列中读取到这些消息。
  • 内核持久性 (Kernel Persistence): 消息队列存在于内核中,其生命周期独立于创建它的进程。只要系统不重启,并且没有被显式删除,消息队列及其中的消息就会一直存在。

  • 支持优先级 (Priority): 大多数消息队列实现(如 POSIX MQ)允许在发送消息时为其指定一个优先级。进程在接收时,总是先收到优先级最高的消息。在优先级相同的情况下,才是先进先出 (FIFO)。这对于处理紧急任务非常有用。

  • 多对多通信 (Many-to-Many): 消息队列不像匿名管道那样局限于父子进程。任何知道队列名称的进程都可以向其发送消息或从中读取消息,非常适合复杂的多进程(如客户端/服务器)模型

工作流程 (以 POSIX 消息队列为例):

  1. 创建/打开队列 (Open): 进程使用 mq_open(const char *name, int oflag, ...) 来创建或打开一个队列。

    • name 必须是一个以 / 开头且不含其他 / 的字符串(例如 /my_mq)。
    • oflag 标志(如 O_CREAT, O_RDWR, O_RDONLY)决定是创建还是打开,以及读写模式。
    • 创建时需要指定队列的属性(mq_attr),比如每条消息的最大大小和队列的最大消息容量。
  2. 发送消息 (Send): 进程使用 mq_send(mqd_t mqdes, const char *msg_ptr, size_t msg_len, unsigned int msg_prio) 发送消息。

    • msg_ptr 和 msg_len:指向要发送的数据及其长度。
    • msg_prio:指定这条消息的优先级(数字越大,优先级越高)。
    • 如果队列已满,mq_send 默认会阻塞,直到队列中有空间。
  3. 接收消息 (Receive): 进程使用 mq_receive(mqd_t mqdes, char *msg_ptr, size_t msg_len, unsigned int *msg_prio) 接收消息。

    • msg_ptr:指向一个缓冲区,用于存放接收到的数据。
    • msg_len:必须大于等于队列中最大的消息大小(在 mq_open 时设定)。
    • msg_prio (可选):可以获取收到的这条消息的优先级。
    • 如果队列为空,mq_receive 默认会阻塞,直到有新消息到来。
  4. 关闭队列描述符 (Close): 当一个进程不再使用该队列时,调用 mq_close(mqd_t mqdes)。这只是关闭了这个进程的“句柄”,并不会从系统中删除消息队列。

  5. 删除消息队列 (Unlink): 当消息队列不再被任何进程需要时,必须由一个进程调用 mq_unlink(const char *name) 来从系统中彻底删除它。这和共享内存的 shm_unlink 机制一样,是防止资源泄漏的关键。

优点:

  • 面向消息: 保留了消息边界,适合传输结构化数据。
  • 异步解耦: 发送和接收进程可以独立运行,提高了系统的灵活性。
  • 优先级支持: 允许处理紧急消息。

缺点:

  • 性能开销: 速度慢于共享内存,也通常慢于管道。因为它涉及两次数据拷贝(用户A -> 内核 -> 用户B)以及管理链表和优先级的额外开销
  • 资源限制: 消息队列的总容量和单条消息的大小都是有限制的(由系统参数决定)。它不适合传输非常大的数据块(例如,一个 50MB 的文件),更适合传输控制命令或状态信息。

信号量

信号 (Signals): 是去“通知”一个进程发生了某个异步事件(如 Ctrl+C), 用于中断。 信号量 (Semaphores): 是用来“同步”多个进程的协作,或者“保护共享资源不被同时访问。

信号量是一种同步机制,本质上是一个由内核管理的整数计数器。它的核心作用是控制多个进程对共享资源的访问。

想象一个停车场,它总共有 N 个停车位。这个停车场(共享资源)门口有一个记分牌(信号量),初始显示 “N”。

  • P 操作 (Wait): 当一辆车(进程)想进入时,它必须先查看记分牌。如果记分牌上的数字 > 0,它就把数字减 1(N–),然后开进去。如果记分牌上的数字 == 0(没车位了),这辆车就必须在入口处排队等待(阻塞)。
  • V 操作 (Signal / Post): 当一辆车(进程)离开时,它会把记分牌上的数字加 1(N++)。做完这个操作后,它会“通知”停车场入口:“我走啦,空出一个位置了!”如果入口处有车在排队(阻塞),那么记分牌(内核)会唤醒一辆正在等待的车,让它执行 P 操作(此时它会发现数字 > 0)然后进入。

信号量的所有魔力都来自于它的两个原子操作 (Atomic Operations)。“原子”意味着这个操作是“不可分割”的,在它执行的瞬间,CPU 不会切换到其他进程,保证了“检查”和“修改”计数器之间不会发生竞态条件。

P 操作 / wait() / sem_wait() : 这个操作的意图是“获取资源”或“等待事件”。

1
2
3
4
5
6
7
8
9
P(Semaphore s):
// 原子地执行以下操作
if (s.value > 0) {
s.value = s.value - 1; // 消耗一个资源
} else {
// s.value == 0
// 将当前进程放入等待队列
sleep(); // 进程阻塞
}
V 操作 / signal() / sem_post() / up(): 这个操作的意图是“释放资源”或“触发事件”。
1
2
3
4
5
6
7
8
9
10
11
V(Semaphore s):
// 原子地执行以下操作
if (有进程在等待 s) {
// 唤醒一个等待的进程
wakeup(one_process);
// 被唤醒的进程会去完成它的 P 操作, 因此不需要修改 s.value
// 不过在实际的 POSIX 实现中 (sem_post),V 操作总是会先 s.value++,然后 sem_wait 会检查 s.value > 0。但上述的伪代码逻辑有助于理解“唤醒”和“计数”的本质。
} else {
// 没有进程在等待
s.value = s.value + 1; // 归还一个资源
}

信号量的两大类型

根据信号量计数器的初始值 N 的不同,信号量可以分为两种,用于完全不同的场景:

  1. 二进制信号量 (Binary Semaphore),又称“互斥锁 (Mutex)”: 初始值 N = 1, 信号量的值永远只在 0 和 1 之间变化。
  • 核心用途: 互斥 (Mutual Exclusion)。

  • 它用来保护一个“临界区 (Critical Section)”—— 一段一次只允许一个进程进入的代码(例如,写入共享内存的代码)。

  • ``` // 信号量 s,初始值为 1

    sem_wait(s); // P操作 (尝试加锁) // — 进入临界区 — // … // … 访问共享内存的代码 … // … // — 退出临界区 — sem_post(s); // V操作 (释放锁)

    // … 其他代码 …

  • 第一个进程调用 sem_wait(s),s.value 变为 0,进程进入临界区。

  • 第二个进程此时调用 sem_wait(s),发现 s.value == 0,于是它阻塞。

  • 第一个进程完成操作,调用 sem_post(s),s.value 变为 1 (或者直接唤醒第二个进程)。

  • 第二个进程被唤醒,完成它的 P 操作,进入临界区。

  • 注意: 严格来说,Mutex(互斥锁)和二进制信号量有细微差别(例如 Mutex 要求“谁加锁,谁解锁”),但在 IPC 层面,它们经常被视为实现了相同的功能。

  1. 计数信号量 (Counting Semaphore): 初始值 N > 1。
  • 核心用途:资源管理 或 复杂同步。

  • 它允许最多 N 个进程同时访问某个资源。

  • ``` // 假设你有一个包含 5 个数据库连接的“连接池”。 // 信号量 db_pool,初始值为 5 // 进程 A sem_wait(db_pool); // 消耗一个连接 (s.value 变为 4) // … 使用数据库连接 … sem_post(db_pool); // 归还一个连接 (s.value 变为 5)

    // 进程 B, C, D, E // … 它们也可以同时获取连接 …

    // 进程 F (第 6 个) sem_wait(db_pool); // s.value 此时为 0,进程 F 阻塞,直到 A/B/C/D/E 中有人释放连接

信号

信号是异步 (asynchronous) 通知机制。它是操作系统用来通知一个进程发生了某个事件的手段。它被认为是“软件中断”。当一个信号被“递达” (deliver) 给一个进程时,该进程会立即中断它当前正在执行的任何操作(无论在做什么),转而去处理这个信号

  • 异步性 (Asynchronous):信号随时都可能抵达。进程无法预知它会在哪一行代码执行时被中断。这使得信号处理变得非常棘手(我们稍后会详细说明)。
  • 不用于数据传输 (Not for Data):信号的唯一目的就是“通知”。它不携带数据。它的信息量极低,基本上只是一个“编号”(例如,信号 9)。(注:虽然POSIX后来扩展了“实时信号”,可以携带少量数据,但在经典IPC中,信号被认为不携带数据)。
  • 开销极低 (Low Overhead):发送一个信号非常快,因为它本质上只是内核目标进程的PCB(进程控制块)中设置一个标志位
  • 方向性:信号可以由“内核 -> 进程”,“进程 -> 进程”,甚至“进程 -> 进程自身” (raise())。

信号的生命周期分为三个阶段:

  1. 信号的产生 (Generation): 信号的产生可以来自多种来源:
  • 内核: 当发生系统事件时,内核会向进程发送信号。
    • SIGSEGV (Segment Fault):进程访问了非法内存。
    • SIGFPE (Floating Point Exception):发生算术错误(如除以零)。
    • SIGCHLD:一个子进程终止或停止时,内核会通知父进程。
  • 用户: 用户通过终端产生信号。
    • Ctrl+C:发送 SIGINT (中断信号),请求进程终止。
    • Ctrl+:发送 SIGQUIT (退出信号),请求进程终止并dump core。
  • 其他进程: 一个进程可以通过 kill() 系统调用向另一个进程(需要有权限)发送信号。
    • kill -9 :发送 SIGKILL 信号。
    • kill (默认):发送 SIGTERM 信号。
  1. 信号的递达 (Delivery): 当信号产生后,它被内核记录在目标进程的“待处理信号” (pending signals) 集合中。直到内核下一次将CPU时间片交给该进程时,它会先检查这个“待处理集合”。如果有信号,内核会在运行该进程的任何用户代码之前,强制它去“处理”这个信号。
    • 也就是说信号的中断不是立即的,而是在下一次内核调度进程时发生
    • 如果进程在用户态运行中,内核会立即打断用户态执行,转而执行信号处理函数。
    • 如果进程在内核态运行中(例如正在执行系统调用),信号会被延迟, 等系统调用结束或被中断后再处理。
  2. 信号的处理 (Handling): 当一个进程被通知去处理一个信号时,它有三种选择:
  • 执行默认操作 (Default Action): 每个信号都有一个系统预设的默认行为。例如, SIGINT 的默认操作是终止进程。SIGSEGV 的默认操作是终止进程并生成核心转储 (Core Dump)。SIGCHLD 的默认操作是忽略。
  • 忽略该信号 (Ignore the Signal): 进程可以显式地告诉内核:“我不在乎这个信号”。例如:signal(SIGINT, SIG_IGN);. 这样,即使你按 Ctrl+C,这个进程也不会退出。
    • 例外:有两个信号是“天选之子”,它们永远不能被忽略或捕获:SIGKILL (信号 9):终极的“杀死”命令,保证能终止进程。SIGSTOP:终极的“暂停”命令。
  • 捕获该信号 (Catch the Signal): 这是最复杂,也是最有用的方式。进程可以注册一个自定义函数,称为“信号处理器 (Signal Handler)”。例如: signal(SIGINT, my_custom_function);
    • 当 SIGINT 信号抵达时,进程会暂停当前的主程序流程, 跳转去执行 my_custom_function。当该函数 return 后,恢复到主程序被中断的地方继续执行(如果该信号没有终止进程的话)。
    • 不过需要注意的是, 信号处理器的执行环境非常受限。因为它是异步执行的, 它可能在任何代码行被打断, 包括正在修改全局变量或持有锁的时候。因此,在信号处理器中只能调用异步信号安全 (Async-Signal-Safe) 的函数(如 write(), _exit() 等),而不能调用可能引起阻塞或死锁的函数(如 malloc(), printf() 等)。

套接字 (Sockets)

到目前为止,我们讨论的所有 IPC 机制(共享内存、管道、消息队列、信号量)都有一个共同的局限:它们只能在同一台物理机器上的进程间使用。套接字则打破了这层壁垒。

套接字是进程间通信的“端点 (Endpoint)”。它是一个抽象概念,允许进程以一种标准化的方式发送和接收数据。

  • 通用性 (Universal): 套接字不仅可以用于同一台机器上的进程间通信(IPC),还可以用于不同机器之间的通信(网络通信)。这使得套接字成为分布式系统和网络应用的基础。
  • 网络能力 (Network-Capable): 这是它的王牌特性。套接字是网络编程的基石。所有你熟悉的网络应用(浏览器、聊天软件、游戏)底层都依赖于套接字。
  • 客户端/服务器模型 (Client/Server Model): 套接字通信通常遵循一个固定的模式:
    • 服务器 (Server): 被动方。它创建一个套接字,将其“绑定”到一个众所周知的地址(IP地址 + 端口号),然后“监听” (Listen) 等待连接。
    • 客户端 (Client): 主动方。它创建一个套接字,然后“连接” (Connect) 到服务器的地址。一旦连接建立,双方就可以双向读写数据。
  • 抽象性 (Abstraction): 它隐藏了底层网络协议的复杂性。你只需要关心“发送数据到套接字”和“从套接字读取数据”,而无需关心数据包如何被拆分、路由、校验和重传(这由 TCP/IP 协议栈负责)。

套接字的两种主要类型 (用于 IPC)

套接字是一个通用的“接口”,但根据你“插”的“插座”不同,它的行为也不同。这通过“域 (Domain)”和“类型 (Type)”来指定。

  1. 网络套接字 (Network Sockets - AF_INET / AF_INET6): 这是最常见的类型,用于跨网络或本地的通信。
  • 域: AF_INET (用于 IPv4) 或 AF_INET6 (用于 IPv6)。
  • 地址: IP 地址 + 端口号 (例如 127.0.0.1:8080)。
  • 它又分为两种主要的协议类型:
    • TCP (流套接字 - SOCK_STREAM): 面向连接, 可靠传输, 字节流。
    • UDP (数据报套接字 - SOCK_DGRAM): 无连接, 不可靠传输, 面向消息。
  1. Unix 域套接字 (Unix Domain Sockets - AF_UNIX / AF_LOCAL): 这是套接字家族中专门用于本地 IPC 的成员。它不能跨网络。
  • 域: AF_UNIX 或 AF_LOCAL。
  • 地址: 在文件系统中是一个路径名(例如 /tmp/my.sock),看起来像一个文件,但它不是普通文件。
  • 核心优势:
    • 性能极高: 当在同一台机器上使用时,Unix 域套接字 (UDS) 远快于基于 IP 的网络套接字(127.0.0.1)。
    • 为什么快? 它不经过完整的 TCP/IP 协议栈(不需要计算校验和、打包/解包、路由等),数据直接在内核中从一个进程复制到另一个进程,非常接近管道的效率。
    • 安全: 可以通过文件系统的权限(rw-rw—-)来控制哪些用户/组的进程可以访问这个套接字。
    • 何时使用 UDS? 当你的客户端和服务器都在同一台 Linux/Unix 机器上,并且你需要高性能、低延迟的通信时(例如,数据库和本地 Web 应用之间的通信),UDS 是首选。