MCP

воскресенье, 15 ноября 2015 г.

Чем мне не нравятся async/await

Тут на днях у нас вышел спор с коллегами по поводу async/await в Microsoft .NET. Мне эта технология не очень нравится, несмотря на её круть, но сформулировать причины сходу весьма непросто. В этом посте попробую объяснить, в чём же проблемы (естественно, это моё мнение, и оно может не совпадать с генеральной линией партии).

1. Это не про потоки, это про асинхронность в одном

Т.е. основной смысл этой конструкции в том, чтобы занять основной поток, пока его часть ждёт какой-то асинхронной операции. Т.е. пока мы скачиываем файл, мы можем поделать ещё что-то, а не тупо ждать завершения. Идея здравая, но есть нюанс: должно существовать то, чем вам заняться. Например, если у нас клиентское приложение, мы в этом же потоке обработаем всякие события перерисовки, если однопоточное серверное, займёмся другим клиентом. Но если нам нечем заняться, то смысла в конструкции нет. Т.е. если мы сделали отдельный поток для пользователя и ждём когда скачается файл, чтобы что-то сделать. Мы не выиграем ничего от использования async/await. Просто будем использовать как небольшой синтаксический сахар с гигантcкой работой под капотом не по делу.

2. Неявная точка асинхронности

Когда мы вызываем асинхронный метод, мы не знаем, когда он реально перейдёт в асинхронность. Другими словами, возьмём стандартный подход: 
Task.Factory.StartNew(DoSomething);

Сразу после вызова данного метода, мы можем продолжать работу. А теперь глянем на async/await:
var t = DoSomething();
..........
await t;

В данном коде мы не знаем, когда реально дойдёт до нас управление, и будет ли реальная асинхронность. Фактически, мы вернёмся в метод, когда в DoSomething() будет встречен await, а когда это будет — зависит от реализации. Фактически, точка асинхронности слово await, но она находится внутри метода который мы вызываем, как результат, мы нам надо знать реализацию метода DoSomething, чтобы было всё прозрачно и понятно.
В большинстве случаев, это не проблема, но когда начинаются проблемы, вот такие мелкие нюансы очень портят жизнь.
Например, указано, что эксепшены пробрасываются на await, в результате можем получить код вида:
У которого вначале уничтожится мир, потом будет всё хорошо, а потом вылетит эксепшен. На мой взгляд не очень очевидно.

Вся эта неявность приводит к следующей проблеме:

3. Легко завалить производительность, при этом всё будет работать

Как я уже указал, данная технология про асинхронность. Соответственно всё должно быть асинхронно, всегда и везде. А что будет, если мы где-то забудем асинхронность? Или не мы, а тот кто написал библиотеку для нас. Может быть он не очень хороший программист и допустил маааленькую ошибку и сделал операцию синхронной. Как вы думаете, что будет? Да ничего фатального! Просто пользователь будет ждать. Например, один await не в том месте, привёл к тому, что HTTP-proxy на async/await стала обрабатывать все запросы от пользователей по очереди. И это работало год. Заметили случайно, при генерации отчётов сайт переставал открываться. А всему виной — ошибка в коде, про который все забыли и который с точки зрения логики, работал замечательно.

Другие примеры, чтобы понять масштаб проблемы: делаем запрос по HTTP, и перед запросом решили порезолвить DNS, синхронно (ну не нашли асинхронный метод). Можем получить 2 секунды тормозов на ровном месте. Использовали бы явную асинхронность вызовом метода в отдельном потоке — проблем не было бы. Ещё вариант — логгер, давно написанный, отлаженный, пишет в файлик, асинхронной версии не имеет, никому не нужна. Всё работает годами. Потом кто-то на том же интерфейсе решил запилить логгирование в базу, результат — пока один пишет, все ждут (у нас ведь синхронная версия). Провал производительности.

Хотя про то, что всё будет работать, я погорячился. Когда писал этот пост выяснил, что легко получить Deadlock из-за повторного входа и контекстов синхронизации. Не буду пересказывать статью, там на третьем примере всё достаточно просто и понятно рассказывается. 

В результате, чтобы справиться с проблемой мы должны использовать везде асинхронные версии, и это нас приводит к следующей проблеме

4. async/await заразные


Чтобы заиспользовать его, методу надо указать что он async. Если метод async, то вызывающий его метод должен использовать await и самому стать тоже async.
Т.е. весь код резко начинает обрастать async'ами, как снежный ком. Вырваться из данной западни можно двумя способами: async void (не используйте никогда!) или явным хаком с вызовом Wait у таска, возвращаемого async методом (что может привести к некоторым проблемам с контекстом синхронизации).
В общем, для нормального использования async'ов, у нас должна быть вся библиотека, написанная на них, которая использует другие библиотеки, написанные тоже на них. Надо писать сразу весь код в этой концепции, добавление небольшого куска не даёт никакого профита. А раз это концепция, то хотелось бы явно выделять её. Например, я вызываю метод DoSomething(), в зависимости от того, async он или нет, при вызове будут абсолютно разные вещи. Но без поддержки IDE я никогда не у знаю об этом. Если я добавлю к сигнатуре async, позднее, код скомпилируется и будет работать как-то, но о проблемах я узнаю по факту, а не во время компиляции.

И это косвенно подводит нас к очередной проблеме

5. Reflection и кодогенерация

Reflection должен знать про то, что методы бывают async, т.к. от этого зависит логика их работы (банально проброс эксепшена о котором я уже упомянул вначале). Оверрайдить данные методы кодогенрацией (моё любимое занятие) тоже надо аккуратно, ибо просто для понимания, вызов метода:
public static async Task DoSomething() { Console.WriteLine("A"); await Task.Delay(1000); }

По факту превратится в что-то подобное:
 А реальная логика будет запрятана далеко, и будет выглядеть как-то так:
Согласитесь, не самый простой для понимания пример.
А так как все эти async/await сделаны на уровне языка, а не MSIL, то среда ничего не знает про это, и тем же рефлекшеном достать это сложно. Что в связи с неявной точкой асинхронности может резко менять логику.

Например, мы хотим добавить валидацию параметров метода через AOP, простой пример, они не должны быть null. Так вот, если мы это делаем кодогенрацией, мы пропустим слово async и добавим провеку  в метод. Вызывающий код упадёт на вызове метода. Если же мы добавим проверку вручную, то на слове await (да, это мой любимый пример с эксепшенами, раз я его разобрал). При этом не обязательно использовать кодогенерацию, достаточно просто наследования.

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

Выводы

На мой взгляд, данная технология похожа на внутренности Perl'а. Для тех, кто не знает этот чудесный язык, поясню: смотришь примеры, видишь как всё просто и круто. Начинаешь углубляться в язык, чтобы понять как это работает и с какого-то момента становится очень страшно. Ты понимаешь огромную магию внутри всего этого. Ты понимаешь, что неверное движение может всё поломать, а работает только благодаря умным людям, которые написали кишки и программистам, которые не суют нос, куда не надо. И лишь спустя длительное время ты становишься таким умным человеком, и тебе становится всё понятно. Тебе спокойно. Так вот, я с async/await не стал таким умным человеком, но мне страшно, я уже могу всё сломать и я боюсь что кто-то из моей команды тоже может всё сломать, и не поймёт этого. И я не пойму. В результате всё будет работать не так как надо, а мы не будет знать, в чём проблема. А чтобы узнать, не достаточно статей "Async и await для чайников". Надо действительно погружаться в технологию. А оно мне не надо. Я не пишу приложения под Windows 10, а больше смысла-то и  нет, хотя Microsoft толкает её в новые библиотеки, вполне можно прожить и без неё, без этого странного волшебства и магии.

2 комментария:

  1. 1. Думаю, что задача в том, чтобы распаралелить задачи. Мне кажется это более лучшее определение. Например, если мы знаем, что в console приложении нет необходимости в распаралеливании, то нужно и можно использовать синхронные методы. Но опять же, в некоторых консольных приложение распаралерирование нужно тоже.
    2. Во-первых, я бы переименовал DoSomething в DoSomethingAsync, чтобы обозначить то, что он асинхронный. Во-вторых, многопоточность никогда не была легкой. Если мы сравниваем async/await с синхронным кодом - то да, все становится сложнее и не очевидно. Если сравнивать async/await со своим способом распаралеривание - то async/await явно все делает проще.
    3. Пишите load tests. C logger проблема может быть проще, что если файловая система тормозит, а единственное место, где она используется - это запись логов в файл? Лучше писать c async в любом случае.
    4. У Async должна быть альтернатива Sync. В большинстве .NET библиотек именно так и есть. Если библиотека не реализует Async, то обернуть достаточно просто. В обратную сторону все сложнее сделать. Как вариант еще можно поставить limit в количество Threads для Tasks в 1, никогда не пробовал делать, поэтому точно не знаю будет ли работать.
    5. Согласен.

    В общем я бы еще раз подчеркнул мысль

    "сли мы сравниваем async/await с синхронным кодом - то да, все становится сложнее и не очевидно. Если сравнивать async/await со своим способом распаралеривание - то async/await явно все делает проще."

    ОтветитьУдалить
    Ответы
    1. 1. Не думаю что это задача про распараллеливание. Изначально концепция была в UI-приложениях у которых возникала концептуальная проблема, надо обрабатывать очередь сообщений, когда код висит в каком-нить модале. Решили таким хитрым образом. Т.е. такая ручная многозадачность с точками переключения контекста.
      2. Async добавить можно и нужно, я просто для примера оставил так, чтобы длинно не писать. Вообще, подобную многопоточность можно реализовать через callback hell, промисы/таски, тупо таски через Wait(). Мне кажется эти концепции честнее в плане кода и понимания того, как он работает.
      3. Это сложно. Мы пытались тут выяснить нет ли у нас проблемы в ноде связанной с синхронностью, было сложно. Потому что в итоге результат будет один, выполнятся n запросов, примерно за одно суммарное время. только в каком порядке, это надо анализировать.
      4. Обернуть async в sync достаточно просто, Wait(), если в твоём коде не используется асинхронность, то и проблемы никакой нет.

      И про итог я написал во втором пункте, мне кажется что он делает проще визуально, ценой слишком нетривиальных возможных ошибок. Вспомни Remoting, этот ужас на крыльях ночи с офигенно гениальной идеей в своей основе.

      Удалить