Skip to content

Commit 7c417f7

Browse files
authored
Merge pull request #14 from ReactiveCocoa/anders/fix-deadlock
Fix a deadlock when `Loop.producer` is started recursively.
2 parents 49f8a95 + 9e03b53 commit 7c417f7

File tree

3 files changed

+115
-34
lines changed

3 files changed

+115
-34
lines changed

Loop/Floodgate.swift

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ final class Floodgate<State, Event>: FeedbackEventConsumer<Event> {
2727
}
2828
}
2929

30-
private let reducerLock = NSLock()
30+
private let reducerLock = RecursiveLock()
31+
3132
private var state: State
3233
private var hasStarted = false
3334

@@ -53,48 +54,60 @@ final class Floodgate<State, Event>: FeedbackEventConsumer<Event> {
5354
)
5455
}
5556

56-
reducerLock.perform {
57+
reducerLock.perform { reentrant in
58+
assert(reentrant == false)
5759
drainEvents()
5860
}
5961
}
6062

6163
override func process(_ event: Event, for token: Token) {
6264
enqueue(event, for: token)
6365

64-
if reducerLock.try() {
65-
repeat {
66-
drainEvents()
67-
reducerLock.unlock()
68-
} while queue.withValue({ $0.hasEvents }) && reducerLock.try()
69-
// ^^^
70-
// Restart the event draining after we unlock the reducer lock, iff:
71-
//
72-
// 1. the queue still has unprocessed events; and
73-
// 2. no concurrent actor has taken the reducer lock, which implies no event draining would be started
74-
// unless we take active action.
75-
//
76-
// This eliminates a race condition in the following sequence of operations:
77-
//
78-
// | Thread A | Thread B |
79-
// |------------------------------------|------------------------------------|
80-
// | concurrent dequeue: no item | |
81-
// | | concurrent enqueue |
82-
// | | trylock lock: BUSY |
83-
// | unlock lock | |
84-
// | | |
85-
// | <<< The enqueued event is left unprocessed. >>> |
66+
var continueToDrain = false
67+
68+
repeat {
69+
// We use a recursive lock to guard the reducer, so as to allow state access via `withValue` to be
70+
// reentrant. But in order to not deadlock in ReactiveSwift, reentrant calls MUST NOT drain the queue.
71+
// Otherwise, `consume(_:)` will eventually invoke the reducer and send out a state via `changeObserver`,
72+
// leading to a deadlock.
8673
//
87-
// The trylock-unlock duo has a synchronize-with relationship, which ensures that Thread A must see any
88-
// concurrent enqueue that *happens before* the trylock.
89-
}
74+
// If we know that the call is reentrant, we can confidently skip draining the queue anyway, because the
75+
// outmost call — the one who first acquires the lock on the current lock owner thread — is already looping
76+
// to exhaustively drain the queue.
77+
continueToDrain = reducerLock.tryPerform { isReentrant in
78+
guard isReentrant == false else { return false }
79+
drainEvents()
80+
return true
81+
}
82+
} while queue.withValue({ $0.hasEvents }) && continueToDrain
83+
// ^^^
84+
// Restart the event draining after we unlock the reducer lock, iff:
85+
//
86+
// 1. the queue still has unprocessed events; and
87+
// 2. no concurrent actor has taken the reducer lock, which implies no event draining would be started
88+
// unless we take active action.
89+
//
90+
// This eliminates a race condition in the following sequence of operations:
91+
//
92+
// | Thread A | Thread B |
93+
// |------------------------------------|------------------------------------|
94+
// | concurrent dequeue: no item | |
95+
// | | concurrent enqueue |
96+
// | | trylock lock: BUSY |
97+
// | unlock lock | |
98+
// | | |
99+
// | <<< The enqueued event is left unprocessed. >>> |
100+
//
101+
// The trylock-unlock duo has a synchronize-with relationship, which ensures that Thread A must see any
102+
// concurrent enqueue that *happens before* the trylock.
90103
}
91104

92105
override func dequeueAllEvents(for token: Token) {
93106
queue.modify { $0.events.removeAll(where: { _, t in t == token }) }
94107
}
95108

96109
func withValue<Result>(_ action: (State, Bool) -> Result) -> Result {
97-
reducerLock.perform { action(state, hasStarted) }
110+
reducerLock.perform { _ in action(state, hasStarted) }
98111
}
99112

100113
func dispose() {

Loop/NSLock+Extensions.swift

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,42 @@
11
import Foundation
22

3-
extension NSLock {
4-
internal func perform<Result>(_ action: () -> Result) -> Result {
5-
lock()
6-
defer { unlock() }
3+
final class RecursiveLock {
4+
let lock = NSRecursiveLock()
5+
private var isLockAcquired = false
76

8-
return action()
7+
init() {}
8+
9+
/// Try to acquire the lock, perform the given action, and return whether or not the action is successful.
10+
///
11+
/// - returns: The boolean flag indicating successfulness as returned by `action`. If the lock cannot be acquired,
12+
/// `false` is returned.
13+
@inlinable
14+
func tryPerform(_ action: (_ isReentrant: Bool) -> Bool) -> Bool {
15+
if lock.try() {
16+
defer { lock.unlock() }
17+
return run(action)
18+
}
19+
20+
return false
21+
}
22+
23+
/// Acquire the lock, and perform the given action. If the lock is contested, wait until it is free.
24+
///
25+
/// - returns: Pass through the value produced by `action`.
26+
@inlinable
27+
func perform<Result>(_ action: (_ isReentrant: Bool) -> Result) -> Result {
28+
lock.lock()
29+
defer { lock.unlock() }
30+
return run(action)
31+
}
32+
33+
private func run<Result>(_ action: (_ isReentrant: Bool) -> Result) -> Result {
34+
if isLockAcquired {
35+
return action(true)
36+
} else {
37+
isLockAcquired = true
38+
defer { isLockAcquired = false }
39+
return action(false)
40+
}
941
}
1042
}

LoopTests/FeedbackLoopSystemTests.swift

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,43 @@ class FeedbackLoopSystemTests: XCTestCase {
336336

337337
expect(results) == [0, 2, 1002, 2004, 4006]
338338
}
339-
339+
340+
func test_should_not_deadlock_when_feedback_effect_starts_loop_producer_synchronously() {
341+
var _loop: Loop<Int, Int>!
342+
343+
let loop = Loop<Int, Int>(
344+
initial: 0,
345+
reducer: { $0 += $1 },
346+
feedbacks: [
347+
.init(
348+
skippingRepeated: { $0 == 1 },
349+
effects: { isOne in
350+
isOne
351+
? _loop.producer.map(value: 1000).take(first: 1)
352+
: .empty
353+
}
354+
)
355+
]
356+
)
357+
_loop = loop
358+
359+
var results: [Int] = []
360+
loop.producer.startWithValues { results.append($0) }
361+
362+
expect(results) == [0]
363+
364+
func evaluate() {
365+
loop.send(1)
366+
expect(results) == [0, 1, 1001]
367+
}
368+
369+
#if arch(x86_64)
370+
expect(expression: evaluate).notTo(throwAssertion())
371+
#else
372+
evaluate()
373+
#endif
374+
}
375+
340376
func test_events_are_produced_in_correct_order() {
341377
let (feedback, input) = Loop<Int, Int>.Feedback.input
342378
var events: [Int] = []

0 commit comments

Comments
 (0)