一、前言
线程同步其实很简单,但是往往被老师教的很复杂。这是之前上课受的伤。脑袋瓜当人人家的跑马场,被蹂躏一番,最后老师留下的是先入为主的错误,以至于后面不停的干扰我的理解,纠起错来,真是不知道浪费了多少精力。
二、什么是线程同步
一直想要找一个良好的方式来表达什么是线程同步。
先看一个模拟线程同步的图:
假如这个盒子一次只能放一个东西,并且接力赛又要保持顺畅,该是怎样的情景?
首先对于Reader来说,取货的时候,箱子必须有货,如果没有货,要在旁边等候;
其次对于Writer来说,存货的时候,箱子必须为空,如果不为空,也要在旁边等候;
两个人要步调一致,并且配合默契,才能顺利的搬运东西。反过来,如果Reader执行了好几次,Writer才执行一次,或者Write执行了好几次,Reader才执行一次,最后都不能很好的保持步调的一致。
把两个人看成是两个线程,这时候线程要同步,也必须要满足上面的要求。两个线程要协同一致的工作,才能完成一项任务。
三、代码演示:
public class ThreadSyn
{
//缓存区,假设一次只能缓存一个字符
private static char buffer;
//线程1:写操作
public Thread thread1 = new Thread(()=>
{
string str = "横看成岭侧成峰,远近高低各不同。不识庐山真面目,只缘身在此山中。";
for (int i = 0; i < 32; i++)
{
buffer = str[i];
Thread.Sleep(26);
}
});
//线程2:读操作
public Thread thread2 = new Thread(() =>
{
for (int i = 0; i < 32; i++)
{
char ch = buffer;
Console.WriteLine(ch);
Thread.Sleep(36);
}
});
}
public class Program
{
static void Main(string[] args)
{
ThreadSyn threadSyn=new ThreadSyn();
threadSyn.thread1.Start();
threadSyn.thread2.Start();
Console.Read();
}
}
运行效果图:
四、原因和方案:
此时线程还是没有协同工作。因为如果写一个,读一个,再写一个,再读一个,那么这首诗应该是一首完整的显示。但是效果图的诗句却是紊乱的。
如何才能解决真正的同步,.net为我们提供了一系列的同步类。包括:互锁(Interlocked),管程(Monitor)和互斥体(Mutex).
4.1下面用互锁来解决上面的问题。
public class ThreadSyn
{
//缓存区
private static char _buffer;
//标示盒子,即缓冲区使用的空间,盒子初始化为0
private static long _box = 0;
//线程1:写操作
public Thread thread1 = new Thread(()=>
{
string str = "横看成岭侧成峰,远近高低各不同。不识庐山真面目,只缘身在此山中。";
for (int i = 0; i < 32; i++)
{
//写入之前检查缓冲区
//如果缓冲区已满,就进行等待,直到缓冲区的数据被进程读取为止
while(Interlocked.Read(ref _box) == 1)
{
Thread.Sleep(10);
}
//向缓冲区写数据
_buffer = str[i];
//写完数据,标记缓冲区已满
Interlocked.Increment(ref _box);
}
});
//线程2:读操作
public Thread thread2 = new Thread(() =>
{
for (int j = 0; j < 32; j++)
{
//写入之前检查缓冲区
//如果缓冲区为空,就进行等待,直到缓冲区的数据被进程填充为止
while(Interlocked.Read(ref _box) == 0)
{
Thread.Sleep(10);
}
//向缓冲区读数据
char ch = _buffer;
Console.Write(ch);
//读完数据,标记缓冲区已空
Interlocked.Decrement(ref _box);
}
});
}
运行效果图:
InterLocked提供了单个指令的操作,因此他提供了性能非常高的同步。
4.2用Monitor来解决问题
Monitor的原理是这样的:先执行的线程,独占锁,进入临界区,执行临界区资源代码。其他线程,只能在集中在临界资源上等待被叫唤。当独占锁推出资源区,也可以继续让自己等待,等待下一次被叫唤。
//缓存区
private static char _buffer;
//用于同步的对象
private static object _objForLock = new object();
//线程1:写操作
public Thread thread1 = new Thread(() =>
{
string str = "横看成岭侧成峰,远近高低各不同。不识庐山真面目,只缘身在此山中。";
for (int i = 0; i < 32; i++)
{
try
{
//进入临界区,获取独占锁
Monitor.Enter(_objForLock);
//向缓冲区写数据
_buffer = str[i];
//写完后,唤醒在临界资源上睡眠的线程
Monitor.Pulse(_objForLock);
//让当前线程睡眠在临界资源上
Monitor.Wait(_objForLock);
//整个流程有点像轮班吃饭,
//第一个人先去吃饭,第二个在值班等待,第一个吃完了,唤醒第二个吃饭,自己则在等待下一次吃饭。
}
catch (ThreadInterruptedException ex)
{
Console.WriteLine("线程被中断……");
}
finally
{
//退出临界区
Monitor.Exit(_objForLock);
}
}
});
//线程2:读操作
public Thread thread2 = new Thread(() =>
{
for (int j = 0; j < 32; j++)
{
try
{
//进入临界区,获取独占锁
Monitor.Enter(_objForLock);
//向缓冲区读数据
char ch = _buffer;
Console.Write(ch);
//写完后,唤醒在临界资源上睡眠的线程
Monitor.Pulse(_objForLock);
//让当前线程睡眠在临界资源上
Monitor.Wait(_objForLock);
}
catch (ThreadInterruptedException ex)
{
Console.WriteLine("线程被中断……");
}
finally
{
//退出临界区
Monitor.Exit(_objForLock);
}
}
});
不同的是Monitor只能锁定引用类型的对象,值类型会被装箱,等于生成另外一个对象,不能达到同步。为了保证推出临界区资源得到释放,使用了finally。为了方便使用,C#专门使用了lock语句。
所以我们可以完全更简洁的重写上面的try{}finally{}中的关键代码,如下所示:
lock (_objForLock)
{
//进入临界区,获取独占锁
Monitor.Enter(_objForLock);
//向缓冲区写数据
_buffer = str[i];
//写完后,唤醒在临界资源上睡眠的线程
Monitor.Pulse(_objForLock);
//让当前线程睡眠在临界资源上
Monitor.Wait(_objForLock);
}
独占锁注意:
因为独占锁,其他线程就不能再访问,只有Lock结束后,其他线程才可以访问,这保证了访问的正确性。但是,如果有多个线程对同一个资源进行写操作,在独占锁解开前,其他线程只能被临时暂停,这使得程序的效率大打折扣。所以应该慎用锁,只有必要时才使用。