Статьи

Опасности ThreadLocal

Языки и рамки развиваются. Мы, как разработчики, должны постоянно узнавать что-то новое и отучиться от уже освоенных знаний. Говоря за себя, обучение является самой трудной частью непрерывного обучения. Когда я впервые столкнулся с многопоточными приложениями в .NET, я наткнулся на атрибут ThreadStatic . Я помнил, что этот атрибут особенно полезен, когда у вас есть статические поля, которые не должны быть общими для потоков. В то время, когда была выпущена .NET Framework 4.0, я обнаружил класс ThreadLocal и то, как он лучше справляется с назначением значений по умолчанию для данных, специфичных для потока. Итак, я отучился ThreadStaticAttribute, отдавая предпочтение ThreadLocal<T>.

Быстро перенесемся на некоторое время спустя, когда я начал копаться async/await. Я стал жертвой убеждения, что данные, относящиеся к потокам, все еще работают. Итак, я снова был неправ, и мне пришлось снова учиться! Если бы я только знал об AsyncLocal ранее.

Давайте учиться и не учиться вместе!

TL; DR

  • А Taskэто не а Thread. Taskэто будущее или обещание, которое в конечном итоге исполняется работником Thread.
  • Если вам нужны окружающие данные, локальные для асинхронного потока управления, например, для кэширования каналов связи WCF, используйте AsyncLocalвместо .NET 4.6 или Core CLR ThreadStaticAttributeили ThreadLocalпредоставьте его.

Ложки нет

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

Затем была выпущена библиотека параллельных задач, которая перевернула наш мир с ног на голову. Глядя на это System.Threading.Tasks.Task, мы внезапно почувствовали себя как Нео из Матрицы и поняли, что ложки нет — или, в нашем контексте, нет Thread!

Я не первый, кто использует аналогию Matrix для описания разницы между a Threadи a Task. У Стивена Клири отличная публикация. Нет темы от 2013 года, которая использует ту же аналогию и углубляется в различия. Я настоятельно рекомендую прочитать это.

А Taskпод параллельной библиотекой задач — это будущее или обещание . А Taskэто то, что вы хотите сделать. Напротив, это Threadодин из многих возможных работников, которые могут выполнить эту задачу. По контракту a Taskмы не знаем, будет ли это запланировано, будет ли оно выполнено немедленно или уже выполнено в тот момент, когда мы объявили это. Среда выполнения параллельной библиотеки задач имеет встроенные смарт-коды, позволяющие решить, будет ли задача выполняться в потоке, который ее создал, или ее нужно запланировать в пуле рабочих потоков или пуле потоков ввода-вывода. Более того, то, что поток работал над заданной задачей, не означает, что поток выполнит все продолжения этой задачи.

Это становится еще сложнее, когда мы начинаем вводить async / await в уравнение. Каждый раз, когда вы пишете awaitоператор вместе с вашим другомConfigureAwait(false) , поток, выполняющий задачу в данный момент, может вернуться назад и начать выполнять несколько других задач. Когда операция ввода / вывода завершается, оставшаяся часть задачи (продолжение) снова назначается Taskответственным в данный момент TaskScheduler. Ранее ответственный поток мог взять эту задачу и продолжить работу над ней. Альтернативно, любой другой доступный поток мог сделать это. Следующий код иллюстрирует это:

static dynamic Local;
static ThreadLocal<string> ThreadLocal = new ThreadLocal<string>(() => "Initial Value");

public async Task ThereIsNoSpoon()
{
    // Assign the ThreadLocal to the dynamic field
    Local = ThreadLocal;

    Console.WriteLine($"Before TopOne: '{Local.Value}'");
    await TopOne().ConfigureAwait(false);
    Console.WriteLine($"After TopOne: '{Local.Value}'");
    await TopTen().ConfigureAwait(false);
    Console.WriteLine($"After TopTen: '{Local.Value}'");
}

static async Task TopOne()
{
   await Task.Delay(10).ConfigureAwait(false);
   Local.Value = "ValueSetBy TopOne";
   await Somewhere().ConfigureAwait(false);
}

static async Task TopTen()
{
   await Task.Delay(10).ConfigureAwait(false);
   Local.Value = "ValueSetBy TopTen";
   await Somewhere().ConfigureAwait(false);
}

static async Task Somewhere()
{
   await Task.Delay(10).ConfigureAwait(false);
   Console.WriteLine($"Inside Somewhere: '{Local.Value}'");
   await Task.Delay(10).ConfigureAwait(false);
   await DeepDown();
}

static async Task DeepDown()
{
   await Task.Delay(10).ConfigureAwait(false);
   Console.WriteLine($"Inside DeepDown: '{Local.Value}'");
   Fire().Ignore();
}

static async Task Fire()
{
   await Task.Yield();
   Console.WriteLine($"Inside Fire: '{Local.Value}'");
}

Вышеприведенный код должен быть относительно простым для понимания. Точкой входа является метод ThereIsNoSpoon, который вызывает два метода TopOneи TopTen. Эти методы устанавливают ThreadLocalзначение ValueSetBy TopOneи ValueSetBy TopTenсоответственно. Оба метода вызывают метод Somewhere, который выводит значение ThreadLocalи вызывает в DeepDown. DeepDownпечатает значение ThreadLocalснова, а затем запускает асинхронный метод, вызванный, Fireне ожидая его (следовательно, метод Ignore, который подавляет предупреждение компилятора CS4014 ). Этот код использует крошечный трюк, который позволяет позже использовать код для демонстрации AsyncLocal. Методы, используемые в пути выполнения, обращаются к динамическому статическому полю Local. Оба класса обеспечиваютValueсобственность и, следовательно, мы можем просто назначить либо ThreadLocalили AsyncLocalбез дублирования ненужного кода.

Вывод приведенного выше кода выглядит примерно так (ваш результат может отличаться):

Before TopOne: 'Initial Value'
Inside Somewhere: 'ValueSetBy TopOne'
Inside DeepDown: 'ValueSetBy TopOne'
After TopOne: 'ValueSetBy TopOne'
Inside Fire: 'Initial Value'
Inside Somewhere: 'Initial Value'
Inside DeepDown: 'ValueSetBy TopTen'
Inside Fire: 'Initial Value'
After TopTen: 'ValueSetBy TopTen'

Перед выполнением TopOneметода значение ThreadLocalимеет значение по умолчанию. Сам метод присваивает значение Initial Valueк ThreadLocal. Это значение остается до тех пор, пока Fireметод не будет запланирован без ожидания. В нашем случае Fireметод назначается по умолчанию TaskScheduler, ThreadPool. Поэтому ранее назначенное значение больше не связано с ThreadLocal, что приводит к тому, что Fireметод может только прочитать ThreadLocalзначение по умолчанию. В нашем примере чтение начального значения не имеет последствий. Но что, если вы использовали ThreadLocalдля кеширования дорогостоящих объектов связи, таких как каналы WCF? Можно предположить, что эти дорогие объекты кэшируются и используются повторно при выполненииFireметод, но они не будут. Это создаст опасный горячий путь в вашей кодовой базе, яростно создавая новые дорогие объекты с каждым вызовом. Вам было бы трудно это обнаружить, пока ваша корзина покупок не произвела впечатляющий эффект в самый напряженный день сезона праздничных покупок.

Короче говоря: а Taskне а Thread. Вместе с тем async/await, мы должны попрощаться с локальными данными потока! Скажи привет AsyncLocal.

Согни это своим разумом

AsyncLocal<T>это класс, представленный в .NET Framework 4.6 (также доступный в новом CoreCLR ). Согласно MSDN , AsyncLocal<T>«представляет окружающие данные, которые являются локальными для данного асинхронного потока управления».

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

Поток 1: TopOne> Где-то> DeepDown> Огонь

Поток 2: TopTen> Где-то> DeepDown> Fire

Итак, обещание AsyncLocalсостоит в том, что мы можем присвоить ему значение, которое присутствует, пока мы находимся в одном и том же асинхронном потоке управления. Мы докажем это с помощью следующего кода.

static AsyncLocal<string> AsyncLocal = new AsyncLocal<string> { Value = "Initial Value" };

public async Task BendItWithYourMind()
{
    // Assign the AsyncLocal to the dynamic field
    Local = AsyncLocal;

    // This code is the same as before but shown again for clarity
    Console.WriteLine($"Before TopOne: '{Local.Value}'");
    await TopOne().ConfigureAwait(false);
    Console.WriteLine($"After TopOne: '{Local.Value}'");
    await TopTen().ConfigureAwait(false);
    Console.WriteLine($"After TopTen: '{Local.Value}'");
}

Вывод приведенного выше кода выглядит примерно так:

Before TopOne: 'Initial Value'
Inside Somewhere: 'ValueSetBy TopOne'
Inside DeepDown: 'ValueSetBy TopOne'
After TopOne: 'Initial Value'
Inside Fire: 'ValueSetBy TopOne'
Inside Somewhere: 'ValueSetBy TopTen'
Inside DeepDown: 'ValueSetBy TopTen'
Inside Fire: 'ValueSetBy TopTen'
After TopTen: 'Initial Value'

Как мы видим, код в конечном итоге ведет себя так, как мы ожидали с самого начала. Вне асинхронного потока управления AsyncLocalзначение по умолчанию «Начальное значение». Как только мы присваиваем Valueсвойство, оно остается установленным, даже если мы вызываем Fireметод, не ожидая его.

Иногда Локально просто не достаточно Локально

Мы только что видели, как [ThreadStaticAttribute]или ThreadLocalбольше не работает, как ожидалось, когда мы объединяем его с асинхронным кодом с использованием async/awaitключевых слов. Итак, если вы хотите создать надежный код, который должен иметь доступ к окружающим данным, локальным по отношению к текущему асинхронному потоку управления, вы должны использовать AsyncLocalи обновить его до .NET 4.6 или Core CLR. Если вы не можете обновить свой проект до одной из этих версий платформы, вы можете попробовать имитировать AsyncLocal, используяExecutionContext вместо этого.

Теперь, когда вы видели AsyncLocal, позвольте мне сказать вам, что обучение еще не закончилось! В следующей части я покажу вам, как вы можете реструктурировать существующий код, чтобы вам больше не требовались окружающие данные. Оставайтесь с нами и не сгибайте слишком много ложек!