Многопоточность в редакторе Unity
Я хочу параллельно делать какие-то штуки в редакторе, но при этом я не хочу вешать UI редактора во время расчетов. Можно ли использовать Task'и или что-то подобное? Или хотя бы потоками вручную управлять?
Ответы (2 шт):
Можно, за одним исключением - Unity запрещает вызывать большую часть своего API вне своего главного потока.
Класс Debug и его методы, к слову, могут вызываться в дочерних потоков.
Что в "рантайме", что в редакторе, Unity имеет свою реализацию SynchronizationContext - UnitySynchronizationContext, кроме этого, нам, в принципе, ничего и не нужно.
Тривиальная задача решается просто до безобразия:
CancellationTokenSource _cts;
var token = _cts.Token;
var context = SynchronizationContext.Current;
Task.Run(() => DoWork(context, ...), token);
Это все и так было понятно, вопрос состоит в том, чтобы после этого вернуться в главный поток, да и к тому же еще в какое-то окно что-то передать.
Ну, вариантов у нас не особо много, а вернее ровно 1 - EditorWindow.GetWindow<T>, который либо создает окно, либо восстанавливает сохраненное окно, либо же, в лучшем случае, фокусируется на открытом окне.
Для бэкграунд таска хотелось бы, чтобы окно что-то там обновляло, но пользователя не дергало и не забирало фокус у активных окон. Тут у нас уже 2 выхода:
- Singleton - классика, но к нему уже банально не интересно обращаться за решением :)
GetWindow<T>("Title", focus: false)- придется передавать заново заголовок окна, но что поделать.
Остановимся на втором варианте и получим что-то такое:
private static CustomWindow GetCustomWindow(bool focus) {
return GetWindow<CustomWindow>("Task example", focus);
}
Дальше все тривиально:
В своем окне имеет какую-то меру "прогресса":
private float _progress = 0f;
private float Progress {
set {
_progress = value;
// этот вызов перерисовки обязателен - без него не обновится визуал
Repaint();
}
}
Делаем важные вещи в таске - спим:
private static void DumbTaskExample(int subTaskCount, int sleepTime, SynchronizationContext context, CancellationToken token) {
Debug.Log($"Task started at {Thread.CurrentThread.ManagedThreadId}");
for (int i = 0; i < subTaskCount; i++) {
// Cancellation
token.ThrowIfCancellationRequested();
// Work
Thread.Sleep(sleepTime);
// Notification
context.Post(_ => GetCustomWindow(false).Progress = (float)i/subTaskCount, null);
}
Debug.Log($"Task done at {Thread.CurrentThread.ManagedThreadId}");
context.Post(_ => GetCustomWindow(true).OnTaskFinishedOrCanceled(), null);
}
Debug.Log() просто ради логов - так проще отлаживать. С помощью контекста получаем ссылку на окно и вызываем у него нужные методы.
Таинственный метод в конце таска - освобождение ресурсов и флагов:
private void OnTaskFinishedOrCanceled() {
_taskRunning = false;
_cts.Dispose();
_cts = null;
}
Чего-то примечательного в вызове таких тасков или их отмене нет:
private void OnGUI() {
ProgressBar();
if (_taskRunning) {
if (GUILayout.Button("Cancel task")) {
_cts.Cancel();
OnTaskFinishedOrCanceled();
}
}
else {
_sleepTaskCount = EditorGUILayout.IntField("Sleeping tasks count:", _sleepTaskCount);
_sleepTaskTime = EditorGUILayout.IntField("Sleeping time (ms):", _sleepTaskTime);
if (GUILayout.Button("Start single task") && _sleepTaskCount > 0) {
_progress = 0f;
_taskRunning = true;
_cts = new CancellationTokenSource();
var token = _cts.Token;
var context = SynchronizationContext.Current;
Task.Run(() => DumbTaskExample(_sleepTaskCount, _sleepTaskTime, context, token), token);
}
}
}
private void ProgressBar() {
var size = position.size;
var fullRect = GUILayoutUtility.GetRect(size.x, 30);
var completedRect = new Rect(fullRect.x, fullRect.y, fullRect.width * _progress, fullRect.height);
EditorGUI.DrawRect(fullRect, Color.black);
EditorGUI.DrawRect(completedRect, Color.Lerp(Color.red, Color.green, _progress));
EditorGUI.LabelField(fullRect, $"{_progress * 100}%", EditorStyles.centeredGreyMiniLabel);
}
Получаем результат:
Также не стоит забывать про цепочки тасков. Например, если нужно выполнить несколько последовательных задач, но при этом результат каждой задачи зависит от предыдущей и нужно бы проверить достоверность результатов на всех этапах. Ну или какой-то этап мог спокойно прокинуть исключение.
Пишем хэндлер исключений:
private static void HandleTaskException(Task task) {
Exception ex = task.Exception;
while (ex is AggregateException && ex.InnerException != null)
ex = ex.InnerException;
EditorUtility.DisplayDialog("Task chain terminated", $"Exception: {ex.Message}", "Ok");
}
Создаем цепочку:
Task.Run(() => DumbTaskExample(_sleepTaskCount, _sleepTaskTime, context, token), token)
.ContinueWith(
t => { HandleTaskException(t); OnTaskFinishedOrCanceled(); },
token,
TaskContinuationOptions.OnlyOnFaulted,
TaskScheduler.FromCurrentSynchronizationContext()
);
Здесь очень важно указывать планировщика текущего контекста, иначе продолжение выполнится там же, где и таск - вне главного потока Unity.
Из всего этого получаем не очень красивое, но зато блокирующее весь UI Unity диалоговое окно:
Полный код
код помещен в контейнер ради спойлера
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
public class CustomWindow : EditorWindow {
[MenuItem("Window/Task example")]
private static void ShowWindow() {
GetCustomWindow(true).Show();
}
private static CustomWindow GetCustomWindow(bool focus) {
return GetWindow<CustomWindow>("Task example", focus);
}
private float _progress = 0f;
private float Progress {
set {
_progress = value;
Repaint();
}
}
private bool _taskRunning;
private int _sleepTaskCount = 3;
private int _sleepTaskTime = 1000;
private CancellationTokenSource _cts;
private void OnGUI() {
ProgressBar();
if (_taskRunning) {
if (GUILayout.Button("Cancel task")) {
_cts.Cancel();
OnTaskFinishedOrCanceled();
}
}
else {
_sleepTaskCount = EditorGUILayout.IntField("Sleeping sub-tasks count:", _sleepTaskCount);
_sleepTaskTime = EditorGUILayout.IntField("sleeping time (ms):", _sleepTaskTime);
if (GUILayout.Button("Start single task") && _sleepTaskCount > 0) {
_progress = 0f;
_taskRunning = true;
_cts = new CancellationTokenSource();
var token = _cts.Token;
var context = SynchronizationContext.Current;
Task.Run(() => DumbTaskExample(_sleepTaskCount, _sleepTaskTime, context, token), token);
}
else if (GUILayout.Button("Start chained task") && _sleepTaskCount > 0) {
_progress = 0f;
_taskRunning = true;
_cts = new CancellationTokenSource();
var token = _cts.Token;
var context = SynchronizationContext.Current;
Task.Run(() => DumbTaskExample(_sleepTaskCount, _sleepTaskTime, context, token), token)
.ContinueWith(
t => { HandleTaskException(t); OnTaskFinishedOrCanceled(); },
token,
TaskContinuationOptions.OnlyOnFaulted,
TaskScheduler.FromCurrentSynchronizationContext()
);
}
}
}
private void ProgressBar() {
var size = position.size;
var fullRect = GUILayoutUtility.GetRect(size.x, 30);
var completedRect = new Rect(fullRect.x, fullRect.y, fullRect.width * _progress, fullRect.height);
EditorGUI.DrawRect(fullRect, Color.black);
EditorGUI.DrawRect(completedRect, Color.Lerp(Color.red, Color.green, _progress));
EditorGUI.LabelField(fullRect, $"{_progress * 100}%", EditorStyles.centeredGreyMiniLabel);
}
private void OnTaskFinishedOrCanceled() {
_taskRunning = false;
_cts.Dispose();
_cts = null;
}
private static void DumbTaskExample(int subTaskCount, int sleepTime, SynchronizationContext context, CancellationToken token) {
Debug.Log($"Task started at {Thread.CurrentThread.ManagedThreadId}");
for (int i = 0; i < subTaskCount; i++) {
// Cancellation
token.ThrowIfCancellationRequested();
// Work
Thread.Sleep(sleepTime);
// Notification
context.Post(_ => GetCustomWindow(false).Progress = (float)i/subTaskCount, null);
}
Debug.Log($"Task done at {Thread.CurrentThread.ManagedThreadId}");
context.Post(_ => GetCustomWindow(true).OnTaskFinishedOrCanceled(), null);
}
private static void HandleTaskException(Task task) {
if (task.IsFaulted) {
Exception ex = task.Exception;
while (ex is AggregateException && ex.InnerException != null)
ex = ex.InnerException;
EditorUtility.DisplayDialog("Task chain terminated", $"Exception: {ex.Message}", "Ok");
}
}
}
Итог
Мы получили довольно простой в написании способ создавать таски и даже цепочки тасков с обработкой исключений. Сюда еще можно прицепить токены отмены, обработку этих ситуаций, обрабатывать ситуации закрытия окна, etc.
Ответ и без всяких тонкостей получился громоздкий, да и на основе этого можно уже всякие примочки сверху приделать - было бы желание :)
Чтоб вызвать API юнити в тасках беру Dispatcher или в этом ответе и переделываю метод так DumbTaskExample :
async private static void DumbTaskExample (int subTaskCount, int sleepTime, SynchronizationContext context, CancellationToken token) {
Debug.Log ($"Task started at {Thread.CurrentThread.ManagedThreadId}");
for (int i = 0; i < subTaskCount; i++) {
// Cancellation
token.ThrowIfCancellationRequested ();
// Work
Thread.Sleep (sleepTime);
string path = "none";
Debug.Log ("path=" + path);
path = await Task.Run (() => {
bool val = false;
Dispatcher.Instance.Invoke (() => {
Texture2D tempTex2D = Resources.Load<Texture2D> ("Texture/Rock (Moss)");
path = AssetDatabase.GetAssetPath (tempTex2D);
Debug.Log ("Dispatcher.path=" + path);
val = true;
});
while (val == false) {
Debug.Log ("val");
}
return path;
});
Debug.Log ("path=" + path);
// Notification
context.Post (_ => GetCustomWindow (false).Progress = (float) i / subTaskCount, null);
//throw new Exception("Dumb exception");
}
Debug.Log ($"Task done at {Thread.CurrentThread.ManagedThreadId}");
context.Post (_ => GetCustomWindow (true).OnTaskFinishedOrCanceled (), null);
}
не забудьте добавить в OnGUI():
private void OnGUI() {
...
Dispatcher.Instance.InvokePending ();
}
Вывод:


