16

В этой теме прозвучала фраза, что работа с событиями по модели async/await имеет множество плюсов нежели традиционный событийных подход-подписался и забыл.

Теоретически, можно жить и без async/await. Но тогда у вас будет код разбросан по обработчикам событий, и состояние будет в виде глобальных переменных. А об обработке исключений я уже и не говорю — с ней будет совсем тяжко. Но да, как-то люди ж без async/await жили раньше, и в других языках до сих пор живут.

Собственно, меня это заинтересовало.

Хотелось бы получить информацию о всех достоинствах и возможно недостатках с примерами, традиционного подхода и подхода через async/await.

iluxa1810
  • 24,899
  • async/await - более лаконично и изящно, но требует немного перестроить мозг под новую парадигму. Калбеки на эвентах - все в целом просто, но в итоге огроменная куча лапшеобразного кода в котором утонуть не просто легко, а очень легко. – srvr4vr Feb 06 '18 at 05:50
  • @Сергей, а где бы почитать? А то впервые про такую штуку в Рихтере увидел, но он говорит, что такая возможность есть не описывая достоинства и все такое. – iluxa1810 Feb 06 '18 at 06:40
  • Почитайте тут и тут дальше гугл вам поможет. Каких то недостатков у async я не знаю - разве что новые способы прострелить себе ноги, но это везде и всегда можно. Это относительно новая и ныне основная парадигма асинхронного программирования у microsoft. Многие языки ее активно перенимают. – srvr4vr Feb 06 '18 at 07:51
  • У традиционного подхода меньший расход динам памяти. В большинстве случаев это конечно не критично. – Vasek Feb 06 '18 at 09:19

2 Answers2

20

Смотрите. Традиционый метод асинхронной работы — использование callback'ов. В каждой точке, где в async-metode у вас await, при традиционном подходе вы должны завершить работу метода, подписавшись на окончание работы асинхронного кода. При этом вы должны где-то сохранить ваше состояние, то есть вы должны при этом таскать с собой локальные переменные вручную. Далее, логика циклов и условий тоже получается размазанной по нескольким кускам кода. Ну и делить на нужные части вам придётся вручную.

Вот пример простого async-кода: копирование потоков.

async Task CopyAsync(Stream source, Stream target, CancellationToken ct)
{
    try
    {
        var buf = new byte[8192];
        while (true)
        {
            var actuallyRead = await source.ReadAsync(buf, 0, buf.Length, ct);
            if (actuallyRead == 0)
                return;
            await target.WriteAsync(buf, 0, actuallyRead, ct);
        }
    }
    catch (OperationCanceledException) when (ct.IsCancellationRequested)
    {
        Debug.WriteLine("Cancelled");
    }
}

Ничего особенного нет.

Как нам написать ту же функциональность без синхронного кода, не занимая поток? У нас есть метод stream.BeginRead, который должен вернуть объект типа IAsyncResult. Попробуем смоделировать нашу функциональность таким же образом.

Для начала, нам нужно где-то хранить буфер, а также рабочие потоки. Для этого нам понадобится класс. Назовём его StreamCopyWorker, имея в виду, что логика работы будет тоже внутри него. Затем, мы хотим определить IAsyncResult. Объявим его отдельным классом, так как это всё же отдельная сущность.

В StreamCopyWorker должны быть методы BeginCopyAsync и EndCopyAsync. Имплементируем. Получается вот такой крокодил:

internal class StreamCopyWorker
{
    internal readonly IAsyncResult Result;
    Stream source;
    Stream target;
    CancellationToken ct;
    ManualResetEventSlim ev = new ManualResetEventSlim();
    AsyncCallback cb;
public StreamCopyWorker(
    Stream source, Stream target, object state, CancellationToken ct,
    AsyncCallback cb)
{
    this.source = source;
    this.target = target;
    this.ct = ct;
    this.cb = cb;
    this.Result = new StreamCopyAsyncResult()
    {
        AsyncState = state,
        AsyncWaitHandle = ev.WaitHandle,
        self = this
    };
}

byte[] buf = new byte[8192];

internal void BeginAsync()
{
    source.BeginRead(buf, 0, buf.Length, DoWrite, null);
}

internal void EndAsync(IAsyncResult ar)
{
    ct.ThrowIfCancellationRequested();
}

void DoWrite(IAsyncResult ar)
{
    int bytesRead = source.EndRead(ar);
    if (bytesRead == 0 || ct.IsCancellationRequested)
        Finish();
    else
        target.BeginWrite(buf, 0, bytesRead, DoRead, null);
}

void DoRead(IAsyncResult ar)
{
    target.EndWrite(ar);
    if (ct.IsCancellationRequested)
        Finish();
    else
        BeginAsync();
}

void Finish()
{
    ((StreamCopyAsyncResult)Result).IsCompleted = true;
    ev.Set();
    cb(Result);
}

internal class StreamCopyAsyncResult : IAsyncResult
{
    public bool IsCompleted { get; internal set; }
    public WaitHandle AsyncWaitHandle { get; internal set; }
    public object AsyncState { get; internal set; }
    public bool CompletedSynchronously => false;
    internal StreamCopyWorker self { get; set; }
}

}

Ну и вспомогательные методы для вызова, чтобы спрятать создание класса:

IAsyncResult BeginCopyAsync(Stream source, Stream target, object state, CancellationToken ct, AsyncCallback cb)
{
    var worker = new StreamCopyWorker(source, target, state, ct);
    worker.BeginAsync();
    return worker.Result;
}

void EndCopyAsync(IAsyncResult ar) { var result = (StreamCopyWorker.StreamCopyAsyncResult)ar; var worker = result.self; worker.EndAsync(ar); }

Вам всё ещё кажется, что без async/await легко?

С чистой событийной моделью получается спагетти ещё похлеще. Сейчас можно протягивать состояние через замыкания, и это немного упрощает код. Но не слишком. У меня та же задача на чистых событиях выглядит примерно так:

var buf = new byte[8192];
ResultCallback cb = (o, args) =>
{
    if (args.IsCancelled)
        Debug.WriteLine("Cancelled");
};
ReadHandler rhandler = null;
rhandler = (o, args) =>
{
    source.ReadFinished -= rhandler;
if (ct.IsCancelationRequested)
    cb?.Invoke(null, new ResultArgs(isCancelled: true));
else
{
    var readBytes = args.ReadBytes;
    if (readBytes == 0)
        cb?.Invoke(null, new ResultArgs(isCancelled: false));
    else
    {
        WriteHandler whandler = null;
        whandler = (o, args) =>
        {
            target.WriteFinished -= whandler;
            if (ct.IsCancelationRequested)
                cb?.Invoke(null, new ResultArgs(isCancelled: true));
            else
            {
                source.ReadFinished += rhandler;
                source.ReadAsync(buf, 0, buf.Length);
            }
        };
        target.WriteFinished += whandler;
        target.WriteAsync(buf, 0, readBytes);
    }
}

}; source.ReadFinished += rhandler; source.ReadAsync(buf, 0, buf.Length);

(плюс определение ResultCallback, ReadHandler, WriteHandler, ResultArgs и т. д.). Наверняка вы видели похожие, только более крупные «пирамиды смерти» в коде на Javascript.

Вы понимаете, что в этом коде творится? Я уже нет.

VladD
  • 206,799
18

Допустим, у нас есть задача для UI приложения: выполнить какую то логику, потом показать представление, дождаться, когда это представление будет закрыто и выполнить ещё что то. Как это решалось бы традиционным способом (я использую Window в качестве представления чисто для упрощения примера, в реальной задаче вместо окна может быть что угодно):

int i = 0;
void EventBased()
{
    i = DoSmthg();
    var wnd = new Wnd();
    wnd.Closed+=Closed;     
    wnd.Show();
}

void Closed(object sender, EventArgs e)
{
    (sender as Wnd).Closed -= Closed;
    DoSmthgElse(i);
}

Обратите внимание на 3 вещи:

  1. Логика казалось бы одного метода раскидана по нескольким методам. Это, конечно, можно исправить, назначив обработчик прямо на месте анонимным делегатом, но и там есть свои минусы.
  2. EventBased() метод неблокирующий. То есть тот, кто будет вызывать этот метод, не узнает об окончании работы всей логики. Это тоже решается добавлением фрейма в диспетчер или через wnd.ShowDialog() если wnd - окно (хотя это по сути также добавление фрейма), или новым событием, что тоже имеет свои минусы.
  3. Мы вынуждены хранить в поле i значение промежуточного результата. Это тоже можно было бы обойти анонимным делегатои, но это привело бы все равно к захвату переменной

Как видите, желая решить казалось бы простую задачу, приходится изворачиваться, чтобы заставить код работать так, как требуется. Но давайте напишем такое окно, с которым работать будет проще:

public class Wnd : Window
{
    TaskCompletionSource<object> s;

    public Wnd()
    {
        s = new TaskCompletionSource<object>();     
        this.Closed+= (sender, args) => s.SetResult(this);
    }

    public Task ShowAsync()
    {
        this.Show();
        return s.Task;
    }
}   

Как видно, у окна теперь есть метод, который вернет таск. И этот таск завершится только когда окно будет закрыто. Теперь мы можем переписать вызывающий код следующим образом:

async Task AsyncBased()
{
    var i = DoSmthg();
    var wnd = new Wnd();
    await wnd.ShowAsync();
    DoSmthgElse(i);
}

Больше нет необходимости шаманить с фреймами или делегатами или ещё с чем-либо.

  1. Вся нужная логика сосредоточена в одном методе (мы то знаем, что это не совсем так, но для читателя кода это верно),
  2. также вызывающий код может дождаться конца работы логики не прибегая к черной магии
  3. Промежуточное состояние выглядит как обычная локальная переменная

Это просто один из простых примеров, что можно жить и без async/await, но использование async/await делает код немного лаконичней и понятней. Вы можете заменить окно чем угодно (получением данных из сети, записью в БД, любым асинхронным вызовом), окно взято чисто для примера работы с TaskCompletionSource.

tym32167
  • 32,857
  • 1
    Особенно тяжко без async/await получаются циклы и локальные переменные во внутренних scope'ах. – VladD Feb 06 '18 at 13:34
  • 1
    @VladD да, я про сложность работы с состоянием упомянул, просто не хотел грузить автора хардкором :) – tym32167 Feb 06 '18 at 13:38