Работа с Task (async/await) из главного потока в Unity

Рейтинг: 0Ответов: 1Опубликовано: 17.01.2023

Есть код, который записывает текст в какое-либо поле данных UnityCloud:

public async void WriteTimer(string status)
{
    var arguments = new Dictionary<string, object>
    {
        { "name", ActiveModule.gameObject.name },
        { "status", status }
    };

    var response = await CloudCodeService.Instance.CallEndpointAsync("SetTime", arguments);
}

И есть кнопка, которая должна ожидать завершения async метода, чтоб изменить своё состояние.

Как заставить кнопку ожидать ответа от Task?

P.S. Моя конструкция Task, такого формата:

public async Task WriteTimer(string status)
{
    var arguments = new Dictionary<string, object>
    {
        { "name", ActiveModule.gameObject.name },
        { "status", status }
    };

    await Task.Run(()=>CloudCodeService.Instance.CallEndpointAsync("SetTime", arguments));
}

А так же дальнейшее ожидание await WriteTimer(status); в кнопке не дало результата.

(UnityException: get_gameObject can only be called from the main thread. Constructors and field initializers will be executed from the loading thread when loading a scene.)

Поэтому в данный момент вся конструкция работает на корутинах синхронно ожидая ответа от асинхронного метода (Знаю, что так нельзя).

Ответы

▲ 1Принят

Сразу скажу, красивого решения пока нет. Разработчики Unity сейчас работают над тем чтобы реализовать async/await как надо, ждемс. Поэтому если не изобретать, то корутины вполне себе нормальное решение.

Смысл проблемы в том, что в Unity пока нет рабочего контекста синхронизации, то есть продолжение асинхронного метода будет выполнено на потоке из пула, так себя ведет SynchronizationContext по умолчанию. Но при этом сам движок на уровне скриптов однопоточный. Поэтому нужна какая-то синхронизация.

Альтернативно вы можете запустить асинхронную операцию.

Task task = CloudCodeService.Instance.CallEndpointAsync("SetTime", arguments);

Куда-то этот таск положить, в поле например.

А затем например каждый кадр его проверять

if (task.IsCompleted)
{
    try
    {
        // таск завершен
        task.Wait(); // для Task
        //var result = task.Result; // для Task<T>

        // здесь можно что-то сделать с результатом или отреагировать на то что таск завершился.
    }
    catch (Exception ex)
    {
        Debug.Log(ex.ToString());
    }
}

И всё, никаких корутин, только поллинг. Unity-стайл. :)


С другой стороны, async/await не магия же какая-то. Можно и приручить, правда уже получится кода побольше. Из нехороших штук, придется использовать тот же поллинг, то есть Update для разгребания очереди асинхронных коллбэков.

Давайте ради интереса и обучения создадим такой контекст синхронизации.

public class SingleThreadedSynchronizationContext : SynchronizationContext
{
    private readonly ConcurrentQueue<(SendOrPostCallback, object)> _queue = new();

    public override void Post(SendOrPostCallback d, object state)
    {
        _queue.Enqueue((d, state));
    }

    public override void Send(SendOrPostCallback d, object state)
    {
        if (Current == this)
            d(state);
        else
        {
            using ManualResetEventSlim mre = new();
            ExceptionDispatchInfo edi = null;
            Post(s =>
            {
                try
                {
                    d(s);
                }
                catch (Exception ex)
                {
                    edi = ExceptionDispatchInfo.Capture(ex); // ловим исключение в колбэке
                }
                mre.Set();
            }, state);
            mre.Wait();
            edi?.Throw(); // выбрасываем пойманное исключение в вызывающей точке
        }
    }

    public void Init(bool force = false)
    {
        if (Current is null || force)
            SetSynchronizationContext(this);
        else if (Current != this)
            throw new InvalidOperationException($"Уже существует другой контекст синхронизации {Current.GetType().Name}, не удается проинициализировать контекст");
    }

    public bool ExecuteCallback()
    {
        if (_queue.TryDequeue(out var t))
        {
            (SendOrPostCallback d, object state) = t;
            d(state);
            return true;
        }
        return false;
    }
}

Много всего непонятно, ну да ладно. Теперь как это вкрутить в Unity. Пусть будет вот такой скрипт.

public class SynchronizationBehavior : MonoBehavior
{
     private SingleThreadedSynchronizationContext _context;
     
     void Awake()
     {
         _context = new SingleThreadedSynchronizationContext();
         _context.Init();
     }

     void Update()
     {
          while (_context.ExecuteCallback()) { } // разгрести очередь колбэков
     }
}

Теперь этот скрипт в одном экземпляре нужно куда-то навесить.

Теперь вы сможете в любом другом скрипте выполнить асинхронный код не вывалившись из контекста синхронизации.

void Start()
{
    WriteTimer("...");
}

public async void WriteTimer(string status)
{
    try
    {
        var arguments = new Dictionary<string, object>
        {
            { "name", ActiveModule.gameObject.name },
            { "status", status }
        };

        var response = await CloudCodeService.Instance.CallEndpointAsync("SetTime", arguments);
    }
    catch (Exception ex)
    {
        Debug.Log(ex.ToString());
    }
}

В async void методах обязательно оборачивайте весь код в try-catch.

await Task.Run(()=> ...) для асинхронных методов делать не нужно. Это бессмысленно.

Больше про однопоточный контекст синхронизации я писал здесь и здесь.