Structured Concurrency - Droidcon Lisbon 2019

B5b545babbc646ce40053512edbcf5b0?s=47 Manuel Vivo
September 09, 2019

Structured Concurrency - Droidcon Lisbon 2019

Before Kotlin Coroutines (async programming in Kotlin) was stable, they were launched in a way that made them difficult to maintain and track down.

Structured Concurrency introduced changes to solve these problems but also involved an ideological shift in order to use them.

In this talk, you'll learn how to do async programming in Kotlin using Structured Concurrency principles covering topics such as launching coroutines, scopes, error handling and testing.

B5b545babbc646ce40053512edbcf5b0?s=128

Manuel Vivo

September 09, 2019
Tweet

Transcript

  1. Structured Concurrency Asynchronous Shift in Kotlin @manuelvicnt Manuel Vicente Vivo

  2. What problems are coroutines trying to solve?

  3. Coroutines simplify async programming

  4. Sync blocking fun loadData() { val data = networkRequest() show(data)

    } networkRequest show onDraw onDraw onDraw
  5. Sync blocking fun networkRequest(): Data { // Blocking network request

    code }
  6. fun loadData() { networkRequest { data -> show(data) } }

    Async with callbacks networkRequest show onDraw onDraw onDraw Network thread
  7. fun networkRequest(onSuccess: (Data) -> Unit) { DefaultScheduler.execute { // Blocking

    network request code postToMainThread(onSuccess(result)) } } Async with callbacks
  8. fun networkRequest(onSuccess: (Data) -> Unit) { DefaultScheduler.execute { // Blocking

    network request code postToMainThread(onSuccess(result)) } } Async with callbacks
  9. fun networkRequest(onSuccess: (Data) -> Unit) { DefaultScheduler.execute { // Blocking

    network request code postToMainThread(onSuccess(result)) } } Async with callbacks
  10. fun loadData() { networkRequest { data -> show(data) } }

    Async with callbacks
  11. fun loadData() { networkRequest { data -> anotherRequest(data) { otherData

    -> // ... } } } Async with callbacks
  12. fun loadData() { networkRequest { data -> anotherRequest(data) { otherData

    -> networkRequest { data -> anotherRequest(data) { otherData -> networkRequest { data -> anotherRequest(data) { otherData -> networkRequest { data -> anotherRequest(data) { otherData -> networkRequest { data -> anotherRequest(data) { otherData -> networkRequest { data -> anotherRequest(data) { otherData -> networkRequest { data -> anotherRequest(data) { otherData -> networkRequest { data -> anotherRequest(data) { otherData -> networkRequest { data -> anotherRequest(data) { otherData -> networkRequest { data -> anotherRequest(data) { otherData -> // Hey there! You want more? } } } } } Callback Hell fun loadData() { networkRequest { data -> anotherRequest(data) { otherData ->
  13. Best of both worlds?

  14. suspend fun loadData() { val data = networkRequest() show(data) }

    Async with coroutines onDraw onDraw onDraw Network thread networkRequest show
  15. suspend fun loadData() { val data = networkRequest() show(data) }

  16. suspend fun loadData() { val data = networkRequest() show(data) }

    suspend
  17. suspend fun loadData() { val data = networkRequest() show(data) }

    suspend resume
  18. suspend fun loadData() { val data = networkRequest() show(data) }

    suspend Callback resume
  19. Callbacks? The Kotlin compiler writes them under the hood when

    the computation can suspend
  20. Continuation Coroutines call those “callbacks” Continuation

  21. Continuation suspend fun loadData() { val data = networkRequest() show(data)

    }
  22. Continuation fun loadData(continuation: Continuation) { val data = networkRequest(continuation) show(data)

    }
  23. State Machine Continuations form a state machine

  24. State Machine LoadData Network Request State 0 - init

  25. State Machine LoadData Network Request State 1 suspend

  26. State Machine LoadData Network Request State 2 resume

  27. State Machine LoadData Network Request State 3 - exit

  28. Continuation-Passing Style (CPS) Disclaimer Not Covered

  29. With Coroutines… Computation gets suspended without blocking the thread

  30. suspend fun loadData() { val data = networkRequest() show(data) }

  31. suspend fun loadData() { val data = networkRequest() show(data) }

    suspend fun networkRequest(): Data
  32. suspend fun loadData() { val data = networkRequest() show(data) }

    suspend fun networkRequest(): Data = withContext(Dispatchers.IO) { }
  33. suspend fun loadData() { val data = networkRequest() show(data) }

    suspend fun networkRequest(): Data = withContext(Dispatchers.IO) { // Blocking network request code } Dispatchers.IO
  34. .Default .Main Dispatchers .IO

  35. Dispatchers .IO .Main .Default

  36. .IO .Main .Default Network & Disk

  37. .IO .Main .Default Network & Disk CPU

  38. .IO .Main .Default Network & Disk UI/Non-blocking CPU

  39. suspend fun loadData() { val data = networkRequest() show(data) }

    suspend fun networkRequest(): Data = withContext(Dispatchers.IO) { // Blocking network request code }
  40. suspend fun loadData() { val data = networkRequest() show(data) }

    suspend fun networkRequest(): Data = withContext(Dispatchers.IO) { // Blocking network request code } Main Safe
  41. But… what is a coroutine?

  42. Coroutine Runnable with super powers Takes a block of code

    to run in a thread
  43. Asynchronicity expressed as sequential code that is easy to read

    and reason about Other perks: exception handling and cancellation ❤ Coroutine
  44. suspend fun loadData() { val data = networkRequest() show(data) }

  45. suspend fun loadData() { val data = networkRequest() show(data) }

    fun onButtonClicked() { loadData() } Suspend fun ‘loadData’ must be called from a coroutine
  46. suspend fun loadData() { val data = networkRequest() show(data) }

    fun onButtonClicked() { launch { loadData() } }
  47. suspend fun loadData() { val data = networkRequest() show(data) }

    fun onButtonClicked() { launch { loadData() } } Who can cancel this execution? Does it follow a particular lifecycle? Who gets exceptions if it fails?
  48. Structured Concurrency

  49. Scopes Keep track of coroutines Ability to cancel them Gets

    exceptions
  50. fun onButtonClicked() { launch { loadData() } } Launch must

    be called in a scope // MyViewModel.kt
  51. fun onButtonClicked() { launch { loadData() } } // MyViewModel.kt

  52. val scope = CoroutineScope(Dispatchers.Main) fun onButtonClicked() { launch { loadData()

    } } // MyViewModel.kt
  53. val scope = CoroutineScope(Dispatchers.Main) fun onButtonClicked() { scope.launch { loadData()

    } } // MyViewModel.kt
  54. val scope = CoroutineScope(Dispatchers.Main) fun onButtonClicked() { scope.launch { loadData()

    } } When loadData throws, the scope gets the exception
  55. val scope = CoroutineScope(Dispatchers.Main) Parent fun onButtonClicked() { scope.launch {

    loadData() } }
  56. val scope = CoroutineScope(Dispatchers.Main) Parent Child fun onButtonClicked() { scope.launch

    { loadData() } }
  57. fun onButtonClicked() { scope.launch { loadData() } } Child fun

    onCleared() { scope.cancel() }
  58. Cancelling a Scope Cancels all children coroutines Useless, cannot start

    more coroutines
  59. suspend fun loadData() { val data = networkRequest() show(data) }

  60. suspend fun loadData() { val data = networkRequest() show(data) }

    Runs in a scope
  61. When a function returns, it has completed all work

  62. When a function returns, it has completed all work suspend

  63. suspend fun loadData() { val data = networkRequest() show(data) }

  64. Scopes Exception Handling

  65. fun onButtonClicked() { scope.launch { loadData() } } val scope

    = CoroutineScope(Dispatchers.Main) // MyViewModel.kt
  66. fun onButtonClicked() { scope.launch { loadData() } } val scope

    = CoroutineScope( Dispatchers.Main + Job() ) // MyViewModel.kt
  67. Scope with a Job When a child fails, it propagates

    cancellation to other (scope) children When a failure is notified, the scope propagates the exception up
  68. fun onButtonClicked() { scope.launch { loadData() } } val scope

    = CoroutineScope( Dispatchers.Main + Job() ) // MyViewModel.kt
  69. fun onButtonClicked() { scope.launch { loadData() } } val scope

    = CoroutineScope( Dispatchers.Main + SupervisorJob() ) // MyViewModel.kt
  70. Scope with a SupervisorJob The failure of a child doesn’t

    affect other (scope) children When a failure is notified, the scope doesn’t do anything
  71. fun onButtonClicked() { scope.launch { loadData() } } val scope

    = CoroutineScope( Dispatchers.Main + SupervisorJob() ) // MyViewModel.kt
  72. TL;DR; work is completed suspend fun returns

  73. TL;DR; work is completed suspend fun returns scope cancelled children

    cancel
  74. TL;DR; work is completed suspend fun returns scope cancelled children

    cancel coroutine errors scope notified
  75. Create Coroutines

  76. Launch Async v s

  77. Launch Async

  78. Creates a new Coroutine Creates a new Coroutine Launch Async

  79. Launch Creates a new Coroutine Fire and Forget scope.launch(Dispatchers.IO) {

    loggingService.upload(logs) }
  80. Async Creates a new Coroutine Returns a value suspend fun

    getUser(userId: String): User = coroutineScope { val deferred = async(Dispatchers.IO) { userService.getUser(userId) } deferred.await() }
  81. Async Creates a new Coroutine Returns a value suspend fun

    getUser(userId: String): User = coroutineScope { val deferred = async(Dispatchers.IO) { userService.getUser(userId) } deferred.await() }
  82. Async Creates a new Coroutine Returns a value suspend fun

    getUser(userId: String): User = coroutineScope { val deferred = async(Dispatchers.IO) { userService.getUser(userId) } deferred.await() }
  83. Async Creates a new Coroutine Returns a value suspend fun

    getUser(userId: String): User = coroutineScope { val deferred = async(Dispatchers.IO) { userService.getUser(userId) } deferred.await() }
  84. Async Creates a new Coroutine Returns a value suspend fun

    getUser(userId: String): User = coroutineScope { val deferred = async(Dispatchers.IO) { userService.getUser(userId) } deferred.await() }
  85. Async Creates a new Coroutine Returns a value suspend fun

    getUser(userId: String): User = coroutineScope { val deferred = async(Dispatchers.IO) { userService.getUser(userId) } deferred.await() }
  86. Creates a new Coroutine Creates a new Coroutine Fire and

    Forget Returns a value Launch Async
  87. Creates a new Coroutine Creates a new Coroutine Takes a

    Dispatcher Takes a Dispatcher Fire and Forget Returns a value Launch Async
  88. Creates a new Coroutine Creates a new Coroutine Takes a

    Dispatcher Takes a Dispatcher Executed in a Scope Executed in a Scope Fire and Forget Returns a value Launch Async
  89. Creates a new Coroutine Creates a new Coroutine Takes a

    Dispatcher Takes a Dispatcher Executed in a Scope Executed in a Scope Not a suspend function Not a suspend function Fire and Forget Returns a value Launch Async
  90. Creates a new Coroutine Creates a new Coroutine Takes a

    Dispatcher Takes a Dispatcher Executed in a Scope Executed in a Scope Re-throws exceptions Not a suspend function Not a suspend function Fire and Forget Returns a value Launch Async
  91. Creates a new Coroutine Creates a new Coroutine Takes a

    Dispatcher Takes a Dispatcher Executed in a Scope Executed in a Scope Not a suspend function Not a suspend function Holds on exceptions until await is called Fire and Forget Returns a value Launch Async Re-throws exceptions
  92. Exception handling

  93. Wrapping in a try-catch block scope.launch(Dispatchers.Default) { try { loggingService.upload(logs)

    } catch(e: Exception) { // Handle Exception } }
  94. suspend fun getUser(userId: String): User { coroutineScope { val deferred

    = async(Dispatchers.IO) { userService.getUser(userId) } try { deferred.await() } catch(e: Exception) { // Handle exception } } }
  95. suspend fun getUser(userId: String): User { coroutineScope { val deferred

    = async(Dispatchers.IO) { userService.getUser(userId) } try { deferred.await() } catch(e: Exception) { // Handle exception } } }
  96. suspend fun getUser(userId: String): User { coroutineScope { val deferred

    = async(Dispatchers.IO) { userService.getUser(userId) } try { deferred.await() } catch(e: Exception) { // Handle exception } } }
  97. scope.launch(Dispatchers.IO) { for (name in files) { readFile(name) } }

  98. Cancellation requires co-operation

  99. scope.launch(Dispatchers.IO) { for (name in files) { readFile(name) } }

  100. Check if the coroutine is Active scope.launch(Dispatchers.IO) { for (name

    in files) { if (!isActive) break readFile(name) } }
  101. When to mark a function as suspend

  102. suspend fun loadData() { val data = networkRequest() show(data) }

    When it calls other suspend functions
  103. suspend fun loadData() { val data = networkRequest() show(data) }

    When it calls other suspend functions
  104. When NOT to mark a function as suspend

  105. When it doesn’t call suspend functions fun onButtonClicked() { scope.launch

    { loadData() } }
  106. Tip Don’t mark a function suspend unless you’re forced to

  107. Unit Testing Coroutines

  108. Use Case 1 The test doesn’t trigger new coroutines

  109. suspend fun loadData() { val data = networkRequest() show(data) }

    // MyViewModel.kt
  110. @Test fun `Test loadData happy path`() = runBlocking { val

    viewModel = MyViewModel() viewModel.loadData() // Assert show did something } // MyViewModelTest.kt
  111. Use Case 2 The test triggers new coroutines

  112. class MyViewModel { val scope = CoroutineScope( Dispatchers.Main + SupervisorJob()

    ) fun onButtonClicked() { scope.launch { loadData() } } }
  113. class MyViewModel { val scope = CoroutineScope( Dispatchers.Main + SupervisorJob()

    ) fun onButtonClicked() { scope.launch { loadData() } } }
  114. @Test fun `Test loadData happy path`() = runBlocking { val

    viewModel = MyViewModel() viewModel.onButtonClicked() // Assert show did something } // MyViewModelTest.kt
  115. @Test fun `Test loadData happy path`() = runBlocking { val

    viewModel = MyViewModel() viewModel.onButtonClicked() // Assert show did something } // MyViewModelTest.kt
  116. @Test fun `Test loadData happy path`() { val viewModel =

    MyViewModel() viewModel.onButtonClicked() // Wait for the result -> using a CountDownLatch // LiveDataTestUtil, Mockito await, etc. } // MyViewModelTest.kt
  117. @Test fun `Test loadData happy path`() { val viewModel =

    MyViewModel() viewModel.onButtonClicked() // Wait for the result -> using a CountDownLatch // LiveDataTestUtil, Mockito await, etc. } // MyViewModelTest.kt Code Smell!! Bad practice
  118. class MyViewModel { val scope = CoroutineScope( Dispatchers.Main + SupervisorJob()

    ) fun onButtonClicked() { scope.launch { loadData() } } }
  119. class MyViewModel( private val dispatcher: CoroutineDispatcher ) { val scope

    = CoroutineScope( Dispatchers.Main + SupervisorJob() ) fun onButtonClicked() { scope.launch(dispatcher) { loadData() } } }
  120. class MyViewModel( private val dispatcher: CoroutineDispatcher ) { val scope

    = CoroutineScope( Dispatchers.Main + SupervisorJob() ) fun onButtonClicked() { scope.launch(dispatcher) { loadData() } } }
  121. val testDispatcher = TestCoroutineDispatcher() @Test fun `Test loadData happy path`()

    = testDispatcher.runBlockingTest { val viewModel = MyViewModel(testDispatcher) viewModel.onButtonClicked() // Assert show did something } // MyViewModelTest.kt
  122. val testDispatcher = TestCoroutineDispatcher() @Test fun `Test loadData happy path`()

    = testDispatcher.runBlockingTest { val viewModel = MyViewModel(testDispatcher) viewModel.onButtonClicked() // Assert show did something } // MyViewModelTest.kt
  123. val testDispatcher = TestCoroutineDispatcher() @Test fun `Test loadData happy path`()

    = testDispatcher.runBlockingTest { val viewModel = MyViewModel(testDispatcher) viewModel.onButtonClicked() // Assert show did something } // MyViewModelTest.kt
  124. class MyViewModel( private val dispatcher: CoroutineDispatcher ) { val scope

    = CoroutineScope( Dispatchers.Main + SupervisorJob() ) fun onButtonClicked() { // Do something else scope.launch(dispatcher) { loadData() } } }
  125. val testDispatcher = TestCoroutineDispatcher() @Test fun `Test loadData happy path`()

    = testDispatcher.runBlockingTest { val viewModel = MyViewModel(testDispatcher) testDispatcher.pauseDispatcher() viewModel.onButtonClicked() // Assert onButtonClicked did something else } // MyViewModelTest.kt
  126. val testDispatcher = TestCoroutineDispatcher() @Test fun `Test loadData happy path`()

    = testDispatcher.runBlockingTest { val viewModel = MyViewModel(testDispatcher) testDispatcher.pauseDispatcher() viewModel.onButtonClicked() // Assert onButtonClicked did something else testDispatcher.resumeDispatcher() // Assert show did something } // MyViewModelTest.kt
  127. Testing Use runBlocking when the test doesn’t create new coroutines

  128. Testing As a good practice, inject Dispatchers and use TestCoroutineDispatcher

    in tests
  129. What we covered What problems Coroutines solve Dispatchers & withContext

    What a Coroutine is How coroutines work under the hood Principles of Structured Concurrency How to create Coroutines Exception Handling When to mark a function as suspend Testing Coroutines & TestCoroutineDispatcher
  130. Thank You Questions? @manuelvicnt Manuel Vicente Vivo