Kontenery Qt i Copy-on-Write

Victor-Suponev

Victor

Senior Software Engineer

Wstęp

Od tak dawna jak istnieje Qt, istnieje wielkie pytanie: czy powinienem używać kontenerów Qt czy std? QVector czy std::vector? Jeśli chodzi o API, taka rozmowa w większości przypadków sprowadza się do osobistych preferencji. Ale jest pewna zabójcza cecha, która sprawia, że kontenery Qt stają się lepsze od std w pojedynkę. Copy-on-Write, lub COW.

W artykule przeczytasz:

Co to jest COW?

Jest na ten temat dobry artykuł w dokumentacji Qt, który podaje szczegóły. Tutaj, przedstawię krótkie wprowadzenie.

Weźmy następujący kod:

std::vector<int> v1 {1, 2, 3};
std::vector<int> v2 = v1;

Kiedy przypisujemy v1 do v2, wykonywana jest głęboka kopia. W efekcie otrzymujemy dwa różne kawałki pamięci, które nie mają ze sobą nic wspólnego.

QVector<int> v1 {1, 2, 3};
QVector<int> v2 = v1;

Tutaj sytuacja jest zupełnie inna. Istnieje jeden kawałek pamięci, do którego oba wektory mają wskaźniki. Dzięki temu uzyskujemy wzrost wydajności bez pisania dodatkowych linii kodu.

Dokładnie tak. Jeśli nie potrzebujesz mieć kopii wektora, to nie będziesz jej miał. Nie musisz w tym celu tworzyć współdzielonych wskaźników, są one dane takie, jakie są.

OK, ale co jeśli faktycznie chcemy zaktualizować początkowy wektor poprzez przypisanie jakiejś wartości do jednego z jego elementów?

Wtedy dzieje się Copy-on-Write. Drugi wektor jest odłączany, a następnie wykonywana jest głęboka kopia. Teraz mamy dwa kawałki pamięci, tak jak w przypadku wektorów std. I to tylko wtedy, gdy faktycznie dochodzi do zapisu. Jak więc widać, w najgorszym przypadku otrzymujemy taką samą wydajność jak std, ale lepszą, gdy jest to możliwe.

Brzmi fajnie, prawda?

Nie tak szybko.

To był wstęp. Teraz zadajmy sobie kilka pytań. Kiedy tak naprawdę wykonywana jest ta głęboka kopia? Czy zawsze jest to Copy-on-Write? Co możemy zrobić, aby tego uniknąć?

O tym właśnie jest ten artykuł.

Co jest nie tak z COW?

Większość artykułów na temat implementacji COW w Qt mówi coś takiego: "Powinieneś być świadomy, że głęboka kopia może być wykonywana na twoich kontenerach".

Nie kupuję tego "może". Jeśli zależy mi na wydajności, muszę znać dokładne przypadki, kiedy to się dzieje, a kiedy nie.

Artykuł Qt mówi: "tylko wskaźnik do danych jest przekazywany, a dane są kopiowane tylko wtedy, gdy i kiedy funkcja zapisuje do niego".

Więc spodziewam się, że wydarzą się następujące rzeczy:

QVector<int> v1 {1, 2, 3};
QVector<int> v2 = v1;
int i = v2[0]; // No detach, we're reading the element
v2[0] = 10; // Detach, we're actually writing to the element

Poczekaj.

Ale przecież w C++ nie mamy różnych operatorów [] dla odczytu i zapisu! To jest ten sam kod wywoływany w obu przypadkach! Skąd wie, że nie powinien się odłączać podczas czytania?

Nie wie.

Zawsze się odłącza.

// We're reading the vector, but it enforces a deep copy!
const int i = v2[0];

Tak, dobrze zrozumiałeś. Copy-on-Write to kłamstwo. To jest raczej Copy-on-Write-or-Read-or-Whatever.

Udowodnijmy naszą tezę za pomocą analizy.

Oto jak operator [] jest zdefiniowany w QVector:

template <typename T>
class QVector
{
 inline T &operator[](int i)
 {
 return data()[i];
 }

inline T *data() {
 detach();
 return d->begin();
 }
};

To jest bezwarunkowe odłączenie. Żadnych weryfikacji, żadnych środków ostrożności. Po prostu odłączenie przed zwróceniem wskaźnika do danych.

To jest właściwie logiczne. Zwracasz wskaźnik. Nigdy nie wiesz, co programista zrobi z tym wskaźnikiem, ale musisz dać gwarancje.

(Zadanie domowe: przeczytaj kod QVector i znajdź wszystkie miejsca, w których wywoływana jest funkcja detach(). Nie spodoba ci się to.)

Ma to surowe konsekwencje. Na przykład, jeśli po prostu przekazałeś swoje QVectory przez wartość jako parametry, mając nadzieję, że domyślne współdzielenie załatwi sprawę, miałeś tylko połowiczną rację.

void readVector(QVector<int> v) {
 // At this point, v is implicitly shared, and everything is fine.
 // But v gets automatically detached on the first iteration below
 for(int i : v) {
 // do something
 }
}

QVector<int> vec {1, 2, 3};
readVector(vec);

Normalnie, przekazałbyś taki wektor przez referencję (wszystko jest oczywiście w porządku w takim przypadku), ale jeśli przekazujesz go do sygnału kolejki, kuszące byłoby przekazanie go przez wartość i chwalenie COW, zamiast robienia bałaganu z zarządzaniem pamięcią lub uzyskania crashu z powodu nieaktualnej referencji. Jest to dobre podejście, ale wymaga dużo uwagi do kodu wewnątrz tych slotów. Bezpieczeństwo jest zakładane, ale nie daje żadnej gwarancji.

Co możemy z tym zrobić?

Ok, COW nie robi tego, co obiecał. Czy istnieje sposób na dostęp do kontenera bez jego odłączania?

Tak, jest. Pamiętasz, jak operator[] był zdefiniowany w QVector? Oto jak zdefiniowany jest const operator[]:

template <typename T>
inline const T &QVector<T>::operator[](int i) const
{
 return d->begin()[i];
}

Bez odłączania. Operator wie, że dane nie mają być modyfikowane, więc nie wykonuje ostrożnej głębokiej kopii.

Tak więc, oto ostateczna odpowiedź: odłączenie zawsze ma miejsce dla dostępu non-const, a nigdy - dla dostępu const.

Na tym można by zakończyć ten artykuł, ale zastanówmy się nad rzeczywistymi przypadkami użycia.

Można by się zapytać: skąd kompilator C++ wie, kiedy wywołać operator const, a kiedy nonconst? To proste. Operator const jest wywoływany dla obiektu const i vice versa.

Tak więc, jeśli chcesz uniknąć odłączania, musisz nadać swojemu drugorzędnemu wektorowi wartość const.

Wracając do naszego oryginalnego przykładu:

QVector<int> v1 {1, 2, 3};
const QVector<int> v2 = v1;
int i = v2[0]; // No detach, v2 is const

Zauważ, że stałość ma znaczenie, gdy próbujesz uzyskać dostęp do danych, a nie za każdym razem. Tak więc, możesz łatwo mieć wektor non-const i po prostu rzutować go na const, gdy odczytujesz wartość (qAsConst został dodany do Qt dokładnie w tym celu).

Jeśli nie możesz manipulować stałością lub nie chcesz, istnieją wywołania API, które domyślnie nie odłączają. Są to wszystkie funkcje członkowskie, które są oznaczone jako const. Na przykład, QVector::at(), QVector::value(), QVector::constData() lub const iteratory.

Często spotykam się z pytaniem, dlaczego powinniśmy być tak czujni w używaniu stałości w naszym kodzie C++. Jest to jeden z powodów, który ma natychmiastowe, wymierne konsekwencje.

Jeśli myślisz o zautomatyzowanym sprawdzaniu takich ukrytych scenariuszy odłączania, clazy jest twoim najlepszym przyjacielem

Przykłady

Iteracja

Najczęściej używana i niedoceniana: iteracja po kontenerze.

QVector<int> v1 {1, 2, 3};
for(const int i : v1) {
 int a = i;
}

To jest to, co regularnie widzę nawet u doświadczonych programistów. Najczęściej wynika to z oczekiwania, że jeśli czytamy wartości, to nie następuje odłączenie. Jak mogliśmy się przekonać w poprzednich rozdziałach, jest to całkowicie błędne.

Istnieją dwa sposoby, aby to naprawić:

  • Jeśli możesz sobie pozwolić na oznaczenie kontenera jako const, zrób to. Być może znalazłeś jeden problem z niejawnym odłączeniem, ale mogłeś pominąć inne. Najbezpieczniejszym sposobem na poradzenie sobie z tym problemem jest oznaczenie kontenera jako stałego w pierwszej kolejności.
const QVector<int> vec {1, 2, 3};
for(const int i : vec) {
 int a = i;
}
  • Oczywiście nie działa to w przypadku dynamicznych kontenerów.
QVector<int> vec;
// Populate the vector
for(int i = 0; i < 100; ++i) {
 vec[i] = i;
}

for(const int i : qAsConst(vec)) {
 int a = i;
}

Wywołanie qAsConst lub std::as_const pomaga tutaj, a jest to najczęstsza sytuacja z iteracjami po kontenerach Qt. Zasada kciuka: jeśli nie aktualizujesz swojego kontenera w pętli, zawsze używaj qAsConst. Jeśli go aktualizujesz, zmień swój algorytm i nadal go używaj. Jawne kopiowanie jest prawie zawsze lepsze niż niejawne odłączanie: przynajmniej pod względem przejrzystości, a tym samym łatwości konserwacji.

Rozwiązanie: oznacz wektor jako const albo jawnie, albo za pomocą qAsConst.

Argumenty funkcji i zwracane wartości

QVector<int> processData(QVector<int> data) {
 QVector<int> dataCopy = data;
 for(int i = 0; i < 1000; ++i) {
 dataCopy = preprocessData(dataCopy); // We apply preprocessing many times, that's why dataCopy is not const
 }
 return dataCopy;
}

QVector<int> preprocessData(QVector<int> data) {
 QVector<int> processedData;
 processedData.reserve(data.size());
 for(int i = 0; i < data.size(); ++i) {
 processedData[i] = data[i] * 2; // Meaningless operation, just for demo purposes
 }
 return processedData;
}

Powyższy kod wykonuje 1000 iteracji wstępnego przetwarzania po wektorze danych. Dla każdej iteracji tworzona jest wewnętrzna kopia, wypełniana i zwracana jako wartość.

Jeśli za bardzo przestraszyłeś się tych "Odłączenia! Odłączenia są wszędzie!", możesz chcieć uniknąć przekazywania wektorów w ten sposób w ogóle. Ale przekazywanie wektora jako wartości jest całkiem nieszkodliwe. Właściwie, to właśnie tutaj COW jest niezwykle użyteczne: tylko niewielka część danych wektora jest umieszczana na stosie. Ciało wektora jest niejawnie współdzielone i nie jest kopiowane, gdy wykonywane jest wywołanie. Tak więc, ten przykład jest faktycznie dobry.

Ale tylko w tym ujęciu. Za każdym razem, gdy preprocessData() zaczyna odpytywać wektor danych, przekazana kopia odłącza się. I tak, dzieje się to 1000 razy, ponieważ za każdym razem na stos funkcji kopiowany jest inny obiekt fizyczny.

Rozwiązanie: zmień sygnaturę funkcji na QVector<int> preprocessData(const QVector<int> data)

Poniższa linia może być nieco trudna do zrozumienia:

dataCopy = preprocessData(dataCopy);

Można by pomyśleć, że możemy przegapić tutaj wiele niejawnych odłączeń, ponieważ dzieje się to w pętli. Ale nie jest to prawda. Przekazujemy dataCopy do preprocessData() przez wartość, i otrzymujemy inny wektor jako wynik również przez wartość. Ten wynikowy wektor przypisujemy do starego dataCopy var. Stara wartość dataCopy zniknęła, a związana z nią pamięć stosu została zwolniona. Jeśli i dla Ciebie jest to nie do przyjęcia, możesz chcieć ponownie wykorzystać stary kontener, ale nie ma to nic wspólnego z odłączeniami, z którymi walczymy w tym artykule.

Jak widzisz, w stosunkowo złożonym przykładzie z wektorami przekazywanymi przez wartość w zagnieżdżonych wywołaniach w pętlach, jedynym problemem było dodanie const do parametru. Z resztą COW radzi sobie całkiem dobrze i to jest właśnie miejsce, w którym COW naprawdę błyszczy.

Sygnały i sloty

class DataNode : public QObject {
public:
 void generateData()
 {
 QVector<int> data;
 // Populate the vector
 emit dataReady(data);
 }
signals:
 void dataReady(QVector<int> data);
slots:
 void processData(QVector<int> data)
 {
 long sum = 0; // Let's just calculate vector's sum as an example
 for(int i : qAsConst(data)) {
 sum += i;
 }
 }
};

DataNode *a1 = new DataNode;
DataNode *a2 = new DataNode;
connect(a1, &A::dataReady, a2, &A::processData);
a1->generateData();

Nie szukaj tutaj błędu. Kod jest dość głupi, ale ogólnie dobry.

Ten przykład jest bardzo podobny do poprzedniego. Nadal jest on przekładany na wywołania funkcji, a wektory są przekazywane przez wartość. Nadal chodzi tylko o to, aby wektory były const tam gdzie trzeba.

Ten przykład jest podany, aby pokazać, że nic się nie zmienia, gdy przechodzimy z czystych wywołań funkcji na sygnały i sloty.

Zauważ, że mimo iż nie oznaczamy parametrów jako const, użyliśmy qAsConst, gdy mogło dojść do odłączenia. To jest wystarczająco ładne.

Jeśli sprawisz, że połączenie będzie kolejkowane, a sloty będą przetwarzane w różnych wątkach, nic się nie zmieni. To wciąż wszystko jest o stałość. Tak, możesz używać const refs w połączeniach kolejkowanych, Qt zapewnia, że twoje refs pozostaną ważne w większości przypadków.

Ten przykład jest jednak wyjątkowy. Możemy mieć wiele połączeń do tego samego sygnału. Wyobraź sobie tysiąc węzłów podłączonych do dataReady, jakaś implementacja Map-Reduce mogłaby wykorzystać taką koncepcję. Nie ma nic złego w przekazywaniu wektora przez wartość nawet w takim przypadku. Ale jeśli faktycznie przegapisz przypadkowy detach w jednym z tych połączonych slotów, twoja wydajność spada tysiąckrotnie (celowa przesada - a może jednak?).

Tak więc, jeśli masz połączenie jeden do wielu w swojej aplikacji i przekazujesz kontenery wokół, bądź niezwykle ostrożny co do rzeczywistej logiki w tych gniazdach, a jeśli możesz sobie pozwolić na argumenty const, zrób to. Tak jak w poprzednim przykładzie.

Rozwiązanie: po prostu bądź uważny i wiedz gdzie szukać.

Warto zauważyć, że we wszystkich tych przykładach używaliśmy wektorów int, co jest tak lekkie, jak tylko może być. Jeśli masz kontenery z klasami niestandardowymi, szczególnie gdy klasy są wystarczająco ciężkie, koszt przypadkowego odłączenia może być druzgocący.

Podsumowanie

Copy-on-Write zdarza się nie tylko podczas zapisu.

Dzieje się to przy każdym dostępie non-const do kontenera, nawet przy odczycie. COW w Qt to tak naprawdę CONCA, Copy-on-Non-Const-Access.

Istnieją dwa sposoby na uniknięcie głębokiego kopiowania w kontenerach COW:

  1. Oznaczyć kontener jako const, np. za pomocą qAsConst.
  2. Użyć funkcji dostępu const.

Jeśli zrobisz to w ten sposób, COW zapewnia miły wzrost wydajności za darmo.

A na koniec, oto dobry wykład do obejrzenia.

Udostępnij w social mediach

Wybierz sposób realizacji i wspólnie zacznijmy realizować Twój projekt

Wycena projektu
Cofnij