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

Allow all termination events to be recursively sent. #137

Merged
merged 21 commits into from
Dec 15, 2016
Merged

Conversation

andersio
Copy link
Member

@andersio andersio commented Nov 30, 2016

Fixes #135 and #136.

As described in #136, and demonstrated by the test case:

  1. A strong release atomically drops the refCount to 0. But deinitialization does not start right away.
  2. A weak retain - the binding's observer closure in this case - manages to increment the refCount before the strong release flags the object as deallocating.
  3. The deinitialization is thus delayed until exiting the scope of the observer closure, i.e. within the sendLock.

In the context of binding:

signal
    .take(during: target.lifetime)
    .observeValues { [weak target] value in
        target?.consume(value)
    }
             Thread 1             |            Thread 2            
----------------------------------|--------------------------------
  1. Received new `value` event.  |
                                  |
  2. Acquired `sendLock`.         |
                                  |
                                  | 3. `swift_release` on `target`.
                                  |    Decrement refCount to 0.
  4. `swift_weakLoadStrong`       |
     on `target`.                 |
     Increment refCount to 1.     |
                                  |     
  5a. Invoke `consume`.           | 5b. CAS to mark deallocating
                                  |     flag failed
                                  |
  6. `swift_release`              | (Deinit of `target` is supposed
     Decrement refCount to 0.     |  to run on this thread, but
                                  |  Thread 1 has stolen it due to
  7. CAS to mark deallocating     |  the weak retain having won the
     Flag succeeded.              |  race.)
                                  |
  8. `target` starts deinit.      |
     `Lifetime.init` sends        |
     `completed`.                 |
                                  |
  9. Upstream `take(until:)`      |
     gets `completed`, and forward|
     it to the downstream.        |
                                  |
  10. Try to acquire `sendLock`,  |
      but it is held by (2).      |
      Deadlock.                   |

So the only solution - except using a recursive lock or going asynchronous - is to handle completed like interrupted. failed do not need this behavior (yet?), but it is more convenient to implement it for all termination events. Can be reverted if this feels an unpleasant change.

@andersio
Copy link
Member Author

andersio commented Nov 30, 2016

It is probably missing a memory barrier for it to work on ARMv8. Gonna run tests on an iPhone to ensure things are right.

@andersio
Copy link
Member Author

andersio commented Nov 30, 2016

The takeaway is that up till now Swift has no memory model. So whatever synchronization we do, including pthread_mutex_*, is depending on the undefined behavior of the compiler. Well, unless the entire routine from acquiring to releasing the lock is written in C/C++, e.g. libdispatch.

(┛✧Д✧))┛彡┻━┻

Though in practice, the Swift compiler does not appear to reorder stuff against C function calls, and more importantly consecutive writes under -Owholemodule.

The rant above was a note on the previous attempt with explicit memory fences between terminationEvent and terminationState.


In the latest commit 4ea7537, these variables are packed as an immutable object before being assigned to terminationState. The states in this form are this easier to deal with in the threading design, since it is just an 8-byte reference that can now be atomically synchronized against sendLock.

If it was unpacked, acquire/release fences and trust in compiler (see the rant) would be critical to ensure terminationEvent is always written before terminationState at runtime, so that concurrent readers would see both values together.

@andersio andersio force-pushed the deinit-race-fix branch 3 times, most recently from 610deed to 753fed3 Compare November 30, 2016 19:55
@@ -265,6 +265,39 @@ class PropertySpec: QuickSpec {
property = nil
expect(isEnded) == true
}

it("should not deadlock") {
Copy link
Member

Choose a reason for hiding this comment

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

Nice test

// the disposal would be delegated to the current sender, or
// occasionally one of the senders waiting on `sendLock`.
if let state = signal.state.swap(nil) {
// Writes to `interruptedState` are implicitly synchronized. So we do
terminationState = SignalTerminationState(state: state,
event: event)
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this really need to be wrapped onto a new line?

Copy link
Member Author

Choose a reason for hiding this comment

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

Nope. 😹

//
// `interrupted`
// It is kind of a special snowflake, as it can inadvertently be sent by
// downstream consumers as part of the `SignalProducer` mechanics.
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 can reword this without the "special snowflake" comment now.

@andersio
Copy link
Member Author

andersio commented Dec 1, 2016

It seems the Swift compiler in Linux has started to impose some resilience rules.

@andersio andersio force-pushed the deinit-race-fix branch 3 times, most recently from d47e3c9 to 60bbd0c Compare December 1, 2016 23:53
@andersio
Copy link
Member Author

andersio commented Dec 1, 2016

In 1a3cb92:

  1. Two pieces of mutable states now become one. Swift enum is amazing. 🍰
  2. Having states as stored properties seems a little bit faster, probably due to locality and/or less ARC calls.

Might need a little bikeshedding on the naming of the enum cases though. Or maybe we should name the enum Signal🚦 (Signal.🚦 in 3.1)` instead, since Swift is an opinionated emoji language, uhuh? 👻

@mdiep mdiep added this to the 1.0 milestone Dec 2, 2016
case yellow(SignalTerminationSnapshot<Value, Error>)

/// The `Signal` has terminated.
case red
Copy link
Contributor

Choose a reason for hiding this comment

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

Would alive/terminating/terminated make more sense here?

Copy link
Member Author

Choose a reason for hiding this comment

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

Renamed.

private var status: SignalStatus<Value, Error>

/// Used to ensure that events are serialized during delivery to observers.
private let sendLock: NSLock
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks like it could remain inside the init.

Copy link
Member Author

@andersio andersio Dec 2, 2016

Choose a reason for hiding this comment

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

Having states as stored properties seems a little bit faster, probably due to improved locality and/or less ARC calls.

Edit: The difference is consistently ranging from 0% (property binding) to 9% (Signal synthetic) worse for different test cases in my benchmark runs.

// The generator disposable needs to be disposed of only if a `green` status
// is observed here, since `red` would mean it has already been disposed of,
// and `yellow` is impossible to be observed here.
if case .green = status {
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks like it could still use the state.swap(nil), in which case status could remain part of the init.

Copy link
Member Author

@andersio andersio Dec 2, 2016

Choose a reason for hiding this comment

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

Ditto.

Copy link
Member Author

Choose a reason for hiding this comment

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

It is now swapped out before disposal in 63a7e0d, so no check is needed.

@andersio andersio added the bug label Dec 3, 2016
@andersio andersio force-pushed the deinit-race-fix branch 3 times, most recently from 5bde9ce to 240b71b Compare December 5, 2016 01:58
@andersio
Copy link
Member Author

andersio commented Dec 5, 2016

Just in case these would be missed:

On Read-Copy-Update and the atomicity of reads to Signal.state.
#137 (comment)

On SignalState being laid out as a tagged pointer.
#137 (comment)

On why the wrapping of SignalState helps.
#137 (comment)

@andersio
Copy link
Member Author

andersio commented Dec 5, 2016

I have refactored the code a bit. Hope it helps the code explaining itself.

let queue: DispatchQueue

if #available(macOS 10.10, *) {
queue = DispatchQueue.global(qos: .userInitiated)
Copy link
Contributor

@liscio liscio Dec 5, 2016

Choose a reason for hiding this comment

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

I think that if you want to match "high priority" you should consider using the .userInteractive QoS instead. Doubt it really matters in a synthetic benchmark/test like this, but the asymmetry stood out to me. :)

//
// `updateLock` must be acquired.
//
// - Transit from `alive` to `terminating`.
Copy link
Contributor

Choose a reason for hiding this comment

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

s/Transit/Transition/

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Yup. 👍

// `updateLock` must be acquired.
//
// - Extract the snapshot of a terminating signal to deliver the
// termination event, and transit from `terminating` to `terminated`.
Copy link
Contributor

Choose a reason for hiding this comment

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

s/transit/transition/

/// observers.
fileprivate let retaining: Signal<Value, Error>?

fileprivate let associated: U
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we split SignalStateSnapshot into 2 different classes?

private enum SignalState<Value, Error: Swift.Error> {
    case alive(AliveState)
    case terminating(TerminatingState)
    case terminated
}

private final class AliveState {
    let observers: Bag<Signal<Value, Error>.Observer>
    let retaining: Signal<Value, Error>?
}

private final class TerminatingState {
    let observers: Bag<Signal<Value, Error>.Observer>
    let event: Event<Value, Error>
}

AFAICS the terminating state doesn't need retaining and the alive state definitely doesn't need event. This would simplify things a bit, I think.

///
/// - returns:
/// The state snapshot associated with a termination event, or `nil`.
private func shouldReallyTerminate() -> SignalStateSnapshot<Value, Error, Event<Value, Error>>? {
Copy link
Contributor

Choose a reason for hiding this comment

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

This function is only used like this:

if let snapshot = signal.shouldReallyTerminate() {
    for observer in snapshot.observers {
        observer.action(snapshot.associated)
    }
}  

That suggests that the code to notify observers should move into this function.

It probably also makes more sense as a block in init since it's only used there.

/// - note: The `updateLock` would be acquired.
///
/// - returns:
/// The state snapshot associated with a termination event, or `nil`.
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 format the - returns: to match the existing ones?

- returns: The stat snapshot associated…

/// all reference counted, and at most one no-payload case.
private enum SignalState<Value, Error: Swift.Error> {
/// The `Signal` is alive.
case alive(AliveState<Value, Error>)
Copy link
Member Author

@andersio andersio Dec 6, 2016

Choose a reason for hiding this comment

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

These should be less confusing once we have them nested in Swift 3.1, e.g. Signal<U, E>.State.Alive.

@mdiep
Copy link
Contributor

mdiep commented Dec 6, 2016

It looks like this comment was missed. Can you take a look at that?

I'm starting to like the way this looks. ✨

@andersio
Copy link
Member Author

andersio commented Dec 6, 2016

Addressed in 5686227 b55fe6a. ☕️

///
/// As `SignalState` is a packed object reference (a tagged pointer) that is
/// naturally aligned, reads to are guaranteed to be atomic on all supported
/// hardware architectures of Swift (ARM and x86).
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we know that this holds true for Linux as well?

Copy link
Member Author

@andersio andersio Dec 7, 2016

Choose a reason for hiding this comment

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

The Swift ABI defines this, regardless of OSes. You would always have the lowest 2 bits available for packing on 32-bit platforms anyway, due to the 4-byte minimum alignment.

Copy link
Member Author

@andersio andersio Dec 7, 2016

Choose a reason for hiding this comment

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

2 bits are enough for 4 with-reference cases, or 3 with-reference cases plus 2^30 (or 2^30? not quite sure) no-payload cases.

@mdiep
Copy link
Contributor

mdiep commented Dec 7, 2016

This looks good to me. But since it's a very critical section of code, I'd like to get another set of eyes on it.

@mdiep
Copy link
Contributor

mdiep commented Dec 15, 2016

Doesn't look like we'll get another review, so let's 🚢.

@mdiep mdiep merged commit 0f2fc54 into master Dec 15, 2016
@mdiep mdiep deleted the deinit-race-fix branch December 15, 2016 01:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants