On the proliferation of try (and, soon, await)

Wouldn't that just be a race?

Async by itself doesn't do anything. I wouldn't be surprised if one struggles to come up with such an example. It's more useful with protected states (like actor) though.

Yes. The await is a suspension point, meaning a place where this code gives up its thread for other code to use. "This code" means "the rest of this function and any functions that are awaiting this function's completion". "Other code" could be anything else.

Well, yes. However, if this is running on the main thread, something servicing the main event loop must have started synchronously, and there had to have been a transition to an initial asynchronous function, at which point the synchronous portion would return to the main event loop.

4 Likes

This was the first thing that popped into my head as well, for the exact same reason, after reading Dave’s post. Except it would be await not async, right?

func processImageData() async throws -> Image {
  await try do {
    let dataResource  = loadWebResource("dataprofile.txt")
    let imageResource = loadWebResource("imagedata.dat")
    let imageTmp      = decodeImage(dataResource, imageResource)
    let imageResult   = dewarpAndCleanupImage(imageTmp)
    return imageResult
  }
}
5 Likes

I mean it could directly modify the instance. (Or call other code that does). No concurrency or suspension points needed.

If the code is being run as part of the main event loop, doesn’t that make the main event loop an “ancestor” of this code (ie. awaiting it), and thus it also gets suspended?

Conversely, if this code is not being run as part of the main event loop, then it should be on a different thread, and the situation would be a regular old race regardless of sync or async.

The async/await proposal says that @main could be async:

@main
struct MyProgram {
  static func main() async { ... }
}

That implies to me that the entire runloop would give up its thread when anything it calls suspends.

We should take this to a new forum topic, if you want to discuss this more. The answer to the question we're discussing matters to this thread, but perhaps the discussion itself is OT.

The short answer is that if anything running on the main thread suspended itself and everything else pending for the main thread, then it has blocked the main thread, which is (a) by definition synchronous, not asynchronous, and (b) therefore unacceptable.

1 Like

I’m somewhat unclear on which cases it will be okay to not include the try keyword and what it will mean. Are you saying some like similar to @discardableResult perhaps spelled as @discardableTry? In that case throwing is still a control flow construct. Or are you referring to a way to mark a scope so that is doesn’t control flow (exit early, sort of like break) and acts more like fallthrough but in that case are we capturing all errors or discarding them? I would love a couple more examples. Thank you!

That base is already covered by “throws.” The language prevents exceptions from being ignored by requiring “throws” on throwing functions. Either a function catches all the errors that may be thrown into its body, or it has to declare that it throws. You don't need try for that.

Overcompensating by forcing try to be in so many places really illustrates the missing of a point I've been making, that the exact control flow doesn't matter at all in so many cases: if there's no mutation, or if the mutation is only of local variables whose lifetime will end when the function throws, or if the mutation is only of local variables of some caller whose lifetime will end when it throws, or… the list goes on (see my original posting). In fact, the idea that you “can see the control flow” is an illusion in all these cases: what you think you're nailing down by making the control flow visible can easily be scrambled by an optimizer without observable effect on the program's meaning.

This failure of the C++/Java model is one of the reasons that C++ exception safe programming model is such a binary (all or nothing) thing,

I have no idea what you might mean by that, honestly.

…the back-pressure on "exception handling" logic is intentional, and is one of the things that is intended to help reduce the number of pervasively throwing methods in APIs.

By “back pressure” I think you are referring to the idea that writing try is odious and the theory that therefore people will try to avoid creating APIs that throw. The premise here is that the language should have this error-handling feature but somehow, at the same time, we need to make it painful because we want to discourage its use.

Well, I don't buy it as a language design strategy. First, it's punitive in a way that's inconsistent with the character of the rest of the language—thank goodness we haven't taken this kind of approach elsewhere, or Swift would be much less enjoyable to use. Second, I don't buy the idea that “oh but the caller will have to try, so I'd better not” ever enters the thought process of an API designer deciding whether or how to report an error. The one thing that will come up is, “the caller is almost sure to want to handle the failure right there, rather than reporting it up the chain”—typical for things like the lowest-level networking operations, which the caller is likely to retry. But again, that disincentive base is covered by the fact that the caller will have to catch, which involves more ceremony than simply checking for nil or looking at a result enum. Last of all, the thought of one try is simply not painful enough to exert any significant “back-pressure.” Remember, I'm not bringing this up because writing try is so horrible for the programmer, but because of what it does to the language, its source code base, and its community of users in aggregate, when it happens over and over in places where it can't make a difference.

Your characterization of marking being a historical artifact (whose "ship has sailed") isn't really fair IMO: many of us are very happy with it for the majority case, and believe that the original Swift 2.0 design decisions have worked out well in practice.

? I never said it was a historical artifact, and I don't understand how fairness comes into it. I do sincerely apologize if I've somehow offended, but when I say “that ship has sailed” I'm not saying anything about marking; I was talking about some of my earlier proposals. I'm merely saying it might have been viable to consider them once upon a time, but the language is too mature at this point to take such a significant turn.

I also personally believe that async marking is a promising (but unproven) direction to eliminate a wide range of deadlock conditions in concurrent programs when applied to the actors model. I'm not aware of any other model that achieves the same thing.

Interesting; I'd like to hear more about that in detail, if you don't mind. It does seem at odds with some of my understanding, though: AFAIK the proposers have not declared an intention to change actors from the unconditionally re-entrant model originally pitched, and IIUC that provably eliminates deadlocks.

You make a good point about the separation of API and implementation. I guess we have no other precedent for a choice like that, so it would be hard to justify.

That said, I agree that you're on to something here and I agree with you that async will exacerbate an existing issue. There are a couple of ways to address this. One is to reduce the keyword soup by introducing a keyword that combines try and await into one keyword (similarly throws and async ) - but I am convinced that we should land the base async proposal and gain usage experience with it before layering on syntactic sugar.

I don't think that scales. What happens when we add an impure effect (or whatever the next effect dimension is)?

That other side of this is the existing point that you're observing where we have existing use cases with try that are so unnecessarily verbose that they obfuscate the logic they contain. To me, I look towards solutions that locally scope the "suppress try " behavior that you're seeking: while you pick some big cases where it appears to be aligned with functions, often this is aligned with regions of functions that are implemented in terms of throwing (and also, in the future, async logic). That said, the whole scope of the declaration isn't necessarily implicated into this.

No, not necessarily, but that's been the problem with the design approach to try all along. Because there are occasional places where being alerted to the source of error propagation can be helpful, we've ignored the broad fact that in the vast majority of cases, it is irrelevant. And I maintain this is not good for programmers. If you look at what's happened with your rewritten encode example below, it gives the impression that it's somehow significant that no error can propagate from the first statement, but AFAICT there is no world in which that helps anyone think about the semantics of this function, any more than putting a non-throwing let inside the try do {...} block would make it worse. I respect your inclination to do something more tightly scoped and conservative, but I hope I've explained why I have the opposite inclination.

OK, I appreciate your willingness to consider the possibilities. But let's compare that with the code you'd write today:

func encode(to encoder: Encoder) throws {
  var output = encoder.unkeyedContainer()
  try output.encode(self.a)
  try output.encode(self.b)
  try output.encode(self.c)
}

I think you'll agree the extra level of nesting in your example is a significant syntactic cost, which makes it hard to argue that there's much improvement.

But if you'll allow me to run with your idea, I think there are two things we can do to improve it and that will give us both what we want:

  1. Eliminate the need for do and allow try { ... } to mark an entire block as throwing.

    func encode(to encoder: Encoder) throws 
    {
      var output = encoder.unkeyedContainer()
      try {
        output.encode(self.a)
        output.encode(self.b)
        output.encode(self.c)
      }
    }
    
  2. Allow that at the top level of the function:

    func encode(to encoder: Encoder) throws 
    try {
      var output = encoder.unkeyedContainer()
      output.encode(self.a)
      output.encode(self.b)
      output.encode(self.c)
    }
    

Thanks for engaging,
Dave

6 Likes

I know this is just my personal experience, but I’ve found the try keyword immensely useful in Swift, especially when reading code, like during code review. Errors and error handling are difficult to get right. Having those places that can throw pointed out clearly forces the reader to think about error handling.

I can’t remember the number of times seeing that try when re-reading my code or reviewing someone else’s code triggered a conversations on error handling, and often resulting in improvements to the code.

Asynchronous code is hard to get right and having those places in the code that can cause a suspension point clearly visible seems like it would have a similar impact for me.

29 Likes

But that's only information available if you look at the declaration and not the call site, as we tend to favor in Swift API design. The language assists us in knowing that method throws by requiring the try keyword.

As David Hart mentions, it's extremely useful in code reviews or returning to code not seen in awhile, that these keywords assist you in knowing that exceptions can happen and failure states need to be taken into account.

The language already has mechanisms in place to indicate that you probably don't care about the error by using try? or try! in many cases - and after you get used to it, it's like they're not there - but they are to still give an indication that there is an error state and it needs to be decided how to handle it.

5 Likes

Yes; but think about why. Having to stop moving forward because a postcondition can't be satisfied (a.k.a. dealing with an error) is difficult because you can easily make temporary invariant breakage permanent. You have to make sure you undo the breakage or make it irrelevant. There are no other hard problems in error handling.

By scattering try everywhere that an error can propagate, instead of only where invariants are broken, we take the focus off what matters in analyzing the correctness of error handling code.

Can you tell the story of one or two of these instances? Did you find a bug because of try that you wouldn't have equally found because of the throws on the function containing the try, or, if the containing function didn't throw, that you wouldn't have found because of a surrounding catch?

Wouldn't it be better if we had an easy way to identify the code where the error-handling didn't need extensive discussion in code review, and we could write that code with less ceremony?

I understand that try and await might “feel good,” but I've seen whole programming communities accept ideas that “feel good” but whose costs in fact outweigh the benefits. I know personally I am gratified by encoding things in the type system and language, and I constantly have to guard against over-investing in mechanisms for rigor and structure when they don't actually pay off. I'm hoping that we're designing the language based on something more principled, here.

6 Likes

As I've said, that base is covered. The call site requires either a catch, or a throws of its own.

2 Likes

The call site does not require throws, the declaration requires throws.

At that point I will have to refer to the greater "context" of the current method's declaration that the method can throw - but I still have no indication where in the method it throws without referring to every calling method's declaration.

func someThrowingFunc() throws {
  // some 100 lines later
  let result = otherMethod() // this call can throw
  // some N lines later
}

At this point, how would you know that this line throws, without knowing that method's declaration, and that your method throws?

How would a new Swift engineer know this, other than the compiler saying "your method has to be marked as throws because 'something' throws in it and isn't handled"

13 Likes

Maybe part of the disagreement here is varying definitions of "call site". Is it the actual line where a throwing function is called, or the enclosing do block, or the entire function?

1 Like

Any UI framework will at some point have the issue of "a user just clicked a button, and the handler for that could take a long time, so how do we write clean code that has decent UX for that long-running operation?" One approach to that is something like Combine, but that can get extremely complicated very quickly. In fact, I would argue that many uses of Combine today for UI programming show how hard callback-based coding can get for relatively simple UI use cases.

Async/await provides a different way of accomplishing the same thing. With async/await, even with SwiftUI, you can have a button click handler that a) updates UI state to say "we're waiting on something", b) kicks off asynchronous work, c) receives the results of that asynchronous work, and then d) updates the UI again with the results. All of that can be in one short async function with ordinary-looking control flow, as opposed to a series of complicated callbacks chained together.

I firmly believe that SwiftUI programmers are going to love this feature just as much as UIKit/AppKit developers.

It matters everywhere you have a suspension point.

I can't speak for those designing the actors feature, but personally I don't believe "actors for everything" is a practical goal, if for no other reason than we have to interact with other parts of the system that aren't written in Swift. As with SwiftUI, this is going to be a useful feature even for people adopting actors.

6 Likes

All I'm saying is that it's rare that choosing one of these three ways to look at it makes any difference to your ability to understand the function. In most functions, it simply does not matter which particular statements throw. Furthermore, there's nothing special about the statement boundary that makes it the one special place where I must be forced to write try. With the right library parts, I can write arbitrarily complex functions as a single expression. With method chaining, it doesn't even need to be hard to read. At the limit, we end up with a single try at the beginning of the function.

1 Like

Oh, me too, absolutely! I hope I didn't give a different impression. I'm just talking about what the rules for the await marking should be.

To me that seems to be an obvious overstatement. If I'm doing non-realtime pure functional async programming, for example, the exact suspension points are an implementation detail, aren't they? If not, why not?

I think the proposers have made it pretty clear that it's a non-goal. But I still don't know how actor isolation is supposed to play out for UI programmers, or if it's going to have significant implications for the seriousness of this issue.

I also find it immensely useful to be able to glance through a long function and easily pick out which thing(s) can throw. I don’t have a strong opinion about try but if we (partially?) got rid of it I do feel strongly that it should be replaced with some automatic syntax-highlighting-ish indicator (bold text? :firecracker: emoji? whatever?)

+1. I don't have time to engage in detailed discussion of this topic, but I find these control flow markers really, really useful to see when reading code.

IMO the two are completely orthogonal issues. I think the importance of maintaining invariants should imply that marking expressions that may throw is not useful.

3 Likes

You got the answer to this already, so I don't understand why you're asking again.

Suspension points are places where broken invariants (to use your terminology) are made visible to (and susceptible to the interference of) the outside world. Conversely, we use synchronous code to safely corral invariant breakage (and susceptible code sequences) to avoid it being exposed to the outside world.

That's not an implementation detail.

3 Likes

In that case they may indicate places where you could consider doing additional work on the original thread or queuing up more asynchronous work while waiting for an asynchronous completion. There are examples of that in the structured concurrency proposal.

If the asynchronous calls (the suspension points) were not called out then it wouldn't be apparent to the person writing or reading the code how that function would behave at runtime from a performance perspective. Once you add in the obligatory await calls it may become obvious that the function is written in a way that is unnecessarily slow.

With async/await plus the structured concurrency proposal you can do relatively simple refactoring to deliberately interleave work and improve performance without having to introduce a lot of complexity. But if you take out the await keyword then it obscures what's actually happening and cause people to write bad code (either buggy or poorly performing) because they literally can't see how the code they're reading/writing actually behaves at runtime. That's why even though the compiler certainly could work without the await keyword it should still be mandatory at every suspension point. Our primary audience in writing code is for other humans (including ourselves), not the compiler.

4 Likes