i was recently thinking about some code that adapts a completion-block-based API to Swift concurrency with support for cancelation of the async work. i found things a bit more complex at times than i initially expected, so wanted to walk through my thought process and the resulting code more explicitly. the general structure began with something like this:
// v1
func doAsyncWorkWithCancelation() async -> Int? {
// async work cancelation handler
var cancelHandler: DispatchWorkItem?
// wrap async work in a cancelation handler
return await withTaskCancellationHandler(
operation: {
return await withCheckedContinuation { continuation in
// configure work
let workItem = DispatchWorkItem {
continuation.resume(returning: 42)
}
// configure cancelation handler
cancelHandler = workItem
// enqueue work
DispatchQueue.global().async(execute: workItem)
}
}, onCancel: {
cancelHandler?.cancel() // 🛑 Reference to captured var 'cancelHandler' in concurrently-executing code
}
)
}
this implementation would not compile due to the mutable reference to the cancelation handler being identified as potentially being accessed concurrently (aside: it's great that this can be surfaced statically!). this led me to conclude that we must use something like an immutable, atomic reference to the cancelation handler, which resulted in a version like this:
// v2
func doAsyncWorkWithCancelation() async -> Int? {
// async work cancelation handler
let atomicCancelHandler = Atomic<DispatchWorkItem>()
// wrap async work in a cancelation handler
return await withTaskCancellationHandler(
operation: {
return await withCheckedContinuation { continuation in
// configure work
let workItem = DispatchWorkItem {
continuation.resume(returning: 42)
}
// configure cancelation handler
atomicCancelHandler.value = workItem
// enqueue work
DispatchQueue.global().async(execute: workItem)
}
}, onCancel: {
atomicCancelHandler.value?.cancel() // now compiles successfully
}
)
}
this addressed the compiler error, and presumably fixed the data race by adding a lock around the cancelation handler. however, upon thinking through the potential orderings of onCancel
and operation
, it became clear that there was another potential issue – since the operation
and onCancel
closures run concurrently with respect to each other, there is a race condition. it would be possible for the cancel handler to be invoked after the work item was created but before it was stored in atomicCancelHandler.value
, which would prevent the expected cancelation of the work item from ocurring. this led to the next implementation which looked like:
// v3
func doAsyncWorkWithCancelation() async -> Int? {
// async work cancelation handler
let atomicCancelHandler = Atomic<DispatchWorkItem>()
// wrap async work in a cancelation handler
return await withTaskCancellationHandler(
operation: {
return await withCheckedContinuation { continuation in
// check if we were canceled before starting
guard !Task.isCancelled else {
continuation.resume(returning: nil)
return
}
// configure work
let workItem = DispatchWorkItem {
continuation.resume(returning: 42)
}
// configure cancelation handler
atomicCancelHandler.value = workItem
// check for cancelation again after
// configuring the cancelation handler
// but before submitting the work
guard !Task.isCancelled else {
continuation.resume(returning: nil)
return
}
// enqueue work
DispatchQueue.global().async(execute: workItem)
}
}, onCancel: {
atomicCancelHandler.value?.cancel()
}
)
}
i thought this was basically the final version, but then realized that a similar race exists between enqueuing the work item, canceling the task, and executing the work item. this resulted in the following, final implementation:
// v4
func doAsyncWorkWithCancelation() async -> Int? {
// async work cancelation handler
let atomicCancelHandler = Atomic<DispatchWorkItem>()
// wrap async work in a cancelation handler
return await withTaskCancellationHandler(
operation: {
return await withCheckedContinuation { continuation in
// check if we were canceled before starting
guard !Task.isCancelled else {
continuation.resume(returning: nil)
return
}
// configure work item
var innerCancelHandler: DispatchWorkItem?
let workItem = DispatchWorkItem { [unowned innerCancelHandler] in
// check for cancelation
guard innerCancelHandler?.isCancelled == false else {
continuation.resume(returning: nil)
return
}
continuation.resume(returning: 42)
}
// configure 'inner' cancelation handler.
// note: the specific approach here could
// differ. this one was used for simplicity
// and to avoid possibly creating a retain cycle
// with the work item
innerCancelHandler = workItem
// configure cancelation handler
atomicCancelHandler.value = workItem
// check if we were canceled again after
// configuring the cancelation handler
guard !Task.isCancelled else {
continuation.resume(returning: nil)
return
}
// enqueue work
DispatchQueue.global().async(execute: workItem)
}
}, onCancel: {
atomicCancelHandler.value?.cancel()
}
)
}
curious if this final implementation seems generally 'correct' for this scenario, or if there's anything else i may be overlooking.