Publisher.swift 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import Dispatch
  2. import Foundation
  3. /// A type that produces valueless observations.
  4. public class Publisher {
  5. /// The id for the next observation (ids are used to cancel observations).
  6. private var nextObservationId = 0
  7. /// All current observations keyed by their id (ids are used to cancel observations).
  8. private var observations: [Int: () -> Void] = [:]
  9. /// Human-readable tag for debugging purposes.
  10. private var tag: String?
  11. /// We guard this against data races, with `serialUpdateHandlingQueue`, and
  12. /// with our lives.
  13. private class UpdateStatistics: @unchecked Sendable {
  14. /// The time at which the last update merging event occurred in
  15. /// `observeOnMainThreadAvoidingStarvation`.
  16. var lastUpdateMergeTime: TimeInterval = 0
  17. /// The amount of time taken per state update, exponentially averaged over time.
  18. var exponentiallySmoothedUpdateLength: Double = 0
  19. }
  20. private let updateStatistics = UpdateStatistics()
  21. private let serialUpdateHandlingQueue = DispatchQueue(
  22. label: "serial update handling"
  23. )
  24. private let semaphore = DispatchSemaphore(value: 1)
  25. /// Creates a new independent publisher.
  26. public init() {}
  27. /// Publishes a change to all observers serially on the current thread.
  28. public func send() {
  29. for observation in self.observations.values {
  30. observation()
  31. }
  32. }
  33. /// Registers a handler to observe future events.
  34. public func observe(with closure: @escaping () -> Void) -> Cancellable {
  35. let id = nextObservationId
  36. observations[id] = closure
  37. nextObservationId += 1
  38. return Cancellable { [weak self] in
  39. guard let self else { return }
  40. self.observations[id] = nil
  41. }
  42. .tag(with: tag)
  43. }
  44. /// Links the publisher to an upstream, meaning that observations from the upstream
  45. /// effectively get forwarded to all observers of this publisher as well.
  46. public func link(toUpstream publisher: Publisher) -> Cancellable {
  47. let cancellable = publisher.observe(with: {
  48. self.send()
  49. })
  50. cancellable.tag(with: "\(tag ?? "no tag") <-> \(cancellable.tag ?? "no tag")")
  51. return cancellable
  52. }
  53. @discardableResult
  54. func tag(with tag: @autoclosure () -> String?) -> Self {
  55. #if DEBUG
  56. self.tag = tag()
  57. #endif
  58. return self
  59. }
  60. /// A specialized version of ``observe(with:)`` designed to mitigate main thread
  61. /// starvation issues observed on weaker systems when using the Gtk3Backend.
  62. ///
  63. /// If observations are produced faster than the update handler (`closure`) can
  64. /// run, then the main thread quickly saturates and there's not enough time
  65. /// between view state updates for the backend to re-render the affected UI
  66. /// elements.
  67. ///
  68. /// This method ensures that only one update can queue up at a time. When an
  69. /// observation arrives while an update is already queued, the observation's
  70. /// resulting update gets 'merged' (which just means dropped, but unlike a
  71. /// dropped frame, a dropped update has no detrimental effects).
  72. ///
  73. /// When updates are getting merged often, this generally means that the
  74. /// update handler is still running constantly (since there's always going to
  75. /// be a new update waiting before the the running update completes). In this
  76. /// situation we introduce a sleep after handling each update to give the backend
  77. /// time to catch up. Heuristically I've found that a delay of 1.5x the length of
  78. /// the update is required on my old Linux laptop using ``Gtk3Backend``, so I'm
  79. /// going with that for now. Importantly, this delay is only used whenever updates
  80. /// start running back-to-back with no gap so it shouldn't affect fast systems
  81. /// like my Mac under any usual circumstances.
  82. ///
  83. /// If the provided backend has the notion of a main thread, then the update
  84. /// handler will end up on that thread, but regardless of backend it's
  85. /// guaranteed that updates will always run serially.
  86. func observeAsUIUpdater<Backend: BaseAppBackend>(
  87. backend: Backend,
  88. action: @escaping @MainActor @Sendable () -> Void
  89. ) -> Cancellable {
  90. let semaphore = self.semaphore
  91. let serialUpdateHandlingQueue = self.serialUpdateHandlingQueue
  92. let updateStatistics = self.updateStatistics
  93. return observe {
  94. // Only allow one update to wait at a time.
  95. guard semaphore.wait(timeout: .now()) == .success else {
  96. // It's a bit of a hack but we just reuse the serial update handling queue
  97. // for synchronisation since updating this variable isn't super time sensitive
  98. // as long as it happens within the next update or two.
  99. let mergeTime = ProcessInfo.processInfo.systemUptime
  100. serialUpdateHandlingQueue.async {
  101. updateStatistics.lastUpdateMergeTime = mergeTime
  102. }
  103. return
  104. }
  105. // Add update to queue. We use our own serial update handling queue since some
  106. // backends don't have the concept of a main thread, leading to the possibility
  107. // that two updates can run at once which would be inefficient and lead to
  108. // incorrect results anyway.
  109. serialUpdateHandlingQueue.async {
  110. backend.runInMainThread {
  111. // Now that we're about to start, let another update queue up. If we
  112. // instead waited until we're finished the update, we'd introduce the
  113. // possibility of dropping updates that would've affected views that
  114. // we've already processed, leading to stale view contents.
  115. semaphore.signal()
  116. // Run the closure and while we're at it measure how long it takes
  117. // so that we can use it when throttling if updates start backing up.
  118. let start = ProcessInfo.processInfo.systemUptime
  119. action()
  120. let elapsed = ProcessInfo.processInfo.systemUptime - start
  121. // I chose exponential smoothing because it's simple to compute, doesn't
  122. // require storing a window of previous values, and quickly converges to
  123. // a sensible value when the average moves, while still somewhat ignoring
  124. // outliers.
  125. updateStatistics.exponentiallySmoothedUpdateLength =
  126. elapsed / 2 + updateStatistics.exponentiallySmoothedUpdateLength / 2
  127. }
  128. if ProcessInfo.processInfo.systemUptime - updateStatistics.lastUpdateMergeTime < 1 {
  129. // The factor of 1.5 was determined empirically. This algorithm is
  130. // open for improvements since it's purely here to reduce the risk
  131. // of UI freezes. A factor of 1.5 equates to a gap between updates of
  132. // approximately 50% of the average update length.
  133. let throttlingDelay = updateStatistics.exponentiallySmoothedUpdateLength * 1.5
  134. // Sleeping on a dispatch queue generally isn't a good idea because
  135. // you prevent the queue from servicing any other work, but in this
  136. // case that's the whole point. The goal is to give the main thread
  137. // a break, which we do by blocking this queue and in effect guarding
  138. // the main thread from subsequent updates until we wake up again.
  139. Thread.sleep(forTimeInterval: throttlingDelay)
  140. }
  141. }
  142. }
  143. }
  144. }