介绍
原子操作在学术上被命名为线性化,原子性是与并发进程隔离的保证,它可以通过建立在缓存一致性协议上的硬件级别,或者软件级别的独占锁来执行。在这篇博文中,我将探讨一些在 .Net 中实现原子操作的机制。
什么是原子操作,什么不是?
在C# Specification中,关于原子操作的声明是:
“以下数据类型的读取和写入应是原子的:bool、char、byte、sbyte、short、ushort、uint、int、float 和引用类型。” 另外:“……不保证原子读取-修改-写入,例如在递增或递减的情况下。”。
Joseph Albahari在 C#中的线程描述:
“对 32 位或更少字段的读/写始终是原子的,64 位上的操作只能在 64 位操作系统中保证是原子的,组合多个读/写操作的语句永远不是原子的。”
例如,以下操作保证是原子操作:
int i = 3; // Always atomic
long l = Int64.MaxValue; // Atomic in 64-bit enviroment, non-atomic on 32-bit environment
像下面这样的代码永远不是原子的:
int i = 0;
int j += i; // Non-atomic, read and write operation
i++; // Non-atomic, read and write operation
我正在尝试在下面的部分中以较低级别记录原子操作。
本质
假设两个线程(或两个进程)同时运行:T1 和 T2,内存中存储了一个字段,T1 读取其值并对值进行一些计算,最后将新值写回内存,期间 T2 为实际上在做完全相同的任务——即读/计算/写回值,所以这个字段上的一个操作可能会覆盖另一个——换句话说:后面执行的线程(T2)可能会覆盖前面执行的线程(T1),因为当它读取字段的值另一个线程正在对其进行操作,在 T1 完成将新值写回内存后,T2 回写。
所以一个简单的例子是递增/递减操作,正如我上面展示的,它不是原子操作,它需要读写,如果多个线程同时在一个字段上做递增,很可能会导致竞争条件(更多线程,增量操作越多,竞争条件发生的可能性就越大)。
原子性示例
我写了一个 Winform 应用程序,它做简单的工作:
在运行时创建 10 个线程并同时对一个私有整数进行操作,有一个初始值为 0 的 volatile 计数器,每个线程完成其工作将:
- 在 UI 上打印信息。
- 增加计数器
- 检查计数器是否达到 10,如果是,打印
CalculatingFinished
方法将打印最终结果。
我希望 UI 在计算过程中不会阻塞,否则,我可以简单地加入每个创建的线程。我的代码骨架如下所示:
private const int MaxThraedCount = 10;
private Thread[] m_Workers = new Thread[MaxThraedCount];
private volatile int m_Counter = 0;
private Int32 x = 0;
protected void btn_DoWork_Click(object sender, EventArgs e)
{
ShowStatus("Starting...");
for (int i = 0; i < MaxThraedCount; i++)
{
m_Workers[i] = new Thread(IncreaseNumber) { Name = "Thread " + (i + 1) };
m_Workers[i].Start();
}
}
void IncreaseNumber()
{
try
{
for (int i = 0; i < 10000; i++)
{
// Different strategy to increment x
}
ShowStatus(String.Format("{0} finished at {1}", Thread.CurrentThread.Name, m_Watcher.Elapsed.TotalMilliseconds));
// Increases Counter and decides whether or not sets the finish signal
m_Counter++;
if (m_Counter == MaxThraedCount)
{
// Print finish information on UI
CalculatingFinished();
m_Counter = 0;
}
}
catch (Exception ex)
{
throw;
}
}
public void ShowStatus(string info)
{
this.InvokeAction(() => listInfo.Items.Add(info));
}
private void CalculatingFinished()
{
ShowStatus("\r\nAll Done at: " + m_Watcher.Elapsed.TotalMilliseconds);
ShowStatus("The result: " + x.ToString());
}
我强调了“// 增加 x 的不同策略”,并将尝试几种使用 FCL 库实现原子性的方法。
让我们先看看非原子例程——x++
在每个线程中简单地做:
for (int i = 0; i < 10000; i++)
{
x++;
}
由于x++
不是原子性,另外,我做了一个大循环 - 10000 次,根据我的经验,我从来没有得到正确的结果,截图如下:
分析与解决方案
为了解决问题并保证原子性,通过2个多星期的零碎研究,我发现了以下可行的方法(理论上,不考虑性能):
- 互锁增量
- 在 for 循环之外应用排他锁(或 Monitor.Enter)。
- AutoResetEvent 确保线程一一完成任务。
- 在每个线程中创建一个临时整数,并在完成后将临时添加到 x 在排他锁下。
- ReaderWriterLockSlim。
- Parallel.For 与 Interlocked.Increment 的坐标。
以上都可以实现增加x值的原子操作,并得到预期的结果:
事实上,我也尝试过其他方法,例如使用 MemoryBarrier、Thread.VolatileRead/VolatileWrite — StackOverFlow 问题链接,但失败了,如果亲爱的读者知道有办法使用它们来实现目标,请指导我。
演示代码
在本节中,我将列出实现上述 5 个解决方案的关键代码。
解决方案#1:互锁。增量
for (int i = 0; i < 10000; i++)
Interlocked.Increment(ref x);
解决方案#2:在 for 循环之外应用排他锁(监视器)。
private readonly string m_Locker = "THREAD_LOCKER";
Monitor.Enter(m_Locker);
for (int i = 0; i < 10000; i++)
x++;
Monitor.Exit(m_Locker);
解决方案#3:AutoResetEvent 确保线程一一完成任务。
private static AutoResetEvent m_AutoReset = new AutoResetEvent(false);
protected void btn_DoWork_Click(object sender, EventArgs e)
{
ShowStatus("Starting...");
for (int i = 0; i < MaxThraedCount; i++)
{
m_Workers[i] = new Thread(IncreaseNumber) { Name = "Thread " + (i + 1) };
m_Workers[i].Start();
}
m_AutoReset.Set();
}
void IncreaseNumber()
{
m_AutoReset.WaitOne();
for (int i = 0; i < 10000; i++)
x++;
m_AutoReset.Set();
}
值得注意的一点是在这种情况下(UI 非阻塞),使用 Monitor.Enter/Monitor.Pulse 对来替换 AutoResetEvent 并实现“逐一”逻辑并不容易,因为 Monitor.Pulse 不会保持状态,下面是MSDN 中的描述:
重要的
Monitor类不维护指示 Pulse 方法已被调用的状态。因此,如果您在没有线程等待时调用 Pulse,则调用Wait的下一个线程会阻塞,就好像 Pulse 从未被调用过一样。如果两个线程正在使用 Pulse 和Wait进行交互,这可能会导致死锁。将此与AutoResetEvent类的行为进行对比:如果您通过调用AutoResetEvent方法的Set方法发出信号,并且没有线程在等待,则AutoResetEvent将保持信号状态,直到线程调用WaitOne、WaitAny或WaitAll。这AutoResetEvent释放该线程并返回到无信号状态。
在我的 Winform 应用程序中,如果我在按钮单击事件中调用 Monitor.Pulse(),许多线程将不会收到信号(而 AutoResetEvent 将保持信号状态)!我写了一个简单的例程来证明这一点:
privatestaticreadonlystring_locker= “THREAD_LOCKER” ;
publicstaticvoidMain()
{
for(inti=0;i<5;i++)
{
Threadt=newThread(DoWork);
t.Name= "T" + (i+1);
t.IsBackground=真;
t.Start();
}
//Thread.Sleep(500);
Monitor.Enter(_locker);
Console.WriteLine( “主线程” );
Monitor.Pulse(_locker);
Monitor.Exit(_locker);
}
privatestaticvoidDoWork()
{
Monitor.Enter(_locker);
Monitor.Wait(_locker);
Monitor.Pulse(_locker);
Monitor.Exit(_locker);
Console.WriteLine(Thread.CurrentThread.Name+“完成并存在” );
}删除“
Thread.Sleep(500)
”将*非常可能*导致少于 5 个线程工作,因为创建 5 个线程需要不短的时间(kenel 对象、TEB、kenel/用户堆栈),在刚刚创建的线程(T2)期间可能会收到信号或可能不会(更有可能),因为当之前创建的线程(T1)调用“Monitor.Pulse(_locker)”T2 尚未设置时,T2 和后来创建的线程将没有机会获得信号!他们将等待... 所以 0.5 秒用于给时间创建 5 个线程,否则主线程将立即退出并收集后台线程。
解决方案#4:在每个线程中创建一个临时整数,一旦完成,将临时添加到 x 下的排他锁。
private readonly string m_Locker = "THREAD_LOCKER";
void IncreaseNumber(object objThreadName)
{
int tmp = 0;
for (int i = 0; i < 10000; i++)
tmp++;
lock (m_Locker)
x += tmp;
}
解决方案#5:ReaderWriterLockSlim。
void IncreaseNumber(object objThreadName)
{
// Or we can use ReaderWriterLock.AcquireWriterLock(500) but it has more performance overhead and is not recommended
m_ReaderWriterLocker.EnterWriteLock();
for (int i = 0; i < 10000; i++)
x++;
m_ReaderWriterLocker.ExitWriteLock(); // Or ReaderWriterLock.ReleaseWriterLock();
}
请注意,不推荐使用 ReaderWriterLock 类,它“执行时间大约是调用 Monitor 的 Enter 方法的五倍”。请参考: Jeffery Richter的 Reader/Writer Locks 和 ResourceLock Library。
解决方案#6:Parallel.Forcoordinate 与 Interlocked.Increment。
Parallel.For(0, 100000, (i) => Interlocked.Increment(ref x));
结论
在这篇文章中,我举了一个简单直接的例子:10个线程同时在字段上操作,在C#.Net中实验原子性操作,使用包括独占锁定、信令、非阻塞同步在内的同步技术,我想这是一个非常掌握基本 FCL 线程库/概念的好例子,例如 Interlocked、Monitor、MemoryBarrier、volatile、AutoResetEvent、ReaderWriterLockSlim 等。
多线程编程确实非常复杂,在我调查期间,我碰巧看到连 Jon Skeet 都承认他“睁大眼睛看到了 volatile 的确切含义,它不是“总是从主存读取,总是直接写入主存” (链接),所以作为这个领域的菜鸟,我应该在这方面投入更多的精力:)