Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revisit changes to the Signal lifetime semantics to improve tooling interoperability. #355

Merged
merged 5 commits into from
May 23, 2017

Conversation

andersio
Copy link
Member

@andersio andersio commented Apr 30, 2017

Rationale

Cease the self reference in AliveState which causes the Signal and all its retained objects being reported as leaks, so that ReactiveSwift would work better with the memory debuggers.

Details

This PR adapts the original design in ReactiveCocoa/ReactiveCocoa#2959.

To recap, the rule of automatic disposal is having no observer and having no external retain. This PR does not change this principle, but makes a few changes in the implementation:

  1. A signal is now broken into a public shell Signal and an internal core Signal.Core.
    The shell is what the public users retain and interact with. The shell is neither strongly or weakly referenced by the core, the generator disposable or any disposable created.

  2. The generator observer now holds a strong reference to Signal.Core, and pipes events to Signal.Core.send(_:). Previously, it holds a weak reference to Signal.

Here is the ownership graph from the perspective of an operator. Note that there is no circular strong reference in the graph as we had before.

------------               --------------                --------
|          |               | endObserve |                |      |
|          | <~~ weak ~~~  | disposable | <== strong === |      |
|          |               --------------                |      | ... downstream(s)
|          |                ------------                 |      |
| upstream | === strong ==> | Observer |  === strong ==> | Core |
------------                ------------                 --------
                            ------------------              ^^
                            | Signal (shell) | === strong ==//
                            ------------------

These changes consequently lead to a few boundary cases that is not source compatible with ReactiveSwift 1.0:

  1. A nil weak reference to a signal no longer means that the signal has been disposed of. on(disposed:) would become the only mean to assert this.

  2. If the generator observer deinitialises, it now triggers the automatic disposal since no event can be sent to the Signal from this point of time. Previously, it has no side-effect.

    In other words, the rule of automatic disposal is now expanded to be (1) having no observer and having no external retain; OR (2) having the generator observer deinitialized".

These are not expected to be of huge impact.

Implementation

The threading design and the logic of event delivery is generally untouched.

  1. The implementation in Signal is moved to Signal.Core. Signal now only forwards calls to Signal.Core.

  2. Signal.Core.init now disposed of the generator disposable immediately, if the signal is terminated within the generator. This was the correct and the original behavior, but had not been covered by the test cases and had been removed by previous changes. (Bugfix)

  3. tryTerminate is now promoted to be Signal.Core.tryCommitTermination.

  4. Signal.Core.tryTerminateSilently is introduced for automatic disposal. Calling it guarantees the Signal to be eventually terminated safely, but the termination may not happen immediately if there is an on-going event delivery.

  5. Signal.Core.shellDidDeinitialize is introduced for the Signal shell to inform the Signal core of its deinitialization.

Alternatives considered

  1. Replace the explicit self reference with manual retains and releases via Unmanaged. This did not work as expected — the memory debuggers still report leaks.

  2. Status quo, which was the alternative of this more complicated design in [5.0] Changes to the lifetime semantic of Signal. ReactiveCocoa#2959.

Testing

This has been tested with a very barebone single UITableView project that had a tremendous amount of false positives from the memory debugger when compiled with master. With the PR, the memory debugger reports no leak.

Edit: This has also been tested with Carthage 0.22, and all test specs have passed.

Potential benefits

The design might enable an optimisation in property composition to dynamically collapse intermediate signals. Specifically in #340, it might allow the observers of the composed property's relay signal to be collapsed into the upstream signal when it is not externally retained. This is not possible at this moment due to the self reference and the semantic of isKnownUniquelyReferenced.

@andersio andersio force-pushed the signal-self-retain branch 2 times, most recently from 5beb43f to df7f83c Compare April 30, 2017 12:02
expect(disposable.isDisposed) == true
}

it("should dispose of the returned disposable upon interrupted") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failure

@mdiep
Copy link
Contributor

mdiep commented May 1, 2017

A nil weak reference to a signal no longer means that the signal has been disposed of. on(disposed:) would become the only mean to assert this.

I'll have to think about the impact of this.

If the generator observer deinitialises, it now triggers the automatic disposal since no event can be sent to the Signal from this point of time. Previously, it has no side-effect.

In other words, the rule of automatic disposal is now expanded to be (1) having no observer and having no external retain; OR (2) having the generator observer deinitialized".

Can you explain this a bit more? I haven't looked over the code, but I'm not sure when this would happen.

@andersio
Copy link
Member Author

andersio commented May 1, 2017

The rule of active observers keeping the Signal alive remains unchanged, but the means to achieve it has changed:

  1. In 1.x, it is done via the self reference in AliveState. The self reference is cleared when the observer bag is emptied, which triggers the deinitialization that leads to the disposal of the generator disposable.

    • Bag emptied => Clear last strong +1 => Signal deinit => Disposal
  2. In this PR, the self reference is replaced by the strong +1 of the generator observer. It is assumed to be retained by the owner or the upstream. For derived signals, it is also assumed that the generator disposable would be responsible of removing the generator observer from the upstreams (as it is used to be).

    The Signal.Core disposes of its generator disposable when shellDeinitialized is asserted and the observer bag is emptied. This would indirectly leads the upstream, if any, to remove the generator observer from its bag. As the generator observer eventually deinitializes, the last strong +1 on the Signal.Core ceases.

    • Bag emptied & Shell deinitialized => Disposal => Last strong +1 gone => Signal.Core deinit

In other words, the rule of automatic disposal is now expanded to be (1) having no observer and having no external retain; OR (2) having the generator observer deinitialized".

Can you explain this a bit more? I haven't looked over the code, but I'm not sure when this would happen.

This change does not affect operators, since they have their generator observer retained by the upstream signals. Only the explicit, non-composition uses of Signal.init and Signal.pipe would be affected.

Say we have a pipe and we have observed it:

func scope() {
    let (signal, observer) = Signal.pipe()
    signal.observe { _ in }
}

In 1.x, the self reference asserted by the sole active observer would keep the Signal alive.

With this PR, the Signal.Core would lose all both of its retains as scope() returns, so the signal would dispose of itself. This is a new but IMO logical behaviour, given that the missing generator observer means no event can be further sent statically.

We may consider emitting interrupted since there are still active observers in this case. But for now it is silent.

@mdiep
Copy link
Contributor

mdiep commented May 16, 2017

Can you add the external observers to your diagram above? I think that's the tricky piece to think about.

Would it work to replace the self reference with an actual strong reference from the observer to the signal? That seems like it would maintain the existing semantics (which are conceptually simpler IMO) but also fix the tooling issues.

A nil weak reference to a signal no longer means that the signal has been disposed of. on(disposed:) would become the only mean to assert this.

I think it's preferable to not have this be the case, which is why I ask. But if this the only way to improve tooling interop, then I think it's worth it.

@andersio
Copy link
Member Author

andersio commented May 17, 2017

Would it work to replace the self reference with an actual strong reference from the observer to the signal? That seems like it would maintain the existing semantics (which are conceptually simpler IMO) but also fix the tooling issues.

It does not solve the issue of deliberate retain cycle being caught with the current implementation.

Specifically, if an observer now holds a strong reference to the Signal, it forms a legit retain cycle. We cannot break the cycle with a weak reference from the Signal to the observers, as the Signal retaining observers is pragmatic.

 ------------               --------------                --------
 |          |               | endObserve |                |      |
 |          | <~~ weak ~~~  | disposable | <== strong === |      |
 |          |               --------------                |      | ... downstream(s)
 | Upstream |                ------------                 |      |
 | Core     | === strong ==> | Observer |  === strong ==> | Core |
 ------------ ===\\          ------------                 -------- ===\\
                  \\         ------------------              ^^        \\
                   \\        | Signal (shell) | === strong ==//         \\
                    \\       ------------------                          \\
                    || strong                                            || strong
                    vv                                                   vv
            -------------------                                 -------------------
            | Other observers |                                 | Other observers |
            -------------------                                 -------------------

@andersio
Copy link
Member Author

andersio commented May 17, 2017

A side note: I have tried many tricks, hoping to trick the memory debugger not to recognise the retain cycle. But none of these works.

  1. A Unmanaged pointer.

  2. A UnsafeMutablePointer pointer.

  3. An OpaquePointer.

  4. Storing a UnsafeMutablePointer as a raw Int.

  5. Storing a UnsafeMutablePointer as a raw Int that is right shifted by 1 bit.
    This does "break" the retain cycle in the debugger, but the debugger still considers it a leak.

Copy link
Contributor

@mdiep mdiep left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finally wrapped my head around this.

A few requests that I think will improve clarity, but overall the approach seems 👍.

signal.state = .terminated
signal.updateLock.unlock()
/// Used to indicate if the `Signal` shell has deinitialized.
private var shellDeinitialized: Bool
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should use signal instead of shell here and in the related methods. I think that's clearer.

withExtendedLifetime(observer) {
// Dispose of the `Signal` core if the shell has deinitialized
// and it has no active observer.
result = tryTerminateSilently(if: state.observers.isEmpty && shellDeinitialized)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition (state.observer.isEmpty && shellDeinitialized) should move into tryTerminateSilently. It's the always the same logic. (In the other case, we know that shellDeinitialized is true, but it doesn't hurt to explicitly check it.)

// released to avoid deadlocks.
withExtendedLifetime(observer) {
// Dispose of the `Signal` core if the shell has deinitialized
// and it has no active observer.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you clarify that this comment only starts the disposal?

observer.sendCompleted()
return nil
if result == .shouldDispose {
disposable.dispose()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment here clarifying that this completes the disposal by removing the last strong reference to the core?

return .none
}

defer { _sendLock?.unlock() }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this a bit confusing as written. I think we should go with a more straightforward version that's self explanatory:

if sendLock == nil {
  if !self.sendLock.try() {
    return .none
  }
  defer { self.sendLock.unlock() }
}

/// `tryTerminate` would attempt to acquire the `sendLock`.
///
/// - returns: `.shouldDispose` if the attempt succeeds. `.none` otherwise.
private func tryCommitTermination(acquired sendLock: Lock? = nil) -> OperationResult {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add to after try in this method and the one below? tryToCommitTermination

}
shellDeinitialized = false
disposable = SerialDisposable()
disposable.inner = generator(Observer(self.send))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a comment here to highlight that it's the other +1?

@andersio andersio requested a review from mdiep May 22, 2017 15:58
@mdiep
Copy link
Contributor

mdiep commented May 22, 2017

That test failure looks like it might be legit.

@andersio
Copy link
Member Author

MutableProperty should not deadlock occasionally fails on single-core CI. 🙈

@mdiep mdiep merged commit 1fa0a89 into master May 23, 2017
@mdiep mdiep deleted the signal-self-retain branch May 23, 2017 02:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants