C#异步编程学习笔记1 之 线程Thread
C#异步编程
线程 Thread
什么是线程
- 线程是一个可执行路径,它可以独立于其它线程执行
- 每个线程都在操作系统的进程内执行,而操作系统进程提供了程序运行的环境。
- 单线程应用,在进程的独立环境中只跑一个线程,该线程拥有独占权
- 多线程应用,单个进程中会跑多个线程,它们会共享当前执行环境(尤其是内存)。
线程被抢占:当前线程与另外一个线程上的代码的执行交织的那一点。
线程的部分属性:
- IsAlive:线程一旦开始执行,IsAlive为true,线程结束为false;
- 线程结束的条件:线程构造函数传入的委托结束了运行。
- 线程一旦结束,就无法再启动
- Name属性,通常用于调试;线程的Name只能设置一次,以后更改会抛出异常。
- 静态的Thread.CurrentThread属性,返回当前执行的线程。
Thread.Join() && Thread.Sleep()
- 在当前线程中调用另一个线程的join()方法,则当前线程会暂停,等待另一个线程结束后,再进行当前线程。
- Thread.Sleep()会暂停当前线程,并等待一段时间。
using System;
using System.Threading;
namespace CSharp_Thread_Learning_Demo
{
class Program
{
static Thread thread1, thread2;
public static void Main()
{
thread1 = new Thread(ThreadProc);
thread1.Name = "Thread1";
thread1.Start();
thread2 = new Thread(ThreadProc);
thread2.Name = "Thread2";
thread2.Start();
}
private static void ThreadProc()
{
Console.WriteLine("\nCurrent thread:{0}", Thread.CurrentThread.Name);
if (Thread.CurrentThread.Name == "Thread1" && thread2.ThreadState != ThreadState.Unstarted)
{
thread2.Join();
}
Thread.Sleep(2000);
Console.WriteLine("\nCurrent thread:{0}", Thread.CurrentThread.Name);
Console.WriteLine("Thread1:{0}", thread1.ThreadState);
Console.WriteLine("Thread2:{0}", thread2.ThreadState);
}
}
}
【注意】
- Thread.Sleep(0)会导致线程立即放弃本身当前的时间片,自动将CPU移交给其它线程
- Thread.Yield()也可以暂停线程,但它只会把执行交给同一处理器上的其它线程
- 当等待 Sleep或者 Join 时,线程处于阻塞状态
添加超时
调用Join()方法时,可以设置一个超时,用毫秒或者TimeSpan都可以并设置时间限制。如果返回True,线程结束;如此超时了,就返回false;
using System;
using System.Threading;
namespace CSharp_Thread_Learning_Demo
{
class Program
{
static TimeSpan waitTime = new TimeSpan(0, 0, 1); //设置超时,时长为1s
public static void Main()
{
//添加超时
Thread newThread = new Thread(ThreadRun);
newThread.Start();
if (newThread.Join(waitTime + waitTime)) //如果线程newThread在 2s 内执行完,则进入下面代码
{
Console.WriteLine("Terminated");
}
else
{
Console.WriteLine("Time out");
}
}
private static void ThreadRun()
{
//此处省略具体线程实体
}
}
}
阻塞与解除阻塞
阻塞 Blocking
- 如果线程在执行过程中,因为某种原因暂停,那边就认为该线程阻塞。如 Sleep 和 Join方法。
- 被阻塞的线程会立即将处理器的时间片生成给其它线程,不再消耗处理器时间;直到满足其阻塞条件,线程继续执行。
- 可以ThreadState这个属性可以判断线程是否处于被阻塞状态:
ThreadState属性
- ThreadState是一个flags enum(枚举值),但它的大部分枚举值没什么用,其中四个最有价值的是
- Unstarted
- Running
- WaitSleepJoin
- Stopped
- ThreadState属性可以用于诊断的目的,但是不适用于同步,因为线程状态可能会在测试ThreadState和对该信息进行操作之间会产生变化。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EQS48Tpa-1604877912234)(C:\Users\86183\Desktop\学习笔记\图片\image-20201101201031246.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TalpyxSq-1604877912244)(C:\Users\86183\Desktop\学习笔记\图片\image-20201101201354757.png)]
解除阻塞 Unblocking
当遇到以下四种情况下,接触阻塞:
- 阻塞条件被满足
- 操作超时(如果设置超时的话)
- 通过 Thread.Interrupt() 进行打断
- 通过 Thread.Abort() 进行中止
一些其它概念
上下文切换:
当线程阻塞或者接触阻塞时,操作系统将执行上下文切换。这会产生少量开销,一般时1或2微秒。
I/O-bound:
一个花费大部分时间等待某事发生的操作称为“I/O-bound”。I/O绑定操作通常涉及输入输出,但这不是固定的,Thread.Sleep()也被视为“I/O-bound”。
Compute-bound(CPU-bound):
一个花费大部分时间执行 CPU密集型工作的称为“Compute-bound”。
忙等待(自旋 Spinning)
I/O-bound操作的工作方式有两种:
- 在当前线程上的同步等待:Console.WriteLine(),Thread.Sleep(),Thread.Join()…
- 异步的操作,在稍后操作完成时触发一个回调动作。
同步等待的I/O-bound操作将大部分的时间发在阻塞线程上。
忙等待(自旋):I/O-bound操作周期性的在一个循环里面“打转(自旋)”
//1、
while(DataTime.Now < nextStartTime);
//2、
while(DataTime.Now < nextStartTime)
Thread.Sleep(100);
忙等待和阻塞存在一些细微的差别:
- 如果希望条件很快地得到满足,则短暂自旋可能会很有效。因为自旋避免了上下文切换的开销和延迟。
- .Net Framework 提供了特殊的方法和类来提供帮助(SpinLock和SpinWait)
- 阻塞不是零成本。因为每个线程在生成期间会占用月1M的内存,并会给CLR和操作系统带来持续的管理开销。
- 因此,在需要处理成百上千个并发操作的大量I/O-bound程序的上下文中,阻塞可能会很麻烦。
- 故,此类程序需要使用基于回调的方法,在等待时完全撤销其线程。
本地 VS 共享的状态(Local VS Shared State)
Local 本地独立
CLR为每个线程分配自己的内存栈(Stack),以便使本地变量保存独立。
class Program
{
static void Main()
{
new Thread(Go).Start(); //在新线程上调用Go()
Go(); //在Main线程上调用Go
}
static void Go()
{
//C 是本地变量。在每个线程的内存栈上,都会创建c独立的副本。
for(int c = 0; c < 5; c++)
{
Console.Write("?");
}
}
}
Shared 共享
-
如果多个线程都引用到同一个对象的实例,那么它们就共享了数据。
class ThreadTest { bool _done; static void Main() { ThreadTest tt = new ThreadTest(); //创建了一个共同的实例 new Thread(tt.Go).Start(); tt.Go(); } void Go() { //由于两个线程在同一个 ThreadTest 实例上调用的 Go(),所以他们共享_done //结果就只打印一次 Done if(!_done) { _done = true; Console.WriteLine("Done"); } } }结果:
输出一个Done
-
被Lambda表达式或匿名委托所捕获的本地变量,会被编译器转化为字段(field),所以也会被共享。
class ThreadTest { static void Main() { bool done = false; ThreadStart action = () => { if(!done) { done = true; Console.WriteLine("Done"); } } new Thread(action).Start(); action(); } }结果:
输出一个Done
-
静态字段(field)也会在线程间共享数据。
class ThreadTest { static bool _done; //静态字段在同一个应用于下的所有线程中被共享 static void Main() { new Thread(Go).Start(); Go(); } static void Go() { if(!_done) { _done = true; Console.WriteLine("Done"); } } }结果:
输出一个Done
线程安全 Thread Safety
上述“共享”中的三个例子都是线程不安全的。因为这些例子中,输出结果实际是不确定的。因为理论上“Done”可能被打印两次,尤其是当交换了Go方法中的语句顺序之后。
故,尽可能地避免使用共享状态。
class ThreadTest
{
static bool _done; //静态字段在同一个应用于下的所有线程中被共享
static void Main()
{
new Thread(Go).Start();
Go();
}
static void Go()
{
if(!_done)
{
Console.WriteLine("Done");
Thread.Sleep(100);
_done = true;
}
}
}
结果:
输出两个Done
锁定与线程安全(简介)
上述线程安全问题,可以通过“锁定 Locking”来解决。
-
在读取和写入共享数据的时候,通过使用一个互斥锁(exclusive lock),就可以修复前面例子的问题。
-
C#中使用 lock 语句来枷锁
-
当两个线程同时竞争一个锁的时候(锁可以基于任何引用类型对象),一个线程会等待或阻塞,直到锁变成可用状态。
class ThreadSafe { static bool _done; static readonly object _locker = new object(); static void Main() { new Thread(Go).Start(); Go(); } static void Go() { lock (_locker) { if(!_done) { Console.WriteLine("Done"); _done = true; } } } } -
在多线程上下文中,以这种方式避免不确定性的代码就叫做线程安全。
向线程传递数据
-
往线程的启动方法里传递参数,最简单的方式就是使用lambda表达式,在里面使用参数调用方法。
class Program { static void Main() { Thread t = new Thread(() => Print("Hello")); t.Start(); } static void Print(string message) { Console.WriteLine(message); } }class Program { static void Main() { new Thread(() => { Console.WriteLine("Hello"); }).Start(); } } -
在 C#3.0之前,没有lambda表达式,可以使用Thread的Start方法来传递参数。
class Program { static void Main() { Thread t = new Thread(Print); t.Start("HELLO"); } static void Print(object messageObj) { string message = (string)messageObje; Console.WriteLine(message); } }-
Thread的重载构造函数可以接收下列两个委托之一作为参数
public delegate void ThreadStart(); public delegate void ParameterizedThreadStart(object obj);
-
Lambda表达式与被捕获的变量
使用Lambda表达式可以很简单的给Thread传递参数。但是线程开始后,可能会不小心修改了被捕获的变量。
class Program
{
static void Main()
{
//i在循环的整个声明周期内指向的是同一个内存地址
//每个线程对Console.Write的调用都会在运行期间对它进行修改
for(int i = 0; i < 10; i++)
{
new Thread(() => Console.Write(i)).Start();
}
}
}
结果:
输出一堆无规律的数字,数字可能重复
解决方案:
class Program
{
static void Main()
{
for(int i = 0; i < 10; i++)
{
int temp = i;
new Thread(() => Console.Write(temp)).Start();
//此处 顺序仍然无法保证,但是临时变量解决了上文的问题。
}
}
}
结果:
输出数字0~9,数字不会重复,但是顺序仍然是是乱的。
异常处理
-
创建线程时在作用范围内的 try/catch/finally 块,在线程开始执行后就与线程无关了。
也就是说,如果在线程里面产生异常了,在new Thread外的 try/catch/finally 块 无法捕获线程里面异常。
class Program { static void Main() { try { new Thread(Go).Start(); } catch(Exception ex) { Console.WriteLine("Exception"); //此处时无法捕获异常的 } } static void Go() { throw null; //Throws a NullReferenceException } }补救方案:在Go()方法中捕获异常
class Program { static void Main() { new Thread(Go).Start(); } static void Go() { try { throw null; } catch(Exception ex) { Console.WriteLine("Exception"); } } } -
在WPF、WinForm中,可以订阅全局异常处理事件:
- Application.DispatcherUnhandledException
- Application.ThreadException
- 在通过消息循环调用的程序的任何部分发生未处理的异常后,将触发这些异常。(相当于主线程上有未处理的异常,则会触发以上两个事件。)
- 但是非UI线程上的未处理异常,并捕获触发它。
-
而在任何线程有任何未处理的异常时,都会触发AppDomain.CurrentDomain.UnHandledException
前台和后台线程(Foreground vs Background Threads)
默认情况下,手动创建的线程就是前台线程。
只要有前台线程在运行,应用程序就一直处于活动状态。
- 只有后台进程时,应用程序就不会处于活动状态
- 一旦所有前台线程停止,应用程序也就停止了,任何的后台线程也就会跟着停止了。
**【注意】**线程的前台、后台状态与它的优先级无关;优先级即所分配的执行时间。
-
可以通过IsBacskground属性判断线程是否为后台线程
class Program { static void Main(string[] args) { Thread worker = new Thread(() => Console.WriteLine()); if(args.Length > 0) { worker.IsBackground = true; } worker.Start(); //程序运行时,因为是手动创建的线程,是前台线程 //若运行时不传入参数,此时程序会停止在控制台等待输入 //若运行时传入参数,通过IsBacskground将线程设置为了后台线程。在执行完worker.Start()后,主线程执行完毕,此时没有前台线程了,程序终止。 } }进程用以上这种形式终止的时候,后台线程执行栈中的finally块就不会执行了。(少数几种finally不会执行的情况之一)
- 如果想让它执行,可以在退出程序时使用Join来等待后台线程(前提时自己创建的线程);
- …还有其它一些方法,后续学到补充。
**【注意】**应用程序无法正常退出的一个常见原因是还有活跃的前台线程。
线程优先级
线程的优先级(Thread的Priority属性)决定了相对于操作系统中其它活跃线程所占的执行时间。
优先级分为:
- enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
提升线程优先级
提升线程的优先级时需要特别注意,因为可能会“饿死”其它线程。
如果想让某进程(Thread)的优先级比其它进程(Process)中的线程(Thread)高,那边必须提升进程(Process)的优先级。
-
使用System.Diagnostics下的Process类。
using (Process p = Process.GetCurrentProcess()) p.PriorityClass = ProcessPoiorityClass.High;
这适用于只做少量公共,且需要较低延迟的非UI进程。对于需要大量计算的应用程序,尤其是有UI的应用程序,提高优先级可能会使其它进程饿死,从而降低整个计算机的速度。
信号 Singaling
有时候需要让某线程一直处于等待的状态,知道接收到其它线程发来的通知。这就叫做signaling(发送信号)。
最简单的信号结构是 ManualResetEvent
-
调用它的 WaitOne 方法会阻塞当前的线程,直到另一个线程通过调用 Set 方法来开启信号。
class Program { static void Main(string[] args) { var signal = new ManualResetEvent(false); new Thread(() => { Console.WriteLine("Waiting for signal....."); signal.WaitOne(); signal.Dispose(); Console.WriteLine("Got signal!"); } ).Start(); Thread.Sleep(3000); signal.Set(); //打开了信号 } } -
调用完Set之后,信号会于“打开”的状态。可以通过调用Reset方法将其再次关闭。
富客户端应用程序的线程
在WPF、UWP、WinForm等类型的程序中,如果主线程执行耗时的操作,就会导致整个程序无响应。因为主线程同时还需要处理消息循环,而渲染和鼠标键盘事件处理等工作都是消息循环来执行的。
针对这种比较耗时的操作,一种比较流行的做法是启用一个 worker 线程。在worker线程上执行这些比较耗时的操作,执行完操作后,再把结果更新到UI。
富客户端应用的线程模型通常是:
- UI 元素和控件只能从创建它们的线程来进行访问(通常是主 UI 线程)
- 当想从 worker 线程更新 UI 的时候,必须把请求交给 UI 线程。
比较底层的实现是:
- 在WPF中,在元素的 Dispatcher 对象上调用 BeginInvoke 或 Invoke 方法。
- 在WinForm中,调用控件的 BeginInvoke 或 Invoke 方法。
- 在UWP中,调用 Dispatcher 对象上调用 RunAsync 或 Invoke 方法。
上述方法都接收一个委托:
- BeginInvoke 和 RunAsync 通过将委托排队到UI线程的消息队列来执行工作。
- Invoke 执行相同的操作,但随后会进行阻塞,直到 UI 线程读取并处理消息。
- Invoke 允许从方法中获取返回值
- 如果不需要返回值,BeginInvoke 和 RunAsync 更可取,因为它们不会阻塞调用方,也不会引入死锁的可能性。
如下程序,当运行“Thread.Sleep(5000)”时,程序会进入假死状态,UI会在5s中之内无法进行任何操作。
private void Button_Click(object sender, RoutedEventArgs e)
{
Work();
}
void Work()
{
Thread.Sleep(5000);
TxtMessage.Text = "The answer";
}
当更改成如下代码时,程序运行“TxtMessage.Text = “The answer”;”会报错,因为TxtMessage 对象属于主线程,其它线程无法之间更新主线程(UI线程)上的对象 。
private void Button_Click(object sender, RoutedEventArgs e)
{
new Thread(Work).Start();
}
void Work()
{
Thread.Sleep(5000);
TxtMessage.Text = "The answer";
}
当更改成如下代码时,程序不会假死,当运行“Thread.Sleep(5000)”后,依然可以对UI继续进行任何操作,直到5s后输出结果。
//WPF
public partial class MainWindow:Window
{
public MainWindow()
{
InitializeComponent();
}
private void Button_Click(object sender, RoutedEventArgs e)
{
new Thread(Work).Start();
}
void Work()
{
Thread.Sleep(5000);
UpdateMessage("The answer");
}
void UpdateMessage(string message)
{
Action action = () => TextMessage.Text = message;
Dispatcher.BeginInvoke(action); //将委托排队发送到UI线程的消息队列来执行工作
}
}
同步上下文(Synchronization Contexts)
在 System.ComponentModel 下有一个抽象类:SynchronizationContext,它使得 Thread Marshaling 得到泛化。
-
Marshaling :如将C#代码中的数据,转换为Json,即为Marshaling 。
-
Thread Marshaling:把一些数据的所有权,从一个线程交给了另外一个线程。
针对移动、桌面(WPF\WinForm\UWP)等富客户端应用的API,它们都定义和实例化了 SynchronizationContext 的子类
- 当运行在UI线程时,可以通过静态属性 SynchronizationContext.Current 来获得;
- 捕获该属性后,可以从worker线程向UI线程发送数据
- 调用 Post 就相当于调用 Dispatcher 或 Control 上面的 BeginInvoke 方法
- 还有一个 Send 方法,它等价于 Invoke 方法
//WPF
public partial class MainWindow:Window
{
SynchronizationContext _uiSyncContext;
public MainWindow()
{
InitializeComponent();
//为当前 UI 线程捕获 Synchronization Context
_uiSyncContext = SynchronizationContext.Current;
}
void Work()
{
Thread.Sleep(5000); //模拟耗时操作
UpdateMessage("The answer");
}
void UpdateMessage(string message)
{
//把委托 Marshal 给 UI 线程
_uiSyncContext.Post(_ => txtMessage.Text = message, null);
//调用 Post 就相当于调用 Dispatcher 或 Control 上面的 BeginInvoke 方法
}
}
线程池(Thread Pool)
当开始一个线程的时候,将花费几百微秒来组织内容,比如准备一个新的局部变量栈。
线程池可以节省这种开销:
- 通过预先创建一个可循环使用线程的“池子”来减少这一开销。
线程池对于高效的并行编程和细粒度并发时必不可少的。
使用线程池线程需要注意的几点:
- 不可以设置池线程的 Name
- 池线程都是后台线程
- 阻塞池线程可使性能降级
使用池线程可以自由更改池线程的优先级,当它释放回线程池的时候,优先级将还原为正常状态。
可以通过 Thread.CurrentThread.IsThreadPoolThread 属性来判断是否执行在池线程上。
进入线程池
最简单的、显式的在池线程运行代码的方式就是使用 Task.Run
//Task is in System.Threading.Tasks
Task.Run(() => Console.WriteLine("Hello from the thread pool"));
谁使用了线程池
- WCF、Remoting、ASP.NET、 ASMX Web Services 应用服务器
- System.Timers.Timer、System.Threading.Timer
- 并行编程结构
- BackgroundWorker 类(现在很多余)
- 异步委托(现在很多余)
线程池中的整洁
线程池提供了另一个功能,即确保临时超出 Compute-Bound 的工作不会导致 CPU 超额订阅。
CPU 超额订阅:活跃的线程超过 CPU 的核数,操作系统就需要对线程进行时间切片。
超额订阅对性能影响很大,时间切片需要昂贵的上下文切换,并且可能使 CPU 缓存失效,而 CPU 缓存对现代处理器的性能至关重要。
CLR 的策略
- CLR 通过对任务排队并对其启动进行节限制来避免线程池中的超额订阅
- 它首先运行尽可能多的并发任务(只要还有CPU 核),然后通过爬山算法跳转并发级别,并在特定方向上不断调整工作负载。
- 如果吞吐量提高,它将继续朝同一方向(否则将反转)。
- 这确保它始终追随最佳性能曲线,即面对计算机上竞争的进行活动时,也是如此。
- 如果下面两点能够满足,那么CLR的策略将会发挥出最佳效果:
- 工作项大多是短时间运行的(< 250毫秒,或者理想情况下 < 100毫秒),因此 CLR 有很多机会进行测量和调整。
- 大部分时间都被阻塞的工作向不会主宰线程池。
总结:如果向充分利用CPU,那么保持线程池的“整洁”时非常重要的。

316

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



