Страницы

Поиск по вопросам

четверг, 7 марта 2019 г.

Работа атрибута ThreadStatic в асинхронных вызовых

Есть библиотека, переделанная под многопоточное выполнение атрибутами ThreadStatic. Есть желание сделать ее асинхронной. Но, как быть со статическими полями и методами? Ведь новый поток не будет создан, и контекст не поменяется...
Это продолжение к прошлому вопросу о атрибуте: ThreadStatic атрибут, тонкости использования


Ответ

Решение можно найти в статье Стивена Клири Implicit Async Context. Я попробую пересказать суть.

Итак, в обыкновеном, не-async-коде, любая цепочка вызовов функций находится всё время в одном и том же потоке. Таким образом, thread static-переменные представляют собой вариант глобальных переменных, которые не подвержены многопоточным проблемам (но имеют проблему с рекурсивным вызовом, конечно).
В случае async-функции, выполнение может «прыгать» между потоками, таким образом, thread local нам не подходит. Что же у нас есть в замену? Что сохраняется при смене потока?
Ответ такой: это «логический контекст вызова», который можно использовать при помощи CallContext.LogicalGetData и CallContext.LogicalSetData. Эти вызовы позволяют записать и считать объект по его строковому имени.
Обычный контекс вызова, доступный через CallContext.GetData и CallContext.SetData, по сути работает как thread local-данные, что не подходит для async-методов.
Логический контекст вызова прикреплён к ExecutionContext'у. Это означает, что на него не влияет ConfigureAwait(continueOnCapturedContext: false). Возможности «отписаться» от сохранения логического контекста нет, он всегда будет передаваться по всем продолжениям (continuations) асинхронного метода.
Когда async-метод начинает выполняться, он сигнализирует логическому контексту вызова активировать отложенное копирование (копирование при фактических измениях). Это значит, что текущий логический контекст не подменяется в реальности немедленно, но как только ваш код вызовет CallContext.LogicalSetData, логический контекст копируется в новый, который становится текущим, и изменения происходят уже на новом контексте. (Обратите внимание: такое поведение имеет место только начиная с .NET 4.5.)
Это значит, что логический контекст работает как стек: изменения в контексте (на верхнем уровне!), внесённые вызываемой функцией, не видны в вызывающей функции. Напротив, изменения, сделанные вызывающей функцией, видны в вызываемой функции.
Копирование логического контекста вызова не глубокое. Проще всего думать о логическом контексте как об IDictionary; при копировании создаётся копия словаря с теми же ключами и ссылками на объекты-значения. Новый словарь ссылается на старые объекты.
Вследствие этого важно не изменять значения внутри объектов, полученных из контекста! Если вам нужно изменить значение, создайте новый объект, и запишите его в контекст при помощи CallContext.LogicalSetData. Лучше всего использовать неизменяемые типы данных, чтобы не поменять значение случайно.
Разумеется, если вы хотите получить данные, изменённые во вложенной функции, вы должны делать всё наоборот: передать изменяемый объект, и изменять значения, записанные в нём. Но такое скорее всего не нужно.
Напоследок: помните, что вы можете попасть в ситуацию, когда изменения видны там, где не должны быть, в двух случаях: (1) версии .NET старше 4.5 не копируют контекст (и значит, локальные изменения становятся глобальными), (2) копирование происходит лишь на верхнем уровне (не рекурсивно), и значит, изменение внутри объекта становится глобальным. Это приведёт к проблемам для кода, в котором поток выполнения ветвится (например, с Task.WhenAll). Поэтому пользуйтесь .NET 4.5+ и неизменяемыми данными!

Как подсказывает @Monk, в .NET Core на текущий момент (Core 2.x) нету аналога CallContext. Используйте в этом случае AsyncLocal, который доступен в .NET Framework 4.6+ и .NET Core 1+. (Вот тут об отличиях между CallContext и AsyncLocal.)

Комментариев нет:

Отправить комментарий