Czym są korutyny w Kotlinie?
Author
Radosław Kondziołka
blog_date_icon
17 grudnia

Wraz z wersją 1.3 do Kotlina zostały wprowadzone korutyny. Sama koncepcja korutyn nie jest żadną nowinką w świecie programowania i występuje już w wielu językach. Korutyny w pewnym sensie oferują nieco inne podejście do programowania współbieżnego.

Pierwsza korutyna

W Kotlinie korutyny tworzy się przy pomocy tzw. coroutine builders, których jednym z reprezentantów jest użyty poniżej launch. Funkcja ta tworzy korutynę, w ramach której wykonywany jest blok kodu przekazany jako parametr:

fun main(args: Array<String>) {
    runBlocking {
        launch {
            delay(1000)
        	println("The first coroutine was executed")
    	  }
    }
}

runBlocking pełni tutaj rolę pewnego ułatwienia i nie należy się na nim skupiać. Na tym etapie wystarczy nam wiedza, że umożliwia on wykonanie korutyny w bieżącym wątku. Tak stworzona korutyna czeka sekundę, następnie wypisuje tekst na ekran, po czym kończy swoje działanie. Korutyny często określane są jako light thread co ma wskazywać na lekkość ich tworzenia i wykonywania. Podobnie do funkcji, korutyny są wykonywane w ramach wątku, przy czym jeden wątek może “jednocześnie” wykonywać wiele korutyn. Z każdą korutyną jest związany kontekst (CoroutineContext). I to od niego zależy, w którym wątku zostanie wykonana korutyna.

Kontekst korutyny

Korutyny zawsze działają w jakimś kontekście. Jak już wspomniano, kontekst określa w jaki sposób korutyna zostanie wykonana oraz utrzymuje referencję do joba (Coroutine Job) , który reprezentuje wykonywany w ramach korutyny blok kodu. Posługując się jobem, który zostaje zwrócony przez funkcję tworzącą, można anulować wykonanie korutyny:

runBlocking {
    val job = launch {
        println("Starting")
        delay(1000)
        println("Exiting")
    }
    delay(500)
    job.cancel()
}

Na ekranie został wypisany tylko pierwszy komunikat, ponieważ korutyna została anulowana.

Innym ważnym elementem kontekstu jest Dispatcher, który określa wątek wykonujący korutynę. Kotlin dostarcza kilka dispatcherów o różnych zastosowaniach. Jednym z takich dispatcherów jest Dispatchers.IO, który przeznaczony jest głównie do wykonywania operacji I/O. Poniżej pokazano, jak można określić, którego Dispatchera chcemy użyć do wykonania naszej korutyny:

runBlocking {
    launch(Dispatchers.IO) {
        println("A coroutine on thread ${Thread.currentThread().name}")
    }
}

Jeżeli nie określimy kontekstu w funkcji tworzącej, to jest on dziedziczony z bieżącego zakresu, o którym więcej poniżej.

Zakres korutyny

CoroutineScope jest w pewnym sensie sposobem na strukturyzację wykonania korutyn. Zakresy mają strukturę hierarchiczną, tzn. istnieje między nimi relacja rodzic-dziecko. Korutyny działające w zakresie będącym w roli rodzica, zanim zakończą swoją pracę, czekają na zakończenie korutyn, które działają w zakresie będącym w roli dziecka. Hierarchiczna relacja między korutynami (a w rzeczywistości między jobami powiązanymi z korutynami) umożliwia propagowanie operacji anulowania od rodziców do dzieci. Poniżej krótki przykład pokazujący strukturę hierarchiczną między dwiema korutynami:

GlobalScope.launch {
    println("I am a parent coroutine")
    launch {
        println("I am a child coroutine")
        delay(1000)
    }.invokeOnCompletion { println("Completion of a child coroutine") }
}.invokeOnCompletion { println("Completion of a parent coroutine") }

Powyżej, zewnętrzna korutyna została umieszczona w tzw. GlobalScope, który jest globalnym zakresem w aplikacji. Korutyna wewnętrzna dziedziczy kontekst z korutyny zewnętrznej oraz staje się dzieckiem korutyny zewnętrznej. Wynik powyższego programu.

I am a parent coroutine
I am a child coroutine
Completion of a child coroutine
Completion of a parent coroutine

zdradza konsekwencję takiego stanu rzeczy: korutyna zewnętrzna jako rodzic czeka na zakończenie korutyny-dziecka.

Korutyny, czym właściwie są?

Na tym etapie nie wygląda na to, żeby wykonanie korutyn jakoś szczególnie odstawało od wykonania zwykłych funkcji. W gruncie rzeczy tak właśnie jest, z tą różnicą, że wykonanie korutyn może być wstrzymywane w specjalnych punktach - tzw. suspension points. Takie wstrzymanie polega na przerwaniu wykonywania w obecnym wątku bieżącej korutyny, ale w sposób nieblokujący wątku, który tą korutynę wykonuje. Taki wątek może zająć się na przykład wykonywaniem kodu innej korutyny. Wykonywanie tak przerwanej korutyny będzie potem kontynuowane od punktu wstrzymania. Spójrzmy poniżej na poniższy kod i rezultat jego wykonania rezultat:

runBlocking {
    launch {
        println("Start #1 on thread ${Thread.currentThread().name}")
        yield() // suspension point
        println("Exit #1 on thread ${Thread.currentThread().name}")
   }
   launch {
        println("Start #2 on thread ${Thread.currentThread().name}")
        println("Exit #2 on thread ${Thread.currentThread().name}")
   }
}
Start #1 on thread main
Start #2 on thread main
Exit #2 on thread main
Exit #1 on thread main

W tym przypadku rolę punktu wstrzymującego pełni funkcja yield. Zaobserwowany przebieg jest następujący:

  1. Wykonana została pierwsza instrukcja z pierwszej korutyny.
  2. Korutyna w punkcie wstrzymania zostaje wstrzymana.
  3. Druga korutyna zostaje w całości wykonana.
  4. Wstrzymana wcześniej korutyna zostaje wznowiona.

Jasno wskazuje to na "przełączenie kontekstu", które nastąpiło w ramach jednego wątku. Warto tutaj zaznaczyć, że - generalnie - dobrymi kandydatami na takie punkty wstrzymania są operacje, które z natury są operacjami blokującymi, np. komunikacja sieciowa. Przykładowo, w momencie wykonywania zapytania sieciowego, korutyna zostaje wstrzymana i na bieżącym wątku może być wykonywana inna korutyna. Nie oznacza to wcale, że zapytanie sieciowe nie jest wykonywane. Jego wykonanie mogło zostać zaimplementowane w sposób nieblokujący bądź zostać przesunięte na inny wątek. Punkty wstrzymania mogą być umieszczane albo bezpośrednio w ciele korutyn, albo w suspend functions, którym przyjrzymy się w następnej sekcji.

Suspend functions

Suspend functions to funkcje, których wykonanie może być wstrzymywane w pewnych punktach (suspension points) i potem kontynuowane. Funkcje tego typu mogą być wykonywane tylko w obrębie korutyny. Można powiedzieć, że funkcje wstrzymywane są podstawowym budulcem, z którego zbudowana jest korutyna a korutyna pełni rolę "wykonawcy" względem tych funkcji. W Koltinie definicję takich funkcji wprowadza się przy pomocy dedykowanego słowa kluczowego suspend.

suspend fun prepareAgreement(templateId: String, customer: Customer)
    : Agreement { 
    val request = prepareRequest()
    val template = client.getTemplate(request) // do HTTP GET request (suspension post)
    return createAgreement(template, customer)
}

Poniżej została przedstawiona grafika pokazująca przebieg wykonania takiego programu:

obraz1.webp

Zielonym kolorem oznaczono funkcję prepareAgreement natomiast niebieskim jakąś inną korutynę. W trakcie pobierania szablonu umowy z innego serwisu wątek T1 wykonuje inną korutynę nie marnując w ten sposób zasobów. Zakładamy tutaj, że korzystamy z biblioteki, która potrafi wykonywać zapytania HTTP w sposób nieblokujący.

Podsumowanie

Programowanie przy użyciu korutyn może wydawać się na pozór bardzo łatwe. Należy jednak być przy tym bardzo uważnym, gdyż niewłaściwie zaimplementowana korutyna może wpływać negatywnie na wykonanie pozostałych korutyn z tego samego kontekstu, co może skutkować na przykład obniżoną responsywnością aplikacji. Nie mniej jednak umiejętność programowania w oparciu o korutyny jest bardzo przydatną umiejętnością w warsztacie programisty.

Przeczytaj także