C #中的原子操作

介绍

原子操作在学术上被命名为线性化,原子性是与并发进程隔离的保证,它可以通过建立在缓存一致性协议上的硬件级别,或者软件级别的独占锁来执行。在这篇博文中,我将探讨一些在 .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 计数器,每个线程完成其工作将:

  1. 在 UI 上打印信息。
  2. 增加计数器
  3. 检查计数器是否达到 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个多星期的零碎研究,我发现了以下可行的方法(理论上,不考虑性能):

  1. 互锁增量
  2. 在 for 循环之外应用排他锁(或 Monitor.Enter)。
  3. AutoResetEvent 确保线程一一完成任务。
  4. 在每个线程中创建一个临时整数,并在完成后将临时添加到 x 在排他锁下。
  5. ReaderWriterLockSlim
  6. 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将保持信号状态,直到线程调用WaitOneWaitAnyWaitAllAutoResetEvent释放该线程并返回到无信号状态。

在我的 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 的确切含义,它不是“总是从主存读取,总是直接写入主存” (链接),所以作为这个领域的菜鸟,我应该在这方面投入更多的精力:)

C语言原子操作函数是一种特殊的函数,用于在多线程环境下对共享资源进行原子操作,即不会被其他线程断的操作。这些函数可以确保在执行期间不会发生竞争条件或数据不一致的情况。 C语言常用的原子操作函数有以下几种: 1. atomic_flag_test_and_set():该函数用于设置一个原子标志,并返回之前的值。如果之前的值为true,则表示已经被设置过,否则表示之前未被设置。 2. atomic_flag_clear():该函数用于清除一个原子标志,将其设置为false。 3. atomic_load():该函数用于原子地加载一个变量的值,并返回该值。 4. atomic_store():该函数用于原子地存储一个值到一个变量。 5. atomic_exchange():该函数用于原子地交换两个变量的值。 6. atomic_compare_exchange_strong():该函数用于原子地比较并交换两个变量的值。如果比较成功,则交换并返回true;否则返回false。 7. atomic_fetch_add():该函数用于原子地将一个值加到一个变量,并返回之前的值。 8. atomic_fetch_sub():该函数用于原子地将一个值从一个变量减去,并返回之前的值。 9. atomic_fetch_and():该函数用于原子地将一个值与一个变量进行按位与操作,并返回之前的值。 10. atomic_fetch_or():该函数用于原子地将一个值与一个变量进行按位或操作,并返回之前的值。 11. atomic_fetch_xor():该函数用于原子地将一个值与一个变量进行按位异或操作,并返回之前的值。 这些原子操作函数可以保证在多线程环境下对共享资源的操作是原子的,避免了竞争条件和数据不一致的问题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值