SE-0395: Observability

Unfortunately this proposal doesn't address the concerns raised in the last pitch. I'll try and summarise those concerns once again here.

Asynchronous Sequences are unsuitable for same-actor observation

The biggest issue for me is the use of asynchronous sequences to deliver observation events. While an asynchronous sequence is a suitable mechanism for inter-actor observation, it seems unsuitable for intra-actor observation.

In other words, while the asynchronous sequence APIs would be great for observing events occurring on different actors, using them to observe events on a graph of objects that exist on the same actor (say the @MainActor) will quickly see the manifestation of race conditions, state desynchronisation, and the above mentioned 'flickery UIs'.

Some of the ways that this might manifest:

And as using 'Observability' to observe events on a graph of @MainActor objects seems like it would be an expected and typical use case, this seems like an unacceptable limitation.

This is because, no matter how you roll it, the use of an asynchronous sequence imposes at minimum a same-actor hop between the dispatch of an observation event and the delivery of that observation event – and often includes a round-trip via the shared thread pool. (In fact, there's a WWDC talk recommending against exactly this sort of frequent actor-hoping somewhere.)

One argument in the prior pitch was that 'that's what the ObservatioinTracking mechanism is for'. But the 'ObservationTracking' mechanism has an API that isn't suitable for general purpose observability. It's been custom designed to trigger view updates for consumption by a UI framework such as SwiftUI. It's not the API most programmers will be reaching for to do their own observations. So internally, SwiftUI has the API they need, but externally we're left fighting with an unsuitable API.

And so it's a new burden that we'll be placing on programmers. That of ensuring they've considered the complexity of concurrent programming even when performing same actor updates. I think that this will be a surprise and unintuitive to many. It's an unnecessary burden.

Simplifying and exaggerating this to illustrate my point: it's as if we've decided that synchronous functions are unnecessary as we can do everything a synchronous function can with an asynchronous function.

We can't.

16 Likes

I concur. I admire the work put in producing sound and correct async apis - I guess we all do. At the same time, I can't help but thinking that synchronous code is simpler, and thus support for synchronous code should be fostered by the stdlib when it is possible.

This is especially true considering that all transitions from sync to async force developers to deal with the delay (the initial hop to the async world). To take an example based on user interface, all async-only apis force the developer to display a waiting screen - even if it is displayed very shortly. This is more work, more opportunities for visual glitches, more opportunities for bugs, all induced by a "waiting state" that no one asked for in the first place. More work is not cool. And visual glitches are egregious. Isn't it a pity that people need to avoid the async apis that have been so carefully crafted? Isn't it a pity that dutifully marking important values and methods as @MainActor doesn't bring benefits?

EDIT: My second paragraph is not quite related to this proposal, it's just a general discomfort I have. The first paragraph has several related posts in this thread.

12 Likes

Is there any for us to reason to think the unintuitive intra-actor hop can/will be optimised out ~before WWDC~ anytime soon and alleviate some of the determinism problems with this proposal?

When can we expect a new 5.9 toolchain?

The memberwise initializer could (but currently does not do this, and I think it might be reasonable to permit it) only synthesize that if it is not present. That way you could write the initializer yourself. However that would require folks to initialize the _$observationStorage themselves.

1 Like

As I understand it this is just a limitation of the current implementation, not a fundamental requirement of Swift's async model. The compiler could be enhanced to see that the actors in both cases are the same and elide the hop. If it could be guaranteed in all situations, would that provide the behavior you need?

1 Like

The short answer is yes. If, as you say, it could be guaranteed in all situations, then I think that would be reasonable.

The longer answer is that I'm sceptical we'll reach that point.

There's already some work being taken on around custom executors, and from what I've seen even custom executors will be unable to elide that initial hop.

There's also the issue that a Task's closure will need to execute synchronously, or at least within the current event loop cycle to ensure that no events are lost between the Task being created and then run.

The other concern is one of ergonomics. If we do indeed make it to that point it would be a shame if it required a bunch of syntax for what should, arguably, be the simplest use case.

Then there's the question of how useful the await annotation becomes. Is this a potential suspension point or not?

Finally, I would question whether or not we're shoe-horning the async mechanisms into areas where they may not be the best fit. These are great tools, but Swift concurrency and async sequences are supposed to make things simpler but if we're creating a bunch of syntax to control actor flow, how much simpler have we actually ended up making things?

To a man with a hammer, everything looks like a nail

But in principal, yes.

6 Likes

I’ve been following this proposal since it’s early stages and although I think is very good I’m still concerned with the ‘flickering UIs’ problem that others have mentioned plenty of times and that I can’t explain any better.

I’m afraid that if this is shipped without solving that concern applications with bad UX will start proliferating leaving Swift in a bad place. We have to remember not everybody uses SwiftUI (which if I read correctly will use the api that doesn’t have this issue?). Furthermore even if a ‘future direction’ can solve this I find that a very bad situation because those hypothetical apps with poor UX will still be out and developers won’t be able to fix it for years because we are tied to OS versions (unless the solution can be back deployed which we can’t assume).

On top if this my concern is that I’m not sure if this is solvable by this proposal since as mentioned above is an intrinsic part if Swift Concurrency, not specific to Observation. So unless 1) swift concurrency in general offers a solution for this situations or 2) we get an observation api that is not and async sequence; I’m not sure this proposal gets a big yes from me.

On another topic, I don’t find the fact that you have to mark dependencies for computed properties manually any good. Developers will expect to work out of the box and needing the boilerplate is unfortunate.

I hope we can get this solved because the rest of the proposal looks AWESOME! Thanks for putting the time and effort on this, to the author and all the community giving feedback. :heart:

12 Likes

I feel like I inadvertently released a flickery UI monster, especially because (it turned out) my concern wasn't the one that others picked up on. I see three different issues being raised, and somewhat conflated.

  1. The first is the question of whether executor hops in delivering observations are regarded as harmful to performance. This seems to be the main concern of @tcldr's comments. This isn't a concern that motivates me, since "solving" it smells a little bit like premature optimization, but I agree it's an issue worth keeping an eye on.

  2. @gwendal.roue raised the issue of transitioning from synchronous to asynchronous code, which can producing ugly results, although it isn't a performance issue. I don't think SE-0395 creates this problem, or even makes it worse. I do think this is a problem which can be solved at a higher level.

  3. The issue I intended to raise was forcing a UI to display successive values, even when the value has already changed by time a change reaches the display. This problem is arguable worse when all the code is synchronous.

Problem #3 arises when an observer starts processing a change, and the value changes again during the processing. Since the observer is committed to some synchronous update, it has to finish with that before getting around to displaying the correct thing.

The point I was trying to make is that for an "observation" system, being faithful to the original mutation events can be undesirable, and prompt propagation of coalesced changes can be very desirable. This differs from a "publication" system, where the identity and integrity of the original events is much more important.

1 Like

I borrowed one of your terms (Flickery UIs) as it succinctly conveys a symptom which we can, apparently, expect from both our issues.

Performance is just one of the issues that concerns me. The main issue for me is that the asynchronous sequences will deliver stale state on same actor observations making it unnecessarily hard to reason about the current state of the system. ‘Flickery UI’s’ will also be caused due to the event being delivered after the current event loop cycle, missing the current animation transaction deadline.

I agree with you.

I have written a thorough reply in Some suggestions for Swift Observation.

2 Likes

Please see Some suggestions for Swift Observation for a thorough reply.

I wanted to share some further thoughts that I originally wrote up in a separate thread to hopefully clarify my position:

In the context of SE-0395, it seems to me that, the intended primary purpose seems to be 'fulfilling the needs of GUI apps' as that was the context given by the proposal – but perhaps the aim was more general.

For me, I would like a cohesive solution, the parts of which can be used together seamlessly; using SE-0395's proposed ObservationTracking mechanism, should work well when also using the ObservedChanges/ObservedValues mechanism.

One example of what I mean by this is that: if I have some View directly observing a property on each of ObservableA and ObservableB using the ObservationTracking mechanism, then ObservableA also directly observes some property on ObservableB (a dependent property) using whatever the ObservedChanges/ObservedValues mechanism becomes, they all need to be able to synchronise in the current event loop cycle.

As SE-0395 is currently proposed, when ObservableB is updated, triggering both an observation event on the View and ObservableA, the View will receive its event in the current event loop cycle, while ObservableA will receive its update (via an async sequence) in some future event loop cycle. So ultimately the View will receive updates for this single update across multiple event loop cycles.

Cue 'Flickery UI's.

This is what I mean when I'm talking about invariants. (In this context, at least.) I realise that invariants can still be temporarily broken in the current frame/event loop cycle, but I think that's a lot easier to reason about than invariants broken across multiple frames/event loop cycles.

I do wonder if the attempt to combine both 'fulfilling the needs of GUI apps' and inter-actor observation in the same mechanism is perhaps too ambitious. Not quite satisfying either goal. Perhaps focusing the proposal on a more specific goal would lead to a better overall solution. Then, as part of a future direction, it can be considered whether or not the mechanism can be successfully expanded to achieve inter-actor observation without compromising the existing primary purpose.

9 Likes

I've been thinking some more about the problem above and I'm wondering if perhaps I'm missing something obvious here. Here's some code which more or less models the diagram above, but leans on withTracking instead of the async sequence bits:

I have a View (SomeView) observing a property on each of ObservableA and ObservableB.

The property being observed on ObservableB is called directlyObservedProperty and, as the name suggests is being observed directly by the view.

The property being observed on ObservableA is called dependentProperty and it itself is updated via an observation on ObservableB using the ObservationTracking mechanism. Specifically, when indirectlyObservedProperty changes.

In code:


struct SomeView: View {
  
  @StateObject private var observableA = ObservableA()
  
  var body: some View {
    VStack {
      Text("\(observableA.observableB.directlyObservedProperty)")
      Text("\(observableA.dependentProperty)")
    }
  }
}

@Observable final class ObservableA {
  var dependentProperty = "Some Value"
  let observableB = ObservableB()
  init() {
    resetObservationOnB()
  }
  
  func resetObservationOnB() {
    ObservationTracking.withTracking {
      self.dependentProperty = "\(observableB.indirectlyObservedProperty). Woohoo!"
    } onChange: { [weak self] in
      self?.resetObservationOnB()
    }
  }
}

@Observable final class ObservableB {
  var directlyObservedProperty = "Some Value"
  var indirectlyObservedProperty = "Some Value"
  func updateBothProps() {
    self.directlyObservedProperty = "I'm changing"
    self.indirectlyObservedProperty = "And so am I!"
  }
}

My questions is, if you'll be gracious enough to humour me @Philippe_Hausler:

Is this a supported use of the withTracking mechanism? Can I assume that when updateBothProps() is called on ObservableB, the update to the directlyObservedProperty and dependentProperty will occur in the same view update/transaction? Do you see any issues arising from using this kind of chained/indirect observation pattern?

In that example, would it be possible to write a unit test for observable A without a handful of arbitrary waits?

3 Likes

I have a feeling that is a bit more complicated than it needs to be:

struct SomeView: View {
  @State private var observableA = ObservableA() // @StateObject is for `ObservableObject`

  var body: some View {
    VStack {
      Text("\(observableA.observableB.directlyObservedProperty)")
      Text("\(observableA.dependentProperty)")
    }
  }
}

@Observable final class ObservableA {
  var dependentProperty: String { observableB.indirectlyObservedProperty }
  let observableB = ObservableB()
}

@Observable final class ObservableB {
  var directlyObservedProperty = "Some Value"
  var indirectlyObservedProperty = "Some Value"
  func updateBothProps() {
    self.directlyObservedProperty = "I'm changing"
    self.indirectlyObservedProperty = "And so am I!"
  }
}

So lets suss that apart a bit:

Consider when the body is executed for SomeView. There are a few property accesses that happen. First the \ObservableA.observableB happens then \ObservableB.directlyObservedProperty, and then \ObservableA.dependentProperty but executing that ALSO hits \ObservableB.indirectlyObservedProperty.

The tracking then is installed as observing those things. When the first one changes out of that set of key paths and instances the view is flagged as needing update and the next pass of the rendering engine comes along and re-evaluates the view accordingly.

That means that when updateBothProps is invoked two of the properties given in the list of observations change (the first one invalidates the view).

3 Likes

That's interesting.

I was overcomplicating for the sake of the example – this isn't the way i'd structure something this simple – but I must say I think my reservations regarding intra-actor communication are beginning to evaporate as the capabilities of the withTracking mechanism become clearer to me.

I'm still interested in the question of whether chained observations as originally described could work, though. I can imagine there could be situations where this would be useful. Perhaps there's some other action that needs to be taken when indirectlyObservedProperty is updated. Would a chained observation as described work?

To be clear; yes that could work if you needed it to.
Perhaps with some slight modifications - particularly it would probably need to re-evaluate things on the next run loop tick in there somewhere.

3 Likes

Well, in that case... consider me converted!

Personally, I think I'd still be cautious about using the ObservedChanges/ObservedValues mechanism for same actor communication. But as I can no longer think of any reason why you'd use ObservedChanges/ObservedValues over the withTracking mechanism for same actor communication – it's a non issue. And, as I say above, for inter-actor observation I think the ObservedChanges/ObservedValues mechanism will suit me well.

Can't wait to take it for a test drive.

2 Likes

Apologies, just noticed the edit on your post there.

I think that's what my concern would be – if an observation became spread across multiple run loop cycles – that's going to cause the Flickery UIs. Granted, the use-cases might be considered 'advanced'. But I can imagine withTracking being useful outside of just the UI render tree.

It would be really nice if we could get chained usage synchronised!

1 Like