Kotlin - Coroutines (Basics)
Kotlin Coroutines - Getting Started
Coroutines are Kotlin's solution to asynchronous programming. They make it easy to write non-blocking code that looks and feels like synchronous code, eliminating callback hell and making concurrent programming approachable.
What Are Coroutines?
Coroutines are lightweight threads that can be suspended and resumed without blocking the underlying thread. Think of them as functions that can pause execution and resume later.
Traditional Threading vs Coroutines
Traditional Threads (Expensive)
// Heavy, limited by OS threads
Thread {
Thread.sleep(1000)
println("Done")
}.start()
// Each thread uses ~1MB memory
// Limited to thousands of threads
Coroutines (Lightweight)
// Light, managed by Kotlin runtime
launch {
delay(1000)
println("Done")
}
// Each coroutine uses ~KB memory
// Can have millions of coroutines
Key Insight: Coroutines are not threads! They run on threads but are much more lightweight. You can have millions of coroutines but only thousands of threads.
Setting Up Coroutines
Adding Dependencies
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") // For Android
}
Basic Imports
import kotlinx.coroutines.*
Your First Coroutine
Hello, Coroutines!
import kotlinx.coroutines.*
fun main() {
println("Start")
// Launch a coroutine
GlobalScope.launch {
delay(1000L) // Non-blocking delay
println("World!")
}
println("Hello")
Thread.sleep(2000L) // Keep main thread alive
}
// Output:
// Start
// Hello
// World!
Using runBlocking
import kotlinx.coroutines.*
fun main() = runBlocking { // Blocks until all coroutines complete
println("Start")
launch {
delay(1000L)
println("World!")
}
println("Hello")
}
// Output:
// Start
// Hello
// World!
Beginner Note:
runBlocking
is mainly for main functions and tests. In real applications, you'll use other coroutine builders within existing coroutine scopes.
Suspend Functions
What Makes a Function Suspendable
// Suspend function - can be paused and resumed
suspend fun doSomething(): String {
delay(1000L) // This suspends the coroutine
return "Result"
}
// Regular function - cannot be suspended
fun regularFunction(): String {
// delay(1000L) // ❌ This would be a compilation error
Thread.sleep(1000L) // ✅ But this blocks the thread
return "Result"
}
fun main() = runBlocking {
val result = doSomething() // Can call suspend functions
println(result)
}
Creating Your Own Suspend Functions
suspend fun fetchUserData(userId: String): String {
println("Fetching user data for $userId...")
delay(1500L) // Simulate network call
return "User data for $userId"
}
suspend fun fetchUserPosts(userId: String): List<String> {
println("Fetching posts for $userId...")
delay(1000L) // Simulate network call
return listOf("Post 1", "Post 2", "Post 3")
}
fun main() = runBlocking {
val userData = fetchUserData("123")
println(userData)
val posts = fetchUserPosts("123")
println("Posts: $posts")
}
Coroutine Builders
1. launch - Fire and Forget
fun main() = runBlocking {
// Starts a coroutine but doesn't wait for result
val job = launch {
repeat(5) { i ->
println("Coroutine: $i")
delay(500L)
}
}
println("Main thread continues...")
delay(1000L)
println("Main thread done")
job.join() // Wait for coroutine to complete
}
2. async - Concurrent Execution with Results
import kotlin.system.measureTimeMillis
fun main() = runBlocking {
val time = measureTimeMillis {
// Sequential execution (slow)
val user = fetchUserData("123")
val posts = fetchUserPosts("123")
println("Sequential: $user, Posts: ${posts.size}")
}
println("Sequential time: $time ms")
val time2 = measureTimeMillis {
// Concurrent execution (fast)
val userDeferred = async { fetchUserData("123") }
val postsDeferred = async { fetchUserPosts("123") }
val user = userDeferred.await()
val posts = postsDeferred.await()
println("Concurrent: $user, Posts: ${posts.size}")
}
println("Concurrent time: $time2 ms")
}
3. runBlocking - Bridge to Blocking World
// Use in main functions and tests
fun main() = runBlocking {
println("This is a coroutine scope")
delay(1000L)
println("Done")
}
// Or as a function call
fun syncFunction() {
val result = runBlocking {
fetchUserData("123")
}
println(result)
}
Coroutine Scope and Context
Understanding Scope
fun main() = runBlocking { // Creates a coroutine scope
launch { // Child coroutine in the same scope
delay(1000L)
println("Child 1 done")
}
launch { // Another child coroutine
delay(500L)
println("Child 2 done")
}
println("All coroutines launched")
// runBlocking waits for all children to complete
}
// Output:
// All coroutines launched
// Child 2 done
// Child 1 done
Creating Custom Scopes
import kotlinx.coroutines.*
class MyService {
// Create a scope tied to the service lifecycle
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
fun doWork() {
scope.launch {
repeat(3) { i ->
println("Working: $i")
delay(1000L)
}
}
}
fun cleanup() {
scope.cancel() // Cancel all coroutines
}
}
fun main() = runBlocking {
val service = MyService()
service.doWork()
delay(2000L)
service.cleanup()
println("Service cleaned up")
}
Exception Handling
Try-Catch in Coroutines
suspend fun riskyOperation(): String {
delay(1000L)
if (kotlin.random.Random.nextBoolean()) {
throw Exception("Something went wrong!")
}
return "Success!"
}
fun main() = runBlocking {
try {
val result = riskyOperation()
println("Result: $result")
} catch (e: Exception) {
println("Caught exception: ${e.message}")
}
}
Exception Handling with async
fun main() = runBlocking {
val deferred = async {
riskyOperation()
}
try {
val result = deferred.await() // Exception thrown here
println("Result: $result")
} catch (e: Exception) {
println("Caught exception: ${e.message}")
}
}
Coroutine Dispatchers
Different Thread Pools
fun main() = runBlocking {
// Default - CPU intensive work
launch(Dispatchers.Default) {
println("Default: ${Thread.currentThread().name}")
// Good for CPU-intensive tasks
}
// IO - Network/file operations
launch(Dispatchers.IO) {
println("IO: ${Thread.currentThread().name}")
// Good for network calls, file operations
}
// Main - UI updates (Android/Desktop)
launch(Dispatchers.Main) { // May not be available in console apps
println("Main: ${Thread.currentThread().name}")
// Good for UI updates
}
// Unconfined - Inherits caller's thread
launch(Dispatchers.Unconfined) {
println("Unconfined: ${Thread.currentThread().name}")
}
delay(100L)
}
Switching Contexts
suspend fun processData(): String = withContext(Dispatchers.IO) {
// Switch to IO dispatcher for network call
println("Processing on: ${Thread.currentThread().name}")
delay(1000L) // Simulate network call
"Processed data"
}
fun main() = runBlocking {
println("Main: ${Thread.currentThread().name}")
val result = processData()
println("Back on: ${Thread.currentThread().name}")
println("Result: $result")
}
Teacher Note: Dispatchers help you run code on appropriate thread pools. Use
Dispatchers.IO
for network/file operations, Dispatchers.Default
for CPU-intensive work, and Dispatchers.Main
for UI updates.
Cancellation and Timeouts
Cancelling Coroutines
fun main() = runBlocking {
val job = launch {
repeat(10) { i ->
println("Working: $i")
delay(500L)
}
}
delay(2000L) // Let it run for 2 seconds
job.cancel() // Cancel the coroutine
job.join() // Wait for cancellation to complete
println("Cancelled!")
}
Cooperative Cancellation
fun main() = runBlocking {
val job = launch {
repeat(10) { i ->
// Check if cancelled
if (!isActive) {
println("Coroutine was cancelled")
return@launch
}
println("Working: $i")
delay(500L) // delay checks for cancellation automatically
}
}
delay(2000L)
job.cancel()
println("Cancelled!")
}
Timeouts
suspend fun longRunningTask(): String {
delay(3000L) // Takes 3 seconds
return "Task completed"
}
fun main() = runBlocking {
try {
// Timeout after 2 seconds
val result = withTimeout(2000L) {
longRunningTask()
}
println(result)
} catch (e: TimeoutCancellationException) {
println("Task timed out!")
}
// Or return null on timeout
val result = withTimeoutOrNull(2000L) {
longRunningTask()
}
println("Result: $result") // null
}
Real-World Example: Data Loading
data class User(val id: String, val name: String)
data class Post(val id: String, val title: String, val content: String)
class UserRepository {
suspend fun getUser(id: String): User {
delay(1000L) // Simulate network call
return User(id, "User $id")
}
suspend fun getUserPosts(userId: String): List<Post> {
delay(800L) // Simulate network call
return listOf(
Post("1", "First Post", "Content 1"),
Post("2", "Second Post", "Content 2")
)
}
}
class UserService {
private val repository = UserRepository()
suspend fun getUserWithPosts(userId: String): Pair<User, List<Post>> {
// Load user and posts concurrently
val userDeferred = async { repository.getUser(userId) }
val postsDeferred = async { repository.getUserPosts(userId) }
return Pair(userDeferred.await(), postsDeferred.await())
}
}
fun main() = runBlocking {
val service = UserService()
val startTime = System.currentTimeMillis()
val (user, posts) = service.getUserWithPosts("123")
val endTime = System.currentTimeMillis()
println("User: ${user.name}")
println("Posts: ${posts.size}")
println("Time taken: ${endTime - startTime} ms") // ~1000ms instead of 1800ms
}
Best Practices for Beginners
✅ Do
- Use
suspend
functions for async operations - Use
async
for concurrent operations that return values - Use
launch
for fire-and-forget operations - Handle exceptions properly with try-catch
- Use appropriate dispatchers for different work types
- Cancel coroutines when they're no longer needed
❌ Don't
- Use
runBlocking
inside coroutines (can cause deadlocks) - Use
GlobalScope
in production (hard to manage lifecycle) - Block threads with
Thread.sleep()
in coroutines - Forget to handle cancellation in long-running operations
- Mix blocking and non-blocking code carelessly
Architecture Note: Coroutines enable writing asynchronous code that's as readable as synchronous code. They're essential for building responsive applications that can handle many concurrent operations efficiently.
Common Patterns
Retry with Exponential Backoff
suspend fun <T> retryWithBackoff(
maxRetries: Int = 3,
initialDelay: Long = 1000L,
maxDelay: Long = 10000L,
factor: Double = 2.0,
block: suspend () -> T
): T {
var currentDelay = initialDelay
repeat(maxRetries - 1) {
try {
return block()
} catch (e: Exception) {
println("Attempt failed, retrying in ${currentDelay}ms...")
}
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
}
return block() // Last attempt
}
// Usage
suspend fun unreliableNetworkCall(): String {
if (kotlin.random.Random.nextFloat() < 0.7) { // 70% failure rate
throw Exception("Network error")
}
return "Success!"
}
fun main() = runBlocking {
try {
val result = retryWithBackoff {
unreliableNetworkCall()
}
println("Result: $result")
} catch (e: Exception) {
println("All retries failed: ${e.message}")
}
}
Practice Exercises
- Create a suspend function that simulates downloading a file with progress updates
- Write a function that makes multiple concurrent API calls and combines the results
- Implement a simple cache that uses coroutines to load data asynchronously
- Create a coroutine that periodically checks for updates and can be cancelled
- Build a simple producer-consumer pattern using coroutines
Quick Quiz
- What keyword makes a function suspendable?
- What's the difference between
launch
andasync
? - Which dispatcher should you use for network calls?
- What happens if you don't call
await()
on a Deferred?
Show answers
suspend
launch
is fire-and-forget (returns Job),async
returns a result (returns Deferred)Dispatchers.IO
for network/file operations- The coroutine runs but you don't get the result - it's like not collecting the return value