Wstęp
Pewnie nieraz obiło ci się o uszy, że korutyny (coroutines) to lekkie wątki. I choć to przydatny model myślowy, nie mówi absolutnie nic o tym, jak naprawdę działają. Naprawmy to. Dziś sprawdzimy, co faktycznie robi kompilator Kotlina, kiedy piszesz funkcję suspend. Zobaczymy wygenerowany kod, zrozumiemy maszynę stanów i odczarujemy magię stojącą za zawieszaniem i wznawianiem. Pod koniec tego odcinka suspend przestanie być słowem kluczowym, które przyjmujesz na wiarę. Będziesz wiedzieć, na poziomie kompilatora, co dokładnie robi z twoim kodem i dlaczego korutyny mogą być o tyle wydajniejsze od wątków. To jeden z tych tematów, w których zrozumienie mechanizmów od środka nie tylko zaspokaja ciekawość, ale realnie zmienia sposób, w jaki piszesz korutyny.
Korutyny to nie wątki
Zacznijmy od precyzyjnego określenia, czym korutyny nie są. Korutyna nie jest wątkiem. Kiedy wywołujesz launch albo async, system nie tworzy nowego wątku systemu operacyjnego. Nie jest alokowany żaden nowy stos. Nie powstaje żadna struktura danych w jądrze. Korutyna to porcja obliczeń, które można zawiesić. To obiekt — zwykły obiekt alokowany na stercie — który potrafi wykonywać swój kod kawałkami. Pomiędzy tymi kawałkami może się zawiesić, zwalniając wątek, na którym działała, a później wznowić — być może na innym wątku. Sama korutyna nie posiada wątku. Pożycza go, gdy musi coś wykonać, i oddaje, gdy się zawiesza. Dlatego możesz mieć sto tysięcy korutyn i nie zabraknie ci pamięci — a to byłoby niemożliwe przy stu tysiącach wątków, z których każdy potrzebuje megabajtowego stosu.
Co suspend robi z sygnaturą funkcji
Zobaczmy teraz, co dzieje się na poziomie kompilatora. Kiedy piszesz funkcję suspend, kompilator Kotlina przekształca ją w bardzo konkretny sposób. To przekształcenie nazywa się Continuation Passing Style, w skrócie CPS. Oto kluczowa idea. Każda funkcja suspend dostaje od kompilatora dodatkowy parametr: Continuation. Continuation to interfejs callbacku z dwoma elementami: metodą resumeWith, która dostarcza wynik, oraz właściwością context. Jeśli więc piszesz funkcję suspend o nazwie fetchUser, która przyjmuje id i zwraca User, kompilator przekształca jej sygnaturę w zwykłą funkcję, która przyjmuje id oraz Continuation<User> i zwraca Any? — czyli nullowalne Any. Ten typ zwracany Any? jest ważny. Funkcja może teraz zwrócić albo faktyczny wynik typu User, albo specjalną wartość-wartownika o nazwie COROUTINE_SUSPENDED. Ta wartość mówi wywołującemu, że funkcja jeszcze się nie zakończyła i dostarczy wynik później, przez callback w obiekcie continuation.
Maszyna stanów
Samo CPS jednak nie wystarcza. Funkcja suspend może mieć wiele punktów zawieszenia. Jeśli twoja funkcja wywołuje delay, potem pobiera dane z sieci, a potem wywołuje kolejną funkcję suspend, ma trzy miejsca, w których może się zawiesić. Kompilator potrzebuje sposobu, by śledzić, w którym punkcie zawieszenia znajduje się funkcja, żeby wznowić ją we właściwym miejscu. Robi to, przekształcając całą funkcję suspend w maszynę stanów. Kompilator generuje wyrażenie when, podobne do instrukcji switch, z etykietami (label). Etykieta zero to stan początkowy — kod przed pierwszym punktem zawieszenia. Etykieta jeden to kod po pierwszym punkcie zawieszenia. Etykieta dwa to kod po drugim punkcie zawieszenia. I tak dalej. Przy każdym wywołaniu funkcja sprawdza, na której etykiecie się znajduje, i przeskakuje do odpowiedniego fragmentu kodu. Zmienne lokalne, które muszą przetrwać punkty zawieszenia, są przechowywane jako pola obiektu continuation, a nie na stosie. To kluczowe: stan korutyny żyje na stercie, a nie na stosie wątku.
Konkretny przykład
Przejdźmy przez prawdziwy przykład. Powiedzmy, że masz funkcję suspend o nazwie loadData. Wywołuje delay na jedną sekundę, potem wywołuje fetchFromNetwork, która też jest funkcją suspend, i zwraca wynik. Oto, co koncepcyjnie generuje kompilator. Tworzy klasę continuation z polami na etykietę, na zmienne lokalne i na wynik. Kiedy loadData jest wywoływana po raz pierwszy, etykieta ma wartość zero. Kod wchodzi do bloku when w gałęzi zero, ustawia etykietę na jeden, przygotowując się do wznowienia, i wywołuje delay. Jeśli delay się zawiesi, nasza funkcja zwraca COROUTINE_SUSPENDED. Później, gdy delay się skończy, wywoływana jest metoda resumeWith obiektu continuation, która ponownie wywołuje loadData z tym samym continuation. Tym razem etykieta ma wartość jeden, więc kod przeskakuje do drugiego fragmentu, ustawia etykietę na dwa i wywołuje fetchFromNetwork. Jeśli ta funkcja się zawiesi, znów zwracane jest COROUTINE_SUSPENDED. Gdy przyjdzie wynik z sieci, resumeWith zostaje wywołane ponownie, etykieta ma teraz wartość dwa, a funkcja wyciąga wynik i go zwraca. Trzy wywołania tej samej funkcji, z których każde podejmuje pracę tam, gdzie skończyło poprzednie — a kieruje nimi etykieta.
Jak działa zawieszanie
Opiszmy bardzo precyzyjnie, co dzieje się w momencie zawieszenia. Gdy korutyna dochodzi do punktu zawieszenia — powiedzmy, wywołuje delay — sekwencja wygląda tak. Po pierwsze, wszystkie zmienne lokalne potrzebne po punkcie zawieszenia są zapisywane w polach obiektu continuation. Po drugie, etykieta jest aktualizowana tak, by wskazywała następny fragment maszyny stanów. Po trzecie, wywoływana funkcja suspend — w tym przypadku delay — otrzymuje continuation jako swój callback. Po czwarte, funkcja zwraca w górę łańcucha wywołań specjalną wartość COROUTINE_SUSPENDED. Ten zwrot propaguje się aż do pętli wykonawczej dispatchera. Wątek, który wykonywał tę korutynę, jest teraz wolny. Dosłownie wraca do puli wątków. Stan korutyny jest bezpiecznie zaparkowany w obiekcie continuation na stercie i czeka. Żaden wątek nie jest zablokowany. Żadne miejsce na stosie nie jest zajęte. Jest tylko mały obiekt w pamięci.
Jak działa wznawianie
Teraz druga połowa: wznawianie. Gdy to, na co czekała korutyna, się zakończy — powiedzmy, odpali timer delay — system wywołuje metodę resumeWith obiektu continuation i przekazuje jej wynik. Oto, co się wtedy dzieje. Dispatcher odbiera continuation i planuje jego wykonanie na dostępnym wątku z puli. Gdy któryś wątek je podejmie, ponownie wywołuje funkcję korutyny, przekazując to samo continuation. Funkcja sprawdza etykietę, przeskakuje do właściwego fragmentu maszyny stanów, odczytuje zapisane zmienne lokalne z pól continuation, pobiera wynik z wywołania resumeWith i kontynuuje wykonanie od miejsca, w którym przerwała. Wątek wykonujący wznowienie może być zupełnie innym wątkiem niż ten, który działał przed zawieszeniem. Korutynie to nie przeszkadza. Jej stan jest w continuation, a nie na stosie konkretnego wątku. Dlatego korutyny mogą przezroczyście migrować między wątkami. Wątek to tylko zasób wykonawczy. Tożsamość i stan korutyny znajdują się w obiekcie continuation.
Dlaczego korutyny są lekkie
Teraz widać, dlaczego korutyny nazywa się lekkimi. Wątek wymaga około jednego megabajta na stos, do tego struktur danych w jądrze i narzutu schedulera. Korutyna to tylko mały obiekt na stercie, zwykle kilkaset bajtów, zawierający etykietę, kilka zapisanych zmiennych i referencję do nadrzędnego continuation. Utworzenie korutyny to po prostu alokacja obiektu. Zawieszenie to po prostu zapisanie kilku pól i powrót z funkcji. Wznowienie to po prostu wywołanie funkcji. Nie ma udziału jądra, alokacji stosu ani przełączania kontekstu. Różnica w koszcie jest ogromna. Utworzenie wątku wymaga wywołania systemowego do jądra, zaalokowania megabajta ciągłej pamięci wirtualnej, ustawienia mapowań stron i dodania wpisu do schedulera. Utworzenie korutyny wymaga zaalokowania małego obiektu na stercie JVM, co zajmuje nanosekundy. Dlatego spokojnie możesz utworzyć sto tysięcy korutyn, a stu tysięcy wątków nikt rozsądny by nie utworzył.
Dekompilacja funkcji suspend
Ten wygenerowany kod możesz zobaczyć na własne oczy. W Android Studio napisz prostą funkcję suspend, przejdź do Tools, potem Kotlin, potem Show Kotlin Bytecode i kliknij Decompile. Zobaczysz klasę continuation z polem label i metodą invokeSuspend zawierającą blok when. Sprawdzenia etykiety, porównania z COROUTINE_SUSPENDED, rzutowania — wszystko tam jest. Wygląda to rozwlekle i mechanicznie, bo to kod generowany, nieprzeznaczony dla ludzi. Ale gdy wiesz, czego szukać, wszystko nabiera sensu. Etykieta napędza maszynę stanów. Pola continuation przechowują zmienne lokalne, które muszą przetrwać zawieszenie. Wartownik COROUTINE_SUSPENDED decyduje, czy wykonanie trwa dalej, czy zostaje wstrzymane. Gorąco polecam zrobić to choć raz z funkcją, która ma dwa lub trzy punkty zawieszenia. Gdy zobaczysz wygenerowaną maszynę stanów na własne oczy, cały model korutyn wskoczy na swoje miejsce w sposób, jakiego nie zapewni żadna ilość czytania dokumentacji.
Co to oznacza dla twojego kodu
Zrozumienie tej transformacji kompilatora zmienia sposób myślenia o kilku rzeczach. Po pierwsze, zawieszenie jest tanie. Gdy wywołujesz delay albo withContext, nie dzieje się nic ciężkiego. To tylko zapisanie kilku pól i powrót. Nie bój się często zawieszać. Po drugie, zmienne lokalne, które przechodzą przez punkty zawieszenia, nie leżą na stosie. Są na stercie, w obiekcie continuation. Dzięki temu przetrwają nawet wtedy, gdy żaden wątek nie wykonuje twojego kodu. Po trzecie, każde wywołanie funkcji suspend to potencjalny punkt zawieszenia. Kompilator generuje maszynę stanów wokół każdego wywołania innej funkcji suspend, nawet jeśli ta funkcja nie zawsze faktycznie się zawiesza. Po czwarte, obiekt continuation to callback. Pod całym tym lukrem składniowym korutyny są callbackami. Kompilator po prostu pisze za ciebie kod callbacków — zarządza przejściami między stanami, obsługą błędów i anulowaniem — dzięki czemu twój kod może wyglądać na sekwencyjny. Na tym polega geniusz korutyn w Kotlinie: to generowane przez kompilator maszyny callbacków, które wyglądają jak zwykły, liniowy kod.
Podsumowanie i co dalej
Zbierzmy wszystko razem. Kiedy piszesz funkcję suspend, kompilator dodaje parametr Continuation i przekształca ciało funkcji w maszynę stanów. Każdy punkt zawieszenia staje się etykietą w wyrażeniu when. Gdy korutyna się zawiesza, zapisuje swój stan w continuation, zwraca COROUTINE_SUSPENDED i zwalnia wątek. Gdy się wznawia, dispatcher planuje ją na dostępnym wątku, a maszyna stanów rusza od zapisanej etykiety. Korutyny są lekkie, bo to zwykłe obiekty na stercie, a nie wątki systemu operacyjnego. Bez udziału jądra, bez megabajtowych stosów. Każdą funkcję suspend możesz zdekompilować w Android Studio i zobaczyć wygenerowaną maszynę stanów. W następnym, ostatnim filmie tej fazy sprawdzimy, gdzie właściwie wykonuje się kod twoich korutyn: przyjrzymy się dispatcherom. Zrozumiemy Main, Default, IO i Unconfined oraz dowiemy się, dlaczego wybór niewłaściwego prowadzi do ANR-ów albo marnowania zasobów. Do zobaczenia.