TSM - Greșeli frecvente în utilizarea coroutinelor în Kotlin

Alexandru Hadar - Senior Android Developer @ P3 Romania


Coroutinele au devenit standardul de facto pentru programarea asincronă în Kotlin. Ele sunt relativ simplu de învățat și ne permit să scriem cod într-un stil secvențial, evitând așa-numitul callback hell. Totuși, deși conceptele de bază sunt accesibile, este la fel de ușor să facem greșeli atunci când lucrăm cu ele.

Haideți să vedem câteva astfel de greșeli.

await(), dar nu acum

Să presupunem că avem o aplicație, precum LinkedIn, unde utilizatorul are o poză de profil și un banner. Acesta își actualizează bannerul și poza de profil. Noi încărcăm pozele direct într-un bucket, de unde obținem un URL pe care îl trimitem mai departe serverului.

O astfel de funcție ar arăta așa:

private suspend fun 
  updateProfileImages(
   profileImage: Image,
   bannerImage: Image,
) = coroutineScope {
   val profileUrl = 
    uploadProfileToBucket(profileImage)
   val bannerUrl = 
    uploadBannerToBucket(bannerImage)

   sendUrlsToBackend(
    profileUrl, bannerUrl)
}

Presupunem că fiecare funcție durează aproximativ 130ms. Rulăm codul și vedem rezultatul: ~260ms. Hmmm... oare nu am putea îmbunătăți timpul?

Citim despre async și realizăm că putem paraleliza execuția. async întoarce un Deferred (echivalentul lui Future din Java), iar prin await() putem obține rezultatul fără a mai avea nevoie de un listener.

Facem toate modificările necesare, iar funcția noastră devine:

private suspend fun 
  updateProfileImages(
   profileImage: Image,
  bannerImage: Image,
) = coroutineScope {
  val profileUrl = 
   async { 
    uploadProfileToBucket(profileImage) 
   }.await()

  val bannerUrl = 
   async { 
    uploadBannerToBucket(bannerImage
    )}.await()

  sendUrlsToBackend(
   profileUrl, bannerUrl)
}

Rulăm codul și observăm rezultatul: Images uploaded in 252 ms. Nicio diferență.

Citind documentația oficială pentru await() înțelegem de ce: "Awaits for completion of this value without blocking the thread and returns the …"

"Awaits" — așteaptă finalizarea, blocând corutina curentă până la obținerea rezultatului. Asta înseamnă că uploadBannerToBucket nu se execută până când uploadProfileToBucket nu întoarce un rezultat, deblocând coroutina. Practic, nu am schimbat nimic față de prima versiune.

Într-un final, decidem să mutăm apelurile await() după ce am lansat ambele async. Astfel, primul async se pornește și continuăm imediat cu al doilea. Cum async nu blochează corutina curentă, ambele sarcini se execută în paralel.

private suspend fun 
 updateProfileImages(
    profileImage: Image,
    bannerImage: Image,
) = coroutineScope {
    val profileDeferred: 

  Deferred = async { 
    uploadProfileToBucket (profileImage) 
  }

  val bannerDeferred: Deferred = async { 
    uploadBannerToBucket (bannerImage) 
  }

  val profileUrl = profileDeferred.await()
  val bannerUrl = bannerDeferred.await()
  sendUrlsToBackend(profileUrl, bannerUrl)
}

Rulăm și obținem rezultatul: Images uploaded in 137 ms.

Asta se întâmplă deoarece cele două apeluri se execută în paralel. Nu contează care durează mai mult:

De aceea, ordinea în care apelăm await() nu contează, atâta timp cât ele sunt chemate după lansarea tuturor async.

Poate exemplul acesta nu v-a convins. Până la urmă, să aștepți 200ms nu pare mult. Dar ce nu luăm în considerare este faptul că putem extinde ideea de mai sus, aproape indefinit.

Dacă, în loc să ne actualizăm poza de profil și bannerul, creăm un post care conține zeci de poze? În acest caz, am putea simplifica întreaga operațiune astfel:

suspend fun uploadPhotos(photosList: List) = 
 withContext(Dispatchers.IO) {

  val photosUrls: List = photosList
      .map { photo -> async {             
        sendPhotoToBucket(photo) 
     }
   }.awaitAll()

  sendUrlsToServer(photosUrls)
}

Supervisor pentru copii, nu și nepoți

Haideți să reluăm ultimul exemplu și să vedem cum l-am putea îmbunătăți.

Ne dorim ca, dacă una dintre funcțiile sendPhotoToBucket eșuează, să nu reluăm toate apelurile, ci doar pe cele care au eșuat. De exemplu: dacă trimitem 20 de poze și una singură nu se încarcă, la re-încercare nu retrimitem toate cele 20, ci doar poza care nu a reușit. Astfel economisim bandwith, timp de procesor etc.

Citim despre CoroutineScope-uri. Aflăm că ele sunt ca niște containere în care putem lansa mai multe coroutine. Avem două tipuri:

Perfect! Pare că supervisorScope este exact ce ne trebuie. Înfășurăm funcția noastră într-un supervisorScope și rulăm din nou, simulând că a 3-a poză eșuează:

val mySuperScope = CoroutineScope(
  SupervisorJob() + Dispatchers.IO)

mySuperScope.launch {
    uploadPhotos(photos)
}

// Output
Processing photo 1
Processing photo 2
Exception in thread ...IllegalArgumentException: Failed to upload picture #3!

Nimic.

Propagarea scope-ului

Asta se întâmplă pentru că sendPhotoToBucket nu este copilul direct al lui supervisorScope. Jobul rezultat în urma apelului supervisorScope.launch { ... } este, de fapt, copilul lui supervisorScope, iar toate acele asyncuri sunt, de fapt, "nepoți" ai lui supervisorScope. Iar nepoții nu beneficiază de supervisorScope!

Vizual, așa arată structura noastră de coroutine:

SupervisorJob

Vedem că avem un SupervisorJob() pe care-l folosim și la crearea lui mySuperScope. Este un context, iar noi folosim withContext în sendPhotoToBucket. Excelent! Îl putem adăuga direct acolo.

Rulăm, iar rezultatul este exact la fel. Hmmm?!

Aici problema stă în modul în care coroutinele funcționează. Fiecare coroutină are un Job, prin intermediul căruia îi controlăm durata de viață, vedem lifecycle-ul ș.a.m.d. Job-ul este singurul context care nu este moștenit de la o coroutină la alta, deoarece fiecare job este atribuit unei coroutine. Așadar, ce am făcut noi mai sus, de fapt, coroutinei noastre uploadPhotos i-am schimbat părintele pe durata execuției lui withContext. Stă și în nume: withContext, nu withScope. Noi am creat un job, nu un scope!

Soluția

Pentru a ne asigura că uploadPhotos funcționează corect fără ca o eroare dintr-o coroutină să afecteze restul, avem două soluții:

  1. Folosirea mySuperScope și în uploadPhotos. Astfel, coroutina noastră moștenește un SupervisorJob, iar eșecul unei coroutine copil nu va anula celelalte.

  2. Folosirea supervisorScope, funcția din biblioteca de coroutine. Aceasta creează un scope temporar cu un SupervisorJob dedicat, asigurându-ne că eșecul unei coroutine nu afectează restul mapului nostru de uploaduri.
suspend fun uploadPhotos(photosList: List): List = withContext(Dispatchers.IO) { 
    supervisorScope { 
        val photosUrls: List = 
          photosList
          .map { photo -> async { 
           sendPhotoToBucket(photo) } }
           .awaitAll()
        photosUrls
    }
}

Acum, dacă unul dintre uploaduri eșuează, celelalte continuă fără probleme. Codul funcționează exact așa cum ne dorim.

Non-Cooperativitatea functiilor

Există o secțiune în documentația oficială care spune că Cancellation is cooperative. Ce înseamnă asta?

Să presupunem că am creat o aplicație care procesează imaginile. Aplică filtre, le scalează și așa mai departe.

Am găsit o bibliotecă, în Java, care face tot ce ne trebuie. Doar că este sincronă, nu are suport pentru coroutine. Nu e problemă! Folosim noi un dispatcher pentru aceasta.
Codul nostru ar arăta astfel:

suspend fun processFile(file: File) = withContext(Dispatchers.Default) {
    scaleDownFile(file) 
   // CPU heavy operation - 3 seconds

    println("Image scaled down!")
    makeImageBlackWhite(file) 
   // CPU heavy operation - 3 seconds

    println("Image is black and white!")
    saveImageToDisk(file) // 3 seconds
    println("Image saved to the disk!")
}

Presupunem că fiecare funcție de mai sus durează cam 3 secunde, deci în total 9 secunde. După 2 secunde, utilizatorul decide să abandoneze procesul, deci închidem și noi coroutina.

Rulăm programul și observăm outputul:

Scope canceled!
Image scaled down!
Image is black and white!
Image saved to the disk!

Se pare că, deși scope-ul nostru este anulat, toate funcțiile sunt apelate. De ce?

Pentru că Kotlin nu a avut nicio șansă să verifice dacă trebuie să oprească munca sau nu. Coroutinele nu funcționează ca un proces, căruia îi dai kill și este oprit. Nu.

Coroutinele au nevoie de anumite puncte în cod, suspension points, unde pot verifica dacă mai trebuie să continue sau nu. Aceste puncte sunt orice funcție suspend, de ex. delay sau orice altă funcție a noastră care folosește o coroutină. Dar, în exemplul de mai sus, nu există nicio astfel de funcție. Toate cele trei funcții sunt sincrone, care nu folosesc coroutine, deci Kotlin nu are niciun moment în care să verifice dacă coroutina curentă mai este activă.

ensureActive() și isActive

Aici intervin cele isActive și ensureActive(). Ambele sunt extensii pe contextul corutinei și ne informează dacă mai trebuie să continuăm.

Pe când isActive, este doar un boolean, care returnează true/false dacă jobul curent mai este activ (deci nu este în proces de închidere sau închis), ensureActive() este o funcție care aruncă CancellationException, dacă isActive este false.

Să nu uităm că CancellationException este o excepție specială în coroutine, care semnifică închiderea coroutinei. Noi nu vom fi afectați de ea (de ex. să ne închidă programul). Deci, dacă adăugăm ensureActive() după fiecare apel de funcție, vedem că rezultatul se schimbă.

suspend fun processFile(file: File) = withContext(Dispatchers.Default) {
    scaleDownFile(file) // CPU heavy operation
    println("Image scaled down!")
    ensureActive()

    makeImageBlackWhite(file) // CPU heavy operation
    println("Image is black and white!")
    ensureActive()

    saveImageToDisk(file)
    println("Image saved to the disk!")
    ensureActive()
}

Vedem acum că programul se închide după scaleDownFile, atunci când s-a ajuns într-un suspension point. Exact ce ne doream!

Bonus

Cu toții cred că am scris un while (true) cel puțin o dată, fie că făceam un polling la server, fie că voiam să verificăm o condiție.

Dar asta este complet greșit atunci când folosim coroutinele. Putem bloca coroutina indefinit, dacă nu există niciun punct de suspendare în interior. De exemplu:

    var i = 0
    while(true) {   // do something }

În acest caz, chiar dacă am anula coroutina, dacă nu e niciun punct de suspendare în interior, while-ul nostru ar rula indefinit. Soluția? În loc de while(true) punem while(isActive). Când isActive devine false, atunci while-ul se oprește, iar coroutina noastră se poate închide liniștit.

Concluzie

Corutinele ne pot ajuta să creăm aplicații unde nu trebuie să ne facem griji că blocăm un thread, UI-ul sau că avem memory leaks. Ele sunt o unealtă utilă iar după cum a spus și unchiul Ben: With great power comes great responsibility.