Skip to content

Commit e59d91e

Browse files
committed
Introduce explicit state update queueing logic to avoid queuing unnecessary updates
This prevents us from starving the main thread just because a view is receiving high frequency state updates. Note that we could still run into issues if the high frequency state updates are getting spread over a large number of views since this optimization applies locally to each ViewGraphNode.
1 parent cadf5b8 commit e59d91e

File tree

1 file changed

+90
-3
lines changed

1 file changed

+90
-3
lines changed

Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,98 @@ public class ViewGraphNode<NodeView: View, Backend: AppBackend> {
107107
let tag = String(String(describing: NodeView.self).split(separator: "<")[0])
108108
backend.tag(widget: widget, as: tag)
109109

110-
// Update the view and its children when state changes (children are always updated first)
110+
// Updates the view and its children when state changes (children are always updated first).
111+
// All of the concurrency related code is there to detect when updates can be merged
112+
// together (a.k.a. when one of the updates is unnecessary).
113+
let protectingQueue = DispatchQueue(label: "state update debouncing")
114+
let concurrentUpdateHandlingQueue = DispatchQueue(
115+
label: "concurrent update handling queue",
116+
attributes: .concurrent
117+
)
118+
var updateIsQueued = false
119+
var updateIsRunning = false
120+
var aCurrentJobDidntHaveToWait = false
121+
let synchronizationSemaphore = DispatchSemaphore(value: 1)
111122
cancellable = view.state.didChange.observe { [weak self] in
112123
guard let self = self else { return }
113-
self.backend.runInMainThread {
114-
self.bottomUpUpdate()
124+
125+
// I'm sorry if you have to make sense of this... Take my comments as a peace offering.
126+
127+
// Hop to a dispatch queue to avoid blocking any threads in the Swift Structured
128+
// Concurrency thread pool in the case that the state update originated from a task.
129+
concurrentUpdateHandlingQueue.async {
130+
// If no one is running, then we run without waiting, and if someone's running
131+
// but no one's waiting, then we wait and prevent anyone else from waiting.
132+
// This ensures that at least one update will always happen after every update
133+
// received so far, without letting unnecessary updates queue up. The reason
134+
// that we can merge updates like this is that all state updates are built equal;
135+
// they don't carry any information other than that they happened.
136+
var shouldWait = false
137+
protectingQueue.sync {
138+
if !updateIsQueued {
139+
shouldWait = true
140+
}
141+
142+
if updateIsRunning {
143+
updateIsQueued = true
144+
} else {
145+
updateIsRunning = true
146+
aCurrentJobDidntHaveToWait = true
147+
}
148+
}
149+
150+
guard shouldWait else {
151+
return
152+
}
153+
154+
// Waiting just involves attempting to jump to the main thread.
155+
self.backend.runInMainThread {
156+
// This semaphore is used because some backends don't put us on the main
157+
// thread since they don't have the concept of a single UI thread like
158+
// macOS does.
159+
//
160+
// If `backend.runInMainThread` is truly putting us on the main thread,
161+
// then this never have to block significantly, otherwise we're just
162+
// blocking some random thread, so either way we're fine since we've
163+
// explicitly hopped to a dispatch queue to escape any cooperative
164+
// Swift Structured Concurrency thread pool the state update may have
165+
// originated from.
166+
synchronizationSemaphore.wait()
167+
168+
protectingQueue.sync {
169+
// If a current job didn't have to wait, then that's us. Due to
170+
// concurrency that doesn't mean we were the first update triggered.
171+
// That is, we could've been the job that set `updateIsQueued` to
172+
// true while still being the job that reached this line first (before
173+
// the one that set `updateIsRunning` to true). And that's why I've
174+
// implemented the check in this way with a protected 'global' and not
175+
// a local variable (being first isn't a property we can know ahead
176+
// of time). I use 'global' in the sense of shared between all calls
177+
// to the state update handling closure for a given ViewGraphNode.
178+
//
179+
// The reason that `aCurrentJobDidntHaveToWait` is needed at all is
180+
// so that we can know whether `updateIsQueued`'s value is due to us
181+
// or someone else/no one.
182+
if aCurrentJobDidntHaveToWait {
183+
aCurrentJobDidntHaveToWait = false
184+
} else {
185+
updateIsQueued = false
186+
}
187+
}
188+
189+
self.bottomUpUpdate()
190+
191+
// If someone is waiting then we leave `updateIsRunning` equal to true
192+
// because they'll immediately begin running as soon as we exit and we
193+
// don't want an extra person queueing until they've actually started.
194+
protectingQueue.sync {
195+
if !updateIsQueued {
196+
updateIsRunning = false
197+
}
198+
}
199+
200+
synchronizationSemaphore.signal()
201+
}
115202
}
116203
}
117204
}

0 commit comments

Comments
 (0)