通常我们认为,并发指的是多个事件同时发生,并行指的是多个任务一起执行。我更喜欢另一种描述,即:被调用操作的开始和结束都独立于调用它的控制流,该工作与当前的控制流同时执行,就实现了并发性。并行是指将一个问题分解为较小的部分,并异步的发起对每个部分的处理,使它们能并发的得到处理。
另外,并发不仅仅指多线程编程,实际上多线程仅仅是并发编程的一种形式。在C#中,异步编程,并行编程,TPL数据流,响应式编程等都属于并发编程技术。
并发编程也并非大型服务器的专利。在许多场合,我们的程序都需要保证及时的响应用户操作,尤其是读写数据,服务通信等场景。这正是并发编程的目的之一。
线程是使应用并行化和分发异步操作的最基本手段。是用户模式程序中最低级别的抽象,线程几乎没有提供结构和控制方面的支持,直接对线程进行编程,是一种较为古老的方式。
在C#中,我们通常采用为Thread类的构造函数传入方法委托的方式创建线程,并调用Start()方法来启动线程运行。另外,Start方法还可以为线程的委托方法传递参数,我们也可以使用lambda表达式的闭包机制来传递参数。
static void Print()
{
Console.WriteLine("Hello World");
}
Thread t = new Thread(Print);
t.Start();
- 如果线程的控制点在finally代码块中,不会试图触发该异常,因为此时可能在执行关键的清理操作,不应被打断。在非托管代码中也不会,因为这会破坏CLR本身。CLR会等到控制点脱离上述情况后再尝试引发异常,但效果是无法保证的。
- 控制点可能处于lock代码块中,lock无法阻止异常发生,但是同步机制会被破坏,这会影响线程安全,带来不可预知的结果。
- 可能会损坏进程的数据结构或者程序的基础类库数据,引发错误。
线程操作需要考虑的问题的不便远不止这些。比如:我们需要找出1-1000000内的所有质数,我们很容易对这个问题进行并行化。假如我们的电脑是8核,那么我们只需要开8个线程,每个线程计算125000个数字,找出哪些是质数并汇集在一起就好了。然而真的是这么简单吗?
这样的程序毫无伸缩性可言。要解决这些问题并不容易,任务并行库(TPL)正是这样一个框架。它能很好的控制线程数量,线程的负载,能生成可靠的结果和错误报告,并很方便的与其他线程进行协作。
要进行手动线程管理,最自然的方式是借助线程池。
线程池是一个组件,它可以管理大量的用于执行工作项的线程。它不会为了某项任务而创建新的线程,而是将任务放置于线程池中排队,由池中的空闲线程来执行。以此分发任务,可以减少线程创建销毁带来的成本,减少线程过载等。
例如:在求1-1000000以内的质数这个例子中,我们可以采用线程池的方式,将数据源划分成更小的块,将每一块都作为一个任务添加到线程池的运行队列中去。这样程序将具有更好的伸缩性以及性能提升。
static IEnumerable PrimesInRange(int start,int end)
{
List primes = new List();
const int chunkSize = 100;
int complete = 0;
ManualResetEvent alldone = new ManualResetEvent(false);
int chunks = (end - start) / chunkSize;
for(int i = 0; i < chunks; i++)
{
int chunkStart = start + i * chunkSize;
int chunkEnd = chunkStart + chunkSize;
ThreadPool.QueueUserWorkItem(_ =>
{
for (int number = chunkStart; number < chunkEnd; number++)
{
if (IsPrime(number))
{
lock (primes)
{
primes.Add(number);
}
}
}
if(Interlocked.Increment(ref complete) == chunks)
{
alldone.Set();
}
});
}
alldone.WaitOne();
return primes;
}
线程池假定所有的工作运行时间都较短,因此可以尽量保证处理器全力以赴完成任务,而非无效率的通过时间片来进行多个任务,工作时间短还可以保证线程池及时的收回线程。
有别于Thread和Task,线程池不提供正在执行给定工作的线程引用,这样我们无法管理线程以及与线程同步等。微软的C#团队认为,开发人员真正要做的是构建高级别的抽象,将线程和线程池作为实现细节使用。这种抽象由任务并行库来实现。
TPL提供一个能够代表异步工作的对象,工作可以理解为某些需要异步执行的代码块,它被抽象为Task。它为我们提供了便利的API来与工作进行交互。这看起来与委托十分类似,委托不也是封装了一段代码的对象吗?任务其实就是将委托同步执行的方式转化成了异步。
Task t1 = new Task(Print);
t1.Start();
Task t2 = Task.Factory.StartNew(Print);
Task t3 = Task.Run(Print);
等待所有任务结束:
Task.WaitAll(new Task[] { t1, t2, t3 });
任务并行库的出现被视为线程池上的又一个抽象,它隐藏了与线程池交互的底层代码,并提供了更方便的细粒度的API。Task具有以下功能:
另外,还有一个非常有用的辅助方法称为Parallel.Invoke,可以执行一组任务,并且等待所有任务返回。
Parallel.Invoke
(
()=>Print(),
()=>Print()
);