您的位置:首页 > 其它

线程同步

2016-04-27 21:18 225 查看

线程同步

 
CLR为每个线程分配了线程栈,用于保存本地变量,这样可以保证本地变量是独立的,案例:
static void Main(string[] args)
{
ThreadStart ts = new ThreadStart(Print);
new Thread(ts).Start();
Print();
}
static void Print()
{
for (int i = 0; i < 3; i++)
{
Console.Write(i);
}
}

在主线程和工作线程中都使用了i变量,但它们是相互独立的,彼此不会影响.线程之间也能共享资源,比如前面的那个shareValue变量的案例.当多个线程访问同一对象时,如果不加以协调,可能会出现结果不正确的情况:
static int i = 0;
static void Main(string[] args)
{
ThreadEntry();
ThreadEntry();
}
static void ThreadEntry()
{
Console.WriteLine("i={0}",i);
i++;
}

这段代码在主线程上执行的结果为:
i=0
i=1
 
如果让一个ThreadEntry(0方法由主线程执行,另一个由工作线程执行:
static void Main(string[] args)
{
new Thread(ThreadEntry).Start();
ThreadEntry();
}

结果可能是:
i=0
i=0

这是因为两个线程同时进入了ThreadEntry()方法,向控制台输出后采取修改i的值.如果i作为if或者while的判断语句,例如(ifi==1){//},那么这段代码能否执行时不确定的.
 
线程同步就是协调多个线程间的并发操作,已获得符合预期的,确定的执行结果,消除多线程应用程序中的不确定性,它包含两个方面:
(1).保护资源(或代码),即确保资源(或代码)同时只能由一个线程(或指定个数的线程)访问,一般措施是获取锁和释放锁(后面简称锁机制).
(2).协调线程对资源(或代码)的访问顺序,即确定某一资源(或代码)只能先有线程T1访问,再由线程T2访问,一般措施是采用信号量(后面简称信号量机制).当T2线程访问资源时,必须等待线程T1先访问,线程T2访问完后,发出信号量,通知线程T2可以访问.
 
 

使用Monitor

 
System.Threading.Monitor对资源进行保护的思路很简单,即使用排它锁.当线程A需要访问某一资源(对象,方法,类型成员,代码段)时,对其进行加锁,线程A获取到锁以后,任何其他线程吐过再次对资源进行访问,则将其放到等待队列中,直到线程A释放锁以后,再将线程从队列中取出.
 
Monitor的Enter()静态方法用于获取锁,Exit()静态方法用于释放锁.
public static void Enter(object obj);
public static void Exit(object obj);
 

使用对象本身作为锁对象

 
现在使用Monitor解决和上面类似的问题,创建一个自定义的类型Resource,然在主线程和worker线程上调用他的Record()方法:
public class Resource
{
public string Called;
public void Record()
{
this.Called += string.Format("{0}[{1}]",Thread.CurrentThread.Name,DateTime.Now.Millisecond);
Console.WriteLine(Called);
}
}
class Program
{

private Resource res = new Resource();

static void Main(string[] args)
{
Thread.CurrentThread.Name = "Main";

Program p = new Program();
Thread worker = new Thread(p.ThreadEntry);
worker.Name = "Worker";
worker.Start();
p.ThreadEntry();
}
void ThreadEntry()
{
Monitor.Enter(res);
res.Record();
Monitor.Exit(res);

}
}

上面的代码将ThreadEntry()改为了实例方法,因此要先创建一个Program的实例.在ThreadEntry()方法中,在访问res对象前对其进行枷锁,在访问之后释放锁.
 
通过输出结果可以看到Main()线程先占用了res,并调用Record(),而worker线程后占用res,两个线程的切换时间还是存在的.如果注释掉Monitro.Enter()和Monitor.Exit()语句,此时再看一下输出.
 
因为两个线程同时进入到了Record()并获取到了Called,而此时Called为空.
 

使用System.Object作为锁对象

 
Monitor有一个限制,就是只能对引用类型加锁.如果将上面的Resource类型改为结构类型,则会抛出异常.解决的办法是将锁加在其他的引用类型上,比如System.Object.此时,线程并不会访问该引用类型的任何属性和方法,该对象的作用仅仅是协调各个线程.换言之,之前的操作流程是占用A,操作A,释放A.现在的流程是占用B,操作A,释放B.当这样做的时候,要保证所有线程在加锁和释放锁的时候都是针对同一个对象B.
 
  public struct Resource
{
public string Called;
public void Record()
{
this.Called += string.Format("{0}[{1}]",Thread.CurrentThread.Name,DateTime.Now.Millisecond);
Console.WriteLine(Called);
}
}
class Program
{

private Resource res = new Resource();
private object lockObj = new object();
static void Main(string[] args)
{
Thread.CurrentThread.Name = "Main";

Program p = new Program();
Thread worker = new Thread(p.ThreadEntry);
worker.Name = "Worker";
worker.Start();
p.ThreadEntry();
}
void ThreadEntry()
{
Monitor.Enter(lockObj);
res.Record();
Monitor.Exit(lockObj);
}
}
使用这种方式的时候,需要创建一个没有实际意义的,专门用于协调线程的对象.对上面的案例进行以下修改,让他们锁定不同的对象:

class Program
{

private Resource res = new Resource();
private object lockObj = new object();
private object lockObj2 = new object();
static void Main(string[] args)
{
Thread.CurrentThread.Name = "Main";
Program p = new Program();
ParameterizedThreadStart ts = new ParameterizedThreadStart(p.ThreadEntry);
Thread worker = new Thread(p.ThreadEntry);
worker.Name = "Worker";
worker.Start(p.lockObj);
p.ThreadEntry(p.lockObj2);
}
void ThreadEntry(object obj)
{
Monitor.Enter(obj);
res.Record();
Monitor.Exit(obj);
}
}

观察一下输出又不符合预期了.
 
 

使用System.Type作为锁对象

 
采用上面的方式时,需要创建一个专门用于协调线程的System.Object对象,有时候显得代码不够简洁.另一种做法是使用System.Type对象作为锁,使用System.Type对象是基于这样一个事实:多次调用typeof(Type)获取的是同一个对象:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
namespace 线程同步
{

public static class Resource
{
public static string Called;
public static void Record()
{
Called += string.Format("{0}[{1}]",Thread.CurrentThread.Name,DateTime.Now.Millisecond);
Console.WriteLine(Called);
}
}
class Program
{

static void Main(string[] args)
{
Program p = new Program();
Type t1 = typeof(Resource);
Type t2 = typeof(Resource);
Type t3 = typeof(Resource);

Console.WriteLine(t1==t2);
Console.WriteLine(t2==t3);
}
void ThreadEntry()
{
Monitor.Enter(typeof(Resource));
Resource.Record();
Monitor.Exit(typeof(Resource));
}
}
}

这样就又可以了.
 
 

使用lock语句

 
考虑这样一种情况:Record()方法抛出了异常.假设主线程先执行ThreadEntry()方法,那么当Record()抛出异常之后,将不会执行Monitor.Exit()方法.程序的输出结果可能是:Main[569],并且会一直等待下去.因为woker线程是前台线程,他一直在等待释放锁,而主线程知道结束都未释放锁.解决的方法是将worker线程设为后台线程,但前面已经说过这是不妥的做法,用这种方式结束线程是不合适的.还有一种方法是将Monitor.Exit()方法放到finally块中:
void ThreadEntry()
{
Monitor.Enter(res);
try
{
res.Record();
}
finally
{
Monitor.Exit(res);
}
}

由于Monitor的这种规模是太常见了,.NET提供了lock语句进行简化,上面的代码等价于:
void ThreadEntry()
{
lock (res)
{
res.Record();
}
}

lock语句只专注于获取锁,释放锁,并不提供处理异常的简写方法,如果要处理异常,还需要使用try/catch块:

void ThreadEntry()
{
lock (res)
{
try
{
res.Record();
}
catch (Exception ex)
{

Console.WriteLine(ex.Message);
}

}
}

 
 
 

创建线程安全类型

 
类型Resource不是类型安全的,它的内部并没有采取线程安全(Thread-Safe)的措施.最好将获取锁,释放锁的逻辑放到Resource内部实现:
public class Resource
{
public string Called;
public void Record()
{
lock (this)
{
this.Called += string.Format("{0}[{1}]",Thread.CurrentThread.Name,DateTime.Now.Millisecond);
Console.WriteLine(Called);
}
}
}

 
因为这种模式常见,因此,.NET提供了内置的编译器支持,称作同步方法(synchronized
methods),只要为方法添加System.Runtime.CompilerServices.MethodImpl标记,并将位置参数设置为MethodImplOptions.Synchronized即可,下面方法的效果与上面的相同的:

[MethodImpl(MethodImplOptions.Synchronized)]
public void Record()
{
this.Called += string.Format("{0}[{1}]", Thread.CurrentThread.Name, DateTime.Now.Millisecond);
Console.WriteLine(Called);
}

也能对类型的成员访问加锁:
public class Resource
{
private int _index;
public int Index
{
get
{
lock (this)
{
return _index;
}
}
set
{
lock (this)
{
_index = value;
}
}
}

}

上面的锁的粒度太小了,如果一个类的字段太多,那就会麻烦死,也容易形成BUG,所以将ThreadEntry()改为:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
using System.Runtime.CompilerServices;
namespace 线程同步
{

public class Resource
{
private int _index;
public int Index
{
get
{
lock (this)
{
return _index;
}
}
set
{
lock (this)
{
_index = value;
}
}
}

}
class Program
{
static Resource res = new Resource();
void ThreadEntry()
{
for (int i = 0; i <= 2; i++)
{
res.Index = res.Index + 1;
}
}
static void Main(string[] args)
{
Thread.CurrentThread.Name = "Main ";

Program p = new Program();
Thread worker = new Thread(p.ThreadEntry);
worker.Name = "Worker";
worker.Start();//worker线程执行一遍
p.ThreadEntry();//主线程执行一遍
Console.WriteLine(res.Index);
}

}
}

res.Index=res.Index+1语句先调用了get代码段,随后调用了set代码段.我盟期望的结果是6,但实际结果是不确定的.因为两个线程可能先后进入了get语句,获取了相同的Index值,然后又先后进入了set语句段,最终的结果可能是3.如果想获取期望的结果,那么应该讲res.Index=res.Index+1视为操作单元为其加锁,此时get和set代码段才会顺序执行.
void ThreadEntry()
{
for (int i = 0; i <= 2; i++)
{
lock (res)
{
res.Index = res.Index + 1;
Console.WriteLine("{0}: {1}",Thread.CurrentThread.Name,res.Index);
}
}
}

能看到输出结果是交替的,如果想要他们顺序输出,则需要再次提高锁的粒度,将锁加在for循环外部:
void ThreadEntry()
{
lock (res)
{
for (int i = 0; i <= 2; i++)
{

res.Index = res.Index + 1;
Console.WriteLine("{0}: {1}", Thread.CurrentThread.Name, res.Index);
}
}
}

可见,锁的粒度对于程序执行的顺序和结果是很重要的.
 
 

使用Moinitor协调线程执行顺序

 
前面使用Monitor保证了资源只能同时由一个线程访问,但是没有限制资源先由T1访问还是T2访问,由于Start()方法实际执行时间的不确定,因此结果可能是主线程先访问,也可能是worker线程先访问.通常,两个线程执行的是不同的任务,比如工作线程获取并计算数据,主线程显示数据.那么此时顺就很重要,来看下面的案例:
  public class Resource
{
public string Data;
}
class Program
{
private Resource res = new Resource();
void ThreadEntry()
{
res.Data = "Retrived";
Monitor.Pulse(res);
}
static void Main(string[] args)
{
Thread.CurrentThread.Name = "Main ";

Program p = new Program();
Thread worker = new Thread(p.ThreadEntry);
worker.Name = "Worker";
worker.Start();
Console.WriteLine("Data={0}",p.res.Data);
}

}
其中worker线程用于获取数据(设置res.Data属性的值),主线程则用于显示数据,上面的结果虽然会报错,但是第一行可能是”Data=”,因为主线程运行到Console.WriteLine()语句的时候,worker线程尚未执行.咱们使用Monitor类型的Wait()和Pulse()静态方法:

public static bool Wait(object obj);
public static void Pulse(object obj);

 
Wait(0用于暂停当前线程并等待信号(调用了Wait()方法的线程,状态为WaitSleepJoin),Pulse()方法则用于发出信号,收到信号的新城将会执行后续代码.这两个方法都必须置于lock块内,并且两个方法接收的对象与lock接受的对象相同.修改上面的代码:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
using System.Runtime.CompilerServices;
namespace 线程同步
{

public class Resource
{
public string Data;
}
class Program
{
private Resource res = new Resource();
void ThreadEntry()
{
lock (res)
{
res.Data = "Retrived";
Monitor.Pulse(res);
}
}
static void Main(string[] args)
{
Thread.CurrentThread.Name = "Main ";

Program p = new Program();
Thread worker = new Thread(p.ThreadEntry);
worker.Name = "Worker";
worker.Start();
lock (p.res)
{
if (string.IsNullOrEmpty(p.res.Data))
{
Monitor.Wait(p.res);
}
Console.WriteLine("Data={0}",p.res.Data);
}
}

}
}

Main()方法中的if语句很重要.因为有可能worker线程已经向res.Data赋值并执行过了Monitor.Pulse(),如果此时至宣城再去执行Mointor.Wait(),那么它永远也等不到这个信号量,会一直等待下去.可以删除if语句,然后在worker.Start()语句后添加Thread.Sleep(100)来模拟这种情况:
            worker.Start();

            Thread.Sleep(100);

            lock (p.res)

            {

                Monitor.Wait(p.res);

                Console.WriteLine("Data={0}", p.res.Data);

            }
此时程序会阻塞在Wait()方法的位置,为了避免这种情况,可以调用Wait()的重载方法,传入一个int类型的参数,该参数指示线程等待的时间,以毫秒为单位.如果在等待期间收到信号(其他线程调用了Monitor.Pulse()),则返回true;如果超时,则返回false.修改lock块中的语句:
lock (p.res)
{
bool isTimeOut = Monitor.Wait(p.res,100);
Console.WriteLine(isTimeOut);
Console.WriteLine("Data={0}", p.res.Data);
}


有时候,多个线程等待同一个数据,此时可以调用Monitor.PulseAll()方法,通知所有等待的线程.
 
 

死锁

 
使用Monitor进行线程同步,可能会出现的一种比较棘手的问题就是死锁,简单来说死锁就是两个或两个以上的线程在执行过程中,因为争夺资源而造成的一种互相等待的现象,如果没有外力进行干预(例如调用Abort()),则它们都将无法推进下去.一个线程T1占用资源R1,同时试图访问资源R2,另一个线程T2占用资源R2,同时试图访问资源R1.结果就是两个线程彼此等待,谁也无法进行下去.案例如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
using System.Runtime.CompilerServices;
namespace 线程同步
{

public class Resource
{
public string Data;
}
class Program
{
private Resource mainRes = new Resource() { Data="mainRes"};
private Resource workerRes = new Resource() { Data="workerRes"};

static void Main(string[] args)
{

}
static void T1(Program p)
{
lock (p.mainRes)
{
Thread.Sleep(10);
lock (p.workerRes)
{
Console.WriteLine(p.workerRes.Data);//死锁
}
}
}

void T2()
{
lock (workerRes)
{
Thread.Sleep(10);
lock (mainRes)
{
Console.WriteLine(mainRes.Data);//死锁
}
}
}
}
}

主线程占用了mainRes,同时尝试访问workerRes;工作线程占用了workerRes,同时尝试访问mainRes,最后的结果是两个线程都卡在了内部的elock语句的位置.上面问题解决方案是使用Monitor.TryEnter()方法,这个方法不会像Monitor.Enter()方法那样一直等待锁,而是立即返回.如果能获取锁,返回true,否则返回false.可以修改内部的lock语句,使用for循环,尝试三次获取锁,如果失败则放弃并退出.修改T1,T2.
static void T1(Program p)
{
lock (p.mainRes)
{
Thread.Sleep(10);
int i = 0;
while (i < 3)
{
if (Monitor.TryEnter(p.workerRes))
{
Console.WriteLine(p.workerRes.Data);
Monitor.Exit(p.workerRes);
break;
}
else
{
Thread.Sleep(1000);
}
i++;
}
if (i == 3)
{
Console.WriteLine("{0}: Tried 3 times , Deadlock", Thread.CurrentThread.Name);
}
}
}

在实际开发中,在哪个位置产生了死锁事先不知道,但可以使用这里的方法来对可能产生死锁的位置进行测试.
 
线程的东西就先说这么多,这里楼主关于.NET的东西就告一段落了,以后楼主的打算是学学Linux!!!还有Docker!!!顺道学学Ruby,这三个东西以前楼主都是学过的,在Linux上玩玩Docker,Kali
Linux的大部分文件都是Ruby写的,所以这三个东西的应该一起学起来不难.
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  .net