TaskGroup Swift Part 2

by Quang Huy
248 views

Nếu đã đọc bài viết trước của mình về task group, mọi người sẽ hiểu bản chất task group, cách khởi tạo, cách add 1 task con, và cách nhận result của tất cả các task con đó.

Tuy nhiên, trong bài viết đó mình không đi sâu vào 1 phần quan trọng của task group, đó là việc handle error.

Nay mình rảnh rảnh nên lên gõ 1 bài về chủ đề này 😁

   

Đầu mục bài viết

   

1-Tổng quát

Như chúng ta đã biết, TaskGroup là 1 tập hợp những task con thực thi đồng thời.

Tuy nhiên, sẽ có vấn đề được đặt ra là khi có bất kỳ 1 trong những task con gặp lỗi, task group handle việc đó như nào? Điều gì sẽ xảy ra cho những task con khác đang thực thi?

Ở bài viết này, mình sẽ đưa ra 2 cách cơ bản để handle error trong một task group, đó là:

– Throw error

– Trả về result của tất cả các task con

Ta sẽ đi sâu vào từng cách ở những phần bên dưới.

   

2-Throw error

Cách đầu tiên là throw error thông qua withThrowingTaskGroup.

Nhưng trước tiên, chúng ta cần phải hiểu một task group sẽ hoạt động thế nào khi một task con của nó gặp lỗi:

1. Một task group sẽ chỉ throw ra error đầu tiên mà task con của nó gặp phải, task group sau đó sẽ bỏ qua tất cả những error sau. Tức là khi bất kỳ task con nào trong quá trình thực thi mà gặp phải lỗi, task group sẽ throw lỗi đó, và sẽ không quan tâm đến bất kỳ error nào task con của nó gặp phải sau đó nữa.

2. Khi một task con throw 1 error, tất cả các task con còn lại (kể cả những task đang thực thi) sẽ được đánh dấu.

3. Những task con còn lại mà đã được đánh dấu đó sẽ tiếp tục thực thi cho đến khi chúng ta can thiệp và dừng chúng lại.

4. Khi chúng ta đã can thiệp và dừng việc thực thi của những task con còn lại đó, những task con đó sẽ không trigger việc add vào result tổng của 1 task group nữa (kể cả với những task con đã hoàn thành việc thực thi).

Để hiểu rõ hơn, mình có tạo ra 1 struct DemoChildTask, mà trong đó xử lý việc chia 2 số cho nhau, và sẽ throw ra error khi có bất kỳ phép chia nào cho 0

enum ErrorCase: Error {
    case divideToZero
}

struct DemoChildTask {
    let name: String
    let a: Double
    let b: Double
    let sleepDuration: UInt64
    
    func execute() async throws -> Double {
        // Sleep for x seconds
        await Task.sleep(sleepDuration * 1_000_000_000)
        
        // Throw error when divisor is zero
        guard b != 0 else {
            print("\(name) throw error")
            throw ErrorCase.divideToZero
        }
        
        let result = a/b
        print("\(name) has been completed: \(result)")
        return result
    }
}

Tiếp theo, mình tạo ra 1 task group và add các task con vào trong đó. Các task con đó sẽ thực thi DivideOperation và trả ra 2 giá trị name và result.

Khi tất cả các task con hoàn thành, task group sẽ add các result con đó vào một dictionary result tổng mà trong đó chứa tất cả giá trị name và result của task con.

Bất kể khi nào một trong những task con gặp error, nó sẽ throw và truyền ra cho task group, và task group sẽ throw error đó.

let demoOperation: [DemoChildTask] = [DemoChildTask(name: "operation-0", a: 4, b: 1, sleepDuration: 3),
                                        DemoChildTask(name: "operation-1", a: 5, b: 2, sleepDuration: 1),
                                        DemoChildTask(name: "operation-2", a: 10, b: 5, sleepDuration: 2),
                                        DemoChildTask(name: "operation-3", a: 5, b: 0, sleepDuration: 2),
                                        DemoChildTask(name: "operation-4", a: 4, b: 2, sleepDuration: 5)]

Task {
    do {
        let allResult = try await withThrowingTaskGroup(of: (String, Double).self,
                                                        returning: [String: Double].self,
                                                        body: { taskGroup in
            // Loop through operations array
            for operation in demoOperation {
                
                // Add child task to task group
                taskGroup.addTask {
                    
                    // Execute slow operation
                    let value = try await operation.execute()
                    
                    // Return child task result
                    return (operation.name, value)
                }
            }
            
            // Collect results of all child task in a dictionary
            var childTaskResults = [String: Double]()
            
            for try await result in taskGroup {
                // Set operation name as key and operation result as value
                childTaskResults[result.0] = result.1
            }
            // All child tasks finish running, thus return task group result
            return childTaskResults
        })
        
        print("Task group completed: \(allResult)")
    } catch {
        print("Task group error: \(error)")
    }
}

withThrowingTaskGroup về cơ bản giống với withTaskGroup, tuy nhiên chúng ta cần sử dụng keyword try để có thể throw error. Đồng thời cũng cần dùng try khi call func execute() của task con, điều này cho phép error throw từ execute() có thể truyền ra cho task group. Bên cạnh đó, try cũng phải được dùng khi tổng hợp result từ các task con.

Khi chạy đoạn code trên, ta sẽ nhận được đoạn log như này:

Đoạn output trên cho chúng ta thấy “operation-3” sẽ throw ra lỗi vì đã chia cho 0.

Mặc dù đoạn code trên đã khá hoàn chỉnh cho việc handle error với throw, tuy nhiên để tối ưu hơn, ta sẽ không muốn các task con khác thực thi tiếp khi một task con đã throw ra error. Đây là lúc chúng ta cần phải can thiệp vào việc thực thi của chúng như mình đã note bên trên.

Để làm được việc này, đơn giản có thể sử dụng Task.checkCancellation(). Func này dùng để check những task nào còn đang thực thi.

func execute() async throws -> Double {
        // Sleep for x seconds
        await Task.sleep(sleepDuration * 1_000_000_000)
        
        // Check for cancellation.
        try Task.checkCancellation()
        
        // Throw error when divisor is zero
//        guard b != 0 else {
//            print("\(name) throw error")
//            throw ErrorCase.divideToZero
//        }
//
//        let result = a/b
//        print("\(name) has been completed: \(result )")
//        return result
    }

Về cơ bản, khi func checkCancellation() gặp bất kỳ task con nào đã được đánh dấu, nó sẽ dừng task con đó và throw ra CancellationError . Tuy nhiên, như mình đã đề cập, việc throw này không mang quá nhiều ý nghĩa vì task group sẽ reject toàn bộ những error này.

Khi chạy lại đoạn code trên với checkCancellation(), ta sẽ nhận được log như này:

   

3-Trả về result

Cách thứ 2 này thực ra hoàn toàn ngược lại với cách đầu tiên. Cách này cơ bản task group sẽ ignore toàn bộ task con mà throw error, mà sẽ chỉ trả về tất cả result của task con thực thi thành công.

Cách 2 này code khá giống với cách 1, chỉ khác là bây giờ sẽ sử dụng non-throwing task group thay vì throw task group, và sẽ ignore toàn bộ error với try?:

Task {
    let allResult = await withTaskGroup(of: (String, Double)?.self,
                                        returning: [String: Double].self,
                                        body: { taskGroup in
        // Loop through operations array
        for operation in demoOperation {
            
            // Add child task to task group
            taskGroup.addTask {
                
                // Execute slow operation
                guard let value = try? await operation.execute() else {
                    return nil
                }
                
                // Return child task result
                return (operation.name, value)
            }
        }
        
        // Collect results of all child task in a dictionary
        var childTaskResults = [String: Double]()
        
        for await result in taskGroup.compactMap({ $0 }) {
            // Set operation name as key and operation result as value
            childTaskResults[result.0] = result.1
        }
        // All child tasks finish running, thus return task group result
        return childTaskResults
    })
    
    print("Task group completed: \(allResult)")
}

Để trả về result tổng của tất cả task con, mình sử dụng compactMap để loại bỏ toàn bộ giá trị nil được trả về từ task con.

Khi chạy đoạn code trên, đây sẽ là output:

   

4-Tổng kết

Như vậy, với 2 bài viết của mình, mọi người đã có những cái nhìn và hiểu biết rõ ràng hơn về task group, một trong những update mới và quan trọng của swift 5.5 😁

Happy coding!!

Leave a Comment

* By using this form you agree with the storage and handling of your data by this website.