Is there any way to fire-and-forget an async function?

Hello Apple, Smalltalk calling. Do you accept the charges?

The 5.9+ code I'm currently writing is using dispatch queues because I couldn't figure out a way to invoke an actor method without waiting for its result.

The context is that I have two actors, let's call them Socket and Database. Socket maintains an outgoing priority queue and Database is serializable. Every now and then, Database will tell Socket that it should send some stuff and every now and then, Socket will ask Database for more stuff because its outbox is running dry.

The data that Socket requests is different from the data that Database nominally provides (e.g. https://rethinkdb.com/, no relation other than the core concept).


So now, Socket's outbox is not empty but close to being empty, so it asks Database for more data. It can't really await on Database as an actor because its job is to await on the socket send() call, or at the very least on a race between the two.

Now of course, under the current architecture, Task { await database.gimme() } solves the problem in a way likely similar to other Future types, but one does wonder:

Whatever happened to message passing?

Unlike a classical future type, unfortunately it allows one to discard the result, which means that all errors are ignored. It also doesn't guarantee ordering. For these reasons, I wouldn't recommend using unstructured Task.init other than in a very limited set of use cases as a building block for higher-level primitives that work strictly within structured concurrency constraints. Also see this post for more details: DispatchQueue.asyncAfter(deadline:) in Structured Concurrency? - #8 by ole.

That is precisely what a classical future type does.

I would expect a classical future type to guarantee that futures created in a certain order also start in the same order, or at least provide a way to schedule those on the same event loop to get some ordering guarantee, this is not the case with Task.init.

Additionally, when using Combine's Future or SwiftNIO EventLoopFuture as an example in the same manner as Task.init tends to be used, you'll get a quite obvious Result of 'Future<Output, Failure>' initializer is unused warning that's hard to miss and to ignore the future itself or errors it produces. This is not the case for Task.init either, making it too easy to ignore errors, quite undesirable in real-world fire-and-forget scenarios.

2 Likes

That was only ever true because the runtimes of languages that previously implemented future types were either single-threaded (nodejs) or under a GIL (python).

There is no "certain" order in distributed computing (of which modern CPUs are a subset threof). At best, you can hope for serializability IFF futures are dispatched by the runtime, but that was never really the case in Swift (except NIO which probably meets your expectations).

This is about something else

1 Like

Do you think it is reasonable to expect the following code to always print "First" rather than "Second"? Swift Concurrency says “no”, but lots of people say “yes”.

func doTasks() {
  Task { print("First") }
  Task { print("Second") }
}

Remember that Swift does guarantee that these tasks will execute serially, because they will be enqueued on the default GlobalActor, and GlobalActor.sharedUnownedExecutor is constrained to UnownedSerialExecutor.

Who?

Unless there's code implied but not shown surounding the function those tasks will be run on the default concurrent executor. Otherwise what would be the point?

Sorry, I mixed up Task { } with a hypothetical Actor.send() that has been discussed previously.

Then what do they expect to happen for this:

func doTasks() {
  Task { await networkIO(); print("First") }
  Task { await fileIO(); print("Second") }
}

I can certainly imagine people making mistaken assumptions when learning Swift's Concurrency system, but I assume they'd figure out how it actually works fairly quickly. And last I read it, the Swift Concurrency documentation was pretty clear about the behaviour & significance of suspension points.

If your task closure doesn't have an explicit suspension point then I guess you could try to argue it's different, but I'm not sure that really holds. One taking that position would have to justify why it should be different (which would have to include explaining how & why the implementation of Task itself is not permitted to have any suspension points).

I don't know if Concurrency is the one saying no. Ignoring any special casing for Task, doTasks() calls two functions without any data dependencies or barriers between them. The optimizer is thus free to reorder them, no?

1 Like

Swift doesn’t have a formal spec like C’s, but I’d bet dollars to donuts that Swift’s sequencing rules are similar to C’s, and that the compiler is not allowed to reorder function calls unless it can recursively inline both functions in their entirety and prove that there are no dependencies, which is nigh impossible in the presence of aliasing.

In this case, since print() is a side effect, the “as-if” rule would certainly require preserving the order of the calls to print() in a straight-line function.

3 Likes

Right, in a language with globals, you can’t prove there are no sequencing dependencies between two arbitrary opaque function calls, as long as those globals are protected by atomics or locks or similar. If everything was actors all the time it’d be different, but (a) it’s not, and (b) if it were, we’d immediately need to introduce a feature to force an order, instead of punting on it, so that you can print things and have them come up sequentially.

1 Like

Yeah, I kinda figured after posting that such aggressive reordering would probably break too many things in practice.

To answer Kyle's original question (with the Task -> Actor.send() substitution), I'd say that since the point of actors is to serialize state, and since no guarantees can be made regarding sequencing before it reaches the actor in general, I'd be okay if messages are dispatched out of order, even if they originate on the same thread. If the relative order of two messages is important, than they probably should be a single message instead.

After thinking about this some more, I believe that messages probably should be FIFO after all (i.e. DispatchQueue) as they're much more useful that way: not just for thread-local sequencing, but also for the ability to "import" non-swift sequencing guarantees.

I'm still okay with Tasks being unordered in general as they can and should run on different threads when available, but since actors are supposed to be sequenced anyway, I see no real downside to offering that one step ahead of the border, maybe via a dispatch operator that expects a non-throwing, void function bound to an actor.

My original thought was that this guarantee could be hacked away via executor preferences, but I now strongly believe that using custom executors in anything other than easy-to-recognize library code will make swift very difficult to reason about in general and should be avoided if possible.

1 Like