Async/Await in fetch API requests (Part 2)

iOS Aug 03, 2021

Ở bài viết trước, chúng ta đã tìm hiểu về async/await mới trong Swift 5.5, giúp giải quyết các vấn đề về callback lồng nhau (điển hình là việc gọi API request trong Swift), giúp code trở nên dễ đọc hơn. Hôm nay, chúng ta sẽ tìm hiểu về việc thực hiện 1 số mã bất đồng bộ chạy cùng một lúc xem như thế nào nhé.

Trước kia, khi thực hiện gọi các API cùng một lúc, chúng ta thường quen với việc sử dụng GCD. Lấy một ví dụ gọi nhiều API load ảnh cùng một lúc:

Mình sẽ giải thích qua một chút đoạn code trên đã làm những gì.

Line 1-2:

  • Khởi tạo các dispatchGroup để nhóm nhiều task vụ với nhau, chờ cho chúng hoàn thành thì mới thực hiện tiếp đoạn code bên dưới,
  • Khởi tạo dispatchGlobal để tạo ra các concurrent queues của hệ thống

Line 3-16: Mỗi tác vụ được tạo ra từ closure dispatchGlobal.async {} trong mỗi lần lặp cho phép các task này chạy song song với nhau: method enter() để báo với dispatch group rằng một task đã được bắt đầu; method leave() để báo với dispatch group rằng task vụ đã thực hiện xong.

Line 17-19:  Thông báo cho chúng ta biết khi các task vụ trong group hoàn thành xong, chúng ta thực hiện update UI trong main thread.

Chạy đoạn code trên và ta thu được kết quả:


Đoạn code trên chạy không xảy ra lỗi. Tuy nhiên, vấn đề ở đây nằm ở dòng thứ 9: . Cho dù chúng ta có biết hay không, tuy nhiên ở đây xảy ra hiện tượng race condition, 1 điều thường xuyên xảy ra trong lập trình bất đồng bộ. Điều đó có nghĩa là chúng ta đang tác động vào biến listImageView trên nhiều luồng khác nhau một cách đồng thời. Vấn đề này có thể cực kỳ khó phát hiện; Nó có thể tự hiển thị nếu listImageView tình cờ thêm giá trị vào mảng cùng một lúc, nhưng nó có thể không tự biểu hiện chút nào và chúng ta có thể viết code như trên và không phát hiện ra vấn đề cho đến khi ứng dụng của được đưa ra thực tế.

Một cách để phát hiện ra  là sử dụng Xcode’s Thread Sanitizer giúp cảnh báo chúng ta về vấn đề này. Nhưng rất tiếc, Thread Sanitizer sẽ không làm được gì trừ khi bạn bật nó lên! (Product -> Scheme -> Edit Scheme -> chọn Thread Sanitizer) Hơn nữa, ngay cả khi bạn bật tính năng này, Thread Sanitizer vẫn có thể không báo cáo bất kỳ sự cố nào. Để xảy ra được trường hợp bạn cần có hai lần truy cập vào biến listImageView xảy ra đủ gần nhau kịp thời để Thread Sanitizer quan sát được vấn đề và điều đó có thể không xảy ra . Vì vậy, chúng ta thực sự không có khả năng phát hiện ra vấn đề ngay từ đầu, mặc dù đã thử nghiệm tất cả các cách

Tasks

Trong Swift 5.5, Task là một đơn vị công việc mà tại đó Swift sẽ thực thi code của bạn một cách song song. Mỗi Task  (tác vụ) cung cấp một async context mới, trong đó nó có thể thực thi đồng thời, cùng với các tasks khác. Swift cung cấp cho chúng ta 2 cách để thực hiện việc gọi đồng thời nhiều hàm bất đồng bộ:

  • async let
  • Task groups

Xét đoạn code dưới đây:

fetchCountryInfo() là một hàm thực hiện việc gọi 2 API và trả về một tuple có kiểu (UIImage, String). Hai Api này được gọi một cách tuần tự. Trong trường hợp này, việc request 2 Api xảy ra một cách độc lập nên việc gọi tuần tự cho chúng dường như là không cần thiết. Tại sao chúng ta không gọi đồng thời 2 hàm này thực hiện ? Tuy nhiên, hàm này lại không tạo ra tasks nào cả. Cả 2 hàm gọi Api này đều được chờ kết quả của hàm được thực hiện trước trả về rồi mới tiếp tục thực hiện các hàm bên dưới, đó là lý do vì sao các hàm này không thể chạy đồng thời được. Ok, ta sẽ đi vào cách giải quyết đầu tiên.

Async-let tasks

Đây là loại Tasks đơn giản nhất để thực hiện việc chạy đồng thời các hàm bất đồng bộ. Cú pháp của nó như sau:

(Nguồn: WWDC 2021)

Nhìn ảnh ở trên, chúng ta Khởi tạo giá trị được sử dụng (result) cho mã không đồng bộ trước. Khi đó, hàm bất đồng bộ này sẽ được thực hiện ngay; tuy nhiên lúc này result chưa có giá trị. Để lấy được giá trị biến result này, bên dưới chúng ta phải gọi (try) await result thì mới có giá trị trả về của hàm bất đồng bộ.

Bây giờ, ví dụ tải xuống hình ảnh tên thủ đô của một quốc gia từ hai URL tương ứng. Trong các hàm bất đồng bộ chạy tuần tự, try await phải được sử dụng ở phía bên phải của let. Viết async let để thực hiện đồng thời hai hàm bất đồng bộ . Việc thực hiện hai hàm bất đồng bộ này nằm ở trong 2 child task. Cuối cùng  try await (hoặc await) được viết để lấy giá trị từ 2 hàm bất đồng bộ ở trên.


Group Task

Một cách khác để thực hiện việc gọi các API đồng thời trong Swift 5.5 là sử dụng Group Task. Khi khám khá async-let ở trên, ta nhận thấy một hạn chế cụ thể: Chúng ta không thể chạy nhiều Tasks cùng một lúc, bởi vì khi  cố gắng làm như vậy, chẳng hạn như trong một vòng lặp, chúng ta cần phải chờ đợi kết quả trả về của vòng lặp đó. Điều này vô tình khiến chúng ta phải sử dụng keyword await. Khi đó, tính đồng thời không được thể hiện ở giữa các vòng lặp đó nữa. Ví dụ: không cho phép tải xuống đồng thời nhiều thông tin (ảnh, tên thủ đô) của nhiều quốc gia nữa vì chúng ta bị hạn chế chờ đợi mỗi lần tải xuống.

Group Task cung cấp tính linh hoạt hơn so với async-let mà không làm mất đi tính đơn giản của đồng thời có cấu trúc. Task group cung cấp một số lượng dynamic các hoạt động concurrency. Từ đó, ta có thể khởi tạo nhiều tasks con, thực thi các task này trong cùng một group, tại cùng một thời điểm. Có 2 cách để tạo một group task:

  • Sử dụng withThrowingTaskGroup
  • Sử dụng withTaskGroup

Hai cách ở trên về cơ bản hoạt động giống nhau, chỉ khác nhau là hàm đầu tiên tạo ra 1 nhóm các tasks có thể throw error.

Đây là cách sử dụng :

Giải thích: Hàm withThrowingTaskGroup() nhận đầu vào một params body là 1 closure, truyền vào đó là object group. group này dùng để tạo ra các task con mới thông qua closure group.async{}. Khi 1 task con được thêm vào group, task này sẽ được thực thi ngay lập tức theo  bất kỳ  thứ tự nào.

Tuy nhiên, sự cố về race condition xảy ra ở đây:

Trình biên dịch của Xcode báo lỗi: điều này là do mỗi task con cố gắng đưa giá trị trả về vào biến result, gây ra hiện tượng xung đột như ở đầu bài viết mình có giới thiệu. Trước đây, các nhà phát triển phải tự kiểm tra lỗi này khi làm việc với GCD do Xcode compiler không báo lỗi, nhưng trong Swift 5.5, vấn đề này đã được giải quyết. Bất cứ khi nào bạn tạo một task mới băng group.async{}, closure này được đặt tên là @Sendable với mục đích ngăn không cho các biến mutable được sử dụng trong này; điều này là do các biến này có thể được thêm, sửa trong quá trình thực thi các tasks. Nói cách khác, các biến bên trong @Sendable closure này phải an toàn để có thể chia sẻ dữ liệu giữa các tasks với nhau; ví dụ như Actors mới được Swift giới thiệu gần đây.

Để giải quyết trường hợp trên, trước ta sẽ return các dữ liệu từ các task con dưới dạng một tuple:

Sau đó chúng ta có thể duyệt các result được trả về từ các task con sinh ra ở trên, sử dụng cơ chế mới for await loop. for-await có thể lần lượt đưa ra kết quả của từng Task con theo thứ tự hoàn thành (thằng nào xong trước thì đc trả ra kết quả trước) bằng cách sử dụng các nhiệm vụ con.  Vì vòng lặp for-await là tuần tự, tác vụ mẹ có thể xử lý từng giá trị  một. Do đó, không thể xảy ra hiện tượng race condition như ở trên nữa.

Tài liệu tham khảo










Tags

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.