C#的Task详解与async/await线程ID变化情况

Task详解

Task是在ThreadPool的基础上推出的。ThreadPool中有若干数量的线程(默认是CPU核心数2倍),如果有任务需要处理时,会从线程池中获取一个空闲的线程来执行任务,任务执行完毕后线程不会销毁,而是被线程池回收以供后续任务使用。当线程池中所有的线程都在忙碌时,又有新任务要处理时,线程池才会新建一个线程来处理该任务,如果线程数量达到设置的最大值,任务会排队,等待其他任务释放线程后再执行。

ThreadPool相对于Thread来说可以减少线程的创建,有效减小系统开销;但是ThreadPool不能控制线程的执行顺序,我们也不能获取线程池内线程取消/异常/完成的通知,即我们不能有效监控和控制线程池中的线程。

.net4.0ThreadPool的基础上推出了Task,Task拥有线程池的优点,同时也解决了使用线程池不易控制的弊端。

Task机制

Task与ThreadPool什么关系呢?简单来说,Task是基于ThreadPool实现的,当然被标记为LongRunning的Task(单独创建线程实现)除外。Task被创建后,通过TaskScheduler执行工作项的分配。TaskScheduler会把工作项存储到两类队列中: 全局队列与本地队列。全局队列被设计为FIFO(先进先出)的队列。本地队列存储在线程中,被设计为LIFO.(先进后出或者后进先出)

  • 当主程序创建了一个Task后,由于创建这个Task的线程不是线程池中的线程,则TaskScheduler 会把该Task放入全局队列中。
  • 如果这个Task是由线程池中的线程创建,并且未设置TaskCreationOptions.PreferFairness标记(默认情况下未设置),TaskScheduler 会把该Task放入到该线程的本地队列中。如果设置了TaskCreationOptions.PreferFairness标记,则放入全局队列

那么任务放入到两类队列中后,是如何被执行的呢? 当线程池中的线程准备好执行更多工作时,首先查看本地队列。 如果工作项在此处等待,直接通过LIFO的模式获取执行。 如果没有,则向全局队列以FIFO的模式获取工作项。如果全局队列也没有工作项,则查看其他线程的本地队列是否有可执行工作项,如果存在可执行工作项,则以FIFO的模式出队执行。

Task的创建并运行(无返回值)

static void Main(string[] args)
{
    //1.new方式实例化一个Task,需要通过Start方法启动
    Task task = new Task(() =>
    {
        Thread.Sleep(100);
        Console.WriteLine($"hello, task1的线程ID为{Thread.CurrentThread.ManagedThreadId}");
    });
    task.Start();

    //2.Task.Factory.StartNew(Action action)创建和启动一个Task
    Task task2 = Task.Factory.StartNew(() =>
    {
        Thread.Sleep(100);
        Console.WriteLine($"hello, task2的线程ID为{Thread.CurrentThread.ManagedThreadId}");
    });

    //3.Task.Run(Action action)将任务放在线程池队列,返回并启动一个Task
    Task task3 = Task.Run(() =>
    {
        Thread.Sleep(100);
        Console.WriteLine($"hello, task3的线程ID为{Thread.CurrentThread.ManagedThreadId}");
    });
    Console.WriteLine("执行主线程!");
    Console.ReadKey();
}

Task的创建并运行(有返回值)

static void Main(string[] args)
{
    // 1.new方式实例化一个Task,需要通过Start方法启动
    Task<string> task1 = new Task<string>(() =>
    {
        return $"hello, task1的ID为{Thread.CurrentThread.ManagedThreadId}";
    });
    task.Start();

    // 2.Task.Factory.StartNew(Func func)创建和启动一个Task
    Task<string> task2 = Task.Factory.StartNew<string>(() =>
    {
        return $"hello, task2的ID为{Thread.CurrentThread.ManagedThreadId}";
    });

    // 3.Task.Run(Func func)将任务放在线程池队列,返回并启动一个Task
    Task<string> task3 = Task.Run<string>(() =>
    {
        return $"hello, task3的ID为{Thread.CurrentThread.ManagedThreadId}";
    });

    Console.WriteLine("执行主线程!");
    Console.WriteLine(task1.Result);
    Console.WriteLine(task2.Result);
    Console.WriteLine(task3.Result);
    Console.ReadKey();
}

Task的阻塞方法(Wait/WaitAll/WaitAny)

Thread的Join方法可以阻塞调用线程,但是有一些弊端,要实现很多线程的阻塞时,每个线程都要调用一次Join方法;

  • task.Wait() 表示等待task执行完毕,功能类似于thead.Join()
  • Task.WaitAll(Task[] tasks) 表示只有所有的task都执行完成了再解除阻塞
  • Task.WaitAny(Task[] tasks) 表示只要有一个task执行完毕就解除阻塞
static void Main(string[] args)
{
    Task task1 = new Task(() => {
        Thread.Sleep(500);
        Console.WriteLine("线程1执行完毕!");
    });
    task1.Start();
    Task task2 = new Task(() => {
        Thread.Sleep(1000);
        Console.WriteLine("线程2执行完毕!");
    });
    task2.Start();
    //阻塞主线程。task1,task2都执行完毕再执行主线程
    //执行【task1.Wait();task2.Wait();】可以实现相同功能
    Task.WaitAll(new Task[] { task1, task2 });
    Console.WriteLine("主线程执行完毕!");
    Console.ReadKey();
}
// 线程1执行完毕!
// 线程2执行完毕!
// 主线程执行完毕!

Task的延续操作(WhenAny/WhenAll/ContinueWith)

  • task.WhenAll(Task[] tasks) 表示所有的task都执行完毕后再去执行后续的ContinueWith中操作
  • task.WhenAny(Task[] tasks) 表示任一task执行完毕后就开始执行后续ContinueWith中操作
  • task.ContinueWith(Action action) 在运行完指定的task后继续执行后续的逻辑, 等价于GetAwaiter方法(该方法也是async await异步函数所用到的)

其他同功能的方法:

  • Task.Factory.ContinueWhenAll(Task[] tasks, Action continuationAction) 所有task执行完毕执行Action
  • Task.Factory.ContinueWhenAny(Task[] tasks, Action continuationAction) 任一task执行完毕执行Action
static void Main(string[] args)
{
    Task task1 = new Task(() => {
        Thread.Sleep(500);
        Console.WriteLine("线程1执行完毕!");
    });
    task1.Start();
    Task task2 = new Task(() => {
        Thread.Sleep(1000);
        Console.WriteLine("线程2执行完毕!");
    });
    task2.Start();
    // task1,task2执行完了后执行后续操作
    Task.WhenAll(task1, task2).ContinueWith((t) => {
        Thread.Sleep(100);
        Console.WriteLine("执行后续操作完毕!ContinueWith");
    });
    // GetAwaiter方法
    var awaiter = Task.Run(() => {
        Thread.Sleep(1500);
        Console.WriteLine("线程3执行完毕!");
    }).GetAwaiter();
    awaiter.OnCompleted(() => {
        Thread.Sleep(100);
        Console.WriteLine("执行后续操作完毕!GetAwaiter");
    });
    Console.WriteLine("主线程执行完毕!");
    Console.ReadKey();
}
// 主线程执行完毕!
// 线程1执行完毕!
// 线程2执行完毕!
// 线程3执行完毕!
// 执行后续操作完毕!ContinueWith
// 执行后续操作完毕!GetAwaiter

Task的任务取消(CancellationTokenSource)

Task中有一个专门的类 CancellationTokenSource 来取消任务执行, 其功能包括直接取消(source.Cancel())、延迟取消(source.CancelAfter(5000))、取消触发回调(source.Token.Register(Action action))等等

static void Main(string[] args)
{
    CancellationTokenSource source = new CancellationTokenSource();
    int index = 0;
    //开启一个task执行任务
    Task task1 = new Task(() =>
    {
        while (!source.IsCancellationRequested)
        {
            Thread.Sleep(1000);
            Console.WriteLine($"第{++index}次执行,线程运行中...");
        }
    });
    task1.Start();
    //五秒后取消任务执行
    Thread.Sleep(5000);
    //source.Cancel()方法请求取消任务,IsCancellationRequested会变成true
    source.Cancel();
    Console.ReadKey();
}

异步async/await

async/await是C#5.0,也就是.NET Framewk 4.5时期推出的C#语法,通过与.NET Framewk 4.0时引入的任务并行库,也就是所谓的TPL(Task Parallel Library)构成了新的异步编程模型,也就是TAP(Task-based asynchronous pattern),基于任务的异步模式

async/await 是 C# 5 引入的一种语言特性,用于简化异步编程。

  • net4.5的Async,抛去语法糖就是Net4.0的Task+状态机。
  • net4.0的Task,退化到3.5即是(Thread、ThreadPool)+实现的等待、取消等API操作

在使用 async/await 进行异步编程时,需要注意以下几点:

  • async 方法必须返回voidTaskTask<T>,其中 T 是返回结果的类型。void(不需要返回任何结果时), Task(返回一个表示异步操作的任务时), Task(当异步方法需要返回异步操作的结果时)。
  • 使用 await 操作符等待异步操作完成。await 表示当前方法的执行会被挂起,直到异步操作完成后再继续执行。
  • await 操作符只能在 async 方法中使用。
  • 如果异步操作抛出异常,可以在 async 方法中使用 try/catch 块来处理异常。
private async Task Test_one_async() //这是一个无返回值的异步方法
private async Task<string> Test_two_async() //这是一个返回值类类型是string的异步方法
private async void Test_two_async() //这是一个异步事件处理程序

在 C# 中,async/await 的原理是使用状态机实现。编译器会将 async 方法编译成一个状态机,其中每个 await 表达式会生成一个状态。当 await 表达式执行时,状态机会保存当前方法的上下文,并将执行权返回给调用方。当异步操作完成后,状态机会恢复上下文,并从上一次暂停的地方继续执行。

async/await的运行机理

反编译后我们可以看到async/await的运作机理主要分为分异步状态机等待器,现在我主要来讲解着两部分的运行机制。

异步状态机

  • 异步状态机 始状态是-1
  • 运行第一个【等待器】 期间异步状态机的状态是0
  • 第一个【等待器】完成后异步状态机状态恢复-1
  • 运行等二个【等待器】期间 异步状态机的状态是1,后面【等待器】以此类推
  • 当所有的等待器都完成后,异步状态机的状态为-2

等待器

TaskAwaiter等待器

ConfiguredTaskAwaiter等待器

StateMachineBoxAwareAwaiter等待器

为什么带async签名的方法返回值一定是void、Task、Task

因为它们是可以等待的,而void不是。因此,如果您有一个异步方法返回 Task<T>Task,则可以将结果传递给等待。使用void方法,您无需等待任何东西。

当您具有异步事件处理程序时,必须返回 void。

async/await的线程ID变化情况

问:async/await会创建新线程吗?

在大多数情况下,异步操作并不会创建新的线程,而是通过利用I/O完成端口或其他异步机制来实现异步操作。这样可以避免创建额外的线程,提高程序的性能和资源利用率。但是如果使用Task.Run等方法来包装一个同步的阻塞操作,那么它可能会在新的线程上执行。

问:异步方法会暂时挂起,是什么意思?

在遇到类似await Task.Delay(100)时,异步方法会暂时挂起,并让出当前线程的控制权。这里的挂起并不是指线程被挂起或阻塞,而是指异步方法暂时停止执行,并将控制权返回给调用它的线程(主线程)。

在挂起期间,异步方法不会占用线程资源,而是让线程可以执行其他任务。这样可以提高程序的并发性和资源利用率。一旦延时任务完成,异步方法会被唤醒,并继续执行后续的代码。

具体来说,当异步方法遇到await Task.Delay(100)时,它会将延时任务交给.NET运行时的任务调度器(Task Scheduler)管理。任务调度器会将延时任务放入等待队列中,并继续执行其他任务。完成任务后,任务调度器会将延时任务标记为完成,并将其添加到就绪队列中。当调度器调度到该任务时,它会通知异步方法继续执行。具体是由哪个线程执行, 取决于任务调度器算法, 可能是之前线程也可能是新线程。

总之,异步方法的挂起并不是线程的挂起或阻塞,而是暂时停止执行,并让出当前线程的控制权。在挂起期间,线程可以执行其他任务。

问:遇到await时主线程在做啥

当遇到await关键字时,主线程会暂时挂起(挂起点),这并不会阻塞主线程的执行,而是让出当前线程的控制权,允许主线程去执行其他任务。

同时,await关键字会将异步操作交给任务调度器来管理。任务调度器会根据当前的线程池状态和调度策略,将异步操作分配给适当的线程执行。

当异步操作完成后,任务调度器会通知异步方法继续执行。这时,可能会发生线程切换,执行剩下的代码的线程可能是之前执行异步操作的线程,也可能是其他线程。

问: await完成后,主线程的ID可能会改变

是的, 因为是由任务调度器来管理线程的。具体来说,任务调度器会选择一个可用的线程(可能是之前执行异步操作的线程,也可能是其他线程),并将执行权转移给该线程。这样,异步方法就可以继续执行await之后的代码。

C#的task与golang协程对比

C# 的 Task 机制使用了线程池来管理任务的执行,通过TaskScheduler可以高效地重用线程,避免频繁创建和销毁线程的开销。这意味着在使用 Task 时,不需要为每个任务创建一个新线程,而是可以共享线程池中的线程,从而减少了线程创建和销毁的开销。然而,由于线程的调度和切换仍然需要操作系统的支持,因此会有一定的开销。

相比之下,Golang协程机制使用了 M:N 调度模型,它可以将多个协程映射到少量的操作系统线程上,并在这些线程之间动态地进行调度。Golang 的协程非常轻量级,可以创建成千上万个协程而不会造成太大的开销。协程的调度和切换是由 Golang 的运行时系统自己控制的,因此不存在操作系统线程调度的开销。

在开销方面,Go 的 Goroutine 通常具有更小的启动和上下文切换开销,以及更少的资源占用。这主要是因为 Go 的 Goroutine 是轻量级的,而 C# 的 Task 是基于线程的。然而,C# 的 Task 具有类型安全和丰富的 API 支持等优点。

同时创建一万个task或者协程,在其内部是怎么处理的?

c#创建大量Task时,需要注意避免线程资源的过度消耗和上下文切换的开销。为此,C#提供了async和await关键字以及ConfigureAwait(false)方法来帮助优化异步操作的管理,通过合理使用async和await,避免线程阻塞后,可以减少不必要的同步上下文切换和资源占用。

class Program  
{  
    static async Task Main(string[] args)  
    {  
        for (int i = 0; i < 10000; i++)  
        {  
            await Task.Run(() =>  
            {
                Console.WriteLine($"Task {i} is running on thread {Task.CurrentId}"); // 任务执行的代码  
            });  
        }  
    }  
}

创建大量Goroutine时,Go运行时会根据系统的能力和并发模型来自动管理和调度。你不需要显式地管理线程或上下文切换,Go运行时会为你处理这些细节。

func main() {  
    for i := 0; i < 10000; i++ {  
        go func(i int) {  
            fmt.Printf("Goroutine %d is running\n", i)  
        }(i)  
    }  
}

使用大量 Task 时最佳实践:

  • 使用异步方法:在创建 Task 的同时,使用异步方法来执行任务(async await)。这样可以避免阻塞主线程,提高应用程序的响应性能。

  • 设置最大并发级别:通过设置 TaskScheduler.MaximumConcurrencyLevel 属性,限制同时执行的任务数量,以避免资源竞争和过度并发导致的性能下降。

  • 使用 Task.WhenAll 方法:当你需要等待所有任务完成时,可以使用 Task.WhenAll 方法来等待多个任务同时完成。这样可以提高整体的执行效率。

  • 使用 Task.Factory.StartNew 方法创建任务Task.Factory.StartNew 方法可以方便地创建任务,并指定任务执行的方式和调度器。可以使用该方法创建并发执行的任务。

  • 错误处理与取消操作:在使用大量的 Task 时,需要考虑错误处理和取消操作。可以使用 try-catch 块捕获异常,并使用 CancellationTokenSource 来取消任务。

  • 考虑任务优先级:如果有任务之间存在优先级关系,可以使用 TaskCreationOptions 枚举中的 TaskCreationOptions.LongRunning 选项来指定任务的优先级。

  • 考虑任务调度器:根据具体的场景和需求,可以使用不同的 TaskScheduler 来进行任务调度,例如 ThreadPoolTaskScheduler自定义的任务调度器

  • 避免过度创建任务:在创建大量任务时,需要考虑任务的数量和资源的限制。避免过度创建任务导致资源耗尽或性能下降。

  • 使用并行 LINQ:如果任务是对集合进行并行处理,可以考虑使用并行 LINQ(PLINQ)来简化代码,并实现自动的并行执行。

  • 对任务进行监控和性能优化:使用一些性能分析工具和监控工具,对任务的执行情况进行监控和性能优化,以提高整体的执行效率。

System.Threading.Tasks.TaskScheduler类

从字面意思理解就是任务调度器,即将任务排队到线程中执行的管理器。并且能控制最大的并行任务数量。其本身是一个抽象类,其官方实现的类有如下几个:

  • 线程池任务调度器:ThreadPoolTaskScheduler是Task默认任务调度器。
  • 核心库任务调度器:ConcurrentExclusiveSchedulerPair 这里Concurrent是“并发”的意思,Exclusive是“独占”的意思。
  • UI任务调度器:SynchronizationContextTaskScheduler,并发度为1。
  • 自定义TaskScheduler:可实现更为细致和任意的任务调度算法。

扩展阅读

Task机制
C# Task和async/await详解
5天玩转C#并行和多线程编程
C#中async/await的线程ID变化情况
Async/Await在 C#语言中是如何工作的
C#.NET理解Task和async await原理
【C# TAP 异步编程】三、async\await的运作机理详解
异步返回类型 (C#)
深入探讨 C# 和 .NET 中 async/await 的历史、背后的设计决策和实现细节
官方文档:TaskScheduler类

此处评论已关闭