在前面的文章我介绍了进程概念,每个进程都有自己的一块内存,也就是虚拟内存隔离机制。还提到了如果进程内要互相通信交换数据就要共享内存,可以查看这篇文章:进程数据共享模型,在文中提到了CSP模型Actor模型通过发送消息来共享内存中的数据,这类模型都是源于操作系统的IPC衍生设计与应用。

本篇文章将继续介绍进程间的通信机制Inter-Process Communication,IPC设计初衷很简单就是要把两个独立的进程要通过发送消息和接收消息的方式联系起来。进程都在运行在操作系统之上的,操作系统为每个进程进行隔离,如果要交换数据那么就需要一种功能来辅助完成这方面工作,这就是IPC要解决的问题。

现实生活中我们如果需要联系朋友约出来吃个饭?我们一般都是使用微信或者打个电话,然后通过手机上的把数据传输到另外一个手机上,这就是符合我们人类理解的通信概念。每个人都是不同的个体,而把每个人连接到一起的是通信设备和互联网,消息则是要传输的数据载体,如果要了解这方面可以看看这:信息论,当然这是一门学科。

IPC作用

相信大家都使用过手机上的支付宝或者PayPal等移动支付软件,例如打开淘宝或者饿了么点餐结账的时候都需要使用到支付软件,这两个软件在对于操作系统而言是完全运行独立个体,如下图:

当使用亚马逊清算商品时到达支付环节的时候,就需要PayPal进行支付结账,这时两个App就是完全独立的进程,此时两个App之间数据通信就要基于IPC来完成支付;估计也有很多读者遇到一种情况📱手机上没有安装相关的支付软件,当购物软件发起支付的时候就会发生支付失败情况。当如上面只是使用购物和支付来举一个例子,现实生活中的支付软件可能不会完全依靠操作系统提供IPC来通信,可能还会依赖自己厂商支付后台服务器来辅助完成提高支付的安全性。

上图就为Android系统中的进程间通信机制Android Binder,这个架构设计很类似于现在的服务端微服务架构,有注册发现机制,当我们在手机上安装了一款应用,这款应用会把所有功能信息提供给操作系统注册一遍,而操作系统也会把自己所有服务告诉给应用,达到不同应用之间的互操作性。

IPC通信原理

目前操作系统之上的进程通信就依靠着IPC作为通信桥梁,可以把两个独立的进程的理解为两个孤岛,而IPC则是连接到这两座岛的桥梁。我们可以把这两个独立进程划分成调用者被调用者,也可以认为是生产者和消费者模型,如下图:

通信的过程: 首先要有发起者发起,发起者会将一段特定长度的数据发送给接收者,当数据发送出去以后发送者会一直等待者接收者回传数据;接收者拿到数据之后会根据自己编写好的处理逻辑来处理请求,处理完成将数据发送回传给发送者,到此此次IPC通信过程结束。

上面讨论了一堆IPC通信的理论,那代码怎么去实现呢?如上图中的消息,可以把进程之间的传输的数据抽象成一块内存中的Message,本质上还是共享内存实现数据交换,当然在计算机中操作数据都是在半导体是或者说是在存储介质上操作,只是给了这些抽象的内存数据做了行为上的抽象概念。

数据传输实现: 两个独立的进程在操作系统内存隔离机制下是完全独立的地址空间,能操作的数据和内存是完全有限的。共享通信那就让操作系统帮忙申请一块共享内存,让两个独立进程能在这块公共区域上活动,之前介绍过虚拟内存,可以通过虚拟内存映射到同一块物理地址上达到IPC通信功能,如下图:

至于具体的通信消息可以抽象成一个数据结构,如最上面那副图,消息可以分为Header字段,Header可以包含消息的魔数、消息长度、消息状态;而另外就是消息体Payload主要是存放消息的数据区域,一般这个区域我在有关资料看到是为500字节,伪代码定义例子:

type Header struct {
    magicNum int
    length   int16
    state    int8
}

type Message struct {
    Header
    // 0 - 250 sender data 
    // 250 - 500 receiver data
    payload     [500]byte 
}

至于上面中出现一个名词Magic Number,这里没有明确定义用途是什么,但是我在知乎上看到一篇相关的讨论话题:编程中的「魔数」

在那么多回答中我找到了一个合理解释应该是说防止缓冲区溢出攻击,这个数值是动态产生的不确定的所以这样攻击者很难确定到缓冲区的大小,我想应该是这样的,在缓冲区数组旁放一个magic number(学术界有一个专业名词称作canary金丝雀),通过检查是否一致可检测缓冲区溢出攻击,这块也是系统安全的一个研究方向。

数据交换过程: 上面讲解整个消息发送和接收过程,但是实现细节是怎么样的?首先在操作系统会帮助申请一块存储Message结构的缓冲区内存,发送者如果要发送消息会把消息数据从自己的进程内部拷贝到缓冲区内,并且设置缓冲区的状态为准备就绪,操作系统还要协助一件事情通知接收者并且让接受者轮询此块区域,如果发现准备就绪即可读取缓冲区内容,当消息被读取之后,发送者也会轮询缓冲区状态等待接收者反馈数据,过程如下图:

目前这个方案可以解决两个独立的进程实现IPC通信基本功能,但是整个方案来看还是有不少问题的:

  1. 两个进程其中一个进程异常退出了另外一个进程是否能正常工作
  2. 数据段的长度是固定的,真实的数据长度过长数据段要多次通信
  3. 两个独立进程都在轮询消息状态,导致CPU资源大量浪费

如何解决这些常见的问题呢?之前讲过OS部分的内容,操作系统可以管理它所创建的进程和调度这些进程,操作系统相当于这些进程的管家,可以让操作系统在需要通信的进程之间提供一个共用的用户态接口,方便用户态程序调用并且传递这些数据。这也是Android Binder设计与相关的应用,简单一点就是让操作系统提供SenderReceiver的接口,而具体的功能实现让OS做,而用户态程序只负责发送和接收的数据,用户态进程只需要关心的是数据,而OS关心的数据安全交换和避免一些进程出现了异常也能正常退出的工作

基于OS的实现

基于OS去做的IPC通信就要简单的多了,当然这个简单只是对于用户态的进程来说,如果读者是一名操作系统内核开发者那么就不是这么简单的说法了,这里的简单针对于是应用程序来说的,用户态的程序就是操作一下senderreceiver的API很简单。但是对于一个实现IPC内核部分的开发者那就要明白具体功能实现细节,下面将讨论内核部分的IPC实现的细节。

通过内核去实现的话本质上还是在内存中提供共享公共区域来存储数据,只是在操作行为上做了抽象的概念。上面讨论如果是普通方式去实现可能需要经过进程把数据拷贝到缓冲区空间,然后接收者再去读取缓冲区里面的数据,要拷贝2次数据才能完成一次通信,这里就会发生上下文切换导致性能损耗问题,对应这个问题完全可以使用mmap技术把进程内的地址映射到内核的实际地址上减少拷贝次数,当然读到这里某些读者可能又不知道零拷贝技术是什么?这里不是本篇文章的重点。

上面传统的共享内存的方式当两个进程开始IPC通信的时候就要不停轮询缓冲区消息的状态,这会导致CPU资源一直浪费在这两个进程上,之前的文章我讲过如果进程遇到了IO事件可以有很多种放上去做调度,而不是让其一直轮询。为何不让进程在没有准备好数据的情况下切换到阻塞状态呢?让CPU去执行其他进程,而不是一直被这个进程一直占用着。

基于OS部分实现的IPC完全可以实现这一点,通过用户态的IPC接口,当Process A调用了Sender接口发送完数据就让其切换到阻塞状态,而操作系统则消息转交给接收者,接收者处理完数据则把消息返回给阻塞的发送者,再发送者没有收到反馈消息之前接收者也会陷入阻塞状态,系统会帮助接收者唤醒阻塞的发送者,如下图:

上图这个过程专业名称叫:IPC控制流转换,这和协程中的生产者和消费者模型很接近,操作系统的IPC通信设计个人感觉很大程度上影响到了一些编程语言在并发和解决数据竞争设计;例如Go语言的CSP模型,还有Erlang的Actor消息共享模型,本质上都是共享内存来传递数据,但是语言设计者在语法层面做了一些优化和语法糖,让并发操作更符合人类的思维方式。

例如下面为Java 19的虚拟线程中的Try关键字,相比Go语言使用的sync.WaitGroup来显示指定范围要更加人性化一点,下面是Go的代码:

package main

import (
    "sync"
    "time"
)

var wg = sync.WaitGroup{}

func main() {
    wg.Add(10000)
    for i := 0; i < 10000; i++ {
        i := i
        go func() int {
            defer wg.Done()
            time.Sleep(1 * time.Second)
            return i
        }()
    }
    wg.Wait()
}

同样的功能看看在Java 19中虚拟线程是怎么编写的,try语句块,代码如下:

import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

public class Main {
    public static void main(String[] args) {
        // 注意这里的try关键字
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 10_000).forEach(i -> {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofSeconds(1));
                    return i;
                });
            });
        }
    }
}

相比Go要去主动指定开启的协程数量,而Java 19中的直接复用了try关键字去做的,不用指明有多少个协程,也不用显示用代码的去等待,而直接使用try关键字来包裹着需要启动的虚拟线程代码块即可完成并行代码逻辑同步操作,更多的关于Java 19的虚拟线程的文章可以查看这篇文章:Going inside Java’s Project Loom and virtual threads

当然这里是针对某些方面的语法糖和语言原生支持做了一些论述,Go也有它独特的地方例如有<-符号了做channel的数据流向控制。而Java目前的try语句块是为了后面支持结构化并发做的特性也就是语言层面的支持,各有千秋,只是论述,语言是个工具没有谁好谁坏,在合适的场景做合适的事情!

小结

本文中介绍了操作系统中的IPC通信功能基础实现,也顺便介绍了Android Binder功能的具体细节,传输数据都是基于共享内存的消息传递,其实核心就是把消息抽象成一种数据结构去共享数据,而传统基于内存页映射共享存在一些问题;而基于OS做了操作系统隔离提供了专用的用户态接口,让用户态程序更加关注的数据本身而不是具体功能实现,也降低用户态程序操作API一些负担。IPC还有通信方式还有双向通信和单向通信,本文介绍的是单向通信,只介绍核心IPC功能实现更多通信方式可以阅读文末其他资料,很多新型编程语言在语法层面也借鉴这方面的设计,基于消息共享解决并发竞争的数据的问题,而不是加锁去访问,当然只是语言语法糖而已本质上底层还是会加锁,例如Go语言中的channel的底层实现也是有锁的。

其他资料

便宜 VPS vultr
最后修改:2023 年 07 月 05 日
如果觉得我的文章对你有用,请随意赞赏 🌹 谢谢 !