Search code examples
c#.net-coreasync-awaitactororleans

Understanding single-threaded nature of Orleans grains


I have the following code snippet of a client and a grain in Orleans. (Although the recommended way to develop in Orleans is to await Tasks, the following code doesn't await at some points purely for experimentation purposes)

// 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);
    }
}

The output when I run the above code is the following:

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

The output makes sense from the perspective of a normal .Net application. If I can take help from this stackoverflow post, what is happening here is that:

  1. Client makes a call to ITemperatureSendorGrain and proceeds ahead. When the await is hit, the client thread is returned to the threadpool.
  2. SubmitTemperatureAsync receives the request and calls a local async function SubmitTemp.
  3. SubmitTemp prints statement corresponding to i=0 after which point it hits await. Await causes the rest of the for loop to be scheduled as a continuation of the awaitable (Task.Delay) and the control returns to the calling function SubmitTemperatureAsync. Note here that the thread isn't returned to the threadpool when it encounters await in SubmitTemp function. The thread control is actually returned to the calling function SubmitTemperatureAsync. So, a turn, as defined in Orleans docs, ends when the top level method encounters an await. When a turn ends, the thread is returned to the threadpool.
  4. The calling function doesn't wait for the task to finish and exits.
  5. When the awaitable in SubmitTemp returns after 1s, it gets a thread from the threadpool and schedules the rest of the for loop on it.
  6. When the awaitable in client code returns, it makes another call to the same grain and another round of for loop is scheduled corresponding to the second call to SubmitTemp.

My first question is have I correctly described what's happening in the code, especially about the thread not being returned to the thread pool when await is hit in the function SubmitTemp.


According to single-threaded nature of grains, at any time only one thread will be executing a grain's code. Also, once a request to a grain begins execution, it will be completed fully before the next request is taken up (called chunk based execution in the Orleans docs). On a high-level, this is true for the above code because the next call to SubmitTemperatureAsync will happen only when the current call to the method exits.

However, SubmitTemp was actually a sub-function of SubmitTemperatureAsync. Although SubmitTemperatureAsync exited, SubmitTemp is still executing and while it does that, Orleans allowed another call to SubmitTemperatureAsync to execute. Doesn't this violate the single-threaded nature of Orleans grain is my second question?


Consider that SubmitTemp in its for loop needs to access some data members of the grain class. So, the ExecutionContext will be captured when await is encountered and when Task.Delay(1000) returns, the captured ExecutionContext will be passed to the scheduling of the remainder for loop on a thread. Because ExecutionContext is passed, the remainder for loop will be able to access the data members in spite of running on a different thread. This is something which happens in any normal .Net asynchronous application.

My third question is about SynchronizationContext. I did a cursory search in the Orleans repository but couldn't find any implementation of SynchronizationContext.Post(), which leads me to believe that no SynchronizationContext is required to run Orleans methods. Can anyone confirm this? If this isn't true, and a SynchronizationContext is required, wouldn't the parallel execution of the various invocations of SubmitTemp (as shown in the above code), run the risk of ending in a deadlock (if someone holds on to the SynchronizationContext and doesn't release it)?


Solution

  • Question 1: Is the described execution flow an accurate representation of what's happening?

    Your description looks roughly correct to me, but here's a few finer points:

    • Whether or not there's a thread pool is an implementation detail.
    • 'Turns' are each synchronous portion of work scheduled on the activation's TaskScheduler.
    • Therefore, a turn ends whenever a execution has to be yielded back to the TaskScheduler.
    • That could be because an await was hit which wasn't synchronously completed, or maybe the user isn't using await at all and is programming using ContinueWith or custom awaitables.
    • A turn could be ended from a non-top-level method, eg if the code was changed to await SubmitTemp(x) instead of .Ignoring() it, then the turn would end when the Task.Delay(...) was hit inside SubmitTemp(x).

    Question 2: Does the example program demonstrate a violation of the single-threadedness guarantee?

    No, there is only ever a single thread executing the grain's code at a given time. However, that 'thread' has to split its time between the various tasks which are scheduled on the activation's TaskScheduler. i.e, there will never be a time where you suspend the process and discover that two threads are executing your grain's code at the same time.

    As far as the runtime is concerned, the processing of your message ends when the Task (or other awaitable type) returned from the top level method completes. Until that occurs, no new messages will be scheduled for execution on your activation. Background tasks spawned from your methods are always allowed to interleave with other tasks.

    .NET allows child tasks to be attached to their parent task. In that case, the parent task only completes when all child tasks complete. This is not the default behavior, however, and it's generally suggested that you avoid opting-in to this behavior (eg, by passing TaskCreationOptions.AttachedToParent to Task.Factory.StartNew).

    If you did make use of that behavior (please don't), then you would see your activation loop on the first call to SubmitTemp() indefinitely, and no more messages will be processed.

    Question 3: Does Orleans use SynchronizationContext?

    Orleans does not use SynchronizationContext. Instead, it uses custom TaskScheduler implementations. See ActivationTaskScheduler.cs. Each activation has its own ActivationTaskScheduler and all messages are scheduler using that scheduler.

    Regarding the follow-on question, the Task instances (each one representing a synchronous piece of work) which are scheduled against the activation are inserted into the same queue and so they are allowed to interleave, but the ActivationTaskScheduler is only executed by one thread at a time.