CodeGym /Kursy /C++ SELF /Producer–consumer — kolejka zadań i worker

Producer–consumer — kolejka zadań i worker

C++ SELF
Poziom 70, Lekcja 2
Dostępny

1. Idea producer–consumer i role

Jeśli spojrzeć na rzeczywiste programy, bardzo często jeden fragment kodu „produkuje pracę”, a inny ją „przetwarza”. Na przykład użytkownik naciska przyciski (produkuje zdarzenia), a handler zdarzeń musi je obsłużyć; sieć dostarcza pakiety, a logika biznesowa je przetwarza; logger otrzymuje komunikaty, a potem zapisuje je do pliku. W takich sytuacjach chcemy, żeby producent nie czekał na przetworzenie, a konsument nie wykonywał pętli „gdzie jest moja praca?” i nie zużywał CPU, jakby mu za to płacono.

Właśnie tutaj pojawia się model producer–consumer. Producer (producent) wkłada „zadania” do kolejki, consumer (konsument) je pobiera i wykonuje. Pomiędzy nimi jest kolejka zadań — wspólny zasób, który chronimy mutex-em i „ożywiamy” condition_variable, aby consumer mógł spać, dopóki pracy nie ma.

Trzy role: producer, kolejka i consumer

Aby nie zamieniać wielowątkowości w magię i rytuały, wygodnie jest mentalnie podzielić system na trzy części. Producer — to ten, kto dodaje elementy do kolejki. Consumer — to ten, kto wyciąga elementy i wykonuje pracę. Kolejka — to „bufor” między nimi i jednocześnie źródło prawdy: czy jest praca teraz, czy nie. Powiadomienia (notify_*) — to nie „praca”, a jedynie delikatne szturchnięcie: „obudź się i sprawdź stan”.

Mała tabelka, żeby utrwalić role:

Rola Co robi Z czym pracuje Ryzyko przy błędnym działaniu
Producer
push
zadania
queue
pod
mutex
utrata powiadomienia / nieobudzenie consumera
Kolejka przechowuje zadania
queue + mutex + condition_variable
warunki wyścigu, uszkodzenie struktury, zablokowanie
Consumer oczekuje i
pop
zadania
wait
na warunku „kolejka niepusta”
busy-wait, odczyt pustej kolejki, deadlock

Schematycznie wygląda to tak:

flowchart LR
    P[Producer<br/>wkłada zadania] -->|push pod mutex| Q[Kolejka zadań<br/>wspólny stan]
    Q -->|pop pod mutex| C[Consumer / Worker<br/>przetwarza]
    P -->|notify_one| CV[condition_variable]
    CV -->|budzi| C

Kluczowa myśl: consumer oczekuje na stan kolejki, a nie na samo notify (np. !q.empty()). Notify — to tylko sygnał „warunek mógł się zmienić”.

Kolejka zadań jako chroniony stan

Bardzo kuszące jest napisanie czegoś w rodzaju „sprawdzimy q.empty(), jeśli pusto — poczekamy, jeśli nie — pobierzemy”. Na jednym wątku to OK. W wielowątkowości to staje się scenariuszem „spojrzałem — było pusto, mrugnąłem — i wszystko się zmieniło”. Dlatego w producer–consumer obowiązuje żelazna zasada: wszystkie operacje na kolejce — tylko pod tym samym mutex. Nawet empty() — to odczyt stanu współdzielonego, a odczyt bez dyscypliny nie jest „bezpieczniejszy” od zapisu.

Zazwyczaj chronimy takie operacje:

  • Producer: lock → q.push(x) → unlock → notify_one
  • Consumer: lock → wait(dopóki !q.empty()) → pobierz element → q.pop() → unlock → przetwórz element

Zwróć uwagę na słowo „przetwórz”: przetwarzanie powinno być poza mutexem. Pod mutexem wykonujemy tylko minimum: sprawdzenie warunku, przeniesienie danych z kolejki do lokalnej zmiennej, aktualizację kolejki.

Aby poczuć, dlaczego empty() też nie wolno dotykać bez mutexa, oto mini-antyprzykład (nie rób tego w domu, nawet jeśli bardzo kusi):

#include <queue>

std::queue<int> q;

bool has_work_unsafe() {
    return !q.empty(); // ZLE: w wielowątkowości tak nie wolno bez mutexa
}

Nawet jeśli „wydaje ci się”, że empty() to „tylko podgląd”, w wielowątkowości to i tak odczyt zasobu współdzielonego bez gwarancji spójności. Potem zwykle następuje klasyka: zobaczyłeś false (niepusto), sięgnąłeś po front(), a kolejka już jest pusta — i witaj, błąd logiczny.

2. Protokół: mutex, oczekiwanie i wykonanie

Granica sekcji krytycznej: „wziąłem zadanie — zwolniłem mutex”

Początkujący często popełniają jedną z dwóch skrajności. Pierwsza — trzymać mutex zawsze, „żeby było bezpiecznie”. Wtedy producer nie może nic dodać, dopóki consumer długo przetwarza zadanie, i cały sens wielowątkowości znika. Druga — trzymać mutex prawie nigdy i liczyć, że „jakoś to przejdzie”. Zwykle nie przechodzi.

Prawidłowy styl: pod mutex wykonujemy tylko „manipulacje kolejką”, a pracę wykonujemy po wyjściu z sekcji krytycznej.

Porównajmy dwa fragmenty. Najpierw zły (mutex trzymany za długo):

#include <mutex>
#include <queue>
#include <iostream>

std::mutex m;
std::queue<int> q;

void process_bad() {
    std::lock_guard<std::mutex> lock(m);
    int x = q.front();
    q.pop();

    // ZLE: pod mutexem wykonujemy długą pracę
    std::cout << "Processing " << x << "\n";
}

Teraz dobry (mutex krótki, przetwarzanie na zewnątrz):

#include <mutex>
#include <queue>
#include <iostream>

std::mutex m;
std::queue<int> q;

void process_good() {
    int x = 0;

    {
        std::lock_guard<std::mutex> lock(m);
        x = q.front();
        q.pop();
    } // mutex zwolniony tutaj

    std::cout << "Processing " << x << "\n"; // juz bez mutexa
}

Druga wersja jest lepsza, bo producer może nadal wkładać nowe zadania, podczas gdy consumer przetwarza stare. Mówiąc obrazowo: „szybko wziąłem zamówienie przy kasie, odszedłem przygotować kawę, nie blokując kolejki”.

Minimalna kolejka zadań: projektujemy TaskQueue

Teraz zbudujemy mały element, z którego łatwo postawić workera. Naszym celem jest klasa, która enkapsuluje trzy rzeczy: std::queue, std::mutex, std::condition_variable. Dzięki temu zasady protokołu nie rozmazują się po kodzie, lecz są w jednym miejscu. To nie „OOP dla OOP”, a sposób, by nie zapomnieć, gdzie są jakie mutexy i kto co komu musi zrobić.

Zaczniemy od modelu zadania. Dla przykładu zadanie niech będzie id i tekst, który worker „wykona”, wypisując go:

#include <string>

struct Task {
    int id = 0;
    std::string text;
};

Teraz szkic kolejki:

#include <condition_variable>
#include <mutex>
#include <queue>

class TaskQueue {
public:
    void push(Task t);

    Task pop_blocking();

private:
    std::mutex m_;
    std::condition_variable cv_;
    std::queue<Task> q_;
};

Zaimplementujemy push. Zmieniamy stan (q_) pod mutex, a następnie powiadamiamy oczekującego consumera. Powiadomienie zwykle robi się po wyjściu z zakresu lock_guard, żeby nie budzić wątku „do środka mutexu”, ale w prostych scenariuszach powiadomienie pod mutexem nie łamie poprawności — jedynie może być mniej wydajne.

#include <utility> // std::move

void TaskQueue::push(Task t) {
    {
        std::lock_guard<std::mutex> lock(m_);
        q_.push(std::move(t));
    }
    cv_.notify_one();
}

Teraz pop_blocking. Potrzebujemy std::unique_lock, bo condition_variable::wait musi umieć tymczasowo zwolnić mutex i ponownie go zająć po przebudzeniu. lock_guard tego nie potrafi — on „zajmuje i trzyma do końca zakresu”, bez możliwości.

Task TaskQueue::pop_blocking() {
    std::unique_lock<std::mutex> lock(m_);

    cv_.wait(lock, [&] {
        return !q_.empty();
    });

    Task t = std::move(q_.front());
    q_.pop();
    return t;
}

Zwróć uwagę: zwracamy Task przez wartość. To wygodne i bezpieczne: pobraliśmy zadanie z kolejki pod mutexem, dalej worker operuje na własnej kopii/przesuniętym obiekcie. Nie zwracamy referencji do elementu kolejki, bo element znika po pop(), a „referencja do znikniętego” to kiepski prezent.

Worker: jak wygląda pętla consumera

Wątek-worker zwykle żyje w nieskończonej pętli: „czekam na zadanie → pobieram → wykonuję”. To podobne do kasjera, który siedzi i czeka na następnego klienta: nie biega co 10 ms z pytaniem „czy już?”, tylko spokojnie czeka. W kodzie rolę „spokojnego czekania” pełni condition_variable::wait.

Zrobimy funkcję workera. Aby nie projektować teraz pełnego protokołu zakończenia (to osobny temat), zastosujemy ograniczenie edukacyjne: worker przetworzy dokładnie N zadań i zakończy działanie. To nie „produkcja”, ale uczciwy sposób zbudować model bez zawieszeń.

#include <chrono>
#include <iostream>
#include <thread>

void worker_loop(TaskQueue& queue, int tasks_to_process) {
    for (int i = 0; i < tasks_to_process; ++i) {
        Task t = queue.pop_blocking(); // czekamy, az pojawi sie zadanie

        // przetwarzanie poza kolejka: symulujemy prace
        std::cout << "[worker] task #" << t.id << ": " << t.text << "\n";
        // przyklad wyjscia: [worker] task #1: say hello

        std::this_thread::sleep_for(std::chrono::milliseconds{50});
    }

    std::cout << "[worker] done\n"; // [worker] done
}

Ważne, że pop_blocking() blokuje wątek bez busy-wait. Worker nie „kręci się”, nie zjada CPU, tylko śpi, dopóki producer nie doda zadania i nie wywoła notify_one().

Producer: uruchamiamy workera i wrzucamy zadania do kolejki

Teraz złożymy małą aplikację konsolową. Niech wątek główny będzie producerem: tworzy zadania i wkłada je do kolejki. Drugi wątek — consumer: pobiera i wypisuje. To bardzo podobne do realnych scenariuszy: wątek główny „generuje zdarzenia”, worker je „przetwarza”.

Minimalne main() wygląda tak:

#include <iostream>
#include <thread>
#include <vector>

int main() {
    TaskQueue queue;

    const std::vector<Task> tasks = {
        {1, "say hello"},
        {2, "compute something (pretend)"},
        {3, "write log line"},
        {4, "send email (pretend)"},
        {5, "cleanup"},
    };

    std::thread worker(worker_loop, std::ref(queue), (int)tasks.size());

    for (const auto& t : tasks) {
        std::cout << "[main] push task #" << t.id << "\n";
        // przyklad wyjscia: [main] push task #1
        queue.push(t);
    }

    worker.join();
    std::cout << "[main] program finished\n"; // [main] program finished
}

Kilka detali do omówienia (bez krzyku). std::ref(queue) jest potrzebne, by przekazać kolejkę przez odniesienie; inaczej std::thread spróbuje skopiować queue, a nasz TaskQueue nie jest kopiowalny (zawiera mutex, a mutex — niekopiowalny).

Zwróć też uwagę na porządek: najpierw uruchamiamy workera, potem dodajemy zadania. Jeśli zrobisz odwrotnie, nic „logicznie” się nie zepsuje, ale sens demonstracji trochę ucierpi: worker i tak sobie poradzi, bo pop_blocking() poprawnie czeka na stan. To ważna cecha dobrego protokołu: powinien być poprawny niezależnie od tego, kto „zdążył pierwszy”.

Gdzie dokładnie trzymany jest mutex: „rentgen” protokołu

Kiedy zaczynasz, wielowątkowość wydaje się czarną skrzynką: „on czeka, potem się budzi, potem coś tam…”. Aby poczuć się pewniej, warto rozłożyć jedną iterację consumera na kroki i wyraźnie oznaczyć, gdzie trzymany jest mutex, a gdzie nie. Wtedy condition_variable przestaje wyglądać jak magia i staje się zwykłym protokołem.

Przedstawmy sekwencję zdarzeń przy jednym push i jednym pop:

sequenceDiagram
    participant P as Producer (main)
    participant M as Mutex
    participant Q as Queue
    participant CV as condition_variable
    participant C as Consumer (worker)

    C->>M: lock (unique_lock)
    C->>CV: wait(lock, pred)<br/>mutex jest zwolniony wewnatrz wait
    Note over C,CV: Watek worker spi

    P->>M: lock (lock_guard)
    P->>Q: push(task)
    P->>M: unlock
    P->>CV: notify_one()

    CV-->>C: wake up
    C->>M: lock (wraca z wait z zajetym mutexem)
    C->>Q: pop(task)
    C->>M: unlock
    C->>C: process task (bez mutexa)

Jeśli streścić to w jednym zdaniu, dostaniemy niemal zen: pod mutexem czytamy i zmieniamy stan, a wait — to „zasnąć, tymczasowo zwolnić mutex i obudzić się z mutexem”.

Bardzo częsty błąd — przetwarzanie zadania pod mutexem. Diagnostyka prosta: jeśli widzisz, że worker trzyma mutex i jednocześnie robi sleep_for, wypisuje duże porcje danych, lub, o zgrozo, wykonuje operacje sieciowe, to znaczy, że kolejka stała się wąskim gardłem.

3. Typowe błędy

Błąd nr 1: „Sprawdzę empty() bez mutexa, to tylko podglad”.
Tak zwykle zaczynają się historie o rzadkich, trudnych do złapania błądach. W wielowątkowości „tylko podgląd” to już udział w wyścigu, jeśli inny wątek może jednocześnie modyfikować kolejkę. Prawidłowa dyscyplina: każde empty()/front()/pop()/push() wykonujemy pod tym samym mutex, bez wyjątków.

Błąd nr 2: consumer robi front()/pop() bez gwarancji !empty().
Nawet jeśli wydaje się, że „producer już na pewno włożył”, rzeczywisty scheduler nie podpisał kontraktu „działaj według twojej wygody”. Consumer musi czekać na stan „kolejka niepusta” i dopiero wtedy pobierać element. W poprawnym modelu wyraża się to przez cv.wait(lock, predicate) albo równoważną pętlę.

Błąd nr 3: trzymanie mutexa podczas przetwarzania zadania.
To zabija równoległość. W efekcie producer stoi w kolejce za workerem jak w urzędzie. Właściwy wzorzec: pod mutexem tylko „pobrać zadanie z kolejki”, potem zwolnić mutex i dopiero wtedy przetwarzać. Jeśli przetwarzanie jest długie — tym bardziej musi być na zewnątrz.

Błąd nr 4: wywołanie notify_one() „gdzieś później” albo zapomnienie o nim.
Jeśli producer zmienia stan tak, że predykat oczekiwania staje się prawdziwy (np. kolejka przestaje być pusta), ale nie wykonuje notify_*, consumer może zbyt długo spać. W dobrej implementacji push zawsze kończy się powiadomieniem. To nie „optymalizacja”, to element protokołu.

Błąd nr 5: zwracanie na zewnątrz referencji/wskaźnika do elementu kolejki.
Kolejka to kontener, a element w kontenerze żyje tylko dopóki go nie usuniesz. Jeśli pobrałeś front(), potem pop(), a na zewnątrz gdzieś trzymasz referencję do „tego frontu”, otrzymasz referencję do już nieistniejącego obiektu. Solidna praktyka: pobrać wartość (kopią lub std::move) do lokalnej zmiennej pod mutexem, a dalej pracować z tą lokalną zmienną.

1
Zadanie
C++ SELF, poziom 70, lekcja 2
Niedostępne
Jedna wiadomość
Jedna wiadomość
1
Zadanie
C++ SELF, poziom 70, lekcja 2
Niedostępne
Kolejka wsparcia
Kolejka wsparcia
1
Zadanie
C++ SELF, poziom 70, lekcja 2
Niedostępne
Dzielimy sumę
Dzielimy sumę
1
Zadanie
C++ SELF, poziom 70, lekcja 2
Niedostępne
Dwa źródła
Dwa źródła
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION