C#多线程之旅~上车吧?
前言:前几天,写了一篇关于多线程使用的文章,【线程使用】用法得到不少博友的好评,博主这几天加班写文章,把剩下的高级使用给写完,期望可以得到博友的追赞吧,那么废话不多说,开始我们的C#高级用法之旅!!
再次访问的执行顺序
从上一篇文章的交替执行的线程演示可以说明线程流是从一个线程到另一个线程随机进行的。这看起来是要先执行一个线程,在控制台中显示十行,再让一个线程显示十五行,然后返回一个线程,执行八行,决定是否使用线程的一个常见错误是,自以为知道在线程的指定时间要执行多少代码。 下面将要使用一个栗子来说明这个问题。看上去T1会先结束,因为它先启动,但这是错的,创建一个应用程序,将其对象设置为Main()。构建这几个示例,会得到不一样的结果。
public class ExecutionOrder
{
static Thread t1;
static Thread t2;
public static void WriteFinished(string threadName)
{
switch (threadName)
{
case "T1":
Console.WriteLine();
Console.WriteLine("T1 Finished");
break;
case "T2":
Console.WriteLine();
Console.WriteLine("T2 Finished");
break;
}
}
public static void MainGo()
{
t1 = new Thread(new ThreadStart(Increment));
t2 = new Thread(new ThreadStart(Increment));
t1.Name = "T1";
t2.Name = "T2";
t1.Start();
t2.Start();
Console.ReadLine();
}
public static void Increment()
{
for (long i=1;i<=1000000;i++)
{
if (i%10000==0)
{
Console.WriteLine("{"+Thread.CurrentThread.Name+"}");
}
}
WriteFinished(Thread.CurrentThread.Name);
}
}
有时候,线程t1先会结束,但有的时候t2会先结束,所以我们不能指定线程按照启动的先后顺序结束。
初级程序员会跳的坑
public static void SendAllEmail()
{
int loopTo = al.count - 1;
for (int i=0;i<=loopTo;i++)
{
Thread t = LoopingThreads.CreateEmail(
new LoopingThreads.SendMail(Mailer.MailMethod,
(string)[i],
"johndoe@somewhere.com",
"Thread in a joop", "Mail Example");
t.Start();
t.Join(Timeout.Infinite);
}
}
为何要使用同步?
.NET 开发人员之所以需要设计多线程应用程序时保持同步,主要有两个原因。- 避免竞争条件
- 确保线程的安全
- 同步代码中的重要部分
- 使对象不可改变
- 使用线程安全包装器
同步重要的代码段
为避免多个线程在同一时刻更新资源而引起的不良结果,需要限制对资源的访问,只允许在同一时刻只有一个线程能够更新资源,换勾话说,就是使资源具有线程安全性。使对象或实例变量具有线程安全性的最简单方式是标识和同步重要的代码段。重要的代码段是指程序中多个线程可以在同一时刻访问以更新对象的状态的一段代码。例如,在上面的例子中,X夫妇二人试图同时访问同一个Withdraw()方法,Withdraw()方法就成为重要的代码段,需要具有线程安全性。最容易实施的方式是同步化方法Withdraw(),以便在任一时刻只有一个线程(X先生或X夫人)能够访问资源。在执行期间不能被中断的事务处理叫作原子操作。原子一其传统的含义是不可分的单元,原子操作的处理是作为一个完整单元执行的代码单元一就好像这些代码是一条处理器指令一样,使Withdraw()方法具有原子性一样,就可以确保在第一个线程更改完成之前,另一个线程无法去修改同一账户的余额。下面列出的是一个非线程安全的Account类的伪代码:public class Account
{
public ApprovedOrNot WithDraw(Amount)
{
//Check...
}
}
public class Account
{
public ApprovedOrNot Withdraw(Amount)
{
lock this section(access for only one thread)
{
//check
}
}
}
使账户对象不可改变
使用线程安全包装器
使对象具有线程安全性的第三个方法是在对象上编写一个具有线程安全性的包装器,而不是使对象本身具有线程安全性,对象保持不变,而新的包装器包括线程安全代码的同步部分。下面列出基于Account对象的包装器类:public class AccountWrapper
{
private Account _a;
public AccountWrapper(Account a)
{
this._a = a;
}
public bool Withdraw(double amount)
{
lock (_a)
{
return this._a.WithDraw(amount);
}
}
}
.NET 对同步的支持
.NET Framework在System.Threading、System.EnterpriseServices和System.Runtime.Complier 命名空间上提供了一些类,当然.NET Core也是如此。程序猿可以通过这些开发线程安全。下面我们逐一谈一谈。MethodlmplAttribute类
System.Runtime.CompilerService命名空间包含的一些属性将影响CLR在运行期间的行为。MethodlmplAttribute就是这样的一个属性,它会告诉CLR方法是如何实现的。MethodlmplAttribute的一个构造函数把MethodImplOptions枚举作为其参数。MethodImplOptions枚举有一个字段Synchronized,它在任一时间只允许一个线程来访问这个方法。这类似于我们的lock关键字,下面说明了这个属性来同步方法(创建MI.cs):public class MI
{
[MethodImpl(MethodImplOptions.Synchronized)]
public void DoSomeWorkSync()
{
Console.WriteLine("DoSomeWorkSync()"
+"--Lock held by Thread"
+Thread.CurrentThread.GetHashCode());
Thread.Sleep(5*1000);
Console.WriteLine("DoSomeWorkSync()"
+ "--Lock held by Thread"
+ Thread.CurrentThread.GetHashCode());
}
public void DoSomeWorkNoSync()
{
Console.WriteLine("DoSomeWorkNoSync()"
+ "--Lock held by Thread"
+ Thread.CurrentThread.GetHashCode());
Thread.Sleep(5 * 1000);
Console.WriteLine("DoSomeWorkNoSync()"
+ "--Lock held by Thread"
+ Thread.CurrentThread.GetHashCode());
}
}
class Program
{
static void Main(string[] args)
{
MI m = new MI();
Thread t1 = new Thread(new ThreadStart(m.DoSomeWorkNoSync));
Thread t2 = new Thread(new ThreadStart(m.DoSomeWorkNoSync));
t1.Start(); t2.Start();
Thread t3 = new Thread(new ThreadStart(m.DoSomeWorkSync));
Thread t4 = new Thread(new ThreadStart(m.DoSomeWorkSync));
t3.Start(); t4.Start();
}
}
在上面的代码中,MI类有两个方法doSomeWorkSync0和doSomeWorkNoSync()。MethodImpl属性应用于doSomeWorkSync()方法, 用于同步该方法;而doSomeW orkNoSync()保持不变,允许多个线程同时访问方法。在Main0方法中,线程t1和t2访问未同步的方法,线程t3和t4访问已同步的方法。在这两个方法中,都添加了Thread,Sleep(方法, 用于为另一个正在等待的线程提供足够的时间进入方法,同时第一个线程仍在方法中。如果程序的情况是这样,线程t1和t2就可以同步进入doSomeWorkNoSync()方法,而线程t3和t4中只有一个可以进入doSomeWorkSync()方法。如果tl和t2有相同的优先级,则哪一个线程会优先执行就是随机的; .NETFramework不保证线程执行的顺序。
仔细研究一下输出, 注意线程2(t1)和线程3(t2)同时进入doSomeW orkNoSync0方法,而"且线程4(t3)获得了doSomeWorkSync()的锁定,线程5(t4)就不能进入该方法了,只有线程4(t3)释放了该方法的锁定,线程5(t4)才能再次获得该锁定。
.NET同步策略
公共语言基础结构提供了3种策略同步去访问示例、静态方法和示例字段,分为以下三种:- 同步上下文
- 同步代码区
- 手控同步
同步上下文
上下文是一组属性或使用规则,这种属性和使用规则对执行时相关的对象集合是通用的。能够添加的上下文属性包括有关同步、线程亲缘性和事务处理的策略,简而言之,上下文把类似的对象组合在一起,这种策略将使用Synchronization 类对ContextBoundObject对象进行自动同步。由于线程同步和并发管理是开发人员遇到最头疼的事情也是最困难的任务,因此这种方法是极大提高了效率。 Synchronization 类对缺少手工处理同步经验的程序员来说是非常有用的。包括我。因为它包括了实例变量,实例方法和应用这个属性的类的实例字段。然而。它不处理静态字段和方法的同步。如果必须同步制定代码块。它不起作用。同步整个对象是对轻松使用必须付出的代价。在使用System.EnterpriseAttribute编程时,Synchronization 非常方便,因为一个上下文的对象由com+运行库组合在一起。
回到前面的栗子里,Account这个示例,可以使用Synchronization 将伪代码变成具有安全性的线程。下面使用了它进行同步Account类:
[SynchronizationAttribute(SynchronizationAttribute.REQUIRED)]
public class Account : ContextBoundObject
{
public ApprovedOrNot Withdraw(Amount)
{
//check
}
}
同步代码区
第二种同步策略是特定代码区的同步,这些特定的代码区是方法种的重要代码段。它们可以改变对象的状态,或者更新另一资源。下面我们介绍Monitor和ReadWriterLock类。
Monitor类
Monitor 用于同步代码区,其方式是使用Monitor.Enter()方法获得一个锁,然后,使用Monitor.Exit()方法释放该锁,锁的概念通常用于解释Monitor类。一个线程获得锁。其他线程就要等到被释放之后才可以使用。一旦在代码中获得了锁,就可以通过Monitor.Enter()和Monitor.Exit()程序块。
-
Wait() ------ 此方法用于释放对象上的锁,并暂停当前线程,知道它重新获得了锁。
-
Pulse() ------ 此方法用于通知正在队列中等待的线程,对象的状态已经改变了。
-
PulseAll() ------ 此方法用于通知所有正在队列中等待的线程,对象的状态已经改变了。
注意,Monitor 方法是静态方法,能被Monitor类自身调用,而不是由该类的实例调用。在.NET Framework中,每个对象都有一个与之相关的锁,可以获取和释放该锁,这样,在任一时刻仅有一个线程可以访问对象的实例变量和方法。与之类似,.NETFramework中的每个对象也提供一种允许它处于等待状态的机制。正如锁的机制,设计这种机制的主要原因是帮助线程间的通信。如果一个线程进入对象的重要代码段,并需要一定的条件才能存在,而另一线程可以在该代码段中创建该条件,此时就需要这种机制,下面是使用Enter()和Exit()方法的示例。
public class MonitorEnterExit
{
private int result = 0;
public MonitorEnterExit()
{
}
public void NonCriticalSection()
{
Console.WriteLine("Entered Thread"+Thread.CurrentThread.ManagedThreadId);
for (int i=0;i<=5;i++)
{
Console.WriteLine("Result="+result+++"ThreadID:"+Thread.CurrentThread.GetHashCode());
Thread.Sleep(1000);
}
Console.WriteLine("Exiting Thread"+Thread.CurrentThread.GetHashCode());
}
public void CriticalSection()
{
//Enter the Critical Section
Monitor.Enter(this);
Console.WriteLine("Enter Thread"+ Thread.CurrentThread.GetHashCode());
for (int i = 0; i <= 5; i++)
{
Console.WriteLine("Result=" +result+++ "ThreadID:" + Thread.CurrentThread.GetHashCode());
Thread.Sleep(1000);
}
Console.WriteLine("Exiting Thread" + Thread.CurrentThread.GetHashCode());
Monitor.Exit(this);
}
}
上述代码的输出如下所示(计算机不同可能结果就不同,因为进程的ID不同)
比较一下重要代码和非重要代码的输出结果,就会使代码变得清晰,两个线程都修改了result变量,产生一个混合变量型输出结果,这是因为在NonCriticalSection方法中没有锁,这个线程不是安全的,多个线程可以访问方法。同时局部变量可以访问方法。想法我们看一下CriticalSection方法,在第一个线程退出之前,其他的线程都无法访问重要的代码段,那就这么锁住了,怎么解开呢?请再读下文!
Wait()和Pulse()机制
Wait()和Pulse{)机制用于线程间的交互。当在一个对象.上执行Wait()时,正在访问该对象的线程就会进入等待状态,直到它得到一个唤醒的信号。Pulse()和PulseAll()用于给等待线程发送信号。下面列出的是一个Wait{)和Pulse()方法如何工作的例子,即WaitAndPulse.cs示例:
注意:使用这两个方法一定要在锁中!!!
public class LockMe{}
public class WaitPulse1
{
private int result = 0;
private LockMe _IM;
public WaitPulse1()
{
}
public WaitPulse1(LockMe l)
{
this._IM = l;
}
public void CriticalSection()
{
Monitor.Enter(this._IM);
Console.WriteLine("WaitPulsel:Entered Thread" + Thread.CurrentThread.ManagedThreadId);
for (int i=1;i<=5;i++)
{
Monitor.Wait(this._IM);
Console.WriteLine("WaitPulsel:WorkUp");
Console.WriteLine("WaitPulsel:Result="+result+++"ThreadID"+Thread.CurrentThread.GetHashCode());
Monitor.Pulse(this._IM);
}
Console.WriteLine("WaitPulsel:Result=" + result++ + "ThreadID" + Thread.CurrentThread.GetHashCode());
Monitor.Exit(this._IM);
}
}
public class WaitPulse2
{
private int result = 0;
private LockMe _IM;
public WaitPulse2()
{
}
public WaitPulse2(LockMe l)
{
this._IM = l;
}
public void CriticalSection()
{
Monitor.Enter(this._IM);
Console.WriteLine("WaitPulse2:Entered Thread" + Thread.CurrentThread.ManagedThreadId);
for (int i = 1; i <= 5; i++)
{
Monitor.Pulse(this._IM);
Console.WriteLine("WaitPulse2:Result=" + result++ + "ThreadID" + Thread.CurrentThread.GetHashCode());
Monitor.Pulse(this._IM);
Console.WriteLine("WaitPulse2:WorkUp");
}
Console.WriteLine("WaitPulse2:Result=" + result++ + "ThreadID" + Thread.CurrentThread.GetHashCode());
Monitor.Exit(this._IM);
}
}
static void Main(string[] args)
{
LockMe lockMe = new LockMe();
WaitPulse1 el = new WaitPulse1(lockMe);
WaitPulse2 e2 = new WaitPulse2(lockMe);
Thread t1 = new Thread(new ThreadStart(el.CriticalSection)); t1.Start();
Thread t2 = new Thread(new ThreadStart(e2.CriticalSection)); t2.Start();
Console.ReadLine();
}
上述代码的输出如下所示
在Main()方法中,创建一个名为1的LockMe对象。接着创建两个类型为WaitPulse!和WaitPulse2的对象,然后把它们作为委托传递,以便线程能够调用这两个对象的CriticalSection()方法。注意,WaitPulsel 中的LockMe 对象实例与WaitPulse2 中的LockMe对象实例相同,因为对象按引用被传递给它们各自的构造函数。初始化对象后,创建两个线程t1和t2,把它们分别传递给两个CriticalSection方法。
假设WaitPulse1.CriticalSection(首先获得调用,则线程t1通过LockMe对象.上的锁定进入该方法的重要代码段,然后在For循环中执行Monitor.Wait()。执行Monitor.Wait()方法后,线程tl等待另一个线程的运行时通知(Monitor.Pulse()),而被唤醒。锁定LockMe对象是希望在任一时刻都只允许一个线程访问共享的LockMe实例。
注意,当线程执行Monitor. Wait(方法时,它会临时释放LockMe对象上的锁,以便其他线程可以访问它。线程t1进入等待状态后,线程t2就可自由访问LockMe对象。即使LockMe对象是个独立的对象((WaitPulsel和WaitPulse2),这两个线程也均指向同一个对象引用。线程t2获得LockMe对象上的锁后,进入WaitPulse2.CriticalSection()方法。它一进入For循环,就给在LockMe对象上等待的线程(此时是t1)发送-一个运行时通知(Monitor.Pulse()),之后进入睡眠状态。结果t1醒来,获得LockMe对象上的锁。接着线程t1访问result变量,并给在LockMe对象.上等待的线程(此时是t2)发送-一个运行时通知。这个循环一直持续到For循环结束。.
如果把程序的输出结果和上面的描述相对比,概念就非常明确了。注意每个Enter()方法都应该伴随一个Exit()方法,否则程序将陷入死循环。
TryEnter()方法
Monitor类的TryEnter()方法非常类似于Enter()方法,它视图获得对象的独占楼,不过它不会想Enter()方法那样暂停。如果线程成功进入,则TryEnter()方法返回True。public class MonitorTryEnter
{
public MonitorTryEnter()
{
}
public void CriticalSection()
{
bool b = Monitor.TryEnter(this,1000);
Console.WriteLine("Thread="
+ Thread.CurrentThread.GetHashCode()+"TryEnter Value"+b);
for (int i=1;i<=3;i++)
{
Thread.Sleep(1000);
Console.WriteLine(i+" "+Thread.CurrentThread.GetHashCode()+" ");
}
Monitor.Exit(this);
}
}
上述代码的输出如下所示
有可能会发生冲突,那也是无法避免的,如果真的需要知道是否被锁定,也只能这么干。现在到了这里,我觉得你已经写的不耐烦了,包括我。。。现在我们使用lock关键字,这个关键字替代了Monitor,和上面部分代码是等价的。
Monitor.Enter(x) ... Monitor.Exit(x) == lock(x){...}
private int result = 0;
public void CriticalSection()
{
lock (this)
{
Console.WriteLine("Result=" + result++ + "ThreadID" + Thread.CurrentThread.GetHashCode());
for (int i=1;i<=5;i++)
{
Console.WriteLine("Result=" + result++ + "ThreadID" + Thread.CurrentThread.GetHashCode());
Thread.Sleep(1000);
}
Console.WriteLine("Exiting Thread"+ Thread.CurrentThread.GetHashCode());
}
}
这是一个最基本的lock用法,除此之外还有一个ReaderWriterLock类
ReadWriteLock
ReaderWriterLock 定义了实现单写程序和多读程序语义的锁,这个类主要用于文件操作,即多个线程可以读取文件,但只能一个线程进行更新文件。ReaderWriterLock 类中4个主要方法是:
- AcquireReaderLock():该重载方法获得一个读程序锁,超时值使用整数或TimeSpan。超时是用于检测死锁的好工具。
- AcquireWriterLock():该重载方法获得了一个写程序锁,超时值使用整数或TimeSpan。
- ReleaseReaderLock():释放读程序锁
- ReleaseWriterLock():释放写程序锁
使用ReaderWriterLock 类时,任意数量的线程都可以同时安全地读取数据。只有当线程进行更新时,数据才被锁定。只有在没有占用锁的写程序线程时,读程序线程才能获得锁。只有在没有占用锁的读程序或写程序线程时,写程序线程才能获得锁。
public class ReadWrite
{
private ReaderWriterLock rwl;
private int x;
private int y;
public ReadWrite()
{
rwl = new ReaderWriterLock();
}
public void ReadInts(ref int a,ref int b)
{
rwl.AcquireReaderLock(Timeout.Infinite);
try
{
a = this.x;
b = this.y;
}
finally
{
rwl.ReleaseReaderLock();
}
}
public void WriteInts(int a,int b)
{
rwl.AcquireWriterLock(Timeout.Infinite);
try
{
this.x = a;
this.y = b;
System.Console.WriteLine("x="+this.x+"y="+this.y+
"ThreadID="+Thread.CurrentThread.GetHashCode());
}
finally
{
rwl.ReleaseWriterLock();
}
}
}
class Program
{
public ReadWrite rw = new ReadWrite();
static void Main(string[] args)
{
Program program = new Program();
//writer thraeds
Thread wt1 = new Thread(new ThreadStart(program.Writer));wt1.Start();
Thread wt2 = new Thread(new ThreadStart(program.Writer));wt2.Start();
//reader threads
Thread rt1 = new Thread(new ThreadStart(program.Read));rt1.Start();
Thread rt2 = new Thread(new ThreadStart(program.Read));rt2.Start();
}
private void Writer()
{
int a = 10;
int b = 11;
Console.WriteLine("*****write*****");
for (int i = 0; i < 5; i++)
{
this.rw.WriteInts(a++,b++);
Thread.Sleep(1000);
}
}
private void Read()
{
int a = 10;
int b = 11;
Console.WriteLine("*****raed*****");
for (int i=0;i<5;i++)
{
this.rw.ReadInts(ref a,ref b);
System.Console.WriteLine("x=" + a + "y=" + b +
"ThreadID=" + Thread.CurrentThread.GetHashCode());
Thread.Sleep(1000);
}
}
}
监控器对于仅计划读取数据而不修改数据的线程来说可能“太安全”了。在这方面,监控器的性能有所下降,而且,对于只读类型的访问,这种性能的降低不是必需的。ReaderWriterLock类允许任意多的线程同时读取数据,提供了一种处理数据读写访问的最佳解决方案。仅当线程更新数据时,它才锁定数据。当且仅当没有写程序线程占用锁时,读程序线程才能获得锁。当且仅当没有读程序或写程序线程占用锁时,写程序线程才能获得锁。因此,ReaderWriterLock 类与重要代码段是一样的。除此之外,该类还支持一个可用于检测死锁的超时值。
手控同步
第三个策略用到了手控同步,而.NET Framework提供了一套经典的技术,允许程序员类似于win32线程API的低级API创建和管理线程。
ManualResetEvent 类
ManualResetEvent对象只能拥有两种状态之一。有信号或者无信号,是个bool值。它继承于WaitHandle 类,其构造函数的参数可确定对象的初始状态。Set()和Reset()方法返回一个bool值,表示是否成功修改。下面列出了相关栗子。
static void Main(string[] args)
{
ManualResetEvent manual;
manual = new ManualResetEvent(false);
Console.WriteLine("稍等会!");
bool b = manual.WaitOne(1000,false);
Console.WriteLine("我靠我来了!"+b);
}
程序块在WaitOne()方法中暂停一秒,然后因为超时而退出,ManualResetEvent 的状态仍然是false,因而返回的值是false,现在我们把它改成true,试试瞧!!!
static void Main(string[] args)
{
ManualResetEvent manual;
manual = new ManualResetEvent(true);
Console.WriteLine("稍等会!");
bool b = manual.WaitOne(1000,true);
Console.WriteLine("我靠我来了!"+b);
}
下面列出Reset()方法的使用,再举个栗子(实在写不动了,累了,明天写...)
好,经过了一晚上的休息,今天早上继续干!。。。
[STAThread]
static void Main(string[] args)
{
ManualResetEvent manualRE;
manualRE = new ManualResetEvent(true);
bool b = manualRE.WaitOne(1000,true);
Console.WriteLine("稍等会"+b);
manualRE.Reset();
b = manualRE.WaitOne(5000,true);
Console.WriteLine("中了不" + b);
}
我们再试一下Set()方法。
[STAThread]
static void Main(string[] args)
{
ManualResetEvent manualRE;
manualRE = new ManualResetEvent(true);
bool b = manualRE.WaitOne(1000,true);
Console.WriteLine("稍等会"+b);
manualRE.Set();
b = manualRE.WaitOne(5000,true);
Console.WriteLine("中了不" + b);
}
WaitOne是等待一个事件有信号,拿WaitAll是等待所有时间对象都有消息才可以的。这里就不再演示了。
AutoResetEvent 类
AutoResetEvent和ManualResetEvent类差不多,它等待时间超时或者事件变成有信号状态,接着将此事件通知给等待线程。说白了这俩的区别就是,咱们这个的WaitOne方法是直接改变状态的了。呵呵。
[STAThread]
static void Main(string[] args)
{
AutoResetEvent aRe;
aRe = new AutoResetEvent(true);
Console.WriteLine("Before First WaitOne");
bool state = aRe.WaitOne(1000,true);
Console.WriteLine("After First WaitOne"+state);
state = aRe.WaitOne(5000,true);
Console.WriteLine("After Second WaitOne" + state);
}
同步和性能
●为安全起见,尽可能实现同步。这将使程序更慢,更糟糕的是,还没有单线程快。。
●为了性能,尽可能减少同步。
尽管线程安全性非常重要,但是如果不使用地使用同步,就可能会引起死锁,如果避免死锁和什么是死锁,还是要谈一谈的。例如程序中的循环就可能会引起死锁。