Generatory Python są implementowane jako wstrzymane obiekty ramki (PyFrameObject), które utrzymują stan wykonania pomiędzy wywołaniami. Gdy wywołana jest send(value), wewnętrzna funkcja CPython gen_send_ex() umieszcza tę wartość na stosie wartości generatora, który następnie wyrażenie yield popsuje i zwróci wywołującemu. Różni się to od początkowego wywołania next(), które niejawnie wysyła None, aby przygotować generator z jego początkowego stanu (gdzie f_lasti == -1) do pierwszego wyrażenia yield. Jeśli send() zostanie wywołane z wartością inną niż None przed tym, jak generator po raz pierwszy podał wartość, CPython zgłasza TypeError, ponieważ klatka generatora nie ma pozycji na stosie, aby otrzymać tę wartość. Ta różnica architektoniczna zapewnia, że dwukierunkowa komunikacja zaczyna się dopiero po osiągnięciu przez generator pierwszego punktu zawieszenia.
Musieliśmy zaimplementować pipeline danych uwzględniający przeciążenie do przetwarzania danych rynkowych o wysokiej częstotliwości, gdzie konsumenci w dół mogli dynamicznie sygnalizować producentom w górę, aby spowolnić lub wznowić przepływ danych bez utraty wiadomości lub wyczerpania pamięci.
Jednym z rozważanych podejść było użycie wątków z ograniczonymi instancjami queue.Queue pomiędzy etapami pipeline'u. Chociaż zapewniało to znane semantyki blokowania i bezpieczeństwo wątków, cierpiało z powodu poważnego konfliktu GIL i kosztów przełączania kontekstu, zużywając 15% CPU wyłącznie na koordynację przy wysokiej przepustowości, a jednocześnie wprowadzając nieprzewidywalne skoki opóźnienia.
Alternatywą było migracja do korożyt asyncio i składni async/await. To wyeliminowałoby konflikt GIL, ale wymagałoby całkowitego przepisania naszej synchronicznej biblioteki analityki numerycznej w formie zgodnej z asynchronicznością, tworząc wirusową refaktoryzację, która dotknęłaby tysięcy linii logiki biznesowej i wprowadziłaby problemy ze zgodnością z dziedzicznymi rozszerzeniami C.
Ostatecznie zdecydowaliśmy się na podejście oparte na generatorach w ramach kooperatywnego wielozadaniowości z użyciem send() do przesyłania "kredytów popytu" w górę. To rozwiązanie całkowicie uniknęło narzutów GIL, nie wymagało żadnych poprawek w bibliotekach, ponieważ generatory działają w kodzie synchronicznym, i umożliwiło wyraźny przepływ kontrolny poprzez wzorce demand = (yield data_chunk), które pozwalały konsumentom w dół natychmiast wstrzymać produkcję w górę, wysyłając wartości zerowe.
Wynikował stąd 40% spadek zużycia pamięci w porównaniu do podejścia opartego na kolejce, a opóźnienia ustabilizowały się poniżej 5 milisekund, a baza kodu pozostała czytelna z wyraźnymi punktami zwrotnymi oznaczającymi granice zawieszenia.
Dlaczego wywołanie send() z wartością różną od None na nowo utworzonym generatorze zgłasza TypeError, i jak to ograniczenie egzekwuje protokół generatora?
Kiedy generator jest po raz pierwszy tworzony, jego wskaźnik ramki f_lasti wynosi -1, co wskazuje, że żaden bajtkod nie został jeszcze wykonany. Interpreter CPython sprawdza, czy generator jest niedziałający, gdy wywoływane jest send(); jeśli wysłana wartość nie jest None, zgłasza TypeError, ponieważ wyrażenie yield nie zostało jeszcze osiągnięte, aby zapewnić slot na stosie dla tej wartości. To egzekwowanie zapewnia, że logika inicjalizacji generatora jest uruchamiana do końca, zanim zacznie się dwukierunkowa komunikacja, utrzymując inwariant, że wartości wpływają do generatora tylko w wyraźnych punktach zawieszenia yield.
Jak generator.close() zapewnia, że kod czyszczący wewnątrz generatora zostanie wykonany, i jakie są różnice między wyjątkiem GeneratorExit, a regularnymi wyjątkami?
Metoda close() wysyła wyjątek GeneratorExit do generatora w jego bieżącym punkcie zawieszenia, wywołując throw(GeneratorExit). GeneratorExit dziedziczy z BaseException zamiast Exception, aby zapobiec jego przechwyceniu przez ogólne obsługiwacze except Exception, które mogłyby go nieprawidłowo zignorować. Jeśli generator złapie GeneratorExit i ponownie go zgłosi lub zakończy normalnie, close() zwraca cicho; jednak jeśli generator zwraca wartość w odpowiedzi na GeneratorExit, CPython zgłasza RuntimeError, ponieważ zamykający się generator nie powinien produkować nowych wartości. Ten mechanizm gwarantuje, że bloki finally i menedżerowie kontekstu wewnątrz ciała generatora zostaną wykonane nawet podczas wymuszonego zakończenia.
Jaki mechanizm umożliwia yield from bezproblemowe obsługiwanie przesyłanych wartości entre zagnieżdżonymi generatorami, i jak to różni się od ręcznej delegacji przy użyciu pętli z send()?
Składnia yield from deleguje nie tylko iterację, ale cały protokół generatora do podgeneratora. Gdy zewnętrzny generator wykonuje yield from subgen(), CPython przekształca polecenie send(value) wywołującego na bezpośrednie wysłanie do podgeneratora, aż ten zgłosi StopIteration (którego wartość staje się wynikiem wyrażenia yield from). Różni się to od ręcznej delegacji, w której pętla, taka jak for x in subgen(): yield x, nie może przechwytywać wartości wysyłanych do zewnętrznego generatora, aby przekazać je do wewnętrznego. Konstrukcja yield from w zasadzie spłaszcza stos wywołań, umożliwiając dwukierunkowy przepływ danych przez dowolnie głębokie zagnieżdżenie generatorów bez zbędnego kodu przekazującego, zachowując jednocześnie odpowiednie propagacje wyjątków i semantyki zamykania.