`for await` and `weak self`

Is there any difference?

  1. Unwrap before loop:
Task { @MainActor [weak self] in
    guard let self else { return }
    for await state in engine.stateUpdates {
          self.lastState = state
    }
}
  1. Unwrap inside the loop:
Task { @MainActor [weak self] in
    for await state in engine.stateUpdates {
          guard let self else { return } 
          self.lastState = state
    }
}

Would self be unwrapped every time stateUpdates emits a new value in the second case? It would be the desired behaviour for long-term observations.

Is weak self needed here at all? What's the reference cycle?

1 Like

@Slava_Pestov, this isn't in the question of this topic, tovarishch.

Yes. In (1), self is unwrapped once before observation begins, and from then, a strong reference will keep self alive for the entire duration of the loop; in (2), self is unwrapped on every iteration, breaking out of the loop once self goes away.

Sure, but it makes a difference to the semantics of what you're trying to represent. (1) means "if self is still alive when the Task gets scheduled, keep it alive", whereas (2) means "keep observing so long as self is alive, but once it goes away, stop".

The question is relevant because if (1) were the behavior you wanted to represent, then the weak reference wouldn't be necessary at all. (Or, e.g., if self were guaranteed to be kept alive externally so long as engine.stateUpdates produces updates, then there'd be no difference in behavior, and no point to the weak reference either.)

4 Likes

@itaiferber, thanks for the answer, where could I learn more about how [weak] unwrapping works in various blocks? Is it strictly documented somewhere?

For example, if we remove await and do a sync loop:

someBlock =  { [weak self] in
    for state in states {
        // Is this unwrapped at every iteration or compiler somehow optimizes 
        // the code so unwrap takes place once before the loop?
        guard let self else { return } 
        self.lastState = state
    }
}

Regarding the application cases, in (1) weak might be useful if we want to store the task to cancel it later. I thought it's obvious.

I don't know whether there's official documentation that gets to the level of implementation detail you might be interested in, but the Weak References section of the Automatic Reference Counting page of The Swift Programming Language book might get you part-way.

A few posts from the Weak Let pitch (which has since been accepted and implemented) that may also be instructive in forming a mental model:

tl;dr: weak references are a special type of value that at read time either produces a value if the referenced object still exists, or returns nil; this unwrapping behavior is consistent in all Swift contexts.

To that end, in this example:

the compiler and optimizer are, to the best of my knowledge, not allowed to hoist the self check outside of the loop in the general case, because it changes the semantics of the loop, same as above:

  1. Setting self.lastState may have side effects which would lead self to be deallocated, normally breaking out of the loop early; if self is unwrapped outside of the loop and retained, it may unexpectedly loop over all of states
  2. In the absence of side-effects, states may be an extremely large (or even infinite) sequence of values such that hoisting the check outside the loop might have a significant performance impact (or even turn what is normally a terminating loop into an infinite loop)
  3. The iteration itself may have side effects: types' Sequence and IteratorProtocol conformances can run arbitrary code, such that terminating at different points may have visible side effects

It's possible that, if the optimizer has enough static knowledge about both self and states that it can prove none of these are possible, and therefore the check can be hoisted out, but I'd wager this'd be an expensive and invasive check for little potential win. (And, to the best of my knowledge, optimizations like this are largely implementation detail, and not documented publicly.)

3 Likes

Thanks for the input but again, that 1, 2, 3 points that you brought are not documented and just your reason. I wouldn't trust it right away, even if it's logical. Remember how they told us (out of similar reason) that .map() and .reduce() would be highly optimized and parallelized and now it turns out that it's just a wraps for a loop. Similar here. You might have many arguments why it should work like that but it practice it may be different. I'm very certain at my position to not assume Swift compiler to be super-optimized and wise.

If I'll have a free time I'll establish a test to check the last case.

TADADADA! I was right.

Playground:

import Foundation


class StateConsumer {
    var lastState: Int = 0
    
    var onLastStateChange: () -> ()
    
    init(onLastStateChange: @escaping () -> ()) {
        self.onLastStateChange = onLastStateChange
    }
}

class Holder {
    var stateConsumer: StateConsumer?
    
    init() {
        stateConsumer = StateConsumer { [weak self] in
            self?.stateConsumer = nil
        }
    }
}

let holder = Holder()

weak var weakConsumer = holder.stateConsumer

// Test weak release inside the loop
for state in 0..<10 {
    guard let weakConsumer else {
        print("didn't found weakConsumer - exiting")
        break
    }
    weakConsumer.lastState = state
    print(state)
}
Output
0
1
2
3
4
5
6
7
8
9

If @itaiferber intuition was right, the output should be:

0
didn't found weakConsumer - exiting

I'm too lasy to check the compiled ASM file, wish somebody does that for us. Might also play with compiler optimization levels.

I think your example might be missing something: it never calls StateConsumer.onLastStateChange(), so doesn't trigger self?.stateConsumer = nil and is never released. If you update StateConsumer.lastState to

var lastState: Int = 0 {
    didSet {
        onLastStateChange()
    }
}

you'll get

0
didn't found weakConsumer - exiting

as expected. See here:

1 Like

Oh yes, I forgot it, thanks!

1 Like