自定义内存分配器实战:消除游戏主循环中的 GC 停顿

封面信息图

在 C#、Java 或使用标准堆分配(malloc / new)的游戏逻辑开发中,内存管理的不当往往是主循环掉帧卡顿的头号元凶。

托管堆垃圾回收器(Garbage Collector, GC)在执行“标记-清除-整理”(Mark-Sweep-Compact)周期时,常常需要触发“世界暂停”(Stop-The-World, STW),导致正在运行的游戏逻辑瞬间冻结 10 到 50 毫秒。即便在 C++ 等非托管语言中,频繁调用操作系统级的底层分配器,也会因为堆碎片化(Fragmentation)和多线程内存锁竞争带来不可预测的耗时尖峰。

消除主循环停顿的根本之道,是根据游戏数据的生命周期模式,构建特化型的自定义内存分配器(Custom Memory Allocator)

游戏数据的三大生命周期模型

游戏运行时的内存申请并非完全随机,90% 以上的数据分配都可以归结为以下三类:

  1. 单帧临时数据(Frame Allocations):例如当帧可见性剔除列表、碰撞检测临时射线结果、UI 顶点动态拼接。这些数据只在当前帧存活,帧末即可彻底废弃。
  2. 关卡阶段数据(Stage/Level Allocations):例如关卡加载时生成的导航网格、特定副本的剧情对话配置。生命周期与关卡场景严格绑定。
  3. 长期同构对象(Persistent Homogeneous Objects):例如上千发同类型子弹、上百个同构怪物实体。对象尺寸固定,频繁创建与销毁。

针对这些场景,通用堆分配器显得极其笨重。我们应当使用**线性分配器(Linear/Arena Allocator)池分配器(Pool Allocator)**来替代通用的堆分配。

线性分配器(Arena Allocator)的极速原理

线性分配器预先向系统申请一大块连续内存(例如 16MB)。

  • 分配时,仅需将内部的偏移指针(Offset Pointer)向前推进所请求的字节数,并做内存对齐(Alignment)。时间复杂度为绝对的 $O(1)$。
  • 释放时,不单独回收某个对象,而是在整帧结束或阶段切换时,将指针一次性归零(Reset)。整个过程不产生任何内存碎片,也没有任何链表遍历开销。

C++ 工业级内存对齐 Arena Allocator 实现

以下是一个具备 64 位指针对齐保障的 C++ Arena 分配器实现:

#include <cstdint>
#include <cstddef>
#include <cstdlib>
#include <new>
#include <cassert>

class ArenaAllocator {
public:
    explicit ArenaAllocator(size_t capacityBytes)
        : m_capacity(capacityBytes), m_offset(0) {
        // 分配大块底层连续物理内存
        m_buffer = static_cast<uint8_t*>(std::malloc(m_capacity));
        assert(m_buffer != nullptr && "Failed to allocate arena memory");
    }

    ~ArenaAllocator() {
        if (m_buffer) {
            std::free(m_buffer);
            m_buffer = nullptr;
        }
    }

    // 禁止拷贝
    ArenaAllocator(const ArenaAllocator&) = delete;
    ArenaAllocator& operator=(const ArenaAllocator&) = delete;

    /// <summary>
    /// 分配指定字节并满足 alignment 字节对齐约束
    /// </summary>
    void* Allocate(size_t size, size_t alignment = alignof(std::max_align_t)) {
        // 计算当前指针对齐所需的向上取整偏移
        uintptr_t currentAddress = reinterpret_cast<uintptr_t>(m_buffer + m_offset);
        size_t padding = 0;
        
        size_t modulo = currentAddress % alignment;
        if (modulo != 0) {
            padding = alignment - modulo;
        }

        size_t totalBytes = size + padding;
        if (m_offset + totalBytes > m_capacity) {
            // 内存耗尽
            return nullptr;
        }

        uint8_t* alignedAddress = m_buffer + m_offset + padding;
        m_offset += totalBytes;
        return alignedAddress;
    }

    /// <summary>
    /// 模板泛型便捷分配
    /// </summary>
    template <typename T, typename... Args>
    T* New(Args&&... args) {
        void* mem = Allocate(sizeof(T), alignof(T));
        if (!mem) return nullptr;
        return new (mem) T(std::forward<Args>(args)...); // 定位 new 构造
    }

    /// <summary>
    /// 极速重置:将整个缓冲区清空复用,耗时仅需几个时钟周期
    /// </summary>
    void Reset() {
        m_offset = 0;
    }

    size_t GetUsedBytes() const { return m_offset; }
    size_t GetCapacity() const { return m_capacity; }

private:
    uint8_t* m_buffer;
    size_t m_capacity;
    size_t m_offset;
};

C# 中借助非托管内存实现零 GC 帧分配器

在 C# 中,我们可以利用 NativeMemory.AllocSpan<T>,在托管堆之外构建属于自己的高性能 Frame Arena,彻底避开 GC 的追踪扫描:

using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

public unsafe class FrameArena : IDisposable
{
    private byte* _buffer;
    private readonly int _capacity;
    private int _offset;

    public FrameArena(int capacityBytes = 1024 * 1024 * 8) // 默认 8MB
    {
        _capacity = capacityBytes;
        _buffer = (byte*)NativeMemory.Alloc((nuint)_capacity);
        _offset = 0;
    }

    /// <summary>
    /// 获取一段连续的内存切片用于单帧临时计算,零托管堆分配
    /// </summary>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public Span<T> AllocateSpan<T>(int count) where T : unmanaged
    {
        int sizeInBytes = sizeof(T) * count;
        int alignment = alignof<T>();

        // 对齐计算
        int padding = (alignment - (_offset % alignment)) % alignment;
        int totalSize = sizeInBytes + padding;

        if (_offset + totalSize > _capacity)
            throw new OutOfMemoryException("FrameArena capacity exceeded!");

        byte* start = _buffer + _offset + padding;
        _offset += totalSize;

        return new Span<T>(start, count);
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Reset()
    {
        _offset = 0;
    }

    public void Dispose()
    {
        if (_buffer != null)
        {
            NativeMemory.Free(_buffer);
            _buffer = null;
        }
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static int alignof<T>() where T : unmanaged => Unsafe.SizeOf<T>() > 8 ? 8 : Unsafe.SizeOf<T>();
}

生产环境中的架构监控与防溢出

在使用自定义分配器时,需要建立两套安全防线:

  1. 高水位标记(High-Water Mark):在 Profiler 中持续监控每帧的内存峰值占用。当某帧的 Arena 占用达到警戒线(如容量的 80%)时触发 Warning,以便及时扩容,防止在复杂战斗场景下突发 OOM。
  2. 生命周期跨越防护:通过编译期注解或 Handle 封装,防止开发者将单帧 Arena 中分配的指针引用带入下一帧使用(Dangling Pointer)。任何需要跨帧存活的数据,必须显式拷贝到持久堆或持久 NativeArray 中。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐