闲得蛋疼,记录一下线程的基础概念和常规用法
**进程:**应用程序(typerpa、word、IDEA)运行的时候进入到内存,程序在内存中占用的内存空间(进程)。
线程:进程中负责程序运行的执行单元,也叫执行路径。
线程任务:线程执行的代码内容。
并行:多个cpu实例或者多台机器同时执行处理逻辑,实现真正的同时。
并发:通过cpu的调度算法,快速的切换线程执行线程任务,并不是真正意义上的同时。
线程安全:同一段代码在多线程使用下,不会因为线程的调度顺序而影响任何结果。在出现共享资源的情况下容易出现线程安全问题。确保线程的安全要优先于性能。
争用条件:当多个线程共享访问同一数据时,每个线程都尝试操作该数据,从而导致数据被破坏,这种现象称为争用条件。
注意,尽管多线程的使用可以提升程序的运行效率,充分发挥CPU的作用,但多线程并不是所有时候都可以随便使用的,多线程在时间和空间上都是有开销的,当超过一定限度之后,多线程开得越多,性能反而会下降。因此使用时应该根据实际需求,谨慎使用。
空间上的开销
时间上的开销
在C#中启动多线程需要实例化一个 Thread
对象,然后在构造方法中传入委托对象,为了方便一般会直接传入lambda表达式, 这个lambda表达式中执行的代码片段就是新的线程中所需要执行的任务。
最后,调用Thread
对象Start()
方法来启动新线程。
static void Main(string[] args)
{
var thread = new Thread(() =>
{
Print();
});
thread.Start();
Console.WriteLine($"主线程ID:{Thread.CurrentThread.ManagedThreadId}");
Console.WriteLine("主线程结束!");
}
private static void Print()
{
for (var i = 0; i< 100; i++)
{
Console.WriteLine($"子线程ID:{Thread.CurrentThread.ManagedThreadId}------{i}");
Thread.Sleep(1000);
}
}
另外提一下,因为线程的构造函数Thread()
中所需要传入的参数是一个委托对象,所以如果任务函数不需要传入任何参数,可以直接将任务函数的名称作为参数传给构造函数。
var thread = new Thread(Print);
此外,如果需要提前终结线程,可以使用Thread
对象的Abort()
方法。
Abort()
:在调用此方法的线程上引发 System.Threading.ThreadAbortException
,通过处理异常,去决定程序的终止与否(例如直接不理会异常导致整个程序终止),并不能直接终止线程。
从上面的例子中可以看到,当程序运行起来后,主线程已经运行结束了,但是程序却没有停止,子线程依然在运行,依然每隔1秒输出一次。
这种情况下,持续运行的子线程就被称作前台线程(Foreground Thread)。
一般来说,在前台线程中执行的任务都比较重要,所以,只有等待所有的前台线程结束以后程序才能关闭。
相对而言,后台线程执行的任务就不是那么重要了,只要主线程和所有的前台线程执行结束,就算后台线程的任务还没完成,也会强行打断直接退出。
在 .Net中,除了主线程以外,任何一个线程都可以在任何时候在前台和后台之间随意切换。代码也非常简单,只需要将它的IsBackground
属性设置为true
,那么这个线程就可以被切换为后台线程了。
var thread = new Thread(() =>
{
Print();
});
thread.IsBackground = true;
thread.Start();
特点:
后台线程不会影响进程的终止,属于某个进程的所有前台线程都终止后,该进程就会被终止,所有剩余的后台线程都会被强制停止。
可以在任何时候(线程开始前或开始后都可以)将前台线程修改为后台线程,方式是设置IsBackground
属性。
不管是前台线程还是后台线程,如果线程内出现了异常,都会导致进程的终止。
托管线程池中的线程都是后台线程,使用new Thread
方式创建的线程默认都是前台线程。
线程是非常消耗资源的,如果我们每次需要子线程来执行任务,就去创建一个新的线程,那当我们执行1千次1万次甚至是100万次的时候,那么估计电脑硬件也受不了这么大的资源消耗。
对于这种情况我们就需要使用线程池来管理线程了。只要是通过线程池创建的线程,那么它的所有的生命周期都会托管在线程池内。所有线程池中的线程都是后台线程,线程池会检查程序当前的运行状态,当程序启动时,线程池内的线程也会开始工作;而当Main方法结束以后,线程池会自动关闭,同时中断所有正在运行的线程。
线程池的使用方式很简单,直接调用线程池ThreadPool
类中的静态方法QueueUserWorkItem()
来创建线程池内的线程就可以了。
static bool QueueUserWorkItem(WaitCallback callBack)
:参数为一个带一个object
类型参数的委托,最后返回bool
值成功则返回true
。
static void Main(string[] args)
{
for(var i = 0; i<100; i++)
{
ThreadPool.QueueUserWorkItem((state) =>
{
Console.WriteLine($"子线程ID:{Thread.CurrentThread.ManagedThreadId}");
});
}
Console.WriteLine($"主线程ID:{Thread.CurrentThread.ManagedThreadId}");
Console.WriteLine("主线程结束!");
}
可以看到,上面的代码执行之后,出现了很多重复的线程ID,这就是线程池的厉害之处,他可以在不新建线程的情况下重复使用已经完成的线程,这样可以保证线程维持在一个很低的水平,极大的节约我们的硬件资源。
其次,能够看到当主线程结束之后,线程池中的线程也没有再执行下去了,这是因为线程池中的线程全部默认为后台线程。所以,在实际工作中一定要小心处理业务场景,对于重要、并发量小的线程,我们需要手动来创建和管理,而对于并发量大、而又不那么重要的线程最好托管在线程池中。
思考一个问题,如何在特定的节点取消子线程的任务?
while
循环,然后主线程执行一个文件下载的任务。这个下载任务需要5秒(用 Thread.Sleep(5000)
来代替),要求在文件下载完成以后,要在程序结束前关闭子线程。为应对这种需求,C#对线程取消的代码做了一个高层次的抽象,把整个任务的取消方式通过令牌token的形式封装成为了一个更加通用的取消机制,这就是CancellationToken
。CancellationToken
不仅可以使用在多线程中,也可以用来取消异步操作。
取消令牌资源对象的创建:
使用取消令牌首先需要实例化一个令牌资源CancellationTokenSource
对象。
var cancelToken = new CancellationTokenSource();
取消令牌对象的传递:
从本质上来说,取消令牌就是一个变量,我们需要把这个令牌传递到子线程中去。但是,线程任务传递的是取消令牌CancellationToken
对象,而不是CancellationTokenSource
对象,令牌对象可以通过CancellationTokenSource
对象的Token
属性获取。
var thread = new Thread(()=>{ Print(cancelToken.Token); });
调用取消令牌:
调用取消令牌可以使用CancellationTokenSource
对象中的Cancle()
或CancleAfter()
方法:
Cancle()
:立即调用取消令牌,将取消令牌对象的IsCancellationRequested
属性设置为true
。CancleAfter(int milliseconds)
,传入毫秒作为参数,表示延迟多少毫秒后调用取消令牌,将取消令牌对象的IsCancellationRequested
属性设置为true
。cancleToken.CancelAfter(3000);
取消令牌的使用:
由于取消令牌的状态可以根据取消令牌对象的IsCancellationRequested
属性获取,因此线程任务可以根据该属性来控制任务的结束与否。
static void Main(string[] args)
{
var cancleToken = new CancellationTokenSource();
var thread = new Thread(()=>
{
Print(cancleToken.Token);
});
thread.Start();
Console.WriteLine("开始下载");
Thread.Sleep(5000);
cancleToken.CancelAfter(3000);
Console.WriteLine("下载完成");
}
private static void Print(CancellationToken token)
{
while(!token.IsCancellationRequested)
{
Thread.Sleep(1000);
Console.WriteLine("测试取消令牌!");
}
}
注意
上述方法,尽管看起来可行,但是在Thread
中这样使用是有BUG的,实际上跟使用标识符来决定线程任务的结束没什么区别。
取消令牌更正确的使用方式是配合Task
来使用(详看下文)。
当主线程的任务依赖于子线程的计算结果,比如希望子线程执行完任务后,主线程才继续执行代码。这个时候我们可以使用Thread
对象的Join()
方法。
Join()
:调用此方法后,主线程和子线程绑定起来,主线程会被暂时挂起,等待子线程执行完毕以后主线程才会继续执行。
Join()
方法的调用必须在线程开始之后。static void Main(string[] args)
{
var thread = new Thread(() => PrintHello());
thread.Start();
thread.Join();
Console.WriteLine("文件下载完成");
Console.WriteLine("退出主程序");
}
static void PrintHello()
{
int i = 0;
while (i++ < 10)
{
Thread.Sleep(new Random().Next(100, 1000));
Console.WriteLine("Join test");
}
}
除了使用Join()
方法,其实还有一个比较笨的方法来探测子线程的执行状态。那就是使用一个while
循环来判断线程的IsAlive
属性。
static void Main(string[] args)
{
var thread = new Thread(() => PrintHello());
thread.Start();
while(thread.IsAlive)
{
Thread.Sleep(100);
}
Console.WriteLine("文件下载完成");
Console.WriteLine("退出主程序");
}
使用线程,我们可以并发或并行的在CPU核心中执行任务,最大化CPU的利用率,但是这样也可能导致产生各种各样奇怪的资源竞争问题。例如运行下列代码,程序报错。
static void Main(string[] args)
{
for(int i = 0; i< 10; i++)
{
var thread = new Thread(AddText);
thread.Start();
}
Console.WriteLine("文件下载完成");
Console.WriteLine("退出主程序");
}
static void AddText()
{
File.AppendAllText(@"ThreadTest.txt", $"开始:{Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(1000);
File.AppendAllText(@"ThreadTest.txt", $"结束:{Thread.CurrentThread.ManagedThreadId}");
}
发生这种错误的原因就是不同的线程之间发生了资源的争抢。在其中一个线程打开文件后,但还没完成操作时,CPU就切换到另外一个线程上,这个线程也要打开文件,由于上一个线程还没有将文件流关闭,就导致异常崩溃了。
这种现象就叫做资源竞争。资源竞争在多线程编程中是一个非常常见问题,最严重的情况就是现在这种系统直接崩溃。
那么,避免这种资源的恶性竞争的关键就在于文件必须每次是能由一个线程打开,当前线程操作文件的时候,其他线程需要排队等待。在C#中,有一个关键词叫做lock
,可以帮我们锁定资源来排除资源竞争的现象。
lock
块语法:
lock
块的参数不能是值类型和string
类型,必须是除了string
外的引用类型,而且这个引用类型对象必须是所有线程都能访问到的,否则锁不住。object
对象来作为指定的锁对象。lock(lockObj)
{
......
}
将上述例子中共享资源部分的代码加上锁,就可以避免资源竞争带来的问题。
static object lockObj = new object();
......
static void AddText()
{
lock (lockObj)
{
File.AppendAllText(@"./ThreadTest.txt", $"开始:{Thread.CurrentThread.ManagedThreadId}\n");
Thread.Sleep(1000);
File.AppendAllText(@"./ThreadTest.txt", $"结束:{Thread.CurrentThread.ManagedThreadId}\n");
}
}
PS:这里讲的是最简单的一种线程锁,lock
块是Monitor语法糖,本质是解决资源的争用问题,至于其他锁有空再整理。
void Suspend()
:将线程(执行中或等待状态下)挂起,进入暂停状态。
void Resume()
:继续已经挂起的线程。
void Interrupt()
:中断线程处于WaitSleepJoin
状态的线程。
void Abort()
:在调用此方法的线程上引发ThreadAbortException
,通常用来终止线程(已过时)。
C#4.0时代,出现了Task
,Task
可以简单看作相当于Thead
+TheadPool
,其性能比直接使用Thread
要更好,在工作中更多的是使用Task
来处理多线程任务。
通过创建Task
对象后调用其Start()
函数。
Task task = new Task(() => { Console.WriteLine("线程1"); });
task.Start();
调用Task
的静态方法Run()
Task task = Task.Run(()=>{Console.WriteLine("线程1"); });
通过Task
工厂,开新建一个线程。
Task task = Task.Factory.StartNew(()=>{Console.WriteLine("线程1"); });
Thread
的阻塞是通过对象的Join()
函数来完成,如果有多个线程需要按序执行,就会出现多个Join()
的情况,且不说这样做效率上会怎么样,代码光看着就不舒服。Task
对于阻塞的处理有着更好的方案。
使用Task
对象的Wait()
函数分别等待单个线程任务完成,这种方式跟Thread
的Join
没啥区别,不推荐使用。
Task task1 = new Task(()=>{...});
Task task2 = new Task(()=>{...});
task1.Start();
task2.Start();
task1.Wait();
task2.Wait();
通过Task
的静态方法WaitAll()
来指定等待的一个或多个线程结束。
Task task1 = new Task(()=>{...});
Task task2 = new Task(()=>{...});
task1.Start();
task2.Start();
Task.WaitAll(task1, task2);
现在假设有三个方法Calculate1、Calculate2和Calculate3,其中Calculate3方法需要依赖Calculate1和Calculate2的计算结果,此时可以通过下列代码来实现。
TaskAwaiter GetAwaiter()
:Task
类的实例方法,返回TaskAwaiter
对象。TResult GetResult()
:TaskAwaiter
类的实例方法,返回线程任务的返回结果。Task<int> task1 = Task.Run(Calculator1);
Task<int> task2 = Task.Run(Calculator2);
Task.WaitAll(task1, task2);
TaskAwaiter<int> task1Awaiter = task1.GetAwaiter();
TaskAwaiter<int> task2Awaiter = task2.GetAwaiter();
Task.Run(()=> { Calculator3(task1Awaiter.GetResult(), task2Awaiter.GetResult()); });
Console.Read();
int Calculator1()
{
return 1*1;
}
int Calculator2()
{
return 1 * 2;
}
void Calculator3(int a, int b)
{
Console.WriteLine(a*b);
}
通过Task
的静态方法WaitAny()
来指定等待任意一个线程任务结束。
Task task1 = new Task(()=>{...});
Task task2 = new Task(()=>{...});
task1.Start();
task2.Start();
Task.WaitAny(task1, task2);
在上面的例子中,虽然可以使用Task
的WaitAll()
等静态方法来阻塞线程,等待完成后获取结果,从而实现Calculate1、Calculate2和Calculate3
三个方法的依赖关系。现在需求有变化,在实现Calculate1、Calculate2和Calculate3时,不希望主线程有阻塞。
在这种需求下,WaitAll()
方法显然是行不通的,此时就需要使用到WhenAll().ContinueWith()
,其作用是当WhenAll()
中指定的线程任务完成后再执行ContinueWith()
中的任务,也就是线程任务的延续。而由于这个等待是异步的,因此不会给主线程造成阻塞。
WhenAll(task1,task2,...)
:Task
的静态方法,作用是异步等待指定任务完成后,返回结果。当线程任务有返回值时,返回Task
对象,否则返回Task
对象。WhenAny()
用法与WhenAll()
是一样的,不同的是只要指定的任意一个线程任务完成则立即返回结果。ContinueWith()
:Task
类的实例方法,异步创建当另一任务完成时可以执行的延续任务。也就是当调用对象的线程任务完成后,执行ContinueWith()
中的任务。var task1 = Task.Run(Calculator1);
var task2 = Task.Run(Calculator2);
Task.WhenAll(task1, task2).ContinueWith((data) => { Calculator3(data.Result[0], data.Result[1]); });
Console.WriteLine("主线程已经完成");
Console.Read();
int Calculator1()
{
Thread.Sleep(1000);
return 1*1;
}
int Calculator2()
{
Thread.Sleep(1000);
return 1 * 2;
}
void Calculator3(int a, int b)
{
Console.WriteLine(a*b);
}
显然,ContinueWith()
作为Task
的实例函数,可以通过Task
对象直接调用,由于是异步创建的延续任务因此不会对主线程造成阻塞。
var task1 = Task.Run(Test);
task1.ContinueWith((data) => { Console.WriteLine("线程1的延续"); });
Console.WriteLine("主线程已经完成");
Console.Read();
int Test()
{
Thread.Sleep(1000);
}
TaskCreationOptions
枚举类型中多个成员,其中最常用的为AttachedToParent
和LongRunning
,前者用于将子线程依附到父线程中,后者用于声明耗时运行的线程任务。
假设遇到如下需求,线程parentTask中开启了线程task1和task2,希望在开启parentTask线程任务的主线程中阻塞等待parentTask、task1和task2的任务完成。
此时可以将task1和task2线程依附到parentTask线程上作为parentTask的子线程,这样主线程在等待parentTask线程完成时,就必须同步等待task1和task2线程的任务完成。
子线程的依附操作很简单,在创建线程对象时,传入参数TaskCreationOptions.AttachedToParent
即可。
Task parentTask = new Task(() => {
Task task1 = new Task(() => { Console.WriteLine("task1任务。。。。。。"); }, TaskCreationOptions.AttachedToParent);
Task task2 = new Task(() => { Console.WriteLine("task2任务。。。。。。"); }, TaskCreationOptions.AttachedToParent);
task1.Start();
task2.Start();
});
parentTask.Start();
parentTask.Wait();
Console.WriteLine("这里是主线程");
Console.Read();
当要执行的线程任务比较耗时时,建议在创建线程对象时传入参数TaskCreationOptions.LongRunning
,以此来声明为长时间运行的线程任务。
默认情况下,新建Task
线程是从线程池ThreadPool
中分配出来的,当使用TaskCreationOptions.LongRunning
声明后则是直接新建一个线程。这样就可以避免耗时任务一直占用线程池资源的情况。当然了,也可以直接使用Thread
,效果上是一样的。
Task task = new Task(()=>{...}, TaskCreationOptions.LongRunning);
task.Start();
Task
中的取消功能使用的是CanclelationTokenSource
,即取消令牌源对象,可用于解决多线程任务中协作取消和超时取消。
CancellationToken Token
:CanclelationTokenSource
类的属性成员,返回CancellationToken
对象,可以在开启或创建线程时作为参数传入。bool IsCancellationRequested
:CanclelationTokenSource
类的属性成员,表示当前任务是否已经请求取消。Token
类中也有此属性成员,两者互相关联。void Cancel()
:CanclelationTokenSource
类的实例方法,取消线程任务,同时将自身以及关联的Token
对象中的IsCancellationRequested
属性置为true
。void CancelAfter(int millisecondsDelay)
:CanclelationTokenSource
类的实例方法,用于延迟取消线程任务。CancellationTokenRegistration Register(Action callback)
:Token
类的实例方法,用于注册取消任务后的回调任务。CancellationTokenSource cst = new CancellationTokenSource();
Task task = Task.Run(() => {
while (!cst.IsCancellationRequested)
{
Console.WriteLine("持续时间:" + DateTime.Now);
}
}, cst.Token);//这里第二个参数传入取消令牌
Thread.Sleep(2000);
cst.Cancel(); //两秒后结束
任务的延时取消可以用于访问超时、执行超时等情况下的任务强制终止。
延时取消任务实现起来很简单,就是将CancellationTokenSource
的Cancel()
方法换成CancelAfter(milliseconds)
就好了。
CancelAfter(milliseconds)
也是异步的,这点请注意。CancellationTokenSource cst = new CancellationTokenSource();
Task task = Task.Run(() => {
while (!cst.IsCancellationRequested)
{
Console.WriteLine("持续时间:" + DateTime.Now);
}
}, cst.Token);//这里第二个参数传入取消令牌
cst.CancelAfter(2000); //两秒后结束
如果取消任务后希望做一些处理工作。此时可以使用CancellationToken
类的Register()
函数来注册一个委托(回调函数),用于取消线程后调用。
CancellationTokenSource cst = new CancellationTokenSource();
Task task = Task.Run(() => {
while (!cst.IsCancellationRequested)
{
Console.WriteLine("持续时间:" + DateTime.Now);
Thread.Sleep(500);
}
}, cst.Token);//这里第二个参数传入取消令牌
cst.Token.Register(() => {
Console.WriteLine("开始处理工作......");
Thread.Sleep(2000);
Console.WriteLine("处理工作完成......");
});
Thread.Sleep(2000);
cst.Cancel(); //两秒后结束
在使用Winform或WPF编写程序时,经常会遇到跨线程访问控件的情况,除了使用Invoke
和委托等方法外,还可以有以下两种解决方法。
直接将TaskScheduler
对象做为参数传给Start()
函数(使用TaskScheduler.FromCurrentSynchronizationContext()
可以获得TaskScheduler
对象),以此来将线程任务传送到指定的调度程序中运行,结合WPF编程宝典多线程章节中的内容,应该是将线程任务丢给控件元素所在线程的调度程序中运行。这样做虽然可以跨线程访问控件,但是带来的弊端就是,如果线程任务耗时,就会让整个窗体卡住。
private void Button_Click(object sender, RoutedEventArgs e)
{
Task task = new Task(() =>
{
Thread.Sleep(5000);//模拟耗时处理
txt_Info.Text = "test"; //此为文本控件
});
task.Start(TaskScheduler.FromCurrentSynchronizationContext());
}
针对线程耗时的情况,如果直接使用方式一,会导致整个UI界面都卡住,等到控件处理完成才恢复,这样显然是不可以的。因此要改变一下用法,利用线程延续,将耗时的任务与访问UI控件的任务分为两个线程,访问UI的线程放到延续的线程中。
private void Button_Click(object sender, RoutedEventArgs e)
{
txt_Info.Text = "数据正在处理中......";
txt_Info.Text = "数据正在处理中......";
Task.Run(() =>
{
Thread.Sleep(5000);
}).ContinueWith(t => {
txt_Info.Text = "test";
}, TaskScheduler.FromCurrentSynchronizationContext());
}
Task线程的异常处理不能直接将线程对象相关代码try-catch
来捕获,那样是捕获不到异常的,需要通过调用线程对象的wait()
函数,通过wait()
函数来进行线程的异常捕获。
此外,线程的异常会聚合到AggregateException
异常对象中(AggregateException
是专门用来收集线程异常的异常类),需要通过遍历该异常对象,获取正确的异常信息。
如果捕获到线程异常之后,还想继续往上抛出,就需要调用AggregateException
对象的Handle
函数,并返回false
。(Handle
函数遍历了一下AggregateException
对象中的异常)
Task task1 = Task.Run(() =>
{
throw new Exception("线程1的异常抛出");
});
Task task2 = Task.Run(() =>
{
throw new Exception("线程2的异常抛出");
});
Task task3 = Task.Run(() =>
{
throw new Exception("线程3的异常抛出");
});
try
{
task1.Wait();
task2.Wait();
task3.Wait();
}
catch (AggregateException ex)
{
foreach (var item in ex.InnerExceptions)
{
Console.WriteLine(item.Message);
}
//如果希望再将异常往外抛出,可以调用AggregateException的Handle函数
//ex.Handle(p => false);
}
Console.Read();