C#深入学习 ----多线程学习(一)第一天学习

时间:2021-12-03 04:35:17

学习最好的方法就是总结,并写下来,能让别人看懂,自己肯定是掌握了的。

针对软件开发,一直停留在能做的层次,今天得到大牛指点,觉得有必要对这门技术深入学习。

翻阅园内各大神的文章,收益匪浅,在这里做下总结。(以下大多数内容摘转自http://blog.csdn.net/xwdpepsi/article/details/6327210)

学习总结:多线程基本概念,临界区概念,锁的概念,监视器概念,以及基本操作,常见的坑等

习得一句话:为了保证程序的健壮性,所有if最好都加else处理,所有try最后都加finally处理。

产生死锁的四个必要条件:

i、互斥条件;

ii、不可剥夺条件(不可抢占);

iii、部分分配;

iv、循环等待。 

只要使其中之一不能成立,死锁就不会出现。为此,可以采取下列三种预防措施: 
i、采用资源静态分配策略,破坏"部分分配"条件; 
ii、允许进程剥夺使用其他进程占有的资源,从而破坏"不可剥夺"条件; 
iii、采用资源有序分配法,破坏"环路"条件。

解除死锁常常采用下面两种方法:  
i、资源剥夺;

ii、撤消进程。

1、基本概念

首先要明白进程与线程的概念和二者之间的关系

C#深入学习 ----多线程学习(一)第一天学习

打开任务管理器,就可以看到当前系统正在运行的进程清单,针对每个应用程序,系统都会分配给一定的资源,也就是一个进程。

每个进程是由私有的虚拟地址空间、代码(操作)、数据和其它相关的系统资源组成,进程在运行过程中创建的资源随着进程的终止而被销毁,所使用的系统资源在进程终止时被释放或关闭。

线程是进程中的一个执行单元,线程不占用资源,能够访问或操作进程中的公共资源

假设要一个施工队要修一面墙,这个项目就是一个进程

项目开始运作,系统要给这个项目分配人员,物资,地盘、设备等等必要的资源。

那么什么是线程呢?我们需要有人把砖块运到合适的位置,然后有人把砖砌成墙,然后再粉刷。每个人负责的工作就是一个线程。

按照单线程的工作方法,如下图所示:

C#深入学习 ----多线程学习(一)第一天学习

原料运到工地后,砌墙的工人开始砌墙,墙砌好了,粉刷匠开始粉刷。

在资源极有限的情况下,这种方法无可非议,但是,目前计算机硬件的发展不可描述。我们及其丰富的资源(人,针对计算机就是CPU时间片)来完成砌墙的工作,而不在乎需要多少人(当然,实际设计也要考虑实际计算机的资源开销成本问题),那么我们就会想办法加快进度,这就出现了多线程的概念。

C#深入学习 ----多线程学习(一)第一天学习

多找几个人去搬砖,多找几个人去砌墙,多找几个人去粉刷,这样就很大程度上加快了进度。

OK,现在项目进度在理论上是能够加快了,但同时呢,管理的难度也随之增加。

凡事有利就有弊,多线程必然增加开发的复杂度。设想一下,如果有1000块砖,找1000个人去搬砖,1000个人砌墙,1000个人粉刷,每个搬砖的人都会去抢砖,砌墙的人和粉刷的人也会乱七八糟,项目根本无法完成,这就是多线程滥用的后果。

以上的例子介绍了什么是进程,什么是线程,下面主要介绍一下C#中线程的几个基本概念:

在.net Framework Class Library中,所有与多线程机制应用相关的类都是放在System.Threading命名空间中的。其中提供Thread类用于创建线程,ThreadPool类用于管理线程池等等,此外还提供解决了线程执行安排,死锁,线程间通讯等实际问题的机制。

1)Thread类,首先通过new 创建一个线程:

  Thread myThread = new Thread(threadMethod);

a、Start方法:

myThread.Start();

这一操作导致系统把myThread的状态更改为ThreadState.Running。

b、Abort方法 ResetAbort方法(静态方法):

myThread.Abort();

这一操作引发myThread的ThreadAbortException,以开始终止myThread线程的过程。调用此方法一般会终止myThread线程。

c、Suspend、Resume方法,(已经被弃用,被Monitor、Mutex、Event、Semaphore等取代):

   myThread.Suspend();
myThread.Resume();

该方法并不终止未完成的线程,它仅仅挂起线程,以后还可恢复。

弃用的原因:停止(挂起)一个线程会导致其解锁其上被锁定的所有监视器(监视器以在栈顶产生ThreadDeath异常的方式被解锁)。如果之前被这些监视器保护的任何对象处于不一致状态,其它线程看到的这些对象就会处于不一致状态。这种对象被称为受损的 (damaged)。当线程在受损的对象上进行操作时,会导致任意行为。这种行为可很微妙且难以检测,也可能会比较明显。不像其他未受检的(unchecked)异常, ThreadDeath 可能悄无声息地杀死其他线程。因此,用户得不到程序可能会崩溃的警告,程序会在真正破坏发生后的任意时刻呈现。

2、Monitor类(监视器):

监视器防止多个线程同时执行代码块。Enter方法允许一个且仅一个线程继续执行后面的语句;其他所有线程都将被阻止,直到执行语句的线程调用Exit。这与使用lock关键字一样。事实上,lock 关键字就是用Monitor 类来实现的。在上面的例子中,Monitor就像是一个监工,指挥工人工作的顺序。

System.Object obj = (System.Object)x;
System.Threading.Monitor.Enter(obj);
try
{
  DoSomething();
}
finally
{
  System.Threading.Monitor.Exit(obj);
}

上面这段代码等效于下面的代码

System.Object obj = (System.Object)x;
lock(x)
{
  DoSomething();
}

Monitor是一个静态类,因此不能被实例化,只能直接调用Monitor上的各种方法来完成与lock相同的功能:

Enter(object)/TryEnter(object)/TryEnter(object, int32)/TryEnter(object, timespan):用来获取对象锁(是对象类型而不能是值类型),标记临界区的开始。与Enter不同,TryEnter永远不会阻塞代码,当无法获取对象锁时它会返回False,并且调用者不进入临界区。TryEnter还有两种重载,可以定义一个时间段,在该时间段内一直尝试获得对象锁,超时则返回False。

Exit(object):没啥好说的,释放对象锁、退出临界区。只是一定记得在try的finally块里调用,否则一但由于异常造成Exit无法执行,对象锁得不到释放,就会造成死锁。此外,调用Exit的线程必须拥有 object 参数上的锁,否则会引发SynchronizationLockException异常。在调用线程获取指定对象上的锁后,可以重复对该对象进行了相同次数的 Exit 和 Enter 调用;如果调用 Exit 与调用 Enter 的次数不匹配,那么该锁不会被正确释放。

Wait(Object)/Wait(Object, Int32)/Wait(Object, TimeSpan)/Wait(Object, Int32, Boolean)/Wait(Object, TimeSpan, Boolean):  释放对象上的锁并阻塞当前线程,直到它重新获取该锁

这里的阻塞是指当前线程进入“WaitSleepJoin”状态,此时CPU不再会分配给这种状态的线程CPU时间片,这其实跟在线程上调用 Sleep()时的状态一样。这时,线程不会参与对该锁的分配争夺。

要打破这种状态,需要其它拥有该对象锁的线程,调用下面要讲到的Pulse()来唤醒。不过这与,Sleep()不同,只有那些因为该对象锁阻塞的线程才会被唤醒。此时,线程重新进入“Running”状态,参与对对象锁的争夺。

强调一下,Wait()其实起到了Exit()的作用,也就是释放当前所获得的对象锁。只不过Wait()同时又阻塞了自己。

Pulse(object):向阻塞线程队列(由于该object而转入WaitSleepJoin状态的所有线程,也就是那些执行了Wait(object)的线程,存放的队列)中第一个线程发信号,该信号通知锁定对象的状态已更改,并且锁的所有者准备释放该锁。收到信号的阻塞线程进入就绪队列中(那些处于Running状态的线程,可以被CPU调用运行的线程在这个队列里),以便它有机会接收对象锁。注意,接受到信号的线程只会从阻塞中被唤醒,并不一定会获得对象锁。

PulseAll(object):与Pulse()不同,阻塞队列中的所有线程都会收到信号,并被唤醒转入 Running状态,即进入就绪队列中。至于它们谁会幸运的获得对象锁,那就要看CPU了。

注意:以上所有方法都只能在临界区内被调用,换句话说,只有对象锁的获得者能够正确调用它们,否则会引发 SynchronizationLockException异常。 

看下面这个例子:

class MonitorSample
{
//容器,一个只能容纳一块糖的糖盒子。PS:现在MS已经不推荐使用ArrayList,
//支持泛型的List才是应该在程序中使用的,我这里偷懒,不想再去写一个Candy类了。
private ArrayList _candyBox = new ArrayList();
private volatile bool _shouldStop = false; //用于控制线程正常结束的标志
/// <summary>
/// 用于结束Produce()和Consume()在辅助线程中的执行
/// </summary>
public void StopThread()
{
_shouldStop = true;
//这时候生产者/消费者之一可能因为在阻塞中而没有机会看到结束标志,
//而另一个线程顺利结束,所以剩下的那个一定长眠不醒,需要我们在这里尝试叫醒它们。
//不过这并不能确保线程能顺利结束,因为可能我们刚刚发送信号以后,线程才阻塞自己。
Monitor.Enter(_candyBox);
try
{
Monitor.PulseAll(_candyBox);
}
finally
{
Monitor.Exit(_candyBox);
}
}
/// <summary>
    /// 生产者的方法
    /// </summary>
public void Produce()
{
while (!_shouldStop)
{
Monitor.Enter(_candyBox);
try
{
if (_candyBox.Count == )
{
_candyBox.Add("A candy");
Console.WriteLine("生产者:有糖吃啦!");
//唤醒可能现在正在阻塞中的消费者
Monitor.Pulse(_candyBox);
Console.WriteLine("生产者:赶快来吃!!");
//调用Wait方法释放对象上的锁,并使生产者线程状态转为WaitSleepJoin,阻止该线程被CPU调用(跟Sleep一样)
//直到消费者线程调用Pulse(_candyBox)使该线程进入到Running状态
Monitor.Wait(_candyBox);
}
else //容器是满的
{
Console.WriteLine("生产者:糖罐是满的!");
//唤醒可能现在正在阻塞中的消费者
Monitor.Pulse(_candyBox);
//调用Wait方法释放对象上的锁,并使生产者线程状态转为WaitSleepJoin,阻止该线程被CPU调用(跟Sleep一样)
//直到消费者线程调用Pulse(_candyBox)使生产者线程重新进入到Running状态,此才语句返回
Monitor.Wait(_candyBox);
}
}
finally
{
Monitor.Exit(_candyBox);
}
Thread.Sleep();
}
Console.WriteLine("生产者:下班啦!");
}
/// <summary>
    /// 消费者的方法
    /// </summary>
public void Consume()
{
//即便看到结束标致也应该把容器中的所有资源处理完毕再退出,否则容器中的资源可能就此丢失
//不过这里_candyBox.Count是有可能读到脏数据的,好在我们这个例子中只有两个线程所以问题并不突出
//正式环境中,应该用更好的办法解决这个问题。
while (!_shouldStop || _candyBox.Count > )
{
Monitor.Enter(_candyBox);
try
{
if (_candyBox.Count == )
{
_candyBox.RemoveAt();
if (!_shouldStop)
{
Console.WriteLine("消费者:糖已吃完!");
}
else
{
Console.WriteLine("消费者:还有糖没吃,马上就完!");
}
//唤醒可能现在正在阻塞中的生产者
Monitor.Pulse(_candyBox);
Console.WriteLine("消费者:赶快生产!!");
Monitor.Wait(_candyBox);
}
else
{
Console.WriteLine("消费者:糖罐是空的!");
//唤醒可能现在正在阻塞中的生产者
Monitor.Pulse(_candyBox);
Monitor.Wait(_candyBox);
}
}
finally
{
Monitor.Exit(_candyBox);
}
Thread.Sleep();
}
Console.WriteLine("消费者:都吃光啦,下次再吃!");
}
static void Main(string[] args)
{
MonitorSample ss = new MonitorSample();
Thread thdProduce = new Thread(new ThreadStart(ss.Produce));
Thread thdConsume = new Thread(new ThreadStart(ss.Consume));
//Start threads.
Console.WriteLine("开始启动线程,输入回车终止生产者和消费者的工作……/r /n******************************************");
thdProduce.Start();
Thread.Sleep(); //尽量确保生产者先执行
thdConsume.Start();
Console.ReadLine(); //通过IO阻塞主线程,等待辅助线程演示直到收到一个回车
ss.StopThread(); //正常且优雅的结束生产者和消费者线程
Thread.Sleep(); //等待线程结束
while (thdProduce.ThreadState != ThreadState.Stopped)
{
ss.StopThread(); //线程还没有结束有可能是因为它本身是阻塞的,尝试使用StopThread()方法中的PulseAll()唤醒它,让他看到结束标志
thdProduce.Join(); //等待生产这线程结束
}
while (thdConsume.ThreadState != ThreadState.Stopped)
{
ss.StopThread();
thdConsume.Join(); //等待消费者线程结束
}
Console.WriteLine("******************************************/r/n输入回车结束!");
Console.ReadLine();
}
}

输出的结果如下:

C#深入学习 ----多线程学习(一)第一天学习

在实际应用中,生产者往往代表负责从某处简单接收资源的线程,比如来自网络的指令、从服务器返回的查询等等;而消费者线程需要负责解析指令、解析返回的查询结果,然后存储到本地数据库、文件或者呈现给用户等等。消费者线程的任务往往更复杂,执行时间更长,为了提高程序的整体执行效率,消费者线程往往会多于生产者线程。

CPU的随机调度,可能会造成各种各样的情况。你基本上是无法预测一段代码在被调用时,与之相关的外部环境是怎样的,所以完备的处理每一个分支是必要的。

另一方面,即便一个分支的情况不是我们设计中期望发生的,但是由于某种现在无法预见的错误,造成本“不可能”、“不应该”出现的分支得以执行,那么在这个分支的代码可以保障你的业务逻辑可以在错误的异常情况下得以修正,至少你也可以报警避免更大的错误。所以总是建议给每个if都写上else分支,这除了让你的代码显得更加仅仅有条、逻辑清晰外,还可能给你带来额外的扩展性和健壮性。

使用 lock 关键字通常比直接使用 Monitor 类更可取,一方面是因为 lock 更简洁,另一方面是因为 lock 确保了即使受保护的代码引发异常,也可以释放基础监视器。这是通过 finally 关键字来实现的,无论是否引发异常它都执行关联的代码块。这里微软已经说得很清楚了,Lock就是用Monitor实现的,两者都是C#中对临界区功能的实现。

 3、这里引入一个【临界区】的概念:

临界区(Critical Section)是一段在同一时候只被一个线程进入/执行的代码。那么临界区有什么意义呢?

------>>>>是因为这段代码访问了“临界资源”,所谓的【临界资源】是指只能同时被互斥地访问的系统资源。

举个例子来说,你的银行账户就是必须是一个互斥资源,一个银行系统里面改变余额(存取)的操作代码就必须用在临界区内。否则的话,咱们设想一下,如果你的账户余额是$100,000,假设有两个人同时给你汇款$50,000。有两个线程分别执行这两笔汇款业务,线程A在获取了你的账户余额后,增加汇款金额$50,000,但是在它把新余额($150000)储存回数据库以前,操作系统把这个线程暂停转而把CPU的时间片分给另一个线程(是的,这太巧了,这就是不可预知的坑);那么线程B此时取出的账户余额仍然是$10000,随后线程B幸运的得到的CPU时间把$50000存入你的账户,那么余额变成$150000。而此后某个时候,线程A再次得以执行,它也把“新”余额$150000更新到系统……于是你的$50000就这么凭空消失了。

------>>>>是因为OS的多任务调度,其实在上一原因里面已经提到。如果OS不支持多任务调度,那么线程A/线程B执行更新余额的操作总是一个接一个进行,那么完全不会有上面的问题了。在多线程的世界里,你必须随时做好你的代码执行过程随时失去控制的准备;你需要好好考虑当代码重新执行的时候,是否可以继续正确的执行。一句话,你的程序段在多线程的世界里,你所写的方法并不是“原子性”的操作(注:【原子性操作】是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch)

C#提供lock关键字实现临界区,用法上面已经给出。lock实现临界区是通过“对象锁”的方式,注意是“对象”,所以你只能锁定一个引用类型而不能锁定一个值类型。第一个执行该代码的线程,成功获取对这个对象的锁定,进而进入临界区执行代码。而其它线程在进入临界区前也会请求该锁,如果此时第一个线程没有退出临界区,对该对象的锁定并没有解除,那么当前线程会被阻塞,等待对象被释放。

Object thisLock = new Object();
lock (thisLock)
{
   // Critical code section
}

  既然如此,在使用lock时,要注意不同线程是否使用同一个“锁”作为lock的对象。现在回头来看上面的这段代码似乎很容易让人误解,容易让人联想到这段代码是在某个方法中存在,以为thisLock是一个局部变量,而局部变量的生命周期是在这个方法内部,所以当不同线程调用这个方法的时候,他们分别请求了不同的局部变量作为锁,那么他们都可以分别进入临界区执行代码。

微软给出的例子:

class Account
{
    private Object thisLock = new Object();
    int balance;
    Random r = new Random();
    public Account(int initial)
    {
        balance = initial;
    }
    int Withdraw(int amount)
    {
        // This condition will never be true unless the lock statement
        // is commented out:
        if (balance < 0)
        {
            throw new Exception("Negative Balance");
        }
        // Comment out the next line to see the effect of leaving out
        // the lock keyword:
        lock (thisLock)
        {     
            if (balance >= amount)
            {
                Console.WriteLine("Balance before Withdrawal :  " + balance);
                Console.WriteLine("Amount to Withdraw        : -" + amount);
                balance = balance - amount;
                Console.WriteLine("Balance after Withdrawal  :  " + balance);
                return amount;
            }
            else
            {
                return 0; // transaction rejected
            }
        }            
    }
    public void DoTransactions()
    {
        for (int i = 0; i < 50; i++)
        {
            Withdraw(r.Next(1, 100));
        }
    }
}
class Test
{
    static void Main()
    {
        Thread[] threads = new Thread[10];
        Account acc = new Account(1000);
        for (int i = 0; i < 10; i++)
        {
            Thread t = new Thread(new ThreadStart(acc.DoTransactions));
            threads[i] = t;
        }
        for (int i = 0; i < 10; i++)
        {
            threads[i].Start();
        }
    }
}

运行结果

C#深入学习 ----多线程学习(一)第一天学习

这个例子中,Account对象只有一个,所以临界区所请求的“锁”是唯一的,因此用类的 private 成员变量是可以实现互斥意图的,其实用lock(this)也是可以的,也即请求这个Account实例本身作为锁。但是如果在类的实例并不唯一或者类中的几个方法之间都必须要互斥,那么就要小心。

必须牢记一点,所有因为同一互斥资源而需要互斥的操作,必须请求“同一把锁”才有效。

假设这个Account类并不只有一个Withdraw方法修改balance,而是用Withdraw()来特定执行取款操作,另外有一个 Deposit()方法专门执行存款操作。很显然这两个方法必须是互斥执行的,所以这两个方法中所用到的锁也必须一致,此时就不能一个用thisLock,而另一个重新用一个private Object thisLock1 = new Object()。再进一步,在这个操作场景下各个互斥区存在的目的是因为有“Balance”这个互斥资源,所以,所有Balance都应该是互斥的。

今天先到这儿,周末继续深入!