Dev With Ethan

Blog Lập trình cùng Ethan

[Swift] Đa nhiệm với Grand Central Dispatch: Xử lý các tác vụ & dữ liệu theo luồng song song

Trong bộ thư viện RubifiedSwift, có một extension không liên quan đến Ruby nhưng mình vẫn đưa vào, đó là NSObject+GCD.swift. Đây là các hàm có liên quan đến một chức năng quan trọng và cũng rất thú vị của Objective-C nói chung và iOS nói riêng: Grand Central Dispatch. Bài hôm nay mình sẽ nói rõ hơn về GCD và những ứng dụng của nó trong thực tiễn.

GCD là gì?

GCD là viết tắt của Grand Central Dispatch, nghĩa là gửi thông điệp tập trung. Đây là cơ chế xuất hiện từ khi Objective-C được sử dụng để làm ra các hệ điều hành cho Apple (như MacOS & iOS). Nói 1 cách dễ hiểu thì GCD là cơ chế mà Objective-C quản lý một cách tập trung tất cả những thông điệp được truyền qua lại giữa các thread (luồng) của thiết bị. Với GCD, chúng ta có thể thực hiện 1 số thao tác rất hữu ích một cách đơn giản và tiện lợi như sau:

  • Gửi một tác vụ đến luồng chạy ngầm (background thread) & song song với luồng chính (main thread).
  • Dùng chung dữ liệu giữa các luồng (mutex).
  • Lập lịch để chạy 1 thao tác nào đó với độ trễ (chạy sau x giây thay vì chạy ngay lúc gọi).
  • Xếp hàng các thao tác để chạy theo thứ tự thay vì mạnh ai nấy chạy.
  • Thiết lập độ ưu tiên giữa các thao tác tại các luồng khác nhau.

So với các ngôn ngữ bậc thấp (C/C++), GCD dễ dùng hơn rất nhiều. So với các ngôn ngữ đơn luồng (Javascript, Ruby,…), GCD là đa luồng thực sự (mỗi luồng được tạo ra đều là real-thread thay vì green-thread là các luồng giả lập), vậy nên nó tối ưu CPU hơn (nhất là với các CPU đa lõi đời mới). Và quan trọng nhất, việc nắm vững và thành thạo GCD là 1 bước để tối ưu các ứng dụng đòi hỏi xử lý phức tạp mà không làm giảm UX của sản phẩm.

Các ứng dụng cụ thể của GCD

1. Thực hiện các thao tác chạy ngầm và song song với luồng giao diện

Trong iOS (và đa số các hệ điều hành khác), giao diện được cài đặt để chạy trên luồng chính (gọi là main-thread). Main-thread này sẽ nhận toàn bộ tương tác của người dùng và thể hiện ra bằng giao diện, từ đó chúng ta cung cấp cho người dùng các tính năng phù hợp. Đa số những thao tác đơn giản sẽ được thực hiện trên main-thread, tuy nhiên có 2 vấn đề:

  1. Tất cả các thao tác trên cùng 1 thread sẽ phải xếp hàng để xử lý. Tức là nếu ta có 4 thao tác cần xử lý là A, B, C, D và được gọi 1 cách lần lượt, thì hệ điều hành sẽ hiểu là chạy xong A thì sẽ chạy đến B, chạy xong B mới chạy đến C,…
  2. Xuất phát từ vấn đề 1., bình thường thì A, B, C, D sẽ khá đơn giản (hiển thị 1 dòng chữ, bật lên 1 thông báo,…), nên việc chạy tuần tự A, B, C, D không gây ra bất kỳ sự chậm trễ khó chịu nào cho người dùng. Nhưng giả sử 1 trong các tác vụ này (là B chẳng hạn) rất nặng hoặc mất rất nhiều thời gian để xử lý (lấy dữ liệu từ server, tính tích của 2 số nguyên tố rất lớn,…) thì đột nhiên người dùng sẽ thấy ứng dụng bị đơ mất 1 lúc, nặng hơn là đơ toàn bộ (giống trường hợp The application is not responding trên Windows vậy).

Lúc này, nếu dùng GCD, chúng ta có thể tạo ra 2 luồng như sau:

  • Main-thread: chạy các tác vụ nhẹ A -> C -> D.
  • Background-thread: chạy tác vụ nặng B.

2 thread này chạy song song độc lập với nhau, tức là trong khi đang chạy A, C, D thì đồng thời B cũng được gọi và chạy, nhưng lại không ảnh hưởng gì đến giao diện, tức là người dùng không cảm thấy bị đơ.

Ta có ví dụ sau:

1
2
3
4
func A() { print("Process A") }
func B() { var i = 1.0; for _ in 0..<10_000 { i *= 2 }; print("Process B") }
func C() { print("Process C") }
func D() { print("Process D") }
Swift code snippet 1
Khai báo các tác vụ

Trong đó A(), C(), D() là các tác vụ rất nhẹ nhàng (in ra 1 dòng chữ “Process …”), còn B() lại là tác vụ rất nặng nề, cần phải làm 10.000 phép nhân trước khi in ra 1 dòng “Process B”.

Bình thường nếu gọi như thế này:

1
2
3
4
A()
B()
C()
D()
Swift code snippet 2
Gọi các tác vụ tuần tự

Ta sẽ phải chờ B() chạy hết 10.000 lần, in ra “Process B” rồi mới nhận được “Process C” và “Process D”. Giao diện người dùng cũng vậy, khi chạy đến B(), do hàm này xử lý quá nặng, nên người dùng không ấn vào được bất kỳ thành phần giao diện nào. Sử dụng GCD, ta làm như sau:

1
2
3
4
5
6
7
let priority = DISPATCH_QUEUE_PRIORITY_DEFAULT
let thread = dispatch_get_global_queue(priority, 0)

A()
dispatch_async(thread) { B() }
C()
D()
Swift code snippet 3
Đưa B() về background-thread

Trong đoạn code này ta có:

  • priority là độ ưu tiên. Ở đây do A(), B(), C() & D() đồng đẳng về chức năng, nên ta chọn độ ưu tiên DEFAULT.
  • thread: GCD sử dụng khái niệm queue để tượng trưng cho các thread. Main-thread sẽ gọi là main_queue còn các thread khác sẽ gọi là global_queue. Ở đây chúng ta khai báo thread là 1 global_queue có độ ưu tiên DEFAULT. Tham số 0 ở cuối gọi là flags, theo như trong tài liệu của Apple thì nó sẽ được dùng ở các tính năng trong tương lai (chưa biết bao giờ, nhưng tham số flags này xuất hiện từ lâu lắm rồi).
  • Cuối cùng, để gọi B() ở background-thread, ta dùng hàm dispatch_async(), truyền vào tham số thread và kèm theo 1 block để định nghĩa hành động mà chúng ta muốn thực thi. Async là viết tắt của asynchronous, tức là không đồng bộ, ngược với sync hay synchronous (có nghĩa là đồng bộ), tức là ta muốn B() được chạy độc lập và không liên quan gì đến A(), C()D().

Kết quả hiện ra tại màn hình Debug sẽ như sau:

1
2
3
4
A
C
D
B
Text snippet 1
Kết quả chạy tại background-thread

Do B() chạy nặng và lâu, nên cho dù gọi trước nhưng vẫn in ra sau C()D(). Đây chính là chức năng cơ bản nhất của GCD: gửi một tác vụ đến background-thread.

2. Dùng chung dữ liệu giữa các luồng

GCD tiện lợi là vậy, tuy nhiên có 1 vấn đề luôn làm đau đầu các lập trình viên khi lập trình đa luồng, đó là xử lý các nguồn tài nguyên dùng chung (mutual resource). Nếu bạn nào đã từng học môn Nguyên lý hệ điều hành trong trường đại học thì sẽ hiểu vấn đề này nghiêm trọng như thế nào. Đại ý là: khi ta chỉ xử lý các thao tác trên 1 thread duy nhất, thì các thao tác được xếp hàng, từ đó các nguồn tài nguyên dùng chung sẽ được sử dụng 1 cách an toàn (không có xung đột). Tuy nhiên nếu thực hiện các thao tác song song đa luồng, tiềm ẩn nguy cơ các nguồn tài nguyên này bị xâu xé một cách không trật tự, từ đó dẫn tới sai sót dữ liệu. Việc này giống như hình ảnh chúng ta xếp hàng lấy đồ ăn miễn phí vậy:

  • Nếu chỉ có 1 hàng đứng chờ, chắc chắn thức ăn sẽ được phân phát cho từng người 1 cách trật tự
  • Nhưng nếu có 2 - 3 hàng cùng xếp, thì cùng 1 lúc có thể có 2 - 3 người lấy thức ăn. Giả sử chúng ta chỉ có 1 con cua hay 1 con hàu thì làm sao?

Giả sử ta có đoạn code như sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var mutualData: Int = 0

func writeSync(inc: Int) { mutualData += inc }

let loop = 0..<1_000

func A() { writeSync(1); print("A - mutualData = \(mutualData)") }
func B() { var i = 1.0; for _ in loop { i *= 2 }; writeSync(2); print("B - mutualData = \(mutualData)") }
func C() { writeSync(3); print("C - mutualData = \(mutualData)") }
func D() { writeSync(4); print("D - mutualData = \(mutualData)") }

let priority = DISPATCH_QUEUE_PRIORITY_DEFAULT
let backgroundThread = dispatch_get_global_queue(priority, 0)
A()
dispatch_async(backgroundThread) { B() }
C()
D()
Swift code snippet 4
Dùng chung dữ liệu giữa các luồng

Ở đây, ta có 1 biến mutualData kiểu Int, khởi tạo bằng 0, đóng vai trò là tài nguyên dùng chung. Ta vẫn có 4 tác vụ A(), B(), C(), D() như cũ, nhưng tại mỗi tác vụ, ta tăng biến mutualData lên 1, 2, 3, 4 và in ra tương ứng. Khi chạy, chúng ta có thể gặp 2 trường hợp như sau:

1 là in ra 4 dòng:

1
2
3
4
A - mutualData = 1
C - mutualData = 4
D - mutualData = 8
B - mutualData = 10
Text snippet 2
Kết quả dùng chung dữ liệu 1

2 là in ra:

1
2
3
4
A - mutualData = 1
C - mutualData = 4
D - mutualData = 10
B - mutualData = 10
Text snippet 3
Kết quả dùng chung dữ liệu 2

Trường hợp 2 đã gặp lỗi: B()D() được gọi cùng lúc và in ra cùng 1 giá trị là 10.

Những trường hợp như thế này Khoa học máy tính gọi là deadlock, tức là tắc lại, không thể xử lý được nữa. Đứng ở góc độ Nguyên lý hệ điều hành thì deadlock có làm cho hệ thống dừng hoạt động hoàn toàn, vì giả sử những tài nguyên quan trọng như RAM hay HDD bị xâu xé, chắc chắn sẽ xảy ra mất mát dữ liệu. Còn đối với ứng dụng của chúng ta, nếu xảy ra deadlock thì sẽ rất rắc rối vì dữ liệu người dùng bị sai toét hết.

Giải pháp ở đây là chúng ta sử dụng 1 kỹ thuật gọi là mutex. Nôm na thì mutex là 1 hàng đợi, nhưng chỉ có trách nhiệm xử lý 1 công đoạn rất đơn giản là phân phát tài nguyên. Quay lại ví dụ về việc lấy thức ăn:

  • Để nhanh chóng, chúng ta vẫn cho mọi người xếp thành 3 hàng
  • Tuy nhiên đến lúc lấy thức ăn, thay vì cho mọi người lấy 1 cách bừa bãi, ta có 1 nhân viên phân phát: trong 2 - 3 người đồng thời lấy thức ăn thì ai đến trước ta phát trước, ai đến sau phát sau. Cách này có nhược điểm là không song song một cách tuyệt đối, nhưng thời gian xếp hàng của 2 - 3 người so với tốc độ xếp hàng của 1000 người vẫn rất nhỏ, và quan trọng hơn là ta bảo toàn được sự nguyên vẹn và an toàn của tài nguyên: thức ăn chia sẻ.

Để dùng mutex, ta sửa lại như sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var mutualData: Int = 0
    
let lockedThread = dispatch_queue_create("dev.ethanify.me.locked_queue", nil)
func writeSync(inc: Int) { dispatch_sync(lockedThread) { mutualData += inc } }

let loop = 0..<10_000

func A() { writeSync(1); print("A - mutualData = \(mutualData)") }
func B() { var i = 1.0; for _ in loop { i *= 2 }; writeSync(2); print("B - mutualData = \(mutualData)") }
func C() { writeSync(3); print("C - mutualData = \(mutualData)") }
func D() { writeSync(4); print("D - mutualData = \(mutualData)") }

let priority = DISPATCH_QUEUE_PRIORITY_DEFAULT
let backgroundThread = dispatch_get_global_queue(priority, 0)
A()
dispatch_async(backgroundThread) { B() }
C()
D()
Swift code snippet 5
Dùng chung dữ liệu một cách an toàn

Vẫn là những tác vụ như thế, nhưng ta tạo ra thêm 1 queue tên là lockedThread, nhằm xếp hàng các tác vụ làm việc với mutualData. Kết quả là chúng ta luôn nhận được các giá trị đúng (vì mutualData đã được bảo vệ và đảm bảo an toàn giữa các thread). lockedThread ở đây chính là mutex.