Поделиться через


Планирование запросов

Активации зерна имеют однопоточную модель выполнения. По умолчанию они обрабатывают каждый запрос от начала до завершения, прежде чем следующий запрос может начать обработку. В некоторых случаях может потребоваться активация для обработки других запросов, пока один запрос ожидает завершения асинхронной операции. По этим и другим причинам 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);

Схема планирования повторного входа.

Поток выполнения выглядит следующим образом:

  1. Звонок поступает в A, который регистрирует"1", а затем инициирует вызов в B.
  2. B возвращается немедленно из Ping() обратно в A.
  3. Выполняет логирование"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. Вызовы не взаимоблокируются

Схема планирования повторного входа без взаимоблокировки.

В этом примере:

  1. Звонок от Ping() поступает в B раньше, чем звонок поступает в CallOther(a).
  2. Таким образом, B обрабатывает Ping() вызов перед вызовом CallOther(a) .
  3. Так как B обрабатывает Ping() вызов, A может вернуться вызывающей стороне.
  4. Когда B осуществляет Ping() вызов A, A по-прежнему занят регистрацией своего сообщения ("2"), поэтому вызов должен подождать некоторое время, но вскоре его могут обработать.
  5. A обрабатывает Ping() вызов и возвращается к B, который возвращается к исходному вызывающему.

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

Случай 2. Взаимоблокировка вызовов

Схема планирования повторного входа с взаимоблокировкой.

В этом примере:

  1. Вызовы CallOther поступают на соответствующие узлы и обрабатываются одновременно.
  2. Журнал регистрации зерна "1" и переход к await other.Ping().
  3. Так как оба зерна по-прежнему заняты (обработка CallOther запроса, который еще не завершен), Ping() запросы ожидают.
  4. Через некоторое время Orleans определяет, что у вызова произошло истечение времени ожидания, и каждый Ping() вызов приводит к возникновению исключения.
  5. Тело CallOther метода не обрабатывает исключение, и оно передается исходному вызывающему.

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

Повторный вход

Orleans по умолчанию используется безопасный поток выполнения, при котором внутреннее состояние зерна не изменяется одновременно несколькими запросами. Параллельное изменение усложняет логику и создает большую нагрузку на вас, разработчик. Эта защита от ошибок параллелизма влечет за собой затраты, прежде всего на жизнеспособность: некоторые шаблоны вызовов могут привести к взаимоблокировкам, о чем говорилось ранее. Одним из способов избежать взаимоблокировок является обеспечение того, чтобы вызовы зерна не образовывали цикл. Часто трудно писать код без циклов и гарантировать отсутствие взаимоблокировок. Ожидание выполнения каждого запроса от начала до завершения перед обработкой следующего может также повредить производительность. Например, если метод грейна выполняет асинхронный запрос к службе базы данных, он приостанавливает выполнение запроса до тех пор, пока ответ базы данных не будет получен.

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

Несколько запросов могут перемежаться в следующих случаях:

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

Случай 3. Зерно или метод повторно переудается

Схема планирования реентерабельности с повторно используемой гранулой или методом.

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

Реентрантные зерна

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

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

Реентрированный код зерен никогда не выполняется параллельно (всегда однопотоковое исполнение), но реентрированные зерна могут столкнуться с перекрытием выполнения кода для разных запросов. То есть продолжения от разных запросов могут переплетаться.

Например, как показано в следующем псевдокоде, давайте рассмотрим, что 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 с любым другим методом. С помощью AllowCallChainReentrancy вы даёте возможность толькоroomGrain вызывать методы из UserGrain, и только до тех пор, пока 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.
    }
}