了解 Orleans grains 的单线程特性

Understanding single-threaded nature of Orleans grains

我有以下客户代码片段和奥尔良的谷物。 (虽然推荐在 Orleans 中开发的方式是 await Tasks,但是下面的代码在某些时候并没有 await 纯粹是为了实验目的)

// client code
while(true)
{
    Console.WriteLine("Client giving another request");   
    double temperature = random.NextDouble() * 40;   
    var grain = client.GetGrain<ITemperatureSensorGrain>(500);
    Task t = sensor.SubmitTemperatureAsync((float)temperature);
    Console.WriteLine("Client Task Status - "+t.Status);
    await Task.Delay(5000);
}

// ITemperatureSensorGrain code
public async Task SubmitTemperatureAsync(float temperature)
{
   long grainId = this.GetPrimaryKeyLong();
   Console.WriteLine($"{grainId} outer received temperature: {temperature}");

   Task x = SubmitTemp(temperature); // SubmitTemp() is another function in the same grain
   x.Ignore();
   Console.WriteLine($"{grainId} outer received temperature: {temperature} exiting");
}

public async Task SubmitTemp(float temp)
{
    for(int i=0; i<1000; i++)
    {
       Console.WriteLine($"Internal function getting awaiting task {i}");
       await Task.Delay(1000);
    }
}

当我运行以上代码时输出如下:

Client giving another request
Client Task Status - WaitingForActivation
500 outer received temperature: 23.79668
Internal function getting awaiting task 0
500 outer received temperature: 23.79668 exiting
Internal function getting awaiting task 1
Internal function getting awaiting task 2
Internal function getting awaiting task 3
Internal function getting awaiting task 4
Client giving another request
Client Task Status - WaitingForActivation
500 outer received temperature: 39.0514
Internal function getting awaiting task 0  <------- from second call to SubmitTemp
500 outer received temperature: 39.0514 exiting
Internal function getting awaiting task 5  <------- from first call to SubmitTemp
Internal function getting awaiting task 1
Internal function getting awaiting task 6
Internal function getting awaiting task 2
Internal function getting awaiting task 7
Internal function getting awaiting task 3
Internal function getting awaiting task 8
Internal function getting awaiting task 4
Internal function getting awaiting task 9

从普通 .Net 应用程序的角度来看,输出是有意义的。如果我能从 那里得到帮助,这里发生的事情是:

  1. 客户调用 ITemperatureSendorGrain 并继续进行。当 await 被命中时,客户端线程返回到线程池。
  2. SubmitTemperatureAsync 收到请求并调用本地异步函数 SubmitTemp.
  3. SubmitTemp 打印对应于 i=0 的语句,之后它会等待。 Await 导致 for loop 的其余部分被安排为 awaitable (Task.Delay) 的延续和调用函数 SubmitTemperatureAsync 的控制 returns。这里要注意,线程在SubmitTemp函数中遇到await时并没有返回到线程池中。线程控制实际上返回给调用函数SubmitTemperatureAsync。因此,turn,如 Orleans 文档中所定义,在顶级方法遇到等待时结束。当回合结束时,线程返回到线程池。
  4. 调用函数不等待任务完成并退出。
  5. SubmitTemp returns中的awaitable 1s后,它从线程池中获取一个线程,并在其上调度其余的for loop
  6. 当客户端代码 returns 中的可等待对象时,它会再次调用相同的 grain 并安排另一轮 for loop 对应于对 SubmitTemp 的第二次调用。

我的第一个问题是我是否正确描述了代码中发生的事情,尤其是当函数中的 await 被命中时线程没有返回到线程池的问题SubmitTemp.


根据grains的单线程特性,任何时候只有一个线程会执行一个grains的代码。此外,一旦对 grain 的请求开始执行,它将在下一个请求被处理之前完全完成(在 Orleans 文档中称为 chunk based execution)。在高层次上,这对于上述代码是正确的,因为下一次调用 SubmitTemperatureAsync 只会在当前对该方法的调用退出时发生。

然而,SubmitTemp实际上是SubmitTemperatureAsync的子函数。尽管 SubmitTemperatureAsync 已退出,但 SubmitTemp 仍在执行,同时,Orleans 允许对 SubmitTemperatureAsync 的另一个调用执行。这不是违反了Orleans grain的单线程特性吗我的第二个问题


考虑 SubmitTemp 在其 for loop 中需要访问 grain class 的一些数据成员。所以,ExecutionContext会在遇到await时被捕获,当Task.Delay(1000)returns时,捕获的ExecutionContext会被传递给余数for loop的调度一个线程。因为 ExecutionContext 被传递,剩余的 for loop 将能够访问数据成员,尽管 运行 在不同的线程上。这在任何普通的 .Net 异步应用程序中都会发生。

我的第三个问题是关于SynchronizationContext的。我粗略地搜索了 Orleans 存储库,但找不到 SynchronizationContext.Post() 的任何实现,这使我相信 运行 Orleans 方法不需要 SynchronizationContext。谁能证实这一点?如果这不是真的,并且需要 SynchronizationContext,那么 SubmitTemp 的各种调用的并行执行(如上面的代码所示), 运行 的风险是不是以死锁结束(如果有人持有 SynchronizationContext 并且不释放它)?

问题 1:所描述的执行流程是否准确反映了正在发生的事情?

你的描述对我来说大致正确,但这里有一些更细微的地方:

  • 是否有线程池是一个实现细节。
  • 'Turns' 是在激活的 TaskScheduler.
  • 上安排的每个同步工作部分
  • 因此,只要执行必须返回到 TaskScheduler,回合就会结束。
  • 这可能是因为未同步完成的 await 被命中,或者用户可能根本没有使用 await 并且正在使用 ContinueWith 或自定义进行编程可等待对象。
  • 回合可以从非顶级方法结束,例如,如果将代码更改为 await SubmitTemp(x) 而不是 .Ignoring(),则回合将在 Task.Delay(...) 被击中 SubmitTemp(x).

问题 2:示例程序是否证明违反了单线程保证?

不,在给定时间只有一个线程执行 grain 的代码。 但是,'thread' 必须在激活的 TaskScheduler 上安排的各种任务之间分配时间。即,永远不会 暂停进程并发现两个线程同时执行您的 grain 代码。

就运行时而言,当从顶级方法返回的 Task(或其他可等待类型)完成时,消息处理结束。在此之前,不会安排任何新消息在您激活时执行。从您的方法中产生的后台任务始终允许与其他任务交错。

.NET 允许将子任务附加到它们的父任务。在这种情况下,父任务仅在所有子任务完成时完成。但是,这不是默认行为,通常建议您避免选择加入此行为(例如,将 TaskCreationOptions.AttachedToParent 传递给 Task.Factory.StartNew)。

如果您确实使用了该行为(请不要),那么您会在第一次调用 SubmitTemp() 时无限期地看到您的激活循环,​​并且不会处理更多消息。

问题3:Orleans是否使用SynchronizationContext

Orleans 使用 SynchronizationContext。相反,它使用自定义 TaskScheduler 实现。参见 ActivationTaskScheduler.cs。每个激活都有自己的 ActivationTaskScheduler,并且所有消息都是使用该调度程序的调度程序。

关于后续问题,针对激活安排的 Task 个实例(每个代表一个同步工作)被插入到同一个队列中,因此它们可以交错,但是ActivationTaskScheduler 一次只能由一个线程执行。

我知道这是为探索 Orleans 运行时的执行保证而编写的人为代码片段。我有点担心有人可能会读到这篇文章并误解这是应该如何实现 grain 方法的推荐模式。

这就是为什么我想强调编写 grain 代码的推荐方法是等待调用堆栈中的每个 Task。在上面的代码中,这意味着在 grain 方法中等待 x,在客户端代码中等待 t。默认情况下,grains 是不可重入的,这将阻止客户端的第二次调用在第一个调用完成之前开始执行。或者可以选择将粒度 class 标记为 [Reentrant] 并允许交错进行第二次调用。这将比后台循环更清晰和明确,并且可以进行错误处理。