C#的Task详解与async/await线程ID变化情况
Task详解
Task
是在ThreadPool
的基础上推出的。ThreadPool中有若干数量的线程(默认是CPU核心数2倍),如果有任务需要处理时,会从线程池中获取一个空闲的线程来执行任务,任务执行完毕后线程不会销毁,而是被线程池回收以供后续任务使用。当线程池中所有的线程都在忙碌时,又有新任务要处理时,线程池才会新建一个线程来处理该任务,如果线程数量达到设置的最大值,任务会排队,等待其他任务释放线程后再执行。
ThreadPool
相对于Thread
来说可以减少线程的创建,有效减小系统开销
;但是ThreadPool
不能控制线程的执行顺序,我们也不能获取线程池内线程取消/异常/完成的通知,即我们不能有效监控和控制线程池中的线程。
.net4.0
在ThreadPool
的基础上推出了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执行完毕执行ActionTask.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 方法必须返回
void
或Task
或Task<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类
最后更新于 2024-01-28 23:48:58 并被添加「」标签,已有 1258 位童鞋阅读过。
本站使用「署名 4.0 国际」创作共享协议,可自由转载、引用,但需署名作者且注明文章出处
此处评论已关闭