Apache Geaflow推理框架Geaflow-infer 解析系列(六)共享内存架构

第6章:共享内存架构

章节导读

如果说环境初始化是"准备舞台",那么共享内存就是"舞台的核心"。Java 和 Python 进程间的高性能通信完全依赖于 DataExchangeQueue 的精妙设计。

本章将深入讲解:

  • mmap 内存映射的原理
  • 环形缓冲区的设计
  • 无锁算法的实现
  • Unsafe 底层操作

通过本章,你将理解为什么 geaflow-infer 能达到微秒级延迟。

核心设计

markdown 复制代码
问题: 如何在毫秒级延迟和通用性之间权衡?

答案: DataExchangeQueue
  ✓ 以微秒级延迟换取本机限制
  ✓ 以复杂的无锁实现换取高性能
  ✓ 以 Unsafe 危险性换取性能优势
  ✓ 这些权衡在推理场景中是值得的

最终结果:
  - 延迟: 微秒级 (vs RPC 毫秒级)
  - 吞吐: Gbps 级别
  - 复杂度: 高,但被隔离在这个模块
  - 可维护性: 通过接口隔离,易于理解

6.1 DataExchangeQueue 核心设计

设计目标

markdown 复制代码
问题: Java 和 Python 进程如何高效传输大量数据?

候选方案对比:
  方案 1: 文件系统
    ├─ 优点: 简单、可靠
    └─ 缺点: 延迟高 (毫秒级)、吞吐量受限

  方案 2: 网络 Socket
    ├─ 优点: 支持远程
    └─ 缺点: 延迟高 (毫秒级)、有网络开销

  方案 3: 消息队列 (如 Redis)
    ├─ 优点: 功能丰富
    └─ 缺点: 延迟中等 (微秒级)、有序列化开销

  方案 4: 共享内存 (DataExchangeQueue)
    ├─ 优点: 延迟极低 (微秒级)、零拷贝、吞吐量高
    └─ 缺点: 仅限本机、需要复杂的并发控制

GeaFlow-Infer 的选择: 方案 4
原因: 推理通常在本机,对延迟敏感,需要高吞吐量

内存映射 (mmap) 的基础

scss 复制代码
┌──────────────────────────────────────────────────┐
│  传统的进程间通信 (IPC)                           │
├──────────────────────────────────────────────────┤
│ Java 进程              系统内核              Python 进程│
│    │                    │                         │
│    ├─ 写数据            │                         │
│    │  (系统调用)        │                         │
│    │   ↓                │                         │
│    │  [数据在内核缓冲] ←─→ [数据在内核缓冲]        │
│    │   ↓                │   ↓                     │
│    │  (系统调用)        │ (系统调用)              │
│    │                    │   ↓                     │
│    │                    │ 读数据                  │
│    │                    │   ↓                     │
│ 缺点: 多次数据拷贝,延迟高              结果进程接收│
└──────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│  基于 mmap 的共享内存通信                        │
├──────────────────────────────────────────────────┤
│ Java 进程              Linux 内核              Python 进程│
│    │                      │                        │
│    │ 1. mmap() 系统调用   │                        │
│    ├─→ 请求内存映射       │                        │
│    │      ↓               │                        │
│    │ 2. 返回虚拟地址      │                        │
│    │      ↓               │   3. mmap() 系统调用    │
│    │  获得虚拟地址 A ─────→ ←─ 获得虚拟地址 B      │
│    │  (不同地址,           │   (不同地址,           │
│    │   指向同一物理内存)    │   指向同一物理内存)    │
│    │                      │                        │
│    │ 4. 直接写内存        │                        │
│    ├─ *(void*)A = data   │ ← 直接读内存            │
│    │                      │    data = *(void*)B   │
│    │  (无系统调用,无拷贝!)│   (无系统调用,无拷贝!) │
│    │                      │                        │
│ 优点: 零拷贝、延迟微秒级、吞吐量高                    │
└──────────────────────────────────────────────────┘

内存映射的工作原理

ini 复制代码
Step 1: 创建共享内存文件

  Linux 文件系统
  /dev/shm/geaflow_queue_input
  (大小: 1MB, 内容: 全 0)

Step 2: Java 进程映射文件

  RandomAccessFile raf = new RandomAccessFile(file, "rw");
  FileChannel channel = raf.getChannel();
  MappedByteBuffer mbb = channel.map(
      MapMode.READ_WRITE, 0, file.length());
  
  结果:
    Java 虚拟地址空间: 0x1000 - 0x2000
    ↓
    页表 (Page Table)
    ↓
    物理内存: Frame 100-200
    ↓
    /dev/shm/geaflow_queue_input

Step 3: Python 进程映射文件

  import mmap
  with open('/dev/shm/geaflow_queue_input', 'r+b') as f:
      mm = mmap.mmap(f.fileno(), 0)
      # 或直接使用 mm[offset:offset+length]
  
  结果:
    Python 虚拟地址空间: 0x5000 - 0x6000
    ↓
    页表 (Page Table)
    ↓
    物理内存: Frame 100-200 (同一份!)

Step 4: 数据交换 (零拷贝!)

  Java:                  Python:
  mbb.put(index, data)  ← 直接写内存
                         data = mm[index]
                         (直接读取, 看到 Java 写入的值)

6.2 无锁队列实现原理

Ring Buffer 设计

ini 复制代码
┌────────────────────────────────────────┐
│    环形缓冲区 (Ring Buffer)             │
│    大小: 2^N = 1MB (对齐)              │
├────────────────────────────────────────┤
│                                        │
│  [写入数据1]                           │
│  [写入数据2]      ← 写指针指向这里      │
│  [写入数据3]                           │
│  [写入数据4]                           │
│  ...                                   │
│  [读取完毕的数据]  ← 读指针指向这里      │
│  [可写入的空间]                         │
│                                        │
│ 大小=2^20, 掩码=0xFFFFF                │
│ 指针值: 0 ~ 2^30 (不清零)             │
│ 实际地址: ptr & 掩码                   │
│                                        │
└────────────────────────────────────────┘

示例:
  queue size = 2^10 = 1024 bytes
  mask = 0x3FF = 1023
  
  write_ptr = 100,  实际地址 = 100 & 0x3FF = 100
  write_ptr = 1100, 实际地址 = 1100 & 0x3FF = 76  (绕一圈)
  write_ptr = 2100, 实际地址 = 2100 & 0x3FF = 52  (绕两圈)
  
  优点: 无需清零指针,&mask 自动处理环绕

无锁并发的关键

arduino 复制代码
问题: 多个 Writer 和 Reader 同时访问队列

┌──────────────────────────────────────┐
│  Writer Thread 1    Writer Thread 2   │
│       │                    │          │
│       └────→ Queue ←───────┘          │
│              │                        │
│              │                        │
│       ┌──────┘                        │
│       ↓                               │
│   Reader Thread                       │
│                                       │
│  不能用 Lock (synchronized)           │
│  原因:                                 │
│   - 太慢 (原子操作之间可能被中断)     │
│   - Java 锁只保证 JVM 内同步          │
│   - Python 的读取无法被保护          │
│                                       │
│  解决方案: 无锁算法                    │
│   - 使用原子操作 (CAS)                │
│   - 使用内存屏障                      │
│   - 利用 CPU 指令级别的原子性         │
└──────────────────────────────────────┘

内存屏障的作用

ini 复制代码
情景: Writer 写数据,Reader 读数据

没有内存屏障:
  Writer                      Reader
    │                          │
    ├─ write data[0]          │
    ├─ write data[1]          │
    ├─ write_ptr = 2 ✗        │
    │                         ├─ read_ptr ✓
    │ (reorder!)              ├─ read data[0]
    │ 实际执行顺序:           ├─ read data[1]
    │ 1. write_ptr = 2        │
    │ 2. write data[0]        │ 问题: Reader 看到
    │ 3. write data[1]        │ write_ptr=2 但数据还没写!

使用内存屏障:
  Writer                      Reader
    │                          │
    ├─ write data[0]          │
    ├─ write data[1]          │
    ├─ [内存屏障]             │
    │  ~~forcedFlush~~        │
    ├─ write_ptr = 2 ✓        │
    │                         ├─ [内存屏障]
    │                         ├─ read_ptr ✓
    │  顺序保证:              ├─ read data[0]
    │  1. write data[0]       ├─ read data[1]
    │  2. write data[1]       │
    │  3. 屏障完成            │ ✓ 正确: Reader 先等待
    │  4. write_ptr = 2       │       再读数据

Java Unsafe 中的方法:
  putOrderedLong()   ← Writer 使用,写屏障
  getVolatileLong()  ← Reader 使用,读屏障

6.3 内存布局与地址管理

共享内存的完整布局

scss 复制代码
┌─────────────────────────────────────────────────────┐
│   DataExchangeQueue 的内存布局                      │
├─────────────────────────────────────────────────────┤
│ [Cache Line 64字节]                                 │
│  ┌─────────────────────────────────┐               │
│  │ startPointAddress (8 bytes)      │ ← 标记开始   │
│  │ [填充到 Cache Line 边界]         │              │
│  └─────────────────────────────────┘               │
│                                                     │
│ [Cache Line 64字节]                                 │
│  ┌─────────────────────────────────┐               │
│  │ capacityAddress (8 bytes)        │ ← 队列大小   │
│  │ outputAddress (8 bytes)          │ ← 写指针    │
│  │ [填充到 Cache Line 边界]         │              │
│  └─────────────────────────────────┘               │
│                                                     │
│ [Cache Line 64字节]                                 │
│  ┌─────────────────────────────────┐               │
│  │ inputNextAddress (8 bytes)       │ ← 下次读    │
│  │ outputNextAddress (8 bytes)      │ ← 下次写    │
│  │ [填充到 Cache Line 边界]         │              │
│  └─────────────────────────────────┘               │
│                                                     │
│ [Cache Line 64字节]                                 │
│  ┌─────────────────────────────────┐               │
│  │ endPointAddress (8 bytes)        │ ← 标记结尾   │
│  │ [填充到 Cache Line 边界]         │              │
│  └─────────────────────────────────┘               │
│                                                     │
│ [Cache Line 64字节]                                 │
│  ┌─────────────────────────────────┐               │
│  │ barrierAddress (8 bytes)         │ ← 屏障      │
│  │ [填充到 Cache Line 边界]         │              │
│  └─────────────────────────────────┘               │
│                                                     │
│ [Cache Line 64字节]                                 │
│  ┌─────────────────────────────────┐               │
│  │ currentBufferAddress (8 bytes)   │ ← 当前缓冲   │
│  │ [填充到 Cache Line 边界]         │              │
│  └─────────────────────────────────┘               │
│                                                     │
│ [环形数据缓冲区] (实际大小: 通常 1MB)              │
│  ┌─────────────────────────────────────────────┐  │
│  │ [数据1] [数据2] [数据3] ... [数据N]         │  │
│  │ ^ read_ptr                  ^ write_ptr      │  │
│  └─────────────────────────────────────────────┘  │
│                                                     │
└─────────────────────────────────────────────────────┘

设计亮点:
  1. Cache Line 对齐 (64 字节)
     原因: 避免 False Sharing
     什么是 False Sharing?
       Writer Thread 在 CPU 0 上修改 write_ptr
       Reader Thread 在 CPU 1 上读 read_ptr
       如果两个指针在同一 Cache Line,
       CPU 0 的缓存失效会导致 CPU 1 的缓存也失效 (通过 Cache Coherency)
       使用 Cache Line 对齐,确保指针在不同 Line,避免这个开销
  
  2. 指针不清零
     原因: 简化逻辑
     write_ptr 可以不断增长: 0 → 1MB → 2MB → ...
     实际地址: ptr & mask (自动绕回)
     不需要检查"是否需要清零"
  
  3. 多个缓冲区地址
     原因: 支持多生产者-多消费者
     outputAddress: 下一个要写入的位置 (Writer 使用)
     inputNextAddress: 下一个要读取的位置 (Reader 使用)

地址计算

java 复制代码
public final class DataExchangeQueue {
    
    // 初始化时计算各地址
    public DataExchangeQueue(String mapKey, int capacity, 
        boolean reset) {
        
        // 1. 获取内存映射
        this.memoryMapper = new MemoryMapper(mapKey, bufferCapacity);
        this.mapAddress = memoryMapper.getMapAddress();
        
        // 2. 对齐到 Cache Line
        this.initialRawAddress = Pow2.align(mapAddress, 
            PortableJvmInfo.CACHE_LINE_SIZE);  // 通常 64 字节
        
        // 3. 计算各个字段的地址
        this.startPointAddress = initialRawAddress;
        this.capacityAddress = startPointAddress 
            + PortableJvmInfo.CACHE_LINE_SIZE;
        this.outputAddress = startPointAddress 
            + 2L * PortableJvmInfo.CACHE_LINE_SIZE;
        this.inputNextAddress = outputAddress + 8;  // 同一 cache line
        this.outputNextAddress = startPointAddress + 8;  // 另一 line
        this.endPointAddress = outputAddress 
            + PortableJvmInfo.CACHE_LINE_SIZE;
        this.barrierAddress = endPointAddress + 8;
        this.currentBufferAddress = barrierAddress + 8;
        
        // 4. 初始化队列
        if (reset) {
            reset();
        }
    }
    
    /**
     * 初始化队列状态
     */
    private void reset() {
        UNSAFE.putOrderedLong(null, startPointAddress, 0);
        UNSAFE.putOrderedLong(null, capacityAddress, queueCapacity);
        UNSAFE.putOrderedLong(null, outputAddress, 0);
        UNSAFE.putOrderedLong(null, inputNextAddress, 0);
        UNSAFE.putOrderedLong(null, outputNextAddress, 0);
    }
}

6.4 Unsafe 底层操作详解

Unsafe 是什么?

objectivec 复制代码
sun.misc.Unsafe 是 Java 提供的一个"后门" API
  ├─ 直接操纵内存 (绕过 GC, 安全检查)
  ├─ 提供原子操作 (CAS, volatile)
  ├─ 访问任意对象的任意字段
  └─ 原本用于 JVM 内部实现,现在被开发者广泛使用

Unsafe 的危险性:
  ⚠️  可能导致 JVM 崩溃
  ⚠️  破坏 Java 的内存安全
  ⚠️  代码难以维护
  ⚠️  Java 9+ 开始限制使用

但是,对于 geaflow-infer:
  ✓ 需要微秒级延迟,无法使用 Java Lock
  ✓ 需要跨进程通信,Unsafe 提供底层能力
  ✓ 开发者理解风险,谨慎使用

核心 Unsafe 操作

java 复制代码
// 1. 获取 Unsafe 实例 (反射)
Field field = sun.misc.Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
Unsafe UNSAFE = (Unsafe) field.get(null);

// 2. 写操作 (带内存屏障)
UNSAFE.putOrderedLong(object, offset, value);
// 相当于: *(long*)(object + offset) = value
// 带屏障: 确保写操作后的所有内存操作完成

// 3. 读操作 (带内存屏障)
long value = UNSAFE.getVolatileLong(object, offset);
// 相当于: return *(long*)(object + offset)
// 带屏障: 确保读操作前的所有内存操作完成

// 4. 写字节
UNSAFE.putByte(address, value);
UNSAFE.putByte(object, offset, value);

// 5. 读字节
byte value = UNSAFE.getByte(address);
byte value = UNSAFE.getByte(object, offset);

// 6. 比较并交换 (CAS - 无锁的基础)
boolean success = UNSAFE.compareAndSwapLong(
    object,       // 对象
    offset,       // 字段偏移
    expect,       // 期望值
    update        // 新值
);
// 原子操作: 如果 *(object+offset) == expect,
//          则设置为 update,返回 true
//          否则返回 false

实际使用示例

java 复制代码
// 来自 DataExchangeQueue 的实际代码

public class DataExchangeQueue {
    
    private static final Unsafe UNSAFE;
    private static final long OUTPUT_ADDRESS_OFFSET;
    private static final long INPUT_NEXT_ADDRESS_OFFSET;
    
    static {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            UNSAFE = (Unsafe) field.get(null);
            
            // 计算字段偏移 (用于后续 putOrderedLong/getVolatileLong)
            OUTPUT_ADDRESS_OFFSET = UNSAFE.objectFieldOffset(
                DataExchangeQueue.class.getDeclaredField("outputAddress")
            );
            INPUT_NEXT_ADDRESS_OFFSET = UNSAFE.objectFieldOffset(
                DataExchangeQueue.class.getDeclaredField("inputNextAddress")
            );
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    
    /**
     * 写入数据
     */
    public void put(byte[] data) {
        long writeIndex = UNSAFE.getVolatileLong(this, 
            OUTPUT_ADDRESS_OFFSET);
        long nextIndex = writeIndex + data.length;
        
        // 检查是否越界
        if ((nextIndex & ~mask) != (writeIndex & ~mask)) {
            throw new BufferFullException();
        }
        
        // 写数据到共享内存
        for (int i = 0; i < data.length; i++) {
            long address = initialRawAddress + (writeIndex + i) & mask;
            UNSAFE.putByte(address, data[i]);
        }
        
        // 更新写指针 (带屏障,其他进程能看到)
        UNSAFE.putOrderedLong(this, OUTPUT_ADDRESS_OFFSET, nextIndex);
    }
    
    /**
     * 读取数据
     */
    public byte[] get() {
        // 读写指针 (带屏障)
        long writeIndex = UNSAFE.getVolatileLong(this, 
            OUTPUT_ADDRESS_OFFSET);
        long readIndex = UNSAFE.getVolatileLong(this, 
            INPUT_NEXT_ADDRESS_OFFSET);
        
        if (writeIndex == readIndex) {
            return null;  // 无数据
        }
        
        // 获取数据长度 (存储在数据前 4 字节)
        long lenAddress = initialRawAddress + (readIndex & mask);
        int len = UNSAFE.getInt(lenAddress);
        
        // 读数据
        byte[] data = new byte[len];
        for (int i = 0; i < len; i++) {
            long address = initialRawAddress + 
                ((readIndex + i + 4) & mask);
            data[i] = UNSAFE.getByte(address);
        }
        
        // 更新读指针 (带屏障)
        UNSAFE.putOrderedLong(this, INPUT_NEXT_ADDRESS_OFFSET,
            readIndex + len + 4);
        
        return data;
    }
}

Unsafe 的性能特性

erlang 复制代码
操作延迟对比:

操作               延迟 (纳秒)
─────────────────────────────
普通字段访问         1-10 ns
Unsafe.getByte()    5-20 ns
Unsafe.getLong()    10-30 ns
getVolatileLong()   20-50 ns (带内存屏障)
putOrderedLong()    20-50 ns (带内存屏障)
synchronized Lock   100-500 ns
Lock (java.util.concurrent) 200-1000 ns

结论: 即使加上内存屏障,Unsafe 操作仍然比 Lock 快 10 倍以上

参考资源

相关推荐
神奇小汤圆8 小时前
浅析二叉树、B树、B+树和MySQL索引底层原理
后端
文艺理科生8 小时前
Nginx 路径映射深度解析:从本地开发到生产交付的底层哲学
前端·后端·架构
千寻girling8 小时前
主管:”人家 Node 框架都用 Nest.js 了 , 你怎么还在用 Express ?“
前端·后端·面试
南极企鹅8 小时前
springBoot项目有几个端口
java·spring boot·后端
Luke君607978 小时前
Spring Flux方法总结
后端
define95278 小时前
高版本 MySQL 驱动的 DNS 陷阱
后端
忧郁的Mr.Li9 小时前
SpringBoot中实现多数据源配置
java·spring boot·后端
暮色妖娆丶10 小时前
SpringBoot 启动流程源码分析 ~ 它其实不复杂
spring boot·后端·spring
Coder_Boy_10 小时前
Deeplearning4j+ Spring Boot 电商用户复购预测案例中相关概念
java·人工智能·spring boot·后端·spring
Java后端的Ai之路10 小时前
【Spring全家桶】-一文弄懂Spring Cloud Gateway
java·后端·spring cloud·gateway