Корутины в Unity: как запустить, подождать и остановить

Корутины в Unity: как запустить, подождать и остановить Полезное

Корутина (сопрограмма) в Unity — это метод C#, который возвращает IEnumerator и запускается через StartCoroutine. На каждом yield return метод приостанавливается и отдает управление движку, а Unity продолжает его с того же места позже: в следующем кадре, через заданное время или когда выполнится условие. Весь код корутины при этом выполняется в главном потоке.

Ниже — минимальный рабочий пример, что происходит под капотом, чего можно ждать через yield, как правильно останавливать корутину и когда вместо нее лучше взять async/Awaitable из Unity 6.

Три понятия, которые путают

Понятие Что это Где выполняется код
Корутина итератор C#, который Unity «докручивает» по кадрам главный поток Unity
Поток (Thread, Task.Run) параллельное выполнение средствами .NET другой поток; API Unity оттуда вызывать нельзя
async/await с Awaitable асинхронный метод C# с типом ожидания от Unity главный поток, пока явно не перейти в фоновый

Главное следствие: корутина не делает тяжелый расчет быстрее и не разгружает процессор. Если внутри нее цикл на миллион итераций без yield, кадр зависнет точно так же, как в Update.

Минимальный пример: обратный отсчет

Скрипт вешается на любой объект сцены. Через секунду после запуска в Console появляется следующая строка отсчета.

using System.Collections;
using UnityEngine;

public class CountdownExample : MonoBehaviour
{
    void Start()
    {
        StartCoroutine(Countdown(3));
        Debug.Log("Start закончился, корутина продолжает работу");
    }

    IEnumerator Countdown(int seconds)
    {
        for (int i = seconds; i > 0; i--)
        {
            Debug.Log($"Осталось {i} с");
            yield return new WaitForSeconds(1f);
        }
        Debug.Log("Поехали!");
    }
}

Порядок сообщений в Console:

Осталось 3 с
Start закончился, корутина продолжает работу
Осталось 2 с
Осталось 1 с
Поехали!

Обратите внимание на первые две строки. StartCoroutine выполняет тело корутины сразу, синхронно, до первого yield, и только потом возвращает управление в Start. Остальные шаги идут с паузой около секунды: WaitForSeconds продолжает работу в первом кадре, когда время ожидания истекло, поэтому пауза не короче заданной, но может быть длиннее на долю кадра.

Что происходит под капотом

Unity опирается на обычные итераторы C#. Вызов метода с yield не выполняет его тело, а только создает объект-итератор. Каждый вызов MoveNext() выполняет код до следующего yield. Это можно увидеть без Unity, в консольном приложении .NET:

using System;
using System.Collections;

class Program
{
    static IEnumerator Countdown()
    {
        Console.WriteLine("старт");
        for (int i = 3; i > 0; i--)
        {
            Console.WriteLine(i);
            yield return null;          // пауза: вернуть управление вызывающему
        }
        Console.WriteLine("готово");
    }

    static void Main()
    {
        IEnumerator routine = Countdown();   // тело еще НЕ выполнялось
        Console.WriteLine("итератор создан");

        int frame = 0;
        while (routine.MoveNext())           // так Unity "двигает" корутину
        {
            frame++;
            Console.WriteLine($"--- конец кадра {frame}");
        }

        Console.WriteLine(ReferenceEquals(Countdown(), Countdown()));
    }
}

Вывод на .NET 10:

итератор создан
старт
3
--- конец кадра 1
2
--- конец кадра 2
1
--- конец кадра 3
готово
False

Это упрощенная модель: настоящий планировщик Unity еще смотрит на то, что вернул yield (время, условие, другую корутину). Но из примера видны две вещи, на которых строятся частые ошибки. Первая: вызов Countdown() без StartCoroutine ничего не запустит. Вторая: каждый вызов метода создает новый итератор, поэтому ReferenceEquals дает False.

Чего можно ждать через yield

Что вернуть Когда корутина продолжится
yield return null в следующем кадре, после Update всех скриптов
new WaitForSeconds(t) через t секунд игрового времени (зависит от Time.timeScale)
new WaitForSecondsRealtime(t) через t секунд реального времени, пауза игры не влияет
new WaitForFixedUpdate() после очередного шага физики (FixedUpdate)
new WaitForEndOfFrame() в конце кадра, после отрисовки
new WaitUntil(() => условие) / WaitWhile когда условие станет истинным / ложным
StartCoroutine(Other()) когда закончится вложенная корутина
yield break корутина завершается сразу

Практическое следствие про паузу: если игра ставится на паузу через Time.timeScale = 0, корутина на WaitForSeconds не продолжится, пока время снова не пойдет. Для меню паузы и таймеров интерфейса берут WaitForSecondsRealtime.

Как запустить корутину: три варианта

Запуск Параметры Как остановить
StartCoroutine(Blink(0.5f)) любые, проверяются компилятором по сохраненному Coroutine или тому же IEnumerator
StartCoroutine("Fade", 2f) не больше одного, тип object StopCoroutine("Fade")
StartCoroutine(routine), где routine — сохраненный IEnumerator заданы при создании итератора StopCoroutine(routine)

Строковый вариант хрупкий: опечатку в имени компилятор не поймает. Основной выбор — передача IEnumerator с сохранением результата StartCoroutine.

Корутина принадлежит компоненту MonoBehaviour, у которого вызвали StartCoroutine. Если написать other.StartCoroutine(...), остановить ее можно будет только через other.

Как остановить корутину правильно

Надежный способ — сохранить объект Coroutine, который возвращает StartCoroutine, и передать его в StopCoroutine. Пример: мигание объекта, которое включается и выключается по команде.

using System.Collections;
using UnityEngine;

public class Blinker : MonoBehaviour
{
    Coroutine blinkRoutine;          // ссылка на запущенную корутину
    Renderer rend;

    void Awake() => rend = GetComponent<Renderer>();

    public void StartBlink()
    {
        StopBlink();                 // не плодить вторую копию
        blinkRoutine = StartCoroutine(Blink(0.5f));
    }

    public void StopBlink()
    {
        if (blinkRoutine == null) return;
        StopCoroutine(blinkRoutine);
        blinkRoutine = null;
        rend.enabled = true;         // вернуть объект в исходное состояние
    }

    IEnumerator Blink(float interval)
    {
        var wait = new WaitForSeconds(interval);   // один объект на весь цикл
        while (true)
        {
            rend.enabled = !rend.enabled;
            yield return wait;
        }
    }
}

Повторный StartBlink не запускает вторую копию мигания, после остановки объект снова видим, а WaitForSeconds создается один раз на весь цикл.

Ошибка: остановка через новый итератор

Неверно:

void Start() => StartCoroutine(Tick());
void Stop() => StopCoroutine(Tick());   // новый итератор, не тот, что запущен

IEnumerator Tick()
{
    while (true) { Debug.Log("tick"); yield return null; }
}

Результат: tick продолжает печататься каждый кадр. Tick() в StopCoroutine создает новый итератор (как False в примере выше), а запущенный остается работать. Исправление — сохранить то, что запускали: Coroutine tickRoutine = StartCoroutine(Tick()); и затем StopCoroutine(tickRoutine);.

Другие способы завершения

  • yield break внутри корутины — завершает ее по собственному условию.
  • StopAllCoroutines() — останавливает все корутины этого компонента, а не всей сцены или проекта.
  • Destroy компонента или объекта и gameObject.SetActive(false) останавливают корутины автоматически.
  • enabled = false у скрипта корутины не останавливает: Update перестает вызываться, а корутина идет дальше.

Отсюда же известная ошибка: на неактивном объекте корутину запустить нельзя, Unity выведет ошибку в Console и не выполнит ее.

Если не получилось

Симптом Вероятная причина Что сделать
Корутина не выполняется вообще метод вызван как Countdown() без StartCoroutine обернуть вызов в StartCoroutine
Редактор завис при запуске цикл while (true) без yield внутри добавить yield return null или ожидание в каждую итерацию
Таймер встал во время паузы WaitForSeconds при Time.timeScale = 0 WaitForSecondsRealtime
Эффект запускается дважды и дергается повторный StartCoroutine без остановки старой хранить Coroutine и останавливать перед запуском
Остановка не срабатывает StopCoroutine(Method()) с новым итератором или строкой при запуске через IEnumerator останавливать тем же объектом, которым запускали
Корутина оборвалась без видимой причины объект деактивирован или уничтожен; исключение внутри корутины проверить SetActive/Destroy, посмотреть ошибку в Console

Корутины или async/Awaitable в Unity 6

В Unity 6 есть тип Awaitable, который позволяет писать ту же логику через async/await. Пример ждет секунду, считает сумму в фоновом потоке и возвращается в главный поток, чтобы снова работать с API Unity:

using UnityEngine;

public class AwaitableExample : MonoBehaviour
{
    async void Start()
    {
        try
        {
            await Awaitable.WaitForSecondsAsync(1f, destroyCancellationToken);
            Debug.Log("Прошла секунда");

            await Awaitable.BackgroundThreadAsync();   // дальше - фоновый поток
            long sum = 0;
            for (int i = 0; i < 10_000_000; i++) sum += i;

            await Awaitable.MainThreadAsync();         // обратно в главный поток
            Debug.Log($"Сумма: {sum}");
        }
        catch (System.OperationCanceledException)
        {
            // объект уничтожили раньше, чем закончилось ожидание
        }
    }
}

Ожидаемо в Console появятся «Прошла секунда» и «Сумма: 49999995000000». Токен destroyCancellationToken отменяет ожидание, если объект уничтожен: в отличие от корутин, async-метод сам по себе при уничтожении объекта не останавливается.

Задача Корутина async + Awaitable
Пауза, таймер, анимация по кадрам удобно и привычно тоже можно
Вернуть результат вызывающему коду неудобно (через поле или колбэк) естественно: await метода с результатом
Обработка ошибок исключение обрывает корутину обычные try/catch
Тяжелый расчет вне главного потока нет BackgroundThreadAsync (API Unity оттуда не вызывать)
Автоостановка при уничтожении объекта да только через токен отмены

Выводы

  • Корутина в Unity — метод с IEnumerator, который движок продолжает после каждого yield; выполняется в главном потоке и не ускоряет расчеты.
  • Запускать через StartCoroutine: тело выполнится сразу до первого yield, дальше — по кадрам или по условию ожидания.
  • Останавливать надежнее всего сохраненным Coroutine; вызов StopCoroutine(Method()) создает новый итератор и ничего не останавливает.
  • StopAllCoroutines действует на один компонент; SetActive(false) и Destroy останавливают корутины, enabled = false — нет.
  • В Unity 6 для цепочек ожиданий с результатом и фоновых расчетов удобнее async + Awaitable с destroyCancellationToken.

Где применяется / связь с практикой

Корутины встречаются в любом Unity-проекте: таймеры способностей, волны врагов, плавное появление интерфейса, задержка перед сменой сцены, загрузка с индикатором прогресса. Хорошая тренировка — собрать небольшую сцену, где один скрипт запускает, перезапускает и останавливает несколько корутин, и проверить поведение при паузе и деактивации объекта.

Освойте тему на практике

Системно C#, жизненный цикл MonoBehaviour и игровую логику на Unity разбирают на курсе Unity Game Developer. Basic. Посмотреть формат занятий и задать вопросы преподавателям можно на открытых уроках Otus.

FAQ

Можно ли сделать Start корутиной?
Да, если объявить его как IEnumerator Start(): Unity сама запустит его как корутину, и внутри можно использовать yield.

Как вернуть значение из корутины?
Напрямую через yield результат не вернуть. Обычно пишут результат в поле или передают колбэк System.Action<T> параметром; в Unity 6 проще сделать асинхронный метод с Awaitable<T>.

Выделяет ли корутина память на каждом шаге?
Сам запуск создает объекты итератора и Coroutine, а new WaitForSeconds в цикле создает новый объект на каждой итерации. Поэтому объект ожидания в частых циклах создают один раз, как в примере с миганием; yield return null новых объектов не создает.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)