Skip to content

Commit 6228bd5

Browse files
committed
Sleep between UI updates when needed to avoid main thread saturation
This change is mainly targeted at weaker systems and helps avoid completely starving the backend's renderer of time on the main thread in situations where app/view state updates are backing up and causing constant back-to-back state updates. The duration of the delay between handling updates is computed based off the time taken to handle state updates (exponentially smoothed over time).
1 parent eab21cb commit 6228bd5

File tree

3 files changed

+68
-13
lines changed

3 files changed

+68
-13
lines changed

Sources/SwiftCrossUI/State/Publisher.swift

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import CoreFoundation
12
import Dispatch
3+
import Foundation
24

35
/// A type that produces valueless observations.
46
public class Publisher {
@@ -11,6 +13,12 @@ public class Publisher {
1113
/// Human-readable tag for debugging purposes.
1214
private var tag: String?
1315

16+
/// The time at which the last update merging event occurred in
17+
/// `observeOnMainThreadAvoidingStarvation`.
18+
private var lastUpdateMergeTime: CFAbsoluteTime = 0
19+
/// The amount of time taken per state update, exponentially averaged over time.
20+
private var exponentiallySmoothedUpdateLength: Double = 0
21+
1422
/// Creates a new independent publisher.
1523
public init() {}
1624

@@ -58,18 +66,33 @@ public class Publisher {
5866
return self
5967
}
6068

61-
// TODO: English this explanation more better.
62-
/// Observes a publisher for changes and runs an action on the main thread whenever
63-
/// a change occurs. This pattern generally leads to starvation if events are produced
64-
/// faster than the serial handler can handle them, so this method deals with that by
65-
/// ensuring that only one update is allowed to be waiting at any given time. The
66-
/// implementation guarantees that at least one update will occur after each update,
67-
/// but if for example 4 updates arrive while a previous update is getting serviced,
68-
/// then all 4 updates will get serviced by a single run of `closure`.
69+
/// A specialized version of `observe(with:)` designed to mitigate main thread
70+
/// starvation issues observed on weaker systems when using the Gtk3Backend.
71+
///
72+
/// If observations are produced faster than the update handler (`closure`) can
73+
/// run, then the main thread quickly saturates and there's not enough time
74+
/// between view state updates for the backend to re-render the affected UI
75+
/// elements.
76+
///
77+
/// This method ensures that only one update can queue up at a time. When an
78+
/// observation arrives while an update is already queued, the observation's
79+
/// resulting update gets 'merged' (which just means dropped, but unlike a
80+
/// dropped frame, a dropped update has no detrimental effects).
6981
///
70-
/// Note that some backends don't have a concept of a main thread, so you the updates
71-
/// won't always end up on a 'main thread', but they are guaranteed to run serially.
72-
func observeOnMainThreadAvoidingStarvation<Backend: AppBackend>(
82+
/// When updates are getting merged often, this generally means that the
83+
/// update handler is still running constantly (since there's always going to
84+
/// be a new update waiting before the the running update completes). In this
85+
/// situation we introduce a sleep after handling each update to give the backend
86+
/// time to catch up. Hueristically I've found that a delay of 1.5x the length of
87+
/// the update is required on my old Linux laptop using ``Gtk3Backend``, so I'm
88+
/// going with that for now. Importantly, this delay is only used whenever updates
89+
/// start running back-to-back with no gap so it shouldn't affect fast systems
90+
/// like my Mac under any usual circumstances.
91+
///
92+
/// If the provided backend has the notion of a main thread, then the update
93+
/// handler will end up on that thread, but regardless of backend it's
94+
/// guaranteed that updates will always run serially.
95+
func observeAsUIUpdater<Backend: AppBackend>(
7396
backend: Backend,
7497
action closure: @escaping () -> Void
7598
) -> Cancellable {
@@ -80,6 +103,13 @@ public class Publisher {
80103
return observe {
81104
// Only allow one update to wait at a time.
82105
guard semaphore.wait(timeout: .now()) == .success else {
106+
// It's a bit of a hack but we just reuse the serial update handling queue
107+
// for synchronisation since updating this variable isn't super time sensitive
108+
// as long as it happens within the next update or two.
109+
let mergeTime = CFAbsoluteTimeGetCurrent()
110+
serialUpdateHandlingQueue.async {
111+
self.lastUpdateMergeTime = mergeTime
112+
}
83113
return
84114
}
85115

@@ -95,7 +125,32 @@ public class Publisher {
95125
// we've already processed, leading to stale view contents.
96126
semaphore.signal()
97127

128+
// Run the closure and while we're at it measure how long it takes
129+
// so that we can use it when throttling if updates start backing up.
130+
let start = CFAbsoluteTimeGetCurrent()
98131
closure()
132+
let elapsed = CFAbsoluteTimeGetCurrent() - start
133+
134+
// I chose exponential smoothing because it's simple to compute, doesn't
135+
// require storing a window of previous values, and quickly converges to
136+
// a sensible value when the average moves while still somewhat ignoring
137+
// outliers.
138+
self.exponentiallySmoothedUpdateLength =
139+
elapsed / 2 + self.exponentiallySmoothedUpdateLength / 2
140+
}
141+
142+
if CFAbsoluteTimeGetCurrent() - self.lastUpdateMergeTime < 1 {
143+
// The factor of 1.5 was determined empirically. This algorithm is
144+
// open for improvements since it's purely here to reduce the risk
145+
// of UI freezes.
146+
let throttlingDelay = self.exponentiallySmoothedUpdateLength * 1.5
147+
148+
// Sleeping on a dispatch queue generally isn't a good idea because
149+
// you prevent the queue from servicing any other work, but in this
150+
// case that's the whole point. The goal is to give the main thread
151+
// a break, which we do by blocking this queue and in effect guarding
152+
// the main thread from subsequent updates until we wake up again.
153+
Thread.sleep(forTimeInterval: throttlingDelay)
99154
}
100155
}
101156
}

Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ public class ViewGraphNode<NodeView: View, Backend: AppBackend> {
109109

110110
// Update the view and its children when state changes (children are always updated first).
111111
cancellable = view.state.didChange
112-
.observeOnMainThreadAvoidingStarvation(backend: backend) { [weak self] in
112+
.observeAsUIUpdater(backend: backend) { [weak self] in
113113
guard let self = self else { return }
114114
self.bottomUpUpdate()
115115
}

Sources/SwiftCrossUI/_App.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class _App<AppRoot: App> {
6161
self.sceneGraphRoot = rootNode
6262

6363
self.cancellable = self.app.state.didChange
64-
.observeOnMainThreadAvoidingStarvation(backend: self.backend) { [weak self] in
64+
.observeAsUIUpdater(backend: self.backend) { [weak self] in
6565
guard let self = self else { return }
6666
self.sceneGraphRoot?.update(
6767
self.app.body,

0 commit comments

Comments
 (0)