C# 异步编程基础(二)线程安全、向线程传递数据和异常处理
此入门教程是记录下方参考资料视频的过程
开发工具:Visual Studio 2019
目录
C# 异步编程基础(六)Continuation 继续/延续 、TaskCompletionSource、实现 Task.Delay
C# 异步编程基础(十) 取消(cancellation)、进度报告、TAP(Task-Based Asynchronous Pattern)、Task组合器
本地 vs 共享的状态 Local vs Stared State
- Local 本地独立
CLR为每个线程分配自己的内存栈(Stack),以便使本地变量(局部变量)保持独立
例子,结果会输出10个 ?
public static void Main(string[] args)
{
//在新线程上调用Go()
new Thread(Go).Start();
//在main线程上调用Go()
Go();
}
public static void Go()
{
//cycles是本地变量
//在每个线程的内存栈上,都会创建cycles独立的副本
for (int cycles = 0; cycles < 5; cycles++)
{
Console.Write("?");
}
}
- hared 共享
如果多个线程都引用到同一个对象的实例,那么它们就共享了数据
例子,由于两个线程是在同一个 ThreadTest 实例上调用Go(),所以它们共享_done,结果就是只打印一次 Done
public static void Main(string[] args)
{
//创建了一个共同的实例
Program tt = new Program();
new Thread(tt.Go).Start();
tt.Go();
}
//这是一个实例方法
void Go()
{
if (false == _done)
{
_done = true;
Console.WriteLine("Done");
}
}
被Lambda表达式或匿名委托所捕获的本地变量,会被编译器转化为字段(field),所以也会被共享
例子,结果就是只打印一次 Done
public static void Main(string[] args)
{
bool done = false;
ThreadStart action = () =>
{
if (false == done)
{
done = true;
Console.WriteLine("Done");
}
};
new Thread(action).Start();
action();
}
静态字段(field)也会在线程间共享数据
例子,结果就是只打印一次 Done
public static void Main(string[] args)
{
new Thread(Go).Start();
Go();
}
public static void Go()
{
if (false == _done)
{
_done = true;
Console.WriteLine("Done");
}
}
线程安全
- 后三个例子就引出了线程安全这个关键概念(或者说缺乏线程安全)
- 上述例子的输出实际上是无法确定的:
有可能(理论上)“Done”会被打印两次
如果交换Go()方法里语句的顺序,那么“Done”被打印两次的几率会大大增加
因为一个线程可能正在评估if,而另外一个线程在执行WriteLine语句,它还没来得及把done设置为true - 尽量的避免使用共享状态
例子,会输出两次Done
public static void Main(string[] args)
{
new Thread(Go).Start();
Go();
}
public static void Go()
{
if (false == _done)
{
Console.WriteLine("Done");
Thread.Sleep(100);
_done = true;
}
}
锁定与线程安全 简介 Locking & Thread Safety
- 在读取和写入共享数据的时候,通过使用一个互斥锁(exclusive lock),就可以修复前面例子的问题
- C#使用 lock 语句来加锁
- 当两个线程同时竞争一个锁的时候(锁可以基于任何引用类型对象),一个线程会等待或阻塞,直到锁变成可用状态
- 在多线程上下文中,以这种方式避免不确定性的代码就叫做线程安全
- lock不是线程安全的银弹,很容易忘记对字段加锁,lock也会引起一些问题(死锁)
例子,结果输出一次Done
static bool _done;
static readonly object _locker = new Object();
static void Main(string[] args)
{
new Thread(Go).Start();
Go();
}
static void Go()
{
lock (_locker)
{
if (false == _done)
{
Console.WriteLine("Done");
_done = true;
}
}
}
向线程传递数据
- 如果你想往线程的启动方法里传递参数,最简单的方式就是使用lambda表达式,在里面使用参数调用方法(例子lambda,在lambda表达式里调用方法)
static void Main(string[] args)
{
Thread t=new Thread(()=>Print("Hello from t!"));
t.Start();
}
static void Print(string message)
{
Console.WriteLine(message);
}
- 甚至可以把整个逻辑都放在lambda里面(例子multi-lambda)
static void Main(string[] args)
{
new Thread(()=>
{
Console.WriteLine("I'm running on another thread!");
}).Start();
}
- 向线程传递数据,在C# 3.0之前没有lambda表达式,可以使用Thread的Start方法来传递参数
static void Main(string[] args)
{
Thread t=new Thread(Print);
t.Start("Hello from t!");
}
static void Print(object messageObj)
{
string message=(string)messageObj;
Console.WriteLine(message);
}
-
Thread的重载构造函数可以接受下列两个委托之一作为参数:
public delegate void ThreadStart();
public delegate void ParameterizedThreadStart(object obj); -
lambda表达式与被捕获的变量
使用lambda表达式可以很简单的给Thread传递参数。但是线程开始后,可能会不小心修改了被捕获的变量,这要多加注意
例子,i在循环的整个生命周期内指向的是同一个内存地址,每个线程对Console.WriteLine()的调用都会在它运行的时候对它进行修改
static void Main(string[] args)
{
for(int i=0;i<10;i++)
{
new Thread(()=>Console.Write(i)).Start();
}
}
解决方案,但是顺序仍然无法保证
static void Main(string[] args)
{
for(int i=0;i<10;i++)
{
int temp=i;
new Thread(()=>Console.Write(temp)).Start();
}
}
异常处理
- 创建线程时在作用范围内的try/catch/finally块,在线程开始执行后就与线程无关了(创建线程的地方不会捕获线程的异常)
public static void Main(string[] agrs)
{
try
{
new Thread(Go).Start();
}
catch(Exception e)
{
Console.WriteLine("Exception!");
}
}
static void Go()
{
throw null;
}
补救办法是把异常处理放在Go方法里面
public static void Main(string[] agrs)
{
new Thread(Go).Start();
}
static void Go()
{
try
{
throw null;
}
catch(Exception e)
{
Console.WriteLine("Exception!");
}
}
- 在WPF、WinForm里,可以大约全局异常处理事件:
Application.DispatcherUnhandledException
Application.ThreadException
在通过消息循环调用的程序的任何部分发生未处理的异常(这相相当于应用程序处于活动状态时在主线程上运行的所有代码)后,将触发这些异常
人话:主线程上有未处理的异常就会被触发
但是非UI线程上未处理异常,并不会触发它 - 而任何线程有任何未处理的异常都会触发
AppDomain.CurrentDomain.UnhandledException
前台和后台线程 Foreground vs Background Threads
- 默认情况下,你手动创建的线程就是前台线程
- 只要有前台线程在运行,那么应用程序就会一直处于活动状态
但是只有后台线程却不行
一旦所有的前台线程停止,那么应用程序就停止了
任何的后台线程也会突然停止 - 注意:线程的前台、后台状态与它的优先级无关(所分配的执行时间)
- 可以通过IsBackground属性判断线程是否是后台线程
static void Main(string[] args)
{
Thread worker=new Thread(()=>Console.ReadLine());
if(args.Length>0)
{
worker.IsBackground=true;
}
worker.Start();
}
如果是前台线程,会一直等待输入,不输入就不终止
如果是后台线程,主线程结束之后,程序就会终止,不会等待我们的输入
- 进程以这种形式终止的时候,后台线程执行栈中的finally块就不会被执行了
如果想让它执行,可以在退出程序时使用Join来等待后台线程(如果是你创建的线程),或者使用signal construct,如果是线程池... - 应用程序无法正常退出的一个常见原因是还有活跃的前台线程