六大进程间通信 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))来确保访问的互斥性。即在写入前“加锁”,写入后“解锁”;读取前“加锁”,读取后“解锁”。
总结: 共享内存提供了“最快的路”,但也要求“驾驶员”自己管理交通(同步)。它通常不单独使用,而是与信号量等同步工具配合使用。
下面是一个写者-读者模型的共享内存示例代码:
1 | /* writer.c */ |
1 | /* reader.c */ |
管道
管道(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], …)。
- 情况A:父进程 -> 子进程. 父进程要写入,子进程要读取。
为什么必须关闭?
- 功能上: 保持清晰的单向数据流。
- 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() : 这个操作的意图是“获取资源”或“等待事件”。
1 | P(Semaphore s): |
1 | V(Semaphore s): |
信号量的两大类型
根据信号量计数器的初始值 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 信号。
- kill -9
- 信号的递达 (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 是首选。