并发编程 - 线程同步(二)
经过前面对线程同步初步了解,相信大家对线程同步已经有了整体概念,今天我们就来一起看看线程同步的具体方案。
01、ThreadStatic
严格意义上来说这两个并不是实现线程同步方案,而是解决多线程资源安全问题,而我们研究线程同步最终也是为了解决多线程资源安全问题,因此就先说下这两个用法。
ThreadStatic特性可以实现线程本地存储,使得每个线程都有一个独立的字段副本。从而避免不同线程间共享资源。
使用ThreadStatic时需要注意以下几点:
1、ThreadStatic仅能作用于静态字段;。
2、ThreadStatic字段不应使用内联初始化。
3、每个线程都会有独立的_threadLocalVariable实例,当线程退出时,相关的线程本地存储会被清除。
4、由于 ThreadStatic 是线程局部存储,它并不是跨线程共享数据的解决方案。
使用起来也很简单,我们来着重说说上面注意点的第二点,虽然语法上可以写出内联初始化,但是这样会导致一个问题:仅有访问其的首个线程上可以获取其初始化变量值,而其他所有线程都只能获取到变量类型的默认值。比如下面这段代码:
[ThreadStatic]
public static int _threadStaticValue = 1;
public static void ThreadStaticRun()
{
var thread1 = new Thread(ThreadStatic1);
var thread2 = new Thread(ThreadStatic2);
var thread3 = new Thread(ThreadStatic3);
thread1.Start();
thread2.Start();
thread3.Start();
}
static void ThreadStatic1()
{
Console.WriteLine($"线程 Id : {Environment.CurrentManagedThreadId},变量值:{_threadStaticValue}");
}
static void ThreadStatic2()
{
Console.WriteLine($"线程 Id : {Environment.CurrentManagedThreadId},变量值:{_threadStaticValue}");
}
static void ThreadStatic3()
{
Console.WriteLine($"线程 Id : {Environment.CurrentManagedThreadId},变量值:{_threadStaticValue}");
}
也就是上面代码只有一个线程能打印出1,其他线程都只能打印出0,我们看看实际打印结果:
因此注意项第二点提出ThreadStatic字段不应使用内联初始化,因为这样并不能保证每个线程都能获取到相同的初始值。
也因为ThreadStatic有这个缺陷所以引出了ThreadLocal。
02、ThreadLocal
可以说ThreadLocal功能和ThreadStatic完全一样,并且还解决了其缺陷,因此更推荐使用ThreadLocal。
可以使用 System.Threading.ThreadLocal 类型创建一个基于实例的线程本地变量,该变量由你提供的 Action 委托在所有线程上进行初始化。如下示例中,访问_threadLocalValue的所有线程都可以获取到初始化值1。
private static ThreadLocal<int> _threadLocalValue = new ThreadLocal<int>(() => 1);
public static void ThreadLocalRun()
{
var thread1 = new Thread(ThreadLocal1);
var thread2 = new Thread(ThreadLocal2);
var thread3 = new Thread(ThreadLocal3);
thread1.Start();
thread2.Start();
thread3.Start();
}
static void ThreadLocal1()
{
Console.WriteLine($"线程 Id : {Environment.CurrentManagedThreadId},变量值:{_threadLocalValue.Value}");
}
static void ThreadLocal2()
{
Console.WriteLine($"线程 Id : {Environment.CurrentManagedThreadId},变量值:{_threadLocalValue.Value}");
}
static void ThreadLocal3()
{
Console.WriteLine($"线程 Id : {Environment.CurrentManagedThreadId},变量值:{_threadLocalValue.Value}");
}
执行结果如下:
并且可以通过ThreadLocal.Value 属性进行读取和写入,也就是通过_threadLocalValue.Value对变量进行赋值和取值。
03、volatile关键字
首先volatile关键字同样不是一个完整的线程同步机制,其主要作用是防止缓存和防止编译器优化。
在C#语言开发中,由于编译器优化、JIT 编译、硬件缓存以及内存重排序等行为,很容易使得程序出现并发错误,尤其在多线程环境下这些情况会更为明显。虽然这些优化是在不影响程序逻辑的情况下进行的,但是因为重新排序对内存的读取和写入,进而可能导致数据竞争和同步问题。
volatile关键字就是为了告诉编译器和运行时:该字段的值可能会被多个线程同时修改,因此每次访问该字段时,都应该直接从主内存中读取,而不是使用寄存器或缓存中的值。这样可以防止 CPU 的优化行为导致某些线程读取到过时的值。
我们一起看看如下代码:
//控制线程的标志
private static bool _flag = false;
//计数器
private static int _counter = 0;
public static void VolatileRun()
{
var thread1 = new Thread(Volatile1);
var thread2 = new Thread(Volatile2);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
//Console.WriteLine($"计数器最后的值: {counter}");
}
static void Volatile1()
{
//注意:以下两行代码可能按相反的顺序执行
//设置计数器
_counter = 88;
//线程1:设置标志位,并且增加计数器
_flag = true;
}
static void Volatile2()
{
//注意:_counter可能优先于_flag读取
//线程2:等待标志位变为 true,然后读取计数器
//等待 _flag 被设置为 true
while (!_flag) ;
//打印计数器值
Console.WriteLine($"当前计数器的值: {_counter}");
}
上面的代码很难在复现下面要说的问题,因此下面仅以此代码作为示例讲解。
上面代码的问题在于,经过编译器优化和内存重排序后, Volatile1线程中的两行赋值代码可能被颠倒了顺序,如果从单线程角度来说这个顺序颠倒无关紧要,最总结果都是_counter被赋值了88,_flag被赋值了true。但是在多线程环境下,对于Volatile2线程来说就完全不一样了,此时却先读取到_flag为true,然后打印_counter为0,和预期完全不一样。
我们再从另一个角度来说,假定Volatile1线程中的代码安装编码顺序执行了,没有被优化。在编译Volatile2线程中的代码时,编译器必须生成代码将_flag和_counter从RAM(主存)中读入CPU寄存器,此时RAM可能先读入_counter的值,为0。与此同时Volatile1线程可能执行,将_counter修改为88,想_flag修改为true。此时Volatile2线程的CPU寄存器还没有看到_counter已被Volatile1线程修改为88,然后继续将_flag的值从RAM中读入CPU寄存器,但是由于此时_flag已经被Volatile1线程修改为true,所以最后Volatile2线程同样会打印_counter为0。
开发时很容易忽略这些细微之处,并且由于开发调试环境不会进行代码优化,就导致问题往往到了生产环境下才显现出来。
为了解决这个问题我们就可以使用volatile关键字了。对于被声明为volatile的字段将从编译器优化、JIT 编译、硬件缓存以及内存重排序等优化中排除,使用也很简单,可以如下使用:
private static volatile bool _flag = false;
另外volatile关键字不能引用于double,long,数组等类型,可以使用Volatile.Read和Volatile.Write静态方法来完成。
同时volatile关键字虽然可以解决许多并发问题,但是因为其不是原子操作,因此它并不能算是一个完整的线程同步机制,因此在多线程环境下还是需要借助一些其他同步机制来保证线程安全。
因此volatile最大的应用场景就是在需要保证多个线程访问同一个共享变量时,大家都可以立刻看到最新的值,尤其是不涉及复杂操作如递增递减等。
注:测试方法代码以及示例源码都已经上传至代码库,有兴趣的可以看看。https://gitee.com/hugogoos/Planner