// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Threading; using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Platform; using osu.Framework.Statistics; using osu.Framework.Timing; namespace osu.Framework.Threading { /// /// A conceptual thread used for running game work. May or may not be backed by a native thread. /// public class GameThread { internal const double DEFAULT_ACTIVE_HZ = 1000; internal const double DEFAULT_INACTIVE_HZ = 60; /// /// The name of this thread. /// public readonly string Name; /// /// Whether the game is active (in the foreground). /// public readonly IBindable IsActive = new Bindable(true); /// /// The current state of this thread. /// public IBindable State => state; private readonly Bindable state = new Bindable(); /// /// Whether this thread is currently running. /// public bool Running => state.Value == GameThreadState.Running; /// /// Whether this thread is exited. /// public bool Exited => state.Value == GameThreadState.Exited; /// /// Whether currently executing on this thread (from the point of invocation). /// public virtual bool IsCurrent => true; /// /// The thread's clock. Responsible for timekeeping and throttling. /// public ThrottledFrameClock Clock { get; } /// /// The current dedicated OS thread for this . /// A value of does not necessarily mean that this thread is not running; /// in execution mode drives its s /// manually and sequentially on the main OS thread of the game process. /// [CanBeNull] public Thread Thread { get; private set; } /// /// The thread's scheduler. /// public Scheduler Scheduler { get; } /// /// Attach a handler to delegate responsibility for per-frame exceptions. /// While attached, all exceptions will be caught and forwarded. Thread execution will continue indefinitely. /// public EventHandler UnhandledException; /// /// The culture of this thread. /// public CultureInfo CurrentCulture { get => culture; set { culture = value; updateCulture(); } } private CultureInfo culture; /// /// The target number of updates per second when the game window is active. /// /// /// A value of 0 is treated the same as "unlimited" or . /// public double ActiveHz { get => activeHz; set { activeHz = value; updateMaximumHz(); } } private double activeHz = DEFAULT_ACTIVE_HZ; /// /// The target number of updates per second when the game window is inactive. /// /// /// A value of 0 is treated the same as "unlimited" or . /// public double InactiveHz { get => inactiveHz; set { inactiveHz = value; updateMaximumHz(); } } private double inactiveHz = DEFAULT_INACTIVE_HZ; internal PerformanceMonitor Monitor { get; } internal virtual IEnumerable StatisticsCounters => Array.Empty(); /// /// The main work which is fired on each frame. /// protected event Action OnNewFrame; private readonly ManualResetEvent initializedEvent = new ManualResetEvent(false); private readonly object startStopLock = new object(); /// /// Whether a pause has been requested. /// private volatile bool pauseRequested; /// /// Whether an exit has been requested. /// private volatile bool exitRequested; internal GameThread(Action onNewFrame = null, string name = "unknown", bool monitorPerformance = true) { OnNewFrame = onNewFrame; Name = name; Clock = new ThrottledFrameClock(); if (monitorPerformance) Monitor = new PerformanceMonitor(this, StatisticsCounters); Scheduler = new GameThreadScheduler(this); IsActive.BindValueChanged(_ => updateMaximumHz(), true); } /// /// Block until this thread has entered an initialised state. /// public void WaitUntilInitialized() { initializedEvent.WaitOne(); } /// /// Returns a string representation that is prefixed with this thread's identifier. /// /// The content to prefix. /// A prefixed string. public static string PrefixedThreadNameFor(string name) => $"{nameof(GameThread)}.{name}"; /// /// Start this thread. /// /// /// This method blocks until in a running state. /// public void Start() { lock (startStopLock) { switch (state.Value) { case GameThreadState.Paused: case GameThreadState.NotStarted: break; default: throw new InvalidOperationException($"Cannot start when thread is {state.Value}."); } state.Value = GameThreadState.Starting; PrepareForWork(); } WaitForState(GameThreadState.Running); Debug.Assert(state.Value == GameThreadState.Running); } /// /// Request that this thread is exited. /// /// /// This does not block and will only queue an exit request, which is processed in the main frame loop. /// /// Thrown when attempting to exit from an invalid state. public void Exit() { lock (startStopLock) { switch (state.Value) { // technically we could support this, but we don't use this yet and it will add more complexity. case GameThreadState.Paused: case GameThreadState.NotStarted: case GameThreadState.Starting: throw new InvalidOperationException($"Cannot exit when thread is {state.Value}."); case GameThreadState.Exited: return; default: // actual exit will be done in ProcessFrame. exitRequested = true; break; } } } /// /// Prepare this thread for performing work. Must be called when entering a running state. /// /// Whether this thread's clock should be throttling via thread sleeps. internal void Initialize(bool withThrottling) { lock (startStopLock) { Debug.Assert(state.Value != GameThreadState.Running); Debug.Assert(state.Value != GameThreadState.Exited); MakeCurrent(); OnInitialize(); Clock.Throttling = withThrottling; Monitor.MakeCurrent(); updateCulture(); initializedEvent.Set(); state.Value = GameThreadState.Running; } } /// /// Run when thread transitions into an active/processing state, at the beginning of each frame. /// internal virtual void MakeCurrent() { ThreadSafety.ResetAllForCurrentThread(); } /// /// Runs a single frame, updating the execution state if required. /// internal void RunSingleFrame() { var newState = processFrame(); if (newState.HasValue) setExitState(newState.Value); } /// /// Pause this thread. Must be run from in a safe manner. /// /// /// This method blocks until in a paused state. /// internal void Pause() { lock (startStopLock) { if (state.Value != GameThreadState.Running) return; // actual pause will be done in ProcessFrame. pauseRequested = true; } WaitForState(GameThreadState.Paused); } /// /// Spin indefinitely until this thread enters a required state. /// For cases where no native thread is present, this will run until the required state is reached. /// /// The state to wait for. internal void WaitForState(GameThreadState targetState) { if (state.Value == targetState) return; if (Thread == null) { GameThreadState? newState = null; // 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. // run frames until the required state is reached. while (newState != targetState) newState = processFrame(); // note that the only state transition here can be an exiting one. entering a running state can only occur in Initialize(). setExitState(newState.Value); } else { while (state.Value != targetState) Thread.Sleep(1); } } /// /// Prepares this game thread for work. Should block until has been run. /// protected virtual void PrepareForWork() { Debug.Assert(Thread == null); createThread(); Debug.Assert(Thread != null); Thread.Start(); } /// /// Called whenever the thread is initialised. Should prepare the thread for performing work. /// protected virtual void OnInitialize() { } /// /// Called when a or is requested on this . /// Use this method to release exclusive resources that the thread could have been holding in its current execution mode, /// like GL contexts or similar. /// protected virtual void OnSuspended() { } /// /// Called when the thread is exited. Should clean up any thread-specific resources. /// protected virtual void OnExit() { } private void updateMaximumHz() { Scheduler.Add(() => Clock.MaximumUpdateHz = IsActive.Value ? activeHz : inactiveHz); } /// /// Create the native backing thread to run work. /// /// /// This does not start the thread, but guarantees is non-null. /// private void createThread() { Debug.Assert(Thread == null); Debug.Assert(!Running); Thread = new Thread(runWork) { Name = PrefixedThreadNameFor(Name), IsBackground = true, }; void runWork() { Initialize(true); while (Running) RunSingleFrame(); } } /// /// Process a single frame of this thread's work. /// /// A potential execution state change. private GameThreadState? processFrame() { if (state.Value != GameThreadState.Running) // host could be in a suspended state. the input thread will still make calls to ProcessFrame so we can't throw. return null; MakeCurrent(); if (exitRequested) { exitRequested = false; return GameThreadState.Exited; } if (pauseRequested) { pauseRequested = false; return GameThreadState.Paused; } try { Monitor?.NewFrame(); using (Monitor?.BeginCollecting(PerformanceCollectionType.Scheduler)) Scheduler.Update(); using (Monitor?.BeginCollecting(PerformanceCollectionType.Work)) OnNewFrame?.Invoke(); using (Monitor?.BeginCollecting(PerformanceCollectionType.Sleep)) Clock.ProcessFrame(); Monitor?.EndFrame(); } catch (Exception e) { if (UnhandledException != null && !ThreadSafety.IsInputThread) // the handler schedules back to the input thread, so don't run it if we are already on the input thread UnhandledException.Invoke(this, new UnhandledExceptionEventArgs(e, false)); else throw; } return null; } private void updateCulture() { if (Thread == null || culture == null) return; Thread.CurrentCulture = culture; Thread.CurrentUICulture = culture; } private void setExitState(GameThreadState exitState) { lock (startStopLock) { Debug.Assert(state.Value == GameThreadState.Running); Debug.Assert(exitState == GameThreadState.Exited || exitState == GameThreadState.Paused); Thread = null; OnSuspended(); switch (exitState) { case GameThreadState.Exited: Monitor?.Dispose(); initializedEvent?.Dispose(); OnExit(); break; } state.Value = exitState; } } private class GameThreadScheduler : Scheduler { public GameThreadScheduler(GameThread thread) : base(() => thread.IsCurrent, thread.Clock) { } } } }