【问题标题】:Windows vs Linux - C++ Thread Pool Memory UsageWindows vs Linux - C++ 线程池内存使用
【发布时间】:2020-08-17 16:02:30
【问题描述】:

我一直在研究 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 Bytes 值。在 Linux 中,我使用docker stats --no-stream --format "{{.MemUsage}} 来记录容器的内存使用情况。这与res 在容器内运行的来自top 的进程一致。从图中可以看出,当线程在 handle_post 函数中为 Windows 中的 map 变量分配内存时,当函数在下一次调用该函数之前退出时,内存被归还 .这是我天真地期待的行为类型。我没有关于操作系统如何处理线程保持活动状态时在线程中执行的函数分配的内存的经验,例如在线程池中。在 Linux 上,内存使用量似乎在不断增长,并且在函数退出时内存没有归还。当所有 40 个线程都已使用,并且还有 10 个任务要处理时,内存使用量似乎停止增长。有人可以从内存管理的角度对 Linux 中正在发生的事情给出一个高层次的看法,甚至可以提供一些关于在哪里可以找到关于这个特定主题的背景信息的一些指针?

编辑 1:我已编辑下图以显示在 Linux 容器中每秒运行 ps -p &lt;pid&gt; -h -o etimes,pid,rss,vszrss 的输出值,其中 &lt;pid&gt; 是正在运行的进程的 id测试。与docker stats --no-stream --format "{{.MemUsage}}的输出有合理的一致性。

编辑 2:根据下面关于 STL 分配器的评论,我通过将 handle_post 函数替换为以下内容并添加包含 #include &lt;cstdlib&gt;#include &lt;cstring&gt;,从 MWE 中删除了映射。现在,handle_post 函数只是为 500K ints 分配和设置内存,大约是 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 镜像结果的结果大体一致。

编辑 4:根据下面的另一条评论,我在 valgrindmassif 工具下运行了示例。 massif 命令行参数在下图中。我用--pages-as-heap=yes 运行它,下面的第二张图片,没有这个标志,下面的第一张图片。第一张图片表明~2MiB 内存分配给(共享)堆,因为handle_post 函数在线程上执行,然后在函数退出时释放。这是我所期望的,也是我在 Windows 上观察到的。我还不确定如何用--pages-as-heap=yes 解释图表,即第二张图片。

我无法将第一张图像中massif 的输出与上图中显示的ps 命令中的rss 的值相一致。如果我运行 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。这表明来自psrss 值准确地反映了进程正在使用的(堆)内存实际,而massif 工具正在计算它应该 em> 基于malloc/newfree/delete 调用。这只是我从这个测试中得到的基本假设。我的问题仍然存在,即为什么,或者看起来,当handle_post 函数退出时堆内存没有被释放或释放?

编辑 5:当您将线程池中的线程数从 1 增加到 4 时,我在下面添加了内存使用图。随着您增加线程数,该模式继续到 10,所以我没有包括 5 到 10。请注意,我在 main 的开头添加了 5 秒的暂停,这是图表中前约 5 秒的初始平线。看来,无论线程数如何,在处理第一个任务后都会释放内存,但在任务 2 到 10 之后没有释放内存(保留以供重用?)。这可能表明在处理期间调整了某些内存分配参数任务 1 执行(只是大声思考!)?

编辑6:根据详细答案below的建议,我在运行示例之前将环境变量MALLOC_ARENA_MAX设置为1和2。这给出了下图中的输出。根据答案中给出的对这个变量的影响的解释,这是预期的。

【问题讨论】:

  • 我首先想到的一个操作系统差异是 Windows 的默认堆栈大小为 1 MB。 linux它更大。我相信 8 到 10MB。
  • @drescherjm 谢谢。如果我将handle_post 中添加到地图的元素数量从 5K 增加到 50K,则 Linux 行在扁平化之前线性增长到 ~140 MB,而 Windows 行在处理每个任务后保持在 ~13 MB 并下降。这让我感到困惑。即使在函数退出时调用了包装映射的结构的析构函数,分配的内存似乎仍然在 Linux 上发挥作用。同样,我在这里的经验很少,所以可能只是有一个非常基本的误解。
  • 你激起了我的好奇心:我查找了 MemUsagedocker stats 的含义,我只能找到:docs.docker.com/engine/reference/commandline/stats 没有帮助。从字面上看,有几十种不同的方法来衡量一个进程“使用”了多少“内存”,具体取决于 Windows 和 Linux 版本的 docker 报告的“内存使用情况”。您可能会将苹果与建筑进行比较。
  • @SolomonSlow 感谢您的关注。 docker stats 输出与在容器内运行 top 并查看 res 列的输出一致。 restop 值稍微低一些,因为它只是被监控的进程本身。我可以编辑问题并使用此结果更新图表。我可以访问运行 CentOS 的 Linux 服务器,因此我也可以从那里添加结果。使用这些统计信息更新问题可能很有用。
  • 假设没有内存泄漏(检查 valgrind),我已经看到实现提供的 STL 容器分配器会发生这种情况。它可能会分配一些空间,除非需要,否则不会将其归还。对于踢球和咯咯笑,尝试比较调试/发布版本。在操作系统之前,我会怀疑 STL、docker 和 malloc,尽管我承认我不知道是否存在 Linux 对回收进程释放的内存如此漠不关心的情况。

标签: c++ multithreading memory-management


【解决方案1】:

许多现代分配器,包括您正在使用的 glibc 2.17 中的分配器,都使用多个 arenas(一种跟踪空闲内存区域的结构)以避免想要在同一时间。

释放回一个竞技场的内存不能被另一个竞技场分配(除非触发了某种类型的跨竞技场传输)。

默认情况下,glibc 将在每次新线程进行分配时分配新的 arenas,直到达到预定义的限制(默认为 8 * CPU 数量),如 examining the code 所示。

这样做的一个后果是,在一个线程上分配然后释放的内存可能对其他线程不可用,因为它们正在使用单独的区域,即使该线程没有做任何有用的工作。

您可以尝试将glibc malloc tunable glibc.malloc.arena_max 设置为1 以强制所有线程到同一个arena,看看它是否会改变您观察到的行为。

请注意,这与用户空间分配器(在 libc 中)有关,与操作系统的内存分配无关:永远不会通知操作系统内存已被释放。 即使您强制使用单个竞技场,这并不意味着用户空间分配器将决定通知操作系统:它可能只是保留内存以满足未来的请求(有可调参数来调整这个行为也)。

但是,在您的测试中,使用单个 arena 应该足以防止不断增加的内存占用,因为内存在下一个线程启动之前被释放,因此我们希望它被下一个任务重用,该任务在不同的线程。

最后,值得指出的是,会发生什么很大程度上取决于条件变量如何通知线程:大概 Linux 使用 FIFO 行为,其中最近排队(等待)的线程将是最后一个被通知的线程.这会导致您在添加任务时循环遍历所有线程,从而创建许多竞技场。一种更有效的模式(出于各种原因)是 LIFO 策略:将最近排队的线程用于下一个作业。这会导致同一个线程在您的测试中重复使用并“解决”问题。

最后说明:许多分配器,但不是您正在使用的旧版本 glibc 中的 on,还实现了 每线程缓存,它允许分配快速路径在没有 的情况下继续进行任何 原子操作。这可以产生与使用多个 arena 类似的效果,并且随着线程数的增加而不断扩展。

【讨论】:

  • 非常感谢您提供的信息,我不知道这一点。不过,这似乎不是我的示例的问题。我使用的是 GNU libc 2.17 版,并且直到 2.26 版才引入每线程缓存。我试了一下以防万一,即GLIBC_TUNABLES=glibc.malloc.tcache_count=0export GLIBC_TUNABLES 然后运行测试,结果是一样的,即内存增长到~16MiB。我从rss 图表中注意到高水平的一件事是,在第一个任务完成后内存下降,为接下来的 8 个任务增长,并且似乎在第 10 个任务上重用内存。
  • @Francis - 如果您执行相同的分配模式但在单个线程上,行为看起来是否相同?您能否在程序中添加一个 threadid 输出来检查线程池正在选择哪些线程(即,如果我的 FIFO 假设是正确的)?即使没有线程缓存,许多分配器也会尝试实现“每 CPU”缓存或类似的东西,这可能会使用线程 ID 作为近似值,因此 malloc 可能仍在不同的领域分配东西。
  • 我在原始问题的末尾添加了一个编辑,即 Edit 5piece,它显示了线程池中从一个线程增加到四个线程时的内存使用情况。正如我所看到的,无论线程数如何,行为都几乎相同,即在第一个任务之后释放的内存,但在后续任务中没有。如果malloc 足够大,每个任务都会释放内存。但是,使用map 代替malloc 并在handle_post 中添加它,内存使用量可以任意大而不会释放。我几乎在所有运行中都观察到了 FIFO 行为,但并非总是如此。
  • @Francis - 你有多少个逻辑核心?无论如何,我更新了我的答案,因为似乎即使是旧版本的 glibc 仍然“主要”使用每个线程领域,每个核心最多 8 个,这将产生与每个线程缓存相同的效果。您也可以使用一个可调参数来调整它,看看是否是您的问题。
  • 非常感谢这个详细的回答。我在问题中添加了一个编辑 Edit 6,该问题显示了将 MALLOC_ARENA_MAX 设置为 1 和 2 而不是设置它并允许它默认的效果。根据阅读您的答案,该图表符合预期。我的测试服务器上的逻辑核心数是 32(=> max arena = 256),而我的本地 Windows 机器(在 Docker 容器中运行示例)上的数量是 8(=> max arena = 64)。再次感谢。
猜你喜欢
  • 2015-07-15
  • 1970-01-01
  • 2010-12-13
  • 2010-09-24
  • 2011-12-05
  • 2016-02-24
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多