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

Signal lock elision. #129

Closed
wants to merge 1 commit into from
Closed

Signal lock elision. #129

wants to merge 1 commit into from

Conversation

andersio
Copy link
Member

@andersio andersio commented Nov 28, 2016

While we advocate for a safe-by-default API like how our beloved Swift does, there are places we may elide the serialization:

  1. Synchronously Signal operators. e.g. map, filter, scan, skipNil.

    These can rely on the serialization at the ultimate upstream, e.g. signals created by Signal.init or Signal.pipe with default settings.

    An illustration of how the call stack would look like with signal.map(t).map(t).map(t).map(t)...:

    sendLock
    observers ---> [sendLock]
                   observers    ---> [sendLock]
                                     observers    ---> [sendLock]
                                                       observers    ---> ...
                                                       [unlock]
                                     [unlock] <---/
                   [unlock] <---/
    unlock    <---/
    
  2. Asynchronous Signal operators which deliver all kinds of events on the same Scheduler.

    For example, observe(on:) forwards all the events to the supplied Scheduler. Since the scheduler serialises all the enqueued actions, there is no need for the created signal to serialise the events again.

  3. Signals that are owned by synchronized entities.

    For entities that are already synchronised, Signal can safely assume events not to be emitted concurrently. For example, MutableProperty always sends event in its critical section.

Implementation

The PR introduces an optional parameter attributes to Signal.init and Signal.pipe that allows configurability of some of the Signal behavior.

For the purpose of the PR, a inheritSerialization option was introduced. Signals created with this flag would not attempt to serialise the inputs it received.

func sendLockLock() {
if let lock = sendLock {
lock.lock()
}
Copy link
Contributor

@sharplet sharplet Nov 29, 2016

Choose a reason for hiding this comment

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

This could be shortened to lock?.lock()

Copy link
Contributor

@sharplet sharplet left a 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 pretty reasonable idea, and it's not a big change.

Are there any ramifications for existing code that might start signals as a side effect in on() or some other operator? What would happen to recursive event sending that currently results in a deadlock — anything?

if let lock = sendLock {
return lock.try()
}
return true
Copy link
Contributor

Choose a reason for hiding this comment

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

This could be shortened to return lock?.try() ?? true

func sendLockUnlock() {
if let lock = sendLock {
lock.unlock()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This could be shortened to lock?.unlock()


let observer = Observer { [weak self] event in
guard let signal = self else {
return
}

@inline(__always)
func sendLockLock() {
Copy link
Contributor

Choose a reason for hiding this comment

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

What do you think about just naming this lock()?

}

@inline(__always)
func sendLockTryLock() -> Bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

What do you think about just naming this tryLock()?

}

@inline(__always)
func sendLockUnlock() {
Copy link
Contributor

Choose a reason for hiding this comment

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

What do you think about just naming this unlock()?

/// - disposable: An optional disposable to associate with the signal, and
/// to be disposed of when the signal terminates.
///
/// - returns: A tuple made of signal and observer.
public static func pipe(disposable: Disposable? = nil) -> (Signal, Observer) {
public static func pipe(_ concurrency: SignalConcurrency = .serialized, disposable: Disposable? = nil) -> (Signal, Observer) {
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 should be a named parameter.

/// - generator: A closure that accepts an implicitly created observer
/// that will act as an event emitter for the signal.
public init(_ generator: (Observer) -> Disposable?) {
public init(_ concurrency: SignalConcurrency = .serialized, _ generator: (Observer) -> Disposable?) {
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 should be a named parameter.

/// The `Signal` may assume the events emitted to it have already been
/// serialized. In other words, the event emitter of the `Signal` is assumed
/// to only be called in synchronized routines.
case contained
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure about the naming — how about this?

public enum SignalSerialization {
  case mutex
  case inherit
}

Signal(serialization: .mutex) { observer in
  // ...
}
Signal.pipe(serialization: .inherit)

@@ -187,13 +219,16 @@ public final class Signal<Value, Error: Swift.Error> {
/// retained.
///
/// - parameters:
/// - contained: Indicates if the event emitter passed to `generator` would
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment will need to be updated when the name is finalised.

@@ -35,9 +35,11 @@ public final class Signal<Value, Error: Swift.Error> {
/// Signal itself will remain alive until the observer is released.
///
/// - parameters:
/// - concurrency: The concurrency mode the signal should assume.
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment will need to be updated too.

@andersio
Copy link
Member Author

andersio commented Nov 29, 2016

Are there any ramifications for existing code that might start signals as a side effect in on() or some other operator?

If you mean starting producers, I don't see it would cause harm.

Edit: Recursive event sending was not allowed since the very beginning. So other than this, the side effects are still executing within the same giant protected section, in the very same order, unless asynchronous operators that involves multiple signals are used.

If you mean observing any Signal along the chain, the observer bag is CoW on insertion or removal.

What would happen to recursive event sending that currently results in a deadlock — anything?

Obviously, the unlocked mode would grant a hole for recursive event sending. But our stance should remain unchanged. One way we could do this is allowing lock elision only in release builds (Edit: though I'd prefer to have it in debug builds, and we could simply declare it unsupported and "don't call us when you crashed".)

As for recursive interrupted handling, since we are just dealing with Signals here, the only source of interrupted events would be the upstream, i.e. no difference from other events.

Edit: It is a different story to have SignalProducer's lock elided, but we should leave it to another PR.

Copy link
Member

@NachoSoto NachoSoto left a comment

Choose a reason for hiding this comment

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

Before I look deeper I wanted to say that I'm 👎 for making this part of the public API. I'm okay if we use it as an optimization internally, but I think the increase in complexity of the API wouldn't be justified.

@andersio
Copy link
Member Author

andersio commented Nov 29, 2016

@NachoSoto Has to be part of the public API, or otherwise RAC can't use them it.

Edit: Having said that, RAC APIs may still enjoy the benefits as users apply more and more operators. But it feels sad (?) to me not to have RAC covered too.

Edit 2: There is a middle ground though: obscurity. We can export this API to a submodule an enum namespace, say ReactiveSwift.UtterlyUnsafe.

@andersio
Copy link
Member Author

Renamed a few stuff according to @sharplet's suggestions, and had four operators joined their lock-elided friends in 1dd6d5f.

@andersio
Copy link
Member Author

andersio commented Nov 29, 2016

A note on the recursive interrupted handling:

AFAIU, it is made for SignalProducer specifically. However, as we are not addressing SignalProducer yet, it is simply assumed to never happen with inherited serialization, since interrupted is only sent on the Signal the producer created, which still uses the default serialization.

 lock
 observers ---> lock
                observers ---> elidedLock
                               observers    ---> lock
                                                 observers    ---> elidedLock
                                                                   observers    ---> ...
                                                                   elidedUnlock
                                                 unlock       <---/
                               elidedUnlock <---/
                unlock    <---/
 unlock    <---/
------------------------------------------------------------------------------------
    SOURCE    |   PRODUCER   |   LIFTED MAP    |    PRODUCER     |   LIFTED MAP    | ...
------------------------------------------------------------------------------------

 Sources can be:
 1. Properties
 2. `SignalProducer(signal:)`
 3. `SignalProducer(_:)` (not necessarily serialised.)

Edit: Updated the ASCII diagrams. The unlocks are misplaced. 🙈
Edit 2: Added the missing "Source" column.


/// The `Signal` assumes to have inherited the serialization from its owner,
/// and its event emitter would not be called concurrently.
case inherited
Copy link
Contributor

Choose a reason for hiding this comment

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

FWIW, I still think this case would read better as an imperative: Signal(serialization: .inherit)

Copy link
Member Author

Choose a reason for hiding this comment

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

Followed FlattenStrategy to use imperative verbs.

@@ -29,7 +29,7 @@ extension Reactive where Base: NotificationCenter {
/// - note: The signal does not terminate naturally. Observers must be
/// explicitly disposed to avoid leaks.
public func notifications(forName name: Notification.Name?, object: AnyObject? = nil) -> Signal<Notification, NoError> {
return Signal { [base = self.base] observer in
return Signal(strategy: .inherit) { [base = self.base] observer in
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've lost too much meaning now at the point of use. It's no longer clear what's being inherited.

Copy link
Member Author

Choose a reason for hiding this comment

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

Does inputStrategy or serializeStrategy make sense to you?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd say serializationStrategy.

However, I'm still partial to serialization: .default and serialization: .inherit. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

...with declaration: init(serialization strategy: SerializationStrategy, ...).

Copy link
Member Author

Choose a reason for hiding this comment

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

Sometimes I do wonder if FlattenStrategy should be FlatteningStrategy instead...

@andersio andersio force-pushed the lock-elision branch 5 times, most recently from fa79195 to c44539b Compare November 29, 2016 17:21
Copy link
Member

@NachoSoto NachoSoto left a comment

Choose a reason for hiding this comment

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

If it can't be private then like I said I don't think the benefits warrant the increase in complexity. This is exposing implementation details that most users won't care about 👎

@andersio
Copy link
Member Author

andersio commented Nov 29, 2016

@NachoSoto Maybe I should have phrased it better: It can for sure be private to ReactiveSwift.

I just want to figure out a middle ground for RAC if it makes sense. If "internal" through obscurity (like stdlib) is still considered exposing the implementation, there is probably no other way to expose this to RAC based on my understanding.

Edit: For example:

public enum __WhenWeDoNotHaveSubmodulesThisHappens {
	public static func __makeInheritlySerializedSignal<Value, Error: Swift.Error>(
		_ generator: @escaping (Observer<Value, Error>) -> Disposable?
	) -> Signal<Value, Error> {
		return ReactiveSwift.Signal(serialization: .inherit, generator)
	}
}

@andersio andersio force-pushed the lock-elision branch 2 times, most recently from 05c3796 to 035d46c Compare November 29, 2016 22:38
@andersio andersio added blocked and removed blocked labels Dec 1, 2016
@andersio
Copy link
Member Author

andersio commented Dec 2, 2016

@NachoSoto Hmm, I actually thought of a counter argument to keeping it private: while most users would not care, users - including depending libraries like ReactiveCocoa - who want custom operators or have all possible means to optimize should be given the tools.

If what you worry is having it as part of the normal overload showing up in code completion, as mentioned we could export it in an separate enum namespace.

@andersio
Copy link
Member Author

andersio commented Dec 2, 2016

Benchmark Source
Environment: macOS Sierra 10.12.1, i5 4258U, Xcode 8.1, -Owholemodule
Comparing against master.

TableView Scrolling Simulation
Producer heavy, Produced signals send only 1 value.
+2% (7%)

Send 10000 values to 1 observer.
+9% (10%)

Send 10000 values to 8 observers.
+12% (5%)

Send 10000 values to 1 produced signal.
+4% (8%)

Send 10000 values to 8 produced signals.
+6% (3%)

@andersio
Copy link
Member Author

andersio commented Dec 3, 2016

Blocked by #137.

@andersio
Copy link
Member Author

andersio commented Jan 14, 2017

The SignalSerialization enum is replaced with an option set, since I expect #163 would need a similar way to customise the Signal. The APIs are also promoted back to be public, primarily for depending frameworks and users writing custom operators.

Note that as a default attribute set is provided, Signal.init and Signal.pipe shows up in autocomplete as two overloads.

@andersio
Copy link
Member Author

andersio commented Apr 28, 2017

Rolled back to be an internal API, and rebased on master. The benefit of opening it up for the sourcing signals in RAC is trivial and not a priority, since the primary concern is cutting the overhead of deep signal composition.

@andersio
Copy link
Member Author

andersio commented May 30, 2017

Benchmark source.

Test body

		let property = MutableProperty((0, 0, 0, 0))
		let composed = { property.map { ($0.1, $0.3) }.combinePrevious((-1, -1)) }()
		//let (signal, observer) = Signal<(Int, Int, Int, Int), NoError>.pipe()
		//let composed = signal.map { ($0.1, $0.3) }.combinePrevious((-1, -1))

		withExtendedLifetime(composed) {
			_measureAndStart(times: 1_000_000, label: label) {
				let value = 1
				//observer.send(value: (value, value, value, value))
				property.value = (value, value, value, value)
			}
		}

Result
1000000 iterations, -Owmo, i5 6260U

// Signal, `observer.send(value:)`
@testNonatomic(): avg 1570 ns; min 1343 ns
@testNormal(): avg 1644 ns; min 1422 ns

// MutableProperty, `property.value.set`
@testNonatomic(): avg 5402 ns; min 4656 ns
@testNormal(): avg 5578 ns; min 4797 ns

The locking overhead is around 5-7% per observer call-out.

For the narrower gap in properties, it is likely due to not having lifting optimization (#140) in place, which elides the serialising, relaying Signal of lifted operators for the concurrent interruption, and allows serialisation and interruption to be inherited from upstreams. Recall that composed properties use SignalProducer at its core.

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.

I'm not very excited about this. The thread safety is already hard to reason about without adding an additional level of complexity. I'm not eager to make it any more complex, especially for modest performance gains.

fileprivate init(_ generator: (Observer) -> Disposable?) {
fileprivate init(_ attributes: SignalAttributes, _ generator: (Observer) -> Disposable?) {
var attributes = attributes
if (Thread.current.threadDictionary["reactiveswift_disable_inherit_serialization"] as! Bool?) ?? false {
Copy link
Contributor

Choose a reason for hiding this comment

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

What's this for?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, I forgot to revert this hack for benchmarking purpose. 🤦‍♂️

}

/// Describes how `Signal` should behave.
internal struct SignalAttributes: OptionSet {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why an OptionSet instead of an enum?

@andersio
Copy link
Member Author

andersio commented Jun 3, 2017

The thread safety is already hard to reason about without adding an additional level of complexity.

The annotation in this PR is built on top of the premises of the Signal contract, saying all Signals must be synchronous (by default) and serialized.

If a synchronous operator derives a Signal from one single upstream, the derived Signal is already implicitly serialized — like a synchronous call tree that starts within a critical section. The keywords are synchronous and single upstream, which are fairly explicit IMO.

@andersio andersio force-pushed the lock-elision branch 3 times, most recently from fff4f32 to 420a3ed Compare June 4, 2017 05:51
@andersio
Copy link
Member Author

Closed since this becomes trivial with an upcoming higher-level SignalProducer optimisation branch.

@andersio andersio closed this Jul 16, 2017
@andersio andersio deleted the lock-elision branch July 16, 2017 19:40
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.

4 participants