Complete Kotlin Coroutines Tutorial
Table of Contents
1. Introduction to Coroutines
Coroutines are Kotlin's solution for asynchronous programming. They allow you to write asynchronous code that looks and feels like synchronous code, making it easier to read and maintain.
Key Benefits:
- Lightweight: Thousands of coroutines can run on a few threads
- Non-blocking: Don't block threads while waiting for operations
- Sequential: Write async code in a sequential manner
- Structured Concurrency: Automatic cleanup and cancellation
Basic Example:
import kotlinx.coroutines.* fun main() = runBlocking { println("Start") delay(1000) // Non-blocking delay println("End") }
2. Core Concepts
2.1 Suspend Functions
Functions marked with
suspend fun fetchUser(): String { delay(1000) // Simulates network call return "User123" } suspend fun fetchOrders(): List<String> { delay(500) return listOf("Order1", "Order2") }
2.2 Coroutine Context
Every coroutine has a context that includes:
- Job: Controls lifecycle
- Dispatcher: Determines which thread(s) to use
- Exception Handler: Handles uncaught exceptions
fun main() = runBlocking { val job = launch(Dispatchers.IO + CoroutineName("MyCoroutine")) { println("Running on: ${Thread.currentThread().name}") } job.join() }
3. Coroutine Builders
3.1 launch - Fire and Forget
Returns a Job for fire-and-forget operations.
fun main() = runBlocking { val job = launch { repeat(3) { println("Task $it") delay(500) } } println("Main continues...") job.join() // Wait for completion }
3.2 async - Concurrent Execution
Returns >Deferred<T> for operations that return results.
fun main() = runBlocking { val userDeferred = async { fetchUser() } val ordersDeferred = async { fetchOrders() } // Both operations run concurrently val user = userDeferred.await() val orders = ordersDeferred.await() println("User: $user, Orders: $orders") }
3.3 runBlocking - Bridge to Blocking World
Blocks the current thread until completion. Use sparingly.
fun main() = runBlocking { println("Start of main") delay(1000) println("End of main") } // In tests @Test fun testCoroutine() = runBlocking { val result = async { computeResult() } assertEquals("expected", result.await()) }
3.4 withContext - Context Switching
Switch execution context without creating new coroutines.
suspend fun loadData(): String = withContext(Dispatchers.IO) { // This runs on IO thread println("Loading on: ${Thread.currentThread().name}") delay(1000) "Data loaded" } fun main() = runBlocking { println("Main on: ${Thread.currentThread().name}") val data = loadData() println("Back to: ${Thread.currentThread().name}") println(data) }
4. Coroutine Scopes
4.1 GlobalScope
Lives for the entire application lifetime. Use carefully.
fun startBackgroundWork() { GlobalScope.launch { while (true) { performBackgroundTask() delay(60000) // Every minute } } }
4.2 Custom Scope
Create your own scope for better control.
class DataRepository { private val job = SupervisorJob() private val scope = CoroutineScope(Dispatchers.IO + job) fun fetchDataAsync() { scope.launch { val data = networkCall() processData(data) } } fun cleanup() { job.cancel() // Cancels all child coroutines } }
4.3 Structured Concurrency
Parent-child relationships ensure proper cleanup.
fun main() = runBlocking { println("Parent starts") launch { println("Child 1 starts") delay(1000) println("Child 1 ends") } launch { println("Child 2 starts") delay(500) println("Child 2 ends") } println("Parent waits for children") }
5. Dispatchers
5.1 Types of Dispatchers
fun main() = runBlocking { // Default - CPU intensive work launch(Dispatchers.Default) { println("Default: ${Thread.currentThread().name}") // Heavy computation } // IO - Network/disk operations launch(Dispatchers.IO) { println("IO: ${Thread.currentThread().name}") // File operations, network calls } // Main - UI operations (Android) // launch(Dispatchers.Main) { ... } // Unconfined - Inherits from caller launch(Dispatchers.Unconfined) { println("Unconfined: ${Thread.currentThread().name}") } }
5.2 Custom Dispatcher
val customDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher() fun main() = runBlocking { launch(customDispatcher) { println("Custom: ${Thread.currentThread().name}") } }
6. Exception Handling
6.1 Try-Catch in Suspend Functions
suspend fun riskyOperation(): String { return try { delay(1000) if (Random.nextBoolean()) { throw Exception("Something went wrong") } "Success" } catch (e: Exception) { "Error: ${e.message}" } }
6.2 CoroutineExceptionHandler
val handler = CoroutineExceptionHandler { _, exception -> println("Caught $exception") } fun main() = runBlocking { val job = launch(handler) { throw Exception("Coroutine failed") } job.join() println("Main continues") }
6.3 SupervisorJob
Prevents child failures from cancelling siblings.
fun main() = runBlocking { val supervisor = SupervisorJob() with(CoroutineScope(coroutineContext + supervisor)) { val child1 = launch { delay(100) throw Exception("Child 1 failed") } val child2 = launch { delay(200) println("Child 2 completed") } joinAll(child1, child2) } }
7. Advanced Topics
7.1 Channels - Communication Between Coroutines
fun main() = runBlocking { val channel = Channel<Int>() // Producer launch { for (x in 1..5) { channel.send(x * x) delay(100) } channel.close() } // Consumer for (y in channel) { println("Received: $y") } }
7.2 Flow - Asynchronous Streams
fun numbers(): Flow<Int> = flow { for (i in 1..3) { delay(1000) emit(i) } } fun main() = runBlocking { numbers() .map { it * it } .collect { println("Collected: $it") } }
7.3 StateFlow and SharedFlow
class Repository { private val _state = MutableStateFlow("Initial") val state: StateFlow<String> = _state fun updateState(newState: String) { _state.value = newState } } fun main() = runBlocking { val repo = Repository() launch { repo.state.collect { println("State: $it") } } delay(100) repo.updateState("Updated") delay(100) }
7.4 Select Expression
suspend fun selectExample() { val channel1 = Channel<String>() val channel2 = Channel<String>() launch { delay(100) channel1.send("from channel1") } launch { delay(200) channel2.send("from channel2") } val result = select<String> { channel1.onReceive { it } channel2.onReceive { it } } println("Selected: $result") }
8. Android-Specific Usage
8.1 ViewModel with Coroutines
class UserViewModel(private val repository: UserRepository) : ViewModel() { private val _users = MutableLiveData<List<User>>() val users: LiveData<List<User>> = _users fun loadUsers() { viewModelScope.launch { try { val userList = repository.getUsers() _users.value = userList } catch (e: Exception) { // Handle error } } } }
8.2 Activity/Fragment Lifecycle
class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> updateUI(state) } } } } }
8.3 Repository Pattern
class UserRepository(private val apiService: ApiService) { suspend fun getUsers(): List<User> = withContext(Dispatchers.IO) { apiService.fetchUsers() } suspend fun createUser(user: User): User = withContext(Dispatchers.IO) { apiService.createUser(user) } }
9. Best Practices
9.1 Do's and Don'ts
DO:
- Use structured concurrency
- Choose appropriate dispatchers
- Handle exceptions properly
- Use appropriate scopes (lifecycleScope, viewModelScope)
- Cancel coroutines when not needed
DON'T:
- Use GlobalScope in UI code
- Block main thread with runBlocking
- Ignore exception handling
- Create too many coroutines
- Forget to handle cancellation
9.2 Performance Tips
// Good: Concurrent execution suspend fun loadDataConcurrently() { val user = async { fetchUser() } val orders = async { fetchOrders() } val profile = async { fetchProfile() } updateUI(user.await(), orders.await(), profile.await()) } // Bad: Sequential execution suspend fun loadDataSequentially() { val user = fetchUser() val orders = fetchOrders() val profile = fetchProfile() updateUI(user, orders, profile) }
10. Interview Questions
Basic Level
Q1: What are coroutines in Kotlin? A: Coroutines are lightweight threads that allow writing asynchronous code in a sequential manner. They can suspend and resume without blocking threads, making them more efficient than traditional threads.
Q2: What's the difference between launch and async? A:
- launchreturns a Job and is used for fire-and-forget operations
- asyncreturns Deferred<T> and is used when you need a result from the coroutine
Q3: What is a suspend function? A: A suspend function is a function marked with the suspend
keyword that can be paused and resumed. It can only be called from other suspend functions or coroutines.
Intermediate Level
Q4: Explain different types of Dispatchers. A:
- Dispatchers.Main: UI thread (Android)
- Dispatchers.IO: I/O operations (network, file)
- Dispatchers.Default: CPU-intensive work
- Dispatchers.Unconfined: Inherits caller's context
Q5: What is structured concurrency? A: Structured concurrency ensures that coroutines follow a parent-child relationship where parent coroutines wait for their children to complete and can cancel all children when cancelled.
Q6: How do you handle exceptions in coroutines? A: Using try-catch blocks, CoroutineExceptionHandler, or SupervisorJob to prevent child failures from affecting siblings.
Advanced Level
Q7: What's the difference between Flow and Channel? A:
- Flow: Cold stream, declarative, lazy evaluation
- Channel: Hot stream, imperative, immediate execution
Q8: Explain the difference between SupervisorJob and regular Job. A: SupervisorJob allows child coroutines to fail independently without cancelling siblings, while regular Job cancels all children if one fails.
Q9: How would you implement timeout in coroutines?
suspend fun fetchWithTimeout(): String { return withTimeout(5000) { // 5 seconds fetchDataFromNetwork() } }
Q10: What are the best practices for using coroutines in Android? A:
- Use lifecycleScope for UI components
- Use viewModelScope for ViewModels
- Avoid GlobalScope in UI code
- Handle cancellation properly
- Use appropriate dispatchers
- Don't block main thread with runBlocking
Practical Scenarios
Q11: How would you make multiple network calls concurrently?
suspend fun loadUserData(userId: String): UserData = coroutineScope { val user = async { userService.getUser(userId) } val posts = async { postService.getUserPosts(userId) } val friends = async { friendService.getUserFriends(userId) } UserData(user.await(), posts.await(), friends.await()) }
Q12: How do you cancel a coroutine?
val job = launch { try { repeat(1000) { i -> if (!isActive) return@launch // Check cancellation delay(100) println("Working $i") } } finally { println("Cleaning up...") } } delay(500) job.cancel() // Cancel the coroutine
Q13: Implement a retry mechanism with coroutines.
suspend fun <T> retry( times: Int, delay: Long = 1000, block: suspend () -> T ): T { repeat(times - 1) { try { return block() } catch (e: Exception) { delay(delay) } } return block() // Last attempt } // Usage val result = retry(3, 2000) { fetchDataFromNetwork() }
Common Mistakes to Avoid
- Using runBlocking in Android UI code
- Not handling exceptions in coroutines
- Using GlobalScope for UI-related operations
- Creating too many coroutines unnecessarily
- Not understanding cancellation and cleanup
- Mixing blocking and non-blocking code incorrectly
This comprehensive tutorial covers all aspects of Kotlin coroutines from basics to advanced concepts, providing practical examples and common interview questions to help you master asynchronous programming in Kotlin.