14.并发与异步 - 2.任务Task -《果壳中的c#》

线程是创建并发的底层工具,因此具有一定的局限性。
- 没有简单的方法可以从联合(Join)线程得到“返回值”。因此必须创建一些共享域。当抛出一个异常时,捕捉和处理异常也是麻烦的。
- 线程完成之后,无法再次启动该线程。相反,只能联合(Join)它(在进程阻塞当前线程)。
与线程相比,Task
是一个更高级的抽象概念,它标识一个通过或不通过线程实现的并发操作。
任务是可组合的——使用延续将它们串联在一起。它们可以使用线程池减少启动延迟,而且它们可以通过TaskCompletionSource
使用回调方法,避免多个线程同时等待I/O密集操作。
14.3.1 启动任务#
从Framework 4.5开始,启动一个由后台线程实现的Task,最简单的方法是使用静态方法Task.Run。调用时需要传入一个Action代理:
Task.Run(() => Console.WriteLine("hello"));
Task.Run
是Framework 4.5新引入的方法,在Framework 4.0中,调用Task.Factory.StartNew
,可以实现相同效果,前者相当于后者的快捷方式。
Task默认使用线程池,它们都是后台线程。意味当主线程结束时,所有任务都会随之停止。因此,要在控制台应用程序中运行这些例子,必须在启动任务之后阻塞主线程。例如,挂起(Waiting)该让你误,或者调用Console.ReadLine:
static void Main(string[] args)
{
Task.Run(() => Console.WriteLine("Foo"));
Console.ReadLine();
}
采用这种方式调用Task.Run,与下面启动线程方式类似(唯一不同的是没有隐含使用线程池):
new Thread(() => Console.WriteLine("Foo")).Start();
Task.Run会返回一个Task对象,它可以用来监控任务执行过程,这一点与Thread对象不同。(这里没有调用Start
,因为Task.Run
创建是“热”任务;相反,想创建“冷”任务,必须使用Task构造函数
,但这种方法在实践中很少用)
任务的
Status
属性可用于跟踪任务的执行状态。
1.等待(Wait)#
调用Wait
方法,可以阻塞任务,直至任务完成,效果等同于Thread.Join
:
Task task = Task.Run(() =>
{
Thread.Sleep(2000);
Console.WriteLine("Foo");
});
Console.WriteLine(task.IsCompleted); //False
task.Wait();//阻塞,直至任务完成
Console.WriteLine(task.IsCompleted); //True
Console.ReadLine();
可以在Wait中指定一个超时时间和一个取消令牌。
2.长任务#
默认情况下,CLR会运行在池化线程上,这种线程非常适合执行短计算密集作业。如果要执行长阻塞操作,则可以按下面方式避免使用池化线程:
Task task = Task.Factory.StartNew(() =>
{
Console.WriteLine("Task started");
Thread.Sleep(2000);
Console.WriteLine("Foo");
}, TaskCreationOptions.LongRunning);
task.Wait(); // Blocks until task is complete
提示:
在池化线程上运行一个长任务问题并不大,但是如果要同时运行多个长任务(特别会阻塞的任务),则会对性能产生影响。在这种情况下,通常更好的方法是使用TaskCreationOptions.LongRunning
:
- 如果运行I/O密集任务,则可以使用
TaskCompletionSource
和异步函数,通过回调函数(延续)实现并发性,而不通过线程实现。- 如果是运行计算密集任务,则可以使用一个生产者/消费者队列,控制这些任务的并发数量,避免出现线程和进程阻塞的问题。
14.3.2 返回值#
Task<TResult>
允许任务返回一个值。调用Task.Run
,传入一个Func<TResult>
代理(或者兼容的Lambda表达式),代替Action,就可以获得一个Task
Task<int> task = Task.Run (() => { Console.WriteLine ("Foo"); return 3; });
int result = task.Result; // Blocks if not already finished
Console.WriteLine (result); // 3
下面的例子创建一个任务,它使用LINQ就按前3百万个整数(从2开始)中的素数个数:
Task<int> primeNumberTask = Task.Run(() =>
Enumerable.Range(2, 3000000).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0)));
Console.WriteLine("Task running...");
Console.WriteLine("The answer is " + primeNumberTask.Result);
这段代码会打印“Task running...”,然后几秒钟后打印216815。
14.3.3 异常#
如果任务中的代码抛出一个未处理异常(换言之,如果你的任务出错(fault) ) ,那么调用 Wait()
或者访问 Task<TResult>
的 Result
属性时,该异常就会被重新抛出
Task task = Task.Run(() => { throw null; });
try
{
task.Wait();
}
catch (AggregateException aex)
{
if (aex.InnerException is NullReferenceException)
Console.WriteLine("Null!");
else
throw;
}
为了适应并行编程场景,CLR 会将异常包装为一个
AggregateException
。
使用 Task 的 IsFaulted
和 IsCanceled
属性可以在不抛出异常的情况下检测出错的任务。如果这两个属性都返回了 false 则说明没有错误发生。
- 如果
IsCanceled
为 true,则说明任务抛出了OperationCanceledException
(请参见 14.6.1 节) ;- 如果
IsFaulted
为true,则说明任务抛出了其他类型的异常,通过Exception
属性可以了解该异常的信息。
异常和“自治”的任务#
- 自治的,“设置完就不管了”的 Task。就是指不通过调用Wait()方法、Result属性或continuation进行会合的任务。
- 针对自治的Task,需要像Thread 一样,显式的处理异常,避免发生“悄无声息的故障”。
- 自治Task上未处理的异常称为未观察到的异常。
未观察到的异常#
使用静态事件 TaskScheduler.UnobservedTaskException
可以在全局范围订阅未观测的异常。处理这个事件,并将错误记录在日志中,是一个有效的处理异常的方式。
未观测异常之间也存在一些细微的差异:
- 如果在等待任务时设置了超时时间,则在超时时间之后发生的错误将产生”未观测异常“。
- 在错误发生之后,如果检查任务的 Exception 属性,则该异常就成为了“已观测到的异常”。
14.3.4 延续#
延续会告知任务在完成之后继续执行后续的操作。延续通常由一个回调方法实现,该方法会在操作完成之后执行。
给一个任务附加延续的方法有两种。
第一种方法:GetAwaiter回调#
是使用.NET Framework 4.5 中引入的,它非常重要,因为C#的异步功能正是使用了这种方法。
Task<int> primeNumberTask = Task.Run (() =>
Enumerable.Range (2, 3000000).Count (n => Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));
//获取用于等待此 System.Threading.Tasks.Task<TResult>的等待者
var awaiter = primeNumberTask.GetAwaiter();
//将操作设置为当 System.Runtime.CompilerServices.TaskAwaiter<TResult> 对象停止等待异步任务完成时执行
awaiter.OnCompleted (() =>
{
int result = awaiter.GetResult(); //异步任务完成后关闭等待任务
Console.WriteLine (result); //打印结果
});
-
在
task
上调用GetAwaiter
会返回一个awaiter
对象- 它的
OnCompleted
方法会告诉之前的task
:“当你结束/发生故障的时候要执行委托”。
- 它的
-
可以将
Continuation
附加到已经结束的task
上面,此时Continuation
将会被安排立即执行。
awaiter
可以是任意暴露了
OnCompleted
和GetResult
方法和IsCompleted
属性的对象。它不需要实现特定的接口或者继承特定基类来统一这些成员(实际上 OnCompleted 是 INotifyCompletion 接口的一部分)
发生故障
-
如果先导任务出现错误,则当延续代码调用
awaiter.GetResult()
的时候将会重新抛出异常。 -
当然我们也可以访问先导任务的
Result
属性而不是调用GetResult
方法。 -
但如果先导任务失败,则调用
GetResult
方法就可以直接得到原始的异常,而不是包装后的AggregateException
。因此,这种方式可以实现更加简洁清晰的 catch 代码块。
非泛型的 task
对于非泛型任务,GetResult 的返回值为 void
,而这个函数的用途完全是为了重新抛出异常。
同步上下文
-
如果提供了同步上下文,则
OnCompleted
就会自动捕获它,并将延续提交到这个上下文中。这对于富客户端应用程序来说非常重要,因为这意味着将延续放回 UI 线程中。 -
但如果编写的是一个程序库,则通常不希望出现上述行为。因为开销较大的 UI 线程切换应当在程序运行离开程序库时发生一次,而不是出现在方法调用之间。我们可以使用 ConfigureAwait 方法来避免这种行为:
var awaiter = primeNumberTask.ConfigureAwait(false).GetAwaiter();
- 如果并未提供任何同步上下文,或者调用了
ConfigureAwait(false)
,延续代码一般会运行在先导任务运行的线程上,从而避免不必要的开销。
第二种附加延续的方法是调用任务的ContinueWith
方法#
-
ContinueWith
方法本身会返回一个 Task 对象,因此它非常适用于添加更多的延续。然而,如果任务出现错误,则我们必须直接处理AggregateException
;如果需要将延续封送到 UI 应用程序上还需要书写额外的代码(请参见 23.4.5 节) 。 -
而在非 UI 上下文下,若希望延续任务和先导任务执行在同一个线程上,还需要指定
TaskContinuationOptions.ExecuteSynchronously
。否则的话,它就会去请求线程池。ContinueWith
更适用于并行编程场景,我们将在 23.4.4 节中介绍。
Task<int> primeNumberTask = Task.Run (() =>
Enumerable.Range (2, 3000000).Count (n => Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));
primeNumberTask.ContinueWith (antecedent =>
{
int result = antecedent.Result;
Console.WriteLine (result); // Writes 123
});
14.3.5 TaskCompletionSource#
前面介绍Task.Run
如何创建一个在池化(或非池化)线程运行代理的任务。另一种就是TaskCompletionSource
。
TaskCompletionSource
可以创建任务,让你在稍后开始和结束的任意操作中创建Task。
-
原理是提供一个可以手工操作的“附属”任务——指示操作何时结束或者故障。
-
它对 I/O 密集型的工作比较理想
- 可以获得所有Task的好处(传播值、异常、Continuation等)
- 不需要在操作时阻塞线程
TaskCompletionSource 的用法#
- 很简单,直接进行实例化即可。
- 它包含一个 Task 属性,返回一个 Task 对象。我们可以等待这个对象,也可以和其他的所有任务一样,在其上附加延续。
- 然而,这个任务完全通过下面的方法由TaskCompletionSource对象控制:
public class TaskCompletionSource<TResult>
{
public void SetCanceled();
public void SetResult(TResult result);
public void SetException(Exception exception);
public bool TrySetCanceled();
public bool TrySetException(Exception exception);
...
}
调用这些方法可以给任务发送信号,将任务修改为完成、异常或取消状态。
- 这些方法只能调用一次,如果多次调用
SetCanceled
、SetResult
或SetException
,将抛出异常,而Try***
等方法则会返回false(可以多次调用)。
var tcs = new TaskCompletionSource<int>();
new Thread(() => { Thread.Sleep(5000); tcs.SetResult(42); }).Start();
Task<int> task = tcs.Task; // Our "slave" task.
Console.WriteLine(task.Result); // 42
使用TaskCompletionSource
,可以编写自定义的Run
方法:
static void Main(string[] args)
{
Task<int> task = Run(() => { Thread.Sleep(5000); return 42; });
Console.WriteLine(task.Result);
Console.Read();
}
static Task<TResult> Run<TResult>(Func<TResult> function)
{
var tcs = new TaskCompletionSource<TResult>();
new Thread(() =>
{
try { tcs.SetResult(function()); }
catch (Exception ex) { tcs.SetException(ex); }
}).Start();
return tcs.Task;
}
调用这个方法等同于使用TaskCreationOptions.LongRunning
选项调用Task.Factory.StartNew
,请求一个非线程池线程。
TaskCompletionSource 的真正作用#
TaskCompletionSource
真正作用是创建一个不绑定线程的任务(不占用线程)。
例如,假设一个任务需要等待5秒钟,然后返回数字42.我们可以使用Timer
类实现,而不需要使用线程,由CLR在x毫秒之后触发一个事件:
static void Main(string[] args)
{
var awaiter = GetAnswerToLife().GetAwaiter();
awaiter.OnCompleted(() => Console.WriteLine(awaiter.GetResult()));
}
static Task<int> GetAnswerToLife()
{
var tcs = new TaskCompletionSource<int>();
// Create a timer that fires once in 5000 ms:
var timer = new System.Timers.Timer(5000) { AutoReset = false };
timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult(42); };
timer.Start();
return tcs.Task;
}
通过给任务附加一个延续,就可以在不阻塞任何线程的前提下打印这个结果。
var awaiter = GetAnswerToLife().GetAwaiter();
awaiter.OnCompleted(() => Console.WriteLine(awaiter.GetResult()));
将延迟时间参数化,并且删除返回值,可以优化这段代码。并且将它变成一个通用的Delay
方法。意味让它返回一个Task
而不是Task<int>
。然而,TaskCompletionSource
没有泛型版本,因此无法创建一个非泛型任务。但变通方法很简单:因为Task<TResult>
派生自Task
,所以创建一个TaskCompletionSource<anything>
,然后将它隐式转换为Task<anything>
,就可以得到一个Task:
var tcs = new TaskCompletionSource<object>();
Task task = tcs.Task;
写出Delay
方法,然后让它5秒打印“42”:
static void Main(string[] args)
{
Delay(5000).GetAwaiter().OnCompleted(() => Console.WriteLine(42));
Console.Read();
}
static Task Delay(int milliseconds)
{
var tcs = new TaskCompletionSource<object>();
var timer = new System.Timers.Timer(milliseconds) { AutoReset = false };
timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult(null); };
timer.Start();
return tcs.Task;
}
不在线程上使用TaskCompletionSource
,意味着只有在延续启动时才创建线程。同时启动10000个这种操作,而不会出错或超出资源限制:
for (int i = 0; i < 10000; i++)
Delay(5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));
14.3.6 Task.Delay#
Task.Delay
是Thread.Sleep
的异步版本
Task.Delay(5000).GetAwaiter().OnCompleted(()=>Console.WriteLine(42));
或者
Task.Delay(5000).ContinueWith(ant => Console.WriteLine(42));
作者:【唐】三三
出处:https://www.cnblogs.com/tangge/p/7231673.html
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具