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

5
我一直在研究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}}记录容器的内存使用情况。这与容器内运行的top进程的res相一致。从图表上看,在Windows中,当线程为handle_post函数中的map变量分配内存时,在函数退出之前给出内存,直到下一次调用该函数。这是我天真地期望的行为类型。我没有关于操作系统如何处理由在线程中执行的函数分配的内存的经验,当线程保持活动状态时,即像这里在线程池中。在Linux上,似乎内存使用情况不断增长,并且在函数退出时内存不会被归还。当使用了所有40个线程并且还有10个任务需要处理时,内存使用情况似乎停止增长。有人能够从内存管理的角度高层次地介绍在Linux中发生了什么,甚至提供一些指向特定主题背景信息的指针吗?

编辑1:我已经编辑下面的图表,以显示在运行ps -p <pid> -h -o etimes,pid,rss,vsz命令时每秒输出的rss值,在Linux容器中进行测试的进程的id为<pid>。它与docker stats --no-stream --format "{{.MemUsage}}的输出基本一致。

win_v_lin_50_seq_tasks_40_threads_rss

编辑2: 根据下面关于STL分配器的评论,我通过用以下内容替换handle_post函数并添加#include <cstdlib>#include <cstring>来删除了MWE中的映射。现在,handle_post函数只是为大约500K个int分配并设置内存,大约为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:根据下面的另一条评论,我在valgrindmassif工具下运行了示例。 massif命令行参数如下图所示。我使用--pages-as-heap=yes参数运行了第二张图片,没有使用这个参数的是第一张图片。第一张图片表明当handle_post函数在一个线程上执行并在函数退出时被释放时,大约分配了 ~2MiB 的内存到(共享)堆中。这是我预期的,并且在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,那么容器将在第七次分配内存时耗尽内存并被操作系统杀死。输出中会出现 Killed,当我查看 dmesg 时,我看到 Killed process 25709 (cpp_testing) total-vm:529960kB, anon-rss:10268kB, file-rss:2904kB, shmem-rss:0kB。这表明 ps 中的 rss 值准确地反映了进程实际使用的(堆)内存,而 massif 工具则基于 malloc/newfree/delete 调用计算应该使用的内存。这只是我从这个测试中得出的基本假设。我的问题仍然存在,即:当 handle_post 函数退出时,为什么堆内存没有被释放或销毁?

massif_output

编辑5:我在下面添加了一张图表,展示了当你将线程池中的线程数从1个增加到4个时内存使用情况的变化。随着线程数量的增加,这种模式会持续下去,所以我没有包括5到10个线程。请注意,在main的开始处我添加了一个5秒的暂停,这是图表中前约5秒钟的初始水平线。似乎无论线程数如何,第一个任务处理后都会释放一些内存,但在2到10个任务之后该内存不会被释放(保留以供重用?)。这可能表明在任务1执行期间调整了某些内存分配参数(只是随口想想!)?

increase_num_threads

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

effect_of_malloc_arena_max


@drescherjm 谢谢。如果我将handle_post中添加到地图中的元素数量从5K增加到50K,则Linux行会呈线性增长,直到达到约140 MB才会变平,而Windows行则保持在约13 MB,并在处理每个任务后下降。这就是让我感到困惑的地方。尽管在函数退出时包装地图的结构体的析构函数被调用,但似乎分配的内存仍然在使用中。再次说明,我在这方面经验很少,可能只是有一个非常基本的误解。 - Francis
Valgrind应该有选项来显示实际的内存使用情况(从分配和释放计算)。Linux内核可能会保留进程的页面,但不会提交,并在实际需要时才回收它们。内存使用量达到最大值的事实可能是这种行为的指标。尝试在进程仍处于活动状态时对系统进行内存压力测试,看看Linux是否会减少其内存使用量。 - Margaret Bloom
@MargaretBloom 感谢您的反馈和建议。我已经使用 valgrindmassif 工具更新了问题的输出。使用 --pages-as-heap=no 的结果让我感到困惑。它显示了我所期望的行为,但是当我在限制容器内存后对其进行压力测试时,操作系统会杀死该进程。如果我根据 massif 输出来判断,我会期望容器的内存使用量保持在 ~2MB 左右。可能只是我对 ps 中的 rss 显示的内容与进程在堆上分配的内存量之间存在一些理解上的差距。 - Francis
那么肯定还有其他问题 :/ 我不确定容器化不是问题所在。也许 cgroups(对容器施加限制的实际组件)没有考虑可回收内存。 - Margaret Bloom
@MargaretBloom 再次感谢,这非常有帮助。一开始我并没有完全理解你的第一条评论。我做了一些阅读,看到了这篇文章:https://lemire.me/blog/2020/03/03/calling-free-or-delete/。我认为这与您的建议是相同的。当我在 handle_post 中调用 free 时,内存不一定会被返回给操作系统,因此 massifrss 之间存在差异。如果内存大小增加,并且在 Linux 机器上运行,您觉得操作系统是否会在开始耗尽内存时重新获取已释放的空间? - Francis
显示剩余7条评论
1个回答

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

非常感谢您提供的信息,我之前并不知道这个。但是这似乎与我的示例无关。我正在使用GNU libc 2.17版本,而每个线程缓存是在2.26版本中引入的。我还是试了一下,即GLIBC_TUNABLES=glibc.malloc.tcache_count=0export GLIBC_TUNABLES,然后运行测试,结果仍然相同,即内存增长到约16MiB。从“rss”图表中可以看出,一个任务完成后内存会下降,接下来的8个任务内存会增长,并且在第10个任务时似乎会重用内存。 - Francis
@Francis - 如果您在单个线程上执行相同的分配模式,行为是否相同?您能否添加一个线程ID输出到您的程序中,以检查线程池选择哪些线程(即,如果我的FIFO假设是正确的)?即使没有线程缓存,许多分配器也会尝试实现“每个CPU”缓存或类似的东西,这可能使用线程ID作为近似值,因此malloc仍然可能在不同的区域中分配内容。 - BeeOnRope
1
非常感谢您提供如此详细的答案。我在问题中添加了一个编辑,即Edit 6,展示了将MALLOC_ARENA_MAX设置为1和2与不设置并允许其默认值的效果。根据阅读您的答案,图表符合预期。我的测试服务器上逻辑核心数为32(=>最大竞技场=256),而在我的本地Windows机器上(在Docker容器中运行示例)的逻辑核心数为8(=>最大竞技场=64)。再次感谢。 - Francis
@Francis - 很高兴它起作用了,第二次尝试成功了!我不认为竞技场实际上会导致每个线程累积无限量的内存,可能存在某个点会释放或重新分配内存,但是您的测试没有达到这个阈值。如果您在网络上搜索可调整的内容,您会发现一些地方抱怨高内存使用率,并且这个可调整的内容会出现。 - BeeOnRope
tcmalloc 是一个很好的分配器示例,其内存消耗不随 nthreads 的增加而增加。它使用 restartable sequences 和每个核心的 arenas。如果不支持 rseq(例如 Windows),则会回退到每个线程。 - Noah
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接