You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

282 lines
11 KiB

  1. #nullable enable
  2. using System;
  3. using System.Collections;
  4. using System.Collections.Concurrent;
  5. using System.Collections.Generic;
  6. using System.Diagnostics;
  7. using System.Linq;
  8. using System.Runtime.CompilerServices;
  9. using System.Threading.Tasks;
  10. namespace IPA.Utilities.Async
  11. {
  12. /// <summary>
  13. /// A task scheduler that runs tasks on the Unity main thread via coroutines.
  14. /// </summary>
  15. public class UnityMainThreadTaskScheduler : TaskScheduler, IDisposable
  16. {
  17. /// <summary>
  18. /// Gets the default main thread scheduler that is managed by BSIPA.
  19. /// </summary>
  20. /// <value>a scheduler that is managed by BSIPA</value>
  21. public static new UnityMainThreadTaskScheduler Default { get; } = new UnityMainThreadTaskScheduler();
  22. /// <summary>
  23. /// Gets a factory for creating tasks on <see cref="Default"/>.
  24. /// </summary>
  25. /// <value>a factory for creating tasks on the default scheduler</value>
  26. public static TaskFactory Factory { get; } = new TaskFactory(Default);
  27. private readonly ConcurrentQueue<QueueItem> tasks = new();
  28. private static readonly ConditionalWeakTable<Task, QueueItem> itemTable = new();
  29. private class QueueItem : IEquatable<Task>, IEquatable<QueueItem>
  30. {
  31. private bool hasTask;
  32. public bool HasTask
  33. {
  34. get => hasTask;
  35. set
  36. {
  37. hasTask = value;
  38. if (!hasTask) Task = null;
  39. }
  40. }
  41. public Task? Task { get; private set; }
  42. public Action? Action { get; private set; }
  43. public QueueItem(Task task)
  44. {
  45. HasTask = true;
  46. Task = task;
  47. }
  48. public QueueItem(Action action)
  49. {
  50. HasTask = true;
  51. Action = action;
  52. }
  53. public bool Equals(Task? other) => other is not null && HasTask && other.Equals(Task);
  54. public bool Equals(QueueItem other) => other.HasTask == HasTask && Equals(other.Task);
  55. }
  56. /// <summary>
  57. /// Gets whether or not this scheduler is currently executing tasks.
  58. /// </summary>
  59. /// <value><see langword="true"/> if the scheduler is running, <see langword="false"/> otherwise</value>
  60. public bool IsRunning { get; private set; } = false;
  61. /// <summary>
  62. /// Gets whether or not this scheduler is in the process of shutting down.
  63. /// </summary>
  64. /// <value><see langword="true"/> if the scheduler is shutting down, <see langword="false"/> otherwise</value>
  65. public bool Cancelling { get; private set; } = false;
  66. private int yieldAfterTasks = 64;
  67. /// <summary>
  68. /// Gets or sets the number of tasks to execute before yielding back to Unity.
  69. /// </summary>
  70. /// <value>the number of tasks to execute per resume</value>
  71. public int YieldAfterTasks
  72. {
  73. get => yieldAfterTasks;
  74. set
  75. {
  76. ThrowIfDisposed();
  77. if (value < 1)
  78. throw new ArgumentException("Value cannot be less than 1", nameof(value));
  79. yieldAfterTasks = value;
  80. }
  81. }
  82. private TimeSpan yieldAfterTime = TimeSpan.FromMilliseconds(.5); // auto-yield if more than half a millis has passed by default
  83. /// <summary>
  84. /// Gets or sets the amount of time to execute tasks for before yielding back to Unity. Default is 0.5ms.
  85. /// </summary>
  86. /// <value>the amount of time to execute tasks for before yielding back to Unity</value>
  87. public TimeSpan YieldAfterTime
  88. {
  89. get => yieldAfterTime;
  90. set
  91. {
  92. ThrowIfDisposed();
  93. if (value <= TimeSpan.Zero)
  94. throw new ArgumentException("Value must be greater than zero", nameof(value));
  95. yieldAfterTime = value;
  96. }
  97. }
  98. /// <summary>
  99. /// When used as a Unity coroutine, runs the scheduler. Otherwise, this is an invalid call.
  100. /// </summary>
  101. /// <remarks>
  102. /// <para>
  103. /// Do not ever call <see cref="UnityEngine.MonoBehaviour.StopCoroutine(IEnumerator)"/> on this
  104. /// coroutine, nor <see cref="UnityEngine.MonoBehaviour.StopAllCoroutines"/> on the behaviour hosting
  105. /// this coroutine. This has no way to detect this, and this object will become invalid.
  106. /// </para>
  107. /// <para>
  108. /// If you need to stop this coroutine, first call <see cref="Cancel"/>, then wait for it to
  109. /// exit on its own.
  110. /// </para>
  111. /// </remarks>
  112. /// <returns>a Unity coroutine</returns>
  113. /// <exception cref="ObjectDisposedException">if this scheduler is disposed</exception>
  114. /// <exception cref="InvalidOperationException">if the scheduler is already running</exception>
  115. public IEnumerator Coroutine()
  116. {
  117. ThrowIfDisposed();
  118. if (IsRunning)
  119. throw new InvalidOperationException("Scheduler already running");
  120. Cancelling = false;
  121. IsRunning = true;
  122. yield return null; // yield immediately
  123. var sw = new Stopwatch();
  124. try
  125. {
  126. while (!Cancelling)
  127. {
  128. if (!tasks.IsEmpty)
  129. {
  130. var yieldAfter = YieldAfterTasks;
  131. sw.Start();
  132. for (int i = 0; i < yieldAfter && !tasks.IsEmpty
  133. && sw.Elapsed < YieldAfterTime; i++)
  134. {
  135. QueueItem task;
  136. do if (!tasks.TryDequeue(out task)) goto exit; // try dequeue, if we can't exit
  137. while (!task.HasTask); // if the dequeued task is empty, try again
  138. if (task.Task is not null)
  139. {
  140. _ = TryExecuteTask(task.Task);
  141. }
  142. task.Action?.Invoke();
  143. }
  144. exit:
  145. sw.Reset();
  146. }
  147. yield return null;
  148. }
  149. }
  150. finally
  151. {
  152. sw.Reset();
  153. IsRunning = false;
  154. }
  155. }
  156. /// <summary>
  157. /// Cancels the scheduler. If the scheduler is currently executing tasks, that batch will finish first.
  158. /// All remaining tasks will be left in the queue.
  159. /// </summary>
  160. /// <exception cref="ObjectDisposedException">if this scheduler is disposed</exception>
  161. /// <exception cref="InvalidOperationException">if the scheduler is not running</exception>
  162. public void Cancel()
  163. {
  164. ThrowIfDisposed();
  165. if (!IsRunning) throw new InvalidOperationException("The scheduler is not running");
  166. Cancelling = true;
  167. }
  168. /// <summary>
  169. /// Throws a <see cref="NotSupportedException"/>.
  170. /// </summary>
  171. /// <returns>nothing</returns>
  172. /// <exception cref="NotSupportedException">Always.</exception>
  173. protected override IEnumerable<Task> GetScheduledTasks()
  174. => tasks.ToArray().Where(q => q.HasTask).Select(q => q.Task).NonNull().ToArray();
  175. /// <summary>
  176. /// Queues a given <see cref="Task"/> to this scheduler. The <see cref="Task"/> <i>must</i> be
  177. /// scheduled for this <see cref="TaskScheduler"/> by the runtime.
  178. /// </summary>
  179. /// <param name="task">the <see cref="Task"/> to queue</param>
  180. /// <exception cref="ObjectDisposedException">Thrown if this object has already been disposed.</exception>
  181. protected override void QueueTask(Task task)
  182. {
  183. ThrowIfDisposed();
  184. var item = new QueueItem(task);
  185. itemTable.Add(task, item);
  186. tasks.Enqueue(item);
  187. }
  188. internal void QueueAction(Action action)
  189. {
  190. ThrowIfDisposed();
  191. tasks.Enqueue(new(action));
  192. }
  193. /// <summary>
  194. /// Runs the task inline if the current thread is the Unity main thread.
  195. /// </summary>
  196. /// <param name="task">the task to attempt to execute</param>
  197. /// <param name="taskWasPreviouslyQueued">whether the task was previously queued to this scheduler</param>
  198. /// <returns><see langword="false"/> if the task could not be run, <see langword="true"/> if it was</returns>
  199. /// <exception cref="ObjectDisposedException">Thrown if this object has already been disposed.</exception>
  200. protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
  201. {
  202. ThrowIfDisposed();
  203. if (!UnityGame.OnMainThread) return false;
  204. if (taskWasPreviouslyQueued)
  205. {
  206. if (itemTable.TryGetValue(task, out var item))
  207. {
  208. if (!item.HasTask) return false;
  209. item.HasTask = false;
  210. }
  211. else return false; // if we couldn't remove it, its not in our queue, so it already ran
  212. }
  213. return TryExecuteTask(task);
  214. }
  215. private void ThrowIfDisposed()
  216. {
  217. if (disposedValue)
  218. throw new ObjectDisposedException(nameof(SingleThreadTaskScheduler));
  219. }
  220. #region IDisposable Support
  221. private bool disposedValue = false; // To detect redundant calls
  222. /// <summary>
  223. /// Disposes this object.
  224. /// </summary>
  225. /// <param name="disposing">whether or not to dispose managed objects</param>
  226. protected virtual void Dispose(bool disposing)
  227. {
  228. if (!disposedValue)
  229. {
  230. if (disposing)
  231. {
  232. }
  233. disposedValue = true;
  234. }
  235. }
  236. /// <summary>
  237. /// Disposes this object. This puts the object into an unusable state.
  238. /// </summary>
  239. // This code added to correctly implement the disposable pattern.
  240. public void Dispose()
  241. {
  242. // Do not change this code. Put cleanup code in Dispose(bool disposing) above.
  243. Dispose(true);
  244. }
  245. #endregion
  246. }
  247. }