理解线程同步

线程的数据访问

在并行(多线程)环境中,不可避免地会存在多个线程同时访问某个数据的情况。多个线程对共享数据的访问有下面3种情形:

  1. 多个线程同时读取数据;

  2. 单个线程更新数据,此时其他线程读取数据;

  3. 多个线程同时更新数据。

显而易见,多个线程同时读取数据是不会产生任何问题的。仅有一个线程更新数据的时候,貌似也没有问题,但真的没有问题吗?多个线程同时更新数据,很明显,你可能把我的更改覆盖掉了,数据从此不再可信。

什么是线程同步

为了解决多线程同时访问共享数据可能导致数据被破坏的问题,我们需要采取一些措施来保证数据的一致性,让每个线程都能准确地读取或更新数据。

问题的根源在于多个线程同时访问数据,那么只要我们保证同一时间只有一个线程访问数据,就能解决问题。保证同一时间只有一个线程访问数据的处理,就是线程同步了。我在访问数据的时候,你们都先等着,我完事了你们再来。

C#中的线程同步

.NET提供了很多线程同步的方式,这些方式分为用户模式和内核模式以及混合模式(即用户模式与内核模式的结合),下面会总结C#/.NET中各模式下的线程同步。

用户模式与内核模式

Windows操作系统下,CPU跟据所执行代码的不同,会在两种模式下进行切换。CPU执行应用程序代码(如我们开发的.NET程序)时,一般运行在用户模式下;执行操作系统核心代码(内核函数或者某些设备驱动程序)时,CPU则切换到内核模式。

用户模式的代码只能访问自身进程的专有地址空间,代码异常不会影响到其他程序或者操作系统;内核模式的所有代码共享单个地址空间,代码异常将可能导致系统崩溃。CPU的模式切换,是为了保证应用程序和操作系统的稳定性。

应用程序中,线程可以通过Windows API调用操作系统内核函数,这时候执行线程的CPU将从用户模式切换到内核模式,执行完操作系统函数后,再由内核模式切换到用户模式。CPU的模式切换是很耗时的,据《Windows核心编程》中的描述,CPU模式的切换,要占用1000个以上的CPU周期。因此,在我们的.NET程序中,应该尽可能地避免CPU的模式切换。

用户模式线程同步

用户模式下,利用特殊的CPU指令来协调线程,使同一时间只有一个线程能访问某内存地址,这种协调在硬件中发生,速度很快。这种模式下,CPU指令对线程的阻塞很短暂,操作系统调度线程时不会认为该线程已被阻塞,这种情况下,线程池不会创建新的线程来替换该线程。

用户模式下,等待资源的线程会一直被操作系统调度,导致线程的“自旋”并因此浪费很多的CPU资源。如果某线程一直占着资源不释放,等待该资源的线程将一直处于自旋状态,这样就造成了“活锁”,活锁除了浪费内存外,还会浪费大量CPU。

.NET提供两种用户模式的线程同步,volatileinterlocked,即易变和互锁。

volatile关键字和Volatile

上面我们遗留了一个问题:只有一个线程更新数据,其他线程读取数据,会不会出现问题?先看一个例子:

private static bool _stop;public static void Run(){    Task.Run(() =>    {int number = 1;while (!_stop)         {            number++;        }        Console.WriteLine($"increase stopped,value = {number}");    });

    Thread.Sleep(1000);    _stop = true;}

编译器和CPU会对上面的代码进行优化(调试模式不会优化),任务线程在执行时,会把_stop读取到CPU寄存器中,while循环的时候,每次都从当前CPU寄存器中读取_stop;同样,主线程执行的时候CPU也会把_stop读取到寄存器,更新_stop时,先更新是CPU寄存器中的_stop值,再把值存到变量_stop;在并行环境中,主线程和任务线程独立执行,主线程对_stop的更新并不会公开到任务线程,这样,任务线程的while循环便不会停止,永远无法得到输出。

把变量读到寄存器只是CPU优化代码的一种方式,CPU还可能调整代码的执行顺序,当前,CPU任务这种调整不会改变代码的意图。上面的代码说明,由于编译器和CPU的优化,只有一个线程更新数据,也可能存在问题

这种情况,我们可以使用volatile关键字或者类System.Threading.Volatile来阻止编译器和CPU的优化,这种阻止利用的是内存屏障MemoryBarrier,它告诉CPU在执行完屏障之前的内存存取后才能执行屏障后面的内存存取。上面代码的问题在于,while循环读取到的值总是CPU寄存器中的false。我们把while循环的条件改成!Volatile.Read(ref _stop)或者把用volatile声明变量_stop,while条件直接读取内存中的值,问题就能得到解决。

Interlocked原子访问

.NET提供的另一种用户模式线程同步方式是System.Threading.InterlockedInterlocked的工作依赖于代码运行的CPU平台,如果是X86的CPU,Interlocked函数会在总线上维持一个硬件信号,来阻止其他CPU访问同一内存地址(《Windows核心编程第五版》)。计算机对变量的修改一般来说并不是原子性的,而是分为3个步骤:

  1. 将变量值加载到CPU寄存器

  2. 改变值

  3. 将更新后的值存储到内存中

假如执行了前两个步骤后,CPU被抢占,变量在之前线程中的修改将丢失。Interlocked函数保证对值的修改是原子性的,一个线程完成变量的修改和存储后,另一个线程才能修改变量

System.Threading.Interlocked提供了很多方法,例如递增、递减、求和等,下面用Interlocked的递增方法展示其线程同步功能。

public static void Run(){    DoIncrease(100000);}

private static void DoIncrease(int incrementPerThread){int number1 = 0;int number2 = 0;

    Console.WriteLine($"use two threads to increase zero. each thread increase {incrementPerThread}.");

    IList<Task> increaseTasks = new List<Task>();

    increaseTasks.Add(Task.Run(() =>    {        Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} increasing number1.");for (int i = 0; i < incrementPerThread; i++)        {            Interlocked.Increment(ref number1);        }    }));    increaseTasks.Add(Task.Run(() =>    {        Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} increasing number1.");for (int i = 0; i < incrementPerThread; i++)        {            Interlocked.Increment(ref number1);        }    }));    increaseTasks.Add(Task.Run(() =>    {        Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} increasing number2.");for (int i = 0; i < incrementPerThread; i++)        {            number2++;        }    }));    increaseTasks.Add(Task.Run(() =>    {        Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} increasing number2.");for (int i = 0; i < incrementPerThread; i++)        {            number2++;        }    }));

    Task.WaitAll(increaseTasks.ToArray());

    Console.WriteLine($"use interlocked: number1 result = {number1}");    Console.WriteLine($"normal increase: number2 result = {number2}");}

运行上面的代码多次(每个线程增加的数量尽量大,否则不容易体现结果),每次number1的结果都一样,number2的结果都不同,足以体现Interlocked的线程同步功能。

SpinLock自旋锁

System.Threading.SpinLock是基于InterLocked和SpinWait实现的轻量级自旋锁,具体的实现方式这里不去关心。SpinLock的简单用法如下:

private static SpinLock _spinlock = new SpinLock();public static void DoWork(){bool lockTaken = false;try    {        _spinlock.Enter(ref lockTaken);

    }finally    {if (lockTaken)        {            _spinlock.Exit(false);        }    }}

SpinLock很轻量级,性能较高,但由于是自旋锁,锁定的操作应该是很快完成,否则会因线程自旋而浪费CPU。

内核模式线程同步

除了用户模式的两种线程同步方式,我们还会利用Windows系统的内核对象实现线程的同步。使用系统内核对象将会导致执行线程的CPU运行模式的切换,这会有很大的消耗,所以能够使用用户模式的线程同步就尽量避免使用内核模式。

内核模式下,线程在等待资源时会被系统阻塞,避免了CPU的浪费,这是内核模式优势。假如线程等待的资源一直被占用则线程将一直处于阻塞状态,造成“死锁”。相对于活锁,死锁只会浪费内存资源。

我们使用系统内核中的事件、信号量和互斥量进行内核模式的线程同步。

利用内核事件实现线程同步

事件实际上是由系统内核维护的一个布尔值。

.NET提供System.Threading.EventWaitHandle进行线程的信号交互。EventWaitHandle继承WaitHandle(封装等待对共享资源独占访问的操作系统特定的对象),有三个关键方法:

  • Set():将事件状态设置为终止状态,允许一个或多个等待线程继续。

  • Reset():将事件状态设置为非终止状态,导致线程阻塞

  • WaitOne():阻塞线程直到收到事件状态信号

线程交互事件有自动重置和手动重置两种类型,分别由AutoResetEventManualResetEvent继承EventWaitHandle得到。自动重置事件在Set唤醒第一个阻塞线程之后,会自动Reset事件,其他阻塞线程仍保持阻塞状态;而手动重置事件Set时,会唤醒所有被该事件阻塞的线程,手动Reset后,事件才会继续起作用。手动重置事件的这种性质,导致它不能用于线程同步,因为不能保证同一时间只有一个线程访问资源;相反,自动重置时间则很适合用来处理线程同步。

下面的例子演示了利用自动重置时间进行的线程同步。

public static void Run(){    DoIncrease(100000);}

private static void DoIncrease(int incrementPerThread){int number = 0;    Console.WriteLine($"use two threads to increase zero. each thread increase {incrementPerThread}.");

    AutoResetEvent are = new AutoResetEvent(true);

    IList<Task> increaseTasks = new List<Task>();    increaseTasks.Add(Task.Run(() =>    {        Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} is increasing the number.");for (int i = 0; i < incrementPerThread; i++)        {            are.WaitOne();            number++;            are.Set();        }    }));    increaseTasks.Add(Task.Run(() =>    {        Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} is increasing the number.");for (int i = 0; i < incrementPerThread; i++)        {            are.WaitOne();            number++;            are.Set();        }    }));

    Task.WaitAll(increaseTasks.ToArray());    are.Dispose();    Console.WriteLine($"use AutoResetEvent: result = {number}");}

利用信号量进行线程同步

信号量是系统内核维护的一个整型变量。

信号量值为0时,所有等待信号量的线程会被阻塞;信号量值大于零0,等待的线程会被解除阻塞,每唤醒一个阻塞的线程,系统内核就会把信号量的值减1。此外,我们能够对信号量进行最大值限制,从而控制访问同一资源的最大线程数量。

.Net中,利用System.Threading.Semaphore进行信号量操作。下面时利用信号量实现线程同步的一个例子。

public static void Run(){    DoIncrease(100000);}

private static void DoIncrease(int incrementPerThread){int number = 0;    Console.WriteLine($"use two threads to increase zero. each thread increase {incrementPerThread}.");

    Semaphore semaphore = new Semaphore(1,1);

    IList<Task> increaseTasks = new List<Task>();    increaseTasks.Add(Task.Run(() =>    {        Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} is increasing the number.");for (int i = 0; i < incrementPerThread; i++)        {            semaphore.WaitOne();            number++;            semaphore.Release(1);

        }    }));    increaseTasks.Add(Task.Run(() =>    {        Console.WriteLine($"thread #{Thread.CurrentThread.ManagedThreadId} is increasing the number.");for (int i = 0; i < incrementPerThread; i++)        {            semaphore.WaitOne();            number++;            semaphore.Release(1);

        }    }));

    Task.WaitAll(increaseTasks.ToArray());    semaphore.Dispose();    Console.WriteLine($"use Semaphore: result = {number}");}

利用互斥体进程线程同步

互斥体Mutex的使用与自动重置事件和信号量类似,这里不再进行详细的总结。

互斥体常被用来保证应用程序只有一个实例运行,具体用法如下:

bool createNew;using (new Mutex(true, Assembly.GetExecutingAssembly().FullName, out createNew)){if (!createNew)    {

        Environment.Exit(0);    }else    {

    }}

线程同步的混合模式

通过上面的总结我们知道,用户模式和内核模式由各自的优缺点,需要有一种模式既能兼顾用户和内核模式的优点又能避免他们的缺点,这就是混合模式。

混合模式会优先使用用户模式的线程同步处理,当多个线程竞争同步锁的时候,才会使用内核对象进行处理。如果多个线程一直不产生资源竞争,就不会发生CPU用户模式到内核模式的转换,开始资源竞争时,又会通过线程阻塞来防止CPU资源的浪费。

.NET中提供了多种混合模式的线程同步方式。例如手工重置事件和信号量的简化版本ManualResetEventSlimSemaphoreSlim,他们是线程在用户模式中自旋,直到发生资源竞争。具体使用与各自的内核模式一样,这里不再赘述。

lock关键字和Monitor

相信lock加锁是很多人做常用的线程同步方式。lock的使用很简单,如下:

private static readonly object _syncObject = new object();public static void DoWork(){lock (_syncObject)    {

    }}

实际上,lock语法是对System.Threading.Monitor使用的一种简化,Monitor的用法如下:

private static readonly object _syncObject = new object();public static void DoWork(){    Monitor.Enter(_syncObject);

    Monitor.Exit(_syncObject);}

使用Monitor的可能会出先一些意象不到的问题。例如,如果不相关的业务代码在使用Monitor进行线程同步的时候,锁定了同一字符串,将会造成不相关业务代码的同步执行;此外需要注意的是,Monitor不能使用值类型作为锁对象,值类型会被装箱,装箱后的对象不同,将导致无法同步。

读写锁ReaderWriterLockSlim

ReaderWriterLockSlim可以用来实现多线程读取或独占写入的资源访问。读写锁的线程控制逻辑如下:

  • 一个线程写数据时,其他请求资源的线程全部被阻塞;

  • 一个线程读数据时,写线程被阻塞,其他读线程能继续运行;

  • 写结束时,解除其他某个写线程的阻塞,或者解除所有读线程的阻塞;

  • 读结束时,解除一个写线程的阻塞。

下面是读写锁的简单用法,详细用法可参考msdn文档。

private static readonly ReaderWriterLockSlim _rwlock = new ReaderWriterLockSlim();public static void DoWork(){_rwlock.EnterWriteLock();

_rwlock.ExitWriteLock();}

ReaderWriterLockSlim还有一个比较老的版本ReaderWriterLock,据说存在较多问题应尽量避免使用。

线程安全集合

.NET除了提供包含上面总结到的各种线程同步的诸多方式外,还封装了一些线程安全集合。这些集合在内部实现了线程同步,我们直接使用即可,很友好。线程安全集合在命名空间System.Collections.Concurrent下,包括ConcurrentQueue (T),ConcurrentStack<T>,ConcurrentDictionary<TKey,TValue>,ConcurrentBag<T>,BlockingCollection<T>,具体可阅读《何时使用线程安全集合》。

各种线程同步性能对比

下面我们对整数零进行多线程递增操作,每个线程固定递增量,来测试以下各种同步方式的性能对比。测试代码如下。



private static int _numberToIncrease;

public static void Run(){int increment = 100000;int threadCount = 4;    DoIncrease(increment, threadCount, DoIncreaseByInterLocked);    DoIncrease(increment, threadCount, DoIncreaseWithSpinLock);    DoIncrease(increment, threadCount, DoIncreaseWithEvent);    DoIncrease(increment, threadCount, DoIncreaseWithSemaphore);    DoIncrease(increment, threadCount, DoIncreaseWithMonitor);    DoIncrease(increment, threadCount, DoIncreaseWithReaderWriterLockSlim);

}

public static void DoIncrease(int increment, int threadCount, Action<int> action)
{
_numberToIncrease = 0;
IList<Task> increaseTasks = new List<Task>(threadCount);
Stopwatch watch = Stopwatch.StartNew();
for (int i = 0; i < threadCount; i++)
{
increaseTasks.Add(Task.Run(() => action(increment)));
}
Task.WaitAll(increaseTasks.ToArray());
Console.WriteLine($"{action.Method.Name}=> Result: {_numberToIncrease} , Time: {watch.ElapsedMilliseconds} ms.");
}

#region 使用Interlocked,用户模式

public static void DoIncreaseByInterLocked(int increment)
{
for (int i = 0; i < increment; i++)
{
Interlocked.Increment(ref _numberToIncrease);
}
}

#endregion

#region 使用SpinLock,用户模式

private static SpinLock _spinlock = new SpinLock();
public static void DoIncreaseWithSpinLock(int increment)
{
for (int i = 0; i < increment; i++)
{
bool lockTaken = false;
try
{
_spinlock.Enter(ref lockTaken);
_numberToIncrease++;
}
finally
{
if (lockTaken)
{
_spinlock.Exit(false);
}
}
}
}

#endregion

#region 使用信号量Semaphore,内核模式

private static readonly Semaphore _semaphore = new Semaphore(1, 10);

public static void DoIncreaseWithSemaphore(int increment)
{
for (int i = 0; i < increment; i++)
{
_semaphore.WaitOne();
_numberToIncrease++;
_semaphore.Release(1);
}
}

#endregion

#region 使用事件AutoResetEvent,内核模式

private static readonly AutoResetEvent _are = new AutoResetEvent(true);
public static void DoIncreaseWithEvent(int increment)
{
for (int i = 0; i < increment; i++)
{
_are.WaitOne();
_numberToIncrease++;
_are.Set();
}
}

#endregion

#region 使用Monitor,混合模式

private static readonly object _monitorLocker = new object();
public static void DoIncreaseWithMonitor(int increment)
{
for (int i = 0; i < increment; i++)
{
bool lockTaken = false;
try
{
Monitor.Enter(_monitorLocker, ref lockTaken);
_numberToIncrease++;
}
finally
{
if (lockTaken)
{
Monitor.Exit(_monitorLocker);
}
}
}
}

#endregion

#region 使用ReaderWriterLockSlim,混合模式

private static readonly ReaderWriterLockSlim _rwlock = new ReaderWriterLockSlim();
public static void DoIncreaseWithReaderWriterLockSlim(int increment)
{
for (int i = 0; i < increment; i++)
{
_rwlock.EnterWriteLock();
_numberToIncrease++;
_rwlock.ExitWriteLock();
}
}

#endregion

下面是一组测试结果,可以很明显地看出,内核模式是相当耗时的,应尽量避免使用。而用户模式和混合模式,也需要根据具体的场景进行选择。这个测试过于简单,不具有普遍性。

DoIncreaseByInterLocked=> Result: 400000 , Time: 15 ms.
DoIncreaseWithSpinLock=> Result: 400000 , Time: 75 ms.
DoIncreaseWithEvent=> Result: 400000 , Time: 1892 ms.
DoIncreaseWithSemaphore=> Result: 400000 , Time: 1779 ms.
DoIncreaseWithMonitor=> Result: 400000 , Time: 14 ms.
DoIncreaseWithReaderWriterLockSlim=> Result: 400000 , Time: 22 ms.

小结

本文对C#/.NET中的线程同步进行了尽量详尽的总结,并行环境中在追求程序的高性能、响应性的同时,务必要保证数据的安全性。

C#并行编程系列的文章暂时就告一段落了。刚开始写博客,文章肯定存在不少问题,欢迎各位博友指出。

原文地址:https://www.cnblogs.com/chenbaoshun/p/10695343.html

.NET社区新闻,深度好文,欢迎访问公众号文章汇总 http://www.csharpkit.com 

C#并行编程(6):线程同步面面观相关推荐

  1. 【转】1.3异步编程:线程同步基元对象

    开始<异步编程:同步基元对象(上)> 示例:异步编程:线程同步基元对象.rar 如今的应用程序越来越复杂,我们常常需要多线程技术来提高我们应用程序的响应速度.每个线程都由自己的线程ID,当 ...

  2. Win32多线程编程(3) — 线程同步与通信

    一.线程间数据通信 系统从进程的地址空间中分配内存给线程栈使用.新线程与创建它的线程在相同的进程上下文中运行.因此,新线程可以访问进程内核对象的所有句柄.进程中的所有内存以及同一个进程中其他所有线程的 ...

  3. JAVA并发编程3_线程同步之synchronized关键字

    在上一篇博客里讲解了JAVA的线程的内存模型,见:JAVA并发编程2_线程安全&内存模型,接着上一篇提到的问题解决多线程共享资源的情况下的线程安全问题. 不安全线程分析 public clas ...

  4. 多线程编程、线程同步|安全和线程通信

    多线程编程 多线程的优势 线程在程序中是独立的.并发的执行流,与分隔的进程相比,进程中的线程之间的隔离程度要小.他们共享内存.文件句柄和其他每个进程应有的状态. 因为线程的划分尺度小于进程,使得多线程 ...

  5. iOS多线程编程:线程同步总结 NSCondtion

    1:原子操作 - OSAtomic系列函数 iOS平台下的原子操作函数都以OSAtomic开头,使用时需要包含头文件<libkern/OSBase.h>.不同线程如果通过原子操作函数对同一 ...

  6. 深入理解并行编程-分割和同步设计(四)

    原文链接    作者:paul    译者:谢宝友,鲁阳,陈渝 图1.1:设计模式与锁粒度 图1.1是不同程度同步粒度的图形表示.每一种同步粒度都用一节内容来描述.下面几节主要关注锁,不过其他几种同步 ...

  7. 【Linux系统编程】线程同步与互斥:POSIX无名信号量

    信号量概述 信号量广泛用于进程或线程间的同步和互斥,信号量本质上是一个非负的整数计数器,它被用来控制对公共资源的访问. 编程时可根据操作信号量值的结果判断是否对公共资源具有访问的权限,当信号量值大于 ...

  8. 【UNIX环境高级编程】线程同步

    当多个线程共享相同的内存时,需要确保每个线程看到一致的数据视图.如果每个线程使用的变量都是其他线程不会读取和修改的,那么就不存在一致性问题.同样,如果变量是只读的也不会有一致性问题.但是,当一个线程可 ...

  9. 深入理解并行编程-分割和同步设计(五)

    原文链接    作者:paul    译者:谢宝友,鲁阳,陈渝 并行快速路径 细粒度的设计一般要比粗粒度的设计复杂.在许多情况,绝大部分开销只由一小部分代码产生[Knu73].所以为什么不把精力放在这 ...

最新文章

  1. python图像分割重组_通过PYTHON来实现图像分割详解
  2. Spring的生命周期
  3. mysql查询大于等于效率_mysql 中4 OR 6的效率要大于 !=5?
  4. python获取当时文件目录_python获取文件目录
  5. rxjava 并行_使用RxJava和Completable并行执行阻塞任务
  6. yii2 ajax访问控制器,如何在yii2中运行控制器动作作为ajax
  7. r语言数据变量分段_使用R语言实现数据分段
  8. 喵哈哈村的魔法考试 Round #9 (Div.2) 题解
  9. linux6.5配置ssh,CentOS 6.5 配置 SSH 免密登录
  10. 大容量NoSql解决方案:Aerospike实战
  11. mysql错误1215hy000_MySQL:错误1215(HY000):无法添加外键约束
  12. Your Command Line Tools are too outdated
  13. and什么意思计算机SQL,SQL语句中 AND和OR的区别,or是什么意思,那and和它有什么区别?...
  14. L2-048 寻宝图
  15. 【Python】《Python语言程序设计》(嵩天 、黄天羽 、礼欣)测验单项选择题答案与解析合辑
  16. 艾伟_转载:VS 2010 和 .NET 4.0 系列之《在ASP.NET 4 Web Forms中实现URL导向》篇
  17. 传智播客开课的第四天
  18. 英语不好怎么自学python_英语不好,能学好Python吗?
  19. 【单片机原理及接口技术】【理解】七种寻址方式
  20. jQuery DataTables的serverSide选项

热门文章

  1. php判断外链,php检查字符串中是否有外链的方法
  2. vcenter 6.7 (vcsa)部署指南
  3. Oracle级联查询
  4. 开源Math.NET基础数学类库使用(04)C#解析Matrix Marke数据格式
  5. JDK5.0新特性--可变参数
  6. 系统安全防护之UNIX下***检测方法
  7. ASP.NET Core 6.0对热重载的支持
  8. Kubernetes:实现应用不停机更新
  9. 迎接.NET 6,《dotnet+Linux中文手册》完整PDF开放下载!
  10. WPF Treeview第三层横向排列