Wyślij zapytanie Dołącz do Sii

Język C++ jest często wybierany, gdy wydajność projektowanego systemu ma kluczowe znaczenie. Niestety samo jego użycie nie zagwarantuje, że osiągnięta zostanie założona szybkość przetwarzania. Dlatego coraz większy nacisk kładzie się na optymalizację kodu źródłowego i na czas jego wykonywania. Jednym z aspektów, który ma wpływ na wydajność, jest ilość kopiowanych danych.

W niniejszym artykule skupię się na liczbie kopii obiektów wykonywanych przez bardzo popularną bibliotekę Qt w wersji 6.2.4, a dokładnie na jej mechanizmie zwanym „Signal & Slot”. Przedstawię również porównanie z własną implementacją stworzonej przeze mnie biblioteki TBC (Template Based Communication).

Biblioteka QT

Mechanizm „Signal & Slot” w Qt służy do synchronicznej lub asynchronicznej komunikacji między obiektami. Aby móc z niego korzystać, obiekty muszą dziedziczyć po klasie QObject oraz użyć makro „Q_OBJECT” w swojej deklaracji. Obiekt wysyłający wiadomość deklaruje sygnał wraz z argumentami, które muszą być kopiowalne:

class Sender : public QObject {
	Q_OBJECT

signals:
    void customSignal(int value);
};

 Obiekt odbierający deklaruje odpowiadający slot:

class Receiver : public QObject {
	Q_OBJECT

public slots:
	void customSlot(int value);

};

Następnie, obiekty należy połączyć przez wykonanie metody statycznej QObject::connect:

QObject::connect(&senderObject,
                 &Sender::customSignal,
				 &receiverObject,
				 &Receiver::customSlot);

Metoda ta przyjmuje również opcjonalny parametr, który definiuje typ połączenia. Skupię się na dwóch najważniejszych:

  • Qt::DirectConnection – połączenie bezpośrednie. Metoda zdefiniowana jako slot zostanie wykonana synchronicznie w tym samym wątku, tak jakby została bezpośrednio wywołana jako slot.
  • Qt::QueuedConnection – połączenie kolejkujące. Metoda zdefiniowana jako slot zostanie wykonana asynchronicznie:
    • W tym samym wątku, gdy wróci do głównej pętli Qt i to wywołanie będzie następne w kolejce.
    • W innym wątku, gdy zostanie wcześniej wykonana metoda QObject::moveToThread na obiekcie odbierającym.

Szczegóły tego mechanizmu są zawarte w dokumentacji Qt. Powyżej opisana funkcjonalność wydaje się bardzo użyteczna i łatwa w użyciu.

W dalszej części artykułu skupię się na sprawdzeniu liczby tworzonych kopii tylko w połączeniu kolejkującym. Napisałem prosty test przesyłający obiekt, który zlicza kopie poprzez inkrementację zmiennej statycznej w konstruktorze kopiującym. Poniżej znajduje się definicja tej klasy:

namespace
{
    static int copyCounter = 0;
}

class Msg {
public:
    Msg() = default;

    Msg(const Msg&) {
        ++::copyCounter;
    }

    Msg(Msg&&) = default;

    int copyCounter() const {
        return ::copyCounter;
    }
};

Po dostarczeniu parametru do slotu, liczba kopii zostaje wypisana na konsolę. Wyniki tego testu są przedstawione w poniższej tabeli:

Typ parametru w sygnaleTyp argumentu w slocieLiczba wykonanych kopii
const Msg&const Msg&1
const Msg&Msg2
Msg&&const Msg&1
Msg&&Msg2
Tab. 1 Wynik testu przesyłającego obiekt

Jak można zaobserwować, w przypadku połączenia kolejkującego liczba wykonanych kopii jest zależna od typu parametru w sygnale i argumentu w slocie. Należy zwrócić uwagę, że w dwóch ostatnich przypadkach parametr podawany jest przez rvalue. Obciążenie wydajnościowe wynikające z tych kopii przedstawię w osobnym rozdziale.

Biblioteka TBC

Dokumentacja Qt wspomina, że biblioteka musi wykonywać kopie obiektów, aby przechowywać je „za kulisami”, ale nigdzie nie definiuje, ile kopii jest wykonanych dla różnych typów argumentów. Moim celem jest udowodnienie, że liczbę kopii dla połączenia kolejkującego można zminimalizować, wykorzystując semantykę przenoszenia lub poprzez zagwarantowanie przez użytkownika, że referencja obiektu będzie ważna w momencie wywołania slotu. W tym celu napisałem swoją własną bibliotekę TBC (Template Based Communication).

Aby uniknąć konieczności tworzenia własnego metajęzyka, skorzystałem z mechanizmu szablonów, dzięki któremu mogę zdefiniować jakie argumenty będą przesyłane. Obiekt wysyłający musi dziedziczyć po klasie TBC::Sender<T>, natomiast obiekt odbierający po klasie TBC::Receiver<T>, gdzie „T” jest typem wysyłanego argumentu. Sygnał wysyłany jest na dwa sposoby:

  • valueSignal(T ) – sygnał przyjmuje argument przez wartość, co pozwala na zastosowanie semantyki przenoszenia.
  • constRefSignal(const T& ) – sygnał przyjmuje stałą referencje do obiektu. Obiekt zostanie skopiowany jedynie wtedy, gdy slot przyjmuje argument jako wartość. Wysyłany obiekt nie może zostać zniszczony przed wywołaniem połączonego slotu.

Analogicznie obsługiwany jest odbiór argumentów poprzez valueSlot(T ) i constRefSlot(const T& ). Przykład użycia w kodzie jest zaprezentowany poniżej:

class Sender : public TBC::Sender<LargeObj> {
public:
    void sendValue(LargeObj value) {
        valueSignal(std::move(value));
    }

    void sendconstRef(const LargeObj& ref) {
        constRefSignal(ref);
    }
};

class Receiver : public TBC::Receiver<LargeObj> {
public:
    void valueSlot(LargeObj value) override {}
	
	void constRefSlot(const LargeObj& ref) override {}
};

Aby połączyć obiekty można użyć statycznej metody TBC::connect:

TBC::connect(&sender, &receiver);

Po szczegóły odsyłam do mojego repozytorium, gdzie można znaleźć implementację biblioteki TBC, diagram klas oraz testy funkcjonalne i wydajnościowe. Wszystkie komentarze i uwagi mile widziane 🙂

Tak jak w przypadku Qt, przeprowadziłem analogiczne testy w celu zbadania liczby wykonanych kopii dla połączenia kolejkującego. Wyniki testów znajdują się w poniższej tabeli:

Typ parametru w sygnaleTyp argumentu w slocieLiczba kopii
const Msg&const Msg&0
const Msg&Msg1
Msg&&const Msg&0
Msg&&Msg0
Tab. 2 Wyniki testów dot. liczby wykonanych kopii

Można zauważyć, że biblioteka TBC zapewnia minimalną liczbę wymaganych kopii dla połączenia kolejkującego.

Testy wydajnościowe

Przeprowadzone testy wydajnościowe mierzyły czas, jaki upłynął od emisji sygnału do wywołania slotu, który działa w osobnym wątku, dla różnych rozmiarów przesyłanego parametru. Każdy test wydajnościowy wykonywał 50 iteracji. Testy zostały uruchomione dwukrotnie, dając łącznie 100 pomiarów, z których obliczono średni czas operacji.

Konfiguracja systemu, na którym przeprowadzono testy, była następująca:

  • System operacyjny: Ubuntu 22.04.2 LTS
  • Kompilator: gcc 11.3.0
  • Wersja biblioteki Qt: 6.2.4

Pierwszy test wydajnościowy mierzył przesłanie obiektu typu std::chrono::high_resolution_clock::time_point ustawionego zaraz przed emisją sygnału oraz analizował opóźnienia w slocie odbiorcy. Dla zobrazowania, fragment kodu korzystającego z Qt:

QObject::connect(&sender, &QTSender::send, &receiver, &QTReceiver::valueSlot, Qt::QueuedConnection);
newThread.start();

std::cout << "QT latency [µs]: ";
for (size_t i = 0; i < iterations; ++i) {
	sender.send(std::chrono::high_resolution_clock::now());
	std::this_thread::sleep_for(std::chrono::seconds{1});
}
std::cout << std::endl;
public slots:
    void valueSlot(std::chrono::high_resolution_clock::time_point sendTimePoint) {
        auto endTimePoint = std::chrono::high_resolution_clock::now();
        std::cout << std::chrono::duration_cast<std::chrono::microseconds> (endTimePoint - sendTimePoint).count() << ",";
    }

oraz TBC:

TBC::connect(&sender, &receiver);
receiver.runInNewThread();

std::cout << "TBC latency [µs]: ";
for (size_t i = 0; i < iterations; ++i) {
	sender.valueSignal(std::chrono::high_resolution_clock::now());
	std::this_thread::sleep_for(std::chrono::seconds{1});
}
std::cout << std::endl;
public:
    void valueSlot(std::chrono::high_resolution_clock::time_point sendTimePoint) override {
        auto endTimePoint = std::chrono::high_resolution_clock::now();
        std::cout << std::chrono::duration_cast<std::chrono::microseconds> (endTimePoint - sendTimePoint).count() << ",";
    }

Wyniki testu są zobrazowane poniżej:

Średnie opóźnienie dostarczania wiadomości
Ryc. 1 Średnie opóźnienie dostarczania wiadomości

Chociaż biblioteka TBC wykazuje około 10% mniejsze opóźnienie względem biblioteki Qt, to nie jest to znacząca różnica, ponieważ dopiero po 100 tysiącach operacji opóźnienie zsumuje się do 1 sekundy.

Dalsze testy wydajnościowe

Kolejne testy wydajnościowe mierzyły czas potrzebny na przesłanie parametru Msg, który zawiera tablicę bajtów o zmiennej długości, przy czym rozmiar tej tablicy był mnożony czterokrotnie, zaczynając od 1kB, aż do 256MB. Kod źródłowy klasy Msg zaprezentowano poniżej:

class Msg {
    std::vector<uint8_t> _data;
    std::chrono::high_resolution_clock::time_point _msgCreationTimePoint;

public:
    Msg() = default;

    Msg(size_t msgByteSizeInKb) :
        _data(msgByteSizeInKb * 1024),
        _msgCreationTimePoint{std::chrono::high_resolution_clock::now()}
    {}

    Msg(const Msg& other) = default;

    Msg(Msg&& other) = default;

    void resetCreationTimePoint() {
        _msgCreationTimePoint = std::chrono::high_resolution_clock::now();
    }

    const std::chrono::high_resolution_clock::time_point& sendTimePoint() const {
        return _msgCreationTimePoint;
    }

    size_t dataSizeInKb() const {
        return _data.size() / 1024;
    }

    static constexpr int maxMsgSizeInKb = power(8, 6);
};

Testy wykonano dla wszystkich kombinacji typów parametru sygnału i argumentu slotu, które zostały przedstawione w tabelach w poprzednich sekcjach. Poniżej znajdują się wykresy stworzone na podstawie osiągniętych wyników:

Ryc. 13 Wyniki testów
Ryc. 2 Wyniki testów

Wykresy pokazują, że czas wykonania operacji przez Qt w każdym przypadku jest liniowo zależny od rozmiaru parametru, a czasy korelują z wcześniej deklarowanymi wartościami liczby wykonywanych kopii. W przypadku TBC, dla trzech sytuacji, w których można uniknąć kopii obiektu, wykazuje on stałą złożoność, co oznacza, że czas przesłania danych jest niezależny od ich wielkości.

Podsumowanie

Testy wydajnościowe potwierdziły, że biblioteka Qt (w wersji 6.2.4) nieoptymalnie zarządza pamięcią w przeprowadzonych testach. Niestety, w dokumentacji Qt nie ma jasnej wzmianki o takim ograniczeniu wydajnościowym. Stworzona przeze mnie biblioteka TBC udowadnia, że można osiągnąć optymalną liczbę operacji kopiowania obiektów, zachowując analogiczny i przyjazny interfejs. Możliwe, że w kolejnych wersjach Qt otrzymamy aktualizacje dodającą wparcie operacji przenoszenia parametrów.

Tymczasem, używając mechanizmu „Signal & Slot” z Qt:

  • Należy pamiętać, że obiekt musi być kopiowalny i zostanie on skopiowany przynajmniej raz przy emisji sygnału w trybie kolejkującym.
  • Preferowane jest przyjmowanie parametru w slocie jako stałej referencję, dzięki czemu uniknie się wykonania jednej kopii.
  • Jeśli jest taka możliwość, warto zapakować parametr w std::shared_ptr, aby uniknąć kopiowania obiektu.

Przy korzystaniu ze std::shared_ptr, należy pamiętać, że parametr wewnątrz zostanie zniszczony w momencie usunięcia ostatniej kopii std::shared_ptr, chyba że ustawiona zostanie inna funkcja destrukcji obiektu, tzw. „custom deleter”. Najbezpieczniej jest użyć metody std::make_shared na etapie alokacji pamięci, aby nie wywołać destruktora przechowywanego obiektu i nie zwolnić jego pamięci drugi raz.

Bibliografia

***

Jeśli interesuje Cię tematyka C++, zajrzyj również do innych artykułów naszych ekspertów.

5/5 ( głosy: 5)
Ocena:
5/5 ( głosy: 5)
Autor
Avatar
Karol Sierociński

Deweloper C/C++ z 6-letnim doświadczeniem, miłośnik czystego kodu i projektowania architektury oprogramowania. Poza pracą entuzjasta gry w ping-ponga i szybkiej jazdy na rolkach

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Może Cię również zainteresować

Pokaż więcej artykułów

Bądź na bieżąco

Zasubskrybuj naszego bloga i otrzymuj informacje o najnowszych wpisach.

Otrzymaj ofertę

Jeśli chcesz dowiedzieć się więcej na temat oferty Sii, skontaktuj się z nami.

Wyślij zapytanie Wyślij zapytanie

Natalia Competency Center Director

Get an offer

Dołącz do Sii

Znajdź idealną pracę – zapoznaj się z naszą ofertą rekrutacyjną i aplikuj.

Aplikuj Aplikuj

Paweł Process Owner

Join Sii

ZATWIERDŹ

This content is available only in one language version.
You will be redirected to home page.

Are you sure you want to leave this page?