Fra*_*cis 5 c++ multithreading memory-management
我一直在研究 Windows 和 Linux (Debian) 中一些 C++ REST API 框架的内存使用情况。我特别研究了这两个框架:cpprestsdk和cpp-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;
}
Run Code Online (Sandbox Code Playgroud)
当我运行这个 MWE 并查看 Windows 与 Linux 中的内存消耗时,我得到了下图。对于 Windows,我曾经perfmon获取Private Bytes值。在 Linux 中,我曾经docker stats --no-stream --format "{{.MemUsage}}记录容器的内存使用情况。这符合在容器内运行res的过程top。从图中可以看出,当一个线程map在handle_post函数中为Windows中的变量分配内存时,该内存被返还当函数在下一次调用函数之前退出时。这是我天真地期待的行为类型。我没有关于操作系统如何处理当线程保持活动状态时正在线程中执行的函数分配的内存的经验,例如在线程池中。在 Linux 上,看起来内存使用量一直在增长,并且在函数退出时不会返回内存。当所有 40 个线程都被使用,并且还有 10 个任务要处理时,内存使用量似乎停止增长。有人可以从内存管理的角度对 Linux 中发生的事情给出一个高层次的看法,甚至可以提供一些关于在哪里寻找关于这个特定主题的背景信息的提示吗?
编辑 1:我编辑了下面的图表,以显示在 Linux 容器中每秒rss运行ps -p <pid> -h -o etimes,pid,rss,vsz一次的输出值,其中<pid>正在测试的进程的 ID。它与 的输出合理一致docker stats --no-stream --format "{{.MemUsage}}。
编辑 2:根据下面关于 STL 分配器的评论,我通过用handle_post以下内容替换函数并添加包含#include <cstdlib>和#include <cstring>. 现在,该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);
}
}
Run Code Online (Sandbox Code Playgroud)
我在这里得到相同的行为。在示例中,我将线程数减少到 8 个,将任务数减少到 10 个。下图显示了结果。
编辑 3:我添加了在 Linux CentOS 机器上运行的结果。它与 Debian docker 镜像结果的结果基本一致。
编辑 4:根据下面的另一条评论,我运行了valgrind's massiftool下的示例。该massif命令行参数在下面的图像。我用--pages-as-heap=yes下面的第二张图片运行它,没有这个标志,下面的第一张图片。第一张图片表明,当handle_post函数在线程上执行时,~2MiB 内存分配给(共享)堆,然后在函数退出时释放。这是我所期望的,也是我在 Windows 上观察到的。我不确定如何解释图表--pages-as-heap=yes,即第二张图片。
我无法massif将第一个图像中的输出与上图中显示rss的ps命令中的值进行协调。如果我运行 Docker 映像并使用 将容器内存限制为 12MB docker run --rm -it --privileged --memory="12m" --memory-swap="12m" --name=mwe_test cpp_testing:1.0,则容器在第 7 次分配时内存不足并被操作系统杀死。我进入Killed输出,当我查看时dmesg,我看到Killed process 25709 (cpp_testing) total-vm:529960kB, anon-rss:10268kB, file-rss:2904kB, shmem-rss:0kB. 这表明rssfrom的值ps准确地反映了进程实际使用的(堆)内存,而该massif工具正在计算它应该基于的malloc/new和free/delete调用。这只是我从这个测试中得到的基本假设。我的问题仍然存在,即为什么在handle_post函数退出时堆内存没有被释放或释放?
编辑 5:当您将线程池中的线程数从 1 增加到 4 时,我在内存使用情况图下方添加了该图。随着您将线程数增加到 10,该模式继续进行,因此我没有包括 5 到 10 . 请注意,我在开始时添加了 5 秒的暂停,main这是图中前 ~ 5 秒的初始平线。看起来,无论线程数如何,在处理第一个任务后都会释放内存,但在任务 2 到 10 之后没有释放内存(保留以供重用?)。这可能表明在执行期间调整了某些内存分配参数任务 1 执行(只是大声思考!)?
编辑 6:根据下面详细答案的建议,我MALLOC_ARENA_MAX在运行示例之前将环境变量设置为 1 和 2。这给出了下图中的输出。这是基于答案中对该变量影响的解释所预期的。
许多现代分配器,包括您正在使用的 glibc 2.17 中的分配器,都使用多个arena(一种跟踪空闲内存区域的结构),以避免想要同时分配的线程之间的争用。
释放回一个 arena 的内存不可由另一 arena 分配(除非触发某种类型的跨 arena 传输)。
默认情况下,每次新线程进行分配时,glibc 都会分配新的 arenas,直到达到预定义的限制(默认为 8 * CPU 数量),正如您通过检查代码可以看到的那样。
这样做的后果之一是,在线程上分配然后释放的内存可能无法供其他线程使用,因为它们使用单独的区域,即使该线程没有执行任何有用的工作。
您可以尝试将glibc malloc 可调 glibc.malloc.arena_max设置为1,以强制所有线程进入同一区域,并查看它是否改变您所观察到的行为。
请注意,这与用户空间分配器(在 libc 中)有关,与操作系统的内存分配无关:操作系统永远不会被告知内存已被释放。即使您强制使用单个竞技场,也不意味着用户空间分配器将决定通知操作系统:它可能只是保留内存以满足未来的请求(也有可调参数来调整此行为)。
但是,在您的测试中,使用单个 arena 应该足以防止内存占用量不断增加,因为内存在下一个线程启动之前被释放,因此我们希望它由在不同线程上启动的下一个任务重用。
最后,值得指出的是,发生的情况很大程度上取决于条件变量如何通知线程:大概 Linux 使用 FIFO 行为,其中最近排队(等待)的线程将是最后一个被通知的线程。这会导致您在添加任务时循环遍历所有线程,从而创建许多竞技场。更有效的模式(出于多种原因)是 LIFO 策略:使用最近排队的线程来执行下一个作业。这将导致同一线程在您的测试中重复重用并“解决”问题。
最后注意:许多分配器(但不是您正在使用的旧版本 glibc 中的分配器)也实现了每线程缓存,该缓存允许分配快速路径在没有任何原子操作的情况下继续进行。这可以产生与使用多个竞技场类似的效果,并且随着线程数量的增加而不断扩展。