大家应该都知道,把一个同步操作放入Task.Run,可以把同步变为异步。然而实际工作中,大家并不会这么做,还会称这种方法为“伪异步”。这是为什么呢?这里我们来探讨一下。

首先,我们从一个例子入手,看看把一个同步操作放入Task.Run会发生什么。

using System.Diagnostics;

namespace TaskRunDemo;

class Program
{
    static Stopwatch sw;

    static async Task Main(string[] args)
    {
        sw = Stopwatch.StartNew();

        Console.WriteLine("=== Task.Run Wrapping Sync Operation — Thread Behavior Demo ===");
        Console.WriteLine();

        await WrappedInTaskRun();

        Console.WriteLine();
        Console.WriteLine("Done.");
    }

    static async Task WrappedInTaskRun()
    {
        int callerThread = Thread.CurrentThread.ManagedThreadId;
        Console.WriteLine($"  [{sw.ElapsedMilliseconds,5}ms] [Thread {callerThread}] Step 1: Calling Task.Run(() => SyncOperation()) — thread {callerThread} is FREE (returned to thread pool)");

        string result = await Task.Run(() =>
        {
            int workerThread = Thread.CurrentThread.ManagedThreadId;
            Console.WriteLine($"  [{sw.ElapsedMilliseconds,5}ms] [Thread {workerThread}] (could be any thread pool thread including caller, but typically not) Step 2: A thread pool thread picked up the work — thread {workerThread} is now BLOCKED during the operation");
            string r = SyncOperation();
            Console.WriteLine($"  [{sw.ElapsedMilliseconds,5}ms] [Thread {Thread.CurrentThread.ManagedThreadId}] (always same as step 2) Step 3: Sync operation completed, Task is completed");
            return r;
        });

        Console.WriteLine($"  [{sw.ElapsedMilliseconds,5}ms] [Thread {Thread.CurrentThread.ManagedThreadId}] (often same as step 3 as runtime optimization, but not guaranteed) Step 4: Continuation runs — got result: {result}");
    }

    static string SyncOperation()
    {
        Thread.Sleep(2000);
        return $"done on thread {Thread.CurrentThread.ManagedThreadId}";
    }
}

这个例子的输出如下:

=== Task.Run Wrapping Sync Operation - Thread Behavior Demo ===

  [    4ms] [Thread 1] Step 1: Calling Task.Run(() => SyncOperation()) - thread 1 is FREE (returned to thread pool)
  [    6ms] [Thread 9] (could be any thread pool thread including caller, but typically not) Step 2: A thread pool thread picked up the work - thread 9 is now BLOCKED during the operation
  [ 2019ms] [Thread 9] (always same as step 2) Step 3: Sync operation completed, Task is completed
  [ 2020ms] [Thread 9] (often same as step 3 as runtime optimization, but not guaranteed) Step 4: Continuation runs - got result: done on thread 9

Done.

步骤2的线程可能是线程池中的任意线程,包括Caller thread,但通常不是Caller thread,这是因为:

  • 在Caller thread调用Task.Run之后,Task.Run包含的工作会被放入线程池的队列中,等待一个线程取出并处理它。
  • 在Caller thread执行到await时,Caller thread被归还到线程池中。
  • 如果线程池中有空闲的线程,很可能在Caller thread被归还之前,就已经把队列中的工作取出了。

步骤4的线程通常和步骤2-3的一样,这是因为runtime的inline optimization。

  • 在线程complete Task时,runtime会检查是否有continuation在等待执行,如果有,就直接在当前线程上继续执行,而不是把continuation放入线程池的队列中。
  • 这样做可以避免排队和context switch,因此执行会更快。
  • Inline optimization只是一个内部优化,并不是一个保证会发生的feature。

可以看出,把一个同步操作放入Task.Run之后,只是把线程阻塞从Caller thread转移到另一个线程池的线程上了,并没有减少线程阻塞。

这样做甚至不如直接调用同步操作,因为会带来额外的开销。

  • 排队的开销:同步操作会被放到线程池的队列中。
  • Context switch的开销:从Caller thread切换到另一个线程执行,需要进行context switch。
  • 内存和GC的开销:需要在堆上创建Task对象,可能会导致更频繁的GC。

所以啊,如果要调用一个同步操作,直接调用就行了,不要把它放到Task.Run里。