I generatori di Python sono implementati come oggetti frame sospesi (PyFrameObject) che mantengono il loro stato di esecuzione tra le invocazioni. Quando viene chiamato send(value), la funzione interna di CPython gen_send_ex() inserisce questo valore nello stack dei valori del generatore, che poi l'espressione yield estrae e restituisce al chiamante. Questo è diverso dalla chiamata iniziale a next(), che invia implicitamente None per preparare il generatore dal suo stato iniziale (dove f_lasti == -1) al primo punto di yield. Se send() viene chiamato con un valore diverso da None prima che il generatore abbia restituito per la prima volta, CPython solleva un TypeError perché il frame del generatore non ha una posizione nello stack per ricevere il valore. Questa distinzione architettonica assicura che la comunicazione bidirezionale inizi solo dopo che il generatore ha raggiunto il suo primo punto di sospensione.
Abbiamo dovuto implementare un pipeline di dati consapevole della contropressione per elaborare flussi di dati di mercato ad alta frequenza, dove i consumatori downstream potevano segnalare dinamicamente ai produttori upstream di limitare o riprendere il flusso di dati senza perdere messaggi o esaurire la memoria.
Un approccio considerato utilizzava il threading con istanze di queue.Queue limitate tra le fasi della pipeline. Sebbene ciò fornisse semantiche di blocco familiari e sicurezza nei thread, soffriva di una grave contesa del GIL e sovraccarico di cambio di contesto, consumando il 15% della CPU solo per la coordinazione ad alti throughput, mentre aggiungeva picchi di latenza imprevedibili.
Un'altra alternativa consisteva nella migrazione a coroutine asyncio e nella sintassi async/await. Questo avrebbe eliminato la contesa del GIL ma richiedeva una riscrittura completa della nostra libreria di analisi numerica sincrona in una forma compatibile con async, creando una ristrutturazione virale che avrebbe toccato migliaia di righe di logica aziendale e introdotto problemi di compatibilità con le estensioni legacy in C.
Alla fine, abbiamo selezionato un approccio di multitasking cooperativo basato su generatori utilizzando send() per trasmettere "crediti di domanda" upstream. Questa soluzione ha evitato completamente il sovraccarico del GIL, non ha richiesto riscritture di librerie poiché i generatori funzionano nel codice sincrono e ha fornito un controllo di flusso esplicito attraverso i modelli demand = (yield data_chunk) che hanno consentito ai consumatori downstream di interrompere immediatamente la produzione upstream inviando valori zero.
Il risultato è stata una riduzione del 40% dell'uso della memoria rispetto all'approccio con la coda, la latenza è stata stabilizzata al di sotto dei 5 millisecondi e il codice è rimasto leggibile con punti di yield espliciti che segnavano i confini di sospensione.
Perché chiamare send() con un valore diverso da None su un generatore appena creato solleva TypeError, e come questa restrizione impone il protocollo del generatore?
Quando un generatore viene creato per la prima volta, il suo puntatore frame f_lasti è -1, il che indica che nessun bytecode è stato eseguito. L'interprete CPython verifica se il generatore è non avviato quando viene invocato send(); se il valore inviato non è None, solleva un TypeError perché l'espressione yield non è ancora stata raggiunta per fornire uno slot nello stack per il valore. Questa enforcement assicura che la logica di inizializzazione del generatore venga completata prima che inizi la comunicazione bidirezionale, mantenendo l'invarianza che i valori fluiscono nel generatore solo nei punti di sospensione yield espliciti.
Come garantisce generator.close() che il codice di cleanup all'interno del generatore venga eseguito, e cosa distingue l'eccezione GeneratorExit dalle eccezioni regolari?
Il metodo close() invia un'eccezione GeneratorExit nel generatore al suo attuale punto di sospensione chiamando throw(GeneratorExit). GeneratorExit eredita da BaseException piuttosto che da Exception per impedire che venga catturata da gestori generici except Exception che potrebbero gestirla in modo improprio. Se il generatore cattura GeneratorExit e la rilancia o termina normalmente, close() restituisce silenziosamente; tuttavia, se il generatore restituisce un valore in risposta a GeneratorExit, CPython solleva un RuntimeError perché un generatore che sta chiudendo non deve produrre nuovi valori. Questo meccanismo garantisce che i blocchi finally e i gestori di contesto all'interno del corpo del generatore vengano eseguiti anche durante la terminazione forzata.
Quale meccanismo consente a yield from di gestire valori inviati in modo trasparente attraverso generatori annidati, e come questo differisce dalla delega manuale utilizzando un ciclo con send()?
La sintassi yield from delega non solo l'iterazione ma l'intero protocollo del generatore a un sub-generatore. Quando il generatore esterno esegue yield from subgen(), CPython trasforma il send(value) del chiamante in un invio diretto al sub-generatore fino a quando non solleva StopIteration (il cui valore diventa il risultato dell'espressione yield from). Questo è diverso dalla delega manuale in cui un ciclo come for x in subgen(): yield x non può intercettare i valori inviati nel generatore esterno per inoltrarli a quello interno. Il costrutto yield from appiattisce essenzialmente lo stack delle chiamate, consentendo un flusso di dati bidirezionale attraverso annidamenti arbitrariamente profondi di generatori senza codice di inoltro boilerplate, mantenendo al contempo la corretta propagazione delle eccezioni e le semantiche di chiusura.