title: 可重入性

description: 本节本节介绍了Orleans中的请求调度机制

请求调度

Grain激活有一个单线程的执行模式,默认情况下,在开始处理下一个请求之前,会从开始到完成处理每个请求。 在某些情况下,当一个请求在等待异步操作完成时,一个激活可能需要处理其他请求。 出于这个原因和其他一些原因,Orleans给开发者提供了一些对请求交错行为的控制,如下文可重入性部分所述。 下面是一个非重入请求调度的例子,这是Orleans的默认行为。

我们最开始的例子主要关注下面的PingGrain的定义:

  1. public interface IPingGrain : IGrainWithStringKey
  2. {
  3. Task Ping();
  4. Task CallOther(IPingGrain other);
  5. }
  6. public class PingGrain : Grain, IPingGrain
  7. {
  8. private readonly ILogger<PingGrain> _logger;
  9. public PingGrain(ILogger<PingGrain> logger) => _logger = logger;
  10. public Task Ping() => Task.CompletedTask;
  11. public async Task CallOther(IPingGrain other)
  12. {
  13. _logger.LogInformation("1");
  14. await other.Ping();
  15. _logger.LogInformation("2");
  16. }
  17. }

在我们的例子中涉及到两个类型为PingGrain的Grain,即AB。 一个调用者发起以下调用:

  1. var a = grainFactory.GetGrain("A");
  2. var b = grainFactory.GetGrain("B");
  3. await a.CallOther(b);

description: 本节本节介绍了Orleans中的请求调度机制 - 图1

执行流程如下:

  1. 调用到达A,它记录下"1",然后发起对B的调用
  2. B立即从Ping()返回到A
  3. A记录下"2"并返回到原始调用者

A在等待对B的调用时,它不能处理其他传入的请求。 因此,如果AB同时调用对方,它们可能会在等待调用完成时陷入死锁。 下面是一个例子,基于客户端发出的以下调用:

  1. var a = grainFactory.GetGrain("A");
  2. var b = grainFactory.GetGrain("B");
  3. // A 调用 B 的同时 B 调用 A。
  4. // 这可能会陷入死锁,这取决于事件的非确定性时机。
  5. await Task.WhenAll(a.CallOther(b), b.CallOther(a));

情况1: 调用没有引发死锁

description: 本节本节介绍了Orleans中的请求调度机制 - 图2

在这个例子中:

  1. 来自APing()调用在CallOther(a)调用到达B之前到达了B
  2. 因此,BCallOther(a)调用之前处理Ping()调用
  3. 因为B处理了Ping()调用,所以A能够返回到调用者那里
  4. BA发出Ping()调用时,A仍在忙于记录其信息("2"),所以该调用需要等待很短的时间,但它很快就能被处理
  5. A处理了Ping()调用并返回到BB再返回到原来的调用者

现在,我们将讨论一系列不那么幸运的事件:由于时机上略有不同,相同的代码引发了死锁

情况2: 调用引发了死锁

description: 本节本节介绍了Orleans中的请求调度机制 - 图3

在这个例子中:

  1. CallOther到达各自的Grain,并同时进行处理
  2. 两个Grain都记录下了"1"然后运行await other.Ping()
  3. 因为两个Grains都在忙碌(处理尚未完成的CallOther请求),Ping()会等待
  4. 经过一段时间后,Orleans认为调用已经超时,并且两个Ping()调用都会抛出异常
  5. 这个异常没有被CallOther方法体处理,所以它抛给了原始调用者

下一节描述了如何通过允许多个请求相互交叉执行来防止死锁。

可重入性

Orleans默认选择一个安全的执行流程:一个Grain的内部状态不会被多个请求并发地修改。 内部状态的并发修改会使逻辑变得复杂,给开发者带来更大的负担。 这种对那些并发性Bug的保护是有代价的,正如我们在上面看到的,主要是活跃性:某些调用模式会导致死锁。 避免死锁的一个方法是确保Grain调用永远不会形成一个回环。 很多时候,要写出无回环且不会死锁的代码是很困难的。 在处理下一个请求之前,等待每个请求从开始运行到完成也会影响性能。 例如,在默认情况下,如果一个Grain方法对数据库服务执行一些异步请求,那么Grain将暂停请求的执行,直到数据库的响应到达Grain。

在后文中会讨论每种情况。 因此,Orleans为开发者提供了一些选项,运行部分或全部请求可以被并发地执行,通过交叉其执行过程。 在Orleans中,这称之为可重入交叉。 通过并发执行请求,执行异步操作的Grains可以在更短的时间内处理更多的请求。

在以下情况下,多个请求可以交叉执行:

  • Grain类带有[Reentrant]特性
  • 接口方法带有[AlwaysInterleave]特性
  • Grain的[MayInterleave(x)]谓词返回true

有了可重入,下面的情况就可以有效执行,上述死锁的可能性也消除了。

情况3: Grain或方法是可重入的

description: 本节本节介绍了Orleans中的请求调度机制 - 图4

在这个例子中,Grain AB能够同时相互调用,而不会出现任何潜在的请求调度死锁,因为这两个晶粒都是可重入的。 下面几节将提供更多关于可重入的细节。

可重入Grains

Grain实现类可以用[Reentrant]特性进行标记,以表明不同的请求可以自由交叉进行。

换句话说,一个可重入的激活可以在前一个请求还没有处理完的时候开始执行另一个请求。 执行仍然被限制在一个线程内,所以激活仍然是一次执行一个回合,每个回合只代表激活的一个请求来执行。

可重入的Grain代码永远不会并行地运行多份Grain代码(Grain代码的执行永远是单线程的),但可重入的Grain可能会看到不同请求的代码交叉执行。 也就是说,来自不同请求的连续回合可能会交叉执行。

例如下面的伪代码,Foo()Bar()是同一个Grain类的2个方法:

  1. Task Foo()
  2. {
  3. await task1; // line 1
  4. return Do2(); // line 2
  5. }
  6. Task Bar()
  7. {
  8. await task2; // line 3
  9. return Do2(); // line 4
  10. }

如果这个Grain被标记为[Reentrant]Foo()Bar()的执行可能会交叉。

例如,以下的执行顺序:

第一行,第三行,第二行和第四行。 也就是说,来自不同请求的回合是交叉的。

如果Grain不是可重入的,唯一可能的执行是:第1行,第2行,第3行,第4行;第3行,第4行,第1行,第2行(新的请求不能在前一个请求完成之前开始)。

在选择可重入和不可重入的Grain时,主要的权衡因素是使交叉正确工作的代码复杂性,以及推理它的难度。

在平凡的情况下,当Grain是无状态的,且逻辑简单时,少量的(但不能太少,以便所有的硬件线程都被利用)可重入Grain,通常会使效率略高一些。

如果代码比较复杂,那么更多的非可重入Grain,即使整体效率略低,也能为你省去很多解决非明显交叉问题的麻烦。

总的来说,答案还是取决于应用的具体细节。

交叉方法

标有[AlwaysInterleave]的Grain接口方法将被交叉使用,无论Grain本身是否是可重入的。请看下面的例子:

  1. public interface ISlowpokeGrain : IGrainWithIntegerKey
  2. {
  3. Task GoSlow();
  4. [AlwaysInterleave]
  5. Task GoFast();
  6. }
  7. public class SlowpokeGrain : Grain, ISlowpokeGrain
  8. {
  9. public async Task GoSlow()
  10. {
  11. await Task.Delay(TimeSpan.FromSeconds(10));
  12. }
  13. public async Task GoFast()
  14. {
  15. await Task.Delay(TimeSpan.FromSeconds(10));
  16. }
  17. }

现在考虑由下述客户请求发起的调用流程:

  1. var slowpoke = client.GetGrain<ISlowpokeGrain>(0);
  2. // A) 此操作耗时约20秒
  3. await Task.WhenAll(slowpoke.GoSlow(), slowpoke.GoSlow());
  4. // B) 此操作耗时约10秒
  5. await Task.WhenAll(slowpoke.GoFast(), slowpoke.GoFast(), slowpoke.GoFast());

GoSlow的调用将不会交叉执行,所以两个GoSlow()的调用的执行将需要大约20秒。 另一方面,由于GoFast被标记为[AlwaysInterleave],对它的三个调用将被同时执行,总共将在大约10秒内完成,而不是需要至少30秒才能完成。

使用谓词的可重入性

Grain类可以指定一个谓词,通过检查请求,在逐个调用的基础上确定是否交叉执行。 [MayInterleave(string methodName)]特性提供了这个功能。 该特性的参数是Grain类中一个静态方法的名称,该方法接受一个InvokeMethodRequest对象,并返回一个bool,表示该请求是否应该交叉执行。

下面是一个例子,如果请求参数类型有[Interleave]特性,则允许交叉:

  1. [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
  2. public sealed class InterleaveAttribute : Attribute { }
  3. // 指定may-interleave谓词
  4. [MayInterleave(nameof(ArgHasInterleaveAttribute))]
  5. public class MyGrain : Grain, IMyGrain
  6. {
  7. public static bool ArgHasInterleaveAttribute(InvokeMethodRequest req)
  8. {
  9. // 返回true表示这个调用应该与其他调用交叉进行
  10. // 返回false则反之
  11. return req.Arguments.Length == 1
  12. && req.Arguments[0]?.GetType().GetCustomAttribute<InterleaveAttribute>() != null;
  13. }
  14. public Task Process(object payload)
  15. {
  16. // Process the object.
  17. }
  18. }