这篇文章包含以下内容
- 线程基础
- 线程同步
- 线程取消
线程基础
线程是操作系统向程序分配处理器时间的基本单位,在进程内多个线程可以同时执行代码。 .NET Framework
将操作系统进程进一步细分为轻型托管子进程,称为“应用域”(由 AppDomain
表示)。一个或多个托管线程(由 Thread
表示)可以在同一个托管进程内的一个或任意多个应用域中运行。
支持多任务的操作系统,同时执行多个进程中的多个线程。为此,它为有需求的线程之间分配可用的处理器时(时间片)。
当前正在执行的线程在时间片结束后暂停,将切换到另一个线程继续运行。期间,系统会保存被暂停的线程的线程上下文,并载入线程队列中下一个线程的线程上下文。
时间片长度具体视操作系统和处理器而定。由于每个时间片都很小,因此即使只有一个处理器,多个线程也可以同时执行(至少我们感觉上会是这样的)。多处理器系统也是如此,不过,可执行线程会在可用处理器之间进行分配
需要注意的点
- 系统使用内存来保存进程、
AppDomain
对象和线程所需的上下文信息。因此,可以创建的进程数、AppDomain
对象数和线程数受可用内存限制(一般我们不用担心这个问题) - 调度大量的线程会消耗非常多的处理器时间。如果线程太多,其中大多数都不会运行很快(因为线程之间会频繁的切换)。如果当前大多数线程都位于同一个进程中,那么其他进程中的线程被调度的频率也会变得很低(也就是我们所说得系统变慢)
- 多个线程之间的资源同步较为复杂,可能会导致许多
Bug
出现,甚至可能发生死锁 - 务必处理线程的异常。除了通过调用
Abort
、卸载AppDomain
、CLR
或主机结束来结束线程外,其他未经处理的线程(包括后台线程)异常通常会终止进程。(虽然在 .NET 4 以后我们可能不需要过于在意这个问题,但有一个良好的编码习惯可使程序更加健壮)
使用多线程时我们应该考虑以下准则
- 尽可能少的使用
Thread.Abort
来中止其他线程,因为我们不知道该线程已经走到了哪一步,强行中止则可能造成数据错误甚至丢失 - 不应该使用
Thread.Suspend
和Thread.Resume
同步多个线程的活动。而应该使用Mutex
、ManualResetEvent
、AutoResetEvent
和Monitor
等同步基元来实现同步 - 不要将类型用作锁定对象。即应该避免
lock(typeof(X))
这种写法,因为对于给定类型,每个应用域只有一个System.Type
实例 - 不应该使用
lock(this)
这种写法,这种写法极易造成死锁 - 对于简单的状态更改,推荐使用
Interlocked
类的方法,而不是lock
语句。虽然lock
语句使用更普遍,但Interlocked
类提升了原子操作的性能。其中,Interlocked.CompareExchange
方法,还可以用作任何引用类型的安全替换 - 线程池线程(
ThreadPool
)是后台线程。从.NET Framework 4
开始,进程的线程池的默认大小取决于若干因素,例如虚拟地址空间的大小。进程可以调用GetMaxThreads
方法,来确定线程数,也可以使用SetMaxThreads
方法来控制最大线程数
对于新开发的应用,推荐使用 Task
或 Task<T>
对象来进行多任务处理,它比 Thread
和 ThreadPool
使用起来更加方便,且更加可控
Task
或 Task<T>
默认使用线程池线程来运行任务,仅当在创建 Task
时指定 TaskCreateOptions.LongRunning
时,它才会创建一个独立的 Thread
来运行任务,如下
/// 普通任务采用的方式
Task.Factory.StartNew(() => {
// 逻辑代码
}, TaskCreationOptions.LongRunning);
/// 对于耗时比较长的任务,推荐的使用方式
Task.Factory.StartNew(() => {
// 逻辑代码
}, TaskCreationOptions.LongRunning);
复制代码
对于耗时较短的任务,我们不应该指定
TaskCreationOptions.LongRunning
,因为它会新开一个线程,增加了性能成本(创建、上下文切换)。而对于耗时较长的任务,我们就应该指定该选项,因为如果不指定,该任务会占用线程池中某一线程过多的时间,这会造成其他任务无法及时的得到处理(线程池里面的线程就那么几个,如果长时间被占用,其他任务就没法使用它)。
因此建议,耗时长的任务,指定 TaskCreationOptions.LongRunning
选项;耗时短的任务,不指定。
线程同步
CLI
(公共语言基础)提供了以下几种策略:
- 同步代码区域:即对某一代码块的同步。一般我们会用
lock
语句来实现(lock
语句是使用Monitor.Enter
和Monitor.Exit
方法进行实现,并使用try…catch…finally
块来确保锁已解除) - 手动同步:即可以使用
.NET Framework
类库提供的同步对象。如Monitor
、Mutex
、SpinLock
、ReaderWriterLock
、Semaphore
等等。不过这些同步对象要比 lock 更加复杂,使用的时候需要保证它们能够被正确的释放 - 同步上下文:可以使用
SynchronizationAttribute
为ContextBoundObject
对象启用简单的自动同步。 System.Collections.Concurrent
命名空间中的集合类:这些类提供了内置的同步添加和删除操作,比如BlockingCollection<T>
、ConcurrentBag<T>
、ConcurrentDictionary<TKey,TValue>
等
部分同步方式的介绍
lock 语句
lock
关键字将代码块标记为互斥区域,方法是获取给定对象的互斥锁,执行语句,然后释放该锁。其内部实现方式是使用 Monitor.Enter
和 Monitor.Exit
方法进行实现,并使用 try…catch…finally
块来确保锁已解除
下面我们给一个线程安全的单例的例子,示例如下
public class MysqlDatabase {
private static MysqlDatabase database;
/// 用于辅助创建对象的互斥锁对象
private static readonly object DatabaseCreateLocker = new object();
public static MysqlDatabase Database {
get {
// 利用双重检测、加锁的方式来创建单例
if (database == null) {
// 此处可确保只有一个线程进入以下代码块
lock (DatabaseCreateLocker) {
// 进行 database == null 检查基于这种情况:
// 线程 A 首先进入了这个语句块,线程 B 访问的时候,会被阻塞,
// 直到线程 A 退出这个语句块
// 线程 A 退出后,线程 B 获得访问这个代码的权力,
// 这个时候 database 已经被 线程 A 初始化了,
// 因此我们需要判断该对象是否已经被创建,来保证 database 不会创建两次
if (database == null) {
database = new MysqlDatabase();
}
}
}
return database;
}
}
}
复制代码
关于 lock
的使用,建议定义 private
对象来进行锁定,或者定义 private static
对象变量(如上面示例所示)来保护所有实例所共有的数据
此外,还需要注意的是,在 lock
语句的代码块中不能使用 await
关键字
Mutex
Mutex
对象可实现对资源的独占访问。它可以跨应用域边界进行封送,可用于多个等待操作以及同步不同进程中的线程
对于Mutex
使用,需要注意以下几点:
Mutex
只能由拥有它的线程(即创建它的线程)释放。如果释放Mutex
的线程不是拥有它的线程,就会抛出ApplicationException
Mutex
有两种类型:本地Mutex
和命名系统Mutex
。本地Mutex
仅存在于进程中,进程中引用本地Mutex
对象的所有线程都可以使用本地Mutex
。而命名系统Mutex
对象与同名的操作系统对象相关联,它在整个操作系统中都可见,并且可用于同步进程活动(可用于进程需要单例运行时,这通常会在程序入口进行检查,如果已经存在这样的Mutex
对象,则会关闭当前进程)。
Interlocked
它用于以下场景:多个线程需要对某一个共享的变量进行访问。如果该变量位于共享内存中,则不同进程的线程都可以使用此机制。它可以提供性能非常高的同步,并且还可以用于生成更高级的同步机制,例如自旋锁。
有如下特点:
Interlocked
是具有原子性:即整个操作是不能由相同变量上的另一个互锁操作中断- 其
CompareExchange
方法可以交换两个值,可使用此方法的重载来实现各种引用类型变量的互换
ReaderWriterLockSlim
借助ReaderWriterLockSlim
类,多个线程可以同时读取资源,但线程必须获得排他锁,才能对资源执行写入操作。即它允许多个线程同时处于读模式,但仅允许一个线程处于写模式
示例代码较长,具体可参考微软文档 ReaderWriterLockSlim类
Semaphore 和 SemaphoreSlim
Semaphore
表示一个命名(系统范围内)或本地信号量。它是对 Win32
信号量对象的封装,Win32
信号量是计数信号量,其可用于控制对资源池的访问。而 SemaphoreSlim
类为一个轻量、快速的信号量,可在等待时间预计很短的情况下,用于在单个进程内的等待
信号量可用于生产者、消费者线程,其中一个线程始终增加信号量计数,另一个始终减少信号量计数
Barrier(拦截点,类似栅栏)
它是由用户定义的同步基元,以便于多个线程(称为“参与者”)可以分阶段同时处理算法。在达到代码中的拦截点之前,每个参与者将继续执行,拦截点表示工作阶段的末尾。单个参与者如果在所有参与者都到达拦截点之前到达,它就会被阻塞,直至所有参与者都到达为止。所有参与者都已达到屏障后,我们就可以继续执行后续操作。
举个例子,比如我们约了几个好友出去旅游,我们约定会合的地点为 A
(拦截点),我们每个人(参与者)在到达 A
地点之前,会乘坐不同的交通工具(执行任务)。在所有人到达之前,已经到了的人都需要等待(阻塞)。所有人都到了之后,我们便可以坐上大巴车取旅游景点了(后续操作)。
Barrier
在算法中用得比较多,比如有些算法,可以拆分成很多小任务以便于充分利用计算机上的多个处理器,待小任务完成后,将每个任务的结果进行处理,而得出最终结果。
不过,大部分情况下,我们可以使用 TaskFactory.ContinueWhenAll
来替代 Barrier
。但如果在各个任务间,需要共享资源,那在这种情况下,Barrier
是最好的选择
SpinWait
SpinWait
是一种轻量级、低级别的同步类型,它可以降低执行内核事件时上下文切换和内核转换产生的高成本。在多核计算机上,如果需要长时间保留资源,更高效的做法是,先让线程在用户模式下旋转几十或几百个周期(可以看成一个机器一直在空转,其实啥事都没做),再重试获取资源。如果资源在旋转后可用,便节省了几千个周期。如果资源仍不可用,那么也只花了几个周期,仍可以进入基于内核的等待
线程安全的集合
线程安全的集合包括以下几种
BlockingCollection<T>
ConcurrentDictionary<TKey,TValue>
ConcurrentQueue<T>
ConcurrentStack<T>
ConcurrentBag<T>
对于线程安全的集合
- 添加:多个线程或任务可同时向集合添加对象,如果集合达到其指定的最大容量,则生产者线程将发生阻塞,直到集合中的某个对象被移除,有空余位置为止;
- 移除:多个消费者可以同时移除对象,如果集合变空,则消费者线程将发生阻塞,直到生产者添加对象进集合,有可以使用的对象为止。
这儿举个生活中的例子,比如我们排队吃饭,餐馆里面的位置数量是固定的(集合的容量),假设顾客是生产者,我们进去吃饭,就需要占一个位置,如果里面没有位置了(集合已满),其他的客人就需要等待,直到有人吃完离开,下一个客人才能进去;假设服务员是消费者,他们负责收拾桌子,打理每个位置,如果没有客人来吃饭(集合为空),他们就没有碗筷需要收拾(等待),直到有人来吃饭(添加元素),他们才能继续收拾桌子。希望这个生活中的例子可以加深我们对 BlockingCollection
的理解
生产者线程可调用 CompleteAdding
来声明自己将不再向集合中添加元素。同时,消费者也可以通过检测 IsCompleted
属性来了解集合何时不再有新对象产生。示例代码如下:
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
public class App {
public class Data {
}
public static void Main() {
// 创建一个容量为 100 的集合
BlockingCollection<Data> dataItems = new BlockingCollection<Data>(100);
// 此处模拟消费者线程
Task.Run(() =>
{
while (!dataItems.IsCompleted) {
Data data = null;
try {
// 当集合为空时,此处将会阻塞,直到有元素添加到集合为止
// 如果我们不希望一直等下去,我们可以使用 dataItems.TryTake 或它的重载方法来取元素
data = dataItems.Take();
} catch (InvalidOperationException) {
// 抛出这个异常表示生产者线程通过 CompleteAdding 将 IsCompleted 设置为了 True,这个时候我们再去取元素就会抛出这个异常
}
if (data != null) {
// 业务逻辑
}
}
// 如果能运行到这儿,表示生产者线程不再继续往集合添加元素(生成内容)了
// 即生产者线程通过 CompleteAdding 将 IsCompleted 设置为了 True
Console.WriteLine("没有元素了。。");
});
// 此处模拟生产者线程. 添加 110 个
Task.Run(() =>
{
int count = 0;
while (count < 110) {
Data data = new Data();
// 如果集合中的元素数量已经达到了 100 个,则此处将会阻塞,直到有人取走其中的某些元素位置
// 如果我们不希望一直等下去,则可以调用 dataItems.TryAdd 或它的重载方法来添加元素
dataItems.Add(data);
}
// 我们不再继续往集合添加元素(生成内容)了
dataItems.CompleteAdding();
});
}
}
复制代码
一般情况下,个人建议采用 BlockingCollection
提供的非阻塞方法执行某些操作,比如使用 TryAdd
、TryTake
等,这种方式可使程序的伸缩性更强、性能更好
在使用方式上 ConcurrentDictionary<TKey,TValue>
、ConcurrentQueue<T>
、ConcurrentStack<T>
及ConcurrentBag<T>
同上述代码差别不大,这个我们可以在写代码的时候,查看 Api 即可得知。 这里唯一不常见的是 ConcurrentBag<T>
,它表示一个线程安全的 无序集合。当一个线程既是生产者,又是消费者的时候,使用它会更加高效(比其他线程安全集合都高效)
此外,BlockingCollection
可以与其他线程安全集合组合使用,如下
BlockingCollection<Data> blockCollections = new BlockingCollection<Data>(new ConcurrentStack<Data>(), 100);
复制代码
线程取消
从.NET 4
开始,我们可以以统一的方式去取消异步、长时间运行的任务,核心就是 CancellationTokenSource
类
实现步骤如下
- 实例化
CancellationTokenSource
对象,此对象管理和发送取消通知给每个持有CancellationTokenSource.Token
的线程 - 将
CancellationTokenSource.Token
传递给每个需要发送取消通知的线程 - 为每个需要取消的线程实现接收到取消通知的代码,比如
CancellationTokenSource.Token.ThrowIfCancellationRequested()
- 在外部调用
CancellationTokenSource.Cancel
以通知持有CancellationTokenSource.Token
的线程结束自己的工作
切记不要忘记调用
CancellationTokenSource.Dispose
方法以释放相关资源
CancellationTokenSource 使用举例
示例代码如下(这儿采用的是通过轮询方式监听取消请求):
using System;
using System.Threading;
using System.Threading.Tasks;
public class App {
public static void Main() {
CancellationTokenSource cts = new CancellationTokenSource();
Task.Run(() => {
for (int i = 0; i < 100000; i++) {
if (cts.Token.IsCancellationRequested) {
Console.WriteLine("取消操作触发于第 {0} 次循环...", i + 1);
break;
}
// 模拟任务处理
Thread.SpinWait(500000);
}
}, cts.Token);
Thread.Sleep(2500);
// 取消任务
Console.WriteLine("准备取消任务....");
cts.Cancel();
Console.WriteLine("取消任务完成....");
Thread.Sleep(2500);
cts.Dispose();
Console.ReadLine();
}
}
复制代码
输出如下
准备取消任务....
取消任务完成....
取消操作触发于第 104 次循环...
复制代码
监听任务取消命令
如果需要在循环或递归中监听取消命令,我们可以在需要时读取 IsCancellationRequested
属性的值。如果使用的是 Task
类型,可以使用 ThrowIfCancellationRequested
方法来检查属性并抛出异常,此方法优于手动抛出 OperationCanceledException
。如果无需抛出异常,那我们直接检查 IsCancellationRequested
属性即可。示例代码见前面 “CancellationTokenSource 使用举例” 中的代码
有时,我们需要在任务被取消之后做一些其他的事情,这时候,我们就需要监听任务取消,代码如下
CancellationTokenSource cts = new CancellationTokenSource();
/// 这个方法有多个重载,具体使用哪种需要我们根据实际情况灵活选择
cts.Token.Register(() => {
// 任务被取消,可用于清理资源等
});
复制代码
以上注册的监听操作,仅在 CancellationTokenSource.Token
被取消之后(即 CancellationTokenSource.Cancel()
或 CancelAfter()
被调用之后),会被触发。但需要注意的是,这个时候,持有 CancellationTokenSource.Token
的线程并不一定都已经取消完了,有些可能还在做收尾工作
同时侦听多个取消命令
当需要同时侦听多个取消命令时(这种情况下,我们可以在其中任意一个命令发出取消请求时中止当前任务),使用CreateLinkedTokenSource
方法可以将两个令牌(CancellationTokenSource.Token
)联接为一个令牌。这样我们就只需要使用链接之后的令牌了,示例代码如下
using System;
using System.Threading;
public class App {
public static void Main() {
CancellationTokenSource cts1 = new CancellationTokenSource();
CancellationTokenSource cts2 = new CancellationTokenSource();
using (CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts1.Token, cts2.Token)) {
try {
DoWorkInternal(linkedCts.Token);
} catch (OperationCanceledException) {
if (cts1.Token.IsCancellationRequested) {
Console.WriteLine("Token 1 Cancelled");
} else if (cts2.Token.IsCancellationRequested) {
Console.WriteLine("Token 2 Cancelled.");
cts2.Token.ThrowIfCancellationRequested();
}
}
}
Console.ReadLine();
cts1.Cancel();
cts2.CancelAfter(2000);
cts1.Dispose();
cts2.Dispose();
}
private static void DoWorkInternal(CancellationToken token) {
for (int i = 0; i < 1000; i++) {
if (token.IsCancellationRequested) {
// 抛出 OperationCanceledException 异常
token.ThrowIfCancellationRequested();
}
// 模拟业务逻辑
Thread.SpinWait(7500000);
Console.Write("working... ");
}
}
}
复制代码
关于线程的一些基础就差不多这些了。关于这篇文章的一些代码,会和此文章一起推送出来
至此,本节内容讲解完毕。 欢迎关注公众号【嘿嘿的学习日记】,所有的文章,都会在公众号首发,Thank you~