Coroutines trong Kotlin: launch, async và scope
1. Tổng quan
Coroutines là cách Kotlin xử lý code bất đồng bộ mà không cần callback hell hay block thread. Bài này giải thích launch, async, suspend fun và CoroutineScope hoạt động thế nào, khi nào dùng cái nào. Đọc xong, bạn sẽ viết được code concurrent gọn gàng, biết cách kiểm soát lifecycle của coroutine và test nó bằng JUnit 5.
2. Dependency cần thiết
Coroutines không có sẵn trong Kotlin standard library, bạn cần thêm kotlinx-coroutines-core.
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core</artifactId>
<version>1.8.1</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-test</artifactId>
<version>1.8.1</version>
<scope>test</scope>
</dependency>
Nếu bạn dùng Spring Boot, module spring-boot-starter-webflux hoặc spring-boot-starter đã kéo theo Kotlin extensions hỗ trợ suspend fun trong controller.
3. suspend fun: đơn vị cơ bản của coroutine
suspend là từ khóa đánh dấu một hàm có thể bị tạm dừng (suspend) mà không block thread.
suspend fun fetchOrder(orderId: Long): Order {
delay(200) // giả lập gọi I/O, không block thread thật
return Order(orderId, "PENDING")
}
data class Order(val id: Long, val status: String)
delay() là hàm suspend có sẵn, khác với Thread.sleep() vì nó không giữ thread trong lúc chờ. suspend fun chỉ gọi được từ một suspend fun khác hoặc từ trong coroutine builder (launch, async, runBlocking).
Lưu ý: gọi
suspend funtrực tiếp từ hàmfunthường (không phải suspend) sẽ bị lỗi compile. Đây là cơ chế Kotlin ép bạn khai báo rõ ràng context bất đồng bộ.
4. launch: fire-and-forget coroutine
launch tạo một coroutine mới, trả về Job, không trả kết quả tính toán — dùng khi bạn chỉ cần “chạy và quên”.
fun main() = runBlocking {
val job: Job = launch {
println("Bắt đầu xử lý order trên thread: ${Thread.currentThread().name}")
val order = fetchOrder(1L)
println("Order status: ${order.status}")
}
println("launch đã trả về, coroutine chạy song song")
job.join() // chờ coroutine hoàn tất
}
launch đã trả về, coroutine chạy song song
Bắt đầu xử lý order trên thread: main
Order status: PENDING
runBlocking tạo scope để chạy coroutine trong main (chỉ dùng cho demo/test, không dùng trong production code thật). launch không block thread hiện tại — nó lên lịch coroutine chạy và trả về ngay. job.join() mới là điểm bạn chờ coroutine đó xong.
5. async: coroutine có kết quả trả về
async giống launch nhưng trả về Deferred<T> — một “promise” mà bạn gọi .await() để lấy kết quả.
suspend fun fetchCustomer(customerId: Long): String {
delay(150)
return "Customer-$customerId"
}
suspend fun loadOrderDetail(orderId: Long, customerId: Long): String = coroutineScope {
val orderDeferred = async { fetchOrder(orderId) }
val customerDeferred = async { fetchCustomer(customerId) }
val order = orderDeferred.await()
val customer = customerDeferred.await()
"Order ${order.id} (${order.status}) của $customer"
}
Hai lệnh async chạy song song (concurrent), không tuần tự. Tổng thời gian chờ ở đây là ~200ms (max của 200ms và 150ms) chứ không phải 350ms. await() mới là điểm suspend để lấy kết quả — nếu bạn gọi await() ngay sau mỗi async thay vì tách riêng, code sẽ chạy tuần tự và mất lợi thế song song.
// Sai: chạy tuần tự, mất ý nghĩa của async
val order = async { fetchOrder(orderId) }.await()
val customer = async { fetchCustomer(customerId) }.await()
Lưu ý: đoạn trên không lỗi cú pháp nhưng phá hỏng mục đích dùng
async— mỗiawait()chặn ngay sau khi tạo, nên hai coroutine không bao giờ chạy đồng thời.
6. CoroutineScope: ai quản lý lifecycle của coroutine
Mọi coroutine phải thuộc một CoroutineScope. Scope quyết định: coroutine sống bao lâu, hủy khi nào, exception propagate ra sao.
class OrderService {
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
fun processOrderAsync(orderId: Long) {
serviceScope.launch {
val order = fetchOrder(orderId)
println("Đã xử lý order ${order.id}")
}
}
fun shutdown() {
serviceScope.cancel() // hủy toàn bộ coroutine con đang chạy trong scope này
}
}
serviceScope giữ bằng một field của class — đây là cách tránh coroutine “mồ côi” chạy mãi sau khi object đã không còn dùng nữa. SupervisorJob() giúp một coroutine con fail không làm hủy các coroutine con khác (khác với Job() thường, nơi một con fail sẽ hủy tất cả anh em).
6.1. coroutineScope vs CoroutineScope
Đừng nhầm coroutineScope { ... } (hàm suspend, chữ thường) với CoroutineScope(...) (constructor tạo scope mới, chữ hoa).
suspend fun loadDashboard(): String = coroutineScope {
// coroutineScope chờ TẤT CẢ coroutine con bên trong hoàn tất
// rồi mới trả về, và nó dùng context của coroutine cha
val a = async { fetchOrder(1L) }
val b = async { fetchCustomer(2L) }
"${a.await()} - ${b.await()}"
}
coroutineScope là một suspend function builder dùng để nhóm nhiều coroutine con lại, đảm bảo structured concurrency: hàm chỉ return khi mọi async/launch bên trong xong (hoặc một trong số đó fail và toàn bộ bị hủy). Nó không tạo scope độc lập mới mà kế thừa context hiện tại — khác hẳn với CoroutineScope(...) ở mục 6 vốn tạo một scope sống độc lập, không tự động bị hủy khi hàm kết thúc.
7. Dispatcher: coroutine chạy trên thread nào
Dispatcher quyết định coroutine chạy ở đâu — tương tự chọn Executor trong Java.
suspend fun readConfigFile(path: String): String = withContext(Dispatchers.IO) {
// Dispatchers.IO dùng cho blocking I/O (đọc file, gọi JDBC...)
File(path).readText()
}
suspend fun heavyCompute(data: List<Int>): Long = withContext(Dispatchers.Default) {
// Dispatchers.Default dùng cho CPU-bound work
data.sumOf { it.toLong() * it }
}
withContext chuyển coroutine hiện tại sang chạy trên dispatcher khác rồi tự động trả lại dispatcher cũ sau khi xong — rất hữu ích khi bạn cần gọi code blocking (JDBC, file I/O) mà không muốn chiếm thread chính của Dispatchers.Default hoặc Dispatchers.Main (trên Android).
| Dispatcher | Dùng cho |
|---|---|
Dispatchers.Default |
Tính toán CPU-bound (parse, sort, compute) |
Dispatchers.IO |
Blocking I/O (file, JDBC, network blocking) |
Dispatchers.Main |
Cập nhật UI (chỉ có trên Android/JavaFX) |
Dispatchers.Unconfined |
Hiếm dùng, chạy trên thread gọi ban đầu |
8. Xử lý exception trong coroutine
Exception trong launch và async propagate khác nhau — đây là nguồn bug phổ biến nhất.
fun main() = runBlocking {
// launch: exception ném ngay, có thể crash scope nếu không bắt
val job = launch {
try {
throw IllegalStateException("Order không tồn tại")
} catch (e: IllegalStateException) {
println("Bắt được lỗi trong launch: ${e.message}")
}
}
job.join()
// async: exception được giữ lại trong Deferred, chỉ ném ra khi gọi await()
val deferred = async {
throw IllegalStateException("Customer không tồn tại")
}
try {
deferred.await()
} catch (e: IllegalStateException) {
println("Bắt được lỗi trong async: ${e.message}")
}
}
Bắt được lỗi trong launch: Order không tồn tại
Bắt được lỗi trong async: Customer không tồn tại
Với async, nếu bạn quên gọi .await(), exception sẽ âm thầm bị “nuốt” cho tới khi Deferred bị garbage-collected hoặc bạn gọi await() ở đâu đó — rất dễ gây bug khó debug trong production.
9. Viết test cho coroutine
Dùng kotlinx-coroutines-test với runTest để test suspend function mà không cần chờ delay thật.
class OrderServiceTest {
@Test
fun `fetchOrder tra ve status PENDING`() = runTest {
val order = fetchOrder(1L)
assertThat(order.id).isEqualTo(1L)
assertThat(order.status).isEqualTo("PENDING")
}
@Test
fun `loadOrderDetail chay song song hai coroutine`() = runTest {
val result = loadOrderDetail(orderId = 1L, customerId = 2L)
assertThat(result).contains("Order 1")
assertThat(result).contains("Customer-2")
}
}
runTest tự động dùng virtual time — delay(200) bên trong fetchOrder không làm test chạy chậm thật 200ms, nó skip ngay tới điểm resume logic. Đây là lý do bạn không nên test coroutine bằng runBlocking trong unit test: nó chờ thời gian thật, làm test suite chậm dần theo số lượng test.
10. Lỗi thường gặp
- Dùng
GlobalScope: coroutine sống suốt đời ứng dụng, không bị hủy theo lifecycle nào — gây leak. Luôn tạo scope gắn với class/component cụ thể như ở mục 6. - Gọi
runBlockingtrong code production: nó block thread hiện tại thật, phá hỏng mục đích của coroutine. Chỉ dùng trongmain(), test, hoặc nơi bắt buộc phải cầu nối code blocking với code suspend. - Quên
await()trênasync: exception bị nuốt, coroutine chạy mà không ai chờ kết quả — lãng phí và khó trace lỗi. - Trộn
Dispatchers.IOcho CPU-bound work:Dispatchers.IOcó thread pool lớn hơn nhiều (tối ưu cho I/O chờ), dùng cho tính toán nặng sẽ làm giảm hiệu quả scheduling.
Lưu ý: nếu service của bạn không cần concurrency thật (chỉ gọi tuần tự một vài I/O), coroutine có thể là over-engineering. Đôi khi một hàm
suspend fungọi tuần tự đơn giản, không cầnasync/launch, đã đủ rõ ràng và dễ maintain hơn.
11. Kết luận
suspend fun là đơn vị nền tảng, launch dùng khi không cần kết quả trả về, async dùng khi cần chạy song song và lấy kết quả qua await(). CoroutineScope quyết định lifecycle — luôn gắn scope với một component cụ thể, tránh GlobalScope, và ưu tiên coroutineScope/SupervisorJob để kiểm soát structured concurrency. Nắm vững ba khái niệm này là đủ để viết phần lớn code bất đồng bộ trong Kotlin một cách an toàn và dễ test.