当你需要 2 个线程读写同一个数据时, 就需要数据同步线程同步的办法有:(1)原子操作;(2)锁原子操作能够保证该操作在 CPU 内核中不会被拆分, 锁能够保证只有一个线程访问该数据, 其他线程在尝试获得有锁的数据时, 会被拒绝, 直到当前获得数据的线程将锁释放, 其他线程才能够获得数据
为什么要线程同步?
我们先看一个需要数据同步的例子,
- static void Main(string[] args){
- bool flag = false;
- var t1 = new Thread(() => { if (flag) Console.WriteLine("Flag"); });
- var t2 = new Thread(() => { flag = true; });
- t1.Start();
- t2.Start();
- Console.ReadLine();
- }
上述例子中, t2 线程将 flag 置为 true, 有可能发生: 当 t2 打算执行 flag = true 时, t1 执行了 if(flag)语句, 这造成了不可知的情况此时就需要在 t2 执行时, 若 t1 想要获取 flag 的值, 要等到 flag=true 执行完成后, 再执行, 这就是所谓的线程同步, 一个线程要等待另一个线程执行到某段代码后, 再执行线程同步能保证程序的执行符合预想 -- 若 t2 没有执行, 则 flag 为 false,t2 若已执行, 则 flag=true 线程同步是为了防止 t2 正在执行 flag=true 的时候, t1 开始执行, 此时 flag 应该是 true, 因为 t2 已经开始执行了, 但是实际上 flag=false, 因为 t2 的 flag=true 没有执行完解决的办法就是当 t2 执行 flag=true 时, 将任何尝试读取 flag 的线程都阻塞, 直到 flag=true 执行结束后, 其他线程再执行类似下面的代码
- var m_lock = GetSomeLock();
- pulick void Go(){
- var t1 = new Thread(()=>Go1());
- var t2 = new Thread(()=>Go2());
- t1.Star();
- t2.Start();
- }
- public void Go1(){
- m_lock.lock();
- if (flag)
- //dosomething;
- Console.WriteLine(flag);
- m_lock.Unlock();
- }
- public void Go2(){
- m_lock.lock();
- flag = true;
- m_lock.Unlock();
- }
在 flag=true 和 if(flag)外面添加 m_lock.lock()和 m_lock.Unlock()就是为了保证线程同步但是这样的同步带来的问题就是性能的下降, 还有可能造成死锁摘要中说过, 线程同步有 2 个手段, 上面介绍了锁, 还有原子操作我没有介绍在介绍原子操作之前, 我介绍下关键字 volatile
关键字 volatile
该关键字能够作用在变量前, 其意义是对该变量的读写操作都是原子操作, 这种特性被称作易变性
编译器在编译过程中, 会根据代码的具体情况进行适当优化, 例如:
- public void Go(){
- int value = 100 * 1 - 50 * 2;
- for (int i = 0; i < value; i++)
- Console.WriteLine(i);
- }
编译器在看到有地方调用该方法, 会跳过其中的语句, 因为这段语句毫无意义, 这当然是好的, 编译器弥补了我们的错误但是有的时候这种优化会造成我们不想要的效果
- private static bool s_stopWorker = false;
- static void Main(string[] args){
- Console.WriteLine("Main:letting worker run for 5s");
- var t = new Thread(Worker);
- t.Start();
- Thread.Sleep(5000);
- s_stopWorker = true;
- Console.WriteLine("Main: waiting for worker to stop.");
- t.Join();
- }
- private static void Worker(object o){
- int x = 0;
- while (s_stopWorker) x++;
- Console.WriteLine("Worker: stopped when x = {0}", x);
- }
该段代码中, 主线程阻塞 5 秒, 然后 s_stopWorker=true, 本意是要中断 t 线程, 让其显示数到的数后返回但实际上编译器在看到 while(s_stopWorker)时, 又看到 s_stopWorker 在 Worker 方法中没有任何改变, 因此该方法中对 s_stopWorker 的判断只会在最开始判断一次, 若 s_stopWorker=true, 则进入死循环, 若是 false, 则显示 Worker stopped when x = 0 之后该线程就返回了若想实际看到运行效果, 需要将改短代码放在. cs 文件中, 利用命令行编译该段代码利用命令行编译代码要添加环境变量, 变量的路径是 C:\Windows\Microsoft.NET\Framework\v4.0.30319 然后就可以在命令行中编译该文件, 注意要打开 / platform:x86, 其意义在 CLR via C#29 章中有解释, x86 编译器比 x64 编译器更成熟, 优化也更大胆在命令行中输入 csc /platform:x86 你的 cs 文件的路径, 之后在输入 Program.exe(假设你的文件名字叫 Program.cs), 之后你会看到程序一直卡死在 Main: waiting for worker to stop. 之后一直没有出现数到的数字
下面来讨论如何解决这个问题在 System.Threading.Volatile 中提供了 2 个静态方法,
- public static class Volatile{
- public static bool Read(ref bool location);
- public static bool Write(ref bool location, bool value);
- }
这两个方法能够阻止编译器对读和写进行优化, 修改后的代码如下:
- private static bool s_stopWorker = false;
- static void Main(string[] args){
- Console.WriteLine("Main:letting worker run for 5s");
- var t = new Thread(Worker);
- t.Start();
- Thread.Sleep(5000);
- // 防止优化
- Volatile.Write(ref s_stopWorker, true);
- Console.WriteLine("Main: waiting for worker to stop.");
- t.Join();
- Console.Read();
- }
- private static void Worker(object o){
- int x = 0;
- // 防止优化
- while (Volatile.Read(ref s_stopWorker)) x++;
- Console.WriteLine("Worker: stopped when x = {0}", x);
- }
在 s_stopWorker 的读写处, 都改用了 Volatile 类中的 Read 和 Write 方法再次利用命令行编译该代码, 会发现运行正常很多时候我们搞不清到底该什么时候调用 Volatile 中的读写, 什么时候该正常读写, 于是 C# 提供了 volatile 关键字, 该关键字能够保证对该变量的读写都是原子的, 并且能够阻止对该方法进行优化由于为了提高 CPU 的运行效率, 现在的程序都是乱序执行, 但是 volatile 能够保证该关键字之前的代码会在该关键字的变量读写时已经执行完成, 该关键字修饰的变量以后的代码一定会在之后执行, 而不会因乱序优化而在之前执行我们去掉 Volatile.Write 和 Read, 然后将 s_stopWorker 前加上 volatile 关键字, 运行上述代码, 会发现结果正确
volatile 关键字能够保证变量的线程安全, 但是其缺点也是很明显的, 将变量的每次读写都变成易变的读写, 是对性能的浪费, 因为这种情况极少发生
- volatile int m = 5;
- m=m+m;//volatile 会阻止优化
通常, 将一个变量增大一倍, 只需要将该变量左移一位, 就可以, 但是 volatile 会阻止该优化 CPU 会将 m 读入一个寄存器, 然后读入另一个寄存器, 然后在执行 add, 再将结果写入 m 如果 m 不是 int 类型, 而是更大的类型, 则造成更大的浪费, 如果在循环中, 那真是杯具
另外 C# 不支持将有 volatile 修饰的变量以引用的形式传入方法, 如 Int32.TryParse("123", m); 会得到一个警告, 对 volatile 字段的引用将不被视为 volatile
变量捕获(闭包)
第一段代码中, flag 变量被 lamda 表达式包含程序并没有在主线程中执行, 而是在 t1 和 t2 中执行, 该变量已经脱离了它的作用域, 为了保证 flag 变量能够生效, 编译器负责延长 flag 的生命周期, 以保证在 t1 和 t2 线程执行时, 该变量能够被访问, 这就是变量捕获, 也叫闭包, 可以利用 IL 反编译器查看上述代码的 IL 指令来验证
上图可以看到为了保证 flag 的生命周期编译器将 2 个 lamda 表达式 (b_0 和 b_1) 和 flag 用一个类包了起来, 这样这 3 个的生命周期就一致了这很好, 因为不需要我们去关心在 t1 和 t2 获取 flag 值时, flag 是否有效, 编译器已经帮我们全做了
本文讲了线程安全的必要性以及线程安全的手段之一: volatile(易变性), 还简单介绍了变量捕获线程安全的内容还没讲完, 预计分 3-4 篇博客来讲线程安全欢迎小伙伴在评论区与我交流
来源: https://www.cnblogs.com/jazzpop/p/8547015.html