Планирование запросов
Активации Grain имеют однопоточную модель выполнения и по умолчанию обрабатывают каждый запрос от начала до завершения, прежде чем следующий запрос может начать обработку. В некоторых случаях может потребоваться активация для обработки других запросов, пока один запрос ожидает завершения асинхронной операции. По этим и другим причинам Orleans разработчик может контролировать поведение чередования между запросами, как описано в разделе Реентерабельность. Ниже приведен пример планирования запросов, отличных от повторного выполнения запроса, который является поведением по умолчанию в Orleans.
Рассмотрим следующее PingGrain
определение:
public interface IPingGrain : IGrainWithStringKey
{
Task Ping();
Task CallOther(IPingGrain other);
}
public class PingGrain : Grain, IPingGrain
{
private readonly ILogger<PingGrain> _logger;
public PingGrain(ILogger<PingGrain> logger) => _logger = logger;
public Task Ping() => Task.CompletedTask;
public async Task CallOther(IPingGrain other)
{
_logger.LogInformation("1");
await other.Ping();
_logger.LogInformation("2");
}
}
В нашем примере участвуют два зерна типа PingGrain
A и B. Вызывающий вызывает следующий вызов:
var a = grainFactory.GetGrain("A");
var b = grainFactory.GetGrain("B");
await a.CallOther(b);
Поток выполнения выглядит следующим образом:
- Звонок поступает в A, который регистрирует
"1"
, а затем инициирует вызов в B. -
B возвращается немедленно из
Ping()
обратно в A. -
Выполняет логирование
"2"
и возвращается к исходному вызывающему объекту.
Пока A ожидает вызова B, он не может обрабатывать входящие запросы. В результате, если A и B будут вызывать друг друга одновременно, они могут взаимоблокироваться, ожидая завершения этих вызовов. Ниже приведен пример, основанный на клиенте, выдавающем следующий вызов:
var a = grainFactory.GetGrain("A");
var b = grainFactory.GetGrain("B");
// A calls B at the same time as B calls A.
// This might deadlock, depending on the non-deterministic timing of events.
await Task.WhenAll(a.CallOther(b), b.CallOther(a));
Случай 1: вызовы не взаимоблокируются
В этом примере:
- Звонок от A поступает в B раньше, чем звонок
CallOther(a)
поступает в B. - Таким образом, B обрабатывает
Ping()
вызов перед вызовомCallOther(a)
. - Так как B обрабатывает
Ping()
вызов, A может вернуться вызывающей стороне. - Когда B осуществляет
Ping()
вызов A, A по-прежнему занят регистрацией своего сообщения ("2"
), поэтому вызов должен подождать некоторое время, но вскоре его могут обработать. -
A обрабатывает
Ping()
вызов и возвращается к B, который возвращается к исходному вызывающему.
Рассмотрим менее благоприятный ряд событий, в котором тот же код приводит к взаимоблокировке из-за немного другого времени выполнения.
Случай 2: взаимоблокировка вызовов
В этом примере:
- Вызовы
CallOther
поступают на соответствующие узлы и обрабатываются одновременно. - Журнал регистрации зерна
"1"
и переход кawait other.Ping()
. - Так как оба зерна по-прежнему заняты (обработка
CallOther
запроса, который еще не завершен),Ping()
запросы ожидают - Через некоторое время Orleans определяет, что вызов превысил время ожидания, и каждый
Ping()
вызов приводит к возникновению исключения. - Тело
CallOther
метода не обрабатывает исключение, и оно доходит до исходного вызывающего объекта.
В следующем разделе описывается, как предотвратить взаимоблокировку, позволяя нескольким запросам пересекать их выполнение друг с другом.
Повторный вход
Orleans по умолчанию выбирается безопасный поток выполнения, при котором внутреннее состояние зерна не изменяется одновременно с обработкой нескольких запросов. Параллельное изменение внутреннего состояния усложняет логику и ставит на разработчика большую нагрузку. Эта защита от таких ошибок параллелизма имеет свою цену, которая ранее обсуждалась, в первую очередь жизнеспособность: некоторые шаблоны вызовов могут привести к взаимным блокировкам. Одним из способов избежать взаимоблокировок является обеспечение того, чтобы вызовы зерна никогда не приводили к циклу. Часто трудно писать код, который не содержит циклов и не вызывает взаимоблокировку. Ожидание выполнения каждого запроса от начала до завершения перед обработкой следующего запроса также может повредить производительность. Например, по умолчанию, если метод объекта зерна выполняет асинхронный запрос к службе базы данных, то зерно приостанавливает выполнение запроса, пока зерно не получит ответ из базы данных.
Каждый из этих случаев рассматривается в следующих разделах. По этим причинам Orleans предоставляет разработчикам возможность разрешать выполнение некоторых или всех запросов одновременно, чередуя их выполнение. В Orleansтаких проблемах называются повторное выполнение или переключение. Одновременно выполняя запросы, зерна, выполняющие асинхронные операции, могут обрабатывать больше запросов за короткий период.
Несколько запросов могут перемежаться в следующих случаях:
- Класс зерна помечается с помощью ReentrantAttribute.
- Метод интерфейса помечается с помощью AlwaysInterleaveAttribute.
- Предикат зерна MayInterleaveAttribute возвращает
true
.
При повторном входе в следующий сценарий становится корректным, и возможность вышеуказанной взаимоблокировки устраняется.
Случай 3: зерно и метод являются реентрантными
В этом примере зерна A и B могут вызывать друг друга одновременно без возможности взаимоблокировок при планировании запросов, так как оба зерна являются реентрантными. В следующих разделах приведены дополнительные сведения о повторном входе.
Перезакрепившиеся зерна
Grain Классы реализации могут быть помечены с ReentrantAttribute указанием того, что различные запросы могут быть свободно чередуются.
Другими словами, активация повторного входа может начать выполнение другого запроса, пока предыдущий запрос не завершил обработку. Выполнение по-прежнему ограничено одним потоком, поэтому активация по-прежнему выполняет один поворот за раз, и каждый поворот выполняется от имени только одного из запросов активации.
Реентерабельный код зерна никогда не выполняет несколько фрагментов кода зерна параллельно (выполнение кода зерна всегда однопотоковое), но реентерабельные зерна могут наблюдать выполнение кода для различных запросов вперемешку. Т. е. продолжения от разных запросов могут пересекаться.
Например, как показано в следующем псевдокоде, давайте рассмотрим, что Foo
и Bar
— два метода одного и того же класса grain:
Task Foo()
{
await task1; // line 1
return Do2(); // line 2
}
Task Bar()
{
await task2; // line 3
return Do2(); // line 4
}
Если это зерно отмечено ReentrantAttribute, выполнение Foo
и Bar
может пересекаться.
Например, возможен следующий порядок выполнения:
Строка 1, строка 3, строка 2 и строка 4. То есть повороты от разных запросов пересекаются.
Если зерно не было повторно входящим, единственными возможными выполнениями будут: строка 1, строка 2, строка 3, строка 4 ИЛИ: строка 3, строка 4, строка 1, строка 2 (новый запрос не может начинаться до завершения предыдущего).
Основной компромисс при выборе между рентрантными и нерентрантными зернами заключается в сложности кода для правильной работы с чередованием и в трудностях с осмыслением этого.
В тривиальном случае, когда зерна без состояния и логика проста, меньшее количество (но не слишком мало, чтобы все аппаратные потоки использовались) повторно входящих зерен, как правило, должно быть немного более эффективным.
Если код более сложный, то большее количество несымметричных блоков, даже если они немного менее эффективны в целом, должно сэкономить много хлопот при решении неочевидных проблем с чередованием.
В конце концов ответ зависит от особенностей приложения.
Методы переключения
Методы интерфейса зерна, помеченные как AlwaysInterleaveAttribute, всегда перемежаются с любым другим запросом и всегда могут быть перемежены с любым другим запросом, даже с запросами для методов, не относящихся к [AlwaysInterleave].
Рассмотрим следующий пример:
public interface ISlowpokeGrain : IGrainWithIntegerKey
{
Task GoSlow();
[AlwaysInterleave]
Task GoFast();
}
public class SlowpokeGrain : Grain, ISlowpokeGrain
{
public async Task GoSlow()
{
await Task.Delay(TimeSpan.FromSeconds(10));
}
public async Task GoFast()
{
await Task.Delay(TimeSpan.FromSeconds(10));
}
}
Рассмотрим поток вызовов, инициированный следующим запросом клиента:
var slowpoke = client.GetGrain<ISlowpokeGrain>(0);
// A. This will take around 20 seconds.
await Task.WhenAll(slowpoke.GoSlow(), slowpoke.GoSlow());
// B. This will take around 10 seconds.
await Task.WhenAll(slowpoke.GoFast(), slowpoke.GoFast(), slowpoke.GoFast());
Вызовы GoSlow
не чередуются, поэтому выполнение двух вызовов GoSlow
занимает около 20 секунд. С другой стороны, GoFast
помечен как AlwaysInterleaveAttribute, и три вызова выполняются одновременно, завершаясь примерно за 10 секунд, вместо того чтобы требовать не менее 30 секунд для завершения.
Методы только для чтения
Если метод зерна не изменяет состояние зерна, безопасно выполнять его одновременно с другими запросами.
ReadOnlyAttribute указывает на то, что метод не изменяет состояние зерна. Пометка методов как ReadOnly
позволяет Orleans обрабатывать запрос одновременно с другими ReadOnly
запросами, что может значительно повысить производительность приложения. Рассмотрим следующий пример:
public interface IMyGrain : IGrainWithIntegerKey
{
Task<int> IncrementCount(int incrementBy);
[ReadOnly]
Task<int> GetCount();
}
Метод GetCount
не изменяет состояние зерна, поэтому он помечается с помощью ReadOnly
. Вызывающие, ожидающие вызова этого метода, не блокируются другими ReadOnly
запросами к грейну, и метод возвращает результат немедленно.
Реентерабельность цепочки вызовов
Если зерно вызывает метод, который на другом зерне, который затем вызывается обратно в исходное зерно, вызов приведет к взаимоблокировке, если вызов не будет повторен. Повторный вход можно включить на основе каждого места вызова с помощью реентерации цепочки вызовов. Чтобы включить повторный вход в цепочку вызовов, вызовите метод AllowCallChainReentrancy(), который возвращает значение, позволяющее повторный вход от любого вызывающего элемента дальше по цепочке вызовов, пока оно не будет удалено. Это включает повторный вызов для зерна, который вызывает сам метод. Рассмотрим следующий пример:
public interface IChatRoomGrain : IGrainWithStringKey
{
ValueTask OnJoinRoom(IUserGrain user);
}
public interface IUserGrain : IGrainWithStringKey
{
ValueTask JoinRoom(string roomName);
ValueTask<string> GetDisplayName();
}
public class ChatRoomGrain : Grain<List<(string DisplayName, IUserGrain User)>>, IChatRoomGrain
{
public async ValueTask OnJoinRoom(IUserGrain user)
{
var displayName = await user.GetDisplayName();
State.Add((displayName, user));
await WriteStateAsync();
}
}
public class UserGrain : Grain, IUserGrain
{
public ValueTask<string> GetDisplayName() => new(this.GetPrimaryKeyString());
public async ValueTask JoinRoom(string roomName)
{
// This prevents the call below from triggering a deadlock.
using var scope = RequestContext.AllowCallChainReentrancy();
var roomGrain = GrainFactory.GetGrain<IChatRoomGrain>(roomName);
await roomGrain.OnJoinRoom(this.AsReference<IUserGrain>());
}
}
В предыдущем примере UserGrain.JoinRoom(roomName)
вызывает ChatRoomGrain.OnJoinRoom(user)
, которое пытается вызвать обратно UserGrain.GetDisplayName()
, чтобы получить отображаемое имя пользователя. Так как эта цепочка вызовов включает цикл, это приведет к взаимоблокировке, если UserGrain
не допускает повторный вход, используя любой из поддерживаемых механизмов, обсужденных в этой статье. В этом случае мы используем AllowCallChainReentrancy(), который позволяет только roomGrain
вызывать обратно в UserGrain
. Это дает вам тонкий контроль над тем, где и как включается повторный вход.
Если вместо этого предотвратить взаимоблокировку, аннотируя объявление метода GetDisplayName()
на IUserGrain
с [AlwaysInterleave]
с помощью, это позволило бы любому зерну чередовать вызов GetDisplayName
с любым другим методом. Вместо этого вы позволяете толькоroomGrain
вызывать методы на нашем grain и только до тех пор, пока scope
не будет удалён.
Подавление реентерабельности цепочки вызовов
Повторное получение цепочки вызовов также может быть подавлено методом SuppressCallChainReentrancy(). Это имеет ограниченную полезность для конечных разработчиков, но важно для внутреннего использования библиотек, которые расширяют Orleans функциональность вершин, таких как потоковая передача и широковещательные каналы, чтобы гарантировать, что разработчики сохраняют полный контроль над включением возможности повторного входа в цепочку вызовов.
Реэнтерабельность с использованием предиката
Классы grain могут указать предикат для определения интерливинга по каждому вызову путем анализа запроса. Атрибут [MayInterleave(string methodName)]
предоставляет эту функцию. Аргумент атрибута — это имя статического метода внутри класса grain, который принимает объект InvokeMethodRequest и возвращает bool
значение, указывающее, нужно ли чередовать запрос.
Ниже приведен пример, позволяющий переключиться, если тип аргумента запроса имеет [Interleave]
атрибут:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class InterleaveAttribute : Attribute { }
// Specify the may-interleave predicate.
[MayInterleave(nameof(ArgHasInterleaveAttribute))]
public class MyGrain : Grain, IMyGrain
{
public static bool ArgHasInterleaveAttribute(IInvokable req)
{
// Returning true indicates that this call should be interleaved with other calls.
// Returning false indicates the opposite.
return req.Arguments.Length == 1
&& req.Arguments[0]?.GetType()
.GetCustomAttribute<InterleaveAttribute>() != null;
}
public Task Process(object payload)
{
// Process the object.
}
}