Task {} syntax sugar

In UI code, I find myself doing stuff like this:

Button("Like") {
    Task { await self.viewModel.like(user) }
}

Where I'm wrapping individual async calls in isolated situations.

I would prefer something like this:

Button("Like") {
    queue self.viewModel.like(user)
}

Or whatever word you'd rather use instead of queue to express that it still is an async call, but without having to wrap it in a Task object.

Is there a reason the closure parameter to Button.init can't be async? Then you could just call await without the Task wrapper; simpler again.

If it's merely an awkward / suboptimal API, that's not forced to be so by the language, then language changes aren't warranted.

(I have no insight into why the closure parameter isn't async in this case, but it seems like it'd make a lot of sense to be since button actions are often non-trivial if not overtly async… perhaps this part of SwiftUI just predates the relevant Swift language features?)

3 Likes

good point. but i can also see a scenario where even from the Button side of things it likely will still be doing something like:

Task { await closure() }

Which would be the case for any SwiftUI element that offers an async closure alternative for an initializer. However, instead of duplicating the exact same functionality just to make call sites cleaner all throughout the framework, I think it would be wiser to just enable the caller to execute the code cleanly using something like I mentioned in the original post.

This doesn't look properly placed to me. Are you doing anything after "await self.viewModel.like(user)"? No. And if you were doing it:

Button("Like") {
    Task {
        await self.viewModel.like(user)
        do something else here
    }
}

would "do something else here" be view related? No. IMHO it should look like so:

Button("Like", action: model.likeClicked)

with everything else what's needed inside.

1 Like

There are several questions to be answered before it should be attempted.

  1. What happens when the button is tapped multiple times while the action is suspended?
    • How does this integrate into the button's styling?
  2. What happens when the button comes and goes while the action is suspended?
  3. Does the action participate in cancellation? If so, how?

Keeping it manually async sidesteps those questions. Button itself has a lot of growing to do before niceties like this become a priority.

8 Likes

I agree there's some challenges here but I think @wadetregaskis is on the right track that in the long term we should be promoting async functions, not explicit tasks everywhere.

4 Likes

I don't agree with Task conveniences per se, but I do think Task is rather poor API itself. Namely, the fact that Task itself doesn't surface its execution context makes it very hard to use precisely. Over the last two years it's been very confusing to learn and teach. I'd like to see it (and Task.detached) split into two context sensitive global functions instead.

When there's an actor context to inherit, this would be available (naming pending):

concurrentlyInContext {

}

Calling this API without an actor context would be an error, and autocomplete wouldn't show it in that case at all. This makes it clear that it will always use the current actor context.

When there's no context, or you just want out of the current context:

concurrentlyWithoutContext {

}

This could go even further and have a different name when in an actor context, but that make be a bit too much.

These would otherwise take the same parameters as Task / Task.detached and return an underlying Task value, but would be more precisely named and context sensitive.

Versions of Task that execute more like DispatchQueue.async would also be appreciated, and would require a different name.

3 Likes

True, but we can't make the world red (or is it green? :thinking:), so improvements to the core Task experience are going to be necessary.

2 Likes

I think @chrisbia points out a core end-user programming issue in this thread's first post where print(a.b) is fine, but the moment you type print(a.c) and a.c happens to require async, you either need to wrap the expression in a Task or async function. It doesn't feel particularly ergonomic, and it seems at least worth some sort of discussion.

Maybe I'm misreading your post here, but are you suggesting that if an expression in a random closure happens to touch async code, it's best to refactor that into its own method?

1 Like

What I’m reacting to is a tendency I’ve seen in the community to view Swift Concurrency as libdispatch-like: “the caller decides where things run by making Tasks and calling synchronous code from them”, which defeats all the nice automatic functionality and puts “run this in the right place” back on each individual developer every time.

Often they get too used to the caller-decides model this way and then are baffled when they call an async function from an actor-bound Task and expect it to run where the caller said instead of where the callee said, e.g.

func foo() async { … }

func bar() {
  Task { @MainActor in 
     await foo() //wait why didn’t foo() run on the main thread??
   }
}

These two factors (plus some less important aesthetic concerns) push me towards the view that most code I see from the community should probably have more async functions and fewer explicit Tasks. It’s not a firm rule or anything though.

4 Likes

I see, thanks for clarifying. I think we've somehow designed the language to make it more tempting in the moment for developers to wrap code in Tasks rather than refactor it into async functions. That's sort of why I like the framing of the original post: "hey, I find myself writing this: (code) often and would appreciate a convenience".

1 Like

Exactly. There's this really interesting tension between making Concurrency easier to incrementally adopt, which Task is quite successful at, and being too immediately appealing, leading to the issues I described.

It's very possible there's something clever that can be done in the larger design here rather than relying on syntactic salt though; so I don't want to discourage thinking about proposals like this, just noting to keep the larger context in mind.

4 Likes

famous words before over-engineering a solution. yes maybe there's something clever, maybe not, maybe the thing that's clever takes weeks to find and then weeks more to implement, or the most likely scenario: it does exist, but is never found in the first place because nobody has the time.

now maybe implementing what i proposed is difficult in and of itself, don't know, don't know how such a thing would be integrated, but what it does propose is an immediate solution to a common problem. and is therefore, a relatively simple way for Swift developers improve the readability of their code bases that could be just round the bend, and isn't writing more digestable code a big part of what being "Swifty" is all about?

what's the downside here?

1 Like

If it only took weeks to figure out how to resolve the issue I brought up that would be wonderful news :slight_smile: I was expecting months or years.

A more extreme historical example as an analogy: before Swift was publicly announced there was briefly a proposal to add syntax sugar for UnsafePointer, which made some progress until someone pointed out: "wait, half the point of UnsafePointer is that you can't use it without typing 'Unsafe', so everywhere that's using memory unsafe code is visibly distinct".

Another example: the keyword await doesn't actually do anything in Swift. It could be removed from the language entirely, saving on typing and clutter. It's there purely for the benefit of the reader, to mark places where control flow behaves differently.

Don't let me stop you from pushing something like this proposal[1], there's nothing wrong with making things nicer. I'm just encouraging pausing a moment to think about what you're making nicer.


  1. ok I do object to the await being removed in your example, that's important to keep ↩︎

6 Likes

Similar to @David_Smith, I didn't mean to argue against the pitch's spirit, I was just suggesting that the given motivating example might not be compelling. If you can find & present examples which are more clearly not library API choices or flaws, then you'll have a stronger case.

I too have occasionally wished for a terser noawait keyword (or similar) to say "yes, this is async, go do it, but I don't care what happens to it". It's just that in the situations I've encountered an apparent need, it's generally turned out that there's actual problems with what I'm doing that this would just gloss over.

3 Likes

How would async functions help here, unless your intent is to paint the world red? The vast majority of Task use is to create an async context in a sync one. Creating one in an already async context is rare, as the user's immediate attempt to await something() succeeds just fine. Any instinct to manually call back to the main actor is often attracted to MainActor.run, and sometimes the more sophisticated (and much more subtle) Task { @MainActor in }. But in the end, getting users to understand that callee determines context is a matter of education. Creating more async functions doesn't really help there, since there's a lot of contextual knowledge you need to pick up to understand where things run, even after you're familiar with callee determines context.

1 Like

That is what having more async functions would do, yes.

I've also long thought about a new keyword to handle this (I tend to call it "send" but the name doesn't really matter).

The problem is, for better or worse... nuanced.

It rather unusual for actors to not have the uni-directional counterpart of await that corresponds more closely to a message send. However in our world this would entail creating new tasks -- though maybe we could keep a pool of tasks we could reuse for this or something. The biggest problem is that we actually don't want to make it easy to do the wrong thing... and the wrong thing being "not using structured concurrency" which the child tasks and await on async calls do force you into.

So on one hand, I'd really like uni-directional "don't wait" semantics especially when developing messaging across process (distributed actors or not, either way), but on another -- yes it invites the risk of people taking the "easy way out" and then losing all the benefits of structured concurrency we're been fighting to make "the" way to do concurrency.

All that to say that... it's not quite clear what the good solution here is. Making it too easy to go the wrong way about concurrency adoption is a concern, however the lack of an "enqueue immediately on a specific actor and don't wait" operation is definitely a source of a lot of pain (a common issue is cancellation handlers and actors together, or just any calls into an actor from synchronous code etc).

We should figure something out here but it'll have to balance those tradeoffs somehow.

(But it's not really motivated by just being "sugar" but by missing semantic power: today we cannot "enqueue a task on actor immediately" without special tricks using custom executors)

5 Likes

how would implementing what's pitched here negatively impact how people adopt concurrency?

i feel like if somebody needs to run async code in a synchronous context and has no need to wait they'll just always wrap it up in a Task object. the barrier to do that is already so low. all introducing some new keyword would do is make what people are already doing liberally, more readable.

now, i'm not saying that just because it is done means it is proper, but in many cases it is. and there's always going to be ways to misuse swift, but i don't see how implementing what's proposed here meaningfully increases that likelihood.

I'm agreeing (at least personally) -- however, when/if we do this, it a keyword should do better than just Task{} does -- and that's why I'm mentioning the nuances and efficiency questions we'd need to get right.

There's a few proposals in flight which might affect how we think about all this, including the linked ones, as well as: Actor isolation inheritance by rjmccall · Pull Request #2210 · apple/swift-evolution · GitHub

So what I'm saying is that this should not be just about "sugar" but about adding stronger control over execution which may end up either as combination of above proposals, or maybe a keyword someday. I don't think "just" motivating this by reading nicer is strong enough motivation -- and to be clear I'm the no.1 fan of "send" semantics, but we gotta do it right :slight_smile:

5 Likes