C#学习笔记 线程同步
来源:互联网 发布:1390清零软件中文版 编辑:程序博客网 时间:2024/06/06 04:10
多个线程同时操作一个数据的话,可能会发生数据的错误。这个时候就需要进行线程同步了。线程同步可以使用多种方法来进行。下面来逐一说明。本文参考了《CLR via C#》中关于线程同步的很多内容。
用户模式同步
易变构造
当对32位及32位以下变量的读写时,CLR保证读写操作是原子性的。也就是说bool、char、int等类型的变量可以一次性读取或者写入。但是对于long、ulong这些64位数据类型来说,就有可能不是原子操作。此外,由于编译器优化的存在,这些语句执行的顺序可能和编写代码时的顺序不同。这样的话,在多线程的环境下就有可能会出现同步问题。鉴于此,FCL提供了Volatile类,用来控制变量的读写和编译器的优化,这样的访问称为易变访问。
Volatile是一个静态类,包含了对于各种基元数据类型已经泛型类型的Write和Read方法。使用这些方法,可以做到:
- 禁止编译器进行任何优化,对变量进行原子操作,只有在调用读写方法的时候才将值读取或写入。
- 插入内存屏障,按照编码顺序,Write方法之前的存取操作必须在调用Write方法之前完成,Read方法之后的存取操作必须在调用Read方法之后完成。
这里有一个例子,在使用发布模式运行(非调试)程序的时候,代码行为会发生变化,导致死循环。原因如下:编译器发现在Work方法中,continue的值并没有发生改变,所以会将代码优化为在while循环之前求值,然后每次循环直接使用这个值。所以,优化后的代码while循环会直接变成死循环。从而导致程序出现问题。这也告诫我们:在多线程的环境下,对于程序应该引起足够的重视,有可能出现一些正式发布时才会出现的问题。
//当启用发布模式时,该类的行为会发生变化//由于优化导致程序进入死循环static class ProblemA{ //添加volatile关键字即可保证正确 static bool continued = true; public static void ShowProblem() { Console.WriteLine("运行3秒之后停止工作:"); Thread t = new Thread(Work); t.Start(); Thread.Sleep(3000); continued = false; Console.WriteLine("等待工作结束..."); t.Join(); Console.WriteLine("--------------------------------"); } private static void Work(object state) { int n = 0; //优化会导致这里变为死循环 while (continued) { n++; } Console.WriteLine("循环结束时n的值是" + n); }}
C#同时还提供了volatile
关键字,标记为volatile
的变量,对其的所有操作都是易变操作。但是由于只有少数情况下才需要进行易变读写,直接标记volatile关键字会影响性能。所以还是推荐使用Volatile静态类,只有在需要的时候才进行易变操作。
互锁构造
Volatile类只有Write和Read两个方法。而Interlocked类增加了更多的方法,并且所有方法都是原子操作并提供内存屏障。
下面的例子说明了Interlocked类的主要方法。Interlocked类在多线程环境下十分有用。注意Exchange和CompareExchange方法返回的都是交换之前的值。
public static void InterlockTest(){ int i = 5; Console.WriteLine("演示Interlocked的方法:"); Console.WriteLine("增加一个值:"); Console.WriteLine("增加之后的值:" + Interlocked.Increment(ref i)); Console.WriteLine("减少一个值:"); Console.WriteLine("减少之后的值:" + Interlocked.Decrement(ref i)); Console.WriteLine("用一个值交换:"); Console.WriteLine("交换之前的值:" + Interlocked.Exchange(ref i, 10)); Console.WriteLine("交换之后的值:" + i); Console.WriteLine("条件交换一个值,如果值和第三个参数相等,就用第二个值交换"); Console.WriteLine("交换之前的值:" + Interlocked.CompareExchange(ref i, 10, 5)); Console.WriteLine("交换之后的值:" + i); Console.WriteLine("--------------------------------");}
简单的自旋锁
利用上面所提到的类,就可以构造用户模式下的自旋锁了。在多个线程同时竞争一个资源的时候,只有一个才能进入,其他的线程必须空转等待。注意在空转的时候仍然会占用CPU资源,所以这种锁称位活锁。只有当访问临界资源非常快的时候才应该使用自旋锁。
internal class MySpinLock{ private int _isUsing; public void Enter() { while (true) { if (Interlocked.Exchange(ref _isUsing, 1) == 0) { return; } } } public void Leave() { Volatile.Write(ref _isUsing, 0); }}
内核模式同步
除了用户模式下的同步之外,还有内核模式下的同步。使用这种同步的时候,代码会从用户模式切换到内核模式,同步完毕之后在重新转换到用户模式,因此开销比较大。但是也有很多优点:
- 内核模式下,内核可以了解到线程的状态,因而可以阻塞暂时不可用的线程,释放CPU,防止自旋。
- 内核模式可以同步同一机器不同进程之间的线程。
- 内核模式可以应用安全设置,防止未授权的访问。
- 内核模式阻塞的线程可以指定超时值,时间内访问不到资源可以解除阻塞,执行其他任务。
Event构造
事件就是由内核维护的布尔型变量。为假的时候,在事件上等待的线程就会阻塞;为真的时候就会解除阻塞。事件分为两种,自动重置事件和手动重置事件。当自动重置事件唤醒一个等待的线程的时候,它会自动重置回false,让其他线程继续等待。而手动重置线程会唤醒所有线程,直到你手动将其重置回假。
Event构造的主要方法如下:
- Set方法,将条件置为真,并唤醒一个或多个线程。
- Reset方法,将条件置为假,阻塞其他线程。
- WaitOne方法,在event上等待,还有接受一个超时值的重载版本。
下面是一个例子,运行可以发现自动重置事件调用Set方法之后,只有一个线程被唤醒,而手动重置事件调用Set方法之后两个线程都被唤醒。
public static void ResetEventTest(){ Console.WriteLine("两种重置事件的比较:"); Console.WriteLine("使用自动重置事件唤醒线程:"); var autoResetEvent = new AutoResetEvent(false); Task t1 = new Task(() => { autoResetEvent.WaitOne(); Console.WriteLine("任务1已经唤醒"); }); Task t2 = new Task(() => { autoResetEvent.WaitOne(); Console.WriteLine("任务2已经唤醒"); }); t1.Start(); t2.Start(); autoResetEvent.Set(); Task.WhenAny(t1, t2).Wait(); Console.WriteLine("使用手动重置事件唤醒线程:"); var manualResetEvent = new ManualResetEvent(false); Task t3 = new Task(() => { manualResetEvent.WaitOne(); Console.WriteLine("任务3已经唤醒"); }); Task t4 = new Task(() => { manualResetEvent.WaitOne(); Console.WriteLine("任务4已经唤醒"); }); t3.Start(); t4.Start(); manualResetEvent.Set(); Task.WhenAll(t3, t4).Wait(); Console.WriteLine("--------------------------------");}
Semaphore构造
信号量是由内核维护的int变量,表示的是临界资源的数目。当信号量为0是表示所有资源都被占用,线程被阻塞。当信号量大于0的时候,解除阻塞,并根据对应的资源使用情况减少信号量的值。信号量含有一个最大资源数目,如果释放操作导致信号量超过最大计数,会抛出SemaphoreFullException。
信号量的主要方法如下:
- WaitOne方法,在信号量上等待,如果信号量大于0,将减少一个信号量;如果信号量为0将阻塞当前线程。另外还有一个指定超时值的重载版本。
- Release方法,释放线程所占用的信号量,将信号量增加1并唤醒其他线程。还有一个指定同时释放多个信号量的重载版本。
下面是使用信号量实现的一个线程安全的计数器:
internal class SemaphoreCounter : AbstractCounter{ private Semaphore _semaphore = new Semaphore(1, 1); public override int Count { get { return _count; } } public override void Increment() { _semaphore.WaitOne(); _count++; _semaphore.Release(); } public override void Decrement() { _semaphore.WaitOne(); _count--; _semaphore.Release(); }}
这个计数器的抽象基类如下:
internal abstract class AbstractCounter{ protected int _count; public abstract int Count { get; } public abstract void Increment(); public abstract void Decrement();}
Mutex构造
互斥锁的工作方式和AutoResetEvent或者最大值指定为1的信号量相似,三者都是一次只唤醒一个等待的线程。不过,互斥锁还会记录锁定的线程ID,防止其他线程释放锁;另外,互斥锁还允许递归锁,拥有了该互斥锁的线程还可以继续上锁。不过相应的,互斥锁的速度更慢。
互斥锁的重要方法如下:
- WaitOne方法,给互斥锁上锁,只允许获得锁的线程访问。另外还有一个指定超时值的重载版本。
- ReleaseMutex方法,释放锁,只能由获得锁的线程调用。
下面是利用互斥锁实现的线程安全计数器:
internal class MutexCounter : AbstractCounter{ private Mutex _mutex = new Mutex(); public override int Count { get { return _count; } } public override void Increment() { _mutex.WaitOne(); _count++; _mutex.ReleaseMutex(); } public override void Decrement() { _mutex.WaitOne(); _count--; _mutex.ReleaseMutex(); }}
混合模式同步
用户模式同步没有用户代码和内核代码之间的切换,但是会造成空转,白白浪费CPU;内核模式同步可以阻塞线程避免空转,但是两种模式之间的开销比较大。因此,就有了混合模式同步构造,集合了这两者的优点。
一个简单的混合锁
这个混合锁使用一个int变量和自动重置事件来实现。当没有竞争的时候,锁只对int变量进行操作,速度很快。只有当发生竞争的时候才会阻塞线程。此时不会对程序的性能造成大的影响,因为这个时候线程本来就要停止了,进行用户模式和内核模式的切换的开销相对来说就比较小了。
internal class MyHibridLock : IDisposable{ private int _isUsing; private AutoResetEvent _lock = new AutoResetEvent(false); public void Enter() { if (Interlocked.Increment(ref _isUsing) == 1) { return; } _lock.WaitOne(); } public void Leave() { if (Interlocked.Decrement(ref _isUsing) == 0) { return; } _lock.Set(); } public void Dispose() { _lock.Dispose(); }}
ManualResetEventSlim和SemaphoreSlim
这两个类是ManualResetEvent和Semaphore类对应的混合模式构造。使用方法也差不多,这里就不在详细说明了。
Monitor类和lock语句
Monitor类是一个支持自旋、线程所有权和递归的混合模式互斥锁。调用其Enter方法锁定一个对象,调用Exit释放一个对象。在进行这些操作的时候需要注意,尽可能的减小需要锁定的对象。这个类在使用的时候有一些注意事项,详细的原因可以参考《CLR via C#》这本书,里面有很详尽的解释。
- 不要向Monitor传递代理对象,Monitor会锁定代理对象而不是实际对象。
- 不要向Monitor传递类型对象的引用。
- 不要向Monitor传递string的引用。由于字符串留用的问题,可能导致两个不相关的线程在不知情的情况下进行同步。
- 不要向Monitor传递值类型。由于值类型会被装箱,而每次装箱的对象都不同,会导致线程完全无法同步。
由于通过调用Monitor.Enter(object)方法锁定一个对象,然后进行某些操作,再调用Monitor.Exit(object)方法释放对象这种模式很普遍,因此C#提供了lock语句,封装了这种操作。
ReaderWriterLockSlim
这个类封装了读者写者问题的处理逻辑:
- 当一个写者线程写入数据的时候,其他所有线程都被阻塞。
- 当一个读者线程读取数据的时候,允许其他读者线程读取数据,写者线程被阻塞。
这个类比较重要的方法如下:
- EnterReadLock方法,进入读取锁定状态。
- EnterWriteLock方法,进入写入锁定状态。
- TryEnterReadLock方法,尝试进入读取锁定状态,不成功则返回false。
- TryEnterWriteLock方法,尝试进入写入锁定状态,不成功则返回false。
- ExitReadLock方法,退出读取锁定状态。
- ExitWriteLock方法,退出写入锁定状态。
CountDownEvent
这个类当其中的计数大于0的时候会阻塞在其上等待的线程,而当计数为0的时候会唤醒线程。可以看出,这个类的行为和信号量恰好相反。
这个类的重要方法如下:
- AddCount方法。计数加1,当计数为0的时候尝试加1会抛出异常。也有一个重载版本可以同时增加多个计数。
- TryAddCount方法。同上,但是用返回值表示是否成功。
- Reset方法。将计数重置为构造函数中指定的值。也有一个重载版本指定其它的值。
- Signal方法。向CountDownEvent发送信号,并将计数减1。也有一个重载版本同时减小多个计数 。
- Wait方法。阻塞当前线程。当计数为0的时候唤醒线程。也有重载版本指定超时值和取消。
下面是这个类的简单使用例子。有一群学生和一个老师,当所有学生做完作业之后,老师开始批改作业:
public static void Problem5(){ Console.WriteLine("所有学生做完作业之后,老师才能开始批改作业:"); int studentsNo = 5; var count = new CountdownEvent(studentsNo); var students = new Student[studentsNo]; var dowork = new Task[studentsNo]; var teacher = new Teacher(count); for (int i = 0; i < studentsNo; ++i) { students[i] = new Student(count, i + 1); int no = i; dowork[i] = Task.Run(() => students[no].FinishWork()); } teacher.CheckWork(); Task.WhenAll(dowork).Wait(); Console.WriteLine("--------------------------------");}public class Teacher{ private CountdownEvent _count; public Teacher(CountdownEvent count) { _count = count; } public void CheckWork() { _count.Wait(); Console.WriteLine("老师开始批改作业"); }}public class Student{ private CountdownEvent _count; private int _no; public Student(CountdownEvent count, int no) { _count = count; _no = no; } public void FinishWork() { //如果输出语句放到下面,可能会出现在输出学生完成作业之前 //老师开始批改作业的情况 Console.WriteLine($"学生{_no}做完了作业"); _count.Signal(); }}
并发集合类
对于某些问题,直接使用并发集合来进行同步可能更简单。FCL有4个线程安全的类,在System.Collections.Concurrent命名空间下定义的:
它们的详细用法请参考MSDN。
- C#学习笔记 线程同步
- C#学习笔记 线程同步问题
- C#多线程学习笔记(二)之线程同步
- 【线程同步学习笔记】C#中的lock关键字
- 学习笔记之线程同步
- 学习笔记:线程间同步
- java学习笔记--线程同步
- C#学习笔记-线程
- Java线程学习笔记之线程同步
- posix线程同步和boost线程同步学习笔记
- C#学习笔记 线程操作
- C#学习笔记之线程
- C#线程池学习笔记
- C#线程学习笔记2
- APUE学习笔记(15)-线程同步
- JVM原理学习笔记 -- (对象线程同步)
- C++ 线程同步 (学习笔记)
- APUE学习笔记(15)-线程同步方法
- 处理GitHub不允许上传大于100M文件问题
- ios上webview与浏览器webview
- Logistic Regression(逻辑回归)详细讲解
- MediaPlayer 用于方法长时间的音乐
- ImageLoader 完全解析
- C#学习笔记 线程同步
- eclipse代码自动提示的问题
- ExtJS显示.Net json日期Date(1451145600000)问题
- Android实现自动定位城市并获取天气信息
- NAT穿透二
- NAT穿透一
- ubantu下安装ncurses
- 自定义AutoCompleteTextView的点击事件
- 移动端导航的七种设计模式