线程安全设计:互斥锁、读写锁、无锁队列在显存池中的应用

说实话,多线程环境下的显存池设计,是我这些年踩坑最多的领域之一。你想想看,GPU显存本身就是一个稀缺资源,多个线程同时申请释放,稍不注意就是各种诡异崩溃。今天我就把这块硬骨头掰开了讲。

为什么显存池必须考虑线程安全?

深度学习框架的推理场景,往往是多线程并发的。比如TensorRT部署时,多个请求线程同时向显存池要内存。如果没有保护机制,两个线程同时修改空闲链表,轻则内存泄漏,重则直接Segmentation Fault。

我遇到过最离谱的一次,线上服务跑了三天突然挂掉。查到最后,就是显存池的free操作没有加锁,两个线程同时释放相邻的block,链表指针被写坏了。嗯,从那以后我再也不敢轻视线程安全了。

方案一:互斥锁(std::mutex)

最朴素的做法,就是给整个显存池加一把大锁。所有申请和释放操作,都得先拿到这把锁。

class MemoryPool {
    std::mutex mtx_;
    std::vector<void*> free_blocks_;
public:
    void* allocate(size_t size) {
        std::lock_guard<std::mutex> lock(mtx_);
        if (free_blocks_.empty()) {
            // 向GPU申请新内存
            return cudaMalloc(size);
        }
        auto ptr = free_blocks_.back();
        free_blocks_.pop_back();
        return ptr;
    }
    
    void deallocate(void* ptr) {
        std::lock_guard<std::mutex> lock(mtx_);
        free_blocks_.push_back(ptr);
    }
};

这种方案的好处是简单,不容易出错。但问题也很明显——锁的粒度太粗了。如果线程A在申请大块内存(比如512MB),线程B只能干等着。在高并发场景下,这会导致严重的性能瓶颈。

适用场景:并发度不高(比如4-8个线程),或者显存操作不频繁的场景。我个人习惯在原型验证阶段先用互斥锁,等性能瓶颈出现了再优化。

方案二:读写锁(std::shared_mutex)

读写锁的思路是:读操作可以共享,写操作必须独占。在显存池里,什么算读?什么算写?

  • 读操作:查询空闲列表大小、检查某个地址是否属于本池
  • 写操作:申请内存(修改空闲列表)、释放内存(修改空闲列表)

说白了,大部分场景下读写锁并不比互斥锁好多少。因为显存池的核心操作——申请和释放——都是写操作。只有在「先检查再操作」这种场景下,读写锁才有优势。

class MemoryPool {
    mutable std::shared_mutex rw_mtx_;
    std::vector<void*> free_blocks_;
public:
    bool has_free_block() const {
        std::shared_lock lock(rw_mtx_);
        return !free_blocks_.empty();
    }
    
    void* allocate() {
        std::unique_lock lock(rw_mtx_);
        // 实际分配逻辑
    }
};

我的经验:读写锁在显存池中实际收益有限。除非你的场景是「大量线程频繁检查空闲状态,但很少实际申请释放」,否则直接用互斥锁反而更简单高效。

方案三:无锁队列(Lock-Free Queue)

这才是真正的高性能方案。无锁队列利用CAS(Compare-And-Swap)原子操作,避免了线程上下文切换的开销。

我曾在PyTorch的CachingAllocator里看到类似的设计。核心思路是:每个线程有自己的本地缓存,优先从本地拿;本地不够了,再从全局无锁队列里取。

class LockFreeMemoryPool {
    std::atomic<BlockHeader*> head_;
    
    void* allocate() {
        BlockHeader* old_head;
        BlockHeader* new_head;
        do {
            old_head = head_.load();
            if (!old_head) return nullptr;
            new_head = old_head->next;
        } while (!head_.compare_exchange_weak(old_head, new_head));
        return old_head;
    }
    
    void deallocate(void* ptr) {
        BlockHeader* block = reinterpret_cast<BlockHeader*>(ptr);
        BlockHeader* old_head;
        do {
            old_head = head_.load();
            block->next = old_head;
        } while (!head_.compare_exchange_weak(old_head, block));
    }
};

注意:无锁队列不是银弹。ABA问题、内存回收问题(怎么安全地释放节点?)、以及多生产者多消费者场景下的正确性验证,都非常复杂。我曾经花了两周调试一个无锁队列的bug,最后发现是内存序(memory order)用错了。

三种方案的对比

方案 性能 实现复杂度 适用场景
互斥锁 中等 低 低并发、原型验证
读写锁 中等 中 读多写少的特殊场景
无锁队列 高 高 高并发、延迟敏感

实战中的选择建议

我个人建议这样选:

  1. 第一步:先用互斥锁把功能跑通。别一上来就搞无锁,bug查死你。
  2. 第二步:用perf工具分析锁竞争情况。如果锁等待时间占比超过5%,再考虑优化。
  3. 第三步:如果确实需要优化,优先考虑「线程本地缓存+全局互斥锁」的混合方案。这比纯无锁队列容易实现得多。

你想想看,很多框架的显存池其实都是这么设计的。PyTorch的CachingAllocator就是每个线程一个缓存,全局用互斥锁保护。效果已经很好了。

核心逻辑流程图

下面这张图展示了三种方案在显存池中的工作流程对比:

显存池线程安全方案对比 互斥锁方案 线程A:请求锁 线程B:等待锁 线程A:操作显存池 线程A:释放锁 线程B:获得锁 优点:实现简单 缺点:并发度低 读写锁方案 线程A:读锁(可共享) 线程B:读锁(可共享) 线程C:写锁(独占) 线程D:等待写锁 优点:读操作可并发 缺点:写操作仍串行 显存池写操作居多 无锁队列方案 线程A:CAS操作 线程B:CAS操作 线程C:CAS操作 线程D:CAS操作 优点:无锁竞争 延迟低、吞吐高 缺点:实现复杂 ABA问题需处理

从流程图可以清楚看到:互斥锁是串行化的,读写锁在读操作时允许并发,而无锁队列则让所有线程都能同时操作。但别忘了,无锁队列的每个CAS操作都可能失败重试,这在高冲突场景下反而会降低性能。

总结一下:线程安全设计没有银弹。互斥锁简单可靠,读写锁适合特定场景,无锁队列性能上限高但实现门槛也高。我的建议是——从简单的开始,用数据说话,哪里瓶颈优化哪里。

想要更多资料,联系可可