Kompleksowy przewodnik po współprogramach C++ – od podstaw do zaawansowanej implementacji
C++20 wprowadził współprogramy jako przełomową funkcję umożliwiającą kooperatywne wielozadaniowość, programowanie asynchroniczne i leniwą obliczalność. W przeciwieństwie do tradycyjnych funkcji, współprogramy mogą zawieszać wykonanie w określonych punktach (co_await, co_yield, co_return) i później je wznawiać, zachowując stan pomiędzy wywołaniami. Niniejszy artykuł omawia mechanikę współprogramów, wzorce implementacyjne oraz praktyczne zastosowania, bazując na standardzie C++, transformacjach kompilatora i realnych scenariuszach użycia.
1. Wprowadzenie do współprogramów
Współprogramy generalizują podprogramy poprzez umożliwienie zawieszania i wznawiania wykonania. Umożliwia to:
- Przechowywanie stanu pomiędzy wywołaniami.
- Asynchroniczne wejście/wyjście bez blokowania wątków.
- Leniwą ewaluację (np. generatory).
C++ współprogramy są bezkstosowe – punkty zawieszenia przechowują stan na stercie (rama współprogramu), a nie na stosie wywołań. Funkcja staje się współprogramem, jeśli zawieraco_await,co_yieldlubco_returni zwraca typ spełniający wymogi współprogramu.
2. Mechanika działania współprogramów i transformacje kompilatora
Kiedy współprogram jest wywoływany, kompilator:
- Alokuje ramę współprogramu przechowującą zmienne lokalne, parametry i stan zawieszenia.
- Tworzy obiekt typu promise (za pomocą
std::coroutine_traits) do zarządzania zachowaniem współprogramu. - Generuje logikę maszyny stanów odpowiedzialną za zawieszanie i wznawianie.
2.1 Struktura ramy współprogramu
Rama przechowuje:
- Obiekt promise,
- Zawieszony kontekst wykonania (np. rejestry),
- Zmienne lokalne i tymczasowe.
Alokacja pamięci jest konfigurowalna przezoperator newlubstd::allocator_arg.
2.2 Odpowiedzialności typu promise
Typ promise definiuje:
get_return_object()– Tworzy wartość zwracaną przez współprogram;initial_suspend()/final_suspend()– Kontrolują zawieszenie na początku lub na końcu;unhandled_exception()– Obsługuje wyjątki;return_void()/return_value()– Obsługujeco_return.
struct GeneratorPromise {
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
Generator get_return_object() { /* … */ }
void return_void() noexcept {}
// …
};
3. Słowa kluczowe współprogramów i punkty rozszerzalności
3.1 co_await i protokół awaitable
co_await expr zawiesza wykonanie, jeśli awaitable expr nie jest gotowy. Obiekt awaitable musi definiować:
await_ready()– Zwracafalse, jeśli potrzebne jest zawieszenie,await_suspend(coroutine_handle)– Wznawia, gdy gotowe,await_resume()– Zwraca wynik.
struct Awaitable {
bool await_ready();
void await_suspend(std::coroutine_handle<>);
int await_resume();
};
Standard dostarcza std::suspend_always (zawsze zawiesza) oraz std::suspend_never (nigdy nie zawiesza).
3.2 co_yield dla generatorów
co_yield value to cukier składniowy dla:
co_await promise.yield_value(value);
Zawiesza współprogram i zwraca value do wywołującego.
3.3 co_return do zakończenia
co_return wywołuje promise.return_void() lub promise.return_value() przed finalnym zawieszeniem.
4. Budowa generatora – współprogram generujący
Generator produkuje wartości leniwie. Poniżej przykład generatora ciągu Fibonacciego:
4.1 Typ promise i typ zwracany
template<typename T>
struct Generator {
struct promise_type {
T current_value;
Generator get_return_object() { return Generator(this); }
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T value) {
current_value = value;
return {};
}
void return_void() noexcept {}
};
// Zarządzanie handle współprogramu
explicit Generator(promise_type* p)
: handle(std::coroutine_handle::from_promise(*p)) {}
bool next() {
if (handle.done()) return false;
handle.resume();
return !handle.done();
}
T value() const { return handle.promise().current_value; }
private:
std::coroutine_handle handle;
};
4.2 Ciało współprogramu
Generator<int> fib() {
int a = 0, b = 1;
while (true) {
co_yield a;
auto next = a + b;
a = b;
b = next;
}
}
// Użycie:
auto gen = fib();
gen.next(); // 0
gen.value(); // 0
gen.next(); // 1
5. Awaitable dla operacji asynchronicznych
Obiekty awaitable łączą współprogramy z operacjami asynchronicznymi. Przykład: integracja odczytu pliku:
5.1 Własny awaitable odczytu pliku
struct FileReadAwaitable {
int fd;
std::vector<uint8_t> buffer;
bool await_ready() { /* Sprawdź czy dane gotowe */ }
void await_suspend(std::coroutine_handle<> h) {
// Zarejestruj callback do wznowienia 'h', gdy I/O zakończy się
}
std::tuple<std::error_code, size_t> await_resume() {
return {error_code, bytes_read};
}
};
task readFile() {
auto [ec, bytes] = co_await FileReadAwaitable{fd, buffer};
// Przetwarzaj dane
}
6. Zaawansowane tematy
6.1 Optymalizacja pamięci
- Eliminacja współprogramu – Kompilatory mogą unikać alokacji na stercie dla współprogramów o statycznie znanym czasie życia;
- Własne alokatory – Możliwa nadpisanie
operator neww typie promise.
6.2 Propagacja wyjątków
promise_type::unhandled_exception() powinien przechwytywać wyjątki do ponownego zgłoszenia przez coroutine_handle.
6.3 Wielowątkowość i współprogramy
Współprogramy wznawiane są na wątku wywołującym/wznawiającym, co umożliwia migrację wątków bez blokad.
7. Nowości w C++23
C++23 upraszcza korzystanie ze współprogramów:
std::generator<T>– Standardowy leniwy generator (np. dla ranges);std::ranges::elements_of– Umożliwia generatory rekurencyjne.
std::generator<int> tree_traversal(Node* node) {
if (node) {
co_yield node->value;
co_yield std::ranges::elements_of(tree_traversal(node->left));
co_yield std::ranges::elements_of(tree_traversal(node->right));
}
}
8. Dobre praktyki i pułapki
- Unikaj parametrów przez referencję – Parametry przekazane przez referencję mogą odwoływać się do nieistniejących obiektów, jeśli współprogram przetrwa funkcję wywołującą;
- Zarządzanie zasobami – Używaj RAII lub
final_suspend(), by zapobiegać wyciekom pamięci; - Wsparcie kompilatora – Używaj GCC z
-fcoroutineslub Clang z-fcoroutines-tsoraz libc++.
Podsumowanie
Współprogramy w C++ są podstawowym mechanizmem do programowania asynchronicznego, generatorów i obliczeń ze stanem. Choć początkowo wymagają większej ilości kodu szablonowego, zrozumienie współpracy promise, awaitable i coroutine handle pozwala na budowę potężnych abstrakcji. Przyszłe standardy rozszerzą wsparcie biblioteczne (np. networking), upraszczając implementację. Wdrażając współprogramy w systemach produkcyjnych, należy priorytetowo traktować bezpieczeństwo pamięciowe, obsługę wyjątków oraz kompatybilność z kompilatorem.
Zagadnienia opracowano na podstawie materiałów KDAB, analizy Lewisa Bakera, Modernes C++ oraz dokumentacji standardu C++.
