Pull to refresh

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).

Sign up to leave a comment.