ABONAMENTE VIDEO REDACȚIA
RO
EN
NOU
Numărul 158
Numărul 157 Numărul 156 Numărul 155 Numărul 154 Numărul 153 Numărul 152 Numărul 151 Numărul 150 Numărul 149 Numărul 148 Numărul 147 Numărul 146 Numărul 145 Numărul 144 Numărul 143 Numărul 142 Numărul 141 Numărul 140 Numărul 139 Numărul 138 Numărul 137 Numărul 136 Numărul 135 Numărul 134 Numărul 133 Numărul 132 Numărul 131 Numărul 130 Numărul 129 Numărul 128 Numărul 127 Numărul 126 Numărul 125 Numărul 124 Numărul 123 Numărul 122 Numărul 121 Numărul 120 Numărul 119 Numărul 118 Numărul 117 Numărul 116 Numărul 115 Numărul 114 Numărul 113 Numărul 112 Numărul 111 Numărul 110 Numărul 109 Numărul 108 Numărul 107 Numărul 106 Numărul 105 Numărul 104 Numărul 103 Numărul 102 Numărul 101 Numărul 100 Numărul 99 Numărul 98 Numărul 97 Numărul 96 Numărul 95 Numărul 94 Numărul 93 Numărul 92 Numărul 91 Numărul 90 Numărul 89 Numărul 88 Numărul 87 Numărul 86 Numărul 85 Numărul 84 Numărul 83 Numărul 82 Numărul 81 Numărul 80 Numărul 79 Numărul 78 Numărul 77 Numărul 76 Numărul 75 Numărul 74 Numărul 73 Numărul 72 Numărul 71 Numărul 70 Numărul 69 Numărul 68 Numărul 67 Numărul 66 Numărul 65 Numărul 64 Numărul 63 Numărul 62 Numărul 61 Numărul 60 Numărul 59 Numărul 58 Numărul 57 Numărul 56 Numărul 55 Numărul 54 Numărul 53 Numărul 52 Numărul 51 Numărul 50 Numărul 49 Numărul 48 Numărul 47 Numărul 46 Numărul 45 Numărul 44 Numărul 43 Numărul 42 Numărul 41 Numărul 40 Numărul 39 Numărul 38 Numărul 37 Numărul 36 Numărul 35 Numărul 34 Numărul 33 Numărul 32 Numărul 31 Numărul 30 Numărul 29 Numărul 28 Numărul 27 Numărul 26 Numărul 25 Numărul 24 Numărul 23 Numărul 22 Numărul 21 Numărul 20 Numărul 19 Numărul 18 Numărul 17 Numărul 16 Numărul 15 Numărul 14 Numărul 13 Numărul 12 Numărul 11 Numărul 10 Numărul 9 Numărul 8 Numărul 7 Numărul 6 Numărul 5 Numărul 4 Numărul 3 Numărul 2 Numărul 1
×
▼ LISTĂ EDIȚII ▼
Numărul 158
Abonamente

Greșeli frecvente în utilizarea coroutinelor în Kotlin

Alexandru Hadar
Senior Android Developer @ P3 Romania



PROGRAMARE


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.

Conferință TSM

NUMĂRUL 157 - Summertime coding

Sponsori

  • BT Code Crafters
  • Bosch
  • Betfair
  • MHP
  • BoatyardX
  • .msg systems
  • P3 group
  • Ing Hubs
  • Cognizant Softvision
  • GlobalLogic

INTERVIU