有时候必须访问变量、实例、方法、属性或者结构体,而这些并没有准备好用于并发访问,或者有时候需要执行部分代码,而这些代码必须单独运行,这是不得不通过将任务分解的方式让它们独立运行。
当任务和线程要访问共享的数据和资源的时候,您必须添加显示的同步,或者使用原子操作或锁。
下面举个例子:
我们吃饭用手机点菜的时候,多个人同时点菜,在最后结账的时候,如果大家都争着买单,那如果没有同步信息,就会造成多个人都买单成功。这就是线程同步的问题之一。
下面介绍几种线程同步的方法:
1、锁 SpinLock 、Mutex、Monitor、lock
SpinLock
自旋锁是指当一个线程在获取锁对象的时候,如果锁已经被其它线程获取,那么这个线程将会循环等待,不断的去获取锁,直到获取到了锁。
优点:避免了线程上下文切换。性能较高。
缺点:如果长时间等待,将消耗大量的CPU资源。而且多个等待中的线程,并不是等待时间越长就先获取到锁,可能会一直等待下去。
主要的方法:
static SpinLock SpinLock = new SpinLock();
static bool hasPay = false;
static void Pay(int num)
{
bool locked = false;
SpinLock.Enter(ref locked);//获取锁
if (!hasPay)
{
Console.WriteLine($"编号{num}:已经买单了");
hasPay = true;
}
else
Console.WriteLine($"编号{num}:买单失败");
//检测是否释放锁,没有的话进行释放
if (locked)
SpinLock.Exit(locked);
}
static void Main(string[] args)
{
for(int i=1;i<10;i++)
{
Task.Factory.StartNew( obj=> { Pay((int)obj); },i);
}
Console.ReadLine();
}
Mutex:
互斥锁是一个互斥的同步对象,意味着同一时间有且仅有一个线程可以获取它,互斥锁可适用于一个共享资源每次只能被一个线程访问的情况,如果线程获取互斥体,则需要获取该互斥体的第二个线程将挂起,直到第一个线程释放该互斥体。
在 Mutex 类中,WaitOne() 方法用于等待资源被释放, ReleaseMutex() 方法用于释放资源。WaitOne() 方法在等待 ReleaseMutex() 方法执行后才会结束。
static Mutex mutex = new Mutex();//互斥锁
static bool hasPay = false;
static void Pay(int num)
{
if (mutex.WaitOne())
{
try
{
if(!hasPay)
{
Console.WriteLine($"编号{num}:已经买单了");
hasPay = true;
}
else
Console.WriteLine($"编号{num}:买单失败");
}
finally
{
mutex.ReleaseMutex();
}
}
}
static void Main(string[] args)
{
for(int i=1;i<10;i++)
{
Task.Factory.StartNew( obj=> { Pay((int)obj); },i);
}
Console.ReadLine();
}
lock:
1、lock锁定的是一个引用类型,值类型不能被lock
2、避免lock一个string,因为程序中任何跟锁定的string的字符串是一样的都会被锁定。
static void Pay(int num)
{
lock(lockobj)
{
if(!hasPay)
{
Console.WriteLine($"编号{num}:已经买单了");
hasPay = true;
}
else
Console.WriteLine($"编号{num}:买单失败");
}
}
Monitor:
Monitor类提供了与lock类似的功能,不过与lock不同的是,它能更好的控制同步块,当调用了Monitor的Enter(Object o)方法时,会获取o的独占权,直到调用Exit(Object o)方法时,才会释放对o的独占权。
可以使用 TryEnter() 方法可以给它传送一个超时值,决定等待获得对象锁的最长时间,该方法能在指定的毫秒数内结束线程,这样能避免线程之间的死锁现象。
主要方法:
static void Pay(int num)
{
try
{
Monitor.Enter(lockobj);
if(!hasPay)
{
Console.WriteLine($"编号{num}:已经买单了");
hasPay = true;
}
else
Console.WriteLine($"编号{num}:买单失败");
}
finally
{
Monitor.Exit(lockobj);
}
}
其实以上三种的方式都差不多,输出结果也是一样的。
同时使用Monitor和lock可以使lock释放当前资源,使当前线程阻塞:
static void Pay(int num)
{
lock(lockobj)
{
try
{
//将当前拥有锁的线程进入等待队列,再次阻塞,直到再次获得资源
Monitor.Wait(lockobj);
if (!hasPay)
{
Console.WriteLine($"编号{num}:已经买单了");
hasPay = true;
}
else
Console.WriteLine($"编号{num}:买单失败");
}
finally
{
//给等待队列的线程进入就绪队列
Monitor.Pulse(lockobj);
}
}
}
static void VipPay()
{
lock (lockobj)
{
try
{
//将等待队列中的线程进入就绪
Monitor.Pulse(lockobj);
Console.WriteLine("Vip插队买单");
hasPay = true;
}
finally
{
//让其他的等待队列的线程进入就绪队列
Monitor.Pulse(lockobj);
}
}
}
static void Main(string[] args)
{
Task.Factory.StartNew( obj=> { Pay((int)obj); },1);
Thread.Sleep(50);
Task.Factory.StartNew(VipPay);
Console.ReadLine();
}
运行结果:
lock和Monitor的区别
一、lock的底层本身是Monitor来实现的,所以Monitor可以实现lock的所有功能。
二、Monitor有TryEnter的功能,可以防止出现死锁的问题,lock没有。
Mutex和前面两者的区别:
Mutex消耗较大的资源,不适合频繁的操作,会降低操作的效率。
Mutex本身是可以系统级别的,所以是可以跨越进程的。比如我们要实现一个软件不能同时打开两次,那么Mutex是可以实现的,而lock和monitor是无法实现的
2、使用SemaphoreSlim和Semaphore
信号量分为两种类型:本地信号量和命名系统信号量。 本地信号灯对应用程序而言是本地的,系统信号量在整个操作系统中均可见,适用于进程间同步。 SemaphoreSlim 是不使用 Windows 内核信号量的 Semaphore 类的轻型替代项。 与 Semaphore 类不同,SemaphoreSlim 类不支持已命名的系统信号量。 只能将其用作本地信号量。 SemaphoreSlim 类是用于在单个应用内进行同步的建议信号量。
限制同时访问同一个资源的线程数量,实例化信号量时,可以指定可同时进入信号量的最大线程数。 还可以指定可同时进入信号量的初始线程数,每次线程进入信号量时,计数就会减少,每次线程释放信号量时都会递增。
以下例子是限制每桌只有三个位,同时吃饭的人只有三个,多余的则需要等位。在初始化的时候设定最多的并发数,调用wait方法限制,当吃饭完后则调用release,发出信号给其他线程。
static SemaphoreSlim semaphore = new SemaphoreSlim(3);
static void Eat(int num)
{
Console.WriteLine($"编号{num}顾客等位");
semaphore.Wait();
Console.WriteLine($"有位了!编号{num}的顾客请用餐");
Thread.Sleep(100);
Console.WriteLine($"编号{num}吃完走了");
semaphore.Release();
}
static void Main(string[] args)
{
for(int i=1;i<6;i++)
{
Task.Factory.StartNew( obj=> { Eat((int)obj); },i);
}
Console.ReadLine();
}
3、AutoResetEvent和ManualResetEvent
Reset ():将事件状态设置为非终止状态,导致线程阻止;如果该操作成功,则返回true;否则,返回false。
Set ():将事件状态设置为终止状态。
WaitOne(): 阻止当前线程,直到收到信号。
WaitOne(TimeSpan, Boolean) :阻止当前线程,直到当前实例收到信号,使用 TimeSpan 度量时间间隔并指定是否在等待之前退出同步域。
下面先演示一下AutoResetEvent ,在这段代码中,每次走一个人都进行了set一次,发信号给下一个人。
static AutoResetEvent evenResetEvent = new AutoResetEvent(false);
static ManualResetEvent manualResetEvent = new ManualResetEvent(false);
static void Eat(int num)
{
Console.WriteLine($"编号{num}顾客等位");
evenResetEvent.WaitOne();
Console.WriteLine($"有位了!编号{num}的顾客请用餐");
Console.WriteLine($"编号{num}吃完走了");
//走一个set一次,发信号给下一个人
evenResetEvent.Set();
}
static void Main(string[] args)
{
for(int i=1;i<5;i++)
{
Task.Factory.StartNew( obj=> { Eat((int)obj); },i);
Thread.Sleep(50);
}
evenResetEvent.Set();
//前面的线程运行完成后,再执行下面这段线程,会发现线程被阻塞
Thread.Sleep(1000);
evenResetEvent.WaitOne();
Task.Factory.StartNew(obj => { Eat((int)obj); }, 10);
Console.ReadLine();
}
演示一下ManualResetEvent,这段代码则创建线程后,在主线程set一次后不再进行set。
static ManualResetEvent manualResetEvent = new ManualResetEvent(false);
static int nummm = 0;
static void Eat(int num)
{
Console.WriteLine($"编号{num}顾客等位");
manualResetEvent.WaitOne();
Console.WriteLine($"有位了!编号{num}的顾客请用餐");
Console.WriteLine($"编号{num}吃完走了");
//这里没有每次都set,只是在主线程那里set了一次
// evenResetEvent.Set();
}
static void Main(string[] args)
{
for(int i=1;i<5;i++)
{
Task.Factory.StartNew( obj=> { Eat((int)obj); },i);
Thread.Sleep(50);
}
manualResetEvent.Set();
//前面的线程运行完成后,再执行下面这段线程,会发现线程依然正常运行。
Thread.Sleep(1000);
manualResetEvent.WaitOne();
Task.Factory.StartNew(obj => { Eat((int)obj); }, 10);
Console.ReadLine();
}
根据两段代码的运行结果,可以得出他们的不同点:
AutoResetEvent 收到 Set 后 , 一次只能执行一个线程,其它线程继续 WaitOne 。
ManualResetEvent 收到 Set 后,所有处理 WaitOne 状态线程均继续执行,但是如果set之后不进行reset重置信号的话的话,下次进入的线程就算调用了waitOne也不会阻塞,上一个set的信号会继续生效。
4、Barrier
官方解释:使多个任务能够采用并行方式依据某种算法在多个阶段中协同工作,简单来说就是需要所有的信号都到达后都才能进行下一步。每次执行完一个动作后,信号个数都会重置为,下次还是依旧等待初始化设置的信号个数。
如上图所示,有四个任务,每个任务里面做各自的事情,当有一个屏障发出信号后就会开始等待,等待到4个全部到达,就会解除等待,同时进行下一步。
主要方法:
static Barrier Barrier = null;
static void Eat(int num)
{
Console.WriteLine($"编号{num}顾客到了");
//发出一个信号,等到4个信号均到达才往下进行
Barrier.SignalAndWait();
Console.WriteLine($"到齐了上菜,可以用餐了");
Barrier.SignalAndWait();
Console.WriteLine($"编号{num}饱了,呃......");
Barrier.SignalAndWait();
}
static void Main(string[] args)
{
Barrier = new Barrier(4);
for (int i=1;i<5;i++)
{
Task.Factory.StartNew( obj=> { Eat((int)obj); },i);
Thread.Sleep(50);
}
//等待所有线程执行完毕
Task.WaitAll();
Console.WriteLine($"都吃完溜了");
Console.ReadLine();
}
5、CountdownEvent
指在被发信号一定次数后,它会解除对等待线程的阻塞,设置等待的次数,待所有线程信号到达后进行下一步。但是每次信号到达后可以重置信号数量,每次调用wait后将不再阻塞,直到调用Reset()函数。
CountdownEvent可以通过TryAddCount()和AddCount()函数来增加函数Signal() 需被调用的次数,但只有当CountDownEvent处于未就绪态时才会成功。
static CountdownEvent countdownEvent = null;
static void Eat(int num)
{
Console.WriteLine($"编号{num}顾客到了");
Console.WriteLine($"编号{num}饱了,呃......");
countdownEvent.Signal();
}
static void Main(string[] args)
{
countdownEvent = new CountdownEvent(2);
// Barrier = new Barrier(4);
for (int i=1;i<3;i++)
{
Task.Factory.StartNew( obj=> { Eat((int)obj); },i);
Thread.Sleep(50);
}
//在此阻塞,全部信号到达后可以进行下一步
countdownEvent.Wait();
Console.WriteLine($"都吃完溜了");
//可以重置信号数量为4个,下次调用wait的时候则需要等待4个信号
//countdownEvent.Reset(4);
Console.ReadLine();
}