Bài viết dịch, tham khảo và chỉnh lý từ bài "Multithread and Kotlin" của Korhan Bircan

Bộ vi xử lý máy tính thời kỳ đầu chỉ xử lý được một lệnh trong một tick của bộ đếm xung nội bộ (internal clock). Máy tính dẫn đường phi thuyền con thoi Apollo 11 đưa Neil Amstrong đặt chân lên Sao Hỏa 40 năm về trước có bộ vi xử lý 1 lõi (Single core) chứa 16,800 transistors, chạy ở xung nhịp 2MHz và có 4kb RAM. Ngày nay, iPhoneX có vi xử lý gồm 6 lõi A11 Bionic chip với hơn 4 triệu transistors chạy ở xung nhịp 2.4 MHz cùng bộ nhớ là 3GRAM. Không ngoa nếu nói rằng chỉ cần một chiếc smart phone ngày này, bạn thừa sức điều khiển  một dàn phi thuyền con thoi đồng thời hạ cánh ở các hành tinh khác nhau trong hệ mặt trời.

Tại sao? bởi vi xử lý hiện đại có thể chạy đồng thời nhiều tác vụ. Theo Geekbench Browser, một công cụ kiểm thử hiệu năng tính toán thì iPhone 7 có điểm cao hơn cả MacBook Pro 13 inch dùng vi xử lý Core i5 đời 7 trong bài kiểm tra tính toán đa nhiệm trên nhiều lõi. Với khả năng tính toán của CPU dữ dội như vậy, thực là phí phạm nếu chúng ta vẫn chạy thực thi 1 tác vụ 1 thời điểm và để các transistor còn lại nghỉ ngơi trong khi vẫn nhiều tác vụ phải xếp hàng chờ đến lượt thực thi. Kiến trúc máy tính hiện nay hỗ trợ rất tốt việc thực thi nhiều process và threads đồng thời.

Một process là một thực thể đang chạy của một chương trình phần mềm. Còn một thread là chuỗi các lệnh được quản lý bởi trình lập lịch thực thi. Mỗi process sở hữu hoặc tạo mới một hoặc nhiều thread. Các thread khác nhau sẽ phối hợp đan xen để chạy. Mỗi thread chỉ được CPU core chạy xoay tua trong một khoảng thời gian gọi là quantum. Khi một thread quantum hoàn thành, trình lập lịch thread (thread scheduler) sẽ chuyển sang thread khác rất nhanh khiến cho chúng ta có cảm nhận là các thread đang chạy đồng thời nhưng thực tế là chúng chạy nối tiếp nhau. Thread context switching (chuyển đổi môi trường thread) sẽ nhẹ nhàng hơn process context switch (chuyển đổi môi trường processs) bởi các threads trong cùng một ứng dụng cùng chia sẻ vùng nhớ cho phép thread có thể đọc và ghi cùng một cấu trúc dữ liệu và biến với thread anh em.

Context switch là gì? Phần này mình cắt nghĩa thêm để các bạn rõ, thi một instruction được thực thi, các biến, tham số liên quan phải được nạp vào các thanh ghi (registers). Thread gồm một chuỗi các instruction. Khi chuyển sang thread khác chứa chuỗi các instruction khác và các biến, tham số khác, CPU phải làm công việc luân chuyển dữ liệu của thread trước nạp vào thanh ghi ra bộ nhớ đệm, và nạp dữ liệu của thread mới vào thanh ghi.  Việc luân chuyển dữ liệu tham số vào và ra khỏi thanh ghi <--> bộ đệm chính là context switch. Dữ liệu, tham số mà thread cần dùng càng lớn, phức tạp thì context switch ~ mất nhiều xung nhịp  để hoàn thành.

Việc truyền thông phối hợp giữa các process được gọi là Inter Process Communication (IPC) rất khó và tốn kém. IPC chia thành 2 loại: local IPC và remote IPC. Remote IPC là trao đổi giữa 2 process chạy trên 2 máy tính khác nhau nối với nhau qua mạng còn phức tạp hơn nữa...

Giờ các bạn đã có chút ý niệm về Thread, Process, CPU, Core, Tick, Thread Scheduler, Thread Context Switch, Inter Process Communication.. Chúng ta sẽ so sánh cách thức ngôn ngữ lập trình Java, Kotlin, Swift thực thi đồng bộ như thế nào nhé.

Lập trình multi-thread với Java

Có 2 cách.
Cách 1 tạo một class kế thừa Thread class

// Java code for thread creation by extending
// the Thread class
class MultithreadingDemo extends Thread
{
    public void run()
    {
        try
        {
            // Displaying the thread that is running
            System.out.println ("Thread " + Thread.currentThread().getId()+ " is running");

        }
        catch (Exception e)
        {
            // Throwing an exception
            System.out.println ("Exception is caught");
        }
    }
}

// Main Class
public class Main
{
    public static void main(String[] args)
    {
        int n = 8; // Number of threads
        for (int i = 0; i < n; i++)
        {
            MultithreadingDemo object = new MultithreadingDemo();
            object.start();
        }
    }
}

Kết quả sẽ kiểu như thế này

Thread 16 is running
Thread 17 is running
Thread 14 is running
Thread 18 is running
Thread 20 is running
Thread 19 is running
Thread 13 is running
Thread 15 is running

Cách 2: tuân thủ interface Runnable

Ưu điểm của tuân thủ interface là class có thể tuân thủ nhiều interface và kế thừa từ một class khác không bị bó buộc như cách số 1.

class MultithreadingDemo implements Runnable
{
    public void run()
    {
        try
        {
            // Displaying the thread that is running
            System.out.println ("Thread " + Thread.currentThread().getId() + " is running");

        }
        catch (Exception e)
        {
            // Throwing an exception
            System.out.println ("Exception is caught");
        }
    }
}

// Main Class
class Main
{
    public static void main(String[] args)
    {
        int n = 8; // Number of threads
        for (int i = 0; i < n; i++)
        {
            Thread object = new Thread(new MultithreadingDemo());
            object.start();
        }
    }
}

Lập trình Multi-thread với Kotlin

Chúng ta sử dụng extenstion function để tạo ra Java Thread

fun thread(
        start: Boolean = true, // If true, the thread is immediately started.
        isDaemon: Boolean = false, //  If true, the thread is created as a daemon thread.
        contextClassLoader: ClassLoader? = null, // The class loader to use for loading classes and resources in this thread.
        name: String? = null, // Name of the thread.
        priority: Int = -1, // Priority of the thread.
        block: () -> Unit // Block of code to run.
): Thread (source)

Rồi sau đó khởi động thread bằng dòng lệnh đơn giản như thế này

thread() {
    println("${Thread.currentThread()} has run.")
}

Khi  đã quen lập trình với Thread, bạn có xu hướng tạo nhiều thread để xử lý đồng thời nhiều tác vụ để ứng dụng chạy nhanh, mượt hơn. Thực tế không phải lúc nào cũng đúng.  Tạo ra nhiều thread có thể khiến ứng dụng chạy chậm lại bởi scheduler sẽ phải làm việc context switching thường xuyên hơn, việc thu dọn vùng nhớ garbage collection khá phức tạp đối khi còn đắt đỏ hơn lợi ích từ việc chạy đa nhiệm.

Nếu bạn từng làm việc với IOS chắc sẽ biết Grand Central Dispatch, GCD. GCD như là một phòng điều phối trung tâm, ở đây lập trình viên không phải bận tâm để khởi tạo thread nữa. Lập trình viên chỉ cần quan tâm có Main Queue chạy tác vụ trên main thread cập nhật, vẽ giao diện và các loại hàng đợi cho các tác vụ sẽ chạy trên các thread khác. Lập trình có thể tạo queue, đặt thuộc tính qos (quality of service, mức độ ưu tiên thực thi), cách thực thi: đồng bộ hay tuần tự. Còn từng tác vụ trong hàng đợi sẽ chạy trên thread nào là việc phân phối điều hành của GCD sao cho tối ưu nhất. GCD cũng giúp lập trình viên điều khiển việc chạy tác vụ theo nhóm đợi nhau (wait group), hay giới hạn số tác vụ thực thi đồng thời (semaphore)... Xem thêm ở đây nhé 

Trong Kotline hãy thử tạo một triệu thread, các thread này cùng truy xuất đến biến counter và cộng thêm một trong lượt chạy của mình

import kotlin.concurrent.thread
import kotlin.system.measureTimeMillis

fun main(args: Array<String>) {
    var counter = 0
    val numberOfThreads = 1_000_000
    val time = measureTimeMillis {
        for (i in 1..numberOfThreads) {
            thread(start = true) {
                counter += 1
            }
        }
    }
    println("Created ${numberOfThreads} threads in ${time}ms.")
}

Ứng dụng Kotlin này chạy mất 33.9 giây trên MacBook Pro 15 inch 2016, 2.9GHz, còn trên Hackintosh DELL M6800, 2.4GHz là 52.8 giây !

Sau một hồi giới thiệu Process, Thread, lập trình Thread trên Java, Swift, rồi Kotlin, giờ chúng ta nói đến nhân vật chính trong bài này đó là Kotline Coroutine

Kotline Coroutine

coroutine là kỹ thuật lập trình mới viết tác vụ chạy không đồng bộ (asynchronous) và không chặn tác vụ khác (non-blocking). Coroutine có thể hiểu như thread ở dạng gọn nhẹ, tiết kiệm tài nguyên CPU khi chạy. Giống với thread, coroutine có thể chạy song song hoặc đồng thời, chờ coroutine khác hoặc trao đổi qua lại giữa các coroutine. Khác biệt lớn nhất là tạo mới coroutine hoàn toàn không tốn tài nguyên.

Non-blocking thực ra một khái niệm rất dễ hiểu, gặp ở khắp nơi. Bạn vào cửa hàng Pizza vào ngày thứ 7 cuối tuần đông đúc và xếp hàng. Khi đến lượt bạn yêu cầu một Pizza đặc biệt cần phải chuẩn bị nướng trong 15 phút mới xong. Cách 1 là bạn cứ đứng ở quầy thu ngân cho đến khi bạn nhận Pizza, các khách hàng sẽ phải chờ lại phía sau rất sốt ruột và mệt mỏi. Cách 2, là quầy thu ngân thu tiền của bạn và gửi cho bạn một hóa đơn đánh số, bạn bước sang một bên, không ngáng đường các khách hàng phía sau (non-blocking), khi nào (lúc này tôi nói đến tương lai, in the future !), pizza của bạn làm xong, nhân viên bán hàng sẽ gọi số, bạn quay lại hàng đợi để lấy bánh. Việc nhận bánh này rất ngắn nên sẽ không ảnh hưởng đến các khách hàng khác. Trong lúc bạn chờ bánh pizza hoàn thành, các khách hàng khác vẫn có thể đặt hàng, bạn có thể lướt web, hoặc đọc sách. Doanh số cửa hàng tăng vì phục vụ được nhiều khách hàng hơn, và khách hàng cũng hài lòng hơn.

Viết lại đoạn code phía trên, thay thread bằng coroutine

var counter = 0
val numberOfCoroutines = 1_000_000
val time = measureTimeMillis {
    for (i in 1..numberOfCoroutines) {
        launch {
            counter += 1
        }
    }
}

Thời gian thực thi giờ chỉ còn 424 milliseconds,  có nghĩa tốc độ đã tăng 80 lần. Điều kỳ diệu bên trong coroutine đó là coroutines thực thị trên một nhóm chung các thread. Một thread có thể chạy nhiều coroutine, do đó không cần phải tạo thread mới cho mỗi tác vụ trong coroutine, sẽ không phải khởi tạo rồi giải phóng bộ nhớ nhiều như trước. Garbage collection được giảm xuống tối đa.

Hàm launch{} trong ví dụ trên là hàm thư viện để khởi động tác vụ trong coroutine, mà không chặn thread hiện tại, và trả về tham chiếu đến coroutine dưới dạng một đối tượng Job. Coroutine có thể hủy bỏ khi job được yêu cầu hủy bỏ. Mặc định coroutine được thực thi ngay.


Nếu bạn muốn trả về giá trị từ một coroutine, hãy bắt đầu với async{} function, hàm này sẽ tạo ra một coroutine mới và trả về kết quả trong tương lai dưới dạng Deferred<T>. Deffered là một dạng tác vụ tương lại chạy không chặn việc thực thi (non-blocking) và có thể hủy được.

Có thể hủy được (cancellable) là thế nào?
Lần này tôi lại lấy ví dụ tôi đặt Pizza. Chiếc bánh Pizza đặc biệt cần 15 phút để làm. Đơn hàng của tôi đã được tiếp nhận, tôi đã trả tiền và lấy hóa đơn để chuẩn bị nhận bánh Pizza khi nào nướng xong. Nhưng do cửa hàng quá đông, có đến 10 khách yêu cầu Pizza đặc biệt.  Đơn hàng của tôi phải xếp vào hàng đợi. Chừng nào đơn hàng của tôi chưa bắt đầu thực sự làm, cửa hàng cho phép tôi có thể hủy đơn hàng.


Giờ chúng ta cùng xem việc chạy tác vụ kiểu async có ích lợi gì nhé. Chúng ta có hai hàm đếm từ 1 đến 10 tỷ.

fun meaninglessCounter(): Int {
    var counter = 0
    for (i in 1..10_000_000_000) {
        counter += 1
    }
    return counter
}
fun main(args: Array<String>) {
    // Sequential execution.
    var time = measureTimeMillis {
        val one = meaninglessCounter()
        val two = meaninglessCounter()
        println("The answer is ${one + two}")
    }
    println("Sequential completed in $time ms")
    
    // Concurrent execution.
    var time2 = measureTimeMillis {
        val one = async { meaninglessCounter() }
        val two = async { meaninglessCounter() }
        runBlocking {
            println("The answer is ${one.await() + two.await()}")
        }
    }
    println("Concurrent completed in $time2 ms\n")
}

Cách 1 là hai hàm one - two thực thi bình thường, mặc định là tuần tự. Còn cách thứ hai, hai hàm đó được gọi async.
Kết quả trả về ở cách 1 sẽ được tính khi cả hàm one xong rồi đến hàm two xong.
Kết quả trả về ở cách 2 sẽ được tính bên trong khối runBlocking, lệnh tính tổng có thêm hàm nối tiếp await()

runBlocking {
   println("The answer is ${one.await() + two.await()}")
}

Bên trong một tiệm Pizza nhỏ chỉ có 1 đầu bếp nhưng có 1 lò nướng đế bột mỳ pizza và 1 lò nướng thịt. Để nướng 1 đế pizza cần 5 phút còn nướng thịt thì cần 4 phút. Nếu đầu bếp làm tuần tự 2 việc: nướng xong đế pizza, rồi mới nướng thịt rồi phủ lên thì sẽ cần tổng cộng 9 phút. Chưa kể vào mùa đông, đế pizza nướng xong để ở ngoài sẽ nguội và ỉu đi. Cách khôn ngoan hơn đó là cho đế pizza vào lò, mất khoảng 20 giây, rồi quay sang cho thịt vào lò. Đế pizza nướng xong, thì cũng chỉ mất độ 40 giây sau, thịt cũng sẵn sàng, đầu bếp chỉ mất 5 phút để hoàn thành một chiếc bánh. Năng suất cao hơn cách làm cũ, mà bánh cũng ngon hơn

Biểu đồ dưới cho thấy nếu chạy nhiều tác vụ ở chế độ concurrent thì thời gian chạy sẽ giảm đi nhiều so với chạy tuần tự sequential

await() là cách ứng dụng đợi và nhận kết quả trả về từ coroutine được khởi động bằng async. Lệnh println sẽ được tạm dừng lại (suspend) để chờ 
one.await() + two.await() hoàn thành.

Để kết thúc bài này, tôi chia sẻ thêm một video mới nhất tại Google IO giải thích tại sao Google lại chọn Kotline và coroutine cho nền tảng Android.

  • Java chỉ có native multi-thread mà không có coroutine (một dạng light weight thread)
  • Cú pháp để thực hiện non-blocking I/O trong Java khá phức tạp hoặc phải dùng đến RxJava
  • Kotline có coroutine được thiết kế để xử lý tốt hạn chế này trong Java. Hãy nhớ rằng Kotline được thiết kế để cải tiến những điểm bất cập trong Java chứ không chỉ là một phiên bản nhái Java code gọn hơn đâu nhé.