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.