六大进程间通信 IPC

六大进程间通信 IPC

进程(Process)是资源分配的基本单位。为了安全和稳定,操作系统会为每个进程分配独立的虚拟地址空间。IPC

是操作系统提供的一套机制,用于跨越这些进程的独立地址空间,让它们能够交换数据和同步状态。

六大主要 IPC 机制:

共享内存

共享内存(Shared Memory)是一种高效的 IPC

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

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

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

创建/打开共享内存段 (Create / Open):

一个进程(通常是“服务端”)首先向操作系统请求创建一块共享内存。

示例 (POSIX C/C++): 使用 shm_open()

函数创建一个共享内存“对象”,它看起来像一个文件,但实际存在于内存中。

设置大小 (Set Size):

创建进程需要指定这块共享内存的大小。

示例 (POSIX C/C++): 使用 ftruncate() 函数设置

shm_open() 返回的文件描述符的大小。

它通过 shm_open() 创建的“对象”看起来像文件(有文件描述符,

通过fd操作),但实际存储在内存中,而不是磁盘上。

映射 (Map / Attach):

需要通信的所有进程(包括创建者自己)都必须将这个共享内存段“附加”或“映射”到自己的虚拟地址空间中。

示例 (POSIX C/C++): 使用 mmap()

函数将共享内存对象映射到进程的地址空间,返回一个指向该内存的指针。

读/写数据 (Read / Write):

一旦映射完成,进程就得到了一个指向共享内存的本地指针(例如

void* ptr)。进程可以通过这个指针,像操作普通 C/C++

指针一样,直接读写数据。

例如: strcpy(ptr, “Hello from Process A”);

另一个进程可以通过它自己的指针(ptr_B)立即读到这个数据:printf(“%s”,

(char*)ptr_B);

解除映射 (Unmap / Detach):

当进程不再需要这块共享内存时,它应该解除映射。

示例 (POSIX C/C++): 使用 munmap()

函数解除映射。

解除映射之后, 进程将无法再通过该指针访问共享内存,

这个指针变为悬空指针. 但这只针对当前进程,

其他进程仍然可以用他们的指针访问该共享内存.

删除共享内存段 (Delete):

当所有进程都使用完毕后,必须由一个进程(通常是创建者)显式地通知操作系统删除这个共享内存段,否则它会一直残留在系统中,造成资源泄漏。

示例 (POSIX C/C++): 使用 shm_unlink()

函数删除共享内存段。

优点: - 速度极快:

这是共享内存最大的优点。数据交换完全不需要经过内核。一旦映射建立,数据读写就只是CPU的内存访问指令,不涉及系统调用(System

Call)的开销,也避免了数据在内核缓冲区和用户缓冲区之间的来回复制。 -

数据量大:

适合传输大量、结构化的数据(例如,视频帧、大型数据集)。

缺点与挑战: - 必须手动同步:

这是共享内存最大的缺点,也是其复杂性的根源。操作系统只负责提供“共享空间”,但不负责管理“访问秩序”。

- 竞态条件 (Race Condition):

如果进程A正在向共享内存写入数据(比如一个复杂的结构体,需要写多次),写到一半时;进程B突然开始读取,那么B就会读到“一半新、一半旧”的垃圾数据。

- 解决方案: 程序员必须自己使用其他的IPC机制(如信号量

(Semaphores) 或 互斥锁

(Mutexes))来确保访问的互斥性。即在写入前“加锁”,写入后“解锁”;读取前“加锁”,读取后“解锁”。

总结:

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

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

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495/* writer.c */#include #include #include #include // For O_* constants#include // For mode constants#include // For mmap()#include // For ftruncate(), close(), sleep()#include // 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;}

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788/* reader.c */#include #include #include #include #include #include #include #include #define SHM_NAME "/my_shm"#define SEM_WRITER "/sem_writer"#define SEM_READER "/sem_reader"#define BUFFER_SIZE 1024int 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)。

创建子进程 (Fork): 父进程调用 fork(),

子进程会继承父进程所有打开的文件描述符,因此子进程也拥有

fd[0] 和 fd[1]。

关闭不需要的端口 (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

表示所有用户可读写)。

打开管道 (Open)

写入进程:使用 open(“/tmp/my_fifo”, O_WRONLY) 打开管道。

读取进程:使用 open(“/tmp/my_fifo”, O_RDONLY) 打开管道。

进程使用标准的 open()、read()、write()、close() 文件 I/O

函数来操作命名管道,就像操作普通文件一样。

同步机制: 命名管道的 open()

调用本身就带有一个隐式的同步机制。如果一个进程以

O_RDONLY (只读) 方式 open() 命名管道,它将会阻塞,直到另一个进程以

O_WRONLY (只写) 方式打开了同一个管道。反之亦然。

通信: 进程像操作文件一样使用 write() 和 read()

进行通信。读写规则(FIFO、字节流、阻塞)与匿名管道完全相同。

关闭与删除: 进程通信完毕后,各自 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 消息队列为例):

创建/打开队列 (Open): 进程使用

mq_open(const char *name, int oflag, ...)

来创建或打开一个队列。

name 必须是一个以 / 开头且不含其他 / 的字符串(例如 /my_mq)。

oflag 标志(如 O_CREAT, O_RDWR,

O_RDONLY)决定是创建还是打开,以及读写模式。

创建时需要指定队列的属性(mq_attr),比如每条消息的最大大小和队列的最大消息容量。

发送消息 (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 默认会阻塞,直到队列中有空间。

接收消息 (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 默认会阻塞,直到有新消息到来。

关闭队列描述符 (Close):

当一个进程不再使用该队列时,调用 mq_close(mqd_t

mqdes)。这只是关闭了这个进程的“句柄”,并不会从系统中删除消息队列。

删除消息队列 (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() :

这个操作的意图是“获取资源”或“等待事件”。

123456789P(Semaphore s): // 原子地执行以下操作 if (s.value > 0) { s.value = s.value - 1; // 消耗一个资源 } else { // s.value == 0 // 将当前进程放入等待队列 sleep(); // 进程阻塞 } V 操作 / signal() / sem_post() / up():

这个操作的意图是“释放资源”或“触发事件”。

1234567891011V(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

的不同,信号量可以分为两种,用于完全不同的场景:

二进制信号量 (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

层面,它们经常被视为实现了相同的功能。

计数信号量 (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())。

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

信号的产生 (Generation): 信号的产生可以来自多种来源:

内核: 当发生系统事件时,内核会向进程发送信号。

SIGSEGV (Segment Fault):进程访问了非法内存。

SIGFPE (Floating Point Exception):发生算术错误(如除以零)。

SIGCHLD:一个子进程终止或停止时,内核会通知父进程。

用户: 用户通过终端产生信号。

Ctrl+C:发送 SIGINT (中断信号),请求进程终止。

Ctrl+:发送 SIGQUIT (退出信号),请求进程终止并dump core。

其他进程: 一个进程可以通过 kill()

系统调用向另一个进程(需要有权限)发送信号。

kill -9 :发送 SIGKILL 信号。

kill (默认):发送 SIGTERM 信号。

信号的递达 (Delivery):

当信号产生后,它被内核记录在目标进程的“待处理信号”

(pending signals)

集合中。直到内核下一次将CPU时间片交给该进程时,它会先检查这个“待处理集合”。如果有信号,内核会在运行该进程的任何用户代码之前,强制它去“处理”这个信号。

也就是说信号的中断不是立即的,而是在下一次内核调度进程时发生。

如果进程在用户态运行中,内核会立即打断用户态执行,转而执行信号处理函数。

如果进程在内核态运行中(例如正在执行系统调用),信号会被延迟,

等系统调用结束或被中断后再处理。

信号的处理 (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)”来指定。

网络套接字 (Network Sockets - AF_INET / AF_INET6):

这是最常见的类型,用于跨网络或本地的通信。

域: AF_INET (用于 IPv4) 或 AF_INET6 (用于 IPv6)。

地址: IP 地址 + 端口号 (例如 127.0.0.1:8080)。

它又分为两种主要的协议类型:

TCP (流套接字 - SOCK_STREAM): 面向连接, 可靠传输, 字节流。

UDP (数据报套接字 - SOCK_DGRAM): 无连接, 不可靠传输, 面向消息。

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 是首选。

相关文章