If you have attempted to adopt Swift Concurrency in your codebase, you have certainly needed to address dozens — likely, hundreds — of warnings and errors. Sometimes the issues can be resolved by addressing them directly. That is, your code was incorrect and you simply have to fix it to make it correct. In other scenarios, the resolution is not so straightforward. In particular, it is difficult to satisfy the compiler when working with APIs that you do not own that have not been updated for concurrency. Or, you may have found yourself in a situation where you know your code is correct, but the compiler is unable to verify its correctness — either because of a few remaining bugs in Swift Concurrency, or because you are using @preconcurrency
APIs.
One of the warnings you have probably seen multiple times is “Capture of ‘variable’ with non-sendable type in a @Sendable
closure.” I was confronted with this warning in a project recently and I want to share the hack for how I worked around it. The issue was the result of a combination of factors I mentioned above. I was interacting with @preconcurrency
APIs and I knew my code was concurrency-safe, but I was unable to accurately express that to the compiler.
Background
First, let’s discuss the context in which I was dealing with this concurrency warning. I have been working on a project that uses UICollectionViewDiffableDataSource
and I am wrapping the UIKit APIs with something more user-friendly. For the purposes of this post, I have omitted much of the complexity to provide a clear, simple example.
I have a custom diffable data source that performs the diffing on a background thread:
@MainActor
final class DiffableDataSource: UICollectionViewDiffableDataSource<String, String> {
typealias Snapshot = NSDiffableDataSourceSnapshot<String, String>
typealias SnapshotCompletion = @MainActor () -> Void
let diffingQueue = DispatchQueue(label: "diffingQueue")
func applyDiff(snapshot: Snapshot, animated: Bool = true, completion: SnapshotCompletion? = nil) {
self.diffingQueue.async {
self.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
}
Per the documentation, the completion
closure is always called on the main queue and you can call apply(_:animatingDifferences:completion:)
from a background queue:
[…]
completion
A closure to execute when the animations are complete. This closure has no return value and takes no parameters. The system calls this closure from the main queue.[…]
You can safely call this method from a background queue, but you must do so consistently in your app. Always call this method exclusively from the main queue or from a background queue.
Also note that UICollectionViewDiffableDataSource
is annotated with @MainActor @preconcurrency
.
With the strictest concurrency checking enabled, the code above produces 2 warnings:
func applyDiff(snapshot: Snapshot, animated: Bool = true, completion: SnapshotCompletion? = nil) {
self.diffingQueue.async {
self.apply(snapshot, animatingDifferences: animated, completion: completion)
// ^
// Capture of 'completion' with non-sendable type 'DiffableDataSource.SnapshotCompletion?' (aka 'Optional<@MainActor () -> ()>') in a `@Sendable` closure
// Converting function value of type '@MainActor () -> Void' to '() -> Void' loses global actor 'MainActor'; this is an error in Swift 6
}
}
One solution would be to make the completion closure @Sendable
:
func applyDiff(snapshot: Snapshot, animated: Bool = true, completion: @escaping @Sendable () -> Void) {
self.diffingQueue.async {
self.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
(Note that we cannot use the defined typealias SnapshotCompletion
in this case, and it must not be optional.)
However, this will not work. This API updates the collection view to reflect the state of the data in the snapshot and, as mentioned, completion
is called on the main thread. Callers of applyDiff(snapshot:)
do additional UI updates and animations, or otherwise deal with @MainActor
members and types in this completion closure. Those members and types cannot be marked as @Sendable
. For example, the owner of the DiffableDataSource
instance could be a view controller. Furthermore, I do not want to impose a @Sendable
restriction upon callers.
Thus, making the closure @Sendable
produces the following kinds of errors at the call site:
self.dataSource.applyDiff(snapshot: snapshot) {
// Call to main actor-isolated instance method 'someMethod()' in a synchronous nonisolated context
// Main actor-isolated property 'someProperty' can not be referenced from a Sendable closure; this is an error in Swift 6
}
Callers could wrap the offending lines in Task { @MainActor in }
or MainActor.assumeIsolated { }
to silence these issues. But, that’s a burden for callers. Not to mention, the wrapper API does not accurately communicate what is happening here. We do not want a @Sendable () -> Void
closure. We want a @MainActor () -> Void
closure.
So, we have situation where the Swift compiler is telling us that the closure being captured needs to be @Sendable
but we cannot make it @Sendable
. It is also telling us that the closure loses its @MainActor
but we know that the closure will always be called from the main queue. Because of these two problems, we need to find a way to work around the warnings and coerce the compiler into doing what we want.
Solution (It’s a hack)
We can wrap the completion closure in another type that is @unchecked Sendable
.
struct UncheckedCompletion: @unchecked Sendable {
typealias Block = () -> Void
let block: Block?
init(_ block: Block?) {
if let block {
self.block = {
dispatchPrecondition(condition: .onQueue(.main))
block()
}
} else {
self.block = nil
}
}
}
This will silence the warning about “capturing a non-sendable type in a @Sendable
closure.” Again, UIKit guarantees that this completion closure will always be called on the main thread, and we can use a dispatchPrecondition()
to verify this is happening.
We can update our API to use this new UncheckedCompletion
wrapper.
func applyDiff(snapshot: Snapshot, animated: Bool = true, completion: UncheckedCompletion) {
self.diffingQueue.async {
self.apply(snapshot, animatingDifferences: animated, completion: completion.block)
}
}
However, exposing UncheckedCompletion
to callers is also not a great API. We should hide this detail. We can wrap this applyDiff()
method with another that uses the original SnapshotCompletion
typealias.
func applyDiff(_ snapshot: Snapshot, animated: Bool = true, completion: SnapshotCompletion? = nil) {
self.applyDiff(snapshot: snapshot, animated: animated, completion: UncheckedCompletion(completion))
// ^ wrapped in UncheckedCompletion
}
private func applyDiff(snapshot: Snapshot, animated: Bool, completion: UncheckedCompletion) {
self.diffingQueue.async {
self.apply(snapshot, animatingDifferences: animated, completion: completion.block)
// ^ access underlying closure
}
}
And now, the public API looks exactly the same as before to callers, but they can safely use @MainActor
members and types in the completion closure without any warnings or errors.
self.dataSource.applyDiff(snapshot: snapshot) {
// do anything here safely with @MainActor with no warnings or errors
}
Is this good?
Is this a good idea? I am actually not sure! But, it seems like the best thing to do in this scenario. If you are facing a similar situation — namely, you know a captured closure is always called on the main thread and you cannot make it @Sendable
— this might be a good solution for you too! However, this is probably a bad idea to attempt to generalize. Use wisely!
Update 05 June 2024
As anticipated, Matt Massicotte has come to the rescue, offering a simpler solution here. While my clever hack works, we can instead make the closure both @Sendable
and @MainActor
. After that, we can simply wrap calling the completion closure in MainActor.assumeIsolated { }
. Here are the changes needed:
// The addition of @Sendable is bizarre, but Swift 5.10 needs it.
// Swift 6 (via SE-0434) will make it unnecessary.
typealias SnapshotCompletion = @Sendable @MainActor () -> Void
func applyDiff(snapshot: Snapshot, animated: Bool = true, completion: SnapshotCompletion? = nil) {
self.diffingQueue.async {
// UIKit guarantees `completion` is called on the main queue.
self.apply(snapshot, animatingDifferences: animated, completion: {
// when you know its on the main actor, perhaps from documentation, but it isn't
// encoded in the API, you can use dynamic isolation to make it work
MainActor.assumeIsolated {
completion?()
}
})
}
}
This is great. It is much less code. I’m not sure why I didn’t try using @Sendable @MainActor
for the closure. It’s probably because I assumed that @MainActor
implied @Sendable
— which apparently it should and it will after SE-0434 in Swift 6.
Anyway, the general idea of this hack might still be useful in other contexts or scenarios — especially if you cannot adopt Swift 6 and need to stay on Swift 5.10.