C++programowanieStarszy programista C++

W odniesieniu do typu obietnicy powiązanej z korutyną w C++20, jaki konkretny typ zwracany z `await_suspend` umożliwia bezstakowe symetryczne przenoszenie korutyn?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie.

Historia pytania

Wczesne implementacje korutyn były stakowe, przydzielając megabajty stałej przestrzeni stosu na kontekst przełączania, co ograniczało współbieżność do tysięcy zadań. C++20 wprowadził korutyny bezstakowe, przydzielające ramki w stercie, jednak naiwna rekurencyjna kompozycja wciąż groziła przepełnieniem stosu, ponieważ przeniesienie asymetryczne — zwracanie void lub bool z await_suspend — zmuszało wznawiającego do wywołania resume(), budując O(N) natywne ramki stosu wywołań. Znormalizowano przeniesienie symetryczne, aby umożliwić korutynie A bezpośrednie wznowienie korutyny B, oddając ramkę stosu A dzięki obowiązkowej optymalizacji wywołania ogonowego.

Problem

Gdy korutyna A wykonuje co_await na korutynie B, a B oczekuje na C, przeniesienie asymetryczne wymaga, aby każde wywołanie resume() wracało do swojego wywołującego przed dalszym zejściem. Przy głębokości rekurencji N (np. przechodząc przez 50 000+ węzłów drzewa), wyczerpuje to natywny stos, pomimo że każda ramka korutyny znajduje się w stercie, co powoduje SIGSEGV lub STATUS_STACK_OVERFLOW.

Rozwiązanie

await_suspend musi zwracać std::coroutine_handle<Promise> (lub std::coroutine_handle<>). Kompilator traktuje to jako wywołanie ogonowe: usuwa aktualny rekord aktywacji i przechodzi bezpośrednio do punktu wznowienia docelowego uchwytu bez zwiększania stosu wywołań. Ten mechanizm gwarantuje wykonanie o stałej głębokości stosu niezależnie od logicznej głębokości zagnieżdżenia korutyn.

struct Task { struct promise_type { Task get_return_object() { return Task{std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_always initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {} }; std::coroutine_handle<> h; }; struct SymmetricAwaiter { std::coroutine_handle<> target; bool await_ready() const noexcept { return false; } // Asymetryczne (złe): void await_suspend(std::coroutine_handle<>) { target.resume(); } // Symetryczne (dobre): optymalizacja wywołania ogonowego std::coroutine_handle<> await_suspend(std::coroutine_handle<>) noexcept { return target; } void await_resume() noexcept {} };

Sytuacja z życia

Opis problemu

Podczas opracowywania silnika handlu o wysokiej częstotliwości przeszliśmy z asynchronicznego I/O opartego na wywołaniach zwrotnych do C++20 korutyn w celu modelowania złożonych drzew cenowych instrumentów pochodnych. Podczas testów obciążeniowych z portfelem zawierającym głęboko zagnieżdżone syntetyczne opcje (50 000+ poziomów), system uległ awarii wskutek przepełnienia stosu, mimo że używano ramek korutyn przydzielonych w stercie. Sprawcą był początkowy sposób implementacji await_suspend, który zwracał void, co powodowało proporcjonalny wzrost natywnego stosu w zależności od głębokości modelu cenowego.

Rozważane różne rozwiązania

Rozwiązanie 1: Zwiększenie rozmiaru natywnego stosu za pomocą ulimit -s lub flag linkera.

Zalety obejmowały brak konieczności zmian w kodzie i natychmiastową ulgę podczas testowania. Wady obejmowały marnotrawstwo gigabajtów pamięci wirtualnej na wątek, brak rozwiązań dla nieograniczonych scenariuszy rekurencji oraz stworzenie koszmarów przenośności między Linux a Windows, gdzie mechanizmy alokacji stosu różnią się znacznie.

Rozwiązanie 2: Wdrożenie pętli wykonawczej trampoliny, która nigdy nie rekurencuje.

Zalety obejmowały zachowanie składni korutyn w nienaruszonej formie, przenosząc zarządzanie stosem do centralnej pętli zdarzeń. Wady obejmowały znaczne opóźnienia (setki nanosekund na przełączenie kontekstu z powodu wirtualnego dispatchingu), zwiększoną złożoność kodu w schedulerze oraz utratę optymalizacji kompilatora dla alokacji rejestrów w punktach zawieszenia.

Rozwiązanie 3: Przyjęcie przeniesienia symetrycznego poprzez zwracanie std::coroutine_handle z await_suspend.

Zalety zapewniały abstrakcję bez narzutu (identyczny kod assemblera jak w ręcznie pisanych maszynach stanów), naturalnie radziły sobie z nieograniczoną rekurencją bez wzrostu stosu i utrzymywały czytelną składnię korutyn. Wady wymagały wsparcia kompilatora C++20 (początkowo ograniczone na niektórych platformach wbudowanych) oraz skomplikowały debugowanie, ponieważ ślady stosu wydawały się skrócone z powodu eliminacji wywołań ogonowych.

Które rozwiązanie zostało wybrane i dlaczego

Wybraliśmy Rozwiązanie 3, ponieważ modele finansowe z natury wymagały nieograniczonej głębokości rekurencji dla teoretycznych obliczeń cenowych. Budżet opóźnienia w mikrosekundach nie mógł tolerować narzutu trampoliny, a ograniczenia pamięci uniemożliwiły masowe wstępne przydzielanie stosu. Przeniesienie symetryczne stanowiło jedyne rozwiązanie bezkosztowe, które było zarówno poprawne, jak i efektywne.

Wynik

Silnik z powodzeniem przetworzył portfele z 100 000+ poziomami zagnieżdżenia bez awarii. Benchmarki opóźnienia wykazały identyczną wydajność jak w ręcznie optymalizowanych maszynach stanów w C, a wykorzystanie pamięci pozostało stabilne niezależnie od głębokości rekurencji. System działał w produkcji przez 18 miesięcy bez żadnych awarii związanych ze stosem.

Co często umyka kandydatom

Dlaczego zwracanie void przez await_suspend różni się od zwracania true pod względem momentu zawieszenia ramki korutyny i dlaczego ma to znaczenie dla bezpieczeństwa wątków?

Wielu kandydatów zakłada, że void oznacza natychmiastowe zawieszenie i przeniesienie kontroli. W rzeczywistości zwracanie void zawiesza aktualną korutynę, ale kontrola wraca do wywołującego resume(), który następnie decyduje o następnym kroku wykonania. Zwracanie true również zawiesza, ale krytycznie, void gwarantuje, że korutyna jest zawieszona przed zwróceniem z await_suspend, podczas gdy dokładny czas zawieszenia przy zwracaniu bool może się różnić w zależności od implementacji. Ta różnica ma znaczenie, ponieważ dostęp do lokalnych zmiennych korutyny po zwróceniu void (np. z innego wątku) jest bezpieczny tylko po osiągnięciu punktu zawieszenia. W przypadku przeniesienia symetrycznego (zwracania uchwytu) ramka stosu jest natychmiast usuwana po zwróceniu, co czyni lokalne zmienne niedostępnymi natychmiast — kandydaci często wprowadzają wyścigi danych, uzyskując dostęp do zebranych zmiennych po zainicjowaniu przeniesienia symetrycznego.

Jak przeniesienie symetryczne współdziała z obsługą wyjątków, gdy docelowa korutyna zgłasza wyjątek, i dlaczego komplikuje to unhandled_exception w typie obietnicy?

Kandydaci często nie dostrzegają faktu, że przeniesienie symetryczne omija normalne rozwiązywanie stosu przez oczekującą korutynę. Kiedy korutyna A symetrycznie przenosi do B, a B zgłasza wyjątek, wyjątek propaguje się do unhandled_exception B. Jednak ramka stosu A została już zastąpiona przez optymalizację wywołania ogonowego, co oznacza, że A nie może wychwycić wyjątków z B za pomocą try/catch wokół wyrażenia co_await. Wyjątek zamiast tego propaguje się do pierwotnego wywołującego A (wznawiającego), potencjalnie pomijając kod sprzątający A, chyba że unhandled_exception w obietnicy A zarządza stanem wyłącznie przez przydzieloną w stercie ramkę. Początkujący często zakładają, że strażnicy RAII na stosie zadziałają w A, prowadząc do wycieków zasobów, gdy wyjątki występują w łańcuchach symetrycznych.

Jakie jest znaczenie std::noop_coroutine() w łańcuchach przeniesienia symetrycznego i dlaczego należy go zwracać zamiast domyślnie skonstruowanego uchwytu, aby wskazać zakończenie?

Domyślnie skonstruowany std::coroutine_handle to uchwyt null, który wykazuje niezdefiniowane zachowanie, jeśli jest wznawiany. Zwracanie go z await_suspend wskazuje „niczego teraz nie wznawiaj”, pozostawiając bieżącą korutynę zawieszoną bez następcy i potencjalnie wieszając system, jeśli planner oczekuje ważnej kontynuacji. std::noop_coroutine() zwraca specjalny pojedynczy uchwyt, który, gdy jest wznawiany, natychmiast wraca do swojego wywołującego. To jest kluczowe dla zakończenia: gdy liściasta korutyna kończy i chce przekazać kontrolę do swojego rodzica bez ręcznego wznawiania, zwraca std::noop_coroutine(). To pozwala na to, aby await_suspend rodzica (który symetrycznie przeniósł do dziecka) otrzymał ważną „kontynuację”, która po prostu wraca, skutecznie kończąc łańcuch w sposób bezpieczny. Kandydaci mylą uchwyty null z uchwytami noop, co prowadzi do subtelnych zakleszczeń, w których system korutyn czeka na wieczność na null jako cel wznowienia.