Kotlinowe korutyny - o co w nich chodzi?
Korutyny w Kotlinie są tam obecne od dłuższego czasu. W tym artykule chciałbym zagłębić się do magicznego świata współbieżności.
Ale chwileczkę... nie możemy tak po prostu zabrać się do tego nieprzygotowani. Przejdźmy przez krótkie podsumowanie i odpowiedzmy sobie na kilka pytań:
- Co dokładnie oznacza współbieżność?
- W jaki sposób współbieżność wiąże się z równoległością?
- I co z wątkami? Dlaczego rozważamy używanie korutyn jeśli mamy wątki? I jakie płyną z tego korzyści?
Po odpowiedzi na te pytania musimy sięgnąć głębiej..
.. i jeszcze głębiej..
.. aby zrozumieć jak działają wątki na bardzo niskim poziomie wewnątrz naszego procesora.
Wątek vs. rdzeń
Załóżmy, że posiadamy procesor czterordzeniowy, czyli procesor, który posiada cztery rdzenie. Wyobraźmy sobie, że nasz procesor to fabryka, w której każdy rdzeń procesora odpowiada jednemu pracownikowi. W naszym scenariuszu mamy czterech pracowników, reprezentujących poszczególne rdzenie procesora. Jednak, jak to zwykle bywa, cały ten proces kontrolowany jest przez Szefa — system operacyjny, który wydaje pracownikom polecenia.
Wątki są niczym innym jak sekwencje poleceń wydawanych rdzeniom procesora. Podobnie jak wątki dostarczają różne zadania do rdzeni procesora, tak linie montażowe dostarczają pracownikom produkty do przetworzenia w naszej fabryce. W tym przypadku linie montażowe to nasze wątki.
Ważne: Za każdym wątkiem, kryje się proces. Jednakże, ponieważ programiści Androida mają do czynienia tylko z jednym procesem przez większość czasu, pomińmy na ten moment ten termin, aby nie wprowadzić zbędnego chaosu.
Podczas gdy pracownicy przetwarzają produkty z linii montażowych, nasz Szef, czyli system operacyjny, również jest bardzo zajęty. Jest on w pełni zaangażowany w proces tak długo, jak długo musi zarządzać wszystkimi wątkami, a przy tym dbać o harmonogram pracy. A jak wiadomo, zajęty szef to kosztowna przyjemność, i podobnie jest z wątkami. Zarówno jedno jak i drugie dużo kosztują i wymagają sporych zasobów. Każdy wątek w maszynie JVM zużywa około 1MB pamięci.
Rdzeń fizyczny a rdzeń logiczny
Rdzeń fizyczny jest częścią niezależną procesora i jest dokładnie tym, na co wskazuje jego nazwa, po prostu jest tam fizycznie. Po prostu grupa tranzystorów wewnątrz samego procesora.
Z kolei rdzeń logiczny jest jak część kodu. Istnieje w komputerze, ale nie jest podłączony do żadnego konkretnego sprzętu. Liczba rdzeni logicznych wyraża liczbę wątków, które mogą być wykonywane w tym samym czasie. Na przykład, jeśli mamy procesor z 4 rdzeniami i 4 wątkami, mamy do czynienia z 4 rdzeniami fizycznymi i 4 rdzeniami logicznymi. Jesli jednak mamy procesor z 4 rdzeniami, ale możemy uruchomić 8 wątków w tym samym czasie, to mamy 8 rdzeni logicznych, ale dalej tylko 4 rdzenie fizyczne.
Można więc zadać sobie pytanie, co się dzieje, jeśli jest więcej rdzeni logicznych od rdzeni fizycznych?
Wracając do przykładu naszej fabryki, możemy to zobrazować jako sytuację, kiedy pracownik odpowiedzialny za dwie linie montażowe po prostu nie jest w stanie zajmować się obiema jednocześnie.
Załóżmy, że pracownik ma zadanie przetworzenia produktów z dwóch linii montażowych.
W pierwszej kolejności rozpoczyna od przetwarzania produktów z pierwszej linii. I nagle linia przestaje działać. Być może coś się zacięło, a może po prostu musi zaczekać na więcej produktów. Cokolwiek by się nie działo, linia jest zablokowana, a nasz pracownik nie może kontynuować swojej pracy, dopóki linia nie zostanie odblokowana.
Jednak w tym samym czasie produkty z drugiej linii mogą być już gotowe do przetworzenia. Tak więc, zamiast rozmawiać z kolegami i czekać, aż pierwsza linia będzie znowu gotowa, nasz pracownik przełącza się na drugą linię i zaczyna przetwarzać produkty stamtąd.
Po zakończeniu pracy na drugiej linii pracownik może sprawdzić, czy pierwsza linia znów działa, a jeśli tak jest, może przełączyć się z powrotem na tę linię i doprowadzić swoją pracę do końca.
Proces jest wykonywany sprawniej, Szef jest zadowolony, a pracownik może wrócić do domu wcześniej niż zwykle.
Sytuację opisaną na początku artykułu, w której wszyscy rdzeniowi pracownicy pracują w tym samym czasie i każdy z nich jest odpowiedzialny tylko za jedną linię montażową, nazywamy przetwarzaniem równoległym.
Ale jak mogliśmy zauważyć, rdzeń sam w sobie nie może pracować na wielu wątkach jednocześnie. Jeśli jest więcej wątków do przetworzenia, rdzeń musi się między nimi przełączać.
Operacja przełączania jest tym, co nazywamy pracą współbieżną. Chociaż pozornie wydaje się być przetwarzaniem równoległym, w rzeczywistości zadania nie są wykonywane w tym samym czasie; są one wykonywane współbieżnie.
Skoro mowa już o współbieżności, przejdźmy szybko do korutyn, czy też coroutines.
Korutyny
Mówiąc krótko, korutyny są jak wątki wykonujące pracę współbieżnie. Jednakże niekoniecznie są one związane z konkretnym wątkiem. Korutyna może zainicjować swoje wykonywanie na jednym wątku, następnie zawiesić i kontynuować swoje wykonywanie na innym wątku.
Gdy korutyna jest zawieszona, nie blokuje ona wątku, na którym była uruchomiona. Kiedy osiągnie ona punkt zawieszenia, wątek jest zwracany z powrotem do puli, dzięki czemu może być użyty przez inną korutynę lub inny proces. Po ustaniu zawieszenia korutyna wznawia działanie na wolnym wątku w puli.
Korutyny Kotlina nie są zarządzane przez system operacyjny; są one wbudowane w język. A więc system operacyjny nie musi martwić się o korutyny ani o ich planowanie. Korutyny radzą sobie z tymi wszystkimi zadaniami same, wykorzystując wielozadaniowość kooperatywną.
W momencie, gdy korutyna się zawiesza, środowisko wykonawcze Kotlina znajduje inną korutynę, która wznawia jej wykonywanie. Oznacza to tyle, że nasz Szef nie musi na szczęście sam zarządzać organizacją pracy. Wyobraźmy sobie scenariusz, w którym znaczną część pracy, którą wcześniej musiał wykonywać Szef, przejmuje wynajęty nadzorca za znacznie mniejsze pieniądze.
W tym samym rozumieniu, korutyny, w przeciwieństwie do wątków, również nie potrzebują dużo pamięci, tylko kilka bajtów. Dzięki temu można uruchomić o wiele więcej korutyn niż wątków. Ta cecha korutyn pozwala nam osiągnąć niewielkim kosztem bardzo wysoki poziom ich współbieżności.
POKAŻ MI KOD!
Niektórzy twierdzą jednak, że praktyka bez teorii jest ślepa, a teoria bez praktyki – kulawa. Tak więc, tchnijmy życie w naszą fabrykę korutyn i zakodujmy prosty program, aby zademonstrować różnicę pomiędzy działaniem wątków i korutyn.
Nasz program wywołuje dwie funkcje. Każda z funkcji:
1) Wypisuje komunikat, mówiący, na którym wątku jest uruchomiona
2) Zatrzymuje swoje wykonanie na 1 sekundę,
3) Wypisuje komunikat, mówiący, w którym wątku jest uruchomiona.
Wątek pierwszy
fun main() {
test1WithThread()
test2WithThread()
}
fun test1WithThread() {
println("Start thread test 1: ${threadName()}")
Thread.sleep(500)
println("End thread test 1: ${threadName()}")
}
fun test2WithThread() {
println("Start thread test 2: ${threadName()}")
Thread.sleep(1000)
println("End thread test 2: ${threadName()}")
}
Start thread test 1: main
End thread test 1: main
Start thread test 2: main
End thread test 2: main
Wszystkie polecenia programu są wykonywane sekwencyjnie, ponieważ Thread.sleep() jest wywołaniem blokującym. Pierwsza funkcja uruchamia się na głównym wątku, następnie wątek jest blokowany na 1 sekundę. Po zakończeniu działania funkcji, wątek będzie dostępny dla drugiej funkcji, która zostanie uruchomiona.
Korutyny
Wpiszmy ponownie ten sam przykład używając korutyn. Zamiast wywoływać Thread.sleep()
, wywołujemy zawieszającą funkcję delay()
, a każdą funkcję uruchamiamy z oddzielnej korutyny.
fun main() {
runBlocking {
launch {
test1WithCoroutines()
}
launch {
test2WithCoroutines()
}
}
}
suspend fun test1WithCoroutines() {
println("Start coroutines test 1: ${threadName()}")
delay(500)
println("End coroutines test 1: ${threadName()}")
}
suspend fun test2WithCoroutines() {
println("Start coroutines test 2: ${threadName()}")
delay(1000)
println("End coroutines test 2: ${threadName()}")
}
Start coroutines test 1: main @coroutine#2
Start coroutines test 2: main @coroutine#3
End coroutines test 1: main @coroutine#2
End coroutines test 2: main @coroutine#3
Kiedy uruchamiasz ten kod, możesz odnieść wrażenie, że obie funkcje działają równolegle, ale jak to możliwe, że działają one jednocześnie na głównym wątku?
Ponieważ delay()
jest funkcją zawieszającą, wywołanie jej z pierwszej funkcji powoduje nieblokujące zawieszenie, a wątek jest wtedy zwalniany do wykonania innego zadania, co w naszym przypadku oznacza wykonanie drugiej funkcji. Kiedy funkcja delay()
się kończy, kontynuuje wykonywanie pierwszej funkcji od punktu, w którym ją przerwała. Operacja przełączania jest wykonywana. Właśnie na tym polega potęga i magia współbieżności.
Oryginał tekstu w języku angielskim możesz przeczytać tutaj.