如何处理Swift任务组中的错误
如果您已经阅读了我之前的文章,您现在应该知道如何创建任务组,将子任务添加到任务组,并从所有子任务中收集结果。然而,有一个与任务组相关的重要主题我没有涉及,那就是“错误处理”。
众所周知,一个任务组由多个同时运行的子任务组成。当其中一个子任务遇到错误时,任务组应该如何处理错误?那些仍在运行的子任务会怎么样?
在本文中,我们将研究可用于处理任务组中错误的2种最常见方法:
- 使用能抛出错误的任务组
- 返回所有已完成的子任务的结果
像往常一样,我将使用一些易于理解的示例代码来帮助您了解这两种方法的工作原理,所以让我们开始吧!
注意:
本文要求您对Swift任务组有基本的了解。如果您不熟悉任务组的基础知识,我强烈建议您首先阅读我之前的文章通过示例理解Swift任务组
定义一个抛出错误的子任务
为了演示任务组中的错误处理,我们必须首先有一个可以抛出错误的子任务。让我们修改我们之前创建的SlowDivideOperation
,以便在除数为零时抛出错误:
enum DivideOperationError: Error {
case divideByZero
}
struct SlowDivideOperation {
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 DivideOperationError.divideByZero
}
let value = a / b
print(”✅ \(name) completed: \(value)“)
return value
}
}
如您所见,execute()
函数现在标有 throws
关键字,表示它现在是一个 throwing 函数。除此之外,我还添加了2个打印语句,以帮助我们可视化执行操作时的真实情况。
有了这一点,我们现在可以开始介绍第一种方法了。
方法1:使用抛出任务组抛出错误
出于演示目的,我们将创建一个任务组,生成多个子任务,执行SlowDivideOperation
并返回其名称和结果。当所有SlowDivideOperation
完成后,任务组将收集其所有子任务结果,并返回一个由所有SlowDivideOperation
名称和结果组成的字典。
如果任何子任务遇到错误,它将抛出并将错误传播到任务组,任务组将抛出错误。以下是示例代码:
// 1
let operations = [
SlowDivideOperation(name: ”operation-0“, a: 5, b: 1, sleepDuration: 5),
SlowDivideOperation(name: ”operation-1“, a: 14, b: 7, sleepDuration: 1),
SlowDivideOperation(name: ”operation-2“, a: 4, b: 0, sleepDuration: 2),
SlowDivideOperation(name: ”operation-3“, a: 8, b: 2, sleepDuration: 3),
]
Task {
do {
// 2
let allResults = try await withThrowingTaskGroup(of: (String, Double).self,
returning: [String: Double].self,
body: { taskGroup in
// Loop through operations array
for operation in operations {
// Add child task to task group
taskGroup.addTask {
// 3
// 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]()
// 4
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 with result: \(allResults)“)
} catch {
print(”👎🏻 Task group throws error: \(error)“)
}
}
让我们来看一下上述代码的一些重要细节:
-
operations
数组定义了任务组要生成的子任务。请注意,当我们执行示例代码时,“operation-2”将抛出错误。 -
我们将使用
withThrowingTaskGroup(of:returning:body:)
函数来创建一个抛出任务组。它的工作原理与withTaskGroup(of:returning:body:)
函数类似,但我们需要使用try
关键字调用它,因为它可能会抛出错误。 -
我们必须使用
try
关键字调用SlowDivideOperation
的execute()
函数。这允许execute()
函数抛出的错误传播到任务组。 -
由于我们现在使用抛出任务组,因此在收集每个子任务的结果时,我们必须使用
try
关键字。
现在,如果我们尝试执行示例代码,我们将获得以下输出:
✅ operation-1 completed: 2.0
⛔️ operation-2 throw error
✅ operation-3 completed: 4.0
✅ operation-0 completed: 5.0
👎🏻 Task group throws error: divideByZero
上述输出恰恰显示了我们所期望的——“operations-2”抛出一个错误,该错误正在传播到任务组,从而导致任务组抛出divideByZero
错误。
尽管我们的示例代码正在做我们想做的事情,但它没有被优化。正如您从输出中看到的,即使“operation-2”抛出错误,“operation-3”和“operation-0”仍将继续执行,直到完成。我们能做些什么来避免这种情况吗?
理解抛出任务组的行为
为了优化我们的示例代码,我们必须首先了解抛出任务组在其子任务抛出错误时的行为。以下是一些您应该注意的重要行为:
-
任务组只会抛出其子任务抛出的第一个错误。随后其他子任务的错误都将被忽略。
-
当子任务抛出错误时,所有剩余的子任务(仍在运行的子任务)将被标记为已取消。
-
标记为已取消的子任务将继续执行,直到我们明确停止它。
-
标记为已取消的子任务不会触发从任务组收集结果的for-loop循环,即使子任务完成了执行。
上述列表中的第三个行为是导致“operation-3”和“operation-0”继续执行的原因,即使“operation-2”抛出了错误。要显式停止已取消的任务,我们可以使用Task.checkCancellation()
方法。此方法将检查当前正在执行代码的任务,如果任务被取消,它将抛出一个CancellationError
错误。
考虑到这一点,让我们把重点转回SlowDivideOperation.execute()
方法。就我们而言,检查取消的最佳地点是Task.sleep()
方法之后。
func execute() async throws -> Double {
// Sleep for x seconds
await Task.sleep(sleepDuration * 1_000_000_000)
// Check for cancellation. If task is canceled, throw `CancellationError`.
try Task.checkCancellation()
// Throw error when divisor is zero
// ...
// ...
}
这就是我们需要做的全部。现在,如果我们再次执行示例代码,我们将获得以下输出:
✅ operation-1 completed: 2.0
⛔️ operation-2 throw error
👎🏻 Task group throws error: divideByZero
通过这一点,我们成功地提高了示例代码的效率。一旦子任务抛出错误,我们任务组中所有剩余的子任务现在将停止执行。
方法2:返回所有已完成的子任务的结果
现在,如果我们想要一个与方法1完全相反的结果呢?我们希望我们的任务组忽略所有有错误的子任务,并返回所有已完成的子任务的结果。
使用的概念与方法1非常相似,但这次我们将创建一个正常(非抛出)任务组,并使用try?
忽略所有错误:
Task {
// 1
let allResults = await withTaskGroup(of: (String, Double)?.self,
returning: [String: Double].self,
body: { taskGroup in
// Loop through operations array
for operation in operations {
// Add child task to task group
taskGroup.addTask {
// Execute slow operation
// 2
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]()
// 3
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 with result: \(allResults)“)
}
上面的示例代码与方法1的示例代码几乎相同,但您应该注意一些显著的差异。让我们详细解释:
-
我们正在使用
withTaskGroup(of:returning:body:)
函数创建任务组,因为我们的任务组将不再抛出错误。除此之外,我们必须将子任务结果类型更改为可选,以便我们的子任务在发生错误时可以返回nil
。 -
当调用
execute()
,使用可选的try?
,如果execute()
函数抛出错误则返回nil
。 -
由于我们的子任务不再抛出错误,我们可以从for-loop中删除
try
关键字。此外,我们必须将compactMap
应用于任务组,以过滤掉子任务返回的所有nil
结果。
以下是我们从上述代码中获得的输出:
✅ operation-1 completed: 2.0
⛔️ operation-2 throw error
✅ operation-3 completed: 4.0
✅ operation-0 completed: 5.0
👍🏻 Task group completed with result: [”operation-0“: 5.0, ”operation-1“: 2.0, ”operation-3“: 4.0]
很简单,不是吗?
小结
我在本文中向您展示的2种方法只是处理任务组中错误的两种最基本方法。您绝对可以扩展这些方法中使用的概念,以处理适合您需求的更复杂的情况。
像往常一样,您可以在Github上的这篇文章中获取示例代码。
我希望这篇文章能让您很好地理解在使用任务组时如何处理错误。如果您有任何问题或意见,请随时在推特上与我联系。
感谢您的阅读。👨🏻💻