Привет, Хабр!

Многим кажется, что await работает только с Task и ValueTask, но на самом деле язык позволяет сделать любой объект ожидаемым — нужно лишь реализовать определённый паттерн.

Итак, зачем нужен свой await? Бывают случаи, когда вам хочется написать асинхронный метод, но результат приходит не из готового Task или таймера. Например, ждёте какое-то событие, изменение файла, считывание из сокета, или просто хотите встроить задержку без запуска Task. Когда вы пишете await expr, компилятор в глубине понимает так: берётся результат expr.GetAwaiter(), затем вызывается awaiter.IsCompleted. Если false, он подписывается на awaiter.OnCompleted, когда завершится, и потом берёт awaiter.GetResult().

Для своего await нужно:

  1. Класс/структуру, у которой есть метод GetAwaiter().

  2. GetAwaiter() возвращает объект (awaiter).

  3. Объект-awaiter реализует интерфейс INotifyCompletion (или ICriticalNotifyCompletion).

  4. У awaiter’а есть свойство bool IsCompleted { get; } и метод void OnCompleted(Action callback), а также GetResult() (возвращает результат или void).

Если всё это соблюдено, можно делать await для любого типа.

Допустим, хочется написать await new DelayAwaitable(1000), и ждать 1 секунду. Вот как это можно реализовать:

public class DelayAwaitable
{
    private readonly int _milliseconds;
    public DelayAwaitable(int milliseconds) => _milliseconds = milliseconds;

    public DelayAwaiter GetAwaiter() => new DelayAwaiter(_milliseconds);

    // Нужен вложенный класс Awaiter, реализующий INotifyCompletion
    public class DelayAwaiter : INotifyCompletion
    {
        private readonly int _ms;
        private Action _continuation;

        public DelayAwaiter(int milliseconds) => _ms = milliseconds;

        // Говорим компилятору, что не завершено мгновенно
        public bool IsCompleted => false;

        // Компилятор вызовет этот метод, когда await нужен.
        // Мы храним callback и запускаем таймер.
        public void OnCompleted(Action continuation)
        {
            _continuation = continuation;
            // Симулируем таймер на отдельном потоке (не самый продакшен-способ, но для примера пойдёт)
            new Thread(() => {
                Thread.Sleep(_ms);
                _continuation?.Invoke();
            }).Start();
        }

        // Метод, который вызывается после завершения await
        public void GetResult() { /* ничего не возвращаем */ }
    }
}

DelayAwaitable — это класс, который мы будем await-ить. У него есть метод GetAwaiter(), который возвращает новый DelayAwaiter.

DelayAwaiter — внутренний класс, имплементирует INotifyCompletion. У него IsCompleted всегда false (это значит не завершено сразу), и в OnCompleted мы сохраняем продолжение (Action continuation). Затем запускаем новый поток, спим _ms миллисекунд, и вызываем continuation. То есть когда время вышло, продолжаем выполнение асинхронного метода.

GetResult() ничего не делает, так как нам не нужно возвращать никакое значение. Если бы мы возвращали результат, то GetResult() бы его возвращал.

Теперь как это использовать:

async Task ExampleDelay()
{
    Console.WriteLine("До задержки");
    await new DelayAwaitable(1000); // ждём 1 секунду
    Console.WriteLine("После задержки");
}

// Вызов метода (например, из Main или другого async)
await ExampleDelay();
// Вывод:
// До задержки
// (пауза ~1 сек)
// После задержки

Весь этот код умеет ждать нашу кастомную задержку так же, как если бы мы ждали Task.Delay. По сути расширили возможности await под свой тип.

Что здесь произошло

Компилятор сгенерировал следующее:

void ExampleDelay() // на самом деле async/горутина
{
    var awaiter = new DelayAwaitable(1000).GetAwaiter();
    if (!awaiter.IsCompleted)
    {
        awaiter.OnCompleted(StateMachineMoveNext);
        return;
    }
    awaiter.GetResult();
    StateMachineMoveNext();
}

Он проверяет IsCompleted. У нас всегда false, значит, выдаёт управление до завершения. Когда закончится OnCompleted (через 1 сек), GetResult() вызовется, и метод продолжит.

Мы могли сделать и IsCompleted => true, тогда await вернётся сразу, не останавливаясь. Вот другой пример — await с результатом:

public class MyAwaitable
{
    public MyAwaiter GetAwaiter() => new MyAwaiter();

    public class MyAwaiter : INotifyCompletion
    {
        public bool IsCompleted => false;
        public void OnCompleted(Action action) 
        {
            // Результат сразу готов, поэтому вызываем callback сразу же
            action();
        }
        public string GetResult()
        {
            return "Результат";
        }
    }
}

Теперь в коде так:

async Task ExampleMyAwait()
{
    Console.WriteLine("Ждём MyAwaitable...");
    string res = await new MyAwaitable();
    Console.WriteLine($"Результат await: {res}");
}

Вывод будет «Результат await: Результат» без какой-либо задержки. Здесь мы установили IsCompleted = false, но в OnCompleted сразу вызываем action(), так что фактически результат готов.

Итак, чтобы сделать свой await, нужно соблюдать паттерн: класс + .GetAwaiter(), у awaiter-а IsCompleted, OnCompleted и GetResult(). В нашем случае мы сделали таймер, но можно реализовать что угодно: например, ожидание события:

public class EventAwaitable
{
    private readonly ManualResetEvent _event = new ManualResetEvent(false);
    public EventAwaiter GetAwaiter() => new EventAwaiter(_event);

    public class EventAwaiter : INotifyCompletion
    {
        private readonly ManualResetEvent _evt;
        private Action _cont;
        public EventAwaiter(ManualResetEvent evt) => _evt = evt;

        public bool IsCompleted => _evt.WaitOne(0);

        public void OnCompleted(Action continuation)
        {
            _cont = continuation;
            // Ждём в отдельном потоке, когда событие установится
            new Thread(() => {
                _evt.WaitOne();
                _cont();
            }).Start();
        }

        public void GetResult() { /* ничего */ }
    }
}

Тогда await new EventAwaitable() завершится, когда ManualResetEvent установится, например, где-то ещё в коде. Подобный подход можно использовать для асинхронного ожидания подписки на какое-нибудь событие.


Конечно, свой await неплоха затея, но применять его стоит осознанно, чаще всего достаточно готовых Task/ValueTask/таймеров.

Если тема await’ов зашла, значит вы уже смотрите на C# не как на «синтаксис», а как на инструмент. На курсе C# Developer. Basic разбираем .NET и ООП, коллекции, LINQ и работу с PostgreSQL, а практикой собираем Telegram-бота: от Git и Visual Studio до реального кода без «магии». По дороге — алгоритмы, структуры данных и чистый код.

Чтобы оставаться в курсе актуальных технологий и трендов, подписывайтесь на Telegram-канал OTUS.

Комментарии (7)


  1. nihil-pro
    16.12.2025 18:19

    Далеко вам до джаваскрипта))

    У нас await работает с любым thenable объектом, а это любой объект, у которого есть метод then.

    P.s. заголовок у вас кликбейтный, конечно, вы же не await реализовали, а awaitable result)


    1. withkittens
      16.12.2025 18:19

      Далеко вам до джаваскрипта))

      Да не, это в статье примеры топорные. Вот рандомный пример на джабаскрипте:

      // A thenable is an object with a `then()` function. The
      // below thenable behaves like a promise that fulfills with
      // the value `42` after 10ms.
      const thenable = {
        then: function(onFulfilled) {
          setTimeout(() => onFulfilled(42), 10);
        }
      };
      
      const v = await thenable;
      v; // 42

      А вот то же самое на C#:

      var v = await new Awaitable();
      Console.WriteLine(v); // 42
      
      class Awaitable
      {
          public TaskAwaiter<int> GetAwaiter()
          {
              return Task.Delay(10).ContinueWith(_ => 42).GetAwaiter();
          }
      }

      Тоже один метод, только называется по-другому. Все вот эти приседания со своими собственными "объектами-awaiter" - это как переизобретение промизов. Можно, иногда нужно, но не часто.


  1. Naf2000
    16.12.2025 18:19

    Объект-awaiter реализует интерфейс

    Вопрос: обязательно реализовывать интерфейс или достаточно класс с такими методами?


    1. kuber
      16.12.2025 18:19

      Чтобы использовать сахар в виде await обязательно, но если не реализовывать интерфейс, то код можно немного поправить и он будет вполне работать. В частности надо будет вручную дёрнуть GetAwaiter()


      1. Naf2000
        16.12.2025 18:19

        Хммм, для использования foreach IEnumerable/IEnumerator не требовалось. Что сломалось?


  1. kuber
    16.12.2025 18:19

    У меня устойчивое ощущение, что я видел этот код ещё год назад.

    А так для понимания вполне полезно.


  1. bighorik
    16.12.2025 18:19

    Дальше, я так понимаю, будут статьи "Свой foreach на c#" и "свой using на c#"