c++ - Windows 与 Linux - C++ 线程池内存使用情况

标签 c++ multithreading memory-management

我一直在研究 Windows 和 Linux (Debian) 中一些 C++ REST API 框架的内存使用情况。我特别看过这两个框架:cpprestsdkcpp-httplib .在这两者中,都创建了一个线程池并用于为请求提供服务。
我从 cpp-httplib 中获取了线程池实现并将其放在下面的最小工作示例中,以显示我在 Windows 和 Linux 上观察到的内存使用情况。

#include <cassert>
#include <condition_variable>
#include <functional>
#include <iostream>
#include <list>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>

using namespace std;

// TaskQueue and ThreadPool taken from https://github.com/yhirose/cpp-httplib
class TaskQueue {
public:
    TaskQueue() = default;
    virtual ~TaskQueue() = default;

    virtual void enqueue(std::function<void()> fn) = 0;
    virtual void shutdown() = 0;

    virtual void on_idle() {};
};

class ThreadPool : public TaskQueue {
public:
    explicit ThreadPool(size_t n) : shutdown_(false) {
        while (n) {
            threads_.emplace_back(worker(*this));
            cout << "Thread number " << threads_.size() + 1 << " has ID " << threads_.back().get_id() << endl;
            n--;
        }
    }

    ThreadPool(const ThreadPool&) = delete;
    ~ThreadPool() override = default;

    void enqueue(std::function<void()> fn) override {
        std::unique_lock<std::mutex> lock(mutex_);
        jobs_.push_back(fn);
        cond_.notify_one();
    }

    void shutdown() override {
        // Stop all worker threads...
        {
            std::unique_lock<std::mutex> lock(mutex_);
            shutdown_ = true;
        }

        cond_.notify_all();

        // Join...
        for (auto& t : threads_) {
            t.join();
        }
    }

private:
    struct worker {
        explicit worker(ThreadPool& pool) : pool_(pool) {}

        void operator()() {
            for (;;) {
                std::function<void()> fn;
                {
                    std::unique_lock<std::mutex> lock(pool_.mutex_);

                    pool_.cond_.wait(
                        lock, [&] { return !pool_.jobs_.empty() || pool_.shutdown_; });

                    if (pool_.shutdown_ && pool_.jobs_.empty()) { break; }

                    fn = pool_.jobs_.front();
                    pool_.jobs_.pop_front();
                }

                assert(true == static_cast<bool>(fn));
                fn();
            }
        }

        ThreadPool& pool_;
    };
    friend struct worker;

    std::vector<std::thread> threads_;
    std::list<std::function<void()>> jobs_;

    bool shutdown_;

    std::condition_variable cond_;
    std::mutex mutex_;
};

// MWE
class ContainerWrapper {
public:
    ~ContainerWrapper() {
        cout << "Destructor: data map is of size " << data.size() << endl;
    }

    map<pair<string, string>, double> data;
};

void handle_post() {
    
    cout << "Start adding data, thread ID: " << std::this_thread::get_id() << endl;

    ContainerWrapper cw;
    for (size_t i = 0; i < 5000; ++i) {
        string date = "2020-08-11";
        string id = "xxxxx_" + std::to_string(i);
        double value = 1.5;
        cw.data[make_pair(date, id)] = value;
    }

    cout << "Data map is now of size " << cw.data.size() << endl;

    unsigned pause = 3;
    cout << "Sleep for " << pause << " seconds." << endl;
    std::this_thread::sleep_for(std::chrono::seconds(pause));
}

int main(int argc, char* argv[]) {

    cout << "ID of main thread: " << std::this_thread::get_id() << endl;

    std::unique_ptr<TaskQueue> task_queue(new ThreadPool(40));

    for (size_t i = 0; i < 50; ++i) {
        
        cout << "Add task number: " << i + 1 << endl;
        task_queue->enqueue([]() { handle_post(); });

        // Sleep enough time for the task to finish.
        std::this_thread::sleep_for(std::chrono::seconds(5));
    }

    task_queue->shutdown();

    return 0;
}
当我运行这个 MWE 并查看 Windows 与 Linux 中的内存消耗时,我得到了下图。对于 Windows,我使用了 perfmon获取私有(private)字节值。在 Linux 中,我使用了 docker stats --no-stream --format "{{.MemUsage}}记录容器的内存使用情况。这符合res对于来自 top 的过程在容器内运行。从图中可以看出,当一个线程为 map 分配内存时Windows 中的变量 handle_post函数,当函数在下一次调用函数之前退出时,内存会被返还。这是我天真地期待的行为类型。我没有关于操作系统如何处理当线程保持事件状态时正在线程中执行的函数分配的内存的经验,例如在线程池中。在 Linux 上,看起来内存使用量一直在增长,并且在函数退出时不会返回内存。当所有 40 个线程都被使用,并且还有 10 个任务要处理时,内存使用量似乎停止增长。有人可以从内存管理的角度对 Linux 中发生的事情给出一个高层次的看法,甚至可以提供一些关于在哪里寻找关于这个特定主题的背景信息的提示吗?
编辑 1 :我编辑了下图以显示 rss 的输出值从运行 ps -p <pid> -h -o etimes,pid,rss,vsz Linux 容器中的每一秒,其中 <pid>是正在测试的进程的 ID。与docker stats --no-stream --format "{{.MemUsage}}的输出相符.
win_v_lin_50_seq_tasks_40_threads_rss
编辑 2 :基于下面关于 STL 分配器的评论,我通过替换 handle_post 从 MWE 中删除了映射具有以下功能并添加包含 #include <cstdlib>#include <cstring> .现在,handle_post函数只是为 500K 分配和设置内存 int s 大约为 2MiB。
void handle_post() {
    
    size_t chunk = 500000 * sizeof(int);
    if (int* p = (int*)malloc(chunk)) {

        memset(p, 1, chunk);
        cout << "Allocated and used " << chunk << " bytes, thread ID: " << this_thread::get_id() << endl;
        cout << "Memory address: " << p << endl;

        unsigned pause = 3;
        cout << "Sleep for " << pause << " seconds." << endl;
        this_thread::sleep_for(chrono::seconds(pause));

        free(p);
    }
}
我在这里得到相同的行为。在示例中,我将线程数减少到 8 个,将任务数减少到 10 个。下图显示了结果。
编辑 3 :我添加了在 Linux CentOS 机器上运行的结果。它与 Debian docker 镜像结果的结果基本一致。
8_threads_10_seq_tasks_e3
编辑 4 :基于下面的另一条评论,我在 valgrind 下运行了示例的 massif工具。 massif命令行参数在下图中。我用 --pages-as-heap=yes 运行它,下面的第二张图片,如果没有这个标志,下面的第一张图片。第一个图像表明 ~2MiB 内存分配给(共享)堆作为 handle_post函数在线程上执行,然后在函数退出时释放。这是我所期望的,也是我在 Windows 上观察到的。我不知道如何用 --pages-as-heap=yes 解释图表然而,即第二张图片。
我无法协调 massif 的输出在第一个图像中,值为 rss来自 ps上图中显示的命令。如果我运行 Docker 镜像并使用 docker run --rm -it --privileged --memory="12m" --memory-swap="12m" --name=mwe_test cpp_testing:1.0 将容器内存限制为 12MB ,容器在第 7 次分配时内存不足,并被操作系统杀死。我收到 Killed在输出中,当我查看 dmesg 时,我看到Killed process 25709 (cpp_testing) total-vm:529960kB, anon-rss:10268kB, file-rss:2904kB, shmem-rss:0kB .这表明 rss值来自 ps准确地反射(reflect)了进程实际使用的(堆)内存,而 massif工具正在计算它应该基于的内容 malloc/newfree/delete调用。这只是我从这个测试中得到的基本假设。我的问题仍然存在,即为什么在 handle_post 时堆内存没有被释放或解除分配,或者看起来没有被释放或释放。函数退出?
massif_output
编辑 5 :当您将线程池中的线程数从 1 增加到 4 时,我在下面添加了内存使用情况图。当您将线程数增加到 10 时,该模式继续进行,因此我没有包括 5 到 10。注意我在 main 开始时添加了 5 秒的暂停这是图表中前 ~5 秒的初始平线。看起来,无论线程数如何,在处理第一个任务后都会释放内存,但在任务 2 到 10 之后没有释放内存(保留以供重用?)。这可能表明在执行期间调整了某些内存分配参数任务 1 执行(只是大声思考!)?
increase_num_threads
编辑 6 : 根据详细回答中的建议below ,我设置了环境变量MALLOC_ARENA_MAX在运行示例之前到 1 和 2。这给出了下图中的输出。这是基于答案中对该变量影响的解释所预期的。
effect_of_malloc_arena_max

最佳答案

许多现代分配器,包括您正在使用的 glibc 2.17 中的分配器,使用多个 arena(一种跟踪空闲内存区域的结构)以避免想要同时分配的线程之间的争用。
释放回一个arena的内存不能被另一个arena分配(除非触发了某种类型的跨arena传输)。
默认情况下,每次新线程进行分配时,glibc 都会分配新的 arenas,直到达到预定义的限制(默认为 8 * CPU 数),如您所见 examining the code .
这样做的一个后果是,在一个线程上分配然后释放的内存可能无法用于其他线程,因为它们使用不同的区域,即使该线程没有做任何有用的工作。
您可以尝试设置 glibc malloc tunable glibc.malloc.arena_max1为了强制所有线程进入同一个领域,看看它是否改变了你正在观察的行为。
请注意,这与用户空间分配器(在 libc 中)有关,而与操作系统的内存分配无关:操作系统永远不会被告知内存已被释放。即使您强制使用单个 arena,也不意味着用户空间分配器将决定通知操作系统:它可能只是保留内存以满足 future 的请求(也有调整此行为的可调参数)。
但是,在您的测试中使用单个 arena 应该足以防止不断增加的内存占用,因为内存在下一个线程启动之前被释放,因此我们希望它被下一个任务重用,该任务在不同的线程上启动。
最后,值得指出的是,发生的事情高度依赖于条件变量如何通知线程:大概 Linux 使用 FIFO 行为,其中最近排队(等待)的线程将是最后被通知的。这会导致您在添加任务时循环遍历所有线程,从而创建许多竞技场。更有效的模式(出于各种原因)是 LIFO 策略:将最近排队的线程用于下一个作业。这将导致在您的测试中重复使用相同的线程并“解决”问题。
最后说明:许多分配器,但不是您正在使用的旧版 glibc 中的分配器,也实现了每线程缓存,允许分配快速路径在没有任何原子操作的情况下进行。这可以产生与使用多个领域类似的效果,并且随着线程的数量不断扩展。

关于c++ - Windows 与 Linux - C++ 线程池内存使用情况,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/63454529/

相关文章:

java - 传入和传出蓝牙连接的并发线程

java - Pragma Pack 使用 C 库导致 jvm 崩溃

c - 这个自定义的malloc可以吗?

c++ - openCV 创建不同大小的 3D 矩阵

C++11 unordered_set with std::owner_less-like hashing

java - 如何在服务器模式下将 OpenOffice 用作多线程服务?

android - 为什么我所有的位图都上采样了 200%?

c++ - 如何使用 sort() 对任意多维 vector 进行排序?

c++ - 如何检测 atof 或 _wtof 是否失败?

java - 使用多线程在数组中查找质数