Start Kontakt

Synchronizacja za pomocą condition variable

Klasa condition_variable jest jednym z narzędzi pozwalających na synchronizację wątków. Blokuje ona wykonanie określonego wątku pozwalając innemu na zmodyfikowanie zasobu krytycznego (np. współdzielonej zmiennej).

Przykładowe kody prezentowane w tym artykule można pobrać z Google Drive.

Opis przykładowego problemu

Załóżmy, że tworzymy grę komputerową, która powinna działać również w sieci. Aplikacja będzie więc mogła pracować w trybie serwera albo klienta sieciowego. Powiedzmy, że uruchomiliśmy na komputerze grę w opcji serwera, do którego podłączają się kolejni gracze.

Ekranowy interfejs gry zawiera między innymi przycisk Rozpocznij grę, którego naciśnięcie umożliwia rozegranie gry ze zdalnymi graczami sieciowymi, którzy podłączyli się do serwera. Prawdopodobnie w 99% przypadków naciśnięcie tego przycisku nie będzie powodowało żadnych problemów, a gra będzie działać poprawnie.

Co jednak w sytuacji, gdy podczas naciśnięcia przycisku Rozpocznij grę będzie się akurat podłączać do serwera kolejny gracz? Pamiętajmy, że dodanie gracza do gry wiąże się z modyfikacją różnych struktur danych. Jeśli więc gra rozpocznie się w momencie, gdy te struktury będą właśnie uaktualniane, może się pojawić niespójność danych (niektóre zmienne zostaną zmodyfikowane, a inne jeszcze nie), a w wyniku tego program może się zakończyć błędem krytycznym.

Inaczej mówiąc, dodawanie kolejnego gracza do gry powinno się traktować jako pewnego rodzaju transakcję, czyli operację, której nie można przerwać. Należałoby zastosować więc takie rozwiązanie, które chwilowo blokuje rozpoczęcie nowej gry aż do momentu, gdy nowy gracz sieciowy zostanie poprawnie dodany.

Wymagane zmienne

Aby rozwiązać nasz problem, musimy użyć kilku zmiennych:

std::mutex mtx;
std::condition_variable cv;
std::atomic netOpFinished{ true };

bool networkOpFinished(void)
{
    return netOpFinished.load();
}

Zmienna mtx jest muteksem, który jest wykorzystywany przez klasę condition_variable. Zmienna cv to obiekt klasy condition_variable. Zmienna netOpFinished informuje o zakończeniu transakcji (np. operacji sieciowej). Wartość tej zmiennej zwraca predykat, czyli funkcja networkOpFinished.

Program testujący condition_variable

Stwórzmy prosty program symulujący operację sieciową (czyli dodawanie zdalnego gracza komputerowego) oraz naciśnięcie przycisku rozpoczynającego grę. Oto funkcja network_op realizująca transakcję, której nie można przerwać:

void network_op(void)
{
    std::cout << "NETWORK: Rozpoczęcie operacji sieciowej trwającej 5 sekund..." << std::endl;
    netOpFinished = false; 
    std::lock_guard guard(mtx);
    // operacja sieciowa - np. dodawanie gracza zdalnego
    std::this_thread::sleep_for(std::chrono::seconds(5));
    cv.notify_one();
    std::cout << "NETWORK: Mineło 5 sekund. Zakończenie operacji sieciowej." << std::endl;
    netOpFinished = true; 
}

Na początku funkcji ustawiamy zmienną netOpFinished na false, aby poinformować, że rozpoczyna się operacja sieciowa. Następnie musimy użyć blokady. W przypadku operacji sieciowej wystarczy użycie opakowania std::lock_guard, ponieważ za pomocą niego można zablokować i odblokować zasób tylko jeden raz. Symulujemy działanie długotrwałej operacji sieciowej (5 sekund), a wreszcie powiadamiamy condition_variable, że transakcja się zakończyła. W tym celu wykorzystujemy metodę notify_one. Na koniec ustawiamy flagę netOpFinished na true.

Funkcja symulująca naciśnięcie przycisku jest równie prosta:

void button_pressed(void)
{
    std::cout << "BUTTON: Po 2 sekundach po rozpoczęciu operacji sieciowej naciskamy przycisk i czekamy jeszcze 3 sekundy na zakończenie operacji sieciowej..." << std::endl;
    std::unique_lock mlock(mtx);
    cv.wait(mlock, networkOpFinished); // czekamy na zakończenie operacji sieciowej
    std::cout << "BUTTON: Operacja sieciowa się zakończyła, kod związany z przyciskiem może się teraz wykonać." << std::endl;
}

Tu też oczywiście stosujemy blokadę, ale tym razem używamy opakowania unique_lock, ponieważ może ono wielokrotnie blokować i odblokowywać mutex. Następnie oczekujemy na powiadomienie od condition_variable, że operacja sieciowa się zakończyła. W tym celu używamy funkcji wait wykorzystującej jako argumenty mutex oraz funkcję networkOpFinished informującą o zakończeniu operacji. Funkcja ta zwraca po prostu wartość flagi netOpFinished.

A oto główna funkcja main:

int main()
{
    setlocale(LC_ALL, "");

    std::thread net_thread(network_op); // rozpoczęcie operacji sieciowej trwającej 5 sekund
    std::this_thread::sleep_for(std::chrono::seconds(2)); 
    // po 2 sekundach klikamy przycisk, który jednak na razie nic nie robi, bo czeka na zakończenie operacji sieciowej
    button_pressed();
    // czekamy jeszcze 3 sekundy na zakończenie operacji sieciowej
    net_thread.join();
}

Jak zwykle w przypadku konsoli, na początku ustawiamy opcję pozwalającą na wyświetlanie polskich liter (setlocale). Następnie uruchamiamy wątek transakcji sieciowej, która potrwa 5 sekund (consumer_thread). Jednak już po 2 sekundach naciskamy przycisk button_pressed(). Wykonanie kodu funkcji button_pressed zostanie opóźnione o 3 dodatkowe sekundy.

Oto wyniki uzyskane w konsoli:

NETWORK: Rozpoczęcie operacji sieciowej trwającej 5 sekund...
BUTTON: Po 2 sekundach po rozpoczęciu operacji sieciowej naciskamy przycisk i czekamy jeszcze 3 sekundy na zakończenie operacji sieciowej...
NETWORK: Mineło 5 sekund. Zakończenie operacji sieciowej.
BUTTON: Operacja sieciowa się zakończyła, kod związany z przyciskiem może się teraz wykonać.

A co w przypadku, jeśli klikniemy przycisk rozpoczęcia gry, gdy żaden gracz nie będzie akurat dodawany, czyli gdy nie będzie się wykonywać wątek network_op? Kod przycisku powinien się od razu wykonać. Sprawdźmy, czy tak będzie - wystarczy zakomentować wywołanie wątku net_thread i następnie jego join:

int main()
{
    setlocale(LC_ALL, "");

    //std::thread net_thread(network_op); // rozpoczęcie operacji sieciowej trwającej 5 sekund
    std::this_thread::sleep_for(std::chrono::seconds(2)); 
    // po 2 sekundach klikamy przycisk, który jednak na razie nic nie robi, bo czeka na zakończenie operacji sieciowej
    button_pressed();
    // czekamy jeszcze 3 sekundy na zakończenie operacji sieciowej
    //net_thread.join();
}

Oto wyniki uzyskane w konsoli:

BUTTON: Po 2 sekundach po rozpoczęciu operacji sieciowej naciskamy przycisk i czekamy jeszcze 3 sekundy na zakończenie operacji sieciowej...
BUTTON: Operacja sieciowa się zakończyła, kod związany z przyciskiem może się teraz wykonać.

Oba komunikaty pojawiły się w tym samym momencie, ponieważ kod button_pressed() nie musiał na nic czekać.

Pamiętajmy, że w wywołaniu cv.wait(mlock, networkOpFinished) należy koniecznie używać funkcji predykatu (w tym przypadku networkOpFinished). Metodę wait można co prawda wywołać bez predykatu, ale kod przycisku będzie czekać w nieskończoność. Można to łatwo przetestować poprzez zakomentowanie lub usunięcie drugiego argumentu z metody wait.