Это - третья статья цикла про функцию ограничения скорости обработки запросов в ASP.NET Core. Она содержит концептуальное (т.е. раскрывающее состав и взаимодействие частей функции друг с другом) описание классов универсального компонента ограничения скорости .NET. Функция ограничения скорости обработки запросов в ASP.NET Core, которая является предметом рассмотрения всего цикла, базируется именно на этом универсальном компоненте. В этой статье я собираюсь завершить описание универсального компонента ограничения в .NET: описать входящие в его состав базовые (неселективные) ограничители.

Предупреждение: если вам не требуется или не интересно просто для себя (как это интересно мне) разбираться, как устроена и работает функция ограничения скорости обработки запросов в ASP.NET Core, то эта статья, скорее всего, покажется вам длинной и занудной. Потому что в ней рассказывается о весьма специфических подробностях, знание которых совершенно не требуются для того чтобы просто взять и начать использовать в своей программе функцию ограничения скорости обработки запросов ASP.NET Core. Для использования этой функции, скорее всего достаточно будет изучить примеры - или из первой статьи цикла - руководства по использованию, или вообще из документации на сайте Microsoft. В таком случае вам, наверное, читать эту статью не стоит. Но, возможно, и в этом случае вам стоит хотя бы заглянуть в приложения к ней. Там я, в качестве иллюстрации к основному материалу статьи, описал сделанные мной компоненты, позволяющие использовать функцию ограничения скорости нестандартным способом: возможно, вы найдёте применение одному из таких компонентов в своей программе. Компоненты эти оформлены в виде библиотек классов .NET, так что для их использования уже сейчас можно взять их в исходном виде и добавить в свое решение (solution). Причем, при описании каждого компонента я постарался вынести в начало их описания пример его использования - так, чтобы для использования компонента не требовалось читать остальной текст приложения, где написано как он устроен и работает.

Ну, а если вам пришлось разбираться (потому что эта функция не работает так, как вы ожидали) или, как мне, просто захотелось разобраться для себя, как работает функция ограничения скорости обработки запросов в ASP.NET Core - читайте дальше.

О структуре статьи.

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

Состав цикла

В основном тексте в тематических разделах дано на уровне концепций описание состава и работы соответствующей части, которой посвящен раздел. Базовые понятия, используемые в этом описании, выделены курсивом. В скрытом тексте с заголовком “Подробности” в этих разделах описаны детали реализации: приведен исходный код классов, названия и назначения полей и методов, обнаруженные интересные приемы и т.п. Этот текст можно пропустить без ущерба для общего понимания. Читать его стоит, только если вам интересны эти детали. Для общего понимания, как работает ограничение скорости обработки запросов, этот скрытый текст читать не обязательно.

В скрытом тексте с заголовком “Сводка” ниже содержится краткое изложение материала из тематических разделов. Эту сводку можно использовать для первичного знакомства со статьей, а при необходимости можно ознакомиться с дополнительными подробностями в соответствующем разделе.

Сводка

В основном, определенные в универсальном компоненте базовые (то есть - неселективные) ограничители являются алгоритмическими, то есть, они реализуют те или иные алгоритмы ограничения скорости или параллелизма обработки запросов. Далее в статье я буду называть их базовые алгоритмические ограничители.

В состав универсального компонента ограничения входят следующие базовые алгоритмические ограничители: три ограничителя скорости, реализующих три разных алгоритма ограничения скорости (FixedWindowRateLimiter - ограничение с фиксированным окном, SlidingWindowRateLimiter - ограничение со скользящим окном, TokenBucketRateLimiter - ограничение на основе алгоритма пополняемой емкости для жетонов), и отдельно - ограничитель, реализующий алгоритм ограничения числа одновременно используемых ресурсов (ограничитель параллелизма). Класс ограничителя параллелизма наследуется напрямую от класса RateLimiter, а классы ограничителей скорости - от промежуточного класса ReplenishingRateLimiter, который определяет методы пополнения разрешений (вызываемые обычно по таймеру).

Конструкторы базовых алгоритмических ограничителей принимают один параметр - экземпляр класса параметров настройки, который для каждого класса ограничителя свой. Эти параметры настройки сохраняются во внутреннем поле копии параметров настройки.

Помимо того, что все базовые ограничители реализуют абстрактные методы своего класса-предка - методы подачи заявок AttemptAcquireCore (синхронный) и AcquireAsyncCore(асинхронный), методы очистки Dispose(Boolean) и DisposeAsyncCore, а ограничители скорости - метод реализации пополнения числа доступных заявок ReplenishInternal), все они поддерживают значение предельного числа выдаваемых разрешений, реализуют механизм постановки заявок в очередь в методе асинхронной подачи заявки, запуск на выполнение заявок из очереди при появлении доступных разрешений, а также реализуют свойство IdleDuration, показывающее время простоя ограничителя.

Методы подачи заявок возвращают ответ на заявку в виде экземпляра класса, унаследованного от RateLimitLease. Классы эти - свои для каждого класса ограничителя. Но для всех трех классов ограничителей скорости они очень похожи. Класс ответа на заявку для ограничителя использования по своему содержанию и поведению имеет существенные отличия.

Методы подачи заявок принимают в качестве параметра число запрошенных единиц разрешений. По значению этого параметра заявки можно разделить на пробные заявки, проверяющие возможность получения разрешений в принципе - с числом запрошенных разрешений равным нулю, и реальные заявки, для которых число запрошенных разрешений - положительное (отрицательное значение числа запрошенных разрешений вызывает исключение), и, в случае удовлетворения заявки, получающие эти разрешения.

Во всех классах базовых алгоритмических ограничителей есть поля, которые содержат число доступных для выдачи разрешений, а в копии параметров настройки содержится максимальное значение этого числа. Оба метода подачи заявок сначала проверяют, что число запрошенных разрешений не превышает этого максимального значения. Если это не так, то такую заявку выполнить невозможно, поэтому оба этих метода вызывают в таком случае исключение ArgumentOutOfRangeException. Далее проверяется, не обрабатывается ли пробная заявка. Если это так, и число доступных для выдачи разрешений больше 0, то оба метода синхронно возвращают ответ на заявку с выданным разрешением. В противном случае, прежде всего, на объект ограничителя накладывается блокировка. Далее методы подачи заявки проверяют, что число запрошенных в заявке разрешений не превышает числа доступных разрешений. Если это так, то оба метода формируют ответ на заявку с успешно выданным разрешением, уменьшают счетчик числа доступных разрешений на число разрешений, запрошенных в заявке, снимают блокировку и синхронно возвращают сформированный ответ с выданным разрешением. Если число доступных разрешений оказывается меньше, чем число запрошенных в заявке, то синхронный метод, сразу снимает блокировку и синхронно возвращает ответ на заявку с отказом, а асинхронный метод пытается далее поставить эту заявку в очередь и, в случае успешной постановки, снимает блокировку и возвращает ответ асинхронно - то есть, возвращает задачу (точнее, ValueTask), отслеживающую состояние этой заявки в очереди. При невозможности постановки заявки в очередь (например, если в результате постановки в очередь был бы превышен предельный размер очереди) асинхронный метод тоже снимает блокировку и синхронно возвращает ответ с отказом. Для асинхронного метода AcquireAsyncCore слова “синхронно возвращает ответ”, означают, что он возвращает изначально завершенную задачу (ValueTask) с этим ответом в качестве результата выполнения задачи.

Сбор и возврат статистики во всех классах базовых алгоритмических ограничителей реализован единообразно. Все классы базовых алгоритмических ограничителей имеют поля счетчиков успешных и отклоненных заявок типа long. При возврате ответа на заявку соответствующий счетчик увеличивается на единицу атомарной операцией Interlocked.Increment. При возврате статистики метод RateLimiterStatistics возвращает структуру RateLimiterStatistics, в которую копирует в соответствующие поля значения текущего количества доступных для выдачи разрешений, текущего размера очереди, а также - упомянутые выше счетчики.

Очередь заявок во всех классах базовых алгоритмических ограничителей устроена одинаково. Реализующий ее объект - двухсторонняя очередь (Double-ended queue, deque) элементов очереди. Тип элементов в каждом из классов формально разный - вложенный в класс базового ограничителя класс RequestRegistration, но по факту код этих типов почти совпадает. Разница в коде этого вложенного класса для разных классов ограничителей есть только в двух местах: в типе ссылки на объект ограничителя в конструкторе и в типе данных состояния задачи, отслеживающей состояние элемента очереди (используется в обработчике отмены задачи - статическом методе Cancel) - оба этих типа являются одним и тем же типом базового ограничителя, в класс которого вложен класс элемента очереди.

Во всех классах параметров настроек базовых алгоритмических ограничителей есть одинаковые свойства, содержащие настройки работы с очередью: максимальный размер очереди - суммы запрашиваемых разрешений всех заявок, находящихся в очереди, и перечислимое значение - порядок обработки очереди: значение OldestFirst обозначает порядок обработки “первым поставлен - первым обрабатывается” (режим FIFO, он же режим очереди, установлен по умолчанию), NewestFirst - “последним поставлен - первым обрабатывается” (режим LIFO, он же режим стека). Эти свойства доступны во внутреннем поле объекта ограничителя, содержащем копию параметров настройки.

Кроме самого объекта очереди базовые алгоритмические ограничители хранят текущий размер очереди: суммарное число разрешений, запрошенных находящимися в очереди объектами заявок.

Объект очереди также используется всеми классами в качестве объекта, на котором осуществляется их блокировка - на него ссылается свойство Lock.

Класс элемента очереди унаследован от класса TaskCompletionSource<RateLimitLease>. Этот исходный класс предоставляет через свое свойство Task задачу с ручным управлением (promise task), возвращающую результат типа RateLimitLease (точнее, производного типа) - т.е., ответ на заявку. Именно эта задача (преобразованная к ValueTask<RateLimitLease>) и является тем результатом, который возвращается методом AcquireAsyncCore если он не может возвратить ответ на заявку синхронно.

Постановка заявки в очередь происходит в методе AcquireAsyncCore класса соответствующего базового ограничителя, если этот метод не смог немедленно вернуть ответ на заявку с выданными разрешениями. Постановка заявки в очередь выполняется под той же блокировкой, что и проверка возможности сразу выдать разрешение. Прежде всего, метод асинхронной подачи заявки AcquireAsyncCore проверяет, есть ли место в очереди. Если места нет, но очередь работает в режиме LIFO, то код постановки в очередь пытается освободить место в очереди для постановки текущей заявки, отклоняя заявки, поставленные в очередь ранее, начиная с самой ранней заявки. При этом учитывается, что заявки могут быть отменены в любой момент методом Cancel (про отмену заявки см. далее). В любом случае если места в очереди для текущей заявки не нашлось, метод AcquireAsyncCore синхронно возвращает ответ на заявку с отказом. Если место в очереди есть (было изначально или было очищено), то для заявки создается и добавляется в очередь описанный выше элемент очереди, после чего метод асинхронной подачи заявки AcquireAsyncCore возвращает преобразованную в ValueTask задачу, отслеживающую состояние заявки в очереди - свойство Task элемента очереди.

Разрешения, которые может выдать базовый ограничитель, становятся доступны ему разными способами, в зависимости от вида ограничителя. Для ограничителей скорости это происходит путем пополнения - автоматического по таймеру или ручного вызовом метода TryReplenish. Для ограничителя использования (параллелизма) разрешения возвращаются в результате очистки выданного ранее ответа на заявку, при этом из метода Dispose очистки ответа вызывается метод Release выдавшего ответ ограничителя, в который передается число разрешений, которое было указано при подаче заявки, а потому после очистки стало доступно.

После пополнения числа доступных разрешений соответствующие методы выдают разрешения по всем заявкам в очереди, на выполнение которых хватает полученных разрешений. Код выполнения заявок из очереди, который выдает вновь полученные разрешения и формирует ответы на заявки, как и любой код работы с очередью, выполняется при захваченной блокировке объекта ограничителя, чтобы избежать проблем с параллельным доступом.

Направление, с которого берутся заявки из очереди, указано, как написано выше, в параметрах настройки. Код выполнения заявок из очереди в цикле проверяет, хватает ли разрешений выполнить первую заявку с нужного направления. Если доступных разрешений для выполнения заявки хватает, то этот код извлекает из очереди элемент, связанный с заявкой, и устанавливает значение, возвращаемое задачей, связанной с элементом, в ответ с выданным разрешением на заявку, извлеченную из очереди. При этом учитывается, что заявки могут быть отменены в любой момент методом Cancel (про отмену заявки см. далее). Элементы для заявок, которые уже были отменены или как-то ещё обработаны, просто извлекаются из очереди и добавляются в список на очистку. При успешной установке результата задачи код выполнения заявок из очереди корректирует размер очереди и число доступных разрешений и добавляет элемент выполненной заявки в список на очистку. Затем код выполнения заявок из очереди переходит к рассмотрению следующей заявки. Если очередь пуста или для выполнения следующей заявки числа доступных разрешений не хватает, цикл прекращается.

Отмена стоящих в очереди заявок через механизм согласованной отмены реализована следующим образом. В асинхронный метод подачи заявок AcquireAsyncCore передается в качестве параметра маркер отмены (CancellationToken) через который можно отменить ожидающую в очереди заявку. Отмена производится в коде элемента очереди в его методе Cancel. Метод Cancel регистрируется в конструкторе элемента в качестве обработчика отмены маркера из параметров AcquireAsyncCore, передаваемого в этот конструктор, а потому он вызывается в процессе отмены маркера.

Метод Cancel прежде всего пытается установить задачу, возвращаемую AcquireAsyncCore при вызове с указанным маркером отмены, в состояние отмены. Если ему это удается, то он извлекает из поля состояния задачи (Task.AsyncState) ссылку на ограничитель (она была помещена туда конструктором базового класса TaskCompletionSource<RateLimitLease>), приводит ее к типу ограничителя и устанавливает блокировку на ограничитель. Под этой блокировкой он вычитает из размера очереди ограничителя количество запрошенных в отмененной заявке разрешений (если оно не было вычтено раньше). Сам элемент для отмененной задачи из очереди при этом не удаляется (так как это невозможно, потому что он находится в произвольном месте очереди), он будет извлечен из очереди и отправлен на очистку в другом месте, там, где очередь обрабатывается с начала или с конца: при очистке места в очереди, работающей в режиме LIFO, при постановке в нее новой заявки или при извлечении заявок, которые можно удовлетворить после получения новых разрешений.

Чтобы избежать возможных проблем со взаимными блокировками, разработчики компонента базового ограничителя вынесли код, выполняющий любую очистку (Dispose) связанных с извлеченными элементами очереди объектов, за пределы участков кода, выполняющихся под блокировкой. Для этого они сделали все такие объекты полями элемента очереди RequestRegistration, организовали добавление извлеченных объектов в специальный список элементов, подлежащих очистке, и сделали очистку элементов из этого списка после снятия блокировки: код очистки проходит по односвязному списку элементов очереди, подлежащих очистке и очищает каждый элемент.

Если при выполнении очистки объекта базового алгоритмического ограничителя в очереди ещё остались заявки, то метод, фактически выполняющий очистку - Dispose(Boolean) - захватывает блокировку, извлекает из очереди по одному все оставшиеся в них элементы, устанавливает результат задачи, связанной с извлеченным элементом в статический, созданный заранее ответ с отказом выделения разрешения и добавляет извлеченный элемент в список подлежащих очистке. После снятия блокировки производится очистка этого списка, точно так же, как это описано в предыдущем абзаце.

Свойство IdleDuration реализовано следующим образом. Каждый из экземпляров классов базовых алгоритмических ограничителей имеет поле, которое содержит либо время перехода к состоянию простоя, либо null, если ограничитель не находится в состоянии простоя. Свойство IdleDuration возвращает либо разницу между текущим временем и значением этого поля, приведенную к типу TimeSpan, если это поле - не null, либо null, если это поле содержит null. Ограничитель переходит в состояние простоя, как только число доступных разрешений достигает предельной величины, и этот момент фиксируется в упомянутом выше поле. Происходит это в конструкторе или в методах, которые увеличивают число доступных разрешений. При выдаче разрешения (возврате ответа на заявку с выданным разрешением) состояние простоя сбрасывается: в упомянутое поле записывается null.

К числу классов базовых ограничителей скорости, реализованных в универсальном компоненте ограничения, относятся классы FixedWindowRateLimiter, SlidingWindowRateLimiter и TokenBucketRateLimiter. Все классы ограничителей скорости унаследованы от абстрактного класса ReplenishingRateLimiter, а потому должны реализовывать определенные в нем абстрактные свойства только для чтения ReplenishmentPeriod и IsAutoReplenishing, а также - абстрактный метод TryReplenish().

Свойство только для чтения IsAutoReplenishing, указывающее на режим пополнения разрешений - ручной (false) или автоматический (true) - во всех классах ограничителей скорости реализовано путем возврата значения свойства AutoReplenishment сохраненной копии параметров настройки ограничителя: во всех классах настройки параметров для ограничителей скорости определено свойство типа Boolean с таким именем. Значение свойства ReplenishmentPeriod, содержащего период обновления, в разных классах ограничителей определяется по-разному (см. описания конкретных классов).

Метод ручного пополнения TryReplenish() во всех классах устроен совершенно одинаково. Он прежде всего проверяет работает ли ограничитель в режиме автоматического пополнения разрешений. Если да (IsAutoReplenishing==true), то он просто возвращает false. в противном случае - в ручном режиме пополнения - TryReplenish вызывает статический метод Replenish этого класса с аргументом - ссылкой на этот экземпляр ограничителя (this) и возвращает true.

Тот же самый метод Replenish с аргументом - экземпляром ограничителя в автоматическом режиме пополнения вызывается по таймеру как функция обратного вызова. Именно поэтому он и сделан статическим - чтобы объект делегата для него не приходилось создавать в куче лишний раз при создании таймера. Таймер (System.Threading.Timer)создается в конструкторе каждого из классов ограничителей скорости, он срабатывает периодически, его период равен значению свойства ReplenishmentPeriod.

Метод Replenish во всех классах ограничителей скорости устроен совершенно одинаково: он просто преобразует переданную ему ссылку на экземпляр ограничителя к нужному классу, получает текущее время и вызывает метод ограничителя ReplenishInternal с этим временем как аргументом.

Метод ReplenishInternal каждого из классов ограничителей скорости логически состоит из двух основных частей: пополнения разрешений и дополнительной обработки. Перед тем, как приступить к выполнению первой части, метод ReplenishInternal сначала проверяет что объект ограничителя очищен, и, возможно - другие, зависящие от конкретного класса, условия, делающие его выполнение не нужным. И если одно из условий выполняется - просто сразу возвращает управление, не делая ничего. Затем метод ReplenishInternal устанавливает блокировку на объект ограничителя, и обе части работают под этой блокировкой.

Первая часть производит пополнение числа доступных разрешений. Она специфична для каждого класса. В конце этой части сохраняется время, когда было выполнено пополнение. Вторая часть метода ReplenishInternal выдает разрешения по тем заявкам из очереди, на которые хватает увеличившегося числа доступных разрешений, а затем, если ограничитель переходит в состоянии простоя - устанавливает время начала простоя, обе эти операции описаны выше. При этом, вторая часть метода выполняется только при условии, что в первой части были фактически добавлены дополнительные разрешения.

Каждый из классов ограничителей скорости возвращает ответ на заявку своего, особого типа, определённого как вложенный класс, унаследованный от абстрактного класса RateLimitLease, в классе ограничителя скорости.

Классы ответов на заявку, как наследники класса RateLimitLease, должны определять его абстрактные свойства IsAcquired и MetadataNames и абстрактный метод TryGetMetadata. MetadataNames возвращает статически определенный список, содержащий имя одного свойства - рекомендуемого времени ожидания перед повторной попыткой. Значения свойства IsAcquired и метаданных для времени ожидания RetryAfter.Name, возвращаемых методом TryGetMetadata, хранятся во внутренних полях, значения которых передаются через параметры конструктора. Значение метаданных для рекомендуемого времени ожидания может отсутствовать (быть null), в таком случае эти метаданные считаются отсутствующими, и TryGetMetadata их не возвращает.

Ответ на заявку с метаданными рекомендуемого времени ожидания возвращается только при синхронном возврате ответа с отказом в тех классах, в которых реализована оценка времени ожидания: FixedWindowRateLimiter и TokenBucketRateLimiter. Если отказ выделения разрешений по заявке происходит из-за удаления заявки из очереди, то связанная с удаленной заявкой задача возвращает неспецифический ответ с отказом без метаданных рекомендуемого времени ожидания до повтора. Для класса SlidingWindowRateLimiter оценка времени ожидания не реализована, поэтому при отказе он всегда возвращает ответ с отказом без этих метаданных.

Алгоритм, реализованный классом FixedWindowRateLimiter, разбивает время на фиксированные промежутки - окна - и в каждом таком промежутке выдает разрешения в количестве не более указанного. Параметры настройки этого класса содержат (кроме описанных выше общих для всех ограничителей скорости параметров QueueLimit, QueueProcessingOrder и AutoReplenishment), продолжительность окна TimeSpan Window и максимальное количество выдаваемых им разрешений int PermitLimit.

Дополнительно к состоянию очистки метод ReplenishInternal класса FixedWindowRateLimiter в качестве условия немедленного возврата проверяет, что он работает в ручном режиме пополнения и пополнение вызвано преждевременно.

Значение периода пополнения, доступное через свойство ReplenishmentPeriod, для класса FixedWindowRateLimiter равно продолжительности окна, указанной в параметрах настройки.

Оценка времени ожидания для ограничителя с фиксированным окном рассчитывается как длительность числа окон, в которые уместится разница между запрошенным числом разрешений плюс текущий размер очереди и числом доступных разрешений (округление вниз, минимум 1).

Алгоритм, реализованный классом SlidingWindowRateLimiter, выдает, аналогично предыдущему алгоритму, разрешения в заданном промежутке времени - окне - в количестве не более указанного. Но его окно отсчитывается так, что оно формально заканчивается на текущем моменте времени. В реальности этот алгоритм в целях упрощения сдвигает начало окна не непрерывно, а в определенные моменты времени. Полная продолжительность окна разбивается на указанное в параметрах настройки число сегментов равной продолжительности. Сдвиг начала окна происходит, когда промежуток между началом окна и текущим моментом превышает размер окна, при этом начало окна сдвигается сразу на один сегмент. И таким образом текущий момент всегда находится не обязательно в конце окна, но обязательно - в его последнем сегменте. При сдвиге окна его первый сегмент удаляется и число доступных разрешений увеличивается на количество разрешений, выданных в этом сегменте. Параметры настройки класса SlidingWindowRateLimiter содержат (кроме описанных выше общих для всех ограничителей скорости параметров QueueLimit, QueueProcessingOrder и AutoReplenishment) продолжительность окна TimeSpan Window, число сегментов, на которые разбивается окно int SegmentsPerWindow и максимальное количество выдаваемых им разрешений int PermitLimit.

Класс SlidingWindowRateLimiter фиксирует количество разрешений, выданных в каждом сегменте окна. Поэтому при синхронном возврате ответа на заявку с выданными разрешениями из методов подачи заявки, число разрешений в этой заявке добавляется к количеству разрешений, выданных в текущем сегменте.

Метод ReplenishInternal класса SlidingWindowRateLimiter дополнительно к состоянию очистки в качестве условия немедленного возврата проверяет, что он работает в ручном режиме пополнения и пополнение вызвано преждевременно. Первая часть этого метода после установления блокировки добавляет к числу доступных разрешений число разрешений, выданных в самом старом сегменте, который будет удален сдвигом окна. После этого производится сдвиг окна. Во второй части, если происходит возврат ответа на заявку с выданными разрешениями, то число разрешений, запрошенных в этой заявке, добавляется к количеству разрешений, выданных в текущем сегменте.

Свойство ReplenishmentPeriod для класса SlidingWindowRateLimiter возвращает продолжительность сегмента.

Класс TokenBucketRateLimiter реализует алгоритм ограничения с пополняемой емкостью для жетонов (иногда также именуемый “алгоритм дырявого ведра”). В этом алгоритме разрешения пополняются непрерывным потоком с заданной скоростью и накапливаются ограничителем, при этом для числа накопленных разрешений есть предел, сверх которого разрешения не накапливаются. Разрешения этим алгоритмическим ограничителем, как и другими ограничителями, выдаются, если число запрошенных в заявке разрешений меньше числа накопленных, при этом число выданных разрешений вычитается из накопленных. Параметры настройки класса TokenBucketRateLimiter содержат (кроме описанных выше общих для всех ограничителей скорости параметров QueueLimit, QueueProcessingOrder и AutoReplenishment) предел для накопленных разрешений int TokenLimit (это - аналог PermitLimit в параметрах других ограничителей) и параметры, задающие скорость пополнения: длину периода пополнения TimeSpan ReplenishmentPeriod и число добавляемых за период разрешений int TokensPerPeriod - скорость пополнения равна их отношению. Свойство ReplenishmentPeriod ограничителя с пополняемой емкостью возвращает период пополнения, указанный в одноименном свойстве параметров настройки. В режиме автоматического пополнения период пополнения также является и промежутком времени, через который таймер вызывает метод пополнения ReplenishInternal (через метод Replenish, см. выше общее описание методов пополнения). Начальное количество доступных для выдачи разрешений равно пределу для накопленных разрешений (TokenLimit).

Дополнительных условий (кроме стандартного - объект ограничителя очищен) для немедленного возврата из метода ReplenishInternal в классе TokenBucketRateLimiter нет. Работа первой части этого метода после установления блокировки в зависимости от режима пополнения происходит по-разному. В режиме автоматического пополнения метод ReplenishInternal просто прибавляет к числу доступных разрешений значение параметра настройки TokensPerPeriod, игнорируя тем самым возможную нестабильность таймера. В режиме ручного пополнения периодичность вызова метода пополнения TryReplenish не гарантируется даже приблизительно, поэтому в этом режиме ReplenishInternal честно вычисляет количество добавляемых разрешений, умножая разницу текущего момента времени и момента предыдущего пополнения на скорость пополнения разрешений. При этом результат умножения не обязательно будет целым числом. А потому число доступных разрешений в классе TokenBucketRateLimiter хранится не как целое, а как число с плавающей точкой. В обоих случаях, если результат добавления превысит предельное число разрешений ограничителя TokenLimit, значение числа доступных разрешений устанавливается в это предельное число.

Оценка рекомендуемого времени ожидания в метаданных ответа с отказом для ограничителя с пополняемой емкостью рассчитывается прямолинейно: как промежуток времени за который поступившие разрешения позволят удовлетворить все уже имеющиеся заявки в очереди плюс отклоняемую заявку (минимум - один период пополнения).

Базовый алгоритмический ограничитель использования (иначе, ограничитель параллелизма, класс ConcurrencyLimiter) ограничивает максимальное количество одновременно выданных им разрешений. Это максимальное количество задается в параметрах настройки. В каждом возвращаемом методами подачи заявки объекте ответа на нее с успешной выдачей разрешений запоминается указанное в этой заявке число разрешений, и одновременно это число вычитается из счетчика доступных разрешений. При очистке объекта ответа на заявку это запомненное число разрешений добавляется к счетчику доступных разрешений.

Класс параметров настройки ограничителя использования ConcurrencyLimiterOptions содержит, кроме описанных выше общих для всех базовых алгоритмических ограничителей параметров настройки очереди QueueLimit и QueueProcessingOrder, также максимальное количество выдаваемых разрешений int PermitLimit.

В ответе на заявку от ограничителя использования сохраняется число выданных по ней разрешений. При очистке ответа на заявку эти разрешения возвращаются ограничителю для их повторного использования вызовом метода Release ограничителя. Метод Release устанавливает блокировку, проверяет, что объект ограничителя не очищен, добавляет к числу доступных для выдачи разрешений значение переданного ему параметра, а затем выдает разрешения по тем заявкам из очереди, на которые хватает увеличившегося числа доступных разрешений, и, если ограничитель переходит в состоянии простоя, устанавливает время начала простоя. Последние две операции описаны ранее.

Для ответа на заявку от ограничителя использования определены единственные метаданные - причина отказа, их тип - строка.

Начиная с версии .NET 10 в универсальный компонент ограничения был добавлен новый тип базового ограничителя - сцепленный базовый ограничитель. Конструкция и логика работы этого типа ограничителя близка к логике работы сцепленного селективного ограничителя, описанного в предыдущей статье.

Для получения разрешения сцепленному базовому ограничителю требуется получить разрешения от всех базовых ограничителей, входящих в его состав. При создании экземпляра этого ограничителя его конструктору передается (через список параметров переменной длинны) набор ограничителей, который будет включать в себя этот сцепленный ограничитель.

Класс сцепленного базового ограничителя ChainedRateLimiter имеет модификатор доступа internal, а потому напрямую его создать нельзя. Создается сцепленный ограничитель статическим методом CreateChained класса RateLimiter. Этот метод имеет переменное число параметров - базовых ограничителей. Список этих параметров передается в виде массива напрямую в конструктор класса сцепленного базового ограничителя.

При подаче заявки на разрешения сцепленный базовый ограничитель пытается по очереди получить разрешение от всех входящий в его состав ограничителей, подавая заявку методом того же типа - синхронным или асинхронным на то же количество разрешений. Если хотя бы один из входящих в состав ограничителей не сможет выделить разрешения, то сцепленный ограничитель возвращает полученный от него ответ на заявку с отказом в разрешении. Если же все ограничители смогли выделить запрошенное количество разрешений, то сцепленный ограничитель возвращает комбинированный ответ на заявку с полученным разрешением.

Очистка сцепленного базового ограничителя просто выставляет флаг очистки, но не приводит к очистке входящих в его состав ограничителей. Это поведение полностью аналогично поведению сцепленного селективного ограничителя.

Дополнительно к методам, аналогичным реализованным сцепленным селективным ограничителем, сцепленный базовый ограничитель реализует свойство IdleDuration - время простоя ограничителя, используемое, напоминаю, для управления временем жизни секции секционированного ограничителя, для которой этот базовый ограничитель является ограничителем секции. В качестве значения времени простоя возвращается наименьшее непустое время простоя для всех ограничителей, которые входят в состав этого ограничителя.

Так как сцепленный базовый ограничитель не является ограничителем скорости, то, если он используется как ограничитель секции в секционированного ограничителе, периодически вызываемый по таймеру код секционированного ограничителя не будет вызывать для такой секции никакой код для ручного пополнения разрешений. Это означает, что секционированный ограничитель в принципе не сможет периодически вызывать методы TryReplenish ручного пополнения разрешений для ограничителей скорости, работающих в ручном режиме и входящих в состав сцепленного ограничителя секции - в отличие того, как это делается для ограничителей секций, являющихся ограничителями скорости. Поэтому для правильного использования ограничителей скорости в составе сцепленного ограничителя секции необходимо, чтобы они работали в режиме автоматического пополнения - тогда как для использования их непосредственно как ограничителей секции, наоборот, рекомендуется, чтобы они работали в режиме ручного пополнения.

В разделе с заголовком “Про код” я изложил (в одноименном скрытом тексте, чтобы уменьшить объём этой и без того объёмной статьи) свои мысли о коде, реализующем описанные в статье классы. Кому вдруг интересно это мое мнение, могут с ним ознакомиться, а остальные вполне могут безболезненно пропустить этот текст.

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

Содержание

О классах базовых ограничителей в целом.

В основном, определенные в универсальном компоненте базовые (то есть - неселективные) ограничители являются алгоритмическими, то есть, они реализуют те или иные алгоритмы ограничения скорости или параллелизма обработки запросов. Далее в статье я буду называть их базовые алгоритмические ограничители.

В универсальном компоненте ограничения определены классы базовых алгоритмических ограничителей, реализующих те или иные алгоритмы ограничения. Для каждого из этих классов ограничителей определен свой класс параметров настройки для алгоритма, реализуемого классом ограничителя. Экземпляр параметров настройки передается в конструктор соответствующего класса ограничителя. В состав универсального компонента ограничения входят следующие базовые алгоритмические ограничители:

  • Ограничитель скорости с фиксированным окном FixedWindowRateLimiter, с параметрами настройки - классом FixedWindowRateLimiterOptions;

  • Ограничитель скорости со скользящим окном SlidingWindowRateLimiter, с параметрами настройки - классом SlidingWindowRateLimiterOptions;

  • Ограничитель скорости, выполняющий алгоритм ограничения с пополняемой емкостью для жетонов TokenBucketRateLimiter, с параметрами настройки - классом TokenBucketRateLimiterOptions;

  • Ограничитель использования (параллелизма) ConcurrencyLimiter, с параметрами настройки - классом ConcurrencyLimiterOptions.

Кроме того, в .NET 10 в универсальном компоненте появился базовый ограничитель, не являющийся алгоритмическим: сцепленный базовый ограничитель - класс ChainedRateLimiter.

Все классы базовых алгоритмических ограничителей устроены примерно одинаково, в их коде, мягко говоря, много общего.

А грубо говоря, там царит дублирование.

Царство дубляжа - именно так можно вкратце описать код базовых алгоритмических ограничителей. Разные классы базовых алгоритмических ограничителей не просто реализуют схожие функции, но и реализуют их почти одинаковым кодом, но при этом, код для каждого класса ограничителя - он свой, отдельный. Если бы эту библиотеку писал я, то я, как человек, который умеет готовить наследование, вынес бы весь общий код в методы общего класса-предка, а то, что для каждого класса является уникальным - реализовал бы через виртуальные методы, используя прием “шаблонный метод” (template method pattern). Но, похоже, авторы кода универсального компонента ограничения, не любит наследование. Возможно - потому что просто не умеют его готовить. И, как результат, копируют почти одинаковый код по разным классам. Извинить бы их могло стремление к производительности - из-за которого приходится избегать виртуальных методов, но здесь, по некоторым признакам, явно не тот случай. Так что, похоже, команда, разработавшая этот компонент, просто не любит наследование, по той или иной причине.

Классы параметров настроек для базовых алгоритмических ограничителей не являются в плане дубляжа исключением: в них, как и самих классах базовых алгоритмических ограничителей, тоже широко используется метод copy-paste (впрочем - иногда творчески). Во всех же классах базовых алгоритмических ограничителей есть поле с одинаковым именем _options и типом класса параметров настроек, предназначенных для этого класса ограничителя. Конструктор ограничителя создает для этого поля новый объект настроек соответствующего класса, и переданные в конструктор значения настроек копируются в этот объект.

Поэтому в статье я прежде всего опишу то, что во всех базовых алгоритмических ограничителях (или их части, конкретно - в ограничителях скорости) есть общего, а только потом - имеющиеся различия. Конкретные свойства классов параметров настроек - общие и специфические для отдельных классов алгоритмических ограничителей - будут описаны при описании конкретных функций - опять-таки общих или специфических для отдельных классов.

Общая функциональность базовых алгоритмических ограничителей.

В этом разделе описано то общее, что есть у всех базовых алгоритмических ограничителей из универсального компонента ограничения.

Ответы на заявки.

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

Подача заявок.

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

Выполнение обоих методов подачи заявок - синхронного AttemptAcquireCore и асинхронного AcquireAsyncCore - начинается одинаково. Эти методы принимают в качестве параметра число запрошенных единиц разрешений, которое должно быть больше или равно нулю: отрицательное значение этого параметра вызывает исключение. По значению этого параметра заявки можно разделить на пробные заявки, проверяющие возможность получения разрешения (хотя бы одного), но фактически разрешений не получающих, для них параметр числа запрошенных разрешений равен нулю, и реальные заявки, для которых параметр числа запрошенных разрешений - положительный, и которые, в случае удовлетворения заявки, получают указанное в параметре число разрешений. Эти два типа заявок обрабатываются по-разному.

Во всех классах базовых алгоритмических ограничителей есть поля, которые содержат число доступных для выдачи разрешений, а в копии параметров настройки в поле _options содержится максимальное значение этого числа. Имена этих полей зависят от конкретного класса (см. скрытый текст “Подробности” ниже).

Оба метода подачи заявок сначала проверяют, что число запрошенных разрешений не превышает указанного выше максимального значения. Если это не так, то такую заявку выполнить невозможно, поэтому оба этих метода вызывают в таком случае исключение ArgumentOutOfRangeException. Если экземпляр базового ограничителя был очищен вызовом Dispose() или DisposeAsync, то методы подачи заявок так или иначе (в разных классах ограничителей эта часть кода несущественно отличается) вызывают исключение ObjectDisposedException.

Далее проверяется, не обрабатывается ли пробная заявка. Если это так, и число доступных для выдачи разрешений больше 0, то оба метода синхронно возвращают ответ на заявку с выданным разрешением. А при обработке реальной заявки оба метода затем пытаются получить указанное в их первом параметре число разрешений. Если удалось успешно выделить нужное число разрешений, то методы подачи заявки синхронно возвращают ответ с полученным разрешением.

Если ответ с разрешением вернуть на предыдущем этапе не получилось, синхронный метод сразу, синхронно возвращает ответ на заявку с отказом в выдаче разрешений, а асинхронный - пытается поставить эту заявку в очередь и возвращает ответ асинхронно - то есть, возвращает задачу (точнее, ValueTask), отслеживающую состояние этой заявки в очереди. При невозможности постановки заявки в очередь (например, если в результате постановки в очередь был бы превышен предельный размер очереди) асинхронный метод синхронно возвращает ответ на заявку с отказом.

Небольшое пояснение про асинхронный метод

Для асинхронного метода AcquireAsyncCore слова “синхронно возвращает ответ”, означает что он возвращает изначально завершенную задачу (ValueTask) с этим ответом в качестве результата выполнения задачи.

Подробности.

Фактическая выдача ответа с выданными разрешениями с одновременным уменьшением числа доступных разрешений производится в частном методе TryLeaseUnsynchronized. Перед его вызовом методы подачи заявок захватывают блокировку объекта ограничителя, которая снимается либо непосредственно перед синхронным возвратом ответа, либо (в асинхронном методе) после постановки в очередь. Ну, и при возникновении исключения блокировка, если она установлена, тоже, естественно, снимается.

В большинстве классов базовых алгоритмических ограничителей текущее число доступных к выдаче разрешений хранится в поле _permitCount типа int. И только в TokenBucketRateLimiter это поле имеет другое имя и другой тип - double _tokenCount. Причина, почему это поле - не целое, изложена ниже, в описании метода пополнения разрешений для этого класса. Впрочем, тот факт, что это поле может содержать нецелое значение, в методах подачи заявок и частном методе TryLeaseUnsynchronized игнорируется, хотя, возможно, это - ошибка: она может привести к странному (хотя формально вполне допустимому) результату подачи пробной заявки - в случае если _tokenCount будет иметь значение между 0 и 1, ответ на пробную заявку будет положительным, хотя попытка запросить хотя бы одно разрешение посредством реальной заявки удачной не будет.

Предельное число выдаваемых разрешений хранится в копии параметров настройки в поле _options в свойстве с именем PermitLimit типа int (кроме параметров настроек TokenBucketRateLimiter, там оно именуется TokenLimit, но тип его, в отличие от поля _tokenCount - тоже int).

Как уже было написано выше, каждый класс базового ограничителя возвращает экземпляр своего собственного класса ответа на заявку, унаследованного от RateLimitLease (другое дело, что код этих классов во многом отличается только именем класса, но об этом - позже). И во многих случаях этот экземпляр будет одним из двух заранее созданных экземпляров этого типа типа: один используется при возврате ответа с выданным разрешением, другой - ответа на заявку с отказом. Эти экземпляры - неспецифические ответы - создаются в конструкторах классов базовых алгоритмических ограничителей и сохраняются в статических полях с именами SuccessfulLease и FailedLease соответственно. Они возвращаются там, где в ответ заявки не требуется добавлять дополнительную, зачастую - специфичную для этой заявки, информацию.

Там, где в ответ на заявку требуется добавлять специфичную информацию, возвращаются другие, чаще всего - вновь созданные, экземпляры класса ответа на заявку - специфические ответы. Список этих мест следующий:

  • синхронные возвраты ответов с отказом в классах ограничителей скорости FixedWindowRateLimiter и TokenBucketRateLimiter:

    • в методе AttemptAcquireCore при возврате ответа с отказом при обработке пробной заявки;

    • в методе AttemptAcquireCore при возврате ответа с отказом при обработке реальной заявки, если TryLeaseUnsynchronized не смог выделить запрошенное число разрешений;

    • в методе AcquireAsyncCore при возврате ответа с отказом при невозможности постановки заявки в очередь;

  • возврат ответов с выданными разрешениями на реальную заявку в ограничителе использования ConcurencyLimiter

    • синхронный возврат ответа с выданным разрешением - формируемого в методе TryLeaseUnsynchronized;

    • асинхронный возврат ответа с выданным разрешением на заявку из очереди - в методе Release;

  • синхронный возврат ответа на заявку с отказом в ограничителе использования ConcurencyLimiter в методе AcquireAsyncCore при невозможности постановки заявки в очередь.

Подробнее эти места и добавляемая в результат заявки информация обсуждаются в описаниях соответствующих классов базовых алгоритмических ограничителей.

Признак того, что объект ограничителя был очищен, хранится в поле bool _disposed. Конкретные способы вызова исключения ObjectDisposedException, если это признак установлен, в разных классах немного отличаются (то есть, дубляж тут творческий). В любом случае проверка и при необходимости вызов исключения производятся в методе TryLeaseUnsynchronized всех классов, поэтому при подачи реальной, а не проверочной заявки (permitCount >0) это исключение вызывается там, если туда доходит управление. Но в том, что касается подачи проверочной заявки (где в коде методов подачи заявки имеется ранний возврат), код этих методов может быть устроен по-разному: либо проверка на очистку может происходить в самом методе до обработки проверочной заявки (так устроены метод AcquireAsyncCore классов FixedWindowRateLimiter, SlidingWindowRateLimiter и TokenBucketRateLimiter и оба метода класса ConcurrencyLimiter), либо для очищенного ограничителя код обработки проверочной заявки обходится (в методах AttemptAcquireCore классов FixedWindowRateLimiter, SlidingWindowRateLimiter и TokenBucketRateLimiter), устраняя возможность раннего возврата. Впрочем, это разнообразие ни на что не влияет.

Особенностью класса ограничителя скорости со скользящим окном SlidingWindowRateLimiter является учет когда (в каком сегменте) и сколько было выдано разрешений. Эта информация необходима для работы его алгоритма ограничения. Поэтому перед возвратом ответа на заявку с выданным разрешением запрошенное для выдачи количество единиц разрешений (значение параметра permitСount метода подачи заявки) добавляется в методах подачи заявок этого класса к счетчику выданных разрешений текущего сегмента. Подробно об этом будет рассказано в описании этого класса тоже в скрытом тексте, с именем “Подробности”.

Статистика.

Сбор и возврат статистики во всех классах базовых алгоритмических ограничителей реализован единообразно. Все эти классы имеют поля счетчиков успешных и отклоненных заявок типа long с одинаковыми именами _successfulLeasesCount и _failedLeasesCount. При возврате ответа на заявку соответствующий счетчик увеличивается на единицу атомарной операцией Interlocked.Increment. При возврате статистики метод RateLimiterStatistics возвращает структуру RateLimiterStatistics, в соответствующие поля которой этот метод копирует значения текущего количества разрешений из соответствующего поля (см. скрытый текст в предыдущем разделе), текущего размера очереди (из поля _queueCount), а также - упомянутые выше счетчики. Короче, опять имеем торжество копипасты (ну, почти - кое-где имена копируемых полей отличаются).

Очередь заявок.

Хотя класс параметров настроек для каждого класса базового алгоритмического ограничителя свой, но все эти классы настроек имеют одинаковые свойства, содержащие настройки работы с очередью: int QueueLimit - максимальный размер очереди - то есть суммы запрашиваемых разрешений во всех заявках, находящихся в очереди, и QueueProcessingOrder QueueProcessingOrder - порядок обработки очереди: значение OldestFirst обозначает порядок обработки “первым поставлен - первым обрабатывается” (режим FIFO, это - режим очереди, установленный по умолчанию), NewestFirst - “последним поставлен - первым обрабатывается” (режим LIFO, он же режим стека). Эти свойства доступны в копиях параметров настройки, которые во всех классах базовых алгоритмических ограничителей находятся в поле _options.

Очередь заявок во всех классах базовых алгоритмических ограничителей устроена одинаково. Реализующий ее объект - это двухсторонняя очередь (Double-ended queue, deque) из элементов очереди. Тип элементов очереди в каждом из классов ограничителей формально разный, а именно - вложенный в класс ограничителя класс RequestRegistration, но по факту код этих типов почти совпадает. Разница в коде этого вложенного класса для разных классов ограничителей есть только в двух местах: в типе ссылки на объект ограничителя в конструкторе и в типе данных состояния задачи, отслеживающей состояние элемента очереди (используется в обработчике отмены задачи - статическом методе Cancel) - оба этих типа являются одним и тем же типом базового ограничителя, в класс которого вложен класс элемента очереди.

Помимо самого объекта очереди к ее состоянию можно отнести упомянутый в разделе о статистике размер очереди - то есть, суммарный счетчик разрешений, запрошенных всеми находящимися в очереди объектами заявок.

Объект очереди также используется всеми классами в качестве объекта, на котором осуществляется блокировка экземпляра ограничителя.

Подробности

Объект очереди является экземпляром внутреннего для библиотеки времени выполнения .NET обобщенного контейнера двухсторонней очереди Deque<T> из пространства имен System.Collections.Generic, параметр-тип T в нем - это тип элемента, в нашем случае - вложенный класс RequestRegistration из соответствующего класса базового ограничителя. Так как документация на контейнер двусторонней очереди отсутствует (потому что он - внутренний), приведу здесь список его методов и свойств, нужных для понимания работы базовых алгоритмических ограничителей:

  • void EnqueueTail(T item) - метод добавления элемента в конец очереди;

  • T PeekHead()/T PeekTail() - методы чтения элемента в начале/конце очереди без изъятия из очереди;

  • T DequeueHead()/T DequeueTail() - методы извлечения элемента в начале/конце очереди, то есть - чтения элемента с одновременным его изъятием из очереди;

  • int Count {get;} - свойство, возвращающее количество элементов в очереди.

Объект очереди создается в конструкторе и сохраняется в поле _queue базового ограничителя.

Счетчик заявок содержится в поле int _queueCount, присутствующем опять-таки в каждом классе базового алгоритмического ограничителя. Ссылка на объект, на котором осуществляется блокировка экземпляра ограничителя - свойство Lock - одинаково определено во всех классах базовых алгоритмических ограничителей как ссылка на объект очереди. То есть дубляж - он тут опять с нами.

Элемент очереди заявок

Класс элемента очереди RequestRegistration для всех базовых алгоритмических ограничителей унаследован от класса TaskCompletionSource<RateLimitLease>. Этот исходный класс предоставляет через свое свойство Task задачу с ручным управлением (promise task), возвращающую результат типа RateLimitLease - т.е., ответ на заявку. Именно эта задача (преобразованная к ValueTask<RateLimitLease>) и является тем результатом, который возвращается методом AcquireAsyncCore если он не может возвратить ответ на заявку синхронно.

Подробности

В полях и автоматических свойствах класса элемента очереди сохраняются количество запрошенных в заявке разрешений (в автоматическом свойстве Count), копия маркера отмены, с которым был вызван метод AcquireAsyncCore (_cancellationToken), ссылка на регистрацию функции обратного вызова при отмене этого маркера (_cancellationTokenRegistration), признак того, что изменение суммарного количества разрешений в очереди при отмене маркера или извлечении этого элемента из очереди уже произведено (автоматическое свойство QueueCountModified, являющееся флагом, то есть имеющее тип bool) и ссылка на следующий очищаемый элемент в списке на очистку (_next). Использование этих полей и свойств будет рассмотрено далее.

Создание и обращение к полям и свойствам элемента очереди RequestRegistration происходит только при захваченной блокировке объекта ограничителя (для нее используется свойство Lock, т.е. объект очереди), к чьей очереди относится этот элемент.

Постановка заявки в очередь.

Постановка заявки в очередь происходит в методе AcquireAsyncCore класса соответствующего базового алгоритмического ограничителя, если этот метод не смог немедленно вернуть ответ на заявку с выданным разрешением. Как и в остальной части этого метода код постановки в очередь для всех классов практически одинаков (уже неоднократно упомянутый дубляж - в действии!), а потому может быть рассмотрен для всех классов совместно.

Постановка заявки в очередь выполняется под той же блокировкой, что и вызываемый перед этим внутренний метод попытки синхронного выполнения заявки TryLeaseUnsynchronized. Прежде всего, метод асинхронной подачи заявки AcquireAsyncCore проверяет, есть ли место в очереди. Если места нет, но очередь работает в режиме LIFO, то код постановки в очередь пытается освободить место в очереди для постановки текущей заявки, отклоняя заявки, поставленные в очередь ранее, начиная с самой ранней заявки. При этом учитывается, что заявки могут быть отменены в любой момент методом Cancel (про отмену заявки см. далее). В любом случае, если места в очереди для текущей заявки не нашлось, метод AcquireAsyncCore синхронно возвращает ответ на заявку с отказом. Если место в очереди нашлось (было изначально или было очищено), то для заявки создается и добавляется в очередь описанный выше элемент очереди, после чего метод асинхронной подачи заявки AcquireAsyncCore возвращает преобразованную в ValueTask задачу, отслеживающую состояние заявки в очереди - свойство Task элемента очереди.

Подробности

Наличие места в очереди проверяется путем сравнения разницы между размером очереди в сохраненных параметрах настройки _options.QueueLimit и текущим размером очереди _queueCount с запрошенным в заявке числом числом разрешений (значением параметра метода permitCount). Если оставшегося места в очереди не хватает, а очередь работает в режиме LIFO (_options.QueueProcessingOrder == QueueProcessingOrder.NewestFirst), то код постановки в очередь пытается освободить это место. Для этого он выполняет код очистки очереди от старых заявок.

Код очистки очереди от старых заявок в цикле, пока очередь не пуста и свободного места в ней недостаточно, извлекает элемент заявки из начала очереди методом DequeueHead() и добавляет число запрошенных разрешения из этой заявки к свободному месту в очереди - то есть, вычитает (возможно, преждевременно) число разрешений в заявке (значение автоматического свойства Count элемента) из размера очереди (поле _queueCount ограничителя). После этого код очистки пытается установить результат связанной с удаляемым элементом задачи в значение неспецифического ответа на заявку с отказом (значение свойства FailedLease). Если результат задачи установить не получается, это означает, что заявка уже была обработана. В настоящее время это может быть сделано только сработавшим обработчиком отмены маркера, который отменил связанную с этой заявкой задачу, см. описание отмены заявки далее. Для уже обработанной заявки необходимо проверить, было ли уже вычтено из размера очереди число разрешений в обработанной заявке. Проверка производится по значению свойства-флага QueueCountModified элемента. Если флаг установлен, это означает, что число разрешений уже было вычтено, и код очистки компенсирует свое предыдущее преждевременное вычитание добавлением к размеру очереди числа разрешений в заявке. В противном случае этот флаг устанавливается, чтобы число разрешений из извлеченной заявки не было вычтено повторно другим кодом. Извлеченный из очереди элемент добавляется в список элементов подлежащих очистке после снятия блокировки (его хранит переменная disposer, про этот список см. ниже, в описании процесса очистки).

При наличии места в очереди (сразу или после выполнения кода очистки очереди) код постановки заявки в очередь создает объект элемента очереди, добавляет его в конец очереди методом EnqueueTail() и увеличивает размер очереди _queueCount на число разрешений, запрошенных в заявке. При создании элемента очереди в его конструктор передается число разрешений, запрошенных в заявке, ссылка на экземпляр ограничителя, которому принадлежит очередь, и маркер отмены, указанный в параметрах вызова метода AcquireAsyncCore. Ссылка на экземпляр базового ограничителя передается в конструктор базового класса TaskCompletionSource<RateLimitLease>, она будет доступна как поле AsyncState объекта задачи, содержащейся в поле Task (унаследованном от базового класса). Число разрешений и маркер отмены сохраняются, соответственно, в автоматическом свойстве Count и поле _cancellationToken. Кроме того, если переданный маркер допускает отмену, то для него регистрируется в качестве обработчика отмены статический метод Cancel класса элемента очереди, которому в качестве объекта состояния будет передаваться ссылка на этот элемент очереди (this). Работа этого метода будет рассмотрена при описании отмены заявок. Полученная ссылка на зарегистрированный обработчик отмены сохраняется в поле _cancellationTokenRegistration.

При невозможности поставить заявку в очередь метод AcquireAsyncCore всех классов ограничителей синхронно возвращает ответ на заявку с отказом. При этом, как уже было упомянуто в более раннем скрытом тексте, методы классов FixedWindowRateLimiter и TokenBucketRateLimiter возвращают специфические вновь созданные ответы на заявки с отказом (см. описание этих классов). Метод класса ConcurencyLimiter тоже возвращает специфический, но заранее созданный ответ с отказом, содержащим в метаданных причины отказа строку с сообщением, что отсутствует место в очереди. Методы остальных классов (а именно, класса SlidingWindowRateLimiter) возвращают при невозможности постановки в очередь неспецифический ответ на заявку с отказом из автоматического свойства FailedLease.

Действия при появлении доступных разрешений.

Разрешения, которые может выдать базовый ограничитель, становятся доступны ему разными способами, в зависимости от вида ограничителя. Для ограничителей скорости это происходит с течением времени путем пополнения - автоматического, по встроенному в объект ограничителя таймеру или ручного, вызовом метода TryReplenish (который тоже вызывается по таймеру, но из другого компонента). В обоих случаях случае сам процесс пополнения производится в методе ReplenishInternal. Количество добавляемых разрешений определяется самим классом ограничителя и зависит от параметров его настройки. Для ограничителя использования (параллелизма) выданные ранее разрешения возвращаются ему в результате очистки выданного ранее ответа на заявку с успешно выделенными разрешениями. При этом из метода очистки Dispose ответа на заявку вызывается метод Release ограничителя, в который передается то число разрешений, которое было указано при подаче заявки, и запомнено в объекте ответа на нее, после очистки объекта ответа на эту заявку по окончании его использования эти разрешения становятся доступными для повторной выдачи.

После пополнения числа доступных разрешений методы пополнения выдают разрешения по всем заявкам из очереди в порядке их извлечения, на выполнение которых хватает полученных разрешений. Код выполнения заявок из очереди, который выдает вновь полученные разрешения и формирует ответы на заявки с выданными разрешениями, устроен во всех классах базовых алгоритмических ограничителей почти одинаково (то есть - опять имеем дубляж), и его можно рассмотреть один раз для всех классов. Этот код, как и любой код работы с очередью, выполняется при захваченной блокировке объекта ограничителя, чтобы избежать проблем с параллельным доступом.

Направление, с которого берутся заявки из очереди, указано, как написано выше, в параметрах настройки. Код выполнения заявок из очереди в цикле проверяет, хватает ли разрешений выполнить первую заявку с нужного направления. Если доступных разрешений для выполнения заявки хватает, то этот код извлекает из очереди элемент, связанный с заявкой, и устанавливает результат задачи, связанной с элементом, в значение ответа на заявку, извлеченную из очереди, с выданным разрешением. При извлечении учитывается, что заявки могли быть обработаны ранее, например, отменены через механизм скоординированной отмены (про отмену заявки см. далее). Элементы для заявок, которые уже были отменены или как-то ещё обработаны, то есть, связанная с которыми задача была завершена, просто извлекаются из очереди и добавляются в список на очистку, ответы на них не формируются, поскольку должны были быть сформированы ранее. Далее производится попытка установки результата задачи. При успешности этой попытки код выполнения заявок из очереди корректирует размер очереди и число доступных разрешений, и добавляет элемент связанный с завершенной заявкой в список на очистку. Неудачная попытка означает что заявка была обработана чуть раньше, параллельно, и элемент такой заявки тоже просто добавляется в список на очистку. Затем код выполнения заявок из очереди переходит к рассмотрению следующей заявки. Если очередь пуста или для выполнения следующей заявки в порядке извлечения из очереди числа доступных разрешений не хватает, цикл прекращается.

Подробности

В зависимости от режима работы очереди - значения поля _options.QueueProcessingOrder для работы с очередью используются разные методы: в режиме OldestFirst (FIFO) чтение производится методом PeekHead(), а извлечение элемента - методом DequeueHead(), в режиме NewestFirst (LIFO) - методами PeekTail() и DequeueTail() соответственно. Сначала код внутри цикла сразу проверяет, не обработан ли уже элемент - то есть, не завершена ли связанная с элементом задача. Если это так, то элемент извлекается и просто помещается в список на удаление (его содержит переменная disposer). В противном случае количество доступных разрешений (поле _permitCount или _tokenCount объекта ограничителя, в зависимости от конкретного класса) сравнивается с числом разрешений, которое было указано в заявке (это число было скопировано в свойство Count элемента очереди). Если доступных разрешений хватает, то код выполнения заявок извлекает элемент заявки из очереди, уменьшает счетчики размера очереди (поле _queueCount объекта ограничителя) и числа доступных ограничителю разрешений на указанное в элементе очереди число разрешений и пытается установить значение, возвращаемое задачей, связанной с извлеченным элементом, в ответ на заявку с выданным разрешением. В какой именно ответ на заявку устанавливается результат задачи - неспецифический из свойства SuccessfulLease или же специфичный - зависит от типа ограничителя: ограничители скорости возвращают неспецифический ответ, а ограничитель использования ConcurrencyLimiter - специально созданный, т.к. этот ответ должен содержать число выданных разрешений (подробнее об этом - в описании ConcurrencyLimiter).

Если результат задачи для элемента заявки установить не удалось, что означает, что заявка уже была ранее обработана, то код выполнения заявок из очереди восстанавливает значения числа доступных разрешений и, если необходимо, то есть при условии что флаг обработки размера очереди QueueCountModified уже установлен - размер очереди. Если же этот флаг не установлен, он устанавливается, чтобы предотвратить повторное изменение размера очереди из-за этой заявки.

Извлеченный из очереди элемент в любом случае добавляется в список элементов подлежащих очистке после снятия блокировки(его хранит переменная disposer, про этот список см. ниже, в описании процесса очистки).

Отмена заявок, стоящих в очереди.

Отмена заявок, стоящих в очереди, через механизм согласованной отмены реализована следующим образом. В асинхронный метод подачи заявок AcquireAsyncCore передается в качестве параметра маркер отмены (CancellationToken) через который можно отменить эту заявку при нахождении ее в очереди. Отмена производится в коде элемента очереди RequestRegistration в его методе Cancel. Эти методы для элементов очереди всех классов базовых алгоритмических ограничителей тоже устроены почти одинаково (единственная разница - имя класса ограничителя), а потому их можно рассмотреть совместно. Метод Cancel регистрируется в конструкторе элемента очереди в качестве обработчика отмены маркера, взятого из параметров AcquireAsyncCore и передаваемого в конструктор элемента, а потому он вызывается в процессе отмены этого маркера.

Метод Cancel прежде всего пытается установить в состояние отмены задачу, возвращаемую AcquireAsyncCore при вызове с указанным маркером отмены. Если ему это удается, то он извлекает из поля состояния задачи (Task.AsyncState) ссылку на ограничитель (она была помещена туда конструктором базового класса TaskCompletionSource<RateLimitLease>), приводит ее к типу ограничителя и устанавливает блокировку на этот ограничитель. Под этой блокировкой он вычитает из размера очереди ограничителя количество запрошенных в отмененной заявке разрешений (если оно не было вычтено раньше, см. “Подробности”). Сам элемент для отмененной задачи из очереди не удаляется (так как это невозможно, потому что он находится в произвольном месте очереди), он будет извлечен из очереди отправлен на очистку в другом месте, где обрабатывается очередь - при очистка места в очереди при постановке новой заявки или при извлечении заявок, которые можно удовлетворить при получении новых разрешений (см. соответствующие разделы выше).

Подробности

При реализации отмены было принято довольно странное, на мой взгляд, решение, усложнившее логику и кода отмены заявки, и другого кода, связанного с ее извлечением - кода очистки места в очереди при постановке новой заявки и кода извлечения из очереди заявок, удовлетворяемых при получении новых разрешений. Дело в том, что код, переводящий задачу в состояние отмены, вызывается до установки блокировки на ограничитель (на его свойство Lock). Поэтому при параллельном выполнении перечисленных разных участков кода возможна ситуация, когда задача успешно отменяется, но блокировку, требующуюся для корректировки размера очереди, код обработчика отмены захватывает только после выполнения того, другого, кода, обрабатывающего тот же элемент очереди. А тот код, обнаружив, что задача отменена, может уменьшить размер очереди до того, как код обработчика отмены захватит блокировку. И без принятия дополнительных мер код обработчика отмены может повторно уменьшить размер очереди. Чтобы избежать такого хода событий, в классы элементов очереди было добавлено логическое свойство-флаг QueueCountModified. И каждый из участков кода, который может уменьшить размер очереди проверяет (под блокировкой) этот флаг, и только если он не установлен, под той же блокировкой уменьшает размер очереди и устанавливает этот флаг. Таким образом размер очереди при любой операции над заявкой может уменьшится на число запрашиваемых в ней разрешений только один раз.

Код обработчика отмены тоже проверяет под блокировкой ограничителя этот флаг и уменьшает под той же блокировкой счетчик размера очереди с одновременной установкой этого флага, только если флаг не был установлен ранее.

Решение это работает, но оно выглядит совершенно не изящно и заметно усложняет код. Особенно - учитывая, что оно тоже сделано дубляжом: не вынесено в отдельный метод, а скопировано в каждый участок кода (возможно, с небольшими изменениями), где оно требуется. Лично я не понимаю, почему бы в обработчике отмены тоже не поместить под блокировку установку состояния задачи (в данном случае - в состояние отмены), как это сделано во всех аналогичных местах. Это бы избавило от необходимости иметь и проверять флаг QueueCountModified.

Обновление: после просмотра истории изменений обсуждаемого кода я нашел, почему отмена задачи не была помещена под блокировку. Причина историческая - когда-то (примерно два с половиной года назад) очистка регистрации обработчика отмены выполнялась в другом коде (конкретно - при извлечении заявок, которые можно удовлетворить при получении новых разрешений), под захваченной блокировкой, в результате чего могла возникнуть взаимная блокировка - тот код при очистке регистрации обработчика отмены ожидал, когда этот обработчик выполнится, а обработчик выполниться не мог, т.к. должен был захватывать блокировку. Впрочем, других возможных причин взаимной блокировки эта мера не устранила, и впоследствии вся очистка объектов - элементов очереди стала выполняться уже после снятия блокировки (см. далее). Так что в нынешнем состоянии взаимная блокировка возникнуть по этой причине уже не может и, наверное, теперь можно смело вносить отмену задачи под блокировку, убирать флаг QueueCountModified и делать весь код по уму и попроще.

Кстати, если вообще заглядывать в историю этого кода, то она собой являет череду правок обнаруженных ошибок (например, упомянутый выше флаг QueueCountModified, устраняющий повторный учет извлечения или отмены заявки при изменении размера очереди, появился уже значительно позже устранения упомянутой взаимной блокировки путем вынесения перевода задачи в состояние отмены из-под блокировки). Короче, код базовых алгоритмических ограничителей имеет весьма длинную и печальную историю правки багов путем добавления костылей. Она, конечно, не красит изготовителей этого кода, но пусть камень первым в них кинет тот, кто никогда не заколачивал баги костылями. И это буду не я.

Очистка элементов, извлеченных из очереди.

Чтобы избежать возможных проблем со взаимными блокировками, типа упомянутой в скрытом тексте выше, разработчики компонента базового ограничителя в конце концов вынесли код, выполняющий любую очистку (Dispose) связанных с извлекаемыми элементами очереди объектов, за пределы участков кода, выполняющихся под блокировкой. Для этого они сделали все такие объекты полями элемента очереди RequestRegistration, организовали добавление извлеченных объектов в специальный список список элементов, подлежащих очистке и сделали очистку этого списка после выхода из-под блокировки. Как и всё остальное в этой части компонента базового ограничителя, очистка элементов очереди после снятия блокировки была добавлена во все классы базовых алгоритмических ограничителей путем дублирования: в класс элемента очереди RequestRegistration каждого из классов базовых алгоритмических ограничителей был добавлена вложенная структура Disposer, реализующая интерфейс очистки (IDisposable). Переменная этого типа (везде - с именем disposer) создается перед захватом блокировки. Код, выполняемый под блокировкой, добавляет каждый извлеченный элемент в список вызовом метода disposer.Add. После снятия блокировки код, ее ранее установивший, вызывает метод Dispose (в реальном коде это делается автоматически, за счет использования оператора using). Метод Dispose структуры Disposer очищает каждый элемент очереди, добавленный в список на очистку.

Подробности

В настоящее время связанный с элементом очереди объект, который имеет смыл очистить при удалении элемента из очереди, сейчас есть только один - регистрация обработчика отмены маркера, которая хранится в поле _cancellationTokenRegistration. Единственное поле структуры Disposer - RequestRegistration _next - является ссылкой на первый элемент односвязного списка элементов очереди RequestRegistration. Если элемент очереди помещен в список элементов, подлежащих очистке, то его поле _next указывает на следующий элемент в этом списке (если такого элемента нет, то это поле равно null). Метод Add структуры Disposer получает элемент очереди как параметр и добавляет его в односвязный список: устанавливает поле _next переданного элемента в значение поля _next структуры, а значение поля _next структуры - в значение параметра. Метод Dispose структуры Disposer проходит по односвязному списку элементов очереди, подлежащих очистке и очищает в каждом элементе регистрацию обработчика отмены маркера, которая хранится в поле _cancellationTokenRegistration. По какой-то причине класс элемента очереди не реализует интерфейс очистки IDisposable, а потому метод Dispose структуры Disposer очищает эту регистрацию напрямую, а не весь объект элемента очереди.

Завершение оставшихся в очереди задач при очистке базового ограничителя.

Если при выполнении очистки объекта базового ограничителя в очереди ещё остались заявки, то метод объекта ограничителя, фактически выполняющий очистку - Dispose(Boolean) - захватывает блокировку, извлекает из очереди по одному все оставшиеся в них элементы, устанавливает результат задачи, связанной с извлеченным элементом в неспецифический (из свойства FailedLease) ответ с отказом выделения разрешения, и добавляет извлеченную заявку в список подлежащих очистке. После снятия блокировки производится очистка этого списка.

Отсчет времени и управление информацией о времени простоя.

Свойство IdleDuration во всех классах базовых алгоритмических ограничителей реализовано отдельно, но совершенно одинаково (дубляж опять с нами). В каждом классе базового алгоритмического ограничителя есть поле, которое содержит либо время перехода этого ограничителя к состоянию простоя, либо null, если ограничитель не простаивает. Свойство IdleDuration возвращает либо разницу между текущим временем и значением этого поля, приведенную к типу TimeSpan, если это поле - не null, либо null, если это поле содержит null. Ограничитель переходит в состояние простоя, как только число доступных разрешений достигает предельной величины, и этот момент фиксируется в упомянутом выше поле. Происходит это в конструкторе или в методах, которые увеличивают число доступных разрешений (ReplenishInternal в ограничителях скорости или Release в ограничителе использования). При выдаче разрешения (возврате ответа на заявку с выданным разрешением) состояние простоя сбрасывается: в упомянутое поле записывается null.

Подробности

Все моменты времени в базовых алгоритмических ограничителях определяются с помощью вызова статического метода GetTimestamp() класса System.Diagnostics.Stopwatch (далее - таймера). Значение, возвращаемое этим методом - это число периодов таймера, прошедших, начиная с некоторого времени.

Для хранения информации о времени простоя в каждом классе ограничителя определено поле long? _idleSince.

Для упрощения пересчета промежутков времени каждый класс базового ограничителя (да, дублирование - оно и тут тоже) имеет статическое поле только для чтения double TickFrequency, которое содержит число единиц времени (Ticks) класса TimeSpan в периоде таймера, значение этого поля устанавливается в конструкторе класса.

Это интересно. В новейшей версии - .NET 10 - реализация свойства IdleDuration изменилась: теперь оно реализуется через вызов статического метода GetElapsedTime вспомогательного статического класса RateLimiterHelper, который использует (в современном .NET) метод Stopwatch.GetElapsedTime. Статическое свойство TickFrequency тоже было перенесено из всех классов базовых алгоритмических ограничителей в этот вспомогательный класс. Ну, хоть от части дублирования кода базовых алгоритмических ограничителей разработчики избавились, правда - от очень небольшой части.

Ограничители скорости: общая функциональность и особенности

Классы базовых алгоритмических ограничителей скорости, реализованные в универсальном компоненте ограничения - это FixedWindowRateLimiter, SlidingWindowRateLimiter и TokenBucketRateLimiter. Далее все эти классы я буду называть просто ограничители скорости. Все ограничители скорости являются наследниками абстрактного класса ReplenishingRateLimiter, а потому должны реализовывать определенные в нем абстрактные свойства с доступом только для чтения ReplenishmentPeriod и IsAutoReplenishing и абстрактный метод TryReplenish(). Код, который это делает - он, формально, в каждом ограничителе свой, но, фактически, значительная его часть дублируется между разными классами с небольшими изменениями. Поэтому большая, общая для всех ограничителей скорости, часть этого кода будет рассмотрена совместно, сразу для всех ограничителей скорости в следующем разделе.

Общая функциональность ограничителей скорости.

Общие поля и методы.

Свойство только для чтения IsAutoReplenishing, указывающее на режим пополнения разрешений - ручной (false) или автоматический (true) - во всех классах ограничителей скорости возвращает значения свойства AutoReplenishment экземпляра класса параметров настройки ограничителя, сохраненного в поле _options: во всех классах настройки параметров для ограничителей скорости определено свойство типа Boolean с таким именем. Значение свойства ReplenishmentPeriod, содержащего период обновления, в разных классах ограничителей определяется по-разному. Поэтому оно будет рассмотрено при описании конкретных классов.

Метод ручного пополнения TryReplenish() во всех классах устроен совершенно одинаково. Он прежде всего проверяет работает ли ограничитель в режиме автоматического пополнения разрешений. Если да (IsAutoReplenishing==true), то он просто возвращает false. в противном случае - в ручном режиме пополнения - TryReplenish вызывает статический метод Replenish с аргументом - ссылкой на этот экземпляр ограничителя (this) и возвращает true.

Тот же самый метод Replenish с аргументом - экземпляром ограничителя в автоматическом режиме пополнения вызывается по таймеру как функция обратного вызова. Именно поэтому он и сделан статическим - чтобы объект делегата для него не приходилось создавать в куче лишний раз при создании таймера автоматического пополнения. Этот таймер (System.Threading.Timer) создается (при указании в параметрах настройки автоматического режима пополнения) в конструкторе каждого из классов ограничителей скорости, он срабатывает периодически, его период совпадает со значением свойства ReplenishmentPeriod.

Метод Replenish во всех классах ограничителей скорости устроен совершенно одинаково (дубляж снова с нами): он просто преобразует переданную ему ссылку на экземпляр ограничителя скорости к нужному классу, получает текущее время и вызывает метод этого экземпляра ограничителя ReplenishInternal с этим временем как аргументом.

Подробности

В классах базовых ограничителей скорости содержатся (и инициализируются в конструкторах классов и экземпляров) все те поля, которые являются частью реализации общей для всех базовых алгоритмических ограничителей функциональности. Во-первых это - копия параметров настройки _options (тип для каждого из классов - свой), используемых для самых разных функций. Во-вторых - набор статических полей и полей экземпляра, используемых при подаче заявок и возврате ответов на них, а именно - счетчик доступных для выдачи разрешений (при общем назначении это поле в разных классах имеет разное имя и тип, см. описание конкретных классов), а также - статические поля с заранее созданными неспецифическими ответами на заявки: при выдаче разрешения - SuccessfulLease и при отказе в выдаче: - FailedLease, оба эти статических поля имеют тип класса ответа на заявку, вложенного в класс ограничителя. В-третьих - счетчики для сбора статистики выдачи заявок, имеющие тип long: _successfulLeasesCount - для ответов с успешной выдачей и _failedLeasesCount - для отказов. В-четвертых - поля, связанные с управлением очередью заявок: _queue - собственно сама очередь (она имеет недоступный извне стандартной библиотеки тип) и int _queueCount - счетчик суммарного числа разрешений, запрошенных в заявках, находящихся в очереди. В-пятых - это поля, связанные с учетом времени простоя этого экземпляра ограничителя: момент перехода в состояние простоя long? _idleSince и коэффициент пересчета для преобразования времени простоя в тип TimeSpan - статическое поле double TickFrequency. И, наконец - признак очистки объекта bool _disposed. Все эти поля и свойства описаны ранее, в соответствующих разделах, посвященных общей функциональности базовых алгоритмических ограничителей, определенных в универсальном компоненте ограничения скорости.

Дополнительно к этим общим для всех базовых алгоритмических ограничителей полям, все ограничители скорости имеют поле long _lastReplenishmentTick. В нем фиксируется время последнего пополнения разрешений. Его значение получается вызовом статического метода Stopwatch.GetTimeStamp() и, соответственно, измеряется в периодах таймера. Кроме того, в классах SlidingWindowRateLimiter и TokenBucketRateLimiter есть дополнительные поля, которые используются для реализации их специфических алгоритмов. В классе FixedWindowRateLimiter таких дополнительных полей нет. Кроме того, в конструкторах ограничителей скорости, создаваемых в режиме автоматического пополнения, создается и сохраняется в поле _renewTimer объект таймера автоматического пополнения, имеющий тип System.Threading.Timer.

Устройство метода ReplenishInternal

Метод ReplenishInternal каждого из классов ограничителей скорости логически состоит из двух основных частей: пополнения разрешений и дополнительной обработки. Но перед тем, как приступить к выполнению первой части, метод ReplenishInternal сначала проверяет что объект ограничителя очищен, и, возможно - другие, зависящие от конкретного класса, условия, делающие его выполнение не нужным. И если одно из условий выполняется - просто сразу возвращает управление, не делая ничего. Затем метод ReplenishInternal устанавливает блокировку на ограничитель, и обе части работают под этой блокировкой.

Первая часть производит пополнение числа доступных разрешений. Она специфична для каждого класса и будет рассмотрена при описании конкретных классов. В конце этой части в поле _lastReplenishmentTick сохраняется время, когда было выполнено пополнение. Вторая часть метода ReplenishInternal выдает разрешения для тех заявок из очереди, на которые хватает пополненного только что числа доступных разрешений, а затем, если ограничитель переходит в состоянии простоя - устанавливает время начала простоя. Обе эти операции описаны ранее в разделах, описывающих работу с очередью заявок (конкретно - выдачу разрешений по заявкам из очереди) и управление информацией о времени простоя. При этом, вторая часть метода выполняется только при условии, что в первой части были фактически добавлены дополнительные разрешения.

Ответы на заявки

Каждый из классов ограничителей скорости возвращает ответ на заявки своего, особого типа, определённого как вложенный класс в классе ограничителя скорости и унаследованный от абстрактного класса RateLimitLease. Но разница между этими классами - только в их именах (FixedWindowLease, SlidingWindowLease и TokenBucketLease соответственно) и внутрь какого именно класса ограничителя они вложены: весь остальной их код скопирован один в один.

Классы ответов заявки, как наследники класса RateLimitLease, должны определять его абстрактные свойства IsAcquired и MetadataNames и абстрактный метод TryGetMetadata. Свойство MetadataNames этих классов возвращает статический список, содержащий имя одних метаданных - рекомендуемого времени ожидания перед повторной попыткой MetadataName.RetryAfter.Name (см. описание статического класса MetadataName в разделе “Объект ответа на заявку” предыдущей статьи). Значения свойства IsAcquired и метаданных для времени ожидания, возвращаемых методом TryGetMetadata, хранятся во внутренних полях, значения которых передаются через параметры конструктора (соответственно, первый - bool isAcquired и второй - TimeSpan? retryAfter). Значение метаданных для рекомендуемого времени ожидания может отсутствовать (быть null), в таком случае эти метаданные считаются отсутствующими, и TryGetMetadata их не возвращает.

Ответы на заявки с установленными значениями метаданных рекомендуемого времени ожидания возвращаются вместо неспецифического ответа с отказом FailedLease при синхронном возврате ответа с отказом в тех классах, в которых реализована оценка времени ожидания: FixedWindowRateLimiter и TokenBucketRateLimiter. Эта оценка и создание ответа с отказом с метаданными производится методом, имя и реализация которого зависит от класса.

Подробности

Синхронный возврат ответа с отказом в коде ограничителей FixedWindowRateLimiter и TokenBucketRateLimiter производится в трех местах:

  1. в синхронном методе подачи заявки AttemptAcquireCore при возврате отказа для пробной заявки;

  2. в этом же методе при невозможности выполнить реальную заявку в методе TryLeaseUnsynchronized: очередь в режиме FIFO не пуста или не хватает доступных для выдачи разрешений;

  3. в методе AcquireAsyncCore при невозможности поставить в очередь заявку (очередь - в режиме FIFO и нет места в очереди).

Если отказ выделения разрешений по заявке происходит из-за удаления заявки из очереди - при удалении старых заявок из очереди в режиме LIFO или при очистке объекта ограничителя - то связанная с удаленной заявкой задача возвращает неспецифический ответ с отказом. Для класса SlidingWindowRateLimiter оценка времени ожидания не реализована, поэтому при отказе он всегда возвращает неспецифический ответ с отказом, без метаданных.

Особенности классов ограничителей скорости, реализующих разные алгоритмы.

Особенности класса FixedWindowRateLimiter.

Алгоритм, реализованный этим классом, разбивает время на фиксированные промежутки - окна - и в каждом таком промежутке выдает разрешения в количестве не более указанного. Параметры настройки этого класса содержат (кроме описанных выше общих для всех ограничителей скорости параметров QueueLimit, QueueProcessingOrder и AutoReplenishment), продолжительность окна TimeSpan Window и предельное количество выдаваемых им разрешений int PermitLimit.

Дополнительно к состоянию очистки метод ReplenishInternal класса FixedWindowRateLimiter в качестве условия немедленного возврата проверяет, что он работает в в ручном режиме пополнения и пополнение вызвано преждевременно. Первая часть этого метода после установления блокировки устанавливает число доступных разрешений в поле _permitCount в максимальное количество - значение поля PermitLimit параметров настройки.

Проверка на преждевременность

Проверяется, что разница текущего момента времени (полученного от метода Stopwatch.GetTimeStamp()) и времени последнего пополнения(_lastReplenishmentTick) находится в пределах окна.

Значение периода пополнения, доступное через свойство ReplenishmentPeriod (и период таймера автоматического пополнения), для класса FixedWindowRateLimiter равно продолжительности окна, указанной в параметрах настройки.

Специфический ответ заявки с отказом создается методом CreateFailedWindowLease. Оценка времени ожидания в этом методе рассчитывается весьма грубо: как длительность числа окон, в которые уместится разница между запрошенным числом разрешений плюс текущий размер очереди и числом доступных разрешений (округление вниз, минимум 1). Более точная оценка, с использованием времени начала текущего окна _lastReplenishmentTick или режима работы очереди (FIFO/LIFO) не производится.

Особенности класса SlidingWindowRateLimiter

Алгоритм, реализованный этим классом, выдает, аналогично, предыдущему алгоритму, разрешения в заданном промежутке времени - окне - в количестве не более указанного. Но его окно отсчитывается так, что оно формально заканчивается на текущем моменте времени. В реальности этот алгоритм в целях упрощения сдвигает начало окна не непрерывно, а в определенные моменты времени. Полная продолжительность окна разбивается на указанное в параметрах настройки число сегментов равной продолжительности. Сдвиг начала окна происходит, когда промежуток между началом окна и текущим моментом превышает размер окна, при этом начало окна сдвигается сразу на один сегмент. И, таким образом, текущий момент всегда находится не обязательно в конце окна, но обязательно - в его последнем сегменте. При сдвиге окна его первый сегмент удаляется и число доступных разрешений увеличивается на количество разрешений, выданных в этом сегменте. Параметры настройки класса SlidingWindowRateLimiter содержат (кроме описанных выше общих для всех ограничителей скорости параметров QueueLimit, QueueProcessingOrder и AutoReplenishment) продолжительность окна TimeSpan Window, число сегментов, на которые разбивается окно int SegmentsPerWindow и максимальное количество выдаваемых ограничителем разрешений int PermitLimit.

Класс SlidingWindowRateLimiter фиксирует количество разрешений, выданных в каждом сегменте окна. Поэтому при синхронном возврате ответа на заявку с выданными разрешениями из методов подачи заявки, число разрешений в этой заявке добавляется к количеству разрешений, выданных в текущем (то есть последнем) сегменте.

Метод ReplenishInternal класса SlidingWindowRateLimiter дополнительно к состоянию очистки в качестве условия немедленного возврата проверяет, что он работает в ручном режиме пополнения и пополнение вызвано преждевременно (проверка - точно такая же, как и в FixedWindowLimiter). Первая часть этого метода после установления блокировки добавляет к числу доступных разрешений число разрешений, выданных в самом старом сегменте, который будет удален сдвигом окна. После этого производится сдвиг окна. Во второй части, если происходит возврат ответа на заявку с выданными разрешениями, то число разрешений, запрошенных в этой заявке, добавляется к количеству разрешений, выданных в текущем (то есть последнем) сегменте.

Подробности

Помимо перечисленных выше в скрытом тексте общих для всех ограничителей скорости полей, класс SlidingWindowRateLimiter имеет дополнительные поля, необходимые для работы реализуемого им алгоритма: массив int[] _requestsPerSegment, размер которого равен числу сегментов (_options.SegmentsPerWindow), в нем хранится количество выданных в каждом сегменте окна разрешений, int _currentSegmentIndex - индекс текущего сегмента (см. описание алгоритма сдвига окна) и TimeSpan _replenishmentPeriod - значение периода пополнения, которое устанавливается равным продолжительности сегмента: продолжительности окна, указанной в параметрах настройки, деленной на указанное там же число сегментов.

В качестве условия немедленного возврата помимо проверки на очистку объекта проверяется, что разница текущего момента времени (полученного от метода Stopwatch.GetTimeStamp()) и момента последнего пополнения(_lastReplenishmentTick) находится в пределах окна (а именно - его текущего, то есть, последнего, сегмента.

Сдвиг окна производится следующим образом. Массив _requestsPerSegment используется как кольцевой буфер: при сдвиге окна индекс текущего сегмента _currentSegmentIndex увеличивается на 1 по модулю числа сегментов (_options.SegmentsPerWindow). В последующем значение элемента массиве с этим индексом будет увеличиваться на число разрешений в заявке при любом возврате ответа на заявку с выделенными разрешениями: и синхронном - в методе TryLeaseUnsynchronized, и асинхронном - при возврате ответа на заявку, стоявшую в очереди - во второй части метода ReplenishInternal. При сдвиге окна после изменения индекса текущего сегмента, значение числа разрешений, выданных в сегменте, удаляемом из окна при сдвиге, находившееся ранее в массиве по этому индексу, добавляется к числу доступных разрешений. Затем значение элемента массива по этому индексу, которое теперь будет содержать число выданных разрешений в новом текущем сегменте, инициализируется в 0.

Свойство ReplenishmentPeriod для класса SlidingWindowRateLimiter возвращает продолжительности сегмента, хранящуюся в поле _replenishmentPeriod .

Поскольку оценка времени ожидания в классе SlidingWindowRateLimiter не реализована, то ответ на заявку с отказом всегда возвращается неспецифический, без дополнительных метаданных с такой оценкой.

Особенности класса TokenBucketRateLimiter

Класс TokenBucketRateLimiter реализует алгоритм ограничения с пополняемой емкостью для жетонов (иногда также именуемый “алгоритм дырявого ведра”). В этом алгоритме разрешения пополняются непрерывным потоком с заданной скоростью и накапливаются ограничителем, при этом для числа накопленных разрешений есть предел, сверх которого разрешения не накапливаются. Разрешения этим алгоритмическим ограничителем, как и другими ограничителями, выдаются, если число запрошенных в заявке разрешений меньше числа накопленных, при этом число выданных разрешений вычитается из накопленных. Параметры настройки класса TokenBucketRateLimiter содержат (кроме описанных выше общих для всех ограничителей скорости параметров QueueLimit, QueueProcessingOrder и AutoReplenishment) предел для накопленных разрешений int TokenLimit (это - аналог PermitLimit в параметрах других ограничителей) и параметры, задающие скорость пополнения: длину периода пополнения TimeSpan ReplenishmentPeriod и число добавляемых за период разрешений int TokensPerPeriod: скорость пополнения равна их отношению. Свойство ReplenishmentPeriod ограничителя с пополняемой емкостью возвращает период пополнения, указанный в одноименном свойстве параметров настройки. В режиме автоматического пополнения период пополнения также является и промежутком времени, через который таймер вызывает метод пополнения ReplenishInternal (через метод Replenish, см. выше общее описание). Начальное количество доступных для выдачи разрешений равно пределу для накопленных разрешений (TokenLimit).

Дополнительных условий (кроме стандартного - объект ограничителя очищен) для немедленного возврата из метода ReplenishInternal в классе TokenBucketRateLimiter нет. Работа первой части этого метода после установления блокировки в зависимости от режима пополнения происходит по-разному. В режиме автоматического пополнения метод ReplenishInternal просто прибавляет к числу доступных разрешений значение параметра настройки TokensPerPeriod, игнорируя тем самым возможную нестабильность таймера. В режиме ручного пополнения периодичность вызова метода пополнения TryReplenish не гарантируется даже приблизительно, поэтому в этом режиме ReplenishInternal честно вычисляет количество добавляемых разрешений, умножая разницу текущего момента времени и момента предыдущего пополнения на скорость пополнения разрешений. При этом результат умножения не обязательно будет целым числом. А потому число доступных разрешений в классе TokenBucketRateLimiter хранится не как целое, а как число с плавающей точкой. В обоих случаях, если результат добавления превысит предельное число разрешений ограничителя TokenLimit, значение числа доступных разрешений устанавливается в это предельное число.

Подробности

Помимо перечисленных выше в скрытом тексте общих для всех ограничителей скорости полей, класс TokenBucketRateLimiter имеет дополнительное поле readonly double _fillRate. В конструкторе экземпляра класса TokenBucketRateLimiter в поле _fillRate сохраняется скорость пополнения разрешений - количество выдаваемых разрешений в единицу времени (Ticks) класса TimeSpan. Для получения числа добавляемых разрешений разница текущего момента времени (значения, возвращаемого вызовом метода Stopwatch.GetTimeStamp() и момента последнего пополнения (_lastReplenishmentTick) приводится к значению в единицах времени(Ticks) класса TimeSpan (умножением на TickFrequency) и умножается на скорость пополнения _fillRate. Кроме того, вместо стандартного поля int _permitCount число доступных разрешений хранится в поле double _tokenCount: из-за особенностей работы алгоритма, реализуемого этим ограничителем (см. его описание), число доступных разрешений в какие-то моменты может быть не целым, иметь дробную часть, и эту дробную часть нужно хранить.

Специфический ответ на заявку с отказом создается методом CreateFailedTokenLease. Оценка времени ожидания в этом методе рассчитывается прямолинейно: как промежуток времени за который поступившие разрешения позволят удовлетворить все уже имеющиеся заявки в очереди плюс отклоняемую заявку (минимум - один период пополнения). Более точная оценка, с учетом дискретности пополнения (в режиме автоматического пополнения), не производится.

Ограничитель использования ConcurrencyLimiter.

Поля и методы.

Базовый алгоритмический ограничитель использования (иначе - ограничитель параллелизма, класс ConcurrencyLimiter) ограничивает максимальное количество одновременно выданных им разрешений. Это максимальное количество задается в параметрах настройки. Оно служит исходным значением для счетчика доступных для выдачи разрешений. В каждом возвращаемом методами подачи заявки ответе с успешной выдачей разрешений запоминается выданное по этой заявке число разрешений, и одновременно это число вычитается из счетчика доступных разрешений. При очистке объекта ответа на заявку это запомненное число разрешений добавляется к счетчику доступных разрешений.

Класс параметров настройки ограничителя использования ConcurrencyLimiterOptions содержит, кроме описанных выше общих для всех базовых алгоритмических ограничителей параметров настройки очереди QueueLimit и QueueProcessingOrder, также максимальное количество выдаваемых разрешений int PermitLimit.

Подробности

В классе базового ограничителя использования содержатся (и инициализируются в конструкторе класса и экземпляра) все те поля, которые являются частью реализации общих для всех базовых алгоритмических ограничителей функциональности. Во-первых это - копия параметров настройки ConcurrencyLimiterOptions _options, используемая для самых разных функций. Во-вторых - набор статических полей и полей экземпляра, используемых при подаче заявок и возврате ответов на них, а именно счетчик доступных для выдачи разрешений, здесь он имеет стандартное имя и тип int _permitCount, а также - заранее созданные неспецифические ответы на заявки: при успешной выдачи разрешений - SuccessfulLease и при отказе в выдаче - FailedLease, оба эти статических поля имеют тип класса ConcurrencyLease ответа на заявку, вложенного в класс ограничителя использования. В-третьих - счетчики для сбора статистики выдачи заявок, имеющие тип long: _successfulLeasesCount - для ответов с успешной выдачей и _failedLeasesCount - для отказов. В-четвертых - поля, связанные с управлением очередью заявок: _queue - собственно сама очередь (она имеет недоступный извне стандартной библиотеки тип) и int _queueCount - счетчик суммарного числа разрешений, запрошенных в заявках, находящихся в очереди. В-пятых - это поля, связанные с учетом времени простоя этого экземпляра ограничителя: момент перехода в состояние простоя long? _idleSince и коэффициент пересчета для преобразования времени простоя в тип TimeSpan - статическое поле double TickFrequency. И, наконец - признак очистки объекта bool _disposed. Все эти поля и свойства описаны в разделах, посвященных общей функциональности базовых алгоритмических ограничителей, определенных в универсальном компоненте ограничения скорости.

Кроме того, в классе ConcurrencyLimiter есть статическое поле для ответа на заявку с отказом при невозможности постановки в очередь ConcurrencyLease QueueLimitLease, которое содержит заранее сформированный ответ для этого случая: отказ с метаданными причины отказа в виде строки “Queue limit reached”.

Ответ на заявку

Ограничитель использования возвращает ответ в виде экземпляра класса ConcurrencyLease, вложенного в класс ConcurrencyLimiter, который является наследником RateLimitLease. В ответе на заявку с выданными разрешениями для ограничителя использования сохраняется число выданных по ней разрешений (для ответа с отказом это поле устанавливается в 0). При очистке ответа на заявку эти разрешения возвращаются ограничителю для их повторного использования вызовом метода Release ограничителя. Метод Release устанавливает блокировку, проверяет, что объект ограничителя не очищен, добавляет к числу доступных для выдачи разрешений значение переданного ему параметра, а затем выдает ответы с разрешениями для тех заявок из очереди, на которые хватает число доступных разрешений, и, если ограничитель переходит в состоянии простоя - устанавливает время начала простоя. Последние две операции описаны ранее в разделах описывающих работу с очередью заявок (конкретно - выдачу разрешений по заявкам из очереди) и управление информацией о времени простоя.

В классе ConcurrencyLease определены единственные метаданные - причина отказа (их тип - строка), описанные полем ReasonPhrase статического класса MetadataName (см. описание статического класса MetadataName в разделе “Объект ответа на заявку” предыдущей статьи).

Подробности

Класс ответа на заявку от ограничителя использования определяет унаследованные от RateLimitLease абстрактные свойства и методы следующим образом. Свойство IsAcquired реализовано как автоматическое свойство, значение которого задается через параметр конструктора isAcquired. Свойство MetadataNames класса ConcurrencyLease возвращает ссылку на статический массив из единственного элемента, содержащего имя метаданных MetadataName.ReasonPhrase.Name. Значение этих метаданных содержится в поле string? _reason, задаваемом через параметр конструктора reason (со значением null по умолчанию). Метод TryGetMetadata возвращает true и значение этого поля, если оно не null, во всех остальных случаях этот метод возвращает false.

Количество выданных разрешений в ответе передается в конструктор как параметр count и запоминается в поле _count. Ссылка на экземпляр ограничителя, выдавшего ответ, передается в конструктор через параметр limiter и запоминается в поле _limiter. При очистке ответа на заявку от ограничителя использования метод Dispose объекта ответа вызывает метод Release выдавшего ответ ограничителя с аргументом - числом запомненных в ответе разрешений, запрошенных в заявке, чтобы вернуть это число разрешений в распоряжение ограничителя использования, который выдал этот ответ.

Особенностью ограничителя использования является необходимость при возврате ответа с выделением разрешений запоминать в объекте этого ответа заявки число разрешений, запрошенных в заявке, для которой возвращен ответ, чтобы потом, при очистке ответа вернуть эти разрешения в число доступных. Поэтому объект ответа при успешном удовлетворении каждой реальной (с ненулевым числом запрошенных разрешений) заявки всегда должен создаваться заново (то есть, быть специфическим ответом)). Такой ответ возвращается в двух местах. Во-первых - при успешной синхронной выдаче разрешений в методе TryLeaseUnsynchronized (вызываемом из обоих методов подачи заявки - синхронного AttemptAcuireCore и асинхронного AcquireAsyncCore). Во-вторых - в методе Release, который, если после возврата разрешений стало достаточно разрешений для удовлетворения заявок в очереди, удовлетворяет их (этот процесс описан ранее в разделе “Действия при появлении доступных разрешений” в описании общей функциональности).

Кроме того, при невозможности постановки заявки в очередь в связи с отсутствием места, метод AcquireAsyncCore возвращает заранее сформированный специфический ответ с отказом из статического поля QueueLimitLease, с метаданными - строкой, описывающей эту причину отказа.

Новая возможность: сцепленный базовый ограничитель.

Начиная с версии .NET 10, в универсальный компонент ограничения был добавлен новый тип базового ограничителя - сцепленный базовый ограничитель. Конструкция и логика работы этого типа ограничителя близка к логике работы сцепленного селективного ограничителя, описанного в предыдущей статье.

Для получения разрешения сцепленному базовому ограничителю требуется получить разрешения от всех базовых ограничителей, входящих в его состав. При создании экземпляра этого ограничителя его конструктору передается (через список параметров переменной длинны) набор ограничителей, которые будет включать в себя этот сцепленный ограничитель.

Класс сцепленного базового ограничителя ChainedRateLimiter имеет модификатор доступа internal, а потому напрямую его создать нельзя. Создается сцепленный ограничитель статическим методом CreateChained класса RateLimiter. Этот метод имеет переменное число параметров - базовых ограничителей. Список этих параметров передается в виде массива напрямую в конструктор класса сцепленного базового ограничителя.

При подаче заявки на разрешения сцепленный базовый ограничитель пытается по очереди получить разрешение от всех входящий в его состав ограничителей, подавая заявку методом того же типа - синхронным или асинхронным на то же количество разрешений. Если хотя бы один из входящих в состав ограничителей не сможет выделить разрешения, то сцепленный ограничитель возвращает полученный от него ответ на заявку с отказом в разрешении. Если же все ограничители смогли выделить запрошенное количество разрешений, то сцепленный ограничитель возвращает комбинированный ответ на заявку с полученным разрешением.

Очистка сцепленного базового ограничителя просто выставляет флаг очистки, но не приводит к очистке входящих в его состав ограничителей. Это поведение полностью аналогично поведению сцепленного селективного ограничителя.

Дополнительно к методам, аналогичным реализованным сцепленным селективным ограничителем, сцепленный базовый ограничитель реализует свойство IdleDuration - время простоя ограничителя, используемое, напоминаю, для управления временем жизни секции секционированного ограничителя, для которой этот базовый ограничитель является ограничителем секции (см. предыдущую статью. В качестве значения времени простоя возвращается наименьшее непустое время простоя для всех ограничителей, которые входят в состав это ограничителя. На мой взгляд, такое решение - неправильное: исключение пустых значений времени простоя, говорящих о том, что соответствующий ограничитель активен, ведет к потере информации о его активности и к возможности преждевременного завершения активного ограничителя, и я бы в случае наличия пустого значения времени простоя хотя бы для одного комбинируемого ограничителя возвращал бы пустое значение.

Так как сцепленный базовый ограничитель не является ограничителем скорости, то, если он используется как ограничитель секции в секционированного ограничителе, периодически вызываемый по таймеру код секционированного ограничителя не будет вызывать для такой секции никакой код для ручного пополнения разрешений. Это означает, что секционированный ограничитель в принципе не сможет периодически вызывать методы TryReplenish ручного пополнения разрешений для ограничителей скорости, работающих в ручном режиме и входящих в состав сцепленного ограничителя секции - в отличие того, как это делается для ограничителей секций, являющихся ограничителями скорости. Поэтому для правильного использования ограничителей скорости в составе сцепленного ограничителя секции необходимо, чтобы они работали в режиме автоматического пополнения - тогда как для использования их непосредственно как ограничителей секции, наоборот, рекомендуется, чтобы они работали в режиме ручного пополнения(можно, конечно, придумать альтернативный способ вызывать метод ручного пополнения разрешений ограничителей скорости, входящих в состав сцепленного ограничителя секции, но, по-моему, это непрактично).

Подробности

Сигнатура метода создания сцепленного базового ограничителя

    public static RateLimiter CreateChained(params RateLimiter[] limiters)

Сигнатура класса сцепленного базового ограничителя - доступные снаружи методы/свойства и важные внутренние поля

internal sealed partial class ChainedRateLimiter : RateLimiter
{
    private readonly RateLimiter[] _limiters;
    
    public ChainedRateLimiter(RateLimiter[] limiters);
    public override TimeSpan? IdleDuration;
    
    public override RateLimiterStatistics? GetStatistics();
    protected override RateLimitLease AttemptAcquireCore(int permitCount);
    protected override async ValueTask<RateLimitLease> AcquireAsyncCore(int permitCount, CancellationToken cancellationToken);
    protected override void Dispose(bool disposing);

    
}

Сигнатура класса комбинированного ответа на заявку - доступные снаружи методы/свойства и важные внутренние поля

private sealed class CombinedRateLimitLease : RateLimitLease
{
    private RateLimitLease[]? _leases;
    private HashSet<string>? _metadataNames;

    public override bool IsAcquired => true;
    public override IEnumerable<string> MetadataNames  { get {/*...*/ } }

    public override bool TryGetMetadata(string metadataName, out object? metadata);
    protected override void Dispose(bool disposing);
}

В основе реализации сцепленного базового ограничителя ChainedPartitionedRateLimiter лежит массив _limiters компонентов сцепленного ограничителя, каждый из которых является базовым ограничителем. Поле _limiters содержит ссылку на вновь создаваемый массив. В этот массив копируются в конструкторе ChainedPartitionedRateLimiter из массива переданных в него параметров ссылки на базовые ограничители, входящие в состав данного сцепленного базового ограничителя. Поэтому потенциальные изменения массива параметров (даже если он передается именно как массив, а не список переменного числа параметров) после создания сцепленного ограничителя не влияют на список используемых им ограничителей-компонентов.

Реализации методов подачи заявок AttemptAcquireCore/AcquireAsyncCore последовательно для каждого элемента массива пытаются получить заказанное количество разрешений. При этом, даже в асинхронном варианте разрешения запрашиваются строго последовательно, без попыток получать разрешения асинхронно/параллельно. Если все ограничители, являющиеся компонентами, выделяют запрошенные разрешения, то сцепленный ограничитель создает составной объект ответа на заявку типа CombinedRateLimitLease и возвращает его. При первом же получении от ограничителя-компонента ответа на заявку с отказом сцепленный ограничитель очищает все ранее полученные ответы на заявки и возвращает этот ответ с отказом.

Класс составного ответа на заявку является внутренним (internal) и извне универсального компонента ограничения недоступен. Поэтому получателю разрешения от сцепленного ограничителя доступны только свойства и методы, определенные в базовом классе ответа на заявку RateLimitLease.

В конструкторе составного ответа на заявку во внутреннем поле-массиве _leases сохраняются все ответы на заявки с выданными разрешениями, полученные от компонентов сцепленного ограничителя. При очистке объекта составного ответа на заявку его методом Dispose все эти объекты ответов очищаются. Свойство IsAcquired составного ответа на заявку всегда возвращает true, т.к. этот объект объединяет ответы на заявки только с выданными разрешениями.

При первом обращении к свойству MetadataNames сцепленного ограничителя создается и запоминается во внутреннем поле хэш-набор имен всех метаданных из всех ответов на заявки, из которых образован этот объект составного ответа на заявку, именно этот набор (приведенный к перечислению строк IEnumerable<String>) возвращается в качестве значения этого свойства при первом и последующих к нему обращениях. Метод TryGetMetadata просматривает по порядку все ответы на заявки, составляющие этот составной ответ и извлекает первые найденные метаданные с указанным именем, если такие метаданные вообще существуют. В этом случае метод игнорирует другие возможные дубликаты из других ответов на заявки и сразу возвращает true. Если метаданные с указанным именем не удается найти ни в одном составляющем составной ответ объекте ответе на заявку, то возвращаемое во выходном параметре значение устанавливается в null и метод возвращает false.

Реализации метода получения статистики GetStatistics сцепленного ограничителя также последовательно получают статистики от каждого входящего в его состав ограничителя, и вычисляют статистику на основе этого набора статистик, в соответствии со смыслом каждого показателя: для поля числа доступных разрешений CurrentAvailablePermits возвращается минимальное значение этого поля из набора, для остальных полей возвращаются суммы значений этих полей в наборе…

Про код

Про код

Часть универсального компонента ограничения, связанная с базовыми алгоритмическими ограничителями, может служить примером, как не надо писать код в парадигме ООП. Потому что эта часть тщательно игнорирует те возможности, которые дает ООП для сокращения сложности и объема кода. Вместо того, чтобы вынести общий код в класс, находящийся ближе к корню иерархии, а мелкие различия между классами внутри этого кода оформить как вызовы виртуальных методов - то есть, использовать прием, давно (уже более 30 лет как) описанный в шаблоне “Шаблонный метод” - автор этих модулей скопировал, с небольшими модификациями, все выполняющие сходные функции куски кода в каждый из классов, реализующих свой алгоритм. В результате получился код, который неудобно поддерживать (можно посмотреть в истории версий, сколько раз автор компонента копировал общий код исправлений по нескольким классам, вместо того, чтобы поменять его в одном месте. И кое-где автор скопировал не все. Например, отсутствие в ответе с отказом от ограничителя SlidingWindowRateLimiter метаданных, указывающих, когда стоило бы повторить заявку (при том, что для других аналогичных классов ограничителей скорости такие метаданные возвращаются) рациональными причинами не объяснить: нужная информация во внутренних данных класса есть. Если бы возврат отказа на заявку выполнялся методом класса ограничителя скорости (даже при том, что исходные данные для подсчета этих метаданных - разные для разных алгоритмов), то про возврат этих метаданных забыть бы не получилось, ну а считать конкретное значение промежутка в этих метаданных можно было бы виртуальным методом, определенным в классе ограничителя скорости и реализованным в каждом из классов алгоритмических ограничителей.

Далее, класс, находящийся в корне иерархии - RateLimiter - сейчас вообще практически не имеет общего кода (проверка числа разрешений на неотрицательность - это ниачом, так же как и реализация стандартных шаблонов методов очистки), так что этот класс вполне в нынешней реализации можно было бы заменить интерфейсом. Но в будущей реализации так делать не стоит: лучше перенести в него весь тот общий код, которого, скопированного по отдельности в каждый класс-потомок, реально у классов-потомков много. Аналогично - с классами ответов на заявки (классами, унаследованными от RateLimitLease): нет никакого смысла возвращать в каждом классе базового ограничителя свой тип ответа, как это сделано сейчас. Фундаментально этих типа - всего два: один - хранит число выданных разрешений и при очистки возвращает их ограничителю (это - класс ConcurrencyLease, вложенный в класс ограничителя совместного использования ConcurrencyLimiter), другой - не хранит, и при очистке ничего не делает - это классы ответов, вложенные в классы ограничителей скорости, и возвращаемые этими ограничителями, они все похожи друг на друга, а также - общие классы SuccessfulLease и Failed Lease, возвращаемые в качестве положительных и отрицательных “пустых” (не влияющих на счетчик разрешений) ответов на заявки. Возможно, для некоторых из классов стоило бы сделать классы-наследники, имеющие заранее заданный положительный или отрицательный ответ и/или специфические метаданные (а может, и нет), но не более того. Аналогичная картина - и с классами параметров настройки базовых алгоритмических ограничителей: немало полей, содержащих эти параметры, является общими для всех или части классов базовых алгоритмических ограничителей, так что можно было бы сделать иерархию классов параметров и эти общие поля перенести в общие классы выше по иерархии. Наличие общих классов облегчило бы, к примеру, создание спецификации вспомогательного метода для создания сцепленного ограничителя или политики ограничения на базе сцепленного ограничителя. А сейчас таких методов нет, и это затрудняет пользователям универсального компонента ограничения использование этой новой функциональности.

Так что можно было бы использовать характерные для ООП приемы чтобы упростить код универсального компонента ограничения. Но разработчиком компонента это сделано не было. Ну и бог с ним, работает как-то - и ладно. Я для себя не вижу сейчас смысла вмешиваться и тратить на это силы и время: код фактически принадлежит фирме Microsoft, оплачивать его поддержку - это их затраты, меня они все равно здесь в России на работу не возьмут и труд мне не оплатят, даже если захотят - сложностей чисто юридических слишком много за такие деньги. Может быть, если вдруг буду писать статью на тему, как надо и как не надо использовать ООП - возьму и перепишу этот код чисто для своих целей, в качестве примера рефакторинга под ООП - но не более того.

Из использованных в коде приемов заслуживает упоминания и взятия на вооружение прием с избежанием двойной модификации размера очереди с помощью флага QueueCountModified при параллельном выполнении не полностью выполняемых под одной взаимоисключающей блокировкой участков кода, модифицирующих одну и ту же величину - состояние задачи (см. скрытый текст “Подробности” в разделе “Отмена заявок, стоящих в очереди”). Хотя в данном случае этот прием и не нужен - в текущем коде можно было бы перенести установку состояния задачи в обработчике отмены заявки под общую блокировку с модификацией размера очереди, так что параллельная модификация состояния задачи стала бы невозможной - но в других случаях он может пригодиться.

Приложение. Пример базового ограничителя.

Без примера реализации собственного базового ограничителя статья, как по мне, выглядела бы незавершенной: “я чувствую сквозняк, потому что это место свободно”(это цитата). Конечно, в качестве примера можно было бы придумать и реализовать в ограничителе какой-нибудь свой алгоритм, но это мне делать не захотелось, по двум причинам. Во-первых - потому что все хорошие, годные, используемые на практике алгоритмы уже сделаны в самом универсальном компоненте. А во-вторых - потому что пришлось бы делать ещё одну копию общего для всех базовых алгоритмических ограничителей кода, а дублировать код - это занятие скучное.

Но я нашел выход - я сделал для примера ограничитель, который меняет поведение другого, подчиненного ему, базового ограничителя лишь частично, в некоторых аспектах, а для выполнения всех остальных функций ограничителя вызывает методы подчиненного ограничителя и возвращает, если надо, их результаты. Для почитателей теории укажу: для написания примера я использовал “шаблон проектирования Декоратор”. Или, если по-простому: как пример базового ограничителя я сделал декоратор. А именно, базовый ограничитель с управлением временем жизни.

Пример применения базового ограничителя с управлением временем жизни - здесь.

Базовый ограничитель с управлением временем жизни. Концепция.

Конкретно в качестве примера для этой статьи я сделал класс базового ограничителя, экземпляр которого содержит ссылку на подчиненный ограничитель, передает в его методы и свойства все обращения к своим методам и свойствам и возвращает результаты вызова этих методов и обращения к его свойствам в вызывающую программу, но - за одним исключением. Исключение это - свойство IdleDuration, показывающее время простоя базового ограничителя. Пока экземпляр созданного мной класса не очищен (то есть,не вызван один из его методов очистки: Dispose иди DisposeAsync), это свойство возвращает null (что означает, что этот экземпляр не находится в состоянии простоя), а после его очистки - непустое значение, заведомо большее, чем любой предел для времени простоя (а именно, TimeSpan.MaxValue). Все остальные методы ограничителя из примера вызывают одноименные методы подчиненного ограничителя и возвращают их результат. Знакомые с теорией в таком классе разглядят использование давно известного шаблона “Декоратор”. Так что я в статье буду время от времени (часто) тоже буду называть такие ограничители декораторами.

Назначение такого класса - служить классом экземпляров ограничителей секций для секционированного ограничителя (о нем читайте в предыдущей статье) в условиях, когда ключами секции являются существующие между запросами объекты в программе (или их уникальные идентификаторы), время использования которых вполне определено и контролируется программой - а потому для определения, будет ли ещё использоваться секция для конкретного такого ключа, нет никакой нужды полагаться на встроенный в секционированный ограничитель механизм таймаутов для времени простоя ограничителя секции. В таком варианте использования ключей секций программа точно знает, может ли быть тот или иной ключ использован в будущем, и передает это знание через ограничитель секции с управлением временем жизни секционированному ограничителю, чтобы тот удалил секцию с этим ключом после того, как она станет ненужной: не раньше, чем она потеряет актуальность, но, при этом - как можно быстрее после того.

Как ограничитель секции с управлением временем жизни управляет временем жизни своей секции.

Как я уже описывал в предыдущей статье, секционированный ограничитель сам управляет временем существования своих секций и очисткой ограничителей этих секций. В его конструкторе создается периодически запускаемая по таймеру задача, которая, в частности, отслеживает время простоя ограничителя каждой секции. И если это время для какой-то секции превышает предел - удаляет эту секцию из списка секций (отправляя тем самым секцию в мусор) и выполняет очистку ограничителя секции. В общем случае по-другому сделать просто нельзя: секционированный ограничитель сам создает свои секции вместе с их ограничителями, и другие ссылки на эти ограничители хранить зачастую больше просто негде - вне ограничителя просто нет объектов, которые могли бы содержать такие ссылки. Например, именно так часто обстоит дело в веб-приложениях: обычно в них нет долгоживущих объектов, сохраняющихся между вызовами обработчиков разных запросов. Но это не всегда так (в частности, это не так в библиотеке ActiveSession): если каждая секция с ее ограничителем может быть сопоставлена с неким объектом, уникально идентифицируемым ключом секции (например, этот объект и является ключом секции), то в этом объекте можно сохранить ссылку на ограничитель и очищать ограничитель секции по факту прекращения использования этого объекта, обычно - в процессе его очистки. Именно для такого сценария использования и предназначен класс ограничителя с управлением временем жизни из этого примера. Работает он так: пока он не очищен, он сообщает упомянутой выше периодической задаче, что он не простаивает, а потому секция из списка не удаляется. А после очистки его экземпляра он возвращает при опросе со стороны периодической задачи в секционированном ограничителе время простоя, превышающее любой возможный предел, а потому его секция удаляется из списка при следующем запуске этой периодической задачи. Эта периодическая задача также пытается и очистить этот, уже очищенный, экземпляр ограничителя, но в коде класса ограничителя с управлением временем жизни такая ситуация предусмотрена: очистка выполняется безопасным при выполнении из нескольких потоков образом, а повторная очистка не делает ничего - даже если первый вызов метода очистки начал выполняться в другом потоке и ещё не завершился.

Пример применения базового ограничителя с управлением временем жизни.

Пример применения базового ограничителя с управлением временем жизни для создания секционированного ограничителя может выглядеть так:

	Action<ManagedLifetimeLimiter, ILocalSession> RegistrarDelegate = null!; //Чтобы код примера можно было скомпилировать
	//Для использования в реальном коде - заменить null на реальный метод регистратора
	ConcurrencyLimiterOptions options = new();
	//установить необходимые параметры для создаваемого подчиненного ограничителя использования
	PartitionedRateLimiter<HttpContext> selective_limiter = PartitionedRateLimiter.Create
		((HttpContext context) =>
		ManagedLifetimePartition.GetManagedLifetimeLimiter(
			context.GetActiveSessionGroup(),
			key => RateLimitPartition.GetConcurrencyLimiter(key, _ => options),
			RegistrarDelegate
		));

Пример - это сильно упрощенный код на тему примера из части 2 приложения к предыдущей статье. В нем в качестве ключа секции для секционированного ограничителя использован долгоживущий (т.е сохраняющийся между запросами к веб-приложению) объект из состава библиотеки ActiveSession (подробности - по ссылке выше), идентифицируемый своим интерфейсом ILocalSession.

Ограничитель с управляемым временем жизни создается в качестве ограничителя секции в данных для секции, возвращаемых вспомогательным методом GetManagedLifetimeLimiter (он рассмотрен дальше), которому передаются аргументы:

  1. ключ секции;

  2. делегат, создающий данные для секции с ограничителем секции, который будет служить подчиненным ограничителем для ограничителя с управлением времени жизни;

  3. делегат для метода-регистратора RegistrarDelegate; этот метод предназначен для сохранения для последующего использования связи между передаваемыми в него в качестве параметров ключом секции и ограничителем с управлением времени жизни, используемым как ограничитель этой секции.

Эти данные для секции возвращаются секционирующим делегатом, из которого создается секционированный ограничитель selective_limiter.


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

Базовый ограничитель с управлением временем жизни. Реализация.

При создании класса базового ограничителя я решил, что расширение функциональности подчиненного ограничителя путем его агрегации может пригодиться не только для ограничителя с управлением временем жизни, но и для создания других классов, модифицирующих поведение исходного. Поэтому я сделал отдельный класс ограничителя-декоратора для реализация шаблона “Декоратор” с агрегированным подчиненным классом, а класс ограничителя с управлением временем жизни унаследовал уже от этого ограничителя-декоратора.

В конструктор экземпляра класса ограничителя-декоратора передается ссылка на подчиненный ограничитель, которая запоминается в созданном экземпляре, и обращения ко всем свойствам и вызовы всех методов ограничителя-декоратора передаются в этот подчиненный ограничитель. Соответственно и вызовы всех остальных методов и обращения к свойствам, кроме IdleDuration, ограничителя с управлением временем жизни, как наследника ограничителя-декоратора, передаются в его подчиненный ограничитель.

Из-за особенностей реализации секционированного ограничителя, которые я описал (и на которые много ругался) в предыдущей статье (раздел “Про код”, одноименный скрытый текст, с самого начала) я счел полезным унаследовать класс ограничителя-декоратора не от первичного класса иерархии базовых ограничителей RateLimiter, а от его потомка - первичного класса ограничителей скорости ReplenishingRateLimiter - чтобы обеспечить работу метода пополнения подчиненного ограничителя, являющегося ограничителем скорости, в ручном режиме пополнения.

Почему

Выполняемый по таймеру код вызова метода пополнения числа доступных разрешений с течением времени для ограничителей скорости явно проверяет, что ссылка на ограничитель секции в записи секции в реальности указывает на класс, производный от первичного класса ограничителя скорости ReplenishingRateLimiter, и если да - приводит ссылку к этому типу, и вызывает для пополнения числа разрешений его метод TryReplenish. Если бы ограничитель-декоратор не представлялся ограничителем скорости, то он бы не смог обеспечить вызов метода пополнения разрешений подчиненного ограничителя скорости в ручном режиме пополнения - а этот режим как раз рекомендуется использовать для ограничителей секций, чтобы не плодить лишние объекты таймеров.

Если подчиненный ограничитель является ограничителем скорости, то обращения к специфичным для ограничителя скорости свойствам и методам передаются в подчиненный ограничитель точно так же, как и обращения к остальным методам и свойствам.Если же подчиненный ограничитель не является ограничителем скорости, то класс ограничителя-декоратора самостоятельно реализует фиктивные свойства ограничителя скорости, которые сообщают вызывающему классу, что этот ограничитель не требует вызова его метода пополнения (его свойство IsAutoReplenishing возвращает true), имеет практически бесконечный период пополнения (свойство ReplenishmentPeriod возвращает TimeSpan.MaxValue), а также - фиктивный метод ручного пополнения (TryReplenish), который не делает ничего.

Подробности

Классы ManagedLifetimeLimiter и его базовый класс ограничителя декоратора PartitionRateLimiterDecorator являются частью библиотеки MVVRus.Extensions.RateLimiting, упомянутой в предыдущей статье. Исходный код класса ограничителя-декоратора PartitionRateLimiterDecorator:

public class PartitionRateLimiterDecorator: ReplenishingRateLimiter
{
	RateLimiter? _inner;
	ReplenishingRateLimiter? _replenishingInner;

	public PartitionRateLimiterDecorator(RateLimiter Inner)
	{
		if(Inner is null) throw new ArgumentNullException(nameof(Inner));
		_inner= Inner;
		_replenishingInner = Inner as ReplenishingRateLimiter;
	}

	public override TimeSpan? IdleDuration => Inner.IdleDuration;

	public RateLimiter Inner => Volatile.Read(ref _inner)??throw new ObjectDisposedException(nameof(Inner));

	public Boolean IsDisposed => Volatile.Read(ref _inner)==null;

	public override RateLimiterStatistics? GetStatistics()
	{
		return Inner.GetStatistics();
	}

	protected override ValueTask<RateLimitLease> AcquireAsyncCore(Int32 permitCount, CancellationToken cancellationToken)
	{
		return Inner.AcquireAsync(permitCount, cancellationToken);
	}

	protected override RateLimitLease AttemptAcquireCore(Int32 permitCount)
	{
		return Inner.AttemptAcquire(permitCount);
	}

	protected override void Dispose(bool disposing)
	{
		if(disposing) {
			RateLimiter? inner = Interlocked.Exchange(ref _inner, null);
			if(inner!=null) inner.Dispose();
		}
	}

	protected override async ValueTask DisposeAsyncCore()
	{
		RateLimiter? inner = Interlocked.Exchange(ref _inner, null);
		if(inner!=null) await inner.DisposeAsync();
		await base.DisposeAsyncCore();
	}

	public override Boolean IsAutoReplenishing
	{
		get
		{
			if(Volatile.Read(ref _inner) == null) throw new ObjectDisposedException(nameof(Inner));
			return _replenishingInner?.IsAutoReplenishing??true;
		}
	}

	public override Boolean TryReplenish()
	{
		if(Volatile.Read(ref _inner) == null) throw new ObjectDisposedException(nameof(Inner));
		return _replenishingInner?.TryReplenish()??false;
	}

	public override TimeSpan ReplenishmentPeriod
	{
		get
		{
			if(Volatile.Read(ref _inner) == null) throw new ObjectDisposedException(nameof(Inner));
			return _replenishingInner?.ReplenishmentPeriod??TimeSpan.MaxValue;
		}
	}
}

Пояснения:

  • Ссылка на подчиненный ограничитель хранится в поле _inner экземпляра, которое доступно по чтению через свойство Inner. Т.к. эта ссылка может быть в любой момент установлена в null, ее чтение осуществляется атомарной операцией.

  • Для выполнения очистки (реализации методов Dispose/DisposeAsync) используются стандартные шаблоны синхронной и асинхронной очистки, включающие в себя вызовы виртуальных методов. Эти методы обнуляют атомарной операцией ссылку на подчиненный ограничитель, и если эта ссылка была не пуста - очищают его, соответственно, синхронно или асинхронно.

  • Пустое null значение ссылки на подчиненный ограничитель рассматривается как признак того, что очистка экземпляра выполнена (или, по крайней мере, начата). Попытка обращения ко всем свойствам и методам, кроме Dispose/DisposeAsync, в этом случае вызывает исключение ObjectDisposedException.

  • Ссылка на подчиненный ограничитель, приведенный к типу ReplenishingRateLimiter, хранится в поле _replenishingInner. Равенство этого поля null означает, что подчиненный ограничитель не является ограничителем скорости. В этом случае ограничитель-декоратор использует собственную фиктивную реализацию свойств и метода, специфичных для ограничителей скорости, которая не делает ничего и предлагает не делать ничего вызывающему коду.

Исходный код класса ограничителя с управлением временем жизни ManagedLifetimeLimiter:

public class ManagedLifetimeLimiter : PartitionRateLimiterDecorator
{
	public ManagedLifetimeLimiter(RateLimiter Inner):base(Inner) { }

	public override TimeSpan? IdleDuration => IsDisposed ? TimeSpan.MaxValue : null;

}

Некоторые пояснения. Как видите, этот класс очень прост: он всего лишь подменяет реализацию свойства IdleDuration на такую, поведение которой описано в основном тексте раздела. Всё перенаправление обращений к методам и свойствам выполняет класс ограничителя-декоратора, от которого класс ограниителя с управлением временем жизни унаследован.

Группа вспомогательных статических методов GetManagedLifetimeLimiter

Для упрощения создания секционированных ограничителей на основе класса ограничителя c управлением временем жизни (ManagedLifetimeLimiter) я добавил в пример группу статических методов GetManagedLifetimeLimiter класса ManagedLifetimePartition, с одним именем, но разными наборами параметров.

Все методы их группы GetManagedLifetimeLimiter - обобщенные, они имеют, как минимум, один параметр-тип, TKey - это тип ключа секционирования. Первый параметр каждого их методов, key - значение ключа секционирования, он как раз имеет тип TKey. Второй параметр, factory - делегат-фабрика для получения подчиненного ограничителя для секции c ключом секционирования из первого параметра. Есть два возможных варианта типа делегата-фабрики: в обоих вариантах делегат принимает один параметр - ключ секционирования типа TKey, но возвращаемые делегатом-фабрикой значения отличаются. В первом варианте делегат-фабрика возвращает сам по себе базовый ограничитель (типа RateLimiter). Во втором варианте делегат-фабрика возвращает данные для создания секции с типом ключа секционирования TKey (типа RateLimiterPartition<TKey> - комбинацию из значения ключа и делегата, создающего искомый ограничитель). Второй вариант сделан для того, чтобы можно было удобнее использовать вспомогательные статические методы из универсального компонента ограничения. Третий параметр, registrar - это делегат регистратора, его задача - сохранить ссылку на созданный ограничитель с управлением временем жизни в том месте, где эта ссылка должна храниться. В делегат регистратора всегда передаются параметры: созданный ограничитель (типа ManagedLifetimeLimiter) и значение ключа секционирования (типа TKey). Четвертый, необязательный, параметр, context, который есть у двух методов из четырех - это ссылка на объект контекста для регистрации созданного ограничителя. Методы, которые принимают ссылку на объект контекста, имеют второй параметр-тип, TContext - тип объекта контекста context. Соответственно, делегат регистратора для вариантов методов GetManagedLifetimeLimiter, принимающих ссылку на контекст, принимает третий параметр - контекст типа TContext. Значение делегатом регистратора не возвращается.

Все варианты метода GetManagedLifetimeLimiter возвращают запись данных для создания секции с типом ключа секционирования TKey и делегатом создания ограничителя секции с управлением временем жизни. Всего в группе получаются четыре метода с именем GetManagedLifetimeLimiter: две пары методов, с различными типами делегата-фабрики, и в каждой паре в один метод контекст не передается, а в другой - передается.

Исходный код класса ManagedLifetimePartition

доступен на github

public static  class ManagedLifetimePartition
{
	public static RateLimitPartition<TKey> GetManagedLifetimeLimiter<TKey>(
		TKey key,
		Func<TKey, RateLimiter> factory,
		Action<ManagedLifetimeLimiter, TKey> registrar)
	{
		return new RateLimitPartition<TKey>(key, Key => {
			ManagedLifetimeLimiter limiter = new ManagedLifetimeLimiter(factory(Key));
			registrar.Invoke(limiter, Key);
			return limiter;
		});
	}

	public static RateLimitPartition<TKey> GetManagedLifetimeLimiter<TKey>(
		TKey key,
		Func<TKey, RateLimitPartition<TKey>> factory,
		Action<ManagedLifetimeLimiter, TKey> registrar)
	{
		return new RateLimitPartition<TKey>(key, Key => {
			ManagedLifetimeLimiter limiter = new ManagedLifetimeLimiter(factory(Key).Factory(Key));
			registrar.Invoke(limiter, Key);
			return limiter;
		});
	}

	public static RateLimitPartition<TKey> GetManagedLifetimeLimiter<TKey, TContext>(
		TKey key,
		Func<TKey, RateLimiter> factory,
		Action<ManagedLifetimeLimiter, TKey, TContext> registrar,
		TContext context)
	{
		return new RateLimitPartition<TKey>(key, Key => {
			ManagedLifetimeLimiter limiter = new ManagedLifetimeLimiter(factory(Key));
			registrar.Invoke(limiter, Key, context);
			return limiter;
		});
	}

	public static RateLimitPartition<TKey> GetManagedLifetimeLimiter<TKey, TContext>(
		TKey key,
		Func<TKey, RateLimitPartition<TKey>> factory,
		Action<ManagedLifetimeLimiter, TKey, TContext> registrar,
		TContext context)
	{
		return new RateLimitPartition<TKey>(key, Key => {
			ManagedLifetimeLimiter limiter = new ManagedLifetimeLimiter(factory(Key).Factory(Key));
			registrar.Invoke(limiter, Key, context);
			return limiter;
		});
	}
}

Использование метода GetManagedLifetimeLimiter можно, помимо примера выше в этой статье, проиллюстрировать примером ограничителя для количества активных сеансов библиотеки ActiveSession с селективностью по группам сеансов, описанного во второй части приложения к предыдущей статье цикла

В этом примере для ограничения числа активных сеансов используется секционированный ограничитель, создаваемый на основе секционирующего делегата, создаваемого методом PartitionerMaker - этот ограничитель используется в качестве референсного, то есть выдающего разрешения для каждого из внешних объектов из библиотеки ActiveSession - активных сеансов, связанных с запросами. Ключом секционирования при этом является другой связанный с запросом внешний объект из библиотеки ActiveSession - группа активных сеансов. То есть, для каждой группы активных сеансов создается свой ограничитель с управлением временем жизни. В качестве подчиненного ограничителя применяется ограничитель использования (экземпляр класса ConcurencyLimiter), параметры для его создания передаются в метод PartitionerMaker. Секционирующий делегат использует вспомогательный метод GetManagedLifetimeLimiter, как именно - см. описание этого примера в предыдущей статье. Ссылка на ограничитель с управлением временем жизни запоминается в объекте группы активных сеансов (это делает метод Registrar, делегат для которого передается в метод GetManagedLifetimeLimiter в качестве делегата-регистратора) таким образом, что прекращение использования и очистка объекта этой группы приводит к очистке ограничителя с управлением временем жизни (и его подчиненного ограничителя) и, при следующем вызове по таймеру задачи очистки секционированного ограничителя - к удалению секции с этим ограничителем из списка активных секций, так как ограничитель с управлением временем жизни после его очистки возвращает в качестве времени простоя величину, заведомо большую таймаута простоя.

Подробности

Код обсуждаемых методов класса ActiveSessionsPerGroupLimiter приведен ниже(для справки: полный код класса)

static Func<HttpContext, RateLimitPartition<ILocalSession>> PartitionerMaker(ConcurrencyLimiterOptions options)
{
    return Partitioner;

    RateLimitPartition<ILocalSession> Partitioner(HttpContext context)
    {
        ILocalSession? group = context.GetActiveSessionGroup();
        group = group!=null && group.IsAvailable ? group : NullGroup;
        return
            group.IsAvailable ?
                    ManagedLifetimePartition.GetManagedLifetimeLimiter(
                        group,
                        gclKey => RateLimitPartition.GetConcurrencyLimiter(gclKey, gclKey => options),
                        RegistrarDelegate
                    )
            : RateLimitPartition.GetNoLimiter(group);
    }
}

static void Registrar(ManagedLifetimeLimiter limiter, ILocalSession sessionGroup)
{
    Monitor.Enter(sessionGroup);
    try {
        sessionGroup.Properties.Add(SessionGroupASLimiterInfo.KEY, new SessionGroupASLimiterInfo(limiter));
        sessionGroup.TakeOwnership(limiter);
    }
    catch(ArgumentException) {
        limiter.Dispose();
        throw new InvalidOperationException($"An active session limiter is already associated with the group with Id={sessionGroup.Id}");
    }
    catch {
        limiter.Dispose();
        throw;
    }
    finally { 
        Monitor.Exit(sessionGroup);
    }
}

Это - всё, что я хотел рассказать про ограничитель с управлением временем жизни как пример создания своего базового ограничителя.