Giới thiệu về coroutine

1. Trước khi bắt đầu

Giao diện người dùng thích ứng là một yếu tố quan trọng giúp tạo ra ứng dụng tốt. Có thể bạn từng xem thường yếu tố này trong những ứng dụng mình đã xây dựng, nhưng khi bạn bắt đầu thêm các tính năng nâng cao hơn (chẳng hạn như khả năng kết nối mạng hoặc cơ sở dữ liệu) thì việc viết mã để đảm bảo cả chức năng lẫn hiệu suất lại không dễ dàng. Ví dụ dưới đây minh hoạ những gì có thể xảy ra khi các tác vụ chạy trong thời gian dài (chẳng hạn như tải hình ảnh xuống từ Internet) không được xử lý chính xác. Trong khi chức năng của hình ảnh vẫn hoạt động, chức năng cuộn lại "nhảy" liên tục khiến giao diện người dùng trông có vẻ không phản hồi (và không chuyên nghiệp!).

fcf3738b61270a1f.gif

Để tránh các sự cố như trên trong ứng dụng, bạn cần tìm hiểu thêm về luồng. Luồng (thread) là một khái niệm trừu tượng, nhưng bạn có thể coi đây là một đường dẫn thực thi duy nhất cho mã trong ứng dụng. Mỗi dòng mã bạn viết là một lệnh cần được thực thi theo thứ tự trên cùng một luồng.

Bạn từng xử lý các luồng trong Android. Mỗi ứng dụng Android đều có một luồng "chính" mặc định. Đây (thường) là luồng giao diện người dùng (UI thread). Tất cả mã bạn đã sử dụng cho đến nay đều nằm trong luồng chính. Mỗi câu lệnh (tức là một dòng mã) đợi cho câu lệnh trước thực thi xong rồi dòng mã tiếp theo mới thực thi.

Tuy nhiên, trong một ứng dụng đang chạy, có nhiều luồng khác ngoài luồng chính. Trong hậu trường, bộ xử lý không thực sự xử lý các luồng riêng biệt mà chuyển đổi qua lại giữa các lệnh để tạo ra hình thức đa nhiệm. Luồng là một khái niệm trừu tượng mà bạn có thể vận dụng khi viết mã để xác định xem mỗi lệnh nên đi theo đường dẫn thực thi nào. Khi sử dụng các luồng khác ngoài luồng chính, ứng dụng của bạn có thể thực hiện các tác vụ phức tạp (chẳng hạn như tải hình ảnh xuống) trong nền trong khi giao diện người dùng của ứng dụng vẫn có tính phản hồi. Đây được gọi là mã đồng thời (concurrent code), hay đơn giản là đồng thời (concurrency).

Trong lớp học lập trình này, bạn sẽ tìm hiểu về luồng cũng như cách sử dụng một tính năng của Kotlin tên là coroutine để viết mã đồng thời có tính rõ ràng và không chặn lẫn nhau.

Điều kiện tiên quyết

Kiến thức bạn sẽ học được

  • Mô hình đồng thời là gì và vì sao mô hình đồng thời lại quan trọng
  • Cách sử dụng coroutine và luồng để viết mã đồng thời không chặn nhau (non-blocking concurrent code)
  • Cách truy cập luồng chính (main thread) để cập nhật giao diện người dùng một cách an toàn khi thực hiện các tác vụ khác trong nền.
  • Cách thức và thời điểm sử dụng các loại mẫu đồng thời (Scope/Dispatchers/Deferred)
  • Cách viết mã tương tác với tài nguyên mạng

Sản phẩm bạn sẽ tạo ra

  • Trong lớp học lập trình này, bạn sẽ viết một số chương trình nhỏ để khám phá cách xử lý các luồng và coroutine trong Kotlin

Bạn cần có

  • Một máy tính sử dụng một trình duyệt web hiện đại (chẳng hạn như trình duyệt Chrome phiên bản mới nhất)
  • Quyền truy cập Internet trên máy tính

2. Giới thiệu

Đa luồng và đồng thời

Cho đến nay, chúng ta đã xem ứng dụng Android là một chương trình có một đường dẫn thực thi duy nhất. Bạn có thể làm được rất nhiều việc với đường dẫn thực thi đó, nhưng khi ứng dụng của bạn phát triển, bạn cần phải tư duy về mô hình đồng thời.

Mô hình đồng thời cho phép nhiều đơn vị mã thực thi không theo thứ tự hoặc có vẻ song song với nhau, từ đó cho phép sử dụng tài nguyên hiệu quả hơn. Hệ điều hành có thể sử dụng đặc điểm của hệ thống, ngôn ngữ lập trình và đơn vị đồng thời để quản lý đa nhiệm.

966e300fad420505.png

Vì sao nên sử dụng luồng đồng thời? Khi ứng dụng của bạn trở nên phức tạp hơn, mã của bạn nhất thiết phải không chặn lẫn nhau. Điều này có nghĩa là một tác vụ chạy trong thời gian dài (chẳng hạn như yêu cầu mạng) sẽ không làm cho các tác vụ khác trong ứng dụng dừng chạy. Việc không triển khai mô hình đồng thời đúng cách có thể khiến người dùng thấy ứng dụng của bạn không phản hồi.

Bạn có thể tham khảo một số ví dụ minh hoạ cách lập trình đồng thời trong Kotlin. Tất cả ví dụ này đều chạy được trong Kotlin Playground:

https://developer.android.com/training/kotlinplayground

Luồng là đơn vị mã nhỏ nhất có thể được lên lịch và chạy trong phạm vi một chương trình. Sau đây là một ví dụ nhỏ mà chúng ta có thể chạy mã đồng thời.

Bạn có thể tạo một luồng đơn giản bằng cách cung cấp một lambda. Hãy thử thực hiện như sau trong Playground.

fun main() {
    val thread = Thread {
        println("${Thread.currentThread()} has run.")
    }
    thread.start()
}

Luồng này không được thực thi cho đến khi hàm tiếp cận lệnh gọi hàm start(). Kết quả đầu ra sẽ có dạng như sau.

Thread[Thread-0,5,main] has run.

Lưu ý rằng currentThread() trả về một thực thể Thread được chuyển đổi thành bản trình bày dạng chuỗi để trả về tên, mức độ ưu tiên và nhóm của luồng. Nội dung đầu ra ở trên có thể hơi khác.

Tạo và chạy nhiều luồng

Để minh hoạ mô hình đồng thời đơn giản, hãy tạo một vài luồng để thực thi. Đoạn mã này sẽ tạo 3 luồng in ra dòng thông tin trong ví dụ trước.

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}

Kết quả trong playground:

Thread[Thread-2,5,main] has started Thread[Thread-2,5,main] - Starting Thread[Thread-0,5,main] - Doing Task 1 Thread[Thread-1,5,main] - Doing Task 1 Thread[Thread-2,5,main] - Doing Task 1 Thread[Thread-0,5,main] - Doing Task 2 Thread[Thread-1,5,main] - Doing Task 2 Thread[Thread-2,5,main] - Doing Task 2 Thread[Thread-0,5,main] - Ending Thread[Thread-2,5,main] - Ending Thread[Thread-1,5,main] - Ending Thread[Thread-0,5,main] has started
Thread[Thread-0,5,main] - Starting
Thread[Thread-1,5,main] has started
Thread[Thread-1,5,main] - Starting

Kết quả trong AS(console):

Thread[Thread-0,5,main] has started
Thread[Thread-1,5,main] has started
Thread[Thread-2,5,main] has started
Thread[Thread-1,5,main] - Starting
Thread[Thread-0,5,main] - Starting
Thread[Thread-2,5,main] - Starting
Thread[Thread-1,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 1
Thread[Thread-2,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 2
Thread[Thread-1,5,main] - Doing Task 2
Thread[Thread-2,5,main] - Doing Task 2
Thread[Thread-0,5,main] - Ending
Thread[Thread-2,5,main] - Ending
Thread[Thread-1,5,main] - Ending

Chạy mã nhiều lần. Bạn sẽ thấy nhiều kết quả khác biệt. Có lúc các luồng có vẻ như chạy theo trình tự còn lúc khác thì nội dung lại được xen kẽ.

3. Thách thức liên quan đến luồng

Sử dụng luồng là một cách đơn giản để bắt đầu xử lý nhiều tác vụ và mô hình đồng thời, nhưng không phải là không có vấn đề. Một số vấn đề có thể phát sinh khi bạn trực tiếp sử dụng Thread trong mã.

Luồng cần đến nhiều tài nguyên

Việc tạo, chuyển đổi và quản lý luồng làm tiêu tốn tài nguyên hệ thống và thời gian, làm hạn chế số luồng thật sự có thể được quản lý cùng lúc. Chi phí tạo thực sự có thể tăng nhiều.

Mặc dù ứng dụng đang chạy sẽ có nhiều luồng, nhưng mỗi ứng dụng lại có một luồng chuyên trách, cụ thể là phụ trách giao diện người dùng của ứng dụng. Luồng này thường được gọi là luồng chính (main thread) hoặc luồng giao diện người dùng (UI thread).

Vì chịu trách nhiệm chạy giao diện người dùng của ứng dụng nên quan trọng là luồng chính phải có hiệu suất tốt để ứng dụng hoạt động trơn tru. Mọi thao tác dài đều sẽ chặn luồng chính cho đến khi hoàn tất và khiến ứng dụng trở nên không phản hồi.

Hệ điều hành làm rất nhiều việc nhằm giữ cho mọi thứ thích ứng cho người dùng. Các dòng điện thoại hiện nay cố gắng cập nhật giao diện người dùng từ 60 đến 120 lần mỗi giây (tối thiểu 60 lần). Có một khoảng thời gian ngắn hữu hạn để chuẩn bị và vẽ giao diện người dùng (ở tốc độ 60 khung hình/giây, mỗi lần cập nhật màn hình chỉ mất tối đa 16 mili giây). Android sẽ bỏ khung hình hoặc huỷ nỗ lực hoàn tất một chu kỳ cập nhật duy nhất để cố gắng bắt kịp thời gian. Một chút giảm khung hình và biến động là bình thường, nhưng nếu quá nhiều sẽ khiến ứng dụng trở nên không phản hồi.

Tình huống tương tranh và hành vi khó đoán

Như đã thảo luận, luồng là một yếu tố trừu tượng phản ánh cách bộ xử lý giải quyết nhiều thao tác cùng lúc. Khi bộ xử lý chuyển đổi giữa các tập lệnh trên nhiều luồng, bạn sẽ không kiểm soát được thời gian chính xác mà một luồng được thực thi cũng như thời điểm một luồng bị tạm dừng. Không phải lúc nào bạn cũng dự đoán được kết quả khi trực tiếp xử lý các luồng.

Ví dụ: đoạn mã sau đây sử dụng một vòng lặp đơn giản để đếm từ 1 đến 50, nhưng trong trường hợp này, một luồng mới được tạo cho mỗi lần số đếm tăng lên. Hãy nghĩ đến kết quả đầu ra bạn mong đợi rồi chạy mã vài lần.

fun main() {
   var count = 0
   for (i in 1..50) {
       Thread {
           count += 1
           println("Thread: $i count: $count")
       }.start()
   }
}

Kết quả có đúng như bạn mong đợi không? Kết quả mỗi lần có giống nhau không? Mời bạn xem một kết quả mẫu do chúng tôi thu được.

Thread: 50 count: 49 Thread: 43 count: 50 Thread: 1 count: 1
Thread: 2 count: 2
Thread: 3 count: 3
Thread: 4 count: 4
Thread: 5 count: 5
Thread: 6 count: 6
Thread: 7 count: 7
Thread: 8 count: 8
Thread: 9 count: 9
Thread: 10 count: 10
Thread: 11 count: 11
Thread: 12 count: 12
Thread: 13 count: 13
Thread: 14 count: 14
Thread: 15 count: 15
Thread: 16 count: 16
Thread: 17 count: 17
Thread: 18 count: 18
Thread: 19 count: 19
Thread: 20 count: 20
Thread: 21 count: 21
Thread: 23 count: 22
Thread: 22 count: 23
Thread: 24 count: 24
Thread: 25 count: 25
Thread: 26 count: 26
Thread: 27 count: 27
Thread: 30 count: 28
Thread: 28 count: 29
Thread: 29 count: 41
Thread: 40 count: 41
Thread: 39 count: 41
Thread: 41 count: 41
Thread: 38 count: 41
Thread: 37 count: 41
Thread: 35 count: 41
Thread: 33 count: 41
Thread: 36 count: 41
Thread: 34 count: 41
Thread: 31 count: 41
Thread: 32 count: 41
Thread: 44 count: 42
Thread: 46 count: 43
Thread: 45 count: 44
Thread: 47 count: 45
Thread: 48 count: 46
Thread: 42 count: 47
Thread: 49 count: 48

Không giống như nội dung trong mã, có vẻ như luồng cuối đã được thực thi đầu tiên và một số luồng khác lại được thực thi không đúng thứ tự. Nếu bạn nhìn vào biến "count" ("số đếm") ở một số lần lặp, bạn sẽ nhận thấy biến này không thay đổi sau nhiều luồng. Lạ hơn nữa, count đạt 50 ở Thread 43 (Luồng 43) mặc dù kết quả cho thấy đây chỉ là luồng thứ hai để thực thi. Nếu chỉ xem xét kết quả đầu ra, bạn không thể biết giá trị cuối cùng của count là gì.

Đây chỉ là một trường hợp luồng gây ra hành vi khó đoán. Khi xử lý nhiều luồng, bạn cũng có thể gặp phải tình trạng gọi là tình huống tương tranh (race condition). Đây là khi nhiều luồng cố gắng truy cập cùng lúc vào cùng một giá trị trong bộ nhớ. Tình huống tương tranh có thể dẫn đến các lỗi khó tái tạo và trông có vẻ ngẫu nhiên, việc này có thể khiến ứng dụng gặp sự cố. Các tình huống như vậy thường không đoán trước được.

Vì một số lý do như vấn đề về hiệu suất, tình huống tương tranh và lỗi khó tái tạo, bạn không nên trực tiếp xử lý các luồng. Thay vào đó, bạn sẽ tìm hiểu về một tính năng trong Kotlin tên là Coroutine. Tính năng này sẽ giúp bạn viết mã đồng thời.

4. Coroutine trong Kotlin

Việc tạo và sử dụng luồng cho các tác vụ ở chế độ nền đã có mặt trong nền tảng Android, nhưng Kotlin cũng cung cấp các coroutine để giúp quản lý mô hình đồng thời một cách linh hoạt và dễ dàng hơn.

Coroutine cho phép xử lý đa nhiệm, nhưng đem đến một cấp độ trừu tượng khác thay vì chỉ xử lý các luồng. Coroutine có một đặc điểm chính là khả năng lưu trữ trạng thái (state) để có thể tạm dừng và tiếp tục hoạt động. Coroutine có thể thực thi hoặc không.

Trạng thái được biểu thị bằng các thành phần tiếp tục (continuation), cho phép các phần mã phát tín hiệu khi cần chuyển giao quyền kiểm soát hoặc chờ một coroutine khác hoàn tất công việc trước khi tiếp tục. Quy trình này được gọi là hợp tác đa nhiệm (cooperative multitasking). Nhờ việc triển khai coroutine của Kotlin, một số tính năng hỗ trợ đa nhiệm cũng được thêm vào. Bên cạnh các thành phần tiếp tục, việc tạo coroutine cũng đưa công việc đó vào Job – đây là một đơn vị công việc huỷ được, có vòng đời và nằm bên trong một CoroutineScope. CoroutineScope là một ngữ cảnh thực thi việc huỷ và các quy tắc khác đối với các phần tử con cũng như con của các phần tử con đó. Dispatcher quản lý việc coroutine sẽ sử dụng luồng dự phòng nào để thực thi, giảm bớt trách nhiệm xử lý thời điểm và vị trí sử dụng luồng mới cho nhà phát triển.

Công việc

Một đơn vị công việc huỷ được, chẳng hạn như đơn vị được tạo bằng hàm launch().

CoroutineScope

Các hàm dùng để tạo coroutine mới như launch()async() sẽ mở rộng CoroutineScope.

Dispatcher

Xác định luồng mà coroutine sẽ sử dụng. Trình điều phối (dispatcher) của Main sẽ luôn chạy các coroutine trên luồng chính, trong khi các trình điều phối như Default, IO hoặc Unconfined sẽ sử dụng các luồng khác.

Bạn sẽ tìm hiểu thêm về nội dung này sau, nhưng Dispatchers là một trong những cách mà các coroutine có thể hoạt động hiệu quả. Một lợi ích là tránh chi phí hiệu năng khi khởi tạo luồng mới.

Hãy điều chỉnh ví dụ trước đây của chúng ta để sử dụng coroutine.

import kotlinx.coroutines.*

fun main() {
    repeat(3) {
        GlobalScope.launch {
            println("Hi from ${Thread.currentThread()}")
        }
    }
}
Hi from Thread[DefaultDispatcher-worker-2@coroutine#2,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#1,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#3`,5,main]

Đoạn mã ở trên tạo ra 3 coroutine trong Global Scope bằng cách sử dụng trình điều phối (dispatcher) mặc định. GlobalScope cho phép mọi coroutine trong đó chạy miễn là ứng dụng còn đang chạy. Vì các lý do liên quan đến luồng chính mà chúng ta đã trình bày, bạn không nên sử dụng mã ví dụ bên ngoài. Khi bạn dùng coroutine trong ứng dụng, chúng ta sẽ dùng các phạm vi khác.

Hàm launch() tạo một coroutine từ mã kèm theo, mã này được bao bọc trong một đối tượng Công việc có thể huỷ. launch() được dùng khi không cần giá trị trả về bên ngoài giới hạn của coroutine.

Hãy xem bản đầy đủ của launch() để hiểu khái niệm quan trọng tiếp theo về coroutine.

fun CoroutineScope.launch() {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
}

Trong hậu trường, khối mã bạn truyền đến để khởi chạy được đánh dấu bằng từ khoá suspend. "Suspend" ("Tạm dừng") báo hiệu rằng một khối mã hoặc một hàm có thể bị tạm dừng hoặc tiếp tục.

Giới thiệu về runBlocking

Các ví dụ tiếp theo sẽ sử dụng runBlocking(). Như chính tên gọi, runBlocking bắt đầu một coroutine mới và chặn luồng hiện tại cho đến khi hoàn tất. runBlocking chủ yếu dùng để làm cầu nối giữa mã chặn và không chặn trong hàm chính và hàm kiểm thử. Bạn sẽ không sử dụng runBlocking trong mã Android thông thường.

import kotlinx.coroutines.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

val formatter = DateTimeFormatter.ISO_LOCAL_TIME
val time = { formatter.format(LocalDateTime.now()) }

suspend fun getValue(): Double {
    println("entering getValue() at ${time()}")
    delay(3000)
    println("leaving getValue() at ${time()}")
    return Math.random()
}

fun main() {
    runBlocking {
        val num1 = getValue()
        val num2 = getValue()
        println("result of num1 + num2 is ${num1 + num2}")
    }
}

getValue() trả về một số ngẫu nhiên sau một khoảng thời gian trễ nhất định. Hàm này sử dụng một DateTimeFormatter. Để minh hoạ thời điểm nhập/xuất phù hợp. Hàm chính gọi getValue() 2 lần và trả về giá trị tổng.

entering getValue() at 17:44:52.311
leaving getValue() at 17:44:55.319
entering getValue() at 17:44:55.32
leaving getValue() at 17:44:58.32
result of num1 + num2 is 1.4320332550421415

Để xem mã này hoạt động như nào trên thực tế, hãy thay thế hàm main() (giữ lại mọi mã khác) bằng đoạn mã sau.

fun main() {
    runBlocking {
        val num1 = async { getValue() }
        val num2 = async { getValue() }
        println("result of num1 + num2 is ${num1.await() + num2.await()}")
    }
}

Hai lệnh gọi đến getValue() là độc lập và không nhất thiết phải tạm dừng coroutine. Kotlin có một hàm async (không đồng bộ) tương tự như hàm launch (khởi chạy). Hàm async() được định nghĩa như sau.

fun CoroutineScope.async() {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
}: Deferred<T>

Hàm async() trả về giá trị thuộc kiểu Deferred. Deferred là một Job huỷ được có thể chứa một tham chiếu đến một giá trị trong tương lai. Bằng cách sử dụng Deferred, bạn vẫn có thể gọi một hàm như thể hàm đó trả về một giá trị ngay lập tức – Deferred chỉ đóng vai trò là phần giữ chỗ vì bạn không thể chắc chắn khi nào một tác vụ không đồng bộ được trả về. Deferred (còn gọi là Promise hoặc Future trong các ngôn ngữ khác) đảm bảo rằng sau đó một giá trị sẽ được trả về cho đối tượng này. Mặt khác, theo mặc định, tác vụ không đồng bộ sẽ không chặn hoặc chờ thực thi. Để bắt đầu, dòng mã hiện tại phải đợi kết quả của Deferred, bạn có thể gọi await() trên đó. Thao tác này sẽ trả về giá trị thô.

entering getValue() at 22:52:25.025
entering getValue() at 22:52:25.03
leaving getValue() at 22:52:28.03
leaving getValue() at 22:52:28.032
result of num1 + num2 is 0.8416379026501276

Khi nào nên đánh dấu hàm là tạm ngưng

Trong ví dụ trước, bạn có thể nhận thấy hàm getValue() cũng được xác định bằng từ khoá suspend. Lý do là hàm này gọi delay(), cũng là một hàm suspend. Mỗi khi một hàm gọi một hàm suspend khác, thì hàm đó cũng phải là một hàm suspend.

Nếu đúng như vậy, thì tại sao hàm main() trong ví dụ của chúng ta lại không được đánh dấu là suspend? Rốt cuộc thì hàm này có gọi getValue().

Không cần thiết. Hàm getValue() thực sự được gọi trong hàm lambda được chuyển vào runBlocking(). Hàm lambda là một hàm suspend, tương tự như các hàm được truyền (tham số) vào launch()async(). Tuy nhiên, runBlocking() không phải là hàm suspend. TranggetValue() hàm không được gọi trongmain() và cũng khôngrunBlocking() mộtsuspend chức năng,main() không được đánh dấu bằngsuspend. Nếu một hàm không gọi một hàm suspend thì bản thân nó không cần phải là hàm suspend.

5. Tự thực hành

Ở đầu lớp học lập trình này, bạn đã thấy ví dụ sau đây sử dụng nhiều luồng. Với kiến thức về coroutine, bạn hãy viết lại mã để sử dụng coroutine thay cho Thread.

Lưu ý: Bạn không cần phải chỉnh sửa các câu lệnh println(), mặc dù các câu lệnh này tham chiếu đến Thread.

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}

6. Đáp án cho bài tập thực hành

import kotlinx.coroutines.*

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       GlobalScope.launch {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
           }
       }
   }
}

7. Tóm tắt

Bạn đã tìm hiểu

  • Tại sao cần có mã đồng thời
  • Luồng là gì và tại sao các luồng lại quan trọng đối với mô hình đồng thời
  • Cách dùng coroutine để viết mã đồng thời bằng Kotlin
  • Khi nào thì nên/không nên đánh dấu một hàm là "suspend" ("tạm ngưng")
  • Vai trò của CoroutineScope, Job, và Dispatcher
  • Sự khác biệt giữa Deferred và Await

8. Tìm hiểu thêm