Net学习难点讨论系列17 - 线程本地变量的使用
关于C#多线程的文章,大部分都在讨论线程的起停或者是多线程同步问题。多线程同步就是在不同线程中访问同一个变量(一般是线程工作函数外部的变量),众所周知在不使用线程同步的机制下,由于竟态的存在会使某些线程产生脏读或者是覆盖其它线程已写入的值(各种混乱)。而另外一种情况就是我们想让线程所访问的变量属于线程自身所有,这就是所谓的线程本地变量。
下文我们将逐渐扩展一个最简单的示例代码,来展示上面所说的变量并发访问以及线程本地变量的区别和各自解决方案。
这里要展示的例子很简单。所访问的变量是一个“袋子内苹果的数量”,而工作函数就是“往袋子里放苹果”。
public class Bag
{
public int AppleNum { get; set; }
}
public class Test
{
public void TryTwoThread()
{
var b = new Bag();
Action localAct = () =>
{
for (int i = 0; i < 10; i++)
{
++b.AppleNum;
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {b.AppleNum}");
Thread.Sleep(100);
}
};
Parallel.Invoke(localAct, localAct);
}
}
// Program.cs
var tester = new Test();
tester.TryTwoThread();
如代码所示,这是一段经典的多线程变量并发访问错误的代码。由于没有任何并发访问控制的代码,所以执行结果是不确定的。我们期望的结果是有20个苹果在袋子种,实际情况下很难达到这个结果。
由于执行结果不确定,所以上面只是展示了其中一种随机出现的情况。
解决这个问题的方法就是使用并发控制,最容易的方法就是给共享变量的访问加个锁。
public class Test
{
private object _locker = new object();
public void TryTwoThread()
{
var b = new Bag();
Action localAct = () =>
{
for (int i = 0; i < 10; i++)
{
lock(_locker)
{
++b.AppleNum;
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {b.AppleNum}");
}
Thread.Sleep(100);
}
};
Parallel.Invoke(localAct, localAct);
}
}
这样执行结果就能得到保障,最终袋子里就会有20个苹果。当然还有其它并发控制方法,但那不是本文重点忽略不说。
在某些场景下我们会有另一种需求,我们关心的是每个线程往袋子里放了多少个苹果。这时我们就需要让Bag对象与线程相关(有多个袋子,每个袋子为线程所有)。这就需要用到本文重点要介绍的内容 - 线程本地变量。
在不使用线程本地变量的情况下,实现上述目的的一个简单方法是把变量放入工作函数内部,作为函数内部变量。
public class Test
{
public void TryTwoThread()
{
Action localAct = () =>
{
var b = new Bag(); //把变量访问工作函数当中
for (int i = 0; i < 10; i++)
{
++b.AppleNum;
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {b.AppleNum}");
Thread.Sleep(100);
}
};
Parallel.Invoke(localAct, localAct);
}
}
可以看到结果如我们所愿。
如果我们的工作函数是独立于一个类中,且要并发的访问的变量是这个类的成员,上面这种方法就不适用了。
前面的例子种的Action换成如下的工作类:
public class Worker
{
private Bag _bag = new Bag();
public void PutTenApple()
{
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
}
private void PutApple()
{
++_bag.AppleNum;
}
private void Show()
{
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {_bag.AppleNum}");
}
}
测试方法改为:
public void TryTwoThread()
{
var worker = new Worker();
Parallel.Invoke(worker.PutTenApple, worker.PutTenApple);
}
注意上面的Worker类也是一个不满足我们每个线程独立操作自己关联变量要求的例子。而且由于没有并发控制,程序的执行结果不可控。
我们也可以将
_bag
变量声明于PutTenApple
中来实现与线程本地变量一样的效果,但那样在调用PutApple
和Show
方法时就免不了传参数。
下面开始介绍几种实现线程本地变量的方法。
线程相关的静态字段
第一种方法线程相关的静态字段是使用ThreadStatic
Attribute。这也是微软推荐的性能更好的方法。
其做法是将成员变量声明为static
并打上[ThreadStatic]
这个标记。我们在之前代码的基础上做如下修改:
[ThreadStatic] private static Bag _bag = new Bag();
注意这个实现是有问题的。下面会详细介绍。
如果你的VS上也安装有Resharper这个宇宙级插件,你会看到在初始化这个静态变量的代码下会有这样的提示:
关于这个提示,ReSharper官网也有解释。
简单来说,就是上面的初始化器只会被调用一次,导致的结果就是只有第一个执行此方法的线程能正确获取到_bag
成员的值,之后的进程再访问_bag
时,会发现_bag
仍是未初始化状态 - 为null。
对于这个问题我选择的解决方式是在工作方法中去初始化_bag
变量。
public class Worker
{
[ThreadStatic] private static Bag _bag;
public void PutTenApple()
{
_bag = new Bag(); //调用前初始化
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
}
private void PutApple()
{
++_bag.AppleNum;
}
private void Show()
{
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {_bag.AppleNum}");
}
}
ReSharper网站给出的方法是通过一个属性去包装这个静态字段,并将对静态字段的访问都换成对静态属性的访问。
public class Worker
{
[ThreadStatic] private static Bag _bag;
public static Bag Bag => _bag ?? (_bag = new Bag());
public void PutTenApple()
{
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
}
private void PutApple()
{
++Bag.AppleNum;
}
private void Show()
{
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {Bag.AppleNum}");
}
}
对于线程本地变量,如果在线程外访问,会发现它并没有受到线程操作的影响。
public void TryTwoThread()
{
var worker = new Worker();
Parallel.Invoke(worker.PutTenApple, worker.PutTenApple);
Console.WriteLine($"Main Thread : {Thread.CurrentThread.ManagedThreadId} - {Worker.Bag.AppleNum}");
}
主线程中访问情况:
数据槽
另一种等价的方法是使用LocalDataStoreSlot
,但是性能不如上面介绍的ThreadStatic
方法。
public class Worker
{
private LocalDataStoreSlot _localSlot = Thread.AllocateDataSlot();
public void PutTenApple()
{
Thread.SetData(_localSlot, new Bag());
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
}
private void PutApple()
{
var bag = Thread.GetData(_localSlot) as Bag;
++bag.AppleNum;
}
private void Show()
{
var bag = Thread.GetData(_localSlot) as Bag;
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {bag.AppleNum}");
}
}
把线程相关的数据存储在LocalDataStoreSlot
对象中,并通过Thread
的GetData
和SetData
进行存取。
数据槽还有一种命名的分配方式:
private LocalDataStoreSlot _localSlot = Thread.AllocateNamedDataSlot("Apple");
public void PutTenApple()
{
_localSlot = Thread.GetNamedDataSlot("Apple");//演示用
Thread.SetData(_localSlot, new Bag());
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
}
在多组件的情况下,用不同名称区分数据槽很有用。但如果不小心给不同组件起了相同的名字,则会导致数据污染。
数据槽的性能较低,微软也不推荐使用,而且不是强类型的,用起来也不太方便。
.NET 4 - ThreadLocal
在.NET Framework 4以后新增了一种泛型化的本地变量存储机制 - ThreadLocal<T>
。下面的例子也是在之前例子基础上修改的。对比之前代码就很好理解ThreadLocal<T>
的使用,ThreadLocal<T>
的构造函数接收一个lambda用于线程本地变量的延迟初始化,通过Value属性可以访问本地变量的值。IsValueCreated可以判断本地变量是否已经创建。
public class Worker
{
private ThreadLocal<Bag> _bagLocal = new ThreadLocal<Bag>(()=> new Bag(), true);
public ThreadLocal<Bag> BagLocal => _bagLocal;
public void PutTenApple()
{
if (_bagLocal.IsValueCreated) //在第一次访问后,线程本地变量才会被创建
{
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - 已初始化");
}
for (int i = 0; i < 10; i++)
{
PutApple();
Show();
Thread.Sleep(100);
}
if (_bagLocal.IsValueCreated)
{
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - 已初始化");
}
}
private void PutApple()
{
var bag = _bagLocal.Value; //通过Value属性访问
++bag.AppleNum;
}
private void Show()
{
var bag = _bagLocal.Value; //通过Value属性访问
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} - {bag.AppleNum}");
}
}
另外如果在初始化ThreadLocal<T>
时,将其trackAllValues设置为true,则可以在使用ThreadLocal<T>
的线程外部访问线程本地变量中所存储的值。如在测试代码中:
public void TryTwoThread()
{
var worker = new Worker();
Parallel.Invoke(worker.PutTenApple, worker.PutTenApple);
// 可以使用Values在线程外访问所有线程本地变量(需要ThreadLocal初始化时将trackAllValues设为true)
foreach (var tval in worker.BagLocal.Values)
{
Console.WriteLine(tval.AppleNum);
}
}
关于线程本地变量就写到这吧。欢迎大家指正补充。