Confusing syntax of `.await`

What exactly makes you say so? What have changed in C# that allows you to say that?

It's still the same language which depends on fat and heavy runtime and GC. And while it got AOT mode (thanks to Apple's dislike of JITs) it's very much a crippled C#.

I would say Rust is prime example because it started as entirely different language than what we have since year 2015… but all that happened in the prehistory phase, while it had the chance for some radical changes.

I would say that language which chanced is character most is not C#, but Java. Java before version 5, without generics and Java today, with generics, are significantly different languages… but Java paid similar price for that as JavaScript/TypeScript: language which you are programming in is significantly different from language as it exists at runtime.

In both Java5+ and JavaScript/TypeScript you can create array of ints variable… and then stuff it with Strings, Rects and various other such objects. Nobody would complain and compiler would only do a token effort of stopping you.

This example shows just why popular languages are so hard to change: it's easy to apply ton of lipstick on a pig, but to change the language core you have to remove certain things… and that's almost impossible to do with popular languages (AlgolPascalModula-2Operon shows what happens when/if you try to do that… the end result is marginal language used by very few developers).

I'm guessing you don't know C#, or you wouldn't say that.

Unreleated to the language per se.

There are an almost ridiculous number of huge changes to C# from the early days:
Generics (C# didn't have generics either. C#'s generics are arguably better than Java's, not relying on type erasure.)
Query syntax (a sql like language syntax embedded inside C#)
Dynamic typing (duck-typing) added via the dynamic keyword (not var, which C# added separately, but which only is used for type inference).
Anonymous methods (then eventually lambdas) - there are several syntaxes for these.
Iterator blocks
Nullable value types (and eventually non-nullable reference types)
Extension methods
Anonymous types
object/collection iniitializers
auto-properties
async/await (as big a change in C# as it was for Rust)
null propagation
proper tuples and destructuring
pattern matching
more direct memory control via stackalloc / fixed / ref struct

Lots more too. More than I can remember and I use this language every day.

1 Like

It would certainly be a subset of Rust, but there's enough interest in coding in such a subset that prusti, a static analyzer that can be used to verify that Rust code is panic-free, exists. It doesn't impede anyone who doesn't want to use it, and prusti-verified crates can be used from non-prusti-verified crates.

If someone wants to use Rust this way, is it a problem?

1 Like

I guess we would have to agree to disagree. To me anything which can be achieved by simple lexical transformation of a source is “just a tiny bit of lipstick which is not really important” while intrinsic properties (like: does my generic only exist at compile-time or is it runtime object, too) are different.

Case to the point:

I don't really see async/await as huge change for Rust. Generators are significant, change, async/await is just a tiny bit of lipstick on top of that.

In C#, AFAIK, there are no generators, async/await is just a magic on top of existing managed runtime. Interesting, but not that much interesting.

Most of you list are “quality of life” improvements. They are not changing what you can actually do in the language.

Similarly to how C++17 if constexpr allows you to do things like this:

auto foo(auto x, auto y) {
    if constexpr (std::is_same_v<std::string, decltype(x)>) {
        return x + ", " + y;
    } else {
        return x + y;
    }
}

Which lots of people say changed C++ radically… but to me it's just a tiny bit of lipstick on good old SFINAE from XX century.

P.S. And the fact that you can not even remember all these “significant” improvements are telling, too. Things that radically change the language are usually easy to remember because they are so rare. With C++, I think, lambdas (closures) were something in that ballpark, move semantic, maybe coroutines (they are made poorly in C++ thus it's not clear how much would they change… adoption is pretty limited so far). In Rust there were a lot of changes pre-stabilization, but after stabilization… const generics come to mind, generators and GATs. All three are pretty raw (generators are still unstable) and it's hard to see how much change to the language they would do.

Sure, but how often is that used outside of research? As I've said: panic-free code is very much something I would like to have, but I'm not sure I'm to the task of writing it.

And if crates.io is not chock-full of crates which pass these checks then theoretical ability is not all that important.

Absolutely not — as long as s/he doesn't try to push changes in the Rust proper for the sake if his or her project.

Changes in the language requires buy-in from the majority of users. Think the exact similar thing with C (I already mentioned that): Google Wuffs. It uses C as a base, not Rust, but does, essentially, the same thing.

it's really nice tool, but does it mean C (or C++) must be changed to accomodate it? I don't think so.

What proposed language changes are you talking about?

Making await similar to let or if… the thing that started the whole discussion, isn't it?

1 Like

Well, for one thing that part of the conversation was about raising panic-safety awareness that was brought in for some reason.

But as for .await, was changing it proposed? People are expressing what they like and dislike about it, and some people talk about what could be done to improve things, and what the problem areas are.

But even if people were to propose changes, that's no reason to treat it as an attack on "proper Rust" in my opinion.

Then literally everything is just sugar for assembly. Once a language is Turing Complete, it can do everything. A language addition is "significant" if it "significantly" changes how you do something. How much of a change is significant is highly subjective.

It's also very difficult to separate the "language" from the compiler, runtime, and the standard libraries. Especially if they're versioned and shipped together, they're intertwined in a way that makes separating them into separate concepts of questionable utility.

So I think the disconnect boils down to two things:

How important is marking unwinding control flow

Things like loop, for, or while are prefix operations because they do control flow, but more specifically, because they do control flow which runs a block not exactly once.

.await, ?, and panicking, on the other hand, don't do any kind of control flow shenanigans that reorder the code written in your function. The only things which .await or ? do is either block forever, continue on with straightline control flow, or unwind the current context. This is exactly equivalent to any how any other potentially panic-unwinding function call works.

There are differences in how these unwinds are both implemented and covered -- .await by dropping the Future, ? is reified into Result, and panicking via some sidechannel which sets thread::is_panicking() before dropping things off of your stack -- but from the viewpoint of this function, all three are the same API: instead of continuing straightline, your stack locals get dropped and you never resume.

async.await isn't implemented this way, but you could implement it just by putting each async block on its own thread and making .await a blocking call, rather than the stackless generator transformation, and unwinding from that blocking call on synchronous cancellation.

Making only some unwinding visible

The point about "every" indexing comes from a position of "why mark more unwinding points if there still are implicit ones?"

If you allow for any code to unwind, then "all" of your code should be correct when unwound through, unless you have a reason to assert that it doesn't unwind. A lack of explicit marked unwind points isn't sufficient if there exists any way to unwind.

There is a difference between fully-implicit panic unwinds and ? or .await propagated unwinding; you can turn off panic unwinding, making it abort instead, and as such, panic unwinding is (typically[1]) only used for programming bugs, whereas .await unwinding due to cancellation or ? unwinding to return the break variant are considered normal, reasonable bits of control flow.

So, if a reason you picked up Rust pre-? was that you could turn off unwinding and got to completely ignore that unwinding was a thing, you're not going to like ? or .await. Both of these contain at the core of their design that a little bit of unwinding, as a treat, is beneficial. There's a reason that exceptions are such a repeating part of language design -- unwinding is a very convenient way to write code, because it allows you to write the "happy path" and not clutter the code with saying if err != nil { return err } everywhere.


If you model unwinding as just a function blocking forever, you don't have .await or panicking doing any control flow. Actually unwinding is understandable as just an optimization over blocking forever; instead of blocking forever, everything on the stack is dropped, notifying the forever-blocked task that it won't be resuming, allowing the will-never-continue task to free any resources that it won't get to use anymore.

? is much more difficult to justify modelling this way, because there's no clear "task" boundary to say is blocked while the dispatcher returns the error result, and unlike task cancellation, just blocking would likely cause normal executions to never complete, rather than just wasting resources. However, it is still possible to model this way, you just have to accept that in this case the you're-not-continuing notify is necessary to complete in order to resume the caller.

From this "unwinding is an optimization over infinite blocking" and the generally-considered-impossible task of proving real-world code completes and doesn't just spin or block forever, and you get Rust's current position of allowing the use of unwinding in very controlled ways.[2]

You can write code, even Rust code, which is highly resilient to errors and doesn't rely on unwinding, but the general consensus of not just the Rust community but the programming community at large is that for general-purpose development, allowing some amount of unwinding makes code much simpler to develop.

Perhaps in another 20 years this will shift, and unwinding will be viewed similarly to how nullable-everything is today, but we're not there yet[3], and today, structured and targeted use of unwinding generally improves code.


  1. There are systems such as rust-analyzer and salsa which use panic unwinding to implement cancellation of synchronous tasks. It would perhaps be "better" ideologically if the entire computation pool were async and cancellation points were provided and marked by .await on a not-actually-async reactor/runtime, but this doesn't come for free, and the OS unwinding mechanism is already set up and specifically optimized for this pattern. Plus, as a persistent server rather than a oneshot process, r-a wants to abort-and-move-on tasks in the face of bug-panics rather than taking down the entire process and the other in-flight tasks with it. Basically, cancellation and "oops this had a bug" are treated (nearly) identically, so it makes sense to use the same unwinding mechanism for both. ↩︎

  2. If you had a few bigger britches, you could say that Rust is positing that its use of "structured unwinding" offers the benefit of unwinding while taming some of the pitfalls of less structured unwinding, similar to how "structured control flow" tames unstructured goto, and "structured concurrency" tames unscoped threads/tasks. ↩︎

  3. In order to get there, I posit that we'll need much stronger dependent typing and proof assistant AIs than are available today to help write code to eliminate panic-as-bug unwinding without significantly increasing the author's burden. Once that source of unwinding no longer exists by necessity, the ideological argument against unwinding for cancellation gets stronger. In order to replace ? with a prefix operation, you probably need a |> style piping so you can |> try method(args...) |> with the desirable binding order. And for cancellation... I have no solution to offer. ↩︎

6 Likes

Because the language is enormous and the way code is written in it has changed dramatically over the past 20 years...not because those changes were insignificant. I simply don't think about most of them any longer: I can't even remember the last time I used the event keyword (probably a decade ago), yet it remains part of the language and I'm sure someone somewhere probably uses it.

You are very dismissive of anything that doesn't fit your preconceived (and ever shifting, it seems) notion of what is or is not a significant change.

As @phaylon mentioned, I don't see discussions with you as very productive. They degenerate into you denigrating anything you don't agree with as wrong or unimportant.

I'm done here.

1 Like

No, it's really simple:

  • Things being panic safe for me means they still have to be memory safe. It doesn't matter if some invariant is broken, like a wrong index, if it can never lead to memory unsafety in safe Rust code. In unsafe Rust code I might like to know where execution suddenly might stop due to a panic so the former point remains true.
  • The actual logic invariants my code should uphold are different. When you end or suspend a function, you have to ensure all logic invariants are in order. And the code should make it easy to see if that is indeed the case. Something being leaky because I didn't see that ? before the drain(..) or whatever is of much more importance to me than panics in safe Rust code. I'll happily create panics to ensure these invariants are upheld.

So I avoid deep inner-expression ? usage. And I try to make .await points stand out. And I don't hide return or break after => and give them their own block.

I admit I'm having a hard time connecting the rest of the things you wrote with what I've been saying. Like for example:

I don't understand what this is a response to, or arguing against

There's two levels:

  • "unwind safety" in the manner of unsafe, that unwinding does not break the unsafe contracts (thus exposing safe code to UB); and
  • "unwind correctness" that logical requirements are met; this is what the UnwindSafe trait and lock poisoning are guarding.

In a perfect world, any unwind that breaks logical requirements would poison the broken state such that touching it in the future panics again unless specifically acknowledging the potential of broken state. This is exactly the behavior of lock poisoning.

I'm still personally of the mind that lock poisoning being built into std's synchronization primitives was a sub-optimal choice, and it should've been an additional layer introduced inside the lock (which would also allow using it without locks being involved!), but the general concept of poisoning is sound and a good concept. It's the same thing as leak amplification / PPYP, just for logical correctness rather than safety.

FWIW, with decent quality of implementation, an unwind through safe code shouldn't cause any memory leaks (in the fully-unreachable meaning of leaks). Leak amplification only applies if you're forgetting structures; an unwind will always drop everything that you have in scope.

Of course, if you have an Arc cycle which you would've removed afterwards, an unexpected unwind will cause a not-necessary-for-soundness leak, but the purposeful existence of such data ownership cycles is rare, even temporarily.

If there's potential for other "Java style" leaks where stuff is still technically reachable but by some long-lived arena which isn't going to go away any time soon, it's still considered "better" to have RAII owners which do the cleanup on Drop (and thus an unexpected unwind won't leak) rather than just having a discard or whatever at every scope exit, even in purely safe code, because then you can't forget to do the cleanup. (This is reasonably lightweight to do adhoc with scopeguard wrappers if making custom RAII handles is too heavyweight.)

This is primarily a justification of allowing ?/.await unwinding, in part a response to

in that the pushback against ".await and ? are control flow that should ideally be more visible" is likely incorporating at least in part that the unwinding caused by .await/? is already more visible than panic-unwinding, so making it more visible is of questionable benefit while panic-unwinding is still allowed to be completely-implicit.


It's quite interesting (at least to me) that the "put control flow at the start of a line for visibility" is being mirrored quite directly with formatting discussions around let chaining. i.e. given

if some.decent().condition() && let val = get() {
}

the let and pattern being bound are drifting further away from the front of the line, and there's a question of visibility; where do we allow the let to show up, do we force linebreaks, does this impact the non-let portion(s), etc.

overly cute

I can't mention it and not provide an example of this formatting choice. I like it, but freely admit it's probably too cute rather than functional, and breaks down in more complicated situations (and doesn't even offer any opinion on mixing in regular boolean tests)

if let Ok(a) = a.lock()
&& let Ok(b) = b.lock()
{
    // code
}

// NB: let-else can't be chained like this currently
let Ok(a) = a.lock() &&
let Ok(b) = b.lock()
else {
    // diverge
}

I agree in general, but

The leak I'm talking about is different. that's why I said ? before a .drain(..). It's just buffers that should have been cleared, states that should have been reset to default or zero, these kinds of invariants Actual leak leaks I find rather uncommon anyway. The ? before .drain(..) causing a buffer to grow exponentially was just the most recent example I ran into.

I'm not sure how to respond to that. As the person that finds control flow at the beginning more readable, the benefit isn't questionable at all. I'm not lying, so could we please avoid the "questionable" angle? Specifically since my only "suggesting" up to this point was explaining what I do to mitigate this.

I don't see where I'm attacking unwinding at all, so why the need to justify it?

Same goes for that let else by the way:

if some.decent().condition()
    && let Some(val) = get()
{
}

is how I'd write it in my code. I changed it to an Option because I wouldn't use it without a fallible pattern.

But me wanting to have that binding let further to the front doesn't amount to some attack on let.

although i don't think .await is bad syntax, i can understand the argument. Maybe there could be a different operator / token for such kind of postfix transformations, that would also motivate new kinds of postfix transformations.

i'm not sure what token could be used that wouldn't cause conflicts, but i think i'd propose ~.
then we could have future~await, ptr.read()~unsafe, x_f32~as<f64>

To be 101% explicit: I'm including .await cancellation and ?'s early return under the "unwinding" umbrella, not just the stack unwinding done by panic=unwind.

The point I'm making is that (it's a valid model to consider that) the unwinding from .await is no different from unwinding from panic!, so what makes it important to make .await more visible than it currently is, when you're perfectly okay with panic! unwinding?

There are multiple valid answers to that question, but it's not something to leave implicit.

As the rest of the post said, it's typically considered good practice to use RAII Drop guards to reset or poison the state, even if you don't have any unwinding (which includes ?, .await).

Yes, making .await/? more visible means that it's more obvious when you can't get away with not using an RAII guard... but if the style is to always use the RAII guard (thus preventing any exit from forgetting to do the cleanup, not just unwinding exits), then you don't need that reminder.


All of that said, my point here is just to illustrate how .await and ? are similar to panic unwinding, and thus while it makes sense to have them marked, letting them "fade into the line noise" (not a quote) isn't all that problematic.

The point isn't to say that you're "wrong" (there isn't any objective truth in style), but to point a clarifying magnifying glass at (a partial reason) why the general opinion is that these bits of "lite control flow" don't need the extra visibility that you like from them.

For some time .await!() was also considered; this is more clearly not a field access, and doesn't strictly require making await a keyword. However, .await is fundamental enough that it did deserve the keyword, and postfix macros have a lot of design considerations (and implementation considerations) that need to be hashed out that would've delayed async.await if it couldn't happen until postfix macros were available.

That said, it's worth reiterating that it is a different token than field access. You cannot have a field named await, the same way you can't have a field named loop or struct.

3 Likes

I explicitly don't.

Because my viewpoint is that they are different, as I've explained.

Which just doesn't always work. Hence the existance of ? in the first place.

I really don't need the 1000th person doing this to me. Thank you, you can stop. It just doesn't change the fact that it's still true for me.

I still don't even know what you want me to do. I didn't propose any changes, just explained why I have problems. Is your intention simply to silence me? Otherwise please state clearly what action of mine you're objecting to.

Is it really that unacceptable to the Rust community for someone to have readability issues and express them? Maybe that should be a focus instead of trying to convince me that I should consider panics and ? the same.

Edit: Removed comment about .await history, as it wasn't directed at me. It's getting late here.

If you explicitly said what makes a panic-unwind violating logical invaraints different from .await-cancellation-unwind violating logical invariants, I missed it.

From the viewpoint of the code which gets unwound, the behavior of the two are identical, as I covered.

backlink for explicitness

There was a (perhaps imagined) assertion of one side not understanding the other's position, and this was also partially an exercise in determining why I don't consider .await important to highlight[1] beyond it being present.

Fundamentally, I think I disagree on the implication of

The model for async.await is that .await blocks the current task until the awaited task is finished. This blocking can unwind if the task is canceled. That other code potentially is running concurrently is a property of async, not the .await. In an async context, .await is no different from a blocking call which can unwind semantically; the difference is whether this starves other async, but not a semantic one.

This is perhaps the fundamental point I'm disagreeing with: if you're using async, you're not "supposed" to care about the fact .await isn't blocking, because the point of .await is to write code in a straightline synchronous-like manner instead of future combinators and/or manual state machines.


But at this point I agree we're aggressively agreeing to disagree; this is just to respond to the direct request. I don't intend the above to be a request for a rebuttal; please feel free to leave it here.


  1. As I've mentioned, I was and still am a proponent of "explicit async, implicit await" as a model. For Rust, having the await point visible is important so that unsafe reasoning can be reasonably scoped and not rely too much on implicit information, but when unsafe is not involved, it's not important the difference between an .await and a synchronous operation. ↩︎

1 Like

I'd love to stop. I'd have loved that hours ago, but I keep having to defend myself for some reason. And I still don't know against what. From my perspective you're simply beating me with the stick the last person dropped.

Like, I don't know why you're fixating on things like "what makes a panic-unwind violating logical invariants different from .await-cancellation-unwind violating logical invariants".

I just have trouble seeing the control flow operations at the end of the line. Would it really kill people to accept that that's something that might be true for someone, and that they also might consider panics, ?, .await, return and whatever else as not all simply semantically equivalent?

(answering the question, no more)

The point of contention is the question of what makes .await/etc worth highlighting that doesn't apply to panics. It's perfectly valid to treat them differently, but ideally you would have an articulatable reason one way or the other, and it's difficult to tell what's supposed to be subjective or objective purely over text.

I do apologize for dragging this out more than necessary; this stems from me misinterpreting a subjective position and not stopping when appropriate.

3 Likes

My expectation is that the language shouldn't drift even more towards confounding different concepts, because it's already big and complex without that. That's not "confusion", and calling it an "incorrect expectation" is highly offensive.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.