Контекст выполнения
Мы все знакомы с передачей состояния от метода к методу. Вы вызываете метод, и если этот метод указывает параметры, вы вызываете метод с аргументами, чтобы передать эти данные вызываемому объекту. Это явная передача данных. Но есть и другие, более неявные средства. Например, вместо передачи данных в качестве аргументов метод может быть без параметров, но может диктовать, что некоторые конкретные статические поля могут быть заполнены до вызова метода, и метод будет извлекать состояние оттуда. Ничто в сигнатуре метода не указывает на то, что он принимает аргументы, потому что это так: между вызывающим и вызываемым объектом существует просто неявный договор о том, что вызывающий объект может заполнить некоторые области памяти, а вызываемый может прочитать эти области памяти. Вызываемый и вызывающий могут даже не осознавать, что это происходит, если они являются посредниками, например, метод A может заполнять статику, а затем вызывать, B какие то вызовы C, какие то вызовы D, которые в конечном итоге вызывают E, которые считывают значения этой статики. Их часто называют «внешними» данными: они не передаются вам через параметры, а просто висят там и доступны для использования при желании.
Мы можем пойти дальше и использовать локальное состояние потока. Локальное состояние потока, которое в .NET достигается с помощью статических полей, приписываемых как [ThreadStatic] или через ThreadLocal<T>тип, может использоваться таким же образом, но с данными, ограниченными только текущим потоком выполнения, причем каждый поток может иметь свой собственная изолированная копия этих полей. При этом вы можете заполнить статический поток, вызвать метод, а затем после завершения метода вернуть изменения в статический поток, обеспечивая полностью изолированную форму таких неявно переданных данных.
Но как насчет асинхронности? Если мы выполним вызов асинхронного метода и логика внутри этого асинхронного метода захочет получить доступ к этим внешним данным, как бы он это сделал? Если бы данные хранились в обычной статике, асинхронный метод мог бы получить к ним доступ, но одновременно в работе мог бы работать только один такой метод, поскольку несколько вызывающих объектов могли бы в конечном итоге перезаписать состояние друг друга при записи в их общие статические поля. Если бы данные хранились в статике потока, асинхронный метод мог бы получить к ним доступ, но только до момента, когда он перестал выполняться синхронно в вызывающем потоке; если бы он подключил продолжение какой-либо операции, которую он инициировал, и это продолжение в конечном итоге выполнялось бы в каком-то другом потоке, у него больше не было бы доступа к статической информации потока. Даже если бы он действительно выполнялся в том же потоке, случайно или потому, что планировщик заставил это сделать, к тому времени, когда это произошло, данные, скорее всего, были бы удалены и/или перезаписаны какой-либо другой операцией, инициированной этим потоком. Для асинхронности нам нужен механизм, который позволил бы произвольным внешним данным проходить через эти асинхронные точки, чтобы на протяжении всей логики асинхронного метода, где бы и когда бы эта логика ни выполнялась, он имел доступ к тем же данным.
Тут появляется ExecutionContext. Тип ExecutionContext— это средство, с помощью которого внешние данные передаются из асинхронной операции в асинхронную операцию. Он живет в файле [ThreadStatic], но затем, когда инициируется какая-то асинхронная операция, он «захватывается» (причудливый способ сказать «прочитать статическую копию из этого потока»), сохраняется, а затем, когда запускается продолжение этой асинхронной операции, ExecutionContext с начала восстанавливается в [ThreadStatic] потоке, который собирается выполнить операцию. ExecutionContext это механизм, с помощью которого AsyncLocal<T>реализуется (фактически в .NET Core ExecutionContext речь идет исключительно о AsyncLocal<T>, и не более того), так что если вы сохраните значение в AsyncLocal<T>, а затем, например, поставите в очередь рабочий элемент для запуска в ThreadPool, это значение будет быть видимым AsyncLocal<T>внутри этого рабочего элемента, работающего в пуле:
Б удет печататься 42 каждый раз при запуске. Не имеет значения, что в тот момент, когда мы поставили делегата в очередь, мы сбрасываем значение обратно AsyncLocal<int>в 0, потому что это в ExecutionContext было зафиксировано как часть вызова QueueUserWorkItem, и этот захват включал состояние AsyncLocal<int>в тот самый момент. Мы можем увидеть это более подробно, реализовав наш собственный простой пул потоков:
Здесь MyThreadPoolесть , BlockingCollection<(Action, ExecutionContext?)>который представляет очередь рабочих элементов, причем каждый рабочий элемент является делегатом для вызываемой работы, а также ExecutionContext связанного с этой работой. Статический конструктор пула запускает несколько потоков, каждый из которых просто находится в бесконечном цикле, беря следующий рабочий элемент и запуская его. Если ExecutionContext для данного делегата не было получено значение, делегат просто вызывается напрямую. Но если объект ExecutionContext был захвачен, то вместо прямого вызова делегата мы вызываем метод ExecutionContext.Run, который восстанавливает предоставленный ExecutionContext в качестве текущего контекста до запуска делегата, а затем впоследствии сбрасывает контекст. Этот пример включает в себя тот же код, что и AsyncLocal<int>ранее показанный, за исключением того, что на этот раз MyThreadPool вместо ThreadPool, но он все равно будет выводить 42 каждый раз, поскольку пул работает правильно ExecutionContext.
Кстати, вы заметите, что я вызвал UnsafeStart статический MyThreadPool конструктор. Запуск нового потока - это именно та асинхронная точка, которая должна передаваться ExecutionContext, и действительно, метод Thread's Startiс пользует ExecutionContext.Capture для захвата текущего контекста, сохранения его в Thread, а затем использования этого захваченного контекста при окончательном вызове делегата Thread's ThreadStart. Однако я не хотел делать это в этом примере, так как не хотел, чтобы Threads фиксировал все ExecutionContext, что присутствовало при запуске статического конструктора (это могло бы сделать демо-версию ExecutionContextболее запутанной), поэтому я использовал UnsafeStart вместо этого метода. Методы, связанные с потоками, которые начинаются с, Unsafe ведут себя точно так же, как соответствующий метод, у которого нет префикса , Unsafe за исключением того, что они не захватывают ExecutionContext, например Thread.Start, и не выполняют идентичную работу , Thread.UnsafeStart а в то время как Start захваты не делают этого.ExecutionContextUnsafeStart
to be continued...