Windows vs Linux-C ++ 스레드 풀 메모리 사용량

Aug 17 2020

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;
}

이 MWE를 실행하고 Windows와 Linux의 메모리 사용량을 보면 아래 그래프가 표시됩니다. Windows의 perfmon경우 Private Bytes 값 을 얻었습니다 . 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 할당 자에 관한 아래의 의견을 바탕으로, 나는 대체하여 MWE에서지도를 제거한 handle_post다음으로 기능을하고, 포함 추가 #include <cstdlib>하고 #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 : Linux CentOS 컴퓨터에서 실행 한 결과를 추가했습니다. 데비안 도커 이미지 결과의 결과에 광범위하게 동의합니다.

편집 4 : 아래의 다른 의견을 기반으로 valgrindmassif도구 에서 예제를 실행했습니다 . massif명령 줄 매개 변수 아래의 이미지에 있습니다. 나는 그것을 실행 --pages-as-heap=yes아래 두 번째 이미지,이 플래그가 없으면, 첫 번째 이미지 아래. 첫 번째 이미지는 handle_post함수가 스레드에서 실행될 때 ~ 2MiB 메모리가 (공유) 힙에 할당 된 다음 함수가 종료 될 때 해제 됨을 나타냅니다 . 이것이 내가 기대하고 Windows에서 관찰하는 것입니다. 나는 --pages-as-heap=yes아직 두 번째 이미지 와 같은 그래프를 해석하는 방법을 잘 모르겠습니다 .

I는 출력 조정할 수 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

사용중인 glibc 2.17의 할당자를 포함하여 많은 최신 할당자는 동시에 할당하려는 스레드 간의 경합을 피하기 위해 다중 영역 ( 사용 가능한 메모리 영역을 추적하는 구조)을 사용합니다.

한 아레나로 다시 확보 된 메모리는 다른 아레나에서 할당 할 수 없습니다 (일부 유형의 아레나 간 전송이 트리거되지 않는 한).

기본적으로 glibc는 코드 를 검사하여 볼 수 있듯이 미리 정의 된 제한 (기본값은 8 * CPU 수)에 도달 할 때까지 새 스레드가 할당 할 때마다 새 아레나를 할당 합니다 .

이로 인해 스레드에서 할당 된 후 해제 된 메모리는 별도의 영역을 사용하고 있기 때문에 해당 스레드가 유용한 작업을 수행하지 않더라도 다른 스레드에서 사용할 수 없습니다.

glibc malloc 튜너 블 glibc.malloc.arena_max1로 설정하여 모든 스레드를 동일한 아레나로 강제 설정하고 관찰중인 동작을 변경하는지 확인할 수 있습니다.

이것은 사용자 공간 할당 자 (libc에서)와 관련이 있으며 OS의 메모리 할당과는 관련이 없습니다. OS는 메모리가 해제되었다는 알림을받지 않습니다. 경우에도 단일 경기장을 강제로, 그것은 사용자 공간 할당이 OS를 통보하기로 결정 것을 의미하지 않습니다 단순히 미래의 요구를 충족하기 위해 주변의 메모리를 유지할 수 있습니다 (튜너 블은이 동작을 조정할 수있다).

그러나 단일 아레나를 사용하는 테스트에서는 메모리가 다음 스레드가 시작되기 전에 해제되기 때문에 지속적으로 증가하는 메모리 풋 프린트를 방지하기에 충분해야합니다. 따라서 다른 스레드에서 시작되는 다음 작업에서 재사용 될 것으로 예상됩니다.

마지막으로, 어떤 일이 발생하는지는 조건 변수에 의해 정확히 어떻게 쓰레드가 통지되는지에 따라 크게 좌우된다는 점을 지적 할 가치가 있습니다. 아마도 Linux는 FIFO 동작을 사용하는데, 가장 최근에 큐에 넣은 (대기중인) 쓰레드가 마지막으로 통지 될 것입니다. 이렇게하면 작업을 추가 할 때 모든 스레드를 순환하여 많은 아레나가 만들어집니다. 보다 효율적인 패턴 (다양한 이유로)은 LIFO 정책입니다. 다음 작업을 위해 가장 최근에 대기열에 넣은 스레드를 사용합니다. 이로 인해 동일한 스레드가 테스트에서 반복적으로 재사용되고 문제가 "해결"됩니다.

최종 참고 사항 : 많은 할당 자,하지만 당신이 사용하고 있는지의 glibc의 이전 버전에는, 또한 구현 당 스레드 캐시 할당 빠른 경로가없이 진행 할 수 있는 원자 작업을. 이는 여러 경기장을 사용하는 것과 비슷한 효과를 낼 수 있으며 스레드 수에 따라 계속 확장됩니다.