线程同步总结

多线程访问共享数据时就会产生线程同步问题,.NET 为解决线程同步问题提供了很多种方法,下面对一些常用的方法做个总结:

1.lock 关键字或 监视器Monitor

lock(obj){
    // synchronized region
}
注意:obj 必须是引用类型,你可以理解为如果是值类型,lock 的是值类型的副本,没有任何意义。

看一个示例:
启动 4 个线程,每个线程多静态变量循环自增 1000000 次,那么结果应该是 4 x 1000000 = 4000000

class Program
    {
        private static volatile int num = 0;
        private static object lockobj = new object();
        static void Main(string[] args)
        {
            Thread[] ts = new Thread[4];
            for (int i = 0; i < 4; i++)
            {
                Thread t = new Thread(new ThreadStart(ThreadPro));
                t.Start();

                ts[i] = t;
            }

            for (int i = 0; i < 4; i++)
            {
                ts[i].Join();
            }

            Console.WriteLine(num.ToString());//最后输出总和
            Console.ReadLine();
        }
        public static void ThreadPro()
        {
            for (int i = 0; i < 1000000; i++)
            {
                lock (lockobj)
                {
                    num++;
                }

            }

            Console.WriteLine("Thread: {0} end", Thread.CurrentThread.ManagedThreadId);
        }

    }
如果不加lock,我们发现运行结果每次都不一样,且循环的数值越大,结果相差越大。这是为什么呢?因为操作 num++ 不是线程安全的。什么意思呢? 我们看一下 num++ 的做了什么就知道了:
1. 把 num 值复制到寄存器
2. 寄存器值 +1
3. 寄存器值拷贝到 num 内存
示例程序启动了 4 个线程,假设某个时间点 num 的值为 100, 假设 1 号线程在执行 num++ 的第一个步骤时后时间片就结束了,1 号线程被线程调度器强制中断,调度器把 CUP 时间分配给 2 号线程,2 号线程开始执行第一个步骤,但注意 2 号线程取到值和 1 号线程的值是一样的,都是 100。于是 2 号进程接着执行步骤 2 , 3 后值为 101,假设时间片结束分配给 1 号线程执行步骤 2,3 结果也为 101,那么久相当于少了一次自增 1 的操作。
这样计算结果我们就不可预期了,也没有任何意义。解决办法就是使 num++ 操作原子化,也就是说 num++ 执行他的三个步骤时只允许一个线程访问。
把上面的注释代码去掉,就是使用 lock 语句线程同步了。同步以后每次的结果都是 4000000.

lock 语句经过编译器后会翻译成:

Monitor.Enter(lockobj);//加锁
try
{
   num++;
}
finally
{
    Monitor.Exit(lockobj);//释放锁
}

 因为这样写起来麻烦,而这种结构又经常用到,所以就用 lock 语句来简化罢了。 

但 Monitor 的用法更丰富一些。它的 TryEnter() 方法可以传递一个超时值,锁定时间超过这个超时值后就吧传出参数置为 false 线程就不再等待

bool lockIsToken = false;//锁是否被占用
Monitor.TryEnter(lockobj,500,ref lockIsToken);//加锁,如果超过500毫秒没有释放锁则自动释放,不再等待
if (lockIsToken)//锁被占用,则执行操作
{
   try
   {
       num++;
   }
   finally
   {
       Monitor.Exit(lockobj);//释放锁
   }
}

2.Interlocked类

此类的方法可以防止可能在下列情况发生的错误:计划程序在某个线程正在更新可由其他线程访问的变量时切换上下文;或者当两个线程在不同的处理器上并发执行时。

Interlocked类的成员不引发异常。
Interlocked 类主要是为了解决变量的并发访问问题。就如 lock 语句中的示例一样自增操作可以使用 Increment (Decrement) 方法。但是 Interlocked 要比其他同步方式要快。

下面列一些 Interlocked 的常用方法:

名称

 说明
Add(Int32, Int32)对两个 32 位整数进行求和并用和替换第一个整数,上述操作作为一个原子操作完成。
Add(Int64, Int64)对两个 64 位整数进行求和并用和替换第一个整数,上述操作作为一个原子操作完成。
CompareExchange(Double, Double, Double)比较两个双精度浮点数是否相等,如果相等,则替换其中一个值。
CompareExchange(Int32, Int32, Int32)比较两个 32 位有符号整数是否相等,如果相等,则替换其中一个值。
CompareExchange(Int64, Int64, Int64)比较两个 64 位有符号整数是否相等,如果相等,则替换其中一个值。
CompareExchange(IntPtr, IntPtr, IntPtr)比较两个平台特定的句柄或指针是否相等,如果相等,则替换其中一个。
CompareExchange(Object, Object, Object)比较两个对象是否相等,如果相等,则替换其中一个对象。
CompareExchange(Single, Single, Single)比较两个单精度浮点数是否相等,如果相等,则替换其中一个值。
CompareExchange(T, T, T)比较指定的引用类型 T 的两个实例是否相等,如果相等,则替换其中一个。
Decrement(Int32)以原子操作的形式递减指定变量的值并存储结果。
Decrement(Int64)以原子操作的形式递减指定变量的值并存储结果。
Exchange(Double, Double)以原子操作的形式,将双精度浮点数设置为指定的值并返回原始值。
Exchange(Int32, Int32)以原子操作的形式,将 32 位有符号整数设置为指定的值并返回原始值。
Exchange(Int64, Int64)将 64 位有符号整数设置为指定的值并返回原始值,上述操作作为一个原子操作完成。
Exchange(IntPtr, IntPtr)以原子操作的形式,将平台特定的句柄或指针设置为指定的值并返回原始值。
Exchange(Object, Object)以原子操作的形式,将对象设置为指定的值并返回对原始对象的引用。
Exchange(Single, Single)以原子操作的形式,将单精度浮点数设置为指定的值并返回原始值。
Exchange(T, T)以原子操作的形式,将指定类型 T 的变量设置为指定的值并返回原始值。
Increment(Int32)以原子操作的形式递增指定变量的值并存储结果。
Increment(Int64)以原子操作的形式递增指定变量的值并存储结果。
MemoryBarrier同步内存存取如下所示:当前执行线程的处理器不能重新排序命令,在内存存取,在对 MemoryBarrier 的调用之后调用 MemoryBarrier的内存存取后之前执行。
Read返回一个以原子操作形式加载的 64 位值。

3.同步事件EventWaitHandle中的AutoResetEvent 和ManualResetEvent

ManualResetEvent和AutoResetEvent在C#中用法比较类似,都是用来做线程控制的, 允许线程通过发信号互相通信。 通常,当线程需要独占访问资源时使用该类。

他们都有对象方法:Set、Reset、WaitOne,用法类似,其中:

Set表示设置为有信号状态,这时调用WaitOne的线程将继续执行;
Reset表示设置为无信号状态,这时调用WaitOne的线程将阻塞;
WaitOne表示在无信号状态时阻塞当前线程,也就是说WaitOne只有在无信号状态下才会阻塞线程。

线程通过调用 XXXResetEvent 上的 WaitOne方法来等待信号。 如果 AutoResetEvent 为 non-signaled 状态,则线程会被阻止,并等待当前控制资源的线程通过调用 Set 方法来通知资源可用。

调用 Set 方法向 XXXResetEvent 发信号以释放等待线程。 XXXResetEvent 将保持 signaled 状态,直到一个正在等待的线程被释放,然后自动返回 non-singaled 状态。 如果没有任何线程在等待,则状态将无限期地保持为 signaled 状态。

如果当 XXXResetEvent 为 signaled 状态时线程调用 WaitOne,则线程不会被阻止。 XXXResetEvent 将立即释放线程并返回到未触发状态。

区别:

对于AutoResetEvent,当调用Set()方法时,处在WaitOne()中的线程哪个先获得CPU,哪个就先执行,然后自动调用Reset方法进而使其他没抢到CPU的线程继续阻塞。

而对于ManualResetEvent,当调用Set方法时,所有处在WaitOne中的线程都继续执行。

 class Program
    {
        private static EventWaitHandle eventWaitHandler = new AutoResetEvent(false);
        public static void Main()
        {
            Thread t1 = new Thread(new ThreadStart(Thread1));
            Thread t2 = new Thread(new ThreadStart(Thread2));
            t1.Start();
            t2.Start();
            Thread.Sleep(1000);
            eventWaitHandler.Set();//设为有信号
            Console.ReadKey();
        }
        private static void Thread1()
        {
            Console.WriteLine("t1 started.");
            eventWaitHandler.WaitOne();
            //eventWaitHandler.Reset();
            Console.WriteLine("t1 completed");
        }
        private static void Thread2()
        {
            Console.WriteLine("t2 started.");
            eventWaitHandler.WaitOne();
           //eventWaitHandler.Reset();
            Console.WriteLine("t2 completed");
        }
    }
//使用AutoResetEvent时,只有一个线程输出completed,而使用ManualResetEvent时,两个线程都输出completed,但是取消reset方法的注释时,则输出结果和AutoResetEvent相同。


根据我的理解打个比方,AutoResetEvent 的实例对象就想是一个通行证,但是这个通行证每次只能使用一次,用完一次(WaitOne 方法)就又要重新充值(调用 Set 方法),而 AutoResetEvent 的构造函数可以传一个布尔值指示这个通行证刚开始充值了没有。

class Program
    {
        private static EventWaitHandle autoEvent = new AutoResetEvent(true);
        public static void Main()
        {
            Thread t = new Thread(new ThreadStart(ThreadPro));
            Console.WriteLine("Thread start...");
            t.Start();
            while (true)
            {
                Console.WriteLine("enter to set a signal");
                Console.ReadLine();
                autoEvent.Set();
            }
        }

        private static void ThreadPro()
        {
            while (true)
            {
                Console.WriteLine("ThreadPro waiting signal...");
                autoEvent.WaitOne();
                Console.WriteLine("ThreadPro get a signal...");
            }
        }
    }
示例中每键入一次 Enter 就会给 autoevent 对象一次(注意不是线程)一个信号(充值一次),线程中的循环就执行一次。如果初始化时 autoenvent 传的是 true 那么程序一启动线程中的循环就会执行一次,因为通行证里已经有钱了 ^_^
细心的人可能会发现了这个示例只有一个线程,如果是多个线程呢?主线程 Set 一次那个线程会执行呢? autoevent.Set() 是给 autoevent 发信号,而每个线程使用的是同一个 autoevent 对象,所以调用 Set() 方法后哪个线程先取得了 CUP 时间那个线程就执行一次(刷完一次卡,卡里有没钱了),其他线程继续等待。所以是抢占式的。

ManualResetEventSlim
.NET Framework 4 使用此 System.Threading.ManualResetEventSlim 类可以获得更好的性能,当等待时间预计非常短时以及当事件不会跨越进程边界时。 它在等待事件变为终止状态时,ManualResetEventSlim 使用繁忙旋转短的时间段。 当等待时间很短时,旋转的开销相对于使用等待句柄来进行等待的开销会少很多。 但是,如果事件在某个时间段内没有变为已发出信号状态,则 ManualResetEventSlim 会采用常规的事件处理等待。

4.等待句柄WaitHandle中的互斥量Mutex和信号量Semaphore

Mutex 是同步基元,与监视器类似;它防止多个线程在某一时间同时执行某个代码块。然而与监视器不同的是,mutex 可以用来使跨进程的线程同步。

mutex只向一个线程授予对共享资源的独占访问权。 如果一个线程获取了互斥体,则要获取该互斥体的第二个线程将被挂起,直到第一个线程释放该互斥体。

Mutex 有两种类型:未命名的局部 mutex(也叫本地mutex) 和已命名的 mutex:(也叫系统mutex)

    如果使用接受名称的构造函数创建了 Mutex 对象,那么该对象将与具有该名称的操作系统对象相关联。 命名的系统 mutex 在整个操作系统中都可见,并且可用于同步进程活动。 您可以创建多个 Mutex 对象来表示同一命名系统 mutex,而且您可以使用 OpenExisting 方法打开现有的命名系统 mutex。

     本地 mutex 仅存在于进程当中。 进程中引用本地 Mutex 对象的任意线程都可以使用本地 mutex。 每个 Mutex 对象都是一个单独的本地 mutex。在本地Mutex中,用法与Monitor基本一致

        private static Mutex mutex = new Mutex();
        
        public static void Main()
        {
            Thread thread;
            for (int i = 0; i < 3; i++)
            {
                thread = new Thread(UsePrinterWithMutex);
                thread.Name = string.Format("Thread{0}", i);
                Thread.Sleep(new Random().Next(3));
                thread.Start();
            }
            Console.ReadKey();
        }
        private static void UsePrinterWithMutex()
        {
            mutex.WaitOne();
            try
            {
                Console.WriteLine("{0} acquired the lock", Thread.CurrentThread.Name);
                //模拟打印操作
                Console.WriteLine("Printing...");
                Thread.Sleep(500);
                Console.WriteLine("{0} exiting lock", Thread.CurrentThread.Name);
            }
            finally
            {
                mutex.ReleaseMutex();
            }
        }
    System.Threading.Semaphore 类表示一个命名(系统范围)信号量或本地信号量。 它是一个对 Win32 信号量对象的精简包装。 Win32 信号量是计数信号量,可用于控制对资源池的访问。
SemaphoreSlim 类表示一个轻量的快速信号量,可用于在一个预计等待时间会非常短的进程内进行等待。 SemaphoreSlim 会尽可能多地依赖由公共语言运行时 (CLR) 提供的同步基元。 但是,它也会根据需要提供延迟初始化的、基于内核的等待句柄,以支持等待多个信号量。 SemaphoreSlim 还支持使用取消标记,但它不支持命名信号量或使用等待句柄来进行同步。

     线程通过调用 WaitOne 方法来进入信号量,此方法是从 WaitHandle 类派生的。 当调用返回时,信号量的计数将减少。 当一个线程请求项而计数为零时,该线程会被阻止。 当线程通过调用 Release 方法释放信号量时,将允许被阻止的线程进入。 并不保证被阻塞的线程进入信号量的顺序,例如先进先出 (FIFO) 或后进先出 (LIFO)。信号量的计数在每次线程进入信号量时减小,在线程释放信号量时增加。 当计数为零时,后面的请求将被阻塞,直到有其他线程释放信号量。 当所有的线程都已释放信号量时,计数达到创建信号量时所指定的最大值。

购买火车票案例:排队进行购买,购买窗口是有限的,只有窗口空闲时才能购买

class Program
    {
        private static Semaphore IdleCashiers = new Semaphore(0, 3);
        
        public static void Main()
        {
            for (int i = 0; i < 5; i++)          
            {
                Thread t = new Thread(new ParameterizedThreadStart(Pay));
                t.Start(i);
            }
            //主线程等待,让所有的的线程都激活
            Thread.Sleep(1000);
            //释放信号量,2个收银员开始上班了或者有两个空闲出来了
            IdleCashiers.Release(2);
            Console.ReadKey();
        }
        private static void Pay(object obj)
        {
            Console.WriteLine("Thread {0} begins and waits for the semaphore.", obj);
            IdleCashiers.WaitOne();
            Console.WriteLine("Thread {0} starts to Pay.", obj);
            //结算
            Thread.Sleep(2000);
            Console.WriteLine("Thread {0}: The payment has been finished.", obj);
            Console.WriteLine("Thread {0}: Release the semaphore.", obj);
            IdleCashiers.Release();
        }
    }

5.读写锁分离之读取器锁和编写器锁ReaderWriterLockSlim

ReaderWriterLockSlim 类允许多个线程同时读取一个资源,但在向该资源写入时要求线程等待以获得独占锁。

可以在应用程序中使用 ReaderWriterLockSlim,以便在访问一个共享资源的线程之间提供协调同步。 获得的锁是针对 ReaderWriterLockSlim 本身的。
设计您应用程序的结构,让读取和写入操作的时间尽可能最短。 因为写入锁是排他的,所以长时间的写入操作会直接影响吞吐量。 长时间的读取操作会阻止处于等待状态的编写器,并且,如果至少有一个线程在等待写入访问,则请求读取访问的线程也将被阻止。

ReaderWriterLockSlim 类似于 ReaderWriterLock,但简化了递归规则以及升级和降级锁定状态的规则。 ReaderWriterLockSlim 可避免多种潜在的死锁情况。 此外,ReaderWriterLockSlim 的性能明显优于 ReaderWriterLock。 对于所有新的开发建议使用 ReaderWriterLockSlim。

    class Program
    {
        private static ReaderWriterLockSlim cacheLock = new ReaderWriterLockSlim();
        private static Dictionary<int, string> innerCache = new Dictionary<int, string>();
        public static void Main()
        {
            for (int i = 0; i < 3; i++)
            {
            
            }
            Console.ReadKey();
        }
        public static string Read(int key)
        {
            cacheLock.EnterReadLock();
            try
            {
                return innerCache[key];
            }
            finally
            {

                cacheLock.ExitReadLock();
            }
        }
        private static void Add(int key, string value)
        {
            cacheLock.EnterWriteLock();
            try
            {
                innerCache.Add(key, value);
            }
            finally
            {
                cacheLock.ExitWriteLock();
            }
        }
        private static bool AddWithTimeout(int key, string value, int timeout)
        {
            if(cacheLock.TryEnterWriteLock(timeout))
            {
                try
                {
                    innerCache.Add(key, value);
                }
                finally
                {
                    cacheLock.ExitWriteLock();
                }
                return true;
            }
            else
                return false;
        }
    }

6.障碍Barrier

使多个任务能够采用并行方式依据某种算法在多个阶段中协同工作。
通过在一系列阶段间移动来协作完成一组任务,此时该组中的每个任务发信号指出它已经到达指定阶段的 Barrier 并且暗中等待其他任务到达。 相同的 Barrier 可用于多个阶段。

7.自旋锁SpinLock

SpinLock 是 .NET 4.0新特性。它在短时间内会使用循环的方式阻塞线程,这样可以提高效率,但是超过一定时间他会使用等待句柄阻塞线程,也就是说和 lock 一样了。

自旋锁可用于叶级锁定,此时在大小方面或由于垃圾回收压力,使用 Monitor 所隐含的对象分配消耗过多。自旋锁非常有助于避免阻塞,但是如果预期有大量阻塞,由于旋转过多,您可能不应该使用自旋锁。当锁是细粒度的并且数量巨大(例如链接的列表中每个节点一个锁)时以及锁保持时间总是非常短时,旋转可能非常有帮助。通常,在保持一个旋锁时,应避免任何这些操作:
1.阻塞,
2.调用本身可能阻塞的任何内容,
3.一次保持多个自旋锁,
4.进行动态调度的调用(接口和虚方法)
5.在某一方不拥有的任何代码中进行动态调度的调用,或
6.分配内存。

SpinLock 仅当您确定这样做可以改进应用程序的性能之后才能使用。另外,务必请注意 SpinLock 是一个值类型(出于性能原因)。因此,您必须非常小心,不要意外复制了 SpinLock 实例,因为两个实例(原件和副本)之间完全独立,这可能会导致应用程序出现错误行为。如果必须传递 SpinLock 实例,则应该通过引用而不是通过值传递。
不要将 SpinLock 实例存储在只读字段中。

 class Program  
    {  
        //得到当前线程的handler  
        [DllImport("kernel32.dll")]  
        static extern IntPtr GetCurrentThread();  
        //创建自旋锁  
        private static SpinLock spin = new SpinLock();  
        public static void doWork1()  
        {  
            bool lockTaken = false;  
            try  
            {  
                //申请获取锁  
                spin.Enter(ref lockTaken);  
                //下面为临界区  
                for(int i=0;i<10;++i)  
                {  
                   Console.WriteLine(2);  
                }  
            }  
            finally  
            {  
                //工作完毕,或者发生异常时,检测一下当前线程是否占有锁,如果咱有了锁释放它  
                //以避免出现死锁的情况  
               if (lockTaken)  
                    spin.Exit();  
            }  
        }  
        public static void doWork2()  
        {  
            bool lockTaken = false;  
            try  
            {  
                spin.Enter(ref lockTaken);  
                for (int i = 0; i < 10; ++i)  
                {  
                    Console.WriteLine(1);  
                }  
            }  
            finally  
            {  
                if (lockTaken)  
                    spin.Exit();  
            }  
  
        }  
        static void Main(string[] args)  
        {  
            Thread[] t = new Thread[2];  
            t[0] = new Thread(new ThreadStart(doWork1));  
            t[1] = new Thread(new ThreadStart(doWork2));  
            t[0].Start();  
            t[1].Start();  
            t[0].Join();  
            t[1].Join();  
            Console.ReadKey();  
        }  
    } 

8.旋转等待SpinWait

System.Threading.SpinWait 是一个轻量同步类型,可以在低级别方案中使用它来避免内核事件所需的高开销的上下文切换和内核转换。 在多核计算机上,当预计资源不会保留很长一段时间时,如果让等待线程以用户模式旋转数十或数百个周期,然后重新尝试获取资源,则效率会更高。 如果在旋转后资源变为可用的,则可以节省数千个周期。 如果资源仍然不可用,则只花费了少量周期,并且仍然可以进行基于内核的等待。 这一旋转-等待的组合有时称为“两阶段等待操作”。

下面的基本示例采用微软案例:无锁堆栈

using System;
using System.Threading;

namespace MutiThreadSample.ThreadSynchronization
{
    public class LockFreeStack<T>
    {
        private volatile Node m_head;

        private class Node { public Node Next; public T Value; }

        public void Push(T item)
        {
            var spin = new SpinWait();
            Node node = new Node { Value = item }, head;
            while (true)
            {
                head = m_head;
                node.Next = head;
                if (Interlocked.CompareExchange(ref m_head, node, head) == head) break;
                spin.SpinOnce();
            }
        }

        public bool TryPop(out T result)
        {
            result = default(T);
            var spin = new SpinWait();

            Node head;
            while (true)
            {
                head = m_head;
                if (head == null) return false;
                if (Interlocked.CompareExchange(ref m_head, head.Next, head) == head)
                {
                    result = head.Value;
                    return true;
                }
                spin.SpinOnce();
            }
        }
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值