Đa luồng và Đồng thời trong phát triển di động: Khái niệm, nguyên lý và cách hoạt động

Tác giả: IT Sectr Đã đăng: 2026-03-12 Thời gian đọc: 13 phút

Mọi ứng dụng di động đều thực hiện nhiều tác vụ cùng lúc: tải dữ liệu từ mạng, xử lý thao tác chạm của người dùng, tạo hiệu ứng hoạt ảnh và lưu tệp. Nếu tất cả mã này chạy trên một luồng duy nhất, ứng dụng sẽ bị treo khi có bất kỳ độ trễ mạng nào. Đa luồng (multithreading) và đồng thời là những khái niệm chính giúp ứng dụng luôn phản hồi nhanh và hiệu quả. Trong bài viết này, chúng ta sẽ xem xét tất cả các công cụ chính: từ Main Thread và RunLoop đến Coroutines trong Kotlin và Combine trên iOS. Nội dung dựa trên tài liệu chính thức của Apple về GCD.

Các điểm chính

  • Main Thread — luồng duy nhất để làm việc với UI; tất cả các tác vụ khác được chuyển sang Background
  • GCD và OperationQueue — cơ chế đa luồng chính trong iOS
  • Coroutines và Flow — tiêu chuẩn hiện đại cho bất đồng bộ trong Kotlin/Android
  • RxJava, RxSwift và Combine — framework phản ứng để làm việc với luồng dữ liệu
  • Race Condition, Deadlock và Livelock — các vấn đề đa luồng cổ điển cần đồng bộ hóa
  • Việc chọn công cụ phụ thuộc vào nền tảng và độ phức tạp của tác vụ: cho các lệnh gọi đơn giản, Async/Await là đủ; cho các luồng phức tạp — Rx hoặc Combine

Đa luồng là gì?

Đa luồng là khả năng của một ứng dụng thực hiện nhiều đoạn mã cùng lúc. Mỗi đoạn chạy trong một luồng (Thread) riêng biệt — một tiến trình nhẹ với ngăn xếp cuộc gọi riêng. Trong phát triển di động, các luồng được chia thành hai loại: Main Thread (luồng UI) và Background Threads (luồng nền).

Hệ điều hành tự quản lý việc phân phối các luồng trên các lõi xử lý. Các thiết bị hiện đại có 6–8 lõi, do đó thực thi song song thực sự có thể tăng tốc công việc. Tuy nhiên, việc tạo luồng là một thao tác tốn kém, vì vậy không nên làm việc trực tiếp với Thread. Thay vào đó, các lớp trừu tượng cấp cao hơn được sử dụng: DispatchQueue, OperationQueue, CoroutineDispatcher.

Đồng thời (Concurrency) là một khái niệm rộng hơn đa luồng. Đồng thời có nghĩa là các tác vụ có thể được thực thi "đồng thời" ngay cả trên một lõi duy nhất thông qua chuyển đổi ngữ cảnh. Bất đồng bộ (Async/Await) là một mô hình lập trình trong đó một tác vụ không chặn luồng mà trả lại quyền điều khiển trong khi chờ kết quả. Các ngôn ngữ hiện đại (Kotlin, Swift, Dart) có hỗ trợ Async/Await tích hợp.

Tại IT Sectr, chúng tôi đặc biệt chú trọng đến kiến trúc đa luồng phù hợp ngay từ khi bắt đầu dự án. Những sai lầm mắc phải ở giai đoạn đầu dẫn đến các lỗi khó phát hiện: điều kiện tranh đua, khóa chết và sự mất ổn định của ứng dụng khi chịu tải. Mỗi dự án của chúng tôi đều trải qua quá trình xem xét kiến trúc đồng thời ở giai đoạn lập kế hoạch.

Luồng chính (Main/Background)

Main Thread (luồng chính) — luồng duy nhất trong ứng dụng di động có quyền truy cập vào UI. Trên Android, nó được gọi là UI Thread, trên iOS — Main Thread. Tất cả các thao tác giao diện — thay đổi văn bản, hoạt ảnh, xử lý chạm — chỉ được thực hiện trên Main Thread. Nếu một thao tác nặng (tải tệp, phân tích JSON) được thực hiện trên luồng chính, giao diện sẽ ngừng phản hồi. Trên Android, điều này dẫn đến ANR (Application Not Responding), trên iOS — màn hình bị "đóng băng".

Background Threads (luồng nền) dành cho mọi thứ không liên quan đến UI: yêu cầu mạng, thao tác cơ sở dữ liệu, xử lý hình ảnh, mã hóa. Sau khi hoàn thành, kết quả được chuyển đến Main Thread để hiển thị. Mỗi nền tảng cung cấp các công cụ riêng để chuyển đổi giữa các luồng: DispatchQueue.main.async trên iOS, runOnUiThread hoặc withContext(Dispatchers.Main) trên Android.

RunLoop — vòng lặp xử lý sự kiện trên luồng chính của iOS. RunLoop chờ các sự kiện (chạm, bộ đếm thời gian, thông báo) và phân phối chúng đến các trình xử lý thích hợp. Trên Android, tương đương là Looper, liên kết với mỗi Main Thread. Main Looper trích xuất thông báo từ hàng đợi vô hạn và chuyển chúng đến Handler để xử lý. Hiểu về RunLoop và Looper giúp tránh rò rỉ bộ nhớ và hiện tượng "giật" giao diện.

GCD và OperationQueue (iOS)

Grand Central Dispatch (GCD) — thư viện Apple để quản lý đa luồng ở cấp độ ngôn ngữ C. GCD làm việc với DispatchQueue — hàng đợi tác vụ. Nhà phát triển không tạo luồng thủ công; GCD quản lý một nhóm luồng (Thread Pool), phân phối tác vụ trên các lõi xử lý khả dụng. DispatchQueue có hai loại: Serial Queue (hàng đợi tuần tự — các tác vụ được thực thi lần lượt) và Concurrent Queue (hàng đợi đồng thời — các tác vụ có thể thực thi cùng lúc).

Main DispatchQueue — hàng đợi tuần tự gắn với luồng chính. Global Queues — hàng đợi đồng thời với các mức ưu tiên khác nhau (QoS — Quality of Service): userInteractive, userInitiated, utility, background. Chọn QoS đúng rất quan trọng cho hiệu suất: .userInteractive — cho các tác vụ ảnh hưởng đến UI (hoạt ảnh, kết xuất); .background — cho các tác vụ không quan trọng về thời gian (đồng bộ hóa, dọn bộ nhớ đệm).

OperationQueue — lớp trừu tượng trên GCD với các khả năng bổ sung: hủy tác vụ, thiết lập phụ thuộc giữa các thao tác, kiểm soát số lượng thao tác đồng thời tối đa. Các thao tác là đối tượng của lớp Operation (hoặc BlockOperation). Ví dụ: nếu bạn cần tải một hình ảnh, sau đó áp dụng bộ lọc và chỉ sau đó hiển thị nó — OperationQueue với các phụ thuộc xử lý hoàn hảo. Trong GCD, bạn phải đồng bộ hóa thủ công các bước này bằng DispatchGroup hoặc semaphore.

Async/Await trong Swift 5.5+ — giải pháp thay thế hiện đại cho GCD. Các từ khóa asyncawait làm cho mã bất đồng bộ trở nên tuyến tính và dễ đọc. Các hàm được đánh dấu là async và các lệnh gọi được chờ đợi thông qua await. Hệ thống tự quản lý việc chuyển đổi ngữ cảnh: theo mặc định, một hàm async chạy trên luồng nền, trong khi cập nhật UI chạy trên MainActor. @MainActor — một thuộc tính đảm bảo thực thi mã trên luồng chính.

Coroutines và Flow (Kotlin)

Coroutines (coroutine) — các luồng nhẹ cho Kotlin do JetBrains phát triển. Không giống như các luồng thông thường, coroutine không bị ràng buộc với một Thread cụ thể. Hàng ngàn coroutine có thể chạy trên nhiều luồng mà không có chi phí đáng kể. CoroutineScope quản lý vòng đời của coroutine: viewModelScope gắn với ViewModel, lifecycleScope — với Activity/Fragment. Khi phạm vi bị hủy, tất cả coroutine con tự động bị hủy.

Dispatchers xác định nhóm luồng nào coroutine chạy trên đó: Dispatchers.Main — luồng UI; Dispatchers.IO — cho các yêu cầu mạng và thao tác đĩa; Dispatchers.Default — cho các tính toán tốn CPU. Để chuyển đổi dispatcher, sử dụng withContext. Coroutine hỗ trợ đồng thời có cấu trúc (structured concurrency): mỗi coroutine có một cha và khi cha bị hủy, tất cả coroutine con đều bị hủy. Điều này ngăn chặn rò rỉ bộ nhớ và các tác vụ treo.

Flow — luồng dữ liệu bất đồng bộ lạnh từ thư viện coroutine. Flow phát ra các giá trị tuần tự: (1) nhà sản xuất tạo dữ liệu, (2) toán tử biến đổi luồng, (3) bộ thu thập tiêu thụ kết quả. Không giống như LiveData, Flow hỗ trợ chuỗi toán tử phức tạp (map, filter, flatMapConcat, catch) và hoàn toàn an toàn với luồng. StateFlowSharedFlow — các biến thể nóng của Flow, lý tưởng cho trạng thái UI và các sự kiện một lần (Snackbar, điều hướng).

Channel — một trừu tượng coroutine khác để truyền dữ liệu giữa các coroutine. Channel hoạt động như một hàng đợi: một người gửi (send) và một hoặc nhiều người nhận (receive). Các kênh có bộ đệm (Channel(UNLIMITED), Channel(BUFFERED)) cho phép cấu hình hành vi khi tràn. Channel thường được sử dụng cùng với Flow để kết nối API dựa trên callback với coroutine: callbackFlow { … }.

Tại IT Sectr, chúng tôi tích cực sử dụng coroutine và Flow trong tất cả các dự án Android. Điều này cho phép viết mã bất đồng bộ trông giống như đồng bộ, dễ kiểm thử (runTest, TestDispatcher) và không yêu cầu quản lý luồng thủ công. Ví dụ về một coroutine đơn giản với tải dữ liệu:

kotlin
class UserRepository(
    private val api: UserApi,
    private val dao: UserDao
) {
    suspend fun getUsers(): List<User> = withContext(Dispatchers.IO) {
        return@withContext try {
            val users = api.fetchUsers()
            dao.insertAll(users)
            users
        } catch (e: Exception) {
            dao.getAll()
        }
    }
}

Rx và Combine

Lập trình phản ứng — một mô hình trong đó dữ liệu lan truyền dưới dạng luồng bất đồng bộ (Observable, Publisher). RxJava/RxKotlin — triển khai phổ biến nhất cho Android, được chuyển từ .NET Rx. RxSwift — thư viện tương tự cho iOS. Các thành phần chính: Observable (nguồn sự kiện), Observer (người đăng ký), Scheduler (quản lý luồng), Operators (biến đổi luồng).

Combine — framework của Apple cho lập trình phản ứng được giới thiệu trong iOS 13. Combine sử dụng các giao thức Publisher (nhà xuất bản) và Subscriber (người đăng ký). Không giống như RxSwift, Combine được tích hợp trong SDK và liên kết chặt chẽ với SwiftUI. Các toán tử trong Combine: map, filter, combineLatest, zip, debounce, throttle — bao phủ hầu hết các tình huống: từ liên kết dữ liệu đến UI đến debounce truy vấn tìm kiếm.

Future và Promise — các mẫu để làm việc với một kết quả bất đồng bộ duy nhất. Future đại diện cho một giá trị sẽ có sẵn sau. Promise là lời hứa cung cấp một giá trị. Trong Rx, đây là Single (một phản hồi thành công hoặc lỗi), trong Combine — Future Publisher. Trong thực tế, Future/Promise thuận tiện cho các yêu cầu API đơn lẻ, trong khi Observable/Publisher — cho các luồng liên tục (định vị địa lý, nhập văn bản).

Callback và Delegate — các mẫu cổ điển cho thao tác bất đồng bộ. Callback — một hàm được truyền như một đối số và được gọi khi hoàn thành thao tác. Delegate — một đối tượng triển khai giao thức với các phương thức xử lý sự kiện. Nhược điểm: "callback hell" (callback lồng nhau) và độ phức tạp xử lý lỗi. NotificationCenter (iOS) và EventBus (Android) — cơ chế phát sóng sự kiện, hữu ích cho giao tiếp kết nối lỏng lẻo nhưng dẫn đến phụ thuộc ngầm.

Vấn đề đa luồng (Race Condition, Deadlock)

Đa luồng mở ra cánh cửa đến hiệu suất cao, nhưng đồng thời tạo ra nguy cơ mắc các lỗi khó phát hiện. Phổ biến nhất: Race Condition (điều kiện tranh đua), Deadlock (khóa chết), Livelock (khóa sống) và Starvation (đói luồng). Hiểu những vấn đề này là kỹ năng bắt buộc đối với bất kỳ nhà phát triển di động nào.

Race Condition

Race Condition xảy ra khi hai hoặc nhiều luồng đồng thời đọc và ghi cùng một dữ liệu mà không có đồng bộ hóa. Kết quả phụ thuộc vào luồng nào thực thi trước. Ví dụ kinh điển: hai luồng tăng một bộ đếm. Thao tác "đọc → tăng → ghi" không phải là nguyên tử, do đó khi thực thi đồng thời, một lần tăng bị "mất". Giải pháp — sử dụng thao tác nguyên tử (AtomicInteger, AtomicReference) hoặc khóa (Mutex, Semaphore, synchronized).

Deadlock

Deadlock — tình huống mỗi luồng giữ một tài nguyên và chờ tài nguyên bị chiếm bởi luồng khác. Không luồng nào có thể tiếp tục. Điều kiện xảy ra: loại trừ lẫn nhau, giữ và chờ, không ưu tiên, chờ vòng tròn. Phòng ngừa: thiết lập thứ tự khóa duy nhất, sử dụng tryLock với thời gian chờ, áp dụng thuật toán Lock-Free (ConcurrentHashMap, CopyOnWriteArrayList).

Livelock và Starvation

Livelock — các luồng không bị khóa nhưng liên tục "chuyển" tài nguyên cho nhau mà không thực hiện công việc hữu ích. Ví dụ: hai người gặp nhau trong hành lang và cả hai đều nhường đường, di chuyển cùng một hướng. Starvation — một luồng không được truy cập tài nguyên vì các luồng khác liên tục chặn nó. Giải pháp: khóa công bằng (fair locks), ưu tiên luồng một cách thận trọng.

Công cụ đồng bộ hóa

Để ngăn ngừa các vấn đề đa luồng, các nguyên thủy đồng bộ hóa được sử dụng: Mutex (loại trừ lẫn nhau), Semaphore (giới hạn số lượng truy cập đồng thời), Lock (giao diện với tryLock), Synchronized (khóa cấp JVM), @MainActor (Swift — đảm bảo thực thi trên luồng chính). Trên Android cũng có ThreadPool (nhóm luồng) thông qua Executors.newFixedThreadPool, newCachedThreadPool. Tuy nhiên, quản lý nhóm thủ công là đặc quyền của các dự án kế thừa; trong các dự án mới, tốt hơn nên sử dụng coroutine.

Công cụ Nền tảng Loại Tính năng
DispatchQueue (GCD)iOSHàng đợi tác vụTuần tự/Đồng thời, ưu tiên QoS, Thread Pool do hệ thống quản lý
OperationQueueiOSHàng đợi thao tácPhụ thuộc, hủy, maxConcurrentOperationCount
Coroutines + FlowAndroidCoroutineNhẹ, đồng thời có cấu trúc, StateFlow, Channel
RxJava / RxKotlinAndroidLuồng phản ứngObservable, Schedulers, bộ toán tử phong phú
CombineiOSLuồng phản ứngPublisher/Subscriber, tích hợp SwiftUI
Async/Await + TaskiOS / AndroidMô hình bất đồng bộMã tuyến tính, @MainActor, đồng thời có cấu trúc

Câu hỏi thường gặp

Main Thread khác Background Thread như thế nào?

Main Thread (luồng UI) chịu trách nhiệm kết xuất giao diện và xử lý thao tác chạm. Background Thread thực hiện các tác vụ nền — tải dữ liệu, tính toán, làm việc với mạng. Chặn Main Thread gây ra đóng băng giao diện (ANR trên Android, frozen UI trên iOS).

Race Condition là gì và làm thế nào để tránh?

Race Condition — điều kiện tranh đua khi hai luồng đồng thời truy cập dữ liệu dùng chung và kết quả phụ thuộc vào thứ tự thực thi. Tránh bằng đồng bộ hóa: Mutex, Semaphore, Lock, Synchronized, @MainActor hoặc thao tác nguyên tử.

Coroutines hay RxJava: chọn gì cho Android?

Coroutines là tiêu chuẩn hiện đại cho Android (JetBrains, được Google hỗ trợ). RxJava/RxKotlin là cách tiếp cận phản ứng với bộ toán tử phong phú. Coroutines đơn giản hơn cho các cuộc gọi bất đồng bộ, RxJava mạnh hơn cho các luồng dữ liệu phức tạp. Tại IT Sectr, chúng tôi sử dụng Coroutines + Flow cho các dự án mới.

Deadlock và Livelock là gì?

Deadlock — khóa chết lẫn nhau khi hai luồng chờ tài nguyên của nhau. Livelock — các luồng không bị khóa nhưng liên tục chuyển tài nguyên mà không làm việc hữu ích. Cả hai vấn đề được giải quyết bằng thứ tự khóa phù hợp và thời gian chờ.

Tại sao cần DispatchQueue trong iOS?

DispatchQueue là một trừu tượng của Grand Central Dispatch (GCD) để quản lý luồng. Main Queue thực thi tác vụ trên luồng chính, Global Queues — trên luồng nền. Serial Queue đảm bảo thực thi tuần tự, Concurrent Queue — song song. Trong các dự án hiện đại, GCD thường được thay thế bằng Async/Await và Task.

Tổng kết

  • Main Thread — chỉ UI; tất cả các thao tác khác vào Background
  • GCD và OperationQueue — nền tảng đa luồng trên iOS; Async/Await — giải pháp thay thế hiện đại
  • Coroutines và Flow — tiêu chuẩn cho Android; đồng thời có cấu trúc ngăn rò rỉ
  • RxJava, RxSwift, Combine — framework phản ứng cho các luồng dữ liệu phức tạp
  • Race Condition và Deadlock — vấn đề chính; giải quyết bằng khóa và thứ tự truy cập tài nguyên phù hợp
  • Thread Pool được quản lý bởi hệ thống (GCD) hoặc framework (coroutine); không khuyến khích tạo luồng thủ công
  • Việc chọn công cụ phụ thuộc vào nền tảng: Coroutines cho Android, GCD/Combine cho iOS

Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay

IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.

Thảo luận dự án