一、Linux的IPC机制 #
Linux提供了多种进程间通信(IPC)机制,主要包括:原子操作、信号、管道、命名管道(FIFO)、消息队列、共享内存、Futex、套接字和信号量等。这些机制各有特点,适用于不同的场景。
- 消息传递
消息传递是指一个进程将数据发送给另一个进程,接收进程通过某种方式获取这些数据。发送进程通过系统调用send将数据发送到内核,内核将数据存储在一个消息队列中,接收进程通过阻塞在系统调用recv从消息队列中获取数据。消息传递机制适用于需要频繁交换数据的场景,如客户端-服务器通信。
消息缓冲区结构:消息头(类型、接收方 PID、发送方 PID、长度、控制信息)+ 消息体。
用 PV 操作实现 send 原语:
buf-empty(初值 n),buf-full(初值 0),mutex1, mutex2
send(dest, &msg):
P(buf-empty); P(mutex1); 取空缓冲区; V(mutex1);
将消息复制到缓冲区;
P(mutex2); 挂到接收进程消息队列; V(mutex2); V(buf-full);用 send/receive 实现生产者-消费者:不需要信号量——send/recv 本身提供同步语义,消费者 recv 阻塞等消息,生产者 send 发消息,缓冲区满时 send 也可阻塞等消费者腾空间。

通过send和receive也可以用来实现生产者-消费者。
- 共享内存
通过系统调用mmap(),多个进程可以将同一块物理内存映射到它们的虚拟地址空间中,从而实现数据共享。共享内存机制适用于需要高效数据交换的场景,如多线程程序中的数据共享。
通过MAP_SHARED标志,多个进程可以共享同一块内存区域。进程可以通过读写共享内存来交换数据,而不需要经过内核的中转,从而提高了通信效率。比如A进程和B进程都映射了同一块共享内存区域,A进程写入数据后,B进程可以直接读取这些数据。后续修改的脏页由内核写回文件。
- 管道通信 Pipe
利用一个缓冲传输介质——内存或文件连接两个相互通信的进程。字符流方式写入读出;先进先出顺序;管道通信机制必须提供的协调能力:互斥、同步、判断对方进程是否存在。
父子进程之间的通信可以使用无名管道,逻辑上是管道文件,物理上是利用高速缓冲区,与外设无关,创建在内存中临时存在,用文件描述符存取。父进程创建一个管道后,子进程继承该管道的文件描述符,从而实现通信。其他进程之间的通信可以使用命名管道(FIFO),逻辑上是管道文件,物理上是利用文件系统实现,是特殊文件,存在于文件系统, ??????????? 创建在内存中临时存在,用文件描述符存取。通过文件系统中的一个特殊文件进行通信。
- 套接字
服务器端创建一个套接字,绑定到一个特定的地址和端口上,并监听来自客户端的连接请求。客户端创建一个套接字,并连接到服务器的地址和端口。连接建立后,双方可以通过套接字进行数据交换。
- RPC(Remote Procedure Call)
RPC是一种技术思想,屏蔽网络编程细节,像调用本地方法一样调用远程方法。
什么时候使用RPC?应用访问量增加和业务增加时,单机无法承受,可以根据不同的业务拆分成互不关联的应用,分别部署在不同的机器上,应用与应用相互调用,此时需要用到RPC。解耦服务、扩展性强、部署灵活,主要解决分布式系统中,服务与服务之间的调用问题。
RPC 流程:Client 调用 → Client Stub 序列化 → Socket 发送 → Server Stub 反序列化 → 本地服务执行 → 序列化结果返回 → Client 反序列化得到结果。应用场景:电商微服务拆分(用户/商品/订单/支付服务)、车载 SOME/IP 协议。
- 管道模式(Pipeline)
管道不仅仅是一种 IPC 机制,也是一种链式编程模型——通过 | 串联多个程序,形成工作流:
cat words | grep purple | awk '{print length($1), $1}' | sort -n | tail -n 1核心设计思想:高内聚、低耦合。每个程序只做一件事,管道将它们串成一条处理链。
- 信号 signal
信号(软中断信号)是一种异步通信机制,用于通知进程发生了某些事件。进程可以通过系统调用signal或sigaction来设置信号处理函数,当特定的信号发生时,内核会调用相应的处理函数。信号机制适用于需要异步通知的场景,如处理外部事件或异常情况。
信号的产生来源:异常(除零、非法指令、段错误)、其他进程(kill / sigsend)、作业控制(Shell 管理前后台进程)、定时器(alarm / setitimer)、设备 I/O 就绪等。
收到信号后三种处理方式:自定义处理函数、忽略、系统默认。检测时机在进程从内核返回用户空间前(系统调用返回、被调度选中时)。
信号生命周期:触发事件 → 信号加入未决信号集 → 目标进程被选中时检测 → 执行处理函数 → 注销。信号本质上是在软件层面上对中断机制的一种模拟。
实现调用链(用户态 → 内核态):signal() → __sigaction → rt_sigaction 系统调用 → do_sigaction()。返回用户态前 do_signal() 设置栈帧,iret/sysret 返回用户态执行 handler,完成后 rt_sigreturn 回到内核。定时器信号:alarm() → setitimer 系统调用 → 内核计时,到期后 it_real_fn 回调 → send_signal 发送 SIGALRM。
在进程被选中、出内核之前,系统会对信号进行处理。
二、Linux内核同步机制 #
1. 原子操作 #
原子操作是指在执行过程中不可被中断的操作,保证了数据的一致性和完整性。Linux内核提供了多种原子操作,如原子变量、原子位操作等。这些操作通过特殊的指令实现,确保在多处理器环境下的正确性。常常用于实现资源的引用计数。
2. 自旋锁 #
自旋锁是为了防止多处理器并发而引入的一种锁,是一种“忙等”,在内核中大量应用于中断处理等部分。
自旋锁 vs 信号量:
| 自旋锁 | 信号量 | |
|---|---|---|
| 等待方式 | 忙等(spin) | 睡眠(sleep) |
| 临界区长度 | 短 | 长 |
| 持有期间可否睡眠 | 否 | 可 |
| 适用场景 | 中断处理、短临界区 | 可能阻塞的操作 |
传统自旋锁用 LOCK decb + rep;nop 自旋,改进版排队自旋锁(类似银行叫号系统)消除惊群问题。
3. RCU机制 #
Linux 2.6之后,一种数据一致性访问机制,是 Linux 内核性能最高的同步机制,专门针对读极多、写极少场景。
- 读者:完全无锁,零开销,直接访问共享数据
- 写者:复制旧数据 → 修改副本 → 原子指针替换 → 宽限期后释放旧内存(等所有读者读完)
- 多个写者仍需锁互斥
对于读操作:直接对共享资源进行访问(需要CPU支持访存操作的原子性),采用read_rcu_lock(),RCU的读操作上下文是不可抢占的。
对于写操作:复制一份旧数据、修改副本、原子指针替换【rcu_assign_pointer()宏】,让大家访问新数据、旧数据暂时保留给还在读取的读者、延迟回收(Grace Period 宽限期):等到所有读者都读完旧数据后,才释放旧内存
采用数据备份的方法可以实现读者与写者之间的并发操作,但是不能解决多个写者之间的同步,所以当存在多个写者时,需要通过锁机制对其进行互斥,也就是在同一时刻只能存在一个写者。
应用场景:推理引擎 KV Cache、RAG 向量索引、GPU 资源池元数据、分布式参数服务器。
4. 其他内核同步机制 #
| 机制 | 用途 |
|---|---|
| 完成变量(completion) | 简单事件通知,wait_for_completion / complete,用于 vfork 等 |
| 顺序锁(seqlock) | 写优先于读,靠序列计数器,读者读前读后比较序列值,若相同则成功读 |
| 读写锁(rwlock) | 读共享、写互斥,适合读多写少(如 IPX 路由表) |
| 屏障(Barrier) | 一组线程到达汇合点后一起推进(矩阵运算等) |
三、死锁 #
并发编程会带来很多困难,要保证安全、性能、可调式。
死锁的定义:一组进程中,每个进程都无限等待被该组进程中另一进程所占有的资源,因而永远无法得到的资源,这种现象称为进程死锁,这一组进程就称为死锁进程。如果死锁发生,会浪费大量系统资源,甚至导致系统崩溃。
为什么会出现死锁?资源竞争、设计不当、缺乏适当的同步机制等都可能导致死锁的发生。大型代码库中,组件之间有复杂的依赖关系在设计锁机制时,要避免循环依赖导致的死锁;封装:模块化隐藏实现细节,开发更容易模块化和锁不是很契合,某些看起来没有关系的接口可能会导致死锁。
例如,在信号处理函数中尽量不要使用printf等可能阻塞的函数,避免死锁。因为信号处理函数可能在任何时候被调用,如果它尝试获取一个已经被其他线程持有的锁,就会导致死锁。
死锁必要条件:
- 互斥使用条件(Mutual Exclusion):一个资源在同一时间只能被一个线程或进程占用。
- 占有且等待条件(Hold and Wait):一个线程或进程在持有至少一个资源的同时,仍然尝试获取其他线程或进程所持有的资源。
- 不可抢占条件(No Preemption):资源不能被强行从一个线程或进程中抢占,只能由占有它的线程或进程主动释放。
- 循环等待条件(Circular Wait):存在一个线程/进程等待序列,其中每个线程/进程都在等待下一个线程/进程所持有的资源,形成一个等待环路。
破坏其中一个条件即可避免死锁。解决办法:
- 不考虑,鸵鸟算法
- 不让死锁发生:
- 死锁预防:静态策略,设计合适的资源分配算法,破坏死锁必要条件之一,不让死锁发生。
- 死锁避免:动态策略,以不让死锁发生为目标,跟踪并评估资源分配过程,根据评估结果决策是否分配。
- 让死锁发生:死锁检测与恢复:动态策略,允许死锁发生,但系统会定期检测死锁状态,并采取措施恢复,如终止进程或回滚操作。
死锁预防针对四种必要条件设计的算法:
- 互斥使用:采用资源转换技术,把独占资源变为共享资源,如SPOOLing技术,设计一个
deamon进程管理打印机资源,用户进程将打印任务发送给deamon,deamon负责调度打印任务,避免了直接访问打印机资源的互斥条件。 - 占有且等待:实现方案1:要求每个进程在运行前必须一次性申请它所要求的所有资源,且仅当该进程所要资源均可满足时才给予一次性分配。但是问题是资源利用率低,且会造成饥饿。实现方案2:在允许进程动态申请资源前提下规定,一个进程在申请新的资源不能立即得到满足而变为等待状态之前,必须释放已占有的全部资源,若需要再重新申请。但是实现困难。
- 不可抢占:实现方案:虚拟化资源:当一个进程申请的资源被其他进程占用时,可以通过操作系统抢占这一资源(两个进程优先级不同)。局限性:适用于状态易于保存和恢复的资源,如CPU、内存。
- 循环等待:通过定义资源类型的线性顺序实现。实施方案:资源有序分配法,把系统中所有资源编号,进程在申请资源时必须严格按资源编号的递增次序进行,否则操作系统不予分配。可以证明是正确的。
死锁避免
活锁是指线程或进程虽然没有被阻塞,但由于相互之间的干扰,导致无法继续执行下去的状态。与死锁不同,活锁中的线程或进程仍然在运行,但它们无法完成任何有意义的工作。例如,在一个多线程环境中,如果两个线程同时尝试获取同一资源,并且在获取失败后都选择了重试,那么它们可能会不断地相互干扰,导致活锁的发生。
饥饿是指某个线程或进程由于资源分配不公平,长时间无法获得所需的资源,从而无法继续执行的状态。例如,在一个多线程环境中,如果一个线程优先级较低,而其他线程优先级较高且频繁地获取资源,那么这个低优先级的线程可能会一直得不到资源,导致饥饿的发生。