-
Notifications
You must be signed in to change notification settings - Fork 432
Improve Signal state management to avoid an ARC race.
#456
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
Conversation
696c4fa to
6c70122
Compare
Sources/Signal.swift
Outdated
| assert(count == -1) | ||
| break | ||
| } | ||
| } while !updaterCount.swap(from: updaterCount.value, to: updaterCount.value + 1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like a poor imitation of a spin/unfair lock. I'd prefer to see us use Atomic like in #453—at least to reestablish correctness. Afterwards we could consider an optimization like this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not a lock, but a custom atomic increment/decrement that is aware of the special -1 case. To some extent it is just a simple ARC clone.
Unlike a spin lock, forward progress is guaranteed even with priority inversion (even if that would mean cache line ping-pong).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd prefer to see us use Atomic like in #453—at least to reestablish correctness. Afterwards we could consider an optimization like this.
To be fair, the Signal core itself is already an Atomic and acts like one for all writes, just with the storage and the lock inlined.
updateState(_:) is just a synonym of modify(_:).
|
Speed stat Hmm, investigating the mysterious bump. |
6c70122 to
e9d3f6c
Compare
e9d3f6c to
1ad2524
Compare
|
It turns out that locking on the hot path with 1ad2524 is less harmful than undoing the inlining of updateLock and the state storage, or the solution I initially pushed.
Kudos to |
|
Since both reads and writes now are locked in all circumstances, the two state box types can be removed. This pushes the overhead down to the current level. 🤦♂️ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great! Just needs an update to the documentation.
| /// Used to indicate if the `Signal` has deinitialized. | ||
| private var hasDeinitialized: Bool | ||
|
|
||
| fileprivate init(_ generator: (Observer) -> Disposable?) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The above documentation needs to be updated to reflect that the lock must be held to read the state.
|
(Fun fact: Swift 4 runs everything slower by ~100ns.) |
Sources/Signal.swift
Outdated
| // | ||
| // Related PR: | ||
| // https://github.com/ReactiveCocoa/ReactiveSwift/pull/112 | ||
| if .shouldDispose == self.tryToCommitTermination() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both the terminal path and the value path run this at the end.
| self.stateLock.lock() | ||
| if result == .none, case .terminating = self.state { | ||
| self.stateLock.unlock() | ||
| result = self.tryToCommitTermination() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tryToCommitTermination already locks the state to check if it is terminating or not. So the locking here is pointless.
df9066b to
d734a11
Compare
d734a11 to
ab84a10
Compare
| // Note that this cannot be `try` since any concurrent observer bag | ||
| // manipulation might then cause the terminating state being missed. | ||
| stateLock.lock() | ||
| if case .terminating = state { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is resurrected because acquiring the spinlock is consistently & considerably faster than calling tryToCommitTermination() on my machine (>5% per observer callout). 🤦♂️
Preceded by #453.
What is the race in particular?
Let's look at how the compiled assembly does the work:
It seems safe, especially since writes are serialised.
But what if we shift the timeline of thread B a little bit further?
Data races now exist due to ARC not being part of the atomic read/write. Our RCU model considers only the atomicity of
self.statealone without the hidden side effect of ARC.While the contention window is extremely small (a few instruction cycles) thus being extremely hard to expose, it is possible on paper that the
AliveStateis released before a concurrent relaxed reader retains it.What is the solution proposed by this PR?
The Signal core now tracks the number of pending state updates.If a sender detects that there is any pending state update, it would acquire alsoupdateLockjust to exploit the serialisation to eliminate any release-retain race with the state updates.Signal.Core.sendlocksupdateLockwhenever it accessesstate.What is the cost of the solution?
It requires anInt32read for everyvalueevent delivered, which is offset by another change in the patch. It also requires atomic increments & decrements before and after state updates.What else is changed in this PR?
Since Swift retains the enum as it loads it, even if only the tag is probed, the relaxed terminating check is also prone to the race.The terminating check now relies instead on a special negative value in the new atomic counter, which would be asserted as the state is bumped toterminating.All the state management is encapsulated inCoreBlackBox, whichSignal.Corenow inherits from.Recursive terminations are no longer prioritized, and are now prone to competition with concurrent senders (if any). This is only a change in the implementation detail, as the
Signalcontract does not guarantee any deterministic order between concurrent senders.Note that the removal offsets the event delivery overhead brought by the atomic counter.Checklist
Updated CHANGELOG.md.