C#并行编程:任务并行

任务并行是PFX中最底层的并行化方式。相关的类定义在System.Threading.Tasks命名空间,其中包括:

  • Task:管理一个工作单元;
  • Task<TResult>:管理一个带有返回值的工作单元;
  • TaskFactory:创建任务;
  • TaskFactory<TResult>:创建具有指定返回类型的任务和延续;
  • TaskScheduler:管理任务的调度;
  • TaskCompletionSource:手动控制任务的工作流;

1.创建并启动任务

Task.Run方法会创建并启动一个Task或者Task<TResult>对象。这个方法实际上和Task.Factory.StartNew是等价的。但后者具有更多的重载版本,也更灵活。

指定状态对象

Task.Factory.StartNew方法可以指定一个状态对象,这个对象会作为参数传递给目标方法,因此目标方法的签名也必须包含一个object类型的参数:

static void Main()
{
    var task = Task.Factory.StartNew(Greet, "hello");
    task.Wait();    // 等待任务完成
    Console.ReadKey();
}

static void Greet(object state)
{
    Console.WriteLine(state);
}

上述方式可以避免在Lambda表达式中直接调用Greet而造成闭包开销。这种优化并不明显,因此实践中很少使用。但我们可以利用状态对象给任务指定一个有意义的名称。之后就可以使用AsyncState属性查询这个名称了:

static void Main()
{
    var task = Task.Factory.StartNew(state => Greet("hello"), "Greeting");
    Console.WriteLine(task.AsyncState);
    task.Wait();    // 等待任务完成
    Console.ReadKey();
}
static void Greet(object state)
{
    Console.WriteLine(state);
}

TaskCreationOptions

在调用StartNew方法或实例化Task对象时,可以指定一个TaskCreationOptions枚举值来调整任务的执行方式。TaskCreationOptions是一个标志枚举类型。它包含以下这些可以组合的值:

  • LongRunning:会通知调度器为任务指定一个线程。这种方式非常适合I/O密集型任务和长时间执行的任务。如果不这样做,那么那些执行时间很短的任务反而可能需要等待很长的时间才能被调度。
  • PreferFairness:会令任务调度器的调度顺序尽可能和任务的启动顺序一致。但通常它会采用另外一种方式,即使用一个本地工作窃取队列来进行内部任务调度优化。这种优化可以在不增加竞争开销的情况下创建子任务(而如果只使用一个单一队列则不然)。其中,子任务在创建时指定了AttachedToParent选项。
  • AttachedToParent:子任务。

子任务

当一个任务启动另一个任务时,可以确定它们的父子任务关系:

Task parent = Task.Factory.StartNew(() =>
{
    Console.WriteLine("父任务");
    
    Task.Factory.StartNew(() =>
    {
        Console.WriteLine("单独任务");
    });
    
    Task.Factory.StartNew(() =>
    {
        Console.WriteLine("子任务");
    },TaskCreationOptions.AttachedToParent);
});

子任务是一类特殊的任务,因为父任务必须在所有子任务结束后才能结束。父任务结束时,子任务中发生的异常才会向上抛出

TaskCreationOptions atp = TaskCreationOptions.AttachedToParent;
var parent = Task.Factory.StartNew(() =>
{
    Task.Factory.StartNew(()=>
    {
        Task.Factory.StartNew(() =>
        {
            throw null;
        }, atp);
    });
});
// 下面的调用会抛出空异常
parent.Wait();

2.等待多个任务

若要等待一个任务,可以调用它的Wait方法,也可以访问Result属性。我们也可以调用静态方法Task.WaitAll(等待所有任务执行结束)和Task.WaitAny(等待任意一个任务执行结束)同时等待多个任务。

WaitAll方法类似于轮流等待每一个任务,但是它的效率更高,因为它至多只需要进行一次上下文切换。此外,如果有一个或者多个任务抛出了未处理的异常,WaitAll仍然会等待所有任务完成,然后再组合所有失败任务的异常,重新抛出一个AggregateException。以上过程相当于:

var t1 = Task.Run(() =>{ throw null;});
var t2 = Task.Run(() =>{ throw new Exception("这是一个异常");});

var exceptions = new List<Exception>();
try { t1.Wait(); } catch (AggregateException ex) { exceptions.Add(ex); }
try { t2.Wait(); } catch (AggregateException ex) { exceptions.Add(ex); }

if (exceptions.Count > 0)
{
    throw new AggregateException(exceptions);
}

调用WaitAny相当于等待一个ManualResetEventSlim对象。这个对象会在任意一个任务完成时触发。

3.取消任务

在启动任务时,我们可以传入一个取消令牌。若通过该令牌执行取消操作,则任务本身就会进入“已取消”状态。
TaskCanceledException是OperationCanceledException的子类。

var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
cts.CancelAfter(500);

Task task = Task.Factory.StartNew(() =>
{
    Thread.Sleep(1000);
    token.ThrowIfCancellationRequested();  // 检查取消请求
}, token);

try { task.Wait(); }
catch (AggregateException ex)
{
    Console.WriteLine(ex.InnerException is TaskCanceledException);  // True
    Console.WriteLine(task.IsCanceled);                             // True
    Console.WriteLine(task.Status);                             // Canceled
}

4.延续任务

ContinueWith方法将在任务执行完毕后立即执行一个委托。

Task task1 = Task.Factory.StartNew (() => Console.Write ("antecedant.."));
Task task2 = task1.ContinueWith (ant => Console.Write ("..continuation"));

一旦task1(前导任务)结束、失败或被取消,task2(延续任务)就开始执行。(如果task1在第二行代码之前就已执行完毕,则task2会立即开始执行。)延续任务中的Lambda表达式的ant参数是对前导任务的引用。ContinueWith方法本身会返回一个任务,因此其后可以添加更多的延续任务。

默认情况下,前导任务和延续任务可能会在不同的线程上执行。如果希望它们在同一线程上执行,则在调用ContinueWith方法时指定TaskContinuationOptions.ExExecuteSynchronously选项。这种方式有助于降低细粒度延续的中间过程从而改善性能。

延续任务和Task<TResult>

延续任务和普通任务一样,其类型也可以是Task<TResult>类型并返回数据。下面的示例将使用一串任务来计算Math.Sqrt(8 * 2),并输出结果:

Task.Factory.StartNew<int> (() => 8)
    .ContinueWith (ant => ant.Result * 2)
    .ContinueWith (ant => Math.Sqrt (ant.Result))
    .ContinueWith (ant => Console.WriteLine (ant.Result));   // 4

延续任务和异常

延续任务可以查询前导任务的Exception属性来确认前导任务是否已经失败。如果前导任务失败,而且延续任务既不确认,也不获得前导任务的结果,则前导任务的异常就成为未观测异常。之后,当垃圾回收器回收前导任务时就会触发TaskScheduler.UnobservedTaskException事件。

重新抛出前导任务的异常是一种安全的处理方式。只要有程序调用延续任务的Wait方法,该异常就会继续传播,并在Wait方法中重新抛出:

Task continuation = Task.Factory.StartNew(()=> { throw null; })
    .ContinueWith (ant => { ant.Wait(); });
continuation.Wait();

另一种处理异常的方式是为异常和正常的结果指定不同的延续任务。指定TaskContinuationOptions就可以做到这一点:

Task task1 = Task.Factory.StartNew (() => { throw null; });

Task error = task1.ContinueWith (ant => Console.Write (ant.Exception),
                                 TaskContinuationOptions.OnlyOnFaulted);
Task ok = task1.ContinueWith (ant => Console.Write ("Success!"),
                              TaskContinuationOptions.NotOnFaulted);
error.Wait();

以下扩展方法将忽略任务的未处理异常。

void Main()
{
    Task.Factory.StartNew (() => { throw null; }).IgnoreExceptions();
}

static class Extensions
{
    public static void IgnoreExceptions (this Task task)
    {
        // 可以通过添加记录异常的代码来改进这一点
        task.ContinueWith (t => { var ignore = t.Exception; },
                           TaskContinuationOptions.OnlyOnFaulted);
    } 
}

延续任务与子任务

延续任务有一个非常重要的特性:在所有子任务完成后它才开始执行。这时,子任务抛出的所有异常都会封送到延续任务中。
image

下面的示例启动了三个子任务,每一个子任务都抛出NullReferenceException。然后在父任务的延续任务中一次性捕获所有的异常:

TaskCreationOptions atp = TaskCreationOptions.AttachedToParent;
Task.Factory.StartNew (() =>
{
    Task.Factory.StartNew (() => { throw null; }, atp);
    Task.Factory.StartNew (() => { throw null; }, atp);
    Task.Factory.StartNew (() => { throw null; }, atp);
})
.ContinueWith (p => Console.WriteLine (p.Exception),
               TaskContinuationOptions.OnlyOnFaulted);

具有多个前导任务的延续任务

使用TaskFactory的ContinueWhenAll和ContinueWhenAny方法就可以从多个前导任务调度延续任务。
但是,在任务组合器(WhenAll和WhenAny)引入之后,这些方法就显得多余了。
例如,对于以下任务,我们可以在上述任务都完成时调度一个延续任务。

var task1 = Task.Run(() => Console.Write("x"));
var task2 = Task.Run(() => Console.Write("y"));

var continuation = Task.Factory.ContinueWhenAll(
    new[] { task1, task2 }, tasks => Console.WriteLine("Done"));

也可以用任务组合器达到相同效果。

var continuation = Task.WhenAll(task1,task2)
    .ContinueWith(ant => Console.WriteLine("Done"));

单一前导任务的多个延续任务

在相同的任务上多次调用ContinueWith就可以在一个前导任务上创建多个延续任务。
当前导任务结束时,所有延续任务会一同开始执行。
例如,以下代码会先等待一秒钟,而后输出XY或者YX:

var t = Task.Factory.StarNew(() => Thread.Sleep(1000));
t.ContinueWith(ant => Console.Write("x"));
t.ContinueWith(ant => Console.Write("y"));

5.任务调度器

任务调度器负责将任务分配到线程。任务调度器是由抽象类TaskScheduler表示的。.NET Core提供了两个具体的任务调度器的实现:与CLR的线程池协同工作的默认调度器,以及同步上下文调度器。后者(主要)是为了和WPF以及Windows Forms这样的线程模型(这种线程模型规定用户界面元素或控件的访问操作只能在创建它们的线程中执行)共同工作而设计的。它会捕获同步上下文,并令任务或延续任务在这个上下文中执行:

_uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();

假设Foo是一个计算密集型的方法,该方法会返回一个字符串。同时,lblResult是一个WPF或Windows Forms的标签控件。那么可以使用以下程序在操作完成之后安全地更新标签的内容。

Task.Run(() => Foo())
    .ContinueWith(ant => lblResult.Content = ant.Result, _uiScheduler);

实际上,大多数此类操作是使用C#的异步函数来实现的。我们也可以从TaskScheduler派生子类,编写自定义任务调度器。当然只有在非常特殊的情况下才会编写任务调度器。对于一般的自定义调度,使用TaskCompletionSource就足够了。

6.TaskFactory类

访问Task.Factory静态属性将返回一个默认的TaskFactory对象。TaskFactory的作用是创建任务,更确切地说是创建以下三种任务:

  • “普通”任务(调用StartNew)
  • 具有多个前导任务的延续任务(调用ContinueWhenAll与ContinueWhenAny)
  • 将符合异步编程模型(APM)的方法包装为任务

另一种创建任务的方法是实例化一个Task对象而后调用Start。但是,这样只能创建“普通”任务,不能创建延续任务。

创建自定义任务工厂

TaskFactory不是一个抽象工厂,因此当需要重复使用非典型的TaskCreationOptions、TaskContinuationOptions以及TaskScheduler设置创建任务时,就可以真正创建一个TaskFactory实例。
例如,若需要重复创建运行时间长,且需要附加到父任务上的任务,则可以用如下方式创建自定义任务工厂对象,接下来,只需要在工厂对象上调用StartNew就可以创建任务了。

var factory = new TaskFactory(
    TaskCreationOptions.LongRunning | TaskCreationOptions.AttachedToParent,
    TaskContinuationOptions.None);

Task task1 = factory.StartNew(Method1);
Task task1 = factory.StartNew(Method2);
......

当调用ContinueWhenAll和ContinueWhenAny时,自定义的TaskContinuationOptions就会生效。

posted @ 2022-09-07 17:32  一纸年华  阅读(1339)  评论(0编辑  收藏  举报