Search code examples
c#multithreadingtaskthreadpoolsystem.diagnostics

Is it safe to use Activity.Current from multiple parallel tasks on the same thread?


When trying to implement W3C tracing which makes use of variables, such as e.g. Activity.Current.SpanId, the following question came up.

The official documentation for the static property Activity.Current says:

Gets or sets the current operation (Activity) for the current thread. This flows across async calls.

I am not entirely sure how to interpret flows across async calls in view of current thread. With modern C#, we should not care about threads anymore when using tasks. So I would expect that when I do a using workActivity = new Activity($"Work {id}").Start() inside multiple worker tasks, this activity instance should always be returned by Activity.Current, even when multiple tasks are run on the same (thread pool) thread.

Is that always true?

It actually boils down to the difference between thread context and task context (if such a thing even exists). Is Activity.Current part of the thread context or part of the task context? Or how can Activity.Current always reflect the right value for each parallel task?

I also did some quick investigation using this sample code:

using System.Diagnostics;

namespace DummyConsoleApp
{
    internal class Program
    {
        static void PrintTraceInfo(string msg, int? workerId = null)
        {
            Debug.Assert(Activity.Current != null, "Activity.Current != null");
            Console.WriteLine($"Worker {workerId} on thread {Thread.CurrentThread.ManagedThreadId} with trace {Activity.Current.TraceId} {Activity.Current.ParentSpanId} --> {Activity.Current.SpanId}: {msg}\n");
        }

        static async Task Work(int id)
        {
            using var workActivity = new Activity($"Work {id}").Start();
            PrintTraceInfo("Work started", id);
            await Task.Delay(3000);
            PrintTraceInfo("Work ended", id);
        }

        static async Task Main(string[] args)
        {
            using var activity = new Activity("Main").Start();
            PrintTraceInfo("Main started.");
            var tasks = new List<Task>();
            for (int i = 0; i < 100; i++)
            {
                var id = i;
                var task = Task.Run(() => Work(id));
                tasks.Add(task);
            }
            await Task.WhenAll(tasks).ConfigureAwait(false);
            PrintTraceInfo("Main ended.");
        }
    }
}using System.Diagnostics;

namespace DummyConsoleApp
{
    internal class Program
    {
        static void PrintTraceInfo(string msg, int? workerId = null)
        {
            Debug.Assert(Activity.Current != null, "Activity.Current != null");
            Console.WriteLine($"Worker {workerId} on thread {Thread.CurrentThread.ManagedThreadId} with trace {Activity.Current.TraceId} {Activity.Current.ParentSpanId} --> {Activity.Current.SpanId}: {msg}\n");
        }

        static async Task Work(int id)
        {
            using var workActivity = new Activity($"Work {id}").Start();
            PrintTraceInfo("Work started", id);
            await Task.Delay(3000);
            PrintTraceInfo("Work ended", id);
        }

        static async Task Main(string[] args)
        {
            using var activity = new Activity("Main").Start();
            PrintTraceInfo("Main started.");
            var tasks = new List<Task>();
            for (int i = 0; i < 100; i++)
            {
                var id = i;
                var task = Task.Run(() => Work(id));
                tasks.Add(task);
            }
            await Task.WhenAll(tasks).ConfigureAwait(false);
            PrintTraceInfo("Main ended.");
        }
    }
}

Everything seems to work as expected and even tasks on the same thread will output their individual id. But then again this is only a little test.

Does anybody have more information on this? Can Activity.Current always be safely accessed from parallel tasks?


Solution

  • If we do a bit of decompiling we can see that Activity.Current uses AsyncLocal<T> as the storage:

    public partial class Activity
    {
            private static readonly AsyncLocal<Activity?> s_current = new AsyncLocal<Activity?>();
    ...
    

    And from the AsyncLocal remarks

    Because the task-based asynchronous programming model tends to abstract the use of threads, AsyncLocal instances can be used to persist data across threads.

    The AsyncLocal class also provides optional notifications when the value associated with the current thread changes, either because it was explicitly changed by setting the Value property, or implicitly changed when the thread encountered an await or other context transition.

    So it uses "task context". For the "thread context" equivalent, see ThreadLocal<T>