2.1~2.10 线程同步技术
-
将学习多线程中使用共享资源的常用技术。
Mutex
semaphoreSlim
autoResetEvent
manualResetSlim
countDownEvent
Barrier
ReaderWriterLockSlim
SpinWait -
单词
Mutex 互斥
semaphoreSlim 信号灯限时。
autoResetEvent 自动重置事件
manualResetSlim 重置Slim手动
countDownEvent 倒计时事件
Barrier 障碍物
ReaderWriterLockSlim
SpinWait 旋转等待。
- 看不懂:2.5、2.6、2.8、2.10 。
2.1 简介
- 当一个线程执行递增或递减操作时,其他线程需要依次等待,这种常见问题被称为线程同步
- 如果无须共享对象,那么就不用进行线程同步。大多时候可以通过
重新设计程序来移除共享状态,从而去掉复杂的同步构造
。如果必须使用共享状态,就是使用原子操作
。就是只有当前操作完成后,其他线程才能执行其他操作。因此,你无须实现其他线程等待当前操作完成,这就避免了使用锁,也排除了死锁的情况。 - 如果前面方式不可行,那么我们不得不使用不同方式来协调线程。方式之一是将等待线程置于阻塞状态。这种情况下,会占用最少的CPU时间。但是会引入至少一次的上下文切换(context switch,线程调度器),会耗费相当多的资源。如果线程要被挂起很长时间,这样做是值得的。这种方式叫“内核模式”(kernel-mode),因为只有操作系统内核才能阻止线程使用CPU时间。
- 万一线程只需要等待一小段时间,最好不要将线程切换到阻塞状态。这样虽然浪费CPU时间,但是节省上下文切换耗费的CPU时间。该方式叫“用户模式”(user-mode)(该方式非常轻量,速度很快,但是如果线程要等很久则会浪费CPU时间。)
- 未来利用好这两种方式,可以使用混合模式(hybrid)。混合模式先尝试使用用户模式等待,如果线程等待了足够长时间,则会切换到阻塞状态以节省CPU资源。
2.2 执行基本的原子操作
- 对对象执行基本原子操作,从而不用阻塞线程就避免竞争条件。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
namespace ConsoleApplication3._2
{
public class Class2_2
{
internal abstract class CounterBase
{
public abstract void Increment();
public abstract void Decrement();
}
internal static void TestCounter(CounterBase c)
{
for (int i = 0; i < 10000; i++)
{
c.Increment();
c.Decrement();
}
}
internal class Counter : CounterBase
{
private int _count;
public int Count { get { return _count; } }
public override void Decrement()
{
_count--;
}
public override void Increment()
{
_count++;
}
}
internal class CounterNoLock : CounterBase
{
private int _count;
public int Count { get { return _count; } }
public override void Decrement()
{
Interlocked.Decrement(ref _count);
}
public override void Increment()
{
Interlocked.Increment(ref _count);
}
}
}
}
static void Main(string[] args)
{
#region 2.2
Console.WriteLine("Incorret counter");
var c = new Class2_2.Counter();
var t1 = new Thread(() => Class2_2.TestCounter(c));
var t2 = new Thread(() => Class2_2.TestCounter(c));
var t3 = new Thread(()=>Class2_2.TestCounter(c));
t1.Start();
t2.Start();
t3.Start();
t1.Join();
t2.Join();
t3.Join();
Console.WriteLine("total count:{0}",c.Count);
Console.WriteLine("----------");
Console.WriteLine("correct counter");
var c1 = new Class2_2.CounterNoLock();
t1 = new Thread(()=>Class2_2.TestCounter(c1));
t2 = new Thread(()=>Class2_2.TestCounter(c1));
t3 = new Thread(()=>Class2_2.TestCounter(c1));
t1.Start();
t2.Start();
t3.Start();
t1.Join();
t2.Join();
t3.Join();
Console.WriteLine("total count:{0}", c1.Count);
Console.ReadKey( );
#endregion
}
- 代码解读 代码中Main方法里
c.Count
结果是随机的,而c1.Count
借助Interlocked
类,不用锁定任何对象也能获得正确结果。Interlocked提供了Increment、Decrement和add等基本数学操作的原子方法。
2.3 使用Mutex类
- Mutex(互斥)是一种原始的同步方式,其只对一个线程授予对其共享资源的独占访问。
//Main方法
#region 2.3
const string MutexName = "abcd";
using (var m = new Mutex(false, MutexName))
{
if(!m.WaitOne(5000,false))
{
Console.WriteLine("second instance is running");
Console.ReadLine();
m.ReleaseMutex();
}
else
{
Console.WriteLine("running");
Console.ReadLine();
m.ReleaseMutex();
}
}
#endregion
- Mutex构造函数传false,表示如果互斥量已经被创建,则允许程序获取该互斥量。如果没有获得互斥量,程序则简单显示running,等待按下任意键,然后释放该互斥量并退出。
- 我们先运行一个编译后的控制台窗口,会显示running,然后不关再开第二遍,第二个窗口会在5s内尝试获得互斥量。如果第一程序输入内容后而关闭,第二个程序会输出“running”。如果第一个窗口不输入则会超时,则第二个窗口无法获得互斥量,而输出“second ......”。
请务必正确关闭互斥量,最好用using代码块来包裹互斥量对象。该方式可以用在不同的程序中同步线程,可以推广到大量的使用场景中。
2.4使用SemaphoreSlim类(信号量)
- 该类限制了同时访问同一个资源的线程数量。是Semaphore类的轻量级版本。
static SemaphoreSlim _se = new SemaphoreSlim(4);
static void AccessDataBase(string name,int s)
{
Console.WriteLine("{0} 等待访问数据库 ",name);
_se.Wait();
Console.WriteLine("{0} 被允许访问数据库",name);
Thread.Sleep(s*1000);
Console.WriteLine("{0} 结束掉了 ",name);
_se.Release();
}
static void Main(string[] args)
{
for (int i = 0; i <=6; i++)
{
string threadName = "thread" + i;
int s = 2 + 2 * i;
var t = new Thread(() => AccessDataBase(threadName, s));
t.Start();
}
}
- 工作原理
- 主程序启动时,创建SemaphoreSlim的一个实例,在构造函数中指定允许的并发线程数量。main方法启动了6个不同线程,每个都尝试获取数据库的访问。但是信号量系统限制了并发量是4个线程。当有4个获取了数据库访问后,其他两个需要等待,直到之前线程完成工作调用release方法来发出信号。
- 这里我们使用了混合模式,其允许我们在等待时间很短情况下无需使用上下文切换。然后
semaphore
类使用内核方式。一般没必要使用它,而在跨程序同步的场景下可以使用它。
2.5 使用AutoRestEvent类
- 该类实现:从一个线程向另一个线程方送通知。它可以通知正在等待的线程有某事件发生。
#region 2.5
private static AutoResetEvent _w = new AutoResetEvent(false);
private static AutoResetEvent _m = new AutoResetEvent(false);
static void Process(int s)
{
Console.WriteLine("开始一个工作...");
Thread.Sleep(s*1000);
Console.WriteLine("正在工作");
_w.Set();
Console.WriteLine("等待主线程完成它的工作");
_m.WaitOne();
Console.WriteLine("开始第二个操作...");
Thread.Sleep(s*1000);
Console.WriteLine("工作完成");
_w.Set();
}
#endregion
static void Main(string[] args)
{
#region 2.5
var t = new Thread(()=>Process(10));
t.Start();
Console.WriteLine("等待其他线程完成");
_w.WaitOne();
Console.WriteLine("第一个操作完成");
Console.WriteLine("在主线程上执行操作");
_w.WaitOne();
Console.WriteLine("第二个线程完成!");
#endregion
}
- 当主程序启动时,定了两个AutoRestEvent实例。其中一个是从子线程向主线程发信号,另一个实例是从主线程向子线程发信号。我们向autorestEvent构造方法传入false,定义了这两个实例的初始状态为unsigned。这意味着任何线程调用这两个对象中的任何一个waiOne方法将会被阻塞,直到调用Set方法。如果初始事件状态为true,则autoRestEvent实例的状态为signaled(信号),如果线程调用waitOne方法则会被立刻处理。然后事件状态自动变为unsigned,所以需要对该实例调用一次set方法,以便让其他线程对该实例调用waitOne方法从而继续执行。
然后我们曾经了第二个线程,其会执行的哥操作10s,然后等待从第二个线程发出的信号。该信号意味着第一个操作已经完成。现在第二个线程在等待主线程的信号。我们对主线程做了一些附加工作,并通过调用_m.Set方法发送一个信号。然后等待从第二个线程发出的另一个信号。AutoRestEvent类采用是内核时间模式,所以等待时间不能太长。
2.6 使用ManualRestEventSlim类
- 该类在线程间以更灵活的方式传递信号。
#region 2.6
static ManualResetEventSlim _m = new ManualResetEventSlim(false);
static void TravelThroughGates(string threadName,int seconds)
{
Console.WriteLine("{0} 睡着了", threadName);
Thread.Sleep(seconds * 1000);
Console.WriteLine("{0} 等待开门!",threadName);
_m.Wait();
Console.WriteLine("{0} 进入大门!",threadName);
}
#endregion
static void Main(string[] args)
{
#region 1.
#endregion
#region 2.6
var t1 = new Thread(()=> TravelThroughGates("thread1",5));
var t2 = new Thread(()=> TravelThroughGates("thread2",6));
var t3 = new Thread(() => TravelThroughGates("thread3",12));
t1.Start();
t2.Start();
t3.Start();
Thread.Sleep(3000);
Console.WriteLine("门打开了!");
_m.Set();
Thread.Sleep(2000);
_m.Reset();
Console.WriteLine("门关闭了1!");
Thread.Sleep(10000);
Console.WriteLine("门第二次打开了!");
_m.Set();
Thread.Sleep(2000);
Console.WriteLine("门关闭了2!");
_m.Reset();
Console.ReadLine( );
#endregion
}
- ManualRestEventSlim的整个工作方式有点像人群通过大门。AutoResetEvent事件像旋转门,一次只能一个人通过。ManualRestEventSlim是ManualRestEvent的混合版本,一直保持大门开着直到手动调Reset方法。当调用
_m.Set
时,相当于打开大门从而允许准备好的线程接收信号并继续工作。然后线程3还在睡眠状态,没有赶上时间。当调用_m.Reset()
相当于关闭大门。最后一个线程已经准备好执行,但是不得不等待下一个信号,即要等待好几秒。 - EventWaitHandle类是AutoRestEvent和ManualRestEvent类的基类。
2.7 使用CountDownEvent类
- 本代码演示使用CountDownEvent信号类来等待直到一定数量的操作完成。
#region 2.7
static CountdownEvent _c = new CountdownEvent(2);
static void PerformOperation(string message,int s)
{
Thread.Sleep(s*1000);
Console.WriteLine(message);
_c.Signal();//向CountdownEvent 注册信号,同时减少其计数。
}
#endregion
static void Main(string[] args)
{
#region 1.
#endregion
#region 2.7
Console.WriteLine("开始两个操作");
var t1 = new Thread(()=>PerformOperation("操作1完成了",4));
var t2 = new Thread(()=>PerformOperation("操作2完成了",8));
t1.Start();
t2.Start();
_c.Wait();
Console.WriteLine("两个操作完成了!");
_c.Dispose();
Console.ReadKey();
#endregion
}
- 工作原理
- 当主程序启动时,创建了一个CountdownEvent实例,在其构造函数中指定了当两个操作完成时会发出信号。然后启动两个线程,当执行完成后会发出信号。一旦第二个线程完成,主线程会从等待Countdownevent的状态中返回并继续执行。针对需要等待多个异步操作完成的情形,使用本方式非常便利。然而有一个重大缺点。如果调用
_c.Signal()
没有到指定的次数,那么_c.Wait()
将一直等待。请确保使用countdownEvent时,所有线程完成后都要调用signal方法。
2.8使用Barrier类
- 该类用于组织多个线程及时在某个时刻碰面。其提供了一个回调函数,每次线程调用了SignalAndWait方法后该回调函数会被执行。
*微软类库解释该作用:使多个任务能够采用并行方式依据某种算法在多个阶段中协同工作。
#region 2.8
static Barrier _b = new Barrier(2, b => Console.WriteLine("end of phase {0}",b.CurrentPhaseNumber+1));//获取屏障的当前阶段的编号。
static void PlayMusic(string name,string message,int s)
{
for (int i = 0; i < 3; i++)
{
Console.WriteLine("---------");
Thread.Sleep(s*1000);
Console.WriteLine("{0} starts to {1}",name,message);
Thread.Sleep(s*1000);
Console.WriteLine("{0} finishes to {1}",name,message);
_b.SignalAndWait();
}
}
#endregion
static void Main(string[] args)
{
var t1 = new Thread(()=>PlayMusic("吉他手","play an amzing solo",5));
var t2 = new Thread(()=> PlayMusic("歌手 ","sing his song",2));
t1.Start();
t2.Start();
Console.Read();
}
#endregion
```
* 工作原理
>* 创建了Barrier类,指定了我们想要同步两个线程。在两个线程中的任何一个调用了`_b.SignalAndWait`方法后,会执行一个回调函数来打印出阶段。
每个线程将向barrier发送两次信号,所以会有两个阶段。每次这两个线程调用signalAndWait方法时,barrier将执行回调函数。这在多线程迭代运算中非常有用,可以在每个迭代结束前执行一些计算。当最后一个线程调用signalandwait方法时可以在迭代结束时进行交互。
**2.9 使用ReaderWriterLockSlim类**
* ReaderWriterLockSlim创建一个线程安全机制,在多线程中对一个集合进行读写操作。它代表了一个管理资源的访问的锁,允许多个线程同时读取,以及独占写。
```c
#region 2.9
static ReaderWriterLockSlim _rw = new ReaderWriterLockSlim();
static Dictionary<int, int> _items = new Dictionary<int, int>();
static void Read()
{
Console.WriteLine("读内容中。。。");
while (true)
{
try
{
_rw.EnterReadLock();
foreach (var key in _items.Keys)
{
Thread.Sleep(100);
}
}finally
{
_rw.ExitReadLock();
}
}
}
static void Write(string threadName)
{
while (true)
{
try
{
int newKey = new Random().Next(250);
_rw.EnterUpgradeableReadLock();
if(!_items.ContainsKey(newKey))
{
try
{
_rw.EnterWriteLock();
_items[newKey] = 1;
Console.WriteLine("new key {0} is added to a dictionary by a {1}",newKey,threadName);
}
finally
{
_rw.ExitWriteLock();
}
}
}
finally
{
_rw.ExitUpgradeableReadLock();
}
}
}
#endregion
static void Main(string[] args)
{
#region 2.9
new Thread(Read)
{
IsBackground = true
}.Start();
new Thread(Read)
{
IsBackground = true
}.Start();
new Thread(Read)
{
IsBackground = true
}.Start();
new Thread(() => Write("t1 ")) { }.Start();
new Thread(() => Write("t2 ")) { }.Start();
Thread.Sleep(TimeSpan.FromSeconds( 30));
#endregion
工作原理:
- 当主程序启动时,同时运行了3个线程从字典中读,还有两个线程向该字典中写入数据。使用ReaderWriterSlim类来实现线程安全,该类专为这样场景设计。
- 这里使用两种锁:读锁允许多线程读取数据,写锁在被释放前会阻塞其他线程的所有操作。从集合中读取数据时(获取读锁),会根据读取数据而决定是否获取一个写锁并修改该集合。一旦得到写锁,会阻止阅读者读取数据。为了最小化阻塞浪费的时间,可以使用EnterUpgradeableReadLock和ExitUpgradeableReadLock方法。先获得读锁后读取数据。如果发现必须修改底层集合,需要EnterWriteLock方法升级锁,然后快速执行一次写操作,最后用ExitWriteLock释放写锁。本例中,我们先生成一个随机数。然后获得读锁并检查该数是否存在字典的键集合中。如果不存在,将读锁更新为写锁然后将新键写入字典中。始终使用try/finally代码块来确保在扑获锁后一定释放锁,这是一项好的实践。 所有的线程都被创建为后台线程。主线程在所有后台线程完成后会等待30s.
2.10 使用SpinWait类
- 介绍如何不使用内核模型的方式来使线程等待。另外,spinWait他是一个混合同步结构,被设计为使用用户模式等待一段时间,然后切换到内核模式以节省CPU时间。
*微软类库说明: SpinWait 提供对基于自旋的等待的支持。
#region 2.10
static volatile bool _isOver = false;
static void UserModeWait()
{
while (!_isOver)
{
Console.WriteLine(".");
}
Console.WriteLine();
Console.WriteLine("waiting is complete");
}
static void HybridSpinWait()
{
var w = new SpinWait();
while (!_isOver)
{
w.SpinOnce();
Console.WriteLine(w.NextSpinWillYield);
}
Console.WriteLine("waiting is complete");
}
#endregion
static void Main(string[] args)
{
#region 2.10
var t1 = new Thread(UserModeWait);
var t2 = new Thread(HybridSpinWait);
Console.WriteLine("运行用户模式等待");
t1.Start();
Thread.Sleep(20);
_isOver = true;
Thread.Sleep(1000);
_isOver = false;
Console.WriteLine("运行混合自旋等待结构等待");
t2.Start();
Thread.Sleep(5);
_isOver = true;
Console.Read();
#endregion
}
- 当主程序启动时,定义了一个线程,将执行一个无止境的循环,直到20毫秒后主线程设置_isOver变量为true。我们可以试验运行该周期为20~30s,通过任务管理器测量CPU负载情况。取决于CPU内核数量,任务管理器将显示一个显著的处理时间。
使用volatile关键字来声明_isOver静态字段,该关键字指出一个字段可能回被同时执行的多个线程修改。声明为volatile字段不会被编译器和处理器优化为只能被单个线程访问。这确保了该字段是最新的值。
然后使用spinwait版本,用于在每个迭代打印一个特殊标志位来显示线程是否切换为阻塞状态。运行该线程5毫秒来看结果。刚开始,spinwait尝试使用用户模式,在9个迭代后,开始切换线程为阻塞状态。如果尝试测量该版本的CPU负载,在win任务管理器将不会看到任务CPU使用。