Comments 15
А вот в ваших рукописных реализациях async/await ничего нет про барьеры памяти. Но они же иногда должны быть, иначе бы мы иногда не видели изменений, сделанных из асинхронного метода в методе, где мы ждем результат. Интересно, что и в официальных доках я тоже ничего про это не вижу, только в ответах на stackoverflow.
Ваш комментарий интересен и за 5 минут я не придумал лаконичного ответа. Как минимум, будет полезно сначала посмотреть на примеры таких ответов на stackoverflow.
Но статья всё-таки не о том, как правильно писать автоматы для стейтмашины работы с Task. Поэтому я не буду обещать, что обязательно подробно отвечу на этот вопрос.
Примеры вот — https://stackoverflow.com/a/54413147/1756750, или https://stackoverflow.com/a/55139219/1756750
В целом, можно пробовать смотреть JIT ASM в условном https://sharplab.io/, но это не выглядит как надежный способ изучения вопроса, при условии что в sharplab даже Арма нет, где более слабые гарантии памяти. Еще можно в сорцы dotnet смотреть, но это про текущее состояние, а не спецификацию поведения.
Все необходимые барьеры памяти находятся внутри класса Task.
Не могли бы вы подсказать, где находится код, делающий это? С первого взгляда не вижу этого.
Посмотрите на количество volatile полей. Volatile read и volatile write в .net являются барьерами.
Ага, я вижу там большое количество volatile и тд. Но все случаи, что я вижу — относятся к работе к самому Task, или его состоянию. Выше же мы обсуждали барьеры для того, чтобы код в других потоках увидел изменения, сделанные внутри async/await, а также для того, чтобы новый код, запущенный через async/await, видел изменения, сделанные до его создания.
Барьеры памяти — глобальны. Поток, прочитавший из volatile поля, увидит все изменения, сделанные другим потоком до записи в любое volatile поле.
По поводу же того какие изменения другие потоки гарантированно увидят — лично я пользуюсь следующим правилом, которое я называю правилом адекватности асинхронных примитивов:
Если значение x было передано из одного потока в другой при помощи адекватного асинхронного примитива, то им можно пользоваться.
Или, в модели памяти Java, запись значения x в любой адекватный асинхронный примитив всегда happens before чтения этого же значения x из этого примитива.
Пока что мне не удалось обнаружить неадекватных примитивов в широком использовании. В частности, примитив Task — адекватный.
Прямые следствия из этого правила:
чтобы код в других потоках увидел изменения, сделанные внутри async…
…этому коду достаточно прямо либо косвенно вызвать await над результатом async
чтобы новый код, запущенный через async/await, видел изменения, сделанные до его создания
Это происходит автоматически.
Барьеры памяти — глобальны. Поток, прочитавший из volatile поля, увидит все изменения, сделанные другим потоком до записи в любое volatile поле.
Все так. Вы хотите сказать, что в этом коде нет явных барьеров для до/после async/await, и мы просто пользуемся барьерами, которые ставятся для работы с состоянием Task?
По поводу же того какие изменения другие потоки гарантированно увидят — лично я пользуюсь следующим правилом, которое я называю правилом адекватности асинхронных примитивов:
Эти правила, что вы сказали, звучат адекватно, и все их копируют из раза в раз на stackoverflow. Проблема в том, что это нигде не сказано в официальной документации.
В случае Java у нас есть https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html. Есть ли что-то подобное у dotnet?
Все так. Вы хотите сказать, что в этом коде нет явных барьеров для до/после async/await, и мы просто пользуемся барьерами, которые ставятся для работы с состоянием Task?
В каком — этом? Вроде бы конкретного кода мы не обсуждали. Но да, подавляющему большинству асинхронного кода достаточно тех барьеров которые находятся внутри используемых им примитивов.
В случае Java у нас есть https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html. Есть ли что-то подобное у dotnet?
Вы пришли сюда получить ответ на вопрос или потролить? Если вам нужна модель памяти .NET, я не понимаю зачем вы расспрашивали про барьеры в async/await и давали ссылку на код Task.
Вы пришли сюда получить ответ на вопрос или потролить? Если вам нужна модель памяти .NET, я не понимаю зачем вы расспрашивали про барьеры в async/await и давали ссылку на код Task.
ээ… Вы сослались на java в предыдущем вопросе. Я сослался на ответ и задал вопрос по вашему ответу. Мне просто не особо нравится, что все ссылаются на правило адекватности асинхронных примитивов, когда у нас нет первоисточника на это. Поэтому я испрашивал про конкретно те барьеры, которые решают обсуждаемые выше проблемы.
Конкретно обсуждаемые проблемы решают барьеры возникающие при работе над volatile полями Task. Если внимательно посмотреть на то как оно работает — можно доказать адекватность класса Task (в том смысле, который я определил выше).
Наличие барьеров при работе с volatile полями описано тут: C# 7.0 draft specification — 15.5.4 Volatile fields
К сожалению, в статье рассказано, пусть и в подробном виде, только то, что и так уже давно известно и публиковалось. Например — в старой, десятилетней давности, книге Дж.Рихтера про программированию на C# для .NET Framework 4.5 (в котором как раз и появились async/await).
Там как раз рассмотрено то самое преобразование async-метода в конечный автомат.
Только вот магия самого асинхронного исполнения переходов между состояниями этого конечного автомата как была у Рихтера скрыта где-то внутри AsyncTaskMethodBuilder.AwaitUnsafeOnCompleted, так и в стаье скрытой осталась.
Так что, к сожалению, ничего нового в статье нет.
PS Слово "Task" на русский переводится "задача". В том числе — и самой фирмой Microsoft, автором .NET и C#.
У Рихтера ещё во времена .Net Framework 2 были публикации про шаблон AsyncEnumerator, никаких особых хитростей там не было, и асинхронные задачи использовали yield return.
Видимо, потом результаты этих наработок были встроены в язык в виде async/await.
Довелось AsyncEnumerator использовать для асинхронности в продакшене на .Net Framework 2.0, и всё нормально работало. В коде выглядело не слишком громоздко, но, конечно, и не так компактно, как async/await.
Кстати, ещё Builder.SetResult (который нам необходимо сделать в конце работы с автоматом, чтобы вернуть результат) тоже кое-когда может создать новую Task, в которой просто лежит готовый ответ.
Не "кое-когда", а в зависимости от того что выполнится раньше.
Если метод завершается синхронно — то первым выполняется Builder.SetResult
и создаёт задачу через FromResult. Если первом происходит асинхронное ожидание — то первым выполняется return machine.Builder.Task;
и создаёт задачу-промис (более старые реализации создавали её через TaskCompletionSource).
ThreadPool. async/await