In my Swift 5 project I have this extension to pass an async
function to Publisher.map
:
import Combine
public extension Publisher {
func asyncMap<T>(
_ asyncFunc: @escaping (Output) async -> T
) -> Publishers.FlatMap<Future<T, Never>, Self> {
flatMap { value in
Future { promise in
Task {
let result = await asyncFunc(value)
promise(.success(result))
}
}
}
}
}
However, I cannot compile this in Xcode 16.1 beta using Swift 6, getting:
"Task-isolated value of type ‘() async -> ()’ passed as a strongly transferred parameter; later accesses could race".
Is it possible to migrate this extension to Swift 6 somehow? Already tried to add Sendable
and @Sendable
everywhere.
2
Answers
If
asyncMap
is required to run on@MainActor
, andT
isSendable
, then it’s straightforward and will just work:If it is not…I don’t believe this is possible without using the universal escape hatch (I’ll show it in a moment). The problem is that Future does not mark its
promise
parameter is@Sendable
orsending
or anything. It doesn’t promise not to hold onto it and run it at some random point itself, and maybe it has side-effects which could cause a race. It doesn’t do that, but it’s not promised. And I don’t think there’s any way to really fix that without Apple updating Combine (which they seem to have mostly abandoned a this point).There is always the universal escape hatch:
@unchecked Sendable
, and coupled with marking just about every other thing@Sendable
will make this work:I’m sorry.
Given what we know of
Future
, I believe this is actually safe. Lots of things Swift 6 calls unsafe are in fact unsafe, and we just kind of ignore it because "that won’t happen" (narrator: sometimes it happens). But I believe we do know that this is safe. Apple just hasn’t updated Combine to mark it.Or…. we could also reimplement Future:
And then your original code will work, changing
Future
toSendingFuture
.I think Rob Napier’s answer is very good (+1).
But I might advise caution with unstructured concurrency (i.e., the use of
Task{…}
). If you create unstructured concurrency, you bear responsibility for handling cancelation. If you wrap aTask {…}
in a standardFuture
there is no (easy) way to cancel tasks that are underway when theFuture
is canceled.If you are going to try to wrap
async
–await
code in aFuture
, I might suggest that you will want to create a customAsyncAwaitFuture
that will also cancel the associated task if the future is canceled. And then, once you have that, you do not need to implement anasyncMap
method, at all, but you should be able to use all of the standardPublisher
methods.So, perhaps:
Then you can do things like:
And:
Or, if you wanted to do a series of these (say ten of them, but not more than three at a time), it might look like:
So, when I profiled this in Instruments, I let it run once to completion, but the second time, I started it, but dismissed the view in question before the tasks finished, but it was canceled automatically at the ⓢ signpost when the
cancellable
fell out of scope:(Forgive the Instruments snapshot, as a I shorted the labels in the intervals so you could more easily see the full string.)
In short, be careful with unstructured concurrency (
Task {…}
) and make sure you cancel theTask
when it is no longer necessary.