1、常见术语
- 并发:一次不止完成一件事。
只要让应用程序同时执行多项任务,就要用到并发。
注意:
在现代应用程序中,直接使用低层级的线程类型几乎毫无价值,但与传统的多线程相比,高层级抽象更为强大、高效。
因此,对应已然过时的技术,本系列的多线程方法均不会赘述也不使用Thread类型或BackgroundWorker类型,它们已经有了更高高级的代替方案。
- 多线程:采用多个执行线程的并发形式。
多线程是一种并发形式,但并发唯一的形式。
- 并行处理:将大量的工作划分到多个并发运行的线程里,从而处理这些工作。
并行处理/并行编程,通过多线程最大化地利用多个处理器核心(每个线程都由格子的核心运行)。并行处理/编程是一种多线程形式,而多线程是一种并发形式。
- 异步编程:一种通过future或回调来规避多余线程的并发形式。
future(或promise)是一种类型,代表了某种会在将来完成的操作。
.NET中的future类型有Task和Task<TResult>。比较老的异步API使用的是回调或者事件,而非future。
异步编程的核心思想是异步操作,也就是说,某个操作已经开始,但一段时间后才会去完成。虽然该操作已经在执行中,但它并不会阻塞原来的线程,而且启动该操作的线程依旧可以自由地进行其他工作。当该操作完成时,它便告知相关future,或者调用其回调或者事件,让应用程序知晓该操作已经完成了。
异步编程是一种强大的并发形式,需要几位复杂的代码。
但是async和await使得异步编程几乎变得和同步一样简单。
- 响应式编程:一种声明式编程,其中应用程序会对事件做出响应。
响应式编程是另一种并发形式,它是基于异步事件,而非异步操作。
异步事件不见得有一个正在意义上的”开始“,它可能会在任何事件发生,可能多次发生,比如用户的输入。
响应式编程并不一定是并发的,但它与并发密切相关。
2、异步编程
- async
在声明方法时,添加async关键字。它会发挥两重用途:
激活该方法种的await关键字,并知会编译器为该方法生成一个状态机,类似于yield return的工作方式。如果aysnc方法有返回值,那么这个之可能是Task<TResult>。如果没有返回值,那么就返回Task。
此外,若async方法以一组枚举返回多个值,它便会返回IAsyncEnumberable<T>或IAsyncEnumberator<T>。这些类似于task的类型都代表future,这些future则会的async方法完成时通知调用调用代码。
注意:
只要在编写async事件处理程序时,才需要使用async方法来返回void。
无返回值的常规async方法都应当返回Task,而非void。
- await
await关键字并不仅限于处理任务,它也可以处理任何特定格式的可等待对象。
例如:ValueTask<T>类型,如果结果时同步的(比如能从内存缓存种读取),那么这种类型可以减少内存占用。ValueTask<T>并不能直接转换成Task<T>,但由于它遵循可等待模式,因此可以直接对他使用await。
但在大多数情况下,await处理的时Task或Task<TResult>。
两种方法来创建Task实例。
(1)某些任务代表的是CPU需要执行的实际代码,这些需要调用Task.Run来创建这些运算的任务。但如果要做某个特定的调度器上运行这些任务,那么需要调用Task.Factory.StartNew。
(2)另一些任务代表的是通知,创建这种基于事件的任务应当采用TaskCompletionSource<TResult>(或者它的某种快捷方式)。大多数I/O任务采用了TaskCompletionSource<TResult>。
3、并行编程
并行编程可以提交客户端系统的CPU利用率。
但对服务器端系统而言,这通常并不适用。大多数服务器拥有并行机制,比如,ASP.NET就会并行处理多个请求。
但在某些情况下(假如一直并发用户的数量始终较低),在服务器上编写并行代码可能仍旧有用。但总体来看,服务器上的并行编程会与其内置的并行机制冲突,因而不会代理任何实际的益处。
并行机制分为数据并行和任务并行。
数据并行:当有一堆数据项需要处理,而且每一份数据的处理都与其他数据直接答题上独立。
任务并行:当有一堆工作需要处理,其中每一项工作与其他之间大体上独立。
各个工作块应尽可能地相互独立。
任务并行可能是动态的,如果一项工作衍生了一些额外的工作,那么可以将它们添加到这一堆工作里。
- 数据并行
实现数据并行的方法很多。Parallel.Foreach、Parallel.For,或者PLINQ(并行LINQ)。
// 执行 foreach(在 Visual Basic 中为 For Each)操作,其中在 System.Collections.IEnumerable 中可能会并行运行迭代,而且可以匹配配置循环选项。
public static ParallelLoopResult ForEach<TSource>(IEnumerable<TSource> source, ParallelOptions parallelOptions, Action<TSource> body);
// 执行 foreach(在 Visual Basic 中为 For Each)操作,其中在 System.Collections.IEnumerable 上可能会并行运行迭代。
public static ParallelLoopResult ForEach<TSource>(IEnumerable<TSource> source, Action<TSource> body);
......
// 启用查询的并行化。
public static ParallelQuery<TSource> AsParallel<TSource>(this IEnumerable<TSource> source);
// 启用查询的并行化,并由负责将输入序列拆分成各个分区的自定义分区程序指明其出处。
public static ParallelQuery<TSource> AsParallel<TSource>(this Partitioner<TSource> source);
// 启用查询的并行化。
public static ParallelQuery AsParallel(this IEnumerable source);
Parallel会与系统中的其他进程更为融洽地共存,但PLINQ会默认设法扩展到所有的CPU里。
只要各个工作块彼此独立,便能最大化地运行并行编程。
一旦开始在多个线程间共享状态,就必须同步对共享状态的访问,应用程序也就会变得没那么并行。
- 任务并行
并行任务与并行数据相似。
Parallel.Invoke是一种Paralle方法,它会执行某种fork/join任务并行。
//
// 摘要:
// 执行所提供的每个操作,而且尽可能并行运行,除非用户取消了操作。
//
// 参数:
// parallelOptions:
// 一个对象,用于配置此操作的行为。
//
// actions:
// 要执行的操作数组。
//
// 异常:
// T:System.OperationCanceledException:
// System.Threading.CancellationToken 中 parallelOptions 设置。
//
// T:System.ArgumentNullException:
// actions 参数为 null。 - 或 - parallelOptions 参数为 null。
//
// T:System.AggregateException:
// 时,将引发的异常中的任何操作 actions 数组将引发异常。
//
// T:System.ArgumentException:
// actions 数组包含 null 元素。
//
// T:System.ObjectDisposedException:
// System.Threading.CancellationTokenSource 与关联 System.Threading.CancellationToken
// 中 parallelOptions 已被释放。
public static void Invoke(ParallelOptions parallelOptions, params Action[] actions);
//
// 摘要:
// 尽可能并行执行提供的每个操作。
//
// 参数:
// actions:
// 要执行的 System.Action 数组。
//
// 异常:
// T:System.ArgumentNullException:
// actions 参数为 null。
//
// T:System.AggregateException:
// 时,将引发的异常中的任何操作 actions 数组将引发异常。
//
// T:System.ArgumentException:
// actions 数组包含 null 元素。
public static void Invoke(params Action[] actions);
对任务而言,特别需要注意被闭包捕获的变量。切记闭包会捕获引用,而不是值。这最终会导致某些共享并不容易察觉。
通常,并不需要操心线程池是如何处理工作的。数据并行与任务并行使用动态调整分区器在worker线程之间分配工作,线程池会在必要是增加线程数。
线程池具备单个工作队列,每个线程也都有自己的工作队列。当一个线程将额外的工作加入队列中时,因为工作通常与目前的工作项是相关的,所有它会先把工作放进自己的队列里。这种行为鼓励各个线程专注与它们各自的工作,并将缓存命中率最大化。
如果一个线程没有任务,它就会从其他线程的队列中”偷盗“工作。
任务不宜过短,也不宜过长。
如果任务过短,那么将数据分解为任务并在线程池中调度这些任务的开销将变得非常大。
如果任务过长,那么线程池无法高效地动态调整其工作平衡性。
但如何如何判断任务过短或过长呢?
很难判定,这取决于具体解决的问题以及大致的硬件性能。
始终要坚持一点,那就是以不出先性能问题为前提,尽可能地缩短任务(但当任务过短时,性能会骤降)。更好的方案是,与其直接使用任务,不如用Parallel类型或者PLINQ。这些较高层的并行形式内置了分区功能,可自动处理这种问题(并在运行时做出必要的调整)。
若希望进一步探索并行编程,建议阅读Colin Campbell等人合著的《设计模式——.NET并行编程》。
4、响应式编程
用响应式编程,你可以把事件流当作数据流来处理。记住一条黄金法则:如果把任何事件参数传入一个事件,那么摈弃常用的事件处理程序,转而使用System.Reactive,这将给代码带来益处。
System.Reactive曾被称作Reactiv Extentions(响应式扩展),常简称为Rx。也就是说,System.Reactive、Reactive Extentions 和Rx指的是同一种技术。
响应式编程基于可观察流的概念。
当订阅一个可观察流时,便会收到任意数量的数据项(OnNext),然后这个流可能会以单个错误(OnError)或者”流结束“通知结束(OnCompleted)。有些可观察流永远不会结束。
interface IObserver<in T>
{
void OnNext(T item);
void OnCompleted();
void OnError(Exception error);
}
interface IObservable<out T>
{
IDisposable Subscribe(IObservable<T> observer);
}
然而,千万别嵌入这样的接口。微软的System.Reactive库已经包含了你所需要的一切接口。
响应式代码的最终形式与LINQ非常类似,可以把它当作LINQ to Events。
System.Reactve库具备了LINQ所能实现的一切。另外,它还增加了许多自己的运算符,特别是处理时间的运算符。
下面的代码以我们可能不熟悉的一些运算符开始(Interval和Timestamp),并以Subscribe结尾,但其中也包含的是LINQ中常见的Where运算符和Select运算符:
Observable.Interval(TimeSpan.FromSeconds(1))
.Timestamp()
.Where(x => x.Value % 2 == 0)
.Select(x => x.Timestamp)
.Subscribe(x => Trace.WriteLine(x));
此代码以一个计数器开始,在周期性计时器(Interval)上运行,并且针对每个事件添加时间戳(Timestamp)。然后,这个代码会过滤部分事件,只留下哪些偶数计数值的事件(Where),接着选择时间戳的值(Timestamp),当得到每一个时间戳结果值时,将它们写入调试器(Subscribe)。
LINQ to Objects 和 LINQ to Entites采用的时拉式模式,LINQ查询的枚举通常会通过查询来拉取数据;
LINQ to Events(System Reactive)采用的时推式模型,事件自行转入并经由查询传播。
观察流的定义独立与它的订阅。
上一个示例与下面的代码是一样的:
Observable<DateTimeOffset> timestamps = IObserver.Interval(TimeSpan.FromSeconds(1))
.Timestamp()
.Where(x => x.Value % 2 == 0)
.Select(x => x.Timestamp);
timestamps.Subscribe(x => Trace.WriteLine(x));
对一个类型来说,订阅可观察流并使用它们作为IObserable<TResult>资源,这是正常的。其他类型可以订阅这些流,或者将它们与其他运算符组合起来,创建另一个可观察流。
System.Reactive订阅也是一种资源。Subscribe运算符会返回代表订阅的IDisposable。当代码完成对可观察流的监听时,就会清除订阅。
对热可观察对象和冷可观察对象来说,订阅会有不同的表现。
热可观察对象是一种持续运行的事件流,如果在事件传入时没有订阅者,它就会丢失,鼠标的移动就是热可观察对象。
冷可观察对象种并不会始终由事件传入,它会通过启动事件序列来响应订阅。以HTTP下载为例,它就是一种冷可观察对象,订阅会导致发送HTTP请求。
Subscribe运算符应当始终带有错误处理参数,而上面的示例代码并没有这么做。这里对它进行的优化,让其对错误终止的可观察流做出恰当的响应:
Observable.Interval(TimeSpan.FromSeconds(1))
.Timestamp()
.Where(x => x.Value % 2 == 0)
.Select(x => x.Timestamp)
.Subscribe(x => Trace.WriteLine(x),ex=>Track.WriteLine(ex));
当用System.Reactive进行某种实验性操作时,Subject<TResult>是一种很有用的类型。
这种subject就像对可观察流的手动实现,当代码调用OnNext、OnError和OnCompleted时,subject会把这些调用转发给它的订阅者。
如果想更深入地了解System.Reactive,推荐阅读电子书 Introduction to Rx。
5、数据流
TPL(Task Parallel Library,任务并行库)数据流混合了异步技术和并行技术。
当数据需要用到一系列进程时,比如:从URL下载数据,然后解析该数据,最后将它同其他的数据并行处理,使用TPL数据流将特别有效。
TPL数据流通常用作简单的管道,数据从其一端进入,通行余其中,并从另一端流出。
不过TPL数据流的强大之处绝不止与此,它还可以处理任何类型的网格。可以在网格种订阅分支,连接循环,TPL数据流会妥当地处理它们。然后在大多数情况下,TPL数据流网格仍是作为管道来应用的。
数据流网格的基本构成单元是数据流块。
数据流块既可以时目标块(接收数据),也可以是源块(产生数据),或者两者兼而有之。
源块可以通过链接到目标块来创建网格。
数据流块是半独立的,当由数据到来时,它会视图处理数据并将结果向下游推出。
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 ex)
{
AggregateException e = ex.Flatten();//将 System.AggregateException 实例平展到一个新实例。
Trace.WriteLine(ex.InnerException);
}
乍看之下,数据流网格(一下简称网格)听起来与可观察流(以下简称流)十分类似,它们的确有诸多共同点。
网格和流都可以处理数据项,另外,在处理完当前数据或在处理数据的过程中出现问题时,它们都会发出代表正常完成或发送错误的通知。
但是Rx和TPL数据流各有所长。
一旦所处理的事情涉及计时,一般首先Rx可观察对象。
在设计并行处理时,一般首选数据流块。
从概念上来看,Rx的工作方式更像设置回调:可观察对象中的每一步都直接调用下一步。
与之相比,网格中的每一块都彼此独立。虽然Rx和TP数据流各有各的用途,但其中不乏一部分交集。将它们混合使用效果也是不错的。
TPL数据流领域actor框架也有相似之处。数据流块会根据需要处理的工作来启动任务,就像执行转换委托或将输出推到下一个数据流块一样,从这方面来看,每个数据流块都是独立的。我们也可以把每个数据流块设置为并行运行,这样一来,它们就会启动多个任务,来处理额外的输入。因此,数据流块确实在某种程度上与actor框架中的actor类似。
但是TPL数据流并不是一个完整的actor框架,尤其是针对错误恢复或者任何类型的重试,他并没有内置的支持功能。TPL数据流是一种看似与actor框架近似的库,但它并不具备actor框架的所有特性。
最常用的TPL数据流块类型有Transform Block<TInput,Toutput>(类似于LINQ的Select)、TransformManyBlock<TInput,Toutput>(类似于LINQ是SelectMany),以及针对每个数据项执行委托的ActionBlock<TResult>。更多内容产科MSDN文档和《Guid to Implementing Custom TPL Dataflow Blocks》。
6、多线程编程
线程是独立的执行单元。
每个进程都包含多个线程,并且其中的每一个线程都能同时各司其职。
每个线程都有独立的栈,但要与进程中的其他所有线程共享内存。
每个.NET应用程序都有线程池。
线程池维系着一系列工作线程,这些工作线程等待着执行任意交由它们执行的工作。线程池控制任意时间内其中线程的数量。
我们可以通过其他设置项来改变这种行为,但是最后不要更改。这是因为线程池以及经过细致的调优,旨在适配现实世界中的绝大多数场景。
我们可以同现有的线程实现绝大多数操作,但有一个例外,那就是如果在COM interrop中应用STA线程,那么需要创建Thread实例,这是唯一需要自行创建新线程的情况。
线程是议长低层次的抽象,线程池则是较高层次的抽象。
当代码是线程池中排列工作时,线程池会在必要时自动创建线程。
7、并发应用程序集合
并发集合允许多个线程以安全的方式同时更新它们。大多数并发集合能够在线程添加或删除值的同时,使用快照让另一个线程枚举数据值。比起单纯地用锁保护一个常规集合,并发集合通常更加高效。
不可变集合是不可修改的。不可变集合会在集合实例之间尽可能地共享内存。
8、现代化设计
大多数并发技术有一个共同点:它们实质上是函数式的(函数式编程)。
函数式编程的原则之一就是纯净性,也就是避免副作用。解决方案的每一个代码块都能接收一些值作为输入并产生一些值作为输出。我们应当尽可能避免让这些代码块依赖全局变量(或共享变量),同时避免让它们更新全局数据结构(或共享数据结构)。
函数式编程的另一个原则是不可变性,即数据不可发生变化。(不可变数据有利于避免副作用)。
9、技术要点小结
.NET 4.0 引入TPL,全面支持数据并行和任务并行。如今在资源较少的平台上也可使用它,比如手机。.net内置了TPL。
System.Reacitve既可以用在客户端应用程序,也可用作服务器端应用程序。
TPL数据流库则经由System.Threading.Tasks.Dataflow的NutGet包正式分发。
本文详细介绍了C#中的并发编程,包括并发、多线程、并行处理、异步编程、响应式编程和数据流等概念。讨论了async和await关键字在异步编程中的作用,以及并行编程中的数据并行和任务并行。强调了响应式编程的事件处理,以及TPL数据流在处理复杂数据网格中的应用。文章还提到了线程池、并发集合和现代化设计原则,如函数式编程的纯净性和不可变性。
631

被折叠的 条评论
为什么被折叠?



