目录
前言
一、异步线程
使用async和await关键字
基于委托实现
二、同步线程
三、Thread线程
开启线程
设置线程优先级
Thread拓展封装
四、ThreadPool线程池
常规使用
设置线程数
线程等待
Thread和ThreadPool比较
通过线程池做一些扩展(定时器类)
五、Task线程(推荐使用)
1、常规使用
new一个新的Task
Task.Run
Task.Factory
RunSynchronously
Parallel
2、Task中6种阻塞方式和任务延续
Join阻塞
Delay延迟
Wait等待
WaitAll等待所有
WaitAny等待任一
ContinueWhenAll
ContinueWhenAny
3、TaskCreationOptions枚举
父子任务
长时间运行的任务处理
4、线程取消
抛出异常
CacellationTokenSoure
5、Task中的返回值获取
单任务返回结果
关联任务返回结果
多任务集中返回结果
6、Task中专门的异常处理AggregateException
7、多线程情况下的异常捕获
六、线程的生命周期
Start(开始)
Suspend(挂起)
Interrupt(中断)
Sleep(休眠)和Wait(等待)
Resume(恢复)
Abort(取消)
Join(阻塞)
ResetAbort(再次启用)
七、应用程序域
八、WinDbg的使用
九、多线程原理研究
线程在内存空间上的开销
线程在时间上的开销
十、多线程资源竞用问题
十一、Debug和Release区别
十二、应用场景
跨线程访问控件
跨线程访问数据库
Q1:为什么要使用线程?
在多CPU和多核时代,使用线程能够充分利用硬件资源,提升软件的运行效率。但是没有章法的乱用线程会适得其反。
Q2:线程和进程关系?
一个程序运行,通常在任务管理器中看到一个进程。这个进程占用多少资源,并不是由进程本身决定。而是由这个进程分配 的线程决定。也就是说操作系统是通过线程来管理程序资源的。
异步线程是一种用于处理耗时操作的机制,它允许应用程序在执行某些操作时不被阻塞,以提高性能和响应性.
using System;
using System.Threading.Tasks;
public class Program
{
public static async Task Main()
{
Console.WriteLine("开始执行异步操作");
await ExecuteAsyncOperation(); // 调用异步方法,并在此等待其完成
Console.WriteLine("异步操作完成");
}
public static async Task ExecuteAsyncOperation()
{
Console.WriteLine("开始执行异步操作");
// 模拟一个耗时的操作,这里使用 Task.Delay 方法来模拟
// 这个操作会暂停方法的执行,但不会阻塞主线程
await Task.Delay(1000); // 暂停 1 秒钟
Console.WriteLine("异步操作完成");
}
}
调用BeginInvoke()方法
利用返回结果状态做进度条
异步等待WaitOne
异步返回值EndInvoke
同步线程是指线程的执行是按照顺序的,每个操作都必须等待前一个操作完成后才能继续执行,这种线程模型被称为同步。
thread.Priority=ThreadPriority.Highest;//设置高优先级,只是一个概率的提升
场景一:开启新线程执行任务后紧接着需要执行的一个任务
场景二:开启一个线程,既要不卡界面,又要返回结果
Func func=()=>
{
Thread.Sleep(2000);
return 6;
};
Fun funcResult=ThreadWithReturn(func);
int iResult=funcResult.Invoke();//如果需要执行结果,等待是必须的
线程池是一个用于管理和重用线程的机制,它提供了一种轻量级的方式来处理并发执行的任务。线程池在应用程序启动时会创建一组预分配的线程,这些线程被放置在线程池中,并准备好处理工作项。当应用程序需要执行某个任务时,可以将任务提交给线程池,并由线程池中的线程来执行任务。使用线程池的好处之一是可以减少线程创建和销毁的开销,因为线程池会重用已创建的线程,从而提高性能和资源利用率。
注:线程池是一个共享的资源,因此需要注意使用线程池的任务应尽量是短期的,以避免阻塞线程池中的其他任务。如果需要执行长时间运行的任务,可以考虑使用Async/Await或其他异步编程模型。
1)ThreadPool.QueueUserWorkItem
运行结果:方法1、2中的任务是由线程池自动调度的
2)一些使用示例
【1】Thread:只要我们需要异步任务,都会开启一个Thread,比然有时间和空间开销。
【2】TheadPool:是一个线程池,由系统维护和缓存,因为默认都是初始化好的,所以使用方便,退回容易。用的时候,只需要请求即可。
Thread开启10个线程
ThreadPool开启10个线程
总结:
【1】我们的线程池可以根据电脑的实际CPU情况开启一定个数的线程,放到线程池中。
【2】10个异步任务,但是我们通过4个线程就完成了,节省时间和空间开销。
Task是一种用于多线程编程的高级概念。Task可以把工作项分配给线程执行,使得你可以轻松地编写异步代码。
在C# 5.0以前,编写异步代码需要使用回调函数或Thread类等底层API,这使得代码复杂难以维护。而在C# 5.0引入async/await语法之后,使用Task来编写异步代码变得更加简单和直观,使用Task来编写异步代码的好处之一是可以避免使用回调函数,使得代码更加简洁和易于维护。同时,Task还提供了其他高级的功能,如取消异步操作和处理多个异步操作的结果等。
例子
运行结果
例子
通常用于同步地执行一个异步操作。这个方法通常是在异步操作的Task对象上调用的,以便立即在当前线程上执行异步操作并等待其完成。
Parallel类提供了一种简单而有效的方式来并行执行任务,以充分利用多核处理器的能力。Parallel类通常用于对集合进行并行操作,如并行循环、并行LINQ查询等。
注意:Parallel类会自动控制线程的创建和任务的分配,以充分利用可用的处理器核心。它提供了一种方便的方式来实现并行计算,避免了手动管理线程和同步的复杂性。
使用Parallel类可以使得在多核处理器上执行任务变得更加简单和高效,但需要注意避免在并行任务中使用共享的可变状态,以避免数据竞争和其它并发问题。
使用Parallel.Invoke
使用 Parallel.For 并行处理循环
使用 Parallel.ForEach 并行处理数组中的元素
Parallel.ForEach(data, (item) =>
{
Console.WriteLine("处理元素 " + item);
// 在这里可以执行一些耗时的操作
});
创建一个在指定时间间隔后完成的异步操作的静态方法
通过调用 task.Wait(),程序会等待任务完成。在任务完成后,才会继续执行下面的代码
task.Wait() 和 task.Join() 都用于等待异步任务的完成。它们的主要区别在于,Wait() 是 Task 类的成员方法,而 Join() 是 Thread 类的成员方法。
task.Wait() 会阻塞当前线程,直到任务完成。如果任务异常退出,Wait() 方法会抛出相应的异常,可以使用 try-catch 块来处理。
task.Join() 会阻塞当前线程,直到对应线程执行完成。如果对应的线程异常退出,Join() 方法不会抛出异常,需要通过其他机制来处理异常。
与 Wait() 不同,Join() 方法是用在多线程编程中的,因为 Join() 方法只能等待线程完成,而不能等待任务完成。因此,如果使用 Join() 方法等待异步任务的完成,必须先使用 Task.Run() 方法将异步任务转换为线程,然后才能使用 Join() 方法。
Task.WaitAll(task1,task2)
Task.WaitAll(taskList.ToArray());
Task.WaitAny(task1,task2)
Task.WaitAny(taskList.ToArray());
主线程不等待,子线程依次进行
不卡UI界面
WhenAll和ContinueWith结合使用
一堆子线程中,执行某一任务后,去执行另外一个动作
WhenAny和ContinueWith结合使用
线程无法从外面取消的,他只能自己取消自己(其实就是抛出一个异常)。
Task中的取消功能:使用的是CacellationTokenSoure解决多任务中协作取消、任务清理、和超时取消方法。
取消当前线程
Token注入到线程中,可取消当前线程后的未启动的所有线程
注:若在当前线程中间调用Cancel(),当前线程会执行完毕,后续线程终止
Task任务取消时,额外工作处理
//Task任务延时自动取消:特别适合于一定时间内容取消任务
//比如我们请求一个任务(可以是远程的接口,或者是某些数据的接收)在一定的时间内,没有返回数据,或者数据没有接收完毕
//这时候可以自己设置延时时间,超时自动取消。
使用Task.WaitAll
线程开始
挂起(suspend),就是我们说的暂停。挂起是用户主动发起的行为,所以,可以恢复。线程被挂起的时候,CPU资源部不被释放。如果当前执行的任务优先级高,其他任务靠边站。挂起一般是程序调试中,为了观察某些数据,而使用,方便调试。
中断(Interrupt), 通过调用Thread实例的Interrupt方法,可以向线程发送一个中断信号,从而引发ThreadInterruptedException,以便中断线程的执行。需要注意的是,中断并不会终止线程的执行,而是将中断标志位置为true,并在适当的时机抛出ThreadInterruptedException。在工作线程中需要使用try...catch块来捕获中断异常,并在捕获到中断信号时执行清理工作并中止线程。
Sleep()和Wait()都可以让程序等待多少毫秒。Sleep()方法没有是放锁。线程调用的时候,CPU资源一直占有。所以称为“占着CPU睡觉”。Wait()方法释放锁。其他线程可以使用资源。
Sleep(2000)表示:占用CPU,程序休眠2秒。
Wait(2000)表示:不占用CPU,程序等待2秒。
恢复(Resume), 不再推荐使用Resume方法来恢复已暂停的线程。在早期版本的.NET Framework中,Thread类提供了Resume方法来恢复线程的执行,但是在现代的.NET版本中,Resume方法已经过时,并且不推荐使用它。在替代方案中,可以使用Monitor类或ManualResetEvent来控制线程的暂停和恢复。
取消(Abort), 可以使用Abort()方法来取消(终止)一个线程的执行。但是,Abort()方法并不是一种建议的线程取消方式,因为它可能导致一些不可预测的结果和资源泄漏。当调用Abort()方法时,线程会立即引发ThreadAbortException,这个异常可以在线程的代码中捕获和处理。如果该异常未被捕获,线程会立即终止并且不会执行任何清理工作。这可能会导致资源未正确释放,例如未关闭的文件或未释放的锁等。此外,取消线程的过程中,线程的堆栈可能被破坏,导致应用程序变得不稳定。通常建议使用一种更安全和优雅的方式来取消线程的执行,例如使用CancellationToenSource类来实现取消操作。
阻塞(Join), 可以使用Join()方法来等待一个线程执行完成。Join()方法将阻塞调用线程,直到目标线程完成执行并终止。如果目标线程已经完成执行了,那么Join()方法会立即返回。Join()方法是一种比较简单直接的方式来等待线程执行完成,但需要注意的是,在执行Join()方法之前,必须先调用Start()方法来启动线程。
thread.Join(2000)//阻塞2s
thread.Join()//阻塞直到任务完成
把终结的线程再次启用
示例:
多线程的优点:
【1】计算机中一般会运行很多程序,会有很多对应的进程,进程的数量都会超过CPU的个数,如果所有的任务都通过进程来切换,会非常的耗时,将每个进程,分成若干线程,通过线程切换代价更小。
【2】提升CPU的利用率。比如某些线程在等待资源的时候(等待输入、监听数据等)多线程允许其他线程继续执行,从而避免整个进程被阻塞。
从而提高了CPU的利用率。这就解释了,为什么有时候CPU的使用率很低,但是发现内存占用还很高的原因。
【3】在多CPU和多核情况下,真正实现并行运行。
多线程的缺点:
【1】线程越多,上下文切换的开销也越大。CPU的效率就会降低。
【2】线程间的同步容易出错,且不易调试。
【3】多线程是需要代价的。
定义:一个应用程序对应一个进程,每个进程会映射对应的物理内存,从而隔离程序。
特殊情况:在一个进程中,我们通常会调用另一个应用程序,比如在VS中,对应devenv进程,创建一个记事本进程。如果单独开一个进程,性能开销是比较大的。为了解决这个问题,.NET中引入应用程序域(AppDomain),并且将它设置在进程和线程之间。每个进程至少包括一个应用程序域,在托管代码运行时,CLR还会额外的创建《系统域》和《共享域》,存放应用程序需要的资源这样的话,就能够减少进程的总数,提高系统性能,减轻进度调度的压力。应用程序域可以看成是程序集的“容器”。应用程序域可以被主动创建,也可以被卸载。并且很快被GC回收。
加载应用程序域前后,进程数没有改变
WinDbg的使用:底层内存查看工具,通过它可以观察CLR这一级别的信息,底层看的更清楚。启动后,首先要载入.NET调试扩展包SOS ,是.NET框架的一部分,无需下来,在操作系统对应目录中。
注意:在使用老版本的时候,需要手动添加符号文件,并且注意你项目的编译位数一定要和windbg的位数一致。
常用的命令:
【1】.loadby sos clr 加载sos.dll (以便支持更多的命令)
【2】!clrstack 打印当前线程调用的Stacktrace
【3】!dumpheap 打印堆中所有对象的地址,大小和方法表
PS:sos所有的命令都以“!”开拓,通过!help查看所有的命令。
目的:增加对线程空间和时间上的开销理解,才能更加合理的使用线程。
内容:
(1)线程Thread在内存空间上和时间的消耗研究
(2)线程Thread相关的实例方法
(3)线程Thread相关的静态方法
【1】Thread内核数据结果:主要有osid(线程的ID)和Context(存放CPU寄存器相关的变量)
寄存器的状态会被随时的保存到Contex中,以便下次使用,多线程时间片切换中是有帮助。
时间片切换理解:计算机基于时间片把当前线程中的资源放到CPU的Context中,然后线程休眠,开始其他线程的调度。
一个时间片应该大约30ms左右(xp和vista时代)
【2】Thead环境块(Thread Environment Block,TEB)
包括:thread本地存储,exceptionList信息等。
0:007> .loadby sos clr
0:007> !threads
ThreadCount: 3
UnstartedThread: 0
BackgroundThread: 1
PendingThread: 0
DeadThread: 1
Hosted Runtime: no
Lock
ID OSID ThreadOBJ State GC Mode GC Alloc Context Domain Count Apt Exception
0 1 294c0 0000018436123130 2a020 Preemptive 0000018437BC7E38:0000018437BC7FD0 00000184360fa5c0 1 MTA
6 2 8454 000001843614d520 2b220 Preemptive 0000000000000000:0000000000000000 00000184360fa5c0 0 MTA (Finalizer)
XXXX 3 0 000001843617cf20 39820 Preemptive 0000000000000000:0000000000000000 00000184360fa5c0 0 Ukn
第1个线程是主线程
第2个线程是终结器(也就是GC用来回收资源的)
第3个线程是我们启动的。
以上内容都会占用资源,所以我们需要研究。
【3】用户堆栈模式:用户程序的“局部变量”和“参数传递”所使用的堆栈。
经常见到StackOverFlowException,内存溢出的基本原因其实就是《堆栈溢出》
默认情况:windows会分配1M的空间用于用户模式堆栈(换句话说,一个线程分配1M堆栈空间,主要用途参数,局部变量)
【4】内核堆栈模式:就是我们的程序要访问操作系统使用的堆栈。
理解:在CLR的线程操作的时候,通常最后会调用底层win32函数,用户模式中的参数需要传递到内核模式中。
【1】资源使用通知的开销:我们运行一个程序,通常会加载很多的托管的,和非托管的dll,exe,资源,元数据... 我们通过windbg可以观察到。
使用windbg加载一个进程的时候,会有很多ModLoad列表,这些就是加载的模块...请在windbg上看那个进程列表...
这些dll有的是托管的,也有的是非托管的。
思考:程序运行的时候,如何查看应用程序域?
《常用命令3》!DumpDomain 输入后看到3个应用程序域,这个是进程启动的时候,默认的。
(1)System Domain: 系统程序域(由CLR创建的)
(2)Shared Domain: 共享程序域,加载了一个叫做mscorlib.dll模块,这个是系统模块,很多程序需要(如果感兴趣,可以自己研究)
比如我们程序中用的那些int、long等都会放到这里面。
(3)Domain 1:加载了当前exe文件,还有mscorlib.dll模块,也就是我们运行的这个程序最终在这个里面。这个可以理解成私有的程序域。
开启一个Thread,销毁一个Thread,都会通知进程中的相关dll。通过相关的attach、detach等标志位。目的就是为了给线程做资源清理。
【2】时间片切换开销:请大家自己打开任务管理器,看看CPU个数。 通过观察,当前我的计算机是4核四线程,如果超过4个线程,比如5个,必然会有一个thread休眠30ms,也就是时间片切换,来实现调度。
以上是我们开启一个线程,所带来的开销,请大家权衡后,适当的开启我们需要的线程,不要任意开启,否则CPU也难以承受。
我们看到的CPU中线程很多。但是本质上会有很多线程都是休眠的。CPU使用很低。
1. 使用《 Thread.AllocateDataSlot()未命名的数据槽位》和《Thread.AllocateNamedDataSlot 命名的数据槽位》解决资源竞用。
使用命名槽位
使用未命名槽位
2.在主线程中使用 ThreadStatic特性标注在变量上面,则只有主线程有权访问该变量
3. ThreadLocal线程的本地存储(TLS: thread local storage),解决资源竞用问题
在实际项目中,我们一般都用Release版本,而不是Debug发布。因为Release中做了一些代码和缓存的优化,
比如说将一些数据从memory中读取到cpu高速缓存中。我们观察一下CPU任务管理器中,L1、L2、L3缓存,速度远高于内存。
编写冒泡排序程序,并测试10次,比较Release和Debug】
Debug版本
Release版本
从结果中可以看到,大概有2-3倍的差距!release做了性能提升!
上面这段代码在release环境下出现问题了:主线程不能执行结束。
【问题分析】
从代码中可以发现,有两个线程在共同一个stop变量。
就是thread这个线程会将stop加载到Cpu Cache中,而主线程中,又修改了stop的数据,所以thread是无法知道的,
这样while就会一直执行!而主线程又在Join子线程,所以,无法输出!
【注意问题】以上情况,不是绝对的,也就是说主线程和子线程公用变量的情况,是否会出现上面的情况,是不完全确定的。
通常的解决方法:
【1】 不要让多个线程去操作一个共享变量,否则容易出问题。从根本上解决问题。
【2】如果一定要这么做,那就需要使用本节课所讲到的两个方法:
MemoryBarrier()
VolatileRead/Write()
也就是:不要进行缓存,每次读取数据都是从memory中读取数据。就不存在上面的问题。
1)UI控件获取其他线程中的数据
2)跨线程读取控件的值