A game framework written with osu! in mind.
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2// See the LICENCE file in the repository root for full licence text.
3
4using System;
5using System.Collections.Generic;
6using System.Diagnostics;
7using System.Globalization;
8using System.Threading;
9using JetBrains.Annotations;
10using osu.Framework.Bindables;
11using osu.Framework.Development;
12using osu.Framework.Platform;
13using osu.Framework.Statistics;
14using osu.Framework.Timing;
15
16namespace osu.Framework.Threading
17{
18 /// <summary>
19 /// A conceptual thread used for running game work. May or may not be backed by a native thread.
20 /// </summary>
21 public class GameThread
22 {
23 internal const double DEFAULT_ACTIVE_HZ = 1000;
24 internal const double DEFAULT_INACTIVE_HZ = 60;
25
26 /// <summary>
27 /// The name of this thread.
28 /// </summary>
29 public readonly string Name;
30
31 /// <summary>
32 /// Whether the game is active (in the foreground).
33 /// </summary>
34 public readonly IBindable<bool> IsActive = new Bindable<bool>(true);
35
36 /// <summary>
37 /// The current state of this thread.
38 /// </summary>
39 public IBindable<GameThreadState> State => state;
40
41 private readonly Bindable<GameThreadState> state = new Bindable<GameThreadState>();
42
43 /// <summary>
44 /// Whether this thread is currently running.
45 /// </summary>
46 public bool Running => state.Value == GameThreadState.Running;
47
48 /// <summary>
49 /// Whether this thread is exited.
50 /// </summary>
51 public bool Exited => state.Value == GameThreadState.Exited;
52
53 /// <summary>
54 /// Whether currently executing on this thread (from the point of invocation).
55 /// </summary>
56 public virtual bool IsCurrent => true;
57
58 /// <summary>
59 /// The thread's clock. Responsible for timekeeping and throttling.
60 /// </summary>
61 public ThrottledFrameClock Clock { get; }
62
63 /// <summary>
64 /// The current dedicated OS thread for this <see cref="GameThread"/>.
65 /// A value of <see langword="null"/> does not necessarily mean that this thread is not running;
66 /// in <see cref="ExecutionMode.SingleThread"/> execution mode <see cref="ThreadRunner"/> drives its <see cref="GameThread"/>s
67 /// manually and sequentially on the main OS thread of the game process.
68 /// </summary>
69 [CanBeNull]
70 public Thread Thread { get; private set; }
71
72 /// <summary>
73 /// The thread's scheduler.
74 /// </summary>
75 public Scheduler Scheduler { get; }
76
77 /// <summary>
78 /// Attach a handler to delegate responsibility for per-frame exceptions.
79 /// While attached, all exceptions will be caught and forwarded. Thread execution will continue indefinitely.
80 /// </summary>
81 public EventHandler<UnhandledExceptionEventArgs> UnhandledException;
82
83 /// <summary>
84 /// The culture of this thread.
85 /// </summary>
86 public CultureInfo CurrentCulture
87 {
88 get => culture;
89 set
90 {
91 culture = value;
92
93 updateCulture();
94 }
95 }
96
97 private CultureInfo culture;
98
99 /// <summary>
100 /// The target number of updates per second when the game window is active.
101 /// </summary>
102 /// <remarks>
103 /// A value of 0 is treated the same as "unlimited" or <see cref="double.MaxValue"/>.
104 /// </remarks>
105 public double ActiveHz
106 {
107 get => activeHz;
108 set
109 {
110 activeHz = value;
111 updateMaximumHz();
112 }
113 }
114
115 private double activeHz = DEFAULT_ACTIVE_HZ;
116
117 /// <summary>
118 /// The target number of updates per second when the game window is inactive.
119 /// </summary>
120 /// <remarks>
121 /// A value of 0 is treated the same as "unlimited" or <see cref="double.MaxValue"/>.
122 /// </remarks>
123 public double InactiveHz
124 {
125 get => inactiveHz;
126 set
127 {
128 inactiveHz = value;
129 updateMaximumHz();
130 }
131 }
132
133 private double inactiveHz = DEFAULT_INACTIVE_HZ;
134
135 internal PerformanceMonitor Monitor { get; }
136
137 internal virtual IEnumerable<StatisticsCounterType> StatisticsCounters => Array.Empty<StatisticsCounterType>();
138
139 /// <summary>
140 /// The main work which is fired on each frame.
141 /// </summary>
142 protected event Action OnNewFrame;
143
144 private readonly ManualResetEvent initializedEvent = new ManualResetEvent(false);
145
146 private readonly object startStopLock = new object();
147
148 /// <summary>
149 /// Whether a pause has been requested.
150 /// </summary>
151 private volatile bool pauseRequested;
152
153 /// <summary>
154 /// Whether an exit has been requested.
155 /// </summary>
156 private volatile bool exitRequested;
157
158 internal GameThread(Action onNewFrame = null, string name = "unknown", bool monitorPerformance = true)
159 {
160 OnNewFrame = onNewFrame;
161
162 Name = name;
163 Clock = new ThrottledFrameClock();
164 if (monitorPerformance)
165 Monitor = new PerformanceMonitor(this, StatisticsCounters);
166 Scheduler = new GameThreadScheduler(this);
167
168 IsActive.BindValueChanged(_ => updateMaximumHz(), true);
169 }
170
171 /// <summary>
172 /// Block until this thread has entered an initialised state.
173 /// </summary>
174 public void WaitUntilInitialized()
175 {
176 initializedEvent.WaitOne();
177 }
178
179 /// <summary>
180 /// Returns a string representation that is prefixed with this thread's identifier.
181 /// </summary>
182 /// <param name="name">The content to prefix.</param>
183 /// <returns>A prefixed string.</returns>
184 public static string PrefixedThreadNameFor(string name) => $"{nameof(GameThread)}.{name}";
185
186 /// <summary>
187 /// Start this thread.
188 /// </summary>
189 /// <remarks>
190 /// This method blocks until in a running state.
191 /// </remarks>
192 public void Start()
193 {
194 lock (startStopLock)
195 {
196 switch (state.Value)
197 {
198 case GameThreadState.Paused:
199 case GameThreadState.NotStarted:
200 break;
201
202 default:
203 throw new InvalidOperationException($"Cannot start when thread is {state.Value}.");
204 }
205
206 state.Value = GameThreadState.Starting;
207 PrepareForWork();
208 }
209
210 WaitForState(GameThreadState.Running);
211 Debug.Assert(state.Value == GameThreadState.Running);
212 }
213
214 /// <summary>
215 /// Request that this thread is exited.
216 /// </summary>
217 /// <remarks>
218 /// This does not block and will only queue an exit request, which is processed in the main frame loop.
219 /// </remarks>
220 /// <exception cref="InvalidOperationException">Thrown when attempting to exit from an invalid state.</exception>
221 public void Exit()
222 {
223 lock (startStopLock)
224 {
225 switch (state.Value)
226 {
227 // technically we could support this, but we don't use this yet and it will add more complexity.
228 case GameThreadState.Paused:
229 case GameThreadState.NotStarted:
230 case GameThreadState.Starting:
231 throw new InvalidOperationException($"Cannot exit when thread is {state.Value}.");
232
233 case GameThreadState.Exited:
234 return;
235
236 default:
237 // actual exit will be done in ProcessFrame.
238 exitRequested = true;
239 break;
240 }
241 }
242 }
243
244 /// <summary>
245 /// Prepare this thread for performing work. Must be called when entering a running state.
246 /// </summary>
247 /// <param name="withThrottling">Whether this thread's clock should be throttling via thread sleeps.</param>
248 internal void Initialize(bool withThrottling)
249 {
250 lock (startStopLock)
251 {
252 Debug.Assert(state.Value != GameThreadState.Running);
253 Debug.Assert(state.Value != GameThreadState.Exited);
254
255 MakeCurrent();
256
257 OnInitialize();
258
259 Clock.Throttling = withThrottling;
260
261 Monitor.MakeCurrent();
262
263 updateCulture();
264
265 initializedEvent.Set();
266 state.Value = GameThreadState.Running;
267 }
268 }
269
270 /// <summary>
271 /// Run when thread transitions into an active/processing state, at the beginning of each frame.
272 /// </summary>
273 internal virtual void MakeCurrent()
274 {
275 ThreadSafety.ResetAllForCurrentThread();
276 }
277
278 /// <summary>
279 /// Runs a single frame, updating the execution state if required.
280 /// </summary>
281 internal void RunSingleFrame()
282 {
283 var newState = processFrame();
284
285 if (newState.HasValue)
286 setExitState(newState.Value);
287 }
288
289 /// <summary>
290 /// Pause this thread. Must be run from <see cref="ThreadRunner"/> in a safe manner.
291 /// </summary>
292 /// <remarks>
293 /// This method blocks until in a paused state.
294 /// </remarks>
295 internal void Pause()
296 {
297 lock (startStopLock)
298 {
299 if (state.Value != GameThreadState.Running)
300 return;
301
302 // actual pause will be done in ProcessFrame.
303 pauseRequested = true;
304 }
305
306 WaitForState(GameThreadState.Paused);
307 }
308
309 /// <summary>
310 /// Spin indefinitely until this thread enters a required state.
311 /// For cases where no native thread is present, this will run <see cref="processFrame"/> until the required state is reached.
312 /// </summary>
313 /// <param name="targetState">The state to wait for.</param>
314 internal void WaitForState(GameThreadState targetState)
315 {
316 if (state.Value == targetState)
317 return;
318
319 if (Thread == null)
320 {
321 GameThreadState? newState = null;
322
323 // if the thread is null at this point, we need to assume that this WaitForState call is running on the same native thread as this GameThread has/will be running.
324 // run frames until the required state is reached.
325 while (newState != targetState)
326 newState = processFrame();
327
328 // note that the only state transition here can be an exiting one. entering a running state can only occur in Initialize().
329 setExitState(newState.Value);
330 }
331 else
332 {
333 while (state.Value != targetState)
334 Thread.Sleep(1);
335 }
336 }
337
338 /// <summary>
339 /// Prepares this game thread for work. Should block until <see cref="Initialize"/> has been run.
340 /// </summary>
341 protected virtual void PrepareForWork()
342 {
343 Debug.Assert(Thread == null);
344 createThread();
345 Debug.Assert(Thread != null);
346
347 Thread.Start();
348 }
349
350 /// <summary>
351 /// Called whenever the thread is initialised. Should prepare the thread for performing work.
352 /// </summary>
353 protected virtual void OnInitialize()
354 {
355 }
356
357 /// <summary>
358 /// Called when a <see cref="Pause"/> or <see cref="Exit"/> is requested on this <see cref="GameThread"/>.
359 /// Use this method to release exclusive resources that the thread could have been holding in its current execution mode,
360 /// like GL contexts or similar.
361 /// </summary>
362 protected virtual void OnSuspended()
363 {
364 }
365
366 /// <summary>
367 /// Called when the thread is exited. Should clean up any thread-specific resources.
368 /// </summary>
369 protected virtual void OnExit()
370 {
371 }
372
373 private void updateMaximumHz()
374 {
375 Scheduler.Add(() => Clock.MaximumUpdateHz = IsActive.Value ? activeHz : inactiveHz);
376 }
377
378 /// <summary>
379 /// Create the native backing thread to run work.
380 /// </summary>
381 /// <remarks>
382 /// This does not start the thread, but guarantees <see cref="Thread"/> is non-null.
383 /// </remarks>
384 private void createThread()
385 {
386 Debug.Assert(Thread == null);
387 Debug.Assert(!Running);
388
389 Thread = new Thread(runWork)
390 {
391 Name = PrefixedThreadNameFor(Name),
392 IsBackground = true,
393 };
394
395 void runWork()
396 {
397 Initialize(true);
398
399 while (Running)
400 RunSingleFrame();
401 }
402 }
403
404 /// <summary>
405 /// Process a single frame of this thread's work.
406 /// </summary>
407 /// <returns>A potential execution state change.</returns>
408 private GameThreadState? processFrame()
409 {
410 if (state.Value != GameThreadState.Running)
411 // host could be in a suspended state. the input thread will still make calls to ProcessFrame so we can't throw.
412 return null;
413
414 MakeCurrent();
415
416 if (exitRequested)
417 {
418 exitRequested = false;
419 return GameThreadState.Exited;
420 }
421
422 if (pauseRequested)
423 {
424 pauseRequested = false;
425 return GameThreadState.Paused;
426 }
427
428 try
429 {
430 Monitor?.NewFrame();
431
432 using (Monitor?.BeginCollecting(PerformanceCollectionType.Scheduler))
433 Scheduler.Update();
434
435 using (Monitor?.BeginCollecting(PerformanceCollectionType.Work))
436 OnNewFrame?.Invoke();
437
438 using (Monitor?.BeginCollecting(PerformanceCollectionType.Sleep))
439 Clock.ProcessFrame();
440
441 Monitor?.EndFrame();
442 }
443 catch (Exception e)
444 {
445 if (UnhandledException != null && !ThreadSafety.IsInputThread)
446 // the handler schedules back to the input thread, so don't run it if we are already on the input thread
447 UnhandledException.Invoke(this, new UnhandledExceptionEventArgs(e, false));
448 else
449 throw;
450 }
451
452 return null;
453 }
454
455 private void updateCulture()
456 {
457 if (Thread == null || culture == null) return;
458
459 Thread.CurrentCulture = culture;
460 Thread.CurrentUICulture = culture;
461 }
462
463 private void setExitState(GameThreadState exitState)
464 {
465 lock (startStopLock)
466 {
467 Debug.Assert(state.Value == GameThreadState.Running);
468 Debug.Assert(exitState == GameThreadState.Exited || exitState == GameThreadState.Paused);
469
470 Thread = null;
471 OnSuspended();
472
473 switch (exitState)
474 {
475 case GameThreadState.Exited:
476 Monitor?.Dispose();
477 initializedEvent?.Dispose();
478
479 OnExit();
480 break;
481 }
482
483 state.Value = exitState;
484 }
485 }
486
487 private class GameThreadScheduler : Scheduler
488 {
489 public GameThreadScheduler(GameThread thread)
490 : base(() => thread.IsCurrent, thread.Clock)
491 {
492 }
493 }
494 }
495}