WindowsとLinux-C ++スレッドプールのメモリ使用量

Aug 17 2020

私は、WindowsおよびLinux(Debian)でのいくつかのC ++ RESTAPIフレームワークのメモリ使用量を調べてきました。特に、cpprestsdkとcpp-httplibの2つのフレームワークを見てきました。どちらの場合も、スレッドプールが作成され、リクエストの処理に使用されます。

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プライベートバイト値を取得していました。Linuxでは、docker stats --no-stream --format "{{.MemUsage}}コンテナのメモリ使用量をログに記録していました。これは、コンテナ内で実行さresれるプロセスと一致していましたtop。グラフから、スレッドmaphandle_post関数内のWindowsの変数にメモリを割り当てると、メモリが返されるように見えます。関数の次の呼び出しの前に関数が終了したとき。これは私が素朴に期待していたタイプの行動でした。スレッドが生きているとき、つまりここのスレッドプールのように、スレッドで実行されている関数によって割り当てられたメモリをOSがどのように処理するかについての経験はありません。Linuxでは、メモリ使用量が増え続けており、関数の終了時にメモリが戻されないようです。40スレッドすべてが使用され、処理するタスクがさらに10ある場合、メモリ使用量は増加しなくなったように見えます。誰かが、メモリ管理の観点から、またはこの特定のトピックに関する背景情報を探す場所についてのいくつかのポインタから、ここLinuxで何が起こっているかについての高レベルのビューを与えることができますか?

編集1:以下のグラフを編集して、テスト対象のプロセスのIDであるLinuxコンテナーで毎秒rss実行さps -p <pid> -h -o etimes,pid,rss,vszれたときの出力値を示し<pid>ました。の出力と合理的に一致していますdocker stats --no-stream --format "{{.MemUsage}}

編集2:STLアロケーターに関する以下のコメントに基づいて、handle_post関数を次のように置き換え、インクルード#include <cstdlib>とを追加して、MWEからマップを削除しました#include <cstring>。これで、このhandle_post関数intは約2MiBである500K秒のメモリを割り当てて設定するだけです。

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:LinuxCentOSマシンで実行した結果を追加しました。これは、Debiandockerイメージの結果の結果とほぼ一致しています。

編集4:以下の別のコメントに基づいて、valgrindmassifツールで例を実行しました。massifコマンドラインパラメータは、下の画像です。--pages-as-heap=yes下の2番目の画像を使用して実行し、このフラグを使用せずに、下の最初の画像を実行しました。最初の画像は、handle_post関数がスレッドで実行されるときに〜2MiBメモリが(共有)ヒープに割り当てられ、関数が終了するときに解放されることを示しています。これは私が期待することであり、Windowsで観察することです。グラフをどのように解釈するか--pages-as-heap=yes、つまり2番目の画像はまだわかりません。

massif最初の画像のの出力を、上のグラフに示されてrssいるpsコマンドのの値と調整できません。Dockerイメージを実行し、を使用してコンテナのメモリを12MBに制限すると、コンテナdocker run --rm -it --privileged --memory="12m" --memory-swap="12m" --name=mwe_test cpp_testing:1.0は7番目の割り当てでメモリが不足し、OSによって強制終了されます。Killed出力を取得し、を見るとdmesg、が表示されKilled process 25709 (cpp_testing) total-vm:529960kB, anon-rss:10268kB, file-rss:2904kB, shmem-rss:0kBます。これは、rssからの値がプロセスで実際に使用されているps(ヒープ)メモリを正確に反映しているのに対し、ツールは/および/呼び出しに基づいて何をすべきかを計算していること示しています。これは、このテストからの私の基本的な仮定です。私の質問はまだ有効です。つまり、関数の終了時にヒープメモリが解放されない、または割り当てが解除されないのはなぜですか。massifmallocnewfreedeletehandle_post

編集5:スレッドプール内のスレッド数を1から4に増やしたときのメモリ使用量のグラフを下に追加しました。スレッド数を10まで増やしてもパターンが続くため、5から10は含めませんでした。 。main最初の約5秒間、グラフの最初のフラットラインである5秒間の一時停止を追加したことに注意してください。スレッド数に関係なく、最初のタスクが処理された後にメモリが解放されますが、タスク2から10の後でメモリが解放されない(再利用のために保持されますか?)ようです。メモリ割り当てパラメータがタスク1の実行(大声で考えているだけです!)?

編集6:以下の詳細な回答からの提案に基づいMALLOC_ARENA_MAXて、例を実行する前に環境変数を1と2に設定しました。これにより、次のグラフの出力が得られます。これは、回答で与えられたこの変数の効果の説明に基づいて予想されたとおりです。

回答

2 BeeOnRope Aug 20 2020 at 03:07

使用しているglibc2.17のアロケータを含む多くの最新のアロケータは、同時に割り当てたいスレッド間の競合を回避するために、複数のアリーナ(空きメモリ領域を追跡する構造)を使用します。

あるアリーナに解放されたメモリを別のアリーナに割り当てることはできません(ある種のアリーナ間転送がトリガーされない限り)。

デフォルトでは、コードを調べるとわかるように、事前定義された制限(デフォルトは8 * CPU数)に達するまで、新しいスレッドが割り当てを行うたびにglibcが新しいアリーナを割り当てます。

この結果の1つは、スレッドで割り当てられてから解放されたメモリは、他のスレッドが別の領域を使用しているため、そのスレッドが有用な作業を行っていない場合でも、他のスレッドで使用できない可能性があることです。

glibc mallocを調整可能 glibc.malloc.arena_maxに設定して、1すべてのスレッドを同じアリーナに強制し、それによって観察していた動作が変わるかどうかを確認できます。

これは(libc内の)ユーザースペースアロケータとはすべて関係があり、メモリのOS割り当てとは関係がないことに注意してください。メモリが解放されたことをOSに通知することはありません。単一のアリーナを強制したとしても、ユーザースペースアロケーターがOSに通知することを決定するという意味ではありません。将来の要求を満たすためにメモリを保持するだけの場合があります(この動作を調整するための調整も可能です)。

ただし、単一のアリーナを使用したテストでは、次のスレッドが開始する前にメモリが解放されるため、メモリフットプリントが絶えず増加するのを防ぐのに十分なはずです。したがって、別のスレッドで開始する次のタスクで再利用されると予想されます。

最後に、何が起こるかは、スレッドが条件変数によって通知される方法に大きく依存することを指摘する価値があります。おそらくLinuxはFIFO動作を使用し、最後にキューに入れられた(待機中の)スレッドが最後に通知されます。これにより、タスクを追加するときにすべてのスレッドを循環し、多くのアリーナが作成されます。(さまざまな理由で)より効率的なパターンはLIFOポリシーです。次のジョブには、最後にキューに入れられたスレッドを使用します。これにより、同じスレッドがテストで繰り返し再利用され、問題が「解決」されます。

最後の注意:多くのアロケーターは、使用している古いバージョンのglibcではオンではなく、スレッドごとのキャッシュも実装しているため、アトミック操作なしで割り当ての高速パスを進めることができます。これにより、複数のアリーナを使用する場合と同様の効果が得られ、スレッドの数に応じてスケーリングが維持されます。