使用QSBR进行安全的内存回收

使用QSBR进行安全的内存回收

在多线程场景下,经常我们需要并发访问一个数据结构,为了保证线程安全我们会考虑使用互斥设施来进行同步,更进一步我们会根据对这个数据结构的读写比例而选用读写锁进行优化。但是读写锁不是唯一的方式,我们可以借助于COW技术来做到写操作不需要加锁,也就是在读的时候正常读,写的时候,先加锁拷贝一份,然后进行写,写完就原子的更新回去,使用COW实现避免了频繁加读写锁本身的性能开销。

读写锁实现

class Server {
private:
    std::shared_mutex m_rwLock;                                   // Read-write lock
    std::vector<int> m_clients;                                   // List of connected clients

public:
    void broadcast(const void* msg, size_t len) {
        std::shared_lock<std::shared_mutex> shared(m_rwLock);     // Shared lock
        for (int fd : m_clients)
            send(fd, msg, len, 0);
    }

    void addClient(int fd) {
        std::unique_lock<std::shared_mutex> exclusive(m_rwLock);  // Exclusive lock
        m_clients.push_back(fd);
    }

    ...

broadcast的时候只是读取m_clients,所以加的是共享锁也就是读锁了,而addClient需要更改m_clients,所以使用的是排它锁。

COW实现

class Server {
private:
    struct ClientList {
        std::vector<int> clients;
    };

    std::atomic<ClientList*> m_currentList;      // The most up-to-date list

public:
    ...

使用COW的实现最为重要的就是要使用指针,因为更新副本后需要原子的替换之前的内容。如果不是指针就需要对象之间的拷贝,这很难做到原子。

void broadcast(const void* msg, size_t len) {
    ClientList* list = m_currentList.load();        // Atomic load from m_currentList
    for (int fd : list->clients)
        send(fd, msg, len);
}

broadcast的去除了读锁,其他的没有什么变化了,省去了加读锁本身的性能开销了。

void addClient(int fd) {
    ClientList* oldList = m_currentList.load();        // Atomic load from m_currentList
    ClientList* newList = new ClientList{*oldList};    // Make a private copy
    newList->clients.push_back(fd);                    // Modify it
    m_currentList.store(newList);                      // Publish the new copy

    // *** Note: Must do something with the old list here ***
}

addClient则是通过私有拷贝,然后在拷贝的List上进行操作,最后通过原子操作进行更新,更新完成后现在的指针就是指向更新后的内容,此后如果再有读操作拿到的就是更新后的数据了,在更新之前进行读操作还没有结束的读到的仍然是旧数据。上面的过程的可以简单的用下面这张图表示。

qsbr-replace-client-list

细心的读者可能会发现上面的的注释Note: Must do something with the old list here,当我们把指针指向更新的副本后,那么原来的那份内容该怎么办呢?如果是带有垃圾回收功能的语言这里是没有什么问题的,可惜C++不是,分配的内存需要自己去回收,但是此时可能还有其他的线程正在读取这块内存,冒然释放恐怕不妥,为此我们引入了一个内存回收的类,负责在必要的时候对内存进行有效的回收。

class Server {
    ...

    MemoryReclaimer m_reclaimer;

    ...

    void addClient(int fd) {
        ClientList* oldList = m_currentList.load();         // Atomic load from m_currentList
        ClientList* newList = new ClientList{*oldList};     // Make a private copy
        newList->clients.push_back(fd);                     // Modify it
        m_currentList.store(newList);                       // Publish the new copy

        m_reclaimer.addCallback([=](){ delete oldList });
    }

    ...

通过在写操作完成后,添加callback,用于释放内存,那么现在的问题是何时调用callback呢?,或许引用计数机制在这里是个不错的方案。不过本文要说的是另外一种方案,也就是本文的主题,QSBR(Quiescent State-Based Reclamation)

Quiescent State-Based Reclamation

这个方法的核心思想就是识别出线程的不活动(quiescent)状态,那么什么时候才算是不活动的状态呢?这个状态和临界区状态是相对的,线程离开临界区就是不活动的状态了。对于上面的broadcast来说,for循环执行结束后就是临界区结束的时候,因为此时不再读取m_currentList了。识别出不活动状态了,还需要把状态通知出去,让其他线程知道,这整个过程可以用下面的图来描述。

qsbr-timeline

上面有四个线程,线程1执行完更新操作后添加了释放内存的callback,此时线程2,3,4都读取的是之前的内容,等他们执行完成后分别回去调用onQuiescentState来表明自己已经不不活动了,等到最后一个线程调用onQuiescentState的时候就可以去调用注册的callback了。要实现上面这个过程其要点就是选择适合的位置执行onQuiescentState,还有就是如何知道谁是最后一个执行onQuiescentState的线程。

批量回收

如果更新的次数比较多的话,但是每次只回调一个callback,释放一次内存就会导致内存释放跟不上回收的速度,为此需要进行批量回收,每次更新都会注册新的callback,当第一次所有的线程都进入不活动状态的时候就把当前的所有callback保存起来,等待下一次所有线程进入不活动的状态的时候就回调前一次所有的callback,整个过程如下图。

qsbr-intervals

每个线程早操作的时候都需要先注册,这样就可以得到所有的线程信息,每个线程在设置不活动状态的时候通过标志位进行开关,这样就可以知道哪个线程是最后一个设置不活动的状态了。下面是一个简单的实现。

class MemoryReclaimer {
private:
    std::mutex m_mutex;
    std::vector<bool> m_threadWasQuiescent;
    std::vector<std::function<void()>> m_currentIntervalCallbacks;
    std::vector<std::function<void()>> m_previousIntervalCallbacks;
    size_t m_numRemaining = 0;

public:
    typedef size_t ThreadIndex;

    ThreadIndex registerThread() {
        std::lock_guard<std::mutex> guard(m_mutex);
        ThreadIndex id = m_threadWasQuiescent.size();
        m_threadWasQuiescent.push_back(false);
        m_numRemaining++;
        return id;
    }

    void addCallback(const std::function<void()>& callback) {
        std::lock_guard<std::mutex> guard(m_mutex);
        m_currentIntervalCallbacks.push_back(callback);
    }

    void onQuiescentState(ThreadIndex id) {
        std::lock_guard<std::mutex> guard(m_mutex);
        if (!m_threadWasQuiescent[id]) {
            m_threadWasQuiescent[id] = true;
            m_numRemaining--;
            if (m_numRemaining == 0) {
                // End of interval. Invoke all callbacks from the previous interval.
                for (const auto& callback : m_previousIntervalCallbacks) {
                    callback();
                }

                // Move current callbacks to previous interval.
                m_previousIntervalCallbacks = std::move(m_currentIntervalCallbacks);
                m_currentIntervalCallbacks.clear();

                // Reset all thread statuses.
                for (size_t i = 0; i < m_threadWasQuiescent.size(); i++) {
                    m_threadWasQuiescent[i] = false;
                }
                m_numRemaining = m_threadWasQuiescent.size();
            }
        }
    }
};

相关信息

本文提到的这种COW实现类似于linux内核中提到的RCU机制,两者在内存回收的策略上有所不同,RCU机制在用户空间也有其实现userspace RCU,而本文提到的QSBR只是一种内存回收策略,Tom Hart's在他的一篇文章中提到了多种回收策略Tom Hart’s 2005 thesis,典型像EBR(epoch-based reclamation)

Reference

本文是对文章 Using Quiescent States to Reclaim Memory的学习总结。

©️2020 CSDN 皮肤主题: 编程工作室 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值