并发编程_C#

并发

同时做多件事情。

并发编程的几种形式

  • 多线程
  • 并行处理
  • 异步编程 (现代程序)
  • 响应式编程

多线程<并行处理>

多线程

  • 并发的一种形式,它采用多个线程来执行程序。

并行处理

  • 把正在执行的大量的任务分割成小块,分配给多个同时运行的线程。

异步编程

  • 并发的一种形式,它采用 future 模式或回调(callback)机制,以避免产生不必要的线程。

响应式编程

  • 一种声明式的编程模式,程序在该模式中对事件做出响应。

异步编程与相应式编程的区别

  异步编程意味着程序启动一个操作,而该操作将会在一段时间后完成。响应式编程与异步编程非常类似,不过它是基于异步事件(asynchronous event)的,而不是异步操作(asynchronous operation)。异步事件可以没有一个实际的 “开始”,可以在任何时间发生,并且可以发生多次,例如用户输入

异步编程概述

异步编程的两大好处

第一个好处是:对于面向终端用户的 GUI 程序:异步编程提高了响应能力。我们都遇到过在运行时会临时锁定界面的程序,异步编程可以使程序在执行任务时仍能响应用户的输入。

第二个好处是:对于服务器端应用:异步编程实现了可扩展性。服务器应用可以利用线程池满足其可扩展性,使用异步编程后,可扩展性通常可以提高一个数量级。

并行编程概述

  如果程序中有大量的计算任务,并且这些任务能分割成几个互相独立的任务块,那就应该使用并行编程。并行编程可临时提高CPU 利用率,以提高吞吐量,若客户端系统中的CPU 经常处于空闲状态,这个方法就非常有用,但通常并不适合服务器系统。大多数服务器本身具有并行处理能力

数据并行重点在处理数据,任务并行则关注执行任务

  • 并行的形式有两种:数据并行(data parallelism)和任务并行(task parallelim)

    • 数据并行是指有大量的数据需要处理,并且每一块数据的处理过程基本上是彼此独立的。

    • 任务并行是指需要执行大量任务,并且每个任务的执行过程基本上是彼此独立的。(任务并行可以是动态的,如果一个任务的执行结果会产生额外的任务,这些新增的任务也可以加入任务池。)

    • 实现数据并行有几种不同的做法:

      • 使用Parallel.ForEach 方法,它类似于foreach 循环,应尽可能使用这种做法
      • Parallel 类也提供Parallel.For 方法,这类似于for 循环,当数据处理过程基于一个索引时,可使用这个方法
      • 使用PLINQ(Parallel LINQ), 它为LINQ 查询提供了AsParallel 扩展。跟PLINQ 相比,Parallel 对资源更加友好,Parallel 与系统中的其他进程配合得比较好, 而PLINQ 会试图让所有的CPU 来执行本进程。Parallel 的缺点是它太明显。很多情况下,PLINQ 的代码更加优美。
      // Parallel.ForEach 例子
      void RotateMatrices(IEnumerable<Matrix> matrices, float degrees)
      {
      Parallel.ForEach(matrices, matrix => matrix.Rotate(degrees));
      }
      
      // PLINQ 例子
      IEnumerable<bool> PrimalityTest(IEnumerable<int> values)
      {
      return values.AsParallel().Select(val => IsPrime(val));
      }
      
    • 在并行处理时有一个非常重要的准则:每个任务块要尽可能的互相独立

响应式编程概述

  跟并发编程的其他形式相比,响应式编程的学习难度较大。如果对响应式编程不是非常熟悉,代码维护相对会更难一点。一旦你学会了,就会发现响应式编程的功能特别强大。响应式编程可以像处理数据流一样处理事件流。根据经验,如果事件中带有参数,那么最好采用响应式编程,而不是常规的事件处理程序

响应式编程基于**“可观察的流”(observable stream)这一概念。你一旦申请了可观察流,就可以收到任意数量的数据项(OnNext),并且流在结束时会发出一个错误(OnError)或一个“流结束”的通知(OnCompleted)**。有些可观察流是不会结束的。

// 实际的接口 微软的Reactive Extensions(Rx)库已经实现了所有接口
interface IObserver<in T>
{
    void OnNext(T item);
    void OnCompleted();
    void OnError(Exception error);
}
interface IObservable<out T>
{
	IDisposable Subscribe(IObserver<T> observer);
}
  • 响应式编程的最终代码非常像 LINQ,可以认为它就是“LINQ to events”
// 例子① Interval(计数器) TimeSpan(时间戳)
Observable.Interval(TimeSpan.FromSeconds(1))
    .Timestamp()
    .Where(x => x.Value % 2 == 0)
    .Select(x => x.Timestamp)
    .Subscribe(x => Trace.WriteLine(x));

现在只要记住这是一个LINQ 查询,与你以前见过的LINQ 查询很类似。主要区别在于:LINQ to Object和LINQ to Entity 使用“拉取”模式,LINQ 的枚举通过查询拉出数据。而LINQ to event(Rx)使用“推送”模式,事件到达后就自行穿过查询。

  • 可观察流的定义和其订阅是互相独立的
// 与例子①等效
IObservable<DateTimeOffset> timestamps =
    Observable.Interval(TimeSpan.FromSeconds(1))
    .Timestamp()
    .Where(x => x.Value % 2 == 0)
    .Select(x => x.Timestamp);
timestamps.Subscribe(x => Trace.WriteLine(x));
  • 一种常规的做法是把可观察流定义为一种类型,然后将其作为IObservable<T> 资源使用。其他类型可以订阅这些流,或者把这些流与其他操作符组合,创建另一个可观察流。
// 带有处理错误参数
Observable.Interval(TimeSpan.FromSeconds(1))
    .Timestamp()
    .Where(x => x.Value % 2 == 0)
    .Select(x => x.Timestamp)
    .Subscribe(x => Trace.WriteLine(x),
    ex => Trace.WriteLine(ex));

数据流概述

  • TPL 数据流很有意思,它把异步编程和并行编程这两种技术结合起来。如果需要对数据进行一连串的处理,TPL 数据流就很有用
  • TPL 数据流通常作为一个简易的管道,数据从管道的一端进入,在管道中穿行,最后从另一端出来。不过,TPL 数据流的功能比普
    通管道要强大多了
  • 对于处理各种类型的网格(mesh),在网格中定义分叉(fork)、连接(join)、循环(loop)的工作,TPL 数据流都能正确地处理。当然了,大多数时候TPL 数据流网格还是被用作管道
  • 数据流网格的基本组成单元是数据流块(dataflow block)。数据流块可以是目标块(接收数据)或源块(生成数据),或两者皆可。源块可以连接到目标块,创建网格
  • 数据流块是半独立的,当数据到达时,数据流块会试图对数据进行处理,并且把处理结果推送给下一个流程
  • 使用TPL 数据流的常规方法是创建所有的块,再把它们链接起来,然后开始在一端填入数据。然后,数据会自行从另一端出来。再强调一次,数据流的功能比这要强大得多,数据穿过的同时,可能会断开连接、创建新的块并加入到网格,不过这是非常高级的使用场景

默认情况下,一个块出错不会摧毁整个网格。这让程序有能力重建部分网格,或者对数据重新定向。然而这是一个高级用法。通常来讲,你是希望这些错误通过链接传递给目标块。数据流也提供这个选择,唯一比较难办的地方是当异常通过链接传递时,它就会被封装在AggregateException 类中。因此,如果管道很长,最后异常的嵌套层次会非常多,这时就可以使用AggregateException.Flatten 方法:

try
{
	var multiplyBlock = new TransformBlock<int, int>(item =>
	{
        if (item == 1)
        throw new InvalidOperationException("Blech.");
        return item * 2;
	});
    var subtractBlock = new TransformBlock<int, int>(item => item - 2);
    multiplyBlock.LinkTo(subtractBlock,
    new DataflowLinkOptions { PropagateCompletion = true });
    multiplyBlock.Post(1);
    subtractBlock.Completion.Wait();
}
catch (AggregateException exception)
{
    AggregateException ex = exception.Flatten();
    Trace.WriteLine(ex.InnerException);
}
  • 数据流网格给人的第一印象是与可观察流非常类似,实际上它们确实有很多共同点

多线程编程概述

  • 线程是一个独立的运行单元,每个进程内部有多个线程,每个线程可以各自同时执行指令
  • 每个线程有自己独立的栈,但是与进程内的其他线程共享内存
  • 对某些程序来说,其中有一个线程是特殊的,例如用户界面程序有一个UI 线程,控制台程序有一个main 线程
  • 线程是低级别的抽象,线程池是稍微高级一点的抽象,当代码段遵循线程池的规则运行时,线程池就会在需要时创建线程
  • 并行和数据流的处理队列会根据情况遵循线程池运行。抽象级别更高,正确代码的编写就更容易

并发编程的集合

  • 并发编程所用到的集合有两类:并发集合不可变集合
  • 多个线程可以用安全的方式同时更新并发集合
  • 大多数并发集合使用快照(snapshot),当一个线程在增加或删除数据时,另一个线程也能枚举数据
  • 比起给常规集合加锁以保护数据的方式,采用并发集合的方式要高效得多
  • 不可变集合则有些不同。不可变集合实际上是无法修改的

要修改一个不可变集合,需要建立一个新的集合来代表这个被修改了的集合。这看起来效率非常低,但是不可变集合的各个实例之间尽可能多地共享存储区,因此实际上效率没想象得那么差。不可变集合的优点之一,就是所有的操作都是简洁的,因此特别适合在函数式代码中使用。

现代设计

大多数并发编程技术有一个类似点:它们本质上都是函数式(functional)的。这里“functional”的意思不是“实用,能完成任务”,而是把它作为一种基于函数组合的编程模式。如果你接受函数式的编程理念,并发编程的设计就会简单得多

  • 函数式编程的一个原则就是简洁(换言之,就是避免副作用)
  • 函数式编程的另一个原则是不变性。不变性是指一段数据是不能被修改的

在并发编程中使用不可变数据的原因之一,是程序永远不需要对不可变数据进行同步。数据不能修改,这一事实让同步变得没有必要。不可变数据也能避免副作用

  • RX 在NuGet 包 Rx-Main
  • TPL 官方版本在NuGet 包 Microsoft.Tpl.Dataflow
  • 不可变集合在NuGet 包 Microsoft.Bcl.Immutable

异步编程基础 (案例)

暂停一段时间

  • 需要让程序(以异步方式)等待一段时间。这在进行单元测试或者实现重试延迟时非常有用。本解决方案也能用于实现简单的超时
static async Task<T> DelayResult<T>(T result, TimeSpan delay)
{
    await Task.Delay(delay);
    return result;
}
  • 实现了一个简单的指数退避。指数退避是一种重试策略,重试的延迟时间会逐次增加。在访问Web 服务时,最好的方式就是采用指数退避,它可以防止服务器被太多的重试阻塞。
static async Task<string> DownloadStringWithRetries(string uri)
{
    using (var client = new HttpClient())
    {
        // 第1 次重试前等1 秒,第2 次等2 秒,第3 次等4 秒。
        var nextDelay = TimeSpan.FromSeconds(1);
        for (int i = 0; i != 3; ++i)
        {
            try
            {
                return await client.GetStringAsync(uri);
            }
            catch {}
        await Task.Delay(nextDelay);
        nextDelay = nextDelay + nextDelay;
        }
        // 最后重试一次,以便让调用者知道出错信息。
        return await client.GetStringAsync(uri);
    }
}
  • 用Task.Delay 实现一个简单的超时功能
// 如果服务在3秒内没有相应,则返回null
static async Task<string> DownloadStringWithTimeout(string uri)
{
    using (var client = new HttpClient())
    {
        var downloadTask = client.GetStringAsync(uri);
        var timeoutTask = Task.Delay(3000);
        var completedTask = await Task.WhenAny(downloadTask, timeoutTask);
        if (completedTask == timeoutTask)
        return null;
        return await downloadTask;
    }
}

Task.Delay 适合用于对异步代码进行单元测试或者实现重试逻辑。要实现超时功能的话,最好使用CancellationToken

返回完成的任务

  • 实现一个具有异步签名的同步方法
interface IMyAsyncInterface
{
	Task<int> GetValueAsync();
}
class MySynchronousImplementation : IMyAsyncInterface
{
    public Task<int> GetValueAsync()
    {
        return Task.FromResult(13);
    }
}
// 如果使用了Microsoft.Bcl.Async,FromResult 方法就在TaskEx 类中

报告进度

  • 异步操作执行的过程中,需要展示操作的进度
// 使用IProgress<T> 和Progress<T> 类型。编写的async 方法需要有IProgress<T> 参数,其中T 是需要报告的进度类型:
static async Task MyMethodAsync(IProgress<double> progress = null)
{
	double percentComplete = 0;
	while (!done)
	{
		...
		if (progress != null)
			progress.Report(percentComplete);
	}
}

// 调用上述方法的代码:
static async Task CallMyMethodAsync()
{
	var progress = new Progress<double>();
	progress.ProgressChanged += (sender, args) =>
	{
    	...
	};
    await MyMethodAsync(progress);
}
// 需要注意的是,IProgress<T>.Report 方法可以是异步的。这意味着真正报告进度之前,MyMethodAsync 方法会继续运行。基于这个原因,最好把T 定义为一个不可变类型,或者至少是值类型。如果T 是一个可变的引用类型,就必须在每次调用IProgress<T>.Report 时,创建一个单独的副本

等待一组任务完成

  • 执行几个任务,等待它们全部完成
// 解决方案1
Task task1 = Task.FromResult(3);
Task task2 = Task.FromResult(5);
Task task3 = Task.FromResult(7);
int[] results = await Task.WhenAll(task1, task2, task3);
// "results" 含有 { 3, 5, 7 }

// 解决方案2
// Task.WhenAll 方法有以IEnumerable 类型作为参数的重载,但建议大家不要使用。只要异步代码与LINQ 结合,显式的“具体化”序列(即对序列求值,创建集合)就会使代码更清晰:
static async Task<string> DownloadAllAsync(IEnumerable<string> urls)
{
    var httpClient = new HttpClient();
    // 定义每一个url 的使用方法。
    var downloads = urls.Select(url => httpClient.GetStringAsync(url));
    // 注意,到这里,序列还没有求值,所以所有任务都还没真正启动。
    // 下面,所有的URL 下载同步开始。
    Task<string>[] downloadTasks = downloads.ToArray();
    // 到这里,所有的任务已经开始执行了。
    // 用异步方式等待所有下载完成。
    string[] htmlPages = await Task.WhenAll(downloadTasks);
    return string.Concat(htmlPages);
}
// 如果使用Microsoft.Bcl.Async 这个NuGet 库,则WhenAll 是TaskEx 类的成员,而不是Task 类的成员

  如果有一个任务抛出异常,则Task.WhenAll 会出错,并把这个异常放在返回的Task 中。如果多个任务抛出异常,则这些异常都会放在返回的Task 中。但是,如果这个Task 在被await 调用,就只会抛出其中的一个异常。如果要得到每个异常,可以检查Task.WhenALl返回的Task 的Exception 属性:

static async Task ThrowNotImplementedExceptionAsync()
{
	throw new NotImplementedException();
}

static async Task ThrowInvalidOperationExceptionAsync()
{
	throw new InvalidOperationException();
}

static async Task ObserveOneExceptionAsync()
{
    var task1 = ThrowNotImplementedExceptionAsync();
    var task2 = ThrowInvalidOperationExceptionAsync();
    try
    {
    	await Task.WhenAll(task1, task2);
    }
    catch (Exception ex)
    {
        // ex 要么是NotImplementedException,要么是InvalidOperationException
        ...
    }
}

static async Task ObserveAllExceptionsAsync()
{
    var task1 = ThrowNotImplementedExceptionAsync();
    var task2 = ThrowInvalidOperationExceptionAsync();
    Task allTasks = Task.WhenAll(task1, task2);
    try
    {
        await allTasks;
    }
    catch
    {
        AggregateException allExceptions = allTasks.Exception;
        ...
    }
}
// 使用Task.WhenAll 时,通常情况下,只处理第一个错误就足够了,没必要处理全部错误。

等待任意一个任务完成

  • 执行若干个任务,只需要对其中任意一个的完成进行响应。这主要用于:对一个操作进行多种独立的尝试,只要一个尝试完成,任务就算完成。例如,同时向多个Web 服务询问股票价格,但是只关心第一个响应的。
// 使用Task.WhenAny 方法。该方法的参数是一批任务,当其中任意一个任务完成时就会返回。作为返回值的Task 对象,就是那个完成的任务

// 返回第一个响应的URL 的数据长度。
private static async Task<int> FirstRespondingUrlAsync(string urlA, string urlB)
{
    var httpClient = new HttpClient();
    // 并发地开始两个下载任务。
    Task<byte[]> downloadTaskA = httpClient.GetByteArrayAsync(urlA);
    Task<byte[]> downloadTaskB = httpClient.GetByteArrayAsync(urlB);
    // 等待任意一个任务完成。
    Task<byte[]> completedTask = await Task.WhenAny(downloadTaskA, downloadTaskB);
    // 返回从URL 得到的数据的长度。
    byte[] data = await completedTask;
    return data.Length;
}

任务完成时的处理

  • 正在await 一批任务,希望在每个任务完成时对它做一些处理。另外,希望在任务一完成就立即进行处理,而不需要等待其他任务。
static async Task<int> DelayAndReturnAsync(int val)
{
    await Task.Delay(TimeSpan.FromSeconds(val));
    return val;
}
// 当前,此方法输出 “2”, “3”, “1”。
// 我们希望它输出 “1”, “2”, “3”。
static async Task ProcessTasksAsync()
{
    // 创建任务队列。
    Task<int> taskA = DelayAndReturnAsync(2);
    Task<int> taskB = DelayAndReturnAsync(3);
    Task<int> taskC = DelayAndReturnAsync(1);
    var tasks = new[] { taskA, taskB, taskC };
    // 按顺序await 每个任务。
    foreach (var task in tasks)
	{
        var result = await task;
        Trace.WriteLine(result);
    }
}

// 不必等待其他任务 重构之后的代码(非唯一解决方案)
static async Task<int> DelayAndReturnAsync(int val)
{
    await Task.Delay(TimeSpan.FromSeconds(val));
    return val;
}
// 现在,这个方法输出 “1”, “2”, “3”。
static async Task ProcessTasksAsync()
{
    // 创建任务队列。
    Task<int> taskA = DelayAndReturnAsync(2);
    Task<int> taskB = DelayAndReturnAsync(3);
    Task<int> taskC = DelayAndReturnAsync(1);
    var tasks = new[] { taskA, taskB, taskC };
    
    var processingTasks = tasks.Select(async t =>
    {
        var result = await t;
        Trace.WriteLine(result);
    }).ToArray();
    // 等待全部处理过程的完成。
    await Task.WhenAll(processingTasks);
}

避免上下文延续

  • 在默认情况下,一个async 方法在被await 调用后恢复运行时,会在原来的上下文中运行。如果是UI 上下文,并且有大量的async 方法在UI 上下文中恢复,就会引起性能上的问题。
// 为了避免在上下文中恢复运行,可让await 调用ConfigureAwait 方法的返回值,参数 continueOnCapturedContext 设为false:
async Task ResumeOnContextAsync()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
    // 这个方法在同一个上下文中恢复运行。
}

async Task ResumeWithoutContextAsync()
{
    await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
    // 这个方法在恢复运行时,会丢弃上下文。
}

处理async Task方法的异常

static async Task ThrowExceptionAsync()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
    throw new InvalidOperationException("Test");
}
static async Task TestAsync()
{
    // 抛出异常并将其存储在Task 中。
    Task task = ThrowExceptionAsync();
    try
    {
        // Task 对象被await 调用,异常在这里再次被引发。
        await task;
    }
    catch (InvalidOperationException)
    {
        // 这里,异常被正确地捕获。
    }
}

处理从async void 方法传递出来的异常

sealed class MyAsyncCommand : ICommand
{
	async void ICommand.Execute(object parameter)
    {
    	await Execute(parameter);
    }
    public async Task Execute(object parameter)
    {
    	... // 这里实现异步操作。
    }
    ... // 其他成员(CanExecute 等)。
}

并行开发基础

数据的并行处理

  • 有一批数据,需要对每个元素进行相同的操作。该操作是计算密集型的,需要耗费一定的时间
// Parallel 类型有专门为此设计的ForEach 方法 对每一个矩阵都进行旋转:
void RotateMatrices(IEnumerable<Matrix> matrices, float degrees)
{
	Parallel.ForEach(matrices, matrix => matrix.Rotate(degrees));
}

// 发现有无效的矩阵,则中断循环
void InvertMatrices(IEnumerable<Matrix> matrices)
{
	Parallel.ForEach(matrices, (matrix, state) =>
    {
    if (!matrix.IsInvertible)
        state.Stop();
    else
        matrix.Invert();
    });
}

// 可以取消并行循环
void RotateMatrices(IEnumerable<Matrix> matrices, float degrees, CancellationToken token)
{
    Parallel.ForEach(matrices, 
                     new ParallelOptions { CancellationToken = token },
                     matrix => matrix.Rotate(degrees));
}

// 反转每个矩阵并统计无法反转的矩阵数量
int InvertMatrices(IEnumerable<Matrix> matrices)
{
    object mutex = new object();
    int nonInvertibleCount = 0;
    Parallel.ForEach(matrices, matrix =>
    {
    	if (matrix.IsInvertible)
			matrix.Invert();
        else
        	lock (mutex)
            {
            	++nonInvertibleCount;
            }
	});
    return nonInvertibleCount;
}
/*
	Parallel.ForEach 方法可以对一系列值进行并行处理。还有一个类似的解决方案,就是使用PLINQ(并行LINQ)。PLINQ 的大部分功能和Parallel 类一样,并且采用与LINQ 类似的语法。Parallel 类和PLINQ 之间有一个区别:PLINQ 假设可以使用计算机内所有的CPU 核,而Parallel 类则会根据CPU 状态的变化动态地调整
	Parallel.ForEach 是并行版本的foreach 循环。Parallel 类也提供了并行版本的for 循环,即Parallel.For 方法。如果有多个数组的数据,并且它们采用了相同的索引,Parallel.For 就特别适用
*/

并行聚合

  • 在并行操作结束时,需要聚合结果,包括累加和、平均值等。

Parallel 类通过局部值(local value)的概念来实现聚合,局部值就是只在并行循环内部存在的变量。这意味着循环体中的代码可以直接访问值,不需要担心同步问题。循环中的代码使用``LocalFinally委托来对每个局部值进行聚合。需要注意的是,ocalFinally` 委托需要以同步的方式对存放结果的变量进行访问

// 并行求累加和

// 注意,这不是最高效的实现方式。
// 只是举个例子,说明用锁来保护共享状态。
static int ParallelSum(IEnumerable<int> values)
{
    object mutex = new object();
    int result = 0;
    Parallel.ForEach(source: values,
    localInit: () => 0,
    body: (item, state, localValue) => localValue + item,
    localFinally: localValue =>
    {
        lock (mutex)
        	result += localValue;
        });
    return result;
}

// 并行LINQ 对聚合的支持,比Parallel 类更加顺手
static int ParallelSum(IEnumerable<int> values)
{
	return values.AsParallel().Sum();
}

// PLINQ也可通过Aggregate 实现通用的聚合功能
static int ParallelSum(IEnumerable<int> values)
{
    return values.AsParallel().Aggregate(
        seed: 0,
        func: (sum, item) => sum + item
    );
}

/*
	如果程序中已经在使用Parallel 类,则可使用它的聚合功能。否则,大多数情况下PLINQ 对聚合的支持更有表现力,代码也更少。
*/

并行调用

  • 需要并行调用一批方法,并且这些方法(大部分)是互相独立的。
// Parallel 类有一个简单的成员Invoke,就可用于这种场合。下面的例子将一个数组分为两半,并且分别独立处理:
static void ProcessArray(double[] array)
{
    Parallel.Invoke(
		() => ProcessPartialArray(array, 0, array.Length / 2),
		() => ProcessPartialArray(array, array.Length / 2, array.Length)
	);
}
static void ProcessPartialArray(double[] array, int begin, int end)
{
	// 计算密集型的处理过程...
}

// 如果在运行之前都无法确定调用的数量,就可以在Parallel.Invoke 函数中输入一个委托数组:
static void DoAction20Times(Action action)
{
	Action[] actions = Enumerable.Repeat(action, 20).ToArray();
    Parallel.Invoke(actions);
}

// 就像Parallel 类的其他成员一样,Parallel.Invoke 也支持取消操作:
static void DoAction20Times(Action action, CancellationToken token)
{
    Action[] actions = Enumerable.Repeat(action, 20).ToArray();
    Parallel.Invoke(new ParallelOptions { CancellationToken = token }, actions);
}

/*
	对于简单的并行调用,Parallel.Invoke 是一个非常不错的解决方案。然而在以下两种情况中使用Parallel.Invoke 并不是很合适:要对每一个输入的数据调用一个操作(改用Parallel.Foreach),或者每一个操作产生了一些输出(改用并行LINQ)
*/

动态并行

  • 并行任务的结构和数量要在运行时才能确定,这是一种更复杂的并行编程

任务并行库(TPL)是以Task 类为中心构建的。Task 类的功能很强大,Parallel 类和并行LINQ 只是为了使用方便,从而对Task 类进行了封装。实现动态并行最简单的做法就是直接使用Task 类。

/*
	下面的例子对二叉树的每个节点进行处理,并且该处理是很耗资源的。二叉树的结构在运行时才能确定,因此非常适合采用动态并行。Traverse 方法处理当前节点,然后创建两个子任务,每个子任务对应一个子节点(本例中,假定必须先处理父节点,然后才能处理子节点)。ProcessTree 方法启动处理过程,创建一个最高层的父任务,并等待任务完成:
*/
void Traverse(Node current)
{
    DoExpensiveActionOnNode(current);
    if (current.Left != null)
    {
        Task.Factory.StartNew(() => Traverse(current.Left),
            CancellationToken.None,
            TaskCreationOptions.AttachedToParent,
            TaskScheduler.Default);
    }
    if (current.Right != null)
    {
        Task.Factory.StartNew(() => Traverse(current.Right),
            CancellationToken.None,
            TaskCreationOptions.AttachedToParent,
            TaskScheduler.Default);
    }
}
public void ProcessTree(Node root)
{
    var task = Task.Factory.StartNew(() => Traverse(root),
        CancellationToken.None,
        TaskCreationOptions.None,
        TaskScheduler.Default);
    task.Wait();
}

// 如果这些任务没有“父/ 子”关系,那可以使用任务延续(continuation)的方法,安排任务一个接着一个地运行。这里continuation 是一个独立的任务,它在原始任务结束后运行:
Task task = Task.Factory.StartNew(
    () => Thread.Sleep(TimeSpan.FromSeconds(2)),
    CancellationToken.None,
    TaskCreationOptions.None,
    TaskScheduler.Default);
Task continuation = task.ContinueWith(
    t => Trace.WriteLine("Task is done"),
    CancellationToken.None,
    TaskContinuationOptions.None,
    TaskScheduler.Default);
// 对continuation 来说,参数“t”相当于“task”

  在并发编程中,Task 类有两个作用:作为并行任务,或作为异步任务。并行任务可以使用阻塞的成员函数,例如Task.Wait、Task.Result、Task.WaitAll 和Task.WaitAny。并行任务通常也使用``AttachedToParent来建立任务之间的“父/ 子”关系。并行任务的创建需要用Task.Run或者Task.Factory.StartNew。相反,异步任务应该避免使用阻塞的成员函数,而应该使用awaitTask.WhenAllTask.WhenAny。异步任务不使用AttachedToParent`,但可以通过await 另一个任务,建立一种隐式的“父/ 子”关系。

并行LINQ

  • 需要对一批数据进行并行处理,生成另外一批数据,或者对数据进行统计
// 大部分开发者对LINQ 比较熟悉,LINQ 可以实现在序列上”拉取“数据的运算。并行LINQ(PLINQ)扩展了LINQ,以支持并行处理。PLINQ 非常适用于数据流的操作,一个数据队列作为输入,一个数据队列作为输出。下面简单的例子将序列中的每个元素都乘以2(实际应用中,计算工作量要大得多):
static IEnumerable<int> MultiplyBy2(IEnumerable<int> values)
{
	return values.AsParallel().Select(item => item * 2);
}

// 按照并行LINQ 的默认方式,这个例子中输出数据队列的次序是不固定的。也可以指明要求保持原来的次序。下面的例子也是并行执行的,但保留了数据的原有次序:
static IEnumerable<int> MultiplyBy2(IEnumerable<int> values)
{
	return values.AsParallel().AsOrdered().Select(item => item * 2);
}

// 并行LINQ 的另一个常规用途是用并行方式对数据进行聚合或汇总。下面的代码实现了并行的累加求和:
static int ParallelSum(IEnumerable<int> values)
{
	return values.AsParallel().Sum();
}

  Parallel 类可适用于很多场合,但是在做聚合或进行数据序列的转换时,PLINQ 的代码更
加简洁。有一点需要注意,相比PLINQ,Parallel 类与系统中其他进程配合得更好。如果
在服务器上做并行处理,这一点尤其需要考虑。

  PLINQ 为各种各样的操作提供了并行的版本,包括过滤(Where)、投影(Select)以及各
种聚合运算,例如 Sum、Average 和更通用的 Aggregate。一般来说,对常规LINQ 的所有
操作都可以通过并行方式对PLINQ 执行。正因为如此,如果准备把已有的LINQ 代码改为
并行方式,PLINQ 是一种非常不错的选择。

数据流基础

  TPL 数据流(dataflow)库的功能很强大,可用来创建网格(mesh)和管道(pipleline),并通过它们以异步方式发送数据。数据流的代码具有很强的“声明式编程”风格。通常要先完整地定义网格,然后才能开始处理数据,最终让网格成为一个让数据流通的体系架构

  每个网格由各种互相链接的数据流块(block)构成。独立的块比较简单,只负责数据处理中某个单独的步骤。当块处理完它的数据后,就会把数据传递给与它链接的块。

  • 使用 TPL 数据流之前,需要在程序中安装一个NuGet 包:Microsoft.Tpl.Dataflow

链接数据流块

  • 创建网格时,需要把数据流块互相链接起来
// TPL 数据流库提供的块只有一些基本的成员,很多实用的方法实际上是扩展方法。这里我们来看LinkTo 方法:
var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
var subtractBlock = new TransformBlock<int, int>(item => item - 2);
// 建立链接后,从multiplyBlock 出来的数据将进入subtractBlock。
multiplyBlock.LinkTo(subtractBlock);

// 默认情况下,链接的数据流块只传递数据,不传递完成情况(或出错信息)。如果数据流是线性的(例如管道),一般需要传递完成情况。要实现完成情况(和出错信息)的传递,可以在链接中设置PropagateCompletion 属性:
var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
var subtractBlock = new TransformBlock<int, int>(item => item - 2);
var options = new DataflowLinkOptions { PropagateCompletion = true };
multiplyBlock.LinkTo(subtractBlock, options);
...
// 第一个块的完成情况自动传递给第二个块。
multiplyBlock.Complete();
await subtractBlock.Completion;
/**
	一旦建立了链接,数据就会自动从源块传递到目标块。如果设置了 PropagateCompletion属性,情况完成的同时也会传递数据。在管道的每个节点上,当出错的块把错误信息传递给下一块时,它会把错误信息封装进 AggregateException对象。因此,如果传递完成情况
的管道很长,错误信息就会被嵌套在很多个 AggregateException实例中。在这种情形下,AggregateException有几个成员(例如Flatten)就可以进行错误处理了。链接数据流块的方式有很多种,可以在网格中包含分叉、连接、甚至循环。不过在大多数情况下,线性的管道就足够管用了。
	利用 DataflowLinkOptions类, 可以对链接设置多个不同的参数( 例如前面用到的 PropagateCompletion 参数)。另外,可以在重载的LinkTo 方法中设置断言,形成一个数据通行的过滤器。数据被过滤器拦截时也不会被删除。通过过滤器的数据会继续下一步流程,被过滤器拦截的数据也会尝试从其他链接通过,如果所有链接都无法通过,则会留在原来的块中。
*/

传递出错信息

  • 需要处理数据流网格中发生的错误
// ① 如果数据流块内的委托抛出错误,这个块就进入故障状态。一旦数据流块进入故障状态,就会删除所有的数据(并停止接收新数据)。该数据流块将不会生成任何新数据。下面的代码中,第一个值引发了一个错误,第二个值被直接删除:
var block = new TransformBlock<int, int>(item =>
{
    if (item == 1)
    	throw new InvalidOperationException("Blech.");
    return item * 2;
});
block.Post(1);
block.Post(2);

// ② 用await 调用它的Completion 属性,即可捕获数据流块的错误。Completion 属性返回一个任务,一旦数据流块执行完成,这个任务也完成。如果数据流块出错,这个任务也出错:
try
{
	var block = new TransformBlock<int, int>(item =>
    {
        if (item == 1)
        	throw new InvalidOperationException("Blech.");
        return item * 2;
	});
    block.Post(1);
    await block.Completion;
}
catch (InvalidOperationException)
{
	// 这里捕获异常。
}

// ③ 如果用 PropagateCompletion 这个参数传递完成情况,错误信息也会被传递。只不过这个异常是被封装在 AggregateException类中传递给下一个块。下面的例子中,程序在管道的末尾捕获到了异常。这说明,如果异常是从前面的块传来的,程序就会捕获到 AggregateException:
try
{
    var multiplyBlock = new TransformBlock<int, int>(item =>
    {
        if (item == 1)
        	throw new InvalidOperationException("Blech.");
        return item * 2;
    });
    var subtractBlock = new TransformBlock<int, int>(item => item - 2);
    multiplyBlock.LinkTo(subtractBlock,
    	new DataflowLinkOptions { PropagateCompletion = true });
    multiplyBlock.Post(1);
    await subtractBlock.Completion;
}
catch (AggregateException)
{
	// 这里捕获异常。
}

/**
	数据流块收到传过来的出错信息后,即使它已经被封装在 AggregateException,仍会用 AggregateException 进行封装。如果在管道的前面部分发生错误,经过了多个链接后才被发现,这个原始错误就会被AggregateException 封装很多层。这时用 AggregateException.Flatten() 方法可以简化错误处理过程。
*/

断开链接

  • 要在处理的过程中修改数据流结构。这是一种高级应用,很少会用到
// 可以随时对数据流块建立链接或断开链接。数据在网格中的自由传递,不会受此影响。建立或断开链接时,线程都是完全安全的。在创建数据流块之间的链接时,保留LinkTo 方法返回的IDisposable 接口。想断开它们的链接时,只需释放该接口:
var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
var subtractBlock = new TransformBlock<int, int>(item => item - 2);

IDisposable link = multiplyBlock.LinkTo(subtractBlock);
multiplyBlock.Post(1);
multiplyBlock.Post(2);

// 断开数据流块的链接。
// 前面的代码中,数据可能已经通过链接传递过去,也可能还没有。
// 在实际应用中,考虑使用代码块,而不是调用Dispose。
link.Dispose();

/**
	除非能保证链接是空闲的,否则在断开数据流块的链接时就会出现竞态条件(racecondition)。但是,通常不需要担心这类竞态条件。数据要么在链接断开之前就已经传递到下一块,要么就永远不会传递。这些竞态条件不会重复出现数据,也不会丢失数据。断开链接是一个高级应用,但它仍能用于一些场合。举个例子,在链接建立后是无法修改过滤器的,要修改一个已有链接的过滤器,必须先断开旧链接,然后用新的过滤器建立新链接(可以把DataflowLinkOptions.Append 设为false)。另外,要暂停数据流网格运行的话,可断开一个关键链接。
*/

限制流量

  • 需要在数据流网格中进行分叉,并且希望数据流量能在各分支之间平衡
/**
	默认情况下,数据流块生成输出的数据后,会检查每个链接(按照创建的次序),逐个地尝试通过链接传递数据。同样,默认情况下,每个数据流块会维护一个输入缓冲区,在处理数据之前接收任意数量的数据。
	
	有分叉时,一个源块链接了两个目标块,上述做法就会产生问题:第一个目标块会不停地缓冲数据,第二个目标块就永远没有机会得到数据。这个问题的解决办法是使用数据流块的BoundedCapacity 属性,来限制目标块的流量(throttling)。BoundedCapacity 的默认设置
是DataflowBlockOptions.Unbounded,这会导致第一个目标块在还来不及处理数据时就得对所有数据进行缓冲了。
	
	BoundedCapacity 可以是大于0 的任何数值(当然也可以是DataflowBlockOptions.Unbounded)。只要目标块来得及处理来自源块的数据,将这个参数设为1 就足够了:
*/
var sourceBlock = new BufferBlock<int>();
var options = new DataflowBlockOptions { BoundedCapacity = 1 };
var targetBlockA = new BufferBlock<int>(options);
var targetBlockB = new BufferBlock<int>(options);

sourceBlock.LinkTo(targetBlockA);
sourceBlock.LinkTo(targetBlockB);

/**
	限流可用于分叉的负载平衡,但也可用在任何限流行为中。例如,在用I/O 操作的数据填充数据流网格时,可以设置数据流块的BoundedCapacity 属性。这样,在网格来不及处理数据时,就不会读取过多的I/O 数据,网格也不会缓存所有数据。
*/

数据流块的并行处理

  • 想对数据流网格进行并行处理
/**
	默认情况下每个数据流块是互相独立的。将两个数据流块链接起来后,它们也是独立运行的。因此每个数据流网格本身就有并行特性。
	如果想更进一步,例如某个特定的数据流块的计算量特别大,那就可以设置MaxDegreeOfParallelism 参数, 使数据流块在处理输入的数据时采用并行的方式。MaxDegreeOfParallelism 的默认值是1,因此每个数据流块同时只能处理一块数据。
	MaxDegreeOfParallelism 可以设为DataflowBlockOptions.Unbounded 或任何大于0 的值。下面的例子允许任意数量的任务,来同时对数据进行倍增:
*/
var multiplyBlock = new TransformBlock<int, int>(
    item => item * 2,
    new ExecutionDataflowBlockOptions
    {
    	MaxDegreeOfParallelism = DataflowBlockOptions.Unbounded
    }
);
var subtractBlock = new TransformBlock<int, int>(item => item - 2);
multiplyBlock.LinkTo(subtractBlock);

/**
	利用MaxDegreeOfParallelism 参数,就可以很容易地在数据流块中实现并行处理。而真正的难点,在于找出哪些数据流块需要并行处理,有一个办法是在调试时暂停数据流的运行,在调试器中查看等待的数据项的数量(就是还没有被数据流块处理的数据项)。如果等待的数据项很多,就表明需要进行重构或并行化处理。
	在数据流块进行异步处理时,MaxDegreeOfParallelism 参数也会发挥作用。这时,MaxDegreeOfParallelism 参数代表的是并发的层次,即一定数量的槽(slot)。在数据流块开始处理数据项之际,每个数据项就会占用一个槽。只有当整个异步处理过程完成后,才会释放槽。
*/

创建自定义数据流块

  • 希望一些可重用的程序逻辑在自定义数据流块中使用。这有助于创建更大的、包含复杂逻辑的数据流快
/**
	通过使用Encapsulate 方法,可以取出数据流网格中任何具有单一输入块和输出块的部分。Encapsulate 方法会利用这两个端点,创建一个单独的数据流块。开发者得自己负责端点之间数据的传递以及完成情况。下面的代码利用两个数据流块创建了一个自定义数据流块,并实现了数据和完成情况的传递:
*/
IPropagatorBlock<int, int> CreateMyCustomBlock()
{
    var multiplyBlock = new TransformBlock<int, int>(item => item * 2);
    var addBlock = new TransformBlock<int, int>(item => item + 2);
    var divideBlock = new TransformBlock<int, int>(item => item / 2);
    
    var flowCompletion = new DataflowLinkOptions { PropagateCompletion = true };
    multiplyBlock.LinkTo(addBlock, flowCompletion);
    addBlock.LinkTo(divideBlock, flowCompletion);
    
    return DataflowBlock.Encapsulate(multiplyBlock, divideBlock);
}

/**
	在把一个网格封装成一个自定义数据流块时,得考虑一下对外提供什么类型的参数,考虑每个块参数应该怎样传递进内部的网格(或不传递)。在很多情况下,有些块参数是不适合的,或者是没有意义的。基于这个原因,创建自定义数据流块时,通常得自行定义参数,而不是沿用DataflowBlockOptions 参数。
	DataflowBlock.Encapsulate 只会封装只有一个输入块和一个输出块的网格。如果一个可重用的网格带有多个输入或输出,就应该把它封装进一个自定义对象,并以属性的形式对外暴露出这些输入和输出,输入的属性类型是ITargetBlock<T>,输出的属性类型是IReceivableSourceBlock<T>。
*/
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

黑夜中的潜行者

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值