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
4// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
5// See the LICENCE file in the repository root for full licence text.
6
7using System;
8using System.Collections.Generic;
9using System.Diagnostics;
10using System.Globalization;
11using System.Linq;
12using System.Threading;
13using osu.Framework.Development;
14using osu.Framework.Extensions.IEnumerableExtensions;
15using osu.Framework.Logging;
16using osu.Framework.Threading;
17
18namespace osu.Framework.Platform
19{
20 /// <summary>
21 /// Runs a game host in a specific threading mode.
22 /// </summary>
23 public class ThreadRunner
24 {
25 private readonly InputThread mainThread;
26
27 private readonly List<GameThread> threads = new List<GameThread>();
28
29 public IReadOnlyCollection<GameThread> Threads
30 {
31 get
32 {
33 lock (threads)
34 return threads.ToArray();
35 }
36 }
37
38 private double maximumUpdateHz = GameThread.DEFAULT_ACTIVE_HZ;
39
40 public double MaximumUpdateHz
41 {
42 set
43 {
44 maximumUpdateHz = value;
45 updateMainThreadRates();
46 }
47 }
48
49 private double maximumInactiveHz = GameThread.DEFAULT_INACTIVE_HZ;
50
51 public double MaximumInactiveHz
52 {
53 set
54 {
55 maximumInactiveHz = value;
56 updateMainThreadRates();
57 }
58 }
59
60 private readonly object startStopLock = new object();
61
62 /// <summary>
63 /// Construct a new ThreadRunner instance.
64 /// </summary>
65 /// <param name="mainThread">The main window thread. Used for input in multi-threaded execution; all game logic in single-threaded execution.</param>
66 /// <exception cref="NotImplementedException"></exception>
67 public ThreadRunner(InputThread mainThread)
68 {
69 this.mainThread = mainThread;
70 AddThread(mainThread);
71 }
72
73 /// <summary>
74 /// Add a new non-main thread. In single-threaded execution, threads will be executed in the order they are added.
75 /// </summary>
76 public void AddThread(GameThread thread)
77 {
78 lock (threads)
79 {
80 if (!threads.Contains(thread))
81 threads.Add(thread);
82 }
83 }
84
85 /// <summary>
86 /// Remove a non-main thread.
87 /// </summary>
88 public void RemoveThread(GameThread thread)
89 {
90 lock (threads)
91 threads.Remove(thread);
92 }
93
94 private ExecutionMode? activeExecutionMode;
95
96 public ExecutionMode ExecutionMode { private get; set; } = ExecutionMode.MultiThreaded;
97
98 public virtual void RunMainLoop()
99 {
100 // propagate any requested change in execution mode at a safe point in frame execution
101 ensureCorrectExecutionMode();
102
103 Debug.Assert(activeExecutionMode != null);
104
105 switch (activeExecutionMode.Value)
106 {
107 case ExecutionMode.SingleThread:
108 {
109 lock (threads)
110 {
111 foreach (var t in threads)
112 t.RunSingleFrame();
113 }
114
115 break;
116 }
117
118 case ExecutionMode.MultiThreaded:
119 // still need to run the main/input thread on the window loop
120 mainThread.RunSingleFrame();
121 break;
122 }
123 }
124
125 public void Start() => ensureCorrectExecutionMode();
126
127 public void Suspend()
128 {
129 lock (startStopLock)
130 {
131 pauseAllThreads();
132 activeExecutionMode = null;
133 }
134 }
135
136 public void Stop()
137 {
138 const int thread_join_timeout = 30000;
139
140 Threads.ForEach(t => t.Exit());
141 Threads.Where(t => t.Running).ForEach(t =>
142 {
143 var thread = t.Thread;
144
145 if (thread == null)
146 {
147 // has already been cleaned up (or never started)
148 return;
149 }
150
151 if (!thread.Join(thread_join_timeout))
152 Logger.Log($"Thread {t.Name} failed to exit in allocated time ({thread_join_timeout}ms).", LoggingTarget.Runtime, LogLevel.Important);
153 });
154
155 // as the input thread isn't actually handled by a thread, the above join does not necessarily mean it has been completed to an exiting state.
156 mainThread.WaitForState(GameThreadState.Exited);
157
158 ThreadSafety.ResetAllForCurrentThread();
159 }
160
161 private void ensureCorrectExecutionMode()
162 {
163 // locking is required as this method may be called from two different threads.
164 lock (startStopLock)
165 {
166 if (ExecutionMode == activeExecutionMode)
167 return;
168
169 activeExecutionMode = ThreadSafety.ExecutionMode = ExecutionMode;
170 Logger.Log($"Execution mode changed to {activeExecutionMode}");
171 }
172
173 pauseAllThreads();
174
175 switch (ExecutionMode)
176 {
177 case ExecutionMode.MultiThreaded:
178 {
179 // switch to multi-threaded
180 foreach (var t in Threads)
181 t.Start();
182
183 break;
184 }
185
186 case ExecutionMode.SingleThread:
187 {
188 // switch to single-threaded.
189 foreach (var t in Threads)
190 {
191 // only throttle for the main thread
192 t.Initialize(withThrottling: t == mainThread);
193 }
194
195 // this is usually done in the execution loop, but required here for the initial game startup,
196 // which would otherwise leave values in an incorrect state.
197 ThreadSafety.ResetAllForCurrentThread();
198 break;
199 }
200 }
201
202 updateMainThreadRates();
203 }
204
205 private void pauseAllThreads()
206 {
207 // shut down threads in reverse to ensure audio stops last (other threads may be waiting on a queued event otherwise)
208 foreach (var t in Threads.Reverse())
209 t.Pause();
210 }
211
212 private void updateMainThreadRates()
213 {
214 if (activeExecutionMode == ExecutionMode.SingleThread)
215 {
216 mainThread.ActiveHz = maximumUpdateHz;
217 mainThread.InactiveHz = maximumInactiveHz;
218 }
219 else
220 {
221 mainThread.ActiveHz = GameThread.DEFAULT_ACTIVE_HZ;
222 mainThread.InactiveHz = GameThread.DEFAULT_INACTIVE_HZ;
223 }
224 }
225
226 /// <summary>
227 /// Sets the current culture of all threads to the supplied <paramref name="culture"/>.
228 /// </summary>
229 public void SetCulture(CultureInfo culture)
230 {
231 // for single-threaded mode, switch the current (assumed to be main) thread's culture, since it's actually the one that's running the frames.
232 Thread.CurrentThread.CurrentCulture = culture;
233
234 // for multi-threaded mode, schedule the culture change on all threads.
235 // note that if the threads haven't been created yet (e.g. if the game started single-threaded), this will only store the culture in GameThread.CurrentCulture.
236 // in that case, the stored value will be set on the actual threads after the next Start() call.
237 foreach (var t in Threads)
238 {
239 t.Scheduler.Add(() => t.CurrentCulture = culture);
240 }
241 }
242 }
243}