Windows против Linux - использование памяти пула потоков C ++

Aug 17 2020

Я изучал использование памяти некоторыми фреймворками C ++ REST API в Windows и Linux (Debian). В частности, я рассмотрел эти два фреймворка: 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внутри контейнера. Из графика видно, что когда поток выделяет память для mapпеременной в Windows в handle_postфункции, эта память возвращаетсякогда функция завершается до следующего вызова функции. Это был тип поведения, которого я наивно ожидал. У меня нет опыта относительно того, как ОС обрабатывает память, выделенную функцией, которая выполняется в потоке, когда поток остается активным, то есть как здесь, в пуле потоков. В Linux похоже, что использование памяти продолжает расти, и эта память не возвращается при выходе из функции. Когда все 40 потоков были использованы и осталось обработать еще 10 задач, использование памяти перестает расти. Может ли кто-нибудь дать общее представление о том, что происходит здесь, в Linux, с точки зрения управления памятью или даже дать некоторые указания о том, где искать некоторую справочную информацию по этой конкретной теме?

Изменить 1 : я отредактировал график ниже, чтобы показать выходное значение rssот запуска ps -p <pid> -h -o etimes,pid,rss,vszкаждую секунду в контейнере Linux, где <pid>это идентификатор тестируемого процесса. Это разумно согласуется с выводом docker stats --no-stream --format "{{.MemUsage}}.

Изменить 2 : на основе комментария ниже относительно распределителей STL, я удалил карту из MWE, заменив handle_postфункцию следующим и добавив include #include <cstdlib>и #include <cstring>. Теперь handle_postфункция просто выделяет и устанавливает память на 500 intКБ, что составляет примерно 2 МБ.

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.

Редактирование 4 : На основании другого комментария ниже, я побежал пример в valgrind«S massifинструмент. Параметры massifкомандной строки показаны на изображениях ниже. Я запустил его со --pages-as-heap=yesвторым изображением ниже и без этого флага с первым изображением ниже. Первое изображение предполагает, что ~ 2 МБ памяти выделяется для (общей) кучи, поскольку handle_postфункция выполняется в потоке, а затем освобождается при выходе из функции. Это то, чего я ожидал и что я наблюдаю в Windows. Я пока не знаю, как интерпретировать график --pages-as-heap=yes, т.е. второе изображение.

Я не могу согласовать вывод massifв первом изображении со значением rssиз psкоманды, показанной на графиках выше. Если я запускаю образ Docker и ограничиваю объем памяти контейнера до 12 МБ docker run --rm -it --privileged --memory="12m" --memory-swap="12m" --name=mwe_test cpp_testing:1.0, контейнер исчерпывает память на седьмом распределении и будет убит ОС. Я получаю Killedрезультат, и когда я смотрю dmesg, я вижу Killed process 25709 (cpp_testing) total-vm:529960kB, anon-rss:10268kB, file-rss:2904kB, shmem-rss:0kB. Это предполагает, что rssзначение from 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 перед запуском примера. Это дает результат на следующем графике. Это, как и ожидалось, основано на объяснении влияния этой переменной, приведенном в ответе.

Ответы

2 BeeOnRope Aug 20 2020 at 03:07

Многие современные распределители, включая тот, который вы используете в glibc 2.17, используют несколько арен (структуру, которая отслеживает свободные области памяти), чтобы избежать конфликта между потоками, которые хотят выделить одновременно.

Память, освобожденная обратно на одну арену, недоступна для распределения другой ареной (если не запущен какой-либо тип передачи между ареной).

По умолчанию glibc будет выделять новые области каждый раз, когда новый поток выполняет выделение, пока не будет достигнут предопределенный предел (который по умолчанию равен 8 * количеству процессоров), как вы можете видеть, изучив код .

Одним из следствий этого является то, что память, выделенная и освобожденная в потоке, может быть недоступна для других потоков, поскольку они используют отдельные области, даже если этот поток не выполняет никакой полезной работы.

Вы можете пробуйте установить Glibc таНос перестраиваемый glibc.malloc.arena_max для 1того , чтобы заставить все темы на той же арене , и посмотреть , если он изменяет поведение вы наблюдения.

Обратите внимание, что это связано с распределителем пользовательского пространства (в libc) и не имеет ничего общего с распределением памяти ОС: ОС никогда не информируется о том, что память освобождена. Даже если вы форсируете одну арену, это не означает, что распределитель пользовательского пространства решит проинформировать ОС: он может просто сохранить память, чтобы удовлетворить будущий запрос (есть настраиваемые параметры для настройки этого поведения).

Однако в вашем тесте использования одной арены должно быть достаточно, чтобы предотвратить постоянно увеличивающийся объем памяти, поскольку память освобождается до запуска следующего потока, и поэтому мы ожидаем, что она будет повторно использована следующей задачей, которая запускается в другом потоке.

Наконец, стоит отметить, что то, что происходит, в значительной степени зависит от того, как именно потоки уведомляются переменной условия: предположительно, Linux использует поведение FIFO, при котором последний (ожидающий) поток будет уведомлен последним. Это заставляет вас циклически перебирать все потоки при добавлении задач, вызывая создание множества арен. Более эффективным шаблоном (по ряду причин) является политика LIFO: для следующего задания используйте последний поток, поставленный в очередь. Это приведет к тому, что один и тот же поток будет повторно использоваться в вашем тесте и «решит» проблему.

Последнее замечание: многие распределители, но не включенные в более старую версию glibc, которую вы используете, также реализуют поточный кеш, который позволяет выполнять быстрый путь выделения без каких-либо атомарных операций. Это может дать эффект, аналогичный использованию нескольких арен, который продолжает масштабироваться с количеством потоков.