Windows vs Linux - C ++ İş Parçacığı Havuzu Bellek Kullanımı

Aug 17 2020

Windows ve Linux'ta (Debian) bazı C ++ REST API çerçevelerinin bellek kullanımına bakıyordum. Özellikle, şu iki çerçeveye baktım: cpprestsdk ve cpp-httplib . Her ikisinde de, bir iş parçacığı havuzu oluşturulur ve isteklere hizmet vermek için kullanılır.

İş parçacığı havuzu uygulamasını cpp-httplib'den aldım ve Windows ve Linux'ta gözlemlediğim bellek kullanımını göstermek için aşağıdaki minimum çalışma örneğine koydum.

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

Bu MWE'yi çalıştırdığımda ve Windows vs Linux'taki bellek tüketimine baktığımda, aşağıdaki grafiği alıyorum. Windows perfmoniçin Özel Bayt değerini alırdım . Linux'ta, docker stats --no-stream --format "{{.MemUsage}}kapsayıcının bellek kullanımını günlüğe kaydediyordum. Bu, resişlemin topkabın içinde çalışmasıyla uyumluydu. Grafikten, bir iş parçacığı işlevde mapWindows'taki değişken için bellek ayırdığında handle_post, belleğin geri verildiği görülmektedir.işlev, işleve sonraki çağrıdan önce çıktığında. Bu, safça beklediğim türden bir davranıştı. İşletim sisteminin iş parçacığı canlı kaldığında, yani burada bir iş parçacığı havuzunda olduğu gibi bir iş parçacığında çalıştırılan bir işlev tarafından ayrılan bellekle nasıl başa çıktığı konusunda hiçbir deneyimim yok. Linux'ta, bellek kullanımının artmaya devam ettiği ve işlevden çıkıldığında bellek geri verilmediği görülüyor. 40 iş parçacığının tümü kullanıldığında ve işlenecek 10 görev daha olduğunda, bellek kullanımının arttığı görülüyor. Birisi, bellek yönetimi açısından burada Linux'ta neler olup bittiğine dair yüksek seviyeli bir bakış açısı verebilir mi, hatta bu özel konu hakkında bazı arka plan bilgilerini nerede arayacağına dair bazı ipuçları verebilir mi?

Düzenleme 1 : Aşağıdaki grafiği , test edilen işlemin kimliğinin bulunduğu Linux kapsayıcısında her saniye rssçalıştırmanın çıktı değerini gösterecek şekilde düzenledim . Çıktıyla makul bir uyum içindedir .ps -p <pid> -h -o etimes,pid,rss,vsz<pid>docker stats --no-stream --format "{{.MemUsage}}

Düzenleme 2 : STL ayırıcılar ile ilgili aşağıda bir yorum dayanarak, değiştirerek MWE haritayı kaldırıldı handle_posttakipçinizin fonksiyonu ve içerir ekleyerek #include <cstdlib>ve #include <cstring>. Şimdi, handle_postişlev sadece intyaklaşık 2MiB olan 500K s için bellek ayırır ve ayarlar .

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

Burada da aynı davranışı görüyorum. Örnekte iş parçacığı sayısını 8'e ve görev sayısını 10'a düşürdüm. Aşağıdaki grafik sonuçları göstermektedir.

Düzenleme 3 : Linux CentOS makinesinde çalışmanın sonuçlarını ekledim. Debian docker görüntüsü sonucunun sonuçlarıyla genel olarak uyumludur.

Düzenleme 4 : Aşağıda başka bir yoruma dayanarak, altında örnek ran valgrindbireyin massifaracı. massifKomut satırı parametreleri aşağıdaki resimlerde bulunmaktadır. Birlikte koştum --pages-as-heap=yesaşağıdaki ikinci görüntüde, ve bu bayrağı olmadan, ilk görüntü altında. İlk görüntü ~ 2MiB belleğin, handle_postişlev bir iş parçacığı üzerinde yürütülürken (paylaşılan) yığına tahsis edildiğini ve ardından işlev çıkarken serbest bırakıldığını gösterir. Bu beklediğim ve Windows'ta gözlemlediğim şey. --pages-as-heap=yesHenüz grafiği , yani ikinci görüntü ile nasıl yorumlayacağımı bilmiyorum .

I çıkış yediremeyen massifdeğeri ile birinci görüntü rssile ilgili psyukarıdaki grafikte gösterilen komut. Docker görüntüsünü çalıştırırsam ve kullanarak konteyner belleğini 12MB ile sınırlarsam, konteyner docker run --rm -it --privileged --memory="12m" --memory-swap="12m" --name=mwe_test cpp_testing:1.07. ayırmada yetersiz kalır ve işletim sistemi tarafından öldürülür. Çıktıya Killedgiriyorum ve baktığımda dmesggörüyorum Killed process 25709 (cpp_testing) total-vm:529960kB, anon-rss:10268kB, file-rss:2904kB, shmem-rss:0kB. Bu, rssdeğerin işlem tarafından gerçekte kullanılan ps(yığın) belleğini doğru bir şekilde yansıttığını, oysa aracın / ve / çağrılarına dayalı olarak neye dayanması gerektiğini hesapladığını gösterir . Bu, bu testteki temel varsayımımdır. Sorum hala geçerli olacaktır, yani öbek bellek neden işlevden çıktığında serbest bırakılmıyor ya da ayrılmıyor mu?massifmallocnewfreedeletehandle_post

Düzenleme 5 : İş parçacığı havuzundaki iş parçacığı sayısını 1'den 4'e çıkardığınızda bellek kullanımının bir grafiğini aşağıya ekledim. İş parçacığı sayısını 10'a kadar yükselttikçe desen devam eder, bu nedenle 5'ten 10'a dahil etmedim mainİlk ~ 5 saniye için grafiğe ilk düz çizgi olan başlangıcında 5 saniyelik bir duraklama eklediğime dikkat edin. Görünüşe göre, iş parçacığı sayısından bağımsız olarak, ilk görev işlendikten sonra bellek serbest bırakılıyor, ancak görev 2'den 10'a kadar olan görevden sonra bu bellek serbest bırakılmıyor (yeniden kullanım için tutuluyor mu?). görev 1 yürütme (sadece yüksek sesle düşünmek!)?

Düzenleme 6 : Aşağıdaki ayrıntılı cevabın önerisine dayanarak MALLOC_ARENA_MAX, örneği çalıştırmadan önce ortam değişkenini 1 ve 2 olarak ayarladım . Bu, çıktıyı aşağıdaki grafikte verir. Bu, yanıtta verilen bu değişkenin etkisinin açıklamasına dayalı olarak beklendiği gibidir.

Yanıtlar

2 BeeOnRope Aug 20 2020 at 03:07

Kullandığınız glibc 2.17 de dahil olmak üzere birçok modern ayırıcı, aynı anda tahsis etmek isteyen iş parçacıkları arasındaki çekişmeyi önlemek için birden çok alan (boş bellek bölgelerini izleyen bir yapı) kullanır.

Bir arenaya geri bırakılan bellek, başka bir arena tarafından tahsis edilemez (bir tür çapraz-arena aktarımı tetiklenmedikçe).

Varsayılan olarak glibc , kodu inceleyerek görebileceğiniz gibi önceden tanımlanmış bir sınıra (varsayılan olarak 8 * CPU sayısı) ulaşılıncaya kadar yeni bir iş parçacığı her ayırma yaptığında yeni alanlar tahsis edecektir .

Bunun bir sonucu, bir iş parçacığına ayrılan ve sonra serbest bırakılan belleğin, iş parçacığı herhangi bir yararlı iş yapmasa bile, ayrı alanlar kullandıklarından diğer evreler tarafından kullanılamayabilir.

Sen ayarı deneyebilirsiniz glibc Malloc ayarlanabilir glibc.malloc.arena_max için 1aynı arenaya bütün konuları zorlamak ve bunu gözlemleyerek edildi davranışını değiştirir olmadığını görmek için.

Bunun kullanıcı alanı ayırıcısı ile (libc'de) ilgisi olduğuna ve işletim sisteminin bellek tahsisi ile hiçbir ilgisi olmadığına dikkat edin: İşletim sistemine belleğin serbest bırakıldığı asla bildirilmez. Bile tek bir arena zorlamak, bu userspace ayırıcısı OS bilgilendirmek karar verecek anlamına gelmez: sadece gelecekteki talebi karşılamak için etrafında hafızayı tutabilir (tunables da bu davranışı ayarlamak için vardır).

Bununla birlikte, testinizde tek bir alan kullanmak, bir sonraki iş parçacığı başlamadan önce bellek serbest kaldığından sürekli artan bellek ayak izini önlemek için yeterli olmalıdır ve bu nedenle, farklı bir iş parçacığında başlayan bir sonraki görev tarafından yeniden kullanılmasını bekliyoruz.

Son olarak, ne olacağı büyük ölçüde iş parçacıklarının koşul değişkeni tarafından tam olarak nasıl bildirildiğine bağlıdır: muhtemelen Linux, en son kuyruğa alınan (bekleyen) iş parçacığının en son bildirileceği FIFO davranışı kullanır. Bu, görevler eklerken tüm iş parçacıkları arasında geçiş yapmanıza ve birçok alanın oluşturulmasına neden olur. Daha verimli bir model (çeşitli nedenlerden dolayı) bir LIFO politikasıdır: bir sonraki iş için en son sıralanan ipliği kullanın. Bu, aynı iş parçacığının testinizde tekrar tekrar kullanılmasına ve sorunu "çözmesine" neden olur.

Son Not: Birçok dağıtıcılar, ancak kullanmakta olduğunuz glibc'nin eski sürümünde üzerinde, aynı zamanda bir uygulamaya başına iş parçacığı önbelleği tahsisi hızlı yolu bir şekilde uygulanmaktadır herhangi atomik operasyonlarda. Bu, birden çok arenanın kullanımına benzer bir etki yaratabilir ve bu da iş parçacığı sayısıyla ölçeklenmeye devam eder.