C#多线程学习笔记(一)线程同步

这是学习C#多线程体系的笔记博客,有条件的同学不妨购买或借阅这两本书(蓝色为主)以供参考。

《C#并行编程高级教程》Gastopn C. Hillar【美】清华大学出版社

《C#多线程编程实战(Multithreading in C#5.0 Cookbook)》【美】Eugene Agafonov,机械工业出版社

此外还需写一句:尽信书不如无书。

一.一些基础的注意点

    1.线程优先级:CPU核心大部分时间在运行高优先级线程,在多核CPU系统中可能体现的不是很明显,但在单核CPU系统中,低优先级的线程可能只获得很少的时间来运行。

    这里我们创建一个循环,循环中使一个long型数值不断自增1,2s后终止循环。同时创建两个优先级不同的线程各自执行这个循环:

接下来分别在多核CPU和单核CPU(模拟)环境中执行这个RunThreads方法:

结果如下:

可以观察到在多核CPU环境下,我们的确在2s左右得到了结果,并且结果反映出高优先级的线程比低优先级的线程进行了更多次的迭代。而在单核CPU环境中,程序计算的时间不仅远不止2s(这期间内电脑的风扇也在呼呼地转......),低优先级的线程更是惨到进行了0次迭代。

2.lock,Moniter和SpinLock

    lock关键字确保当一个线程使用被lock锁定的资源时,其他线程无法使用该资源。锁的使用主要注意点在于尽可能避免死锁问题,通常应该避免锁定外部对象,也应该避免跨成员或类的边界来获得和释放锁。当持有锁的时候,应该避免调用任何可能导致阻塞的代码。

    或许更广为人知的是,lock关键字是Moniter用例的一个语法糖。当我们使用lock关键字“锁定”了一些资源时,这等价于下列的语句:

    因此很多时候更需要直接使用Monitor类,Monitor类含有TryEnter方法,其接受一个超时参数。若我们直到超时时间到也未能获得被lock保护的资源,该方法会返回false。这避免了我们得不到被lock的资源时一直等待下去的烦恼。

    如果持有锁的时间总是非常短,而且锁的粒度很精细(很精细的一个例子是当你需要修改集合中某个元素时,仅锁定需要修改的元素是比锁定整个集合要精细的),那么自旋锁可以获得比其他锁机制更好的性能。关于自旋的意义在SpinWait处有所解释。

    此外,始终使用try/finally代码块来确保捕获锁之后一定会释放锁,这是一个好的习惯。

二.一些线程同步的“产品”

1.线程同步基础

    用于实现线程同步的“产品”很多,但线程同步的构造往往是晦涩而复杂的。在遇到可能需要共享一些对象的问题时,应当遵循下列的思想:

    首先考虑自己的设计,是否可以通过重新设计来规避共享状态(事实上大多数时候我们都能够做到这一点)。应尽量避免在多个线程之间使用共享对象。;

    如果必须使用共享的状态,第二种方式是采用原子操作(注1)。简言之就是当这个操作只占用CPU非常小的时间,一次就可以完成时,我们就不需要花心思去实现“其他线程等待此线程完成”的过程,这就避免了使用锁,也排除了死锁的情况。

    如果上述的方式均不可行,并且程序的逻辑复杂又耗时,那我们不得不使用一些方式来协调线程。其一是将其他等待的线程置于阻塞状态。当线程处于阻塞状态时,会占用尽可能少的CPU时间。但需要注意,线程从正常状态到阻塞状态需要引入至少一次的上下文切换(即操作系统的线程调度器依据线程调度算法,保存等待线程的状态并转入另一个线程,这需要消耗相当多的资源),这种方式称为“内核模式”;另一种方式是不进行切换,仅仅作简单的等待。这种方式称为“用户模式”,其更轻量,速度很快,但长久的单纯等待意味着CPU时间的流逝。

    “内核模式”尽可能规避了CPU时间的浪费,但消耗了上下文切换的时间,适用于需要线程被挂起很久的情况;“用户模式”省下了上下文切换的资源消耗,但等待得越久CPU时间浪费地就越严重,因此适用于线程短暂等待的情况。此外,还有一类“混合模式,即先尝试采用“用户模式”,当等待的时间过长发现“情况不对”了再切换为“内核模式”,籍此节省CPU资源。

    注1:指多线程程序中“最小的且不可并行化的”操作。System.Threading.Interlocked类为多个任务或者线程共享的变量提供了诸如Add,Increment,Decrement等原子操作。

2.原始的同步方式:Mutex互斥量和SemaphoreSlim类

    Mutex是一种原始的同步方式,其一次只对一个线程授予对共享资源的访问。

    这里创建了一个“具名”的Mutex互斥量(具名是指创建时给这个互斥量指定了一个名字,从而这个互斥量成为一个全局的操作系统对象,可以在不同的程序中访问。一个具名的互斥量应当被正确关闭,因此最好是使用using代码块来包裹互斥量对象)。

    我们同时编译好代码,并找到编译好的exe文件。Mutex的initialOwner值我们初始化为false,这意味着第一次运行这个exe程序时,它可以获取到互斥量,WaitOne返回true,执行else代码块中的内容,程序打印出“Running"字样。我们不要按下任何按键,保持这个程序运行着,在5s之内再次运行exe程序,第二个exe程序无法获得到互斥量,什么也不会显示。这时我们切到第一个程序窗口并按下任意键(确保还没有过5s),执行Console.ReadKey()语句及其下面的语句,使得ReleaseMutex方法被执行,释放了互斥量并打印出"mutex realeased"字样。这时还记得第二个程序吗,它一直在等待着这个互斥量,由于5s的超时时间未过,现在它获取了这个互斥量并打印出了“Running”字样。

    如果我们重新来一次,但这一次打开第二个程序之后我们拖一会儿,等待5s的超时时间过去。可以观察到第二个程序的窗口打印出了“Second instance is running”字样,这意味着它放弃了获取互斥量。

    具名的Mutex可以用于在不同的程序中同步线程,可以推广到很多用途。

    如果想要一次对多个线程授予对共享资源的独占访问该怎么办呢?这时可以用到SemaphoreSlim类,使用方法与Mutex类似,可以参考相关手册。需要注意的是SemaphoreSlim类和Semaphore类的区别。Semaphore类是SemaphoreSlim类的老版本,它使用纯粹的内核时间方式。优点是可以通过上述的“具名”方式来达成跨进程同步的场景(内核模式使得内核对象归于系统内核所有,不属于某个用户进程,因此可以实现进程间同步),但它不支持使用取消标记。而SemaphoreSlim虽然不支持windows内核信号量,也不支持进程间同步,但它更轻量。一般不是非常重要的场景不使用Semaphore。

3.AutoResetEvent,ManualResetEvent, ManualResetEventSlim和EventWaitHandle类

    有关Auto和Manual的区别可以先来看EventWaitHandle类,它是前两者的基类,是内核模式因此可以用来做全局信号(注意AutoResetEvent,ManualResetEvent和ManualResetEventSlim不可以这样)。其构造函数的EventResetMode参数的注释信息已经说明了:AutoReset调用Set释放一个之前在等待的线程后就会重置,如果没有其他等待的线程,其会一直阻塞下去。而ManualReset则在调用Set时“广开大门”,一次性释放所有之前在等待的线程,并保持这样直到手动调用Reset方法关闭大门。

    书中给出了一个非常典型的利用AutoResetEvent信号协调两个线程工作的例子。

    Process是工作线程所需要做的事情:

    下面是Main函数中的内容,它指明了主线程所要做的事情:

来看一下运行时会发生什么吧:

1)MainThread和WorkThread差不多是同时开始工作,MainThread更早一点,因此先打印出了"Main Thread: Waiting for another thread to complete work...";

2)这时MainThread遇到了_workerEvent.WaitOne()语句,不要忘了初始化_workerEvent和_mainEvent信号时我们都是把它们的状态设置为false的,因此这里MainThread傻眼了,阻塞住了;

3)我们把MainThread放到一边,再来看WorkThread也就是Process方法里面的内容,它首先打印出“Starting a long running work"字样,并暂停了一会儿,随后打印出“work is done"表示工作做完了。自己知道做完了当然不够,还要告诉主线程啊,于是它调用Set方法将_workerEvent信号的状态设置为true。(注意这之后_workerEvent的状态会很快自动变回false,不然怎么叫AutoReset呢);

4)WorkThread继续往下执行,执行到_mainEvent.WaitOne()语句时轮到它傻眼了,没有主线程的命令它不敢轻举妄动,于是工作线程被阻塞住。我们回过头来看主线程,它收到了之前工作线程Set所传来的信号,可以活动了。一直执行到_mainEvent.Set()语句,主线程发出信号通知工作线程可以进行下一步的工作。(不要忘记马上_mainEvent的状态又被"auto"置回false了)。

5)主线程继续执行,遇到_workerEvent.WaitOne()语句,阻塞。又回过头来看工作线程,它之前收到主线程Set方法传来的信号,接着工作,一直执行到最后的_workerEvent.Set()语句,通知主线程工作完毕。主线程收到此信号也不再被_workerEvent.WaitOne()语句阻塞,打印出“Second operation is completed!"。至此协调工作完毕。

注意:称状态为false和true并不严谨,书中给出的说法是signaled和unsignaled。此外虽然代码看起来是顺序的,上述的步骤看起来也是顺序的,但一定不要忘了是两个线程,在脑海里有个“同步”的时间观点。

最后核对一下程序的运行结果:

    ManualResetEventSlim是ManualResetEvent的轻量级版本,其主要差别在于实现的机制不同。关于ManualResetEventSlim,书中同样给出了一个有趣的例子:

    设想一些线程先休眠数秒,醒来时尝试通过“大门”的情形。

    

    在Main函数中,我们创建三个这样的线程,并规定大门的行为是:等待若干秒(Thread.Sleep),开门(Set),关门(Reset),等待若干秒,开门,关门。

    留意一下这里所设置的各个秒数。第一个4s之后,大门开启,这时t1早早地(在2s时)就醒过来等待开门了,因此t1被放进去。不要忘了ManualReset的特点是门一开(Set),直到手动Reset之前,门一直都是开着的。因此6s时t2醒过来,这时候门还是开着的,t2也被放进去。8s时,我们调用了_mainEvent.Reset()关闭大门,9s时t3醒过来了,便一直等待,直到12s我们再次打开大门。

    不妨修改例子中的时间点来进一步观察ManualReset的用法。

4.CountdownEvent类

    顾名思义,CountdownEvent类提供了一个可以等待直到一定数量的操作完成的信号量。它的开销比起后面我们会学到的Task.WaitAll和TaskFactory.ContinueWhenAll要小得多。

    每当一个任务调用CountdownEvent的Signal方法,都会使信号量的“计数”增1,当计数达到所设定的数值时会发出信号。如果这时候有其他线程处于等待CountdownEvent的状态,其会返回并继续执行。

    CountdownEvent类适用于需要等待多个异步操作完成的情况。需要注意的是,确保CountdownEvent.Signal()能够达到指定的计数,否则那个使用CountdownEvent.Wait()的线程将一直处于等待状态。

5.Barrier屏障同步类

    Barrier适合于分多个阶段的同步计划。

    在使用CountdownEvent的过程中,A线程调用CountdownEvent.Signal()通知某个此前调用了CountdownEvent.Wait()的B线程之后,A线程并不会阻塞。而Barrier信号提供的是SignalAndWait()方法,这意味着A调用之后自身也会阻塞(可以理解为A等待他人一起完成此阶段)。同时,Barrier在构造方法中可以传入一个Action<Barrier>委托方法作为回调函数,每当计数达到时(阶段完成时)Barrier将执行回调函数。

    书中给出了一个歌手与吉他手协调演奏的例子:

    

在Main函数中,我们创建了两个线程分别代表singer和guitarist。两人都演奏两轮:


在Barrier的协调下,两者很好的完成了音乐,同时每轮结束时正确的调用了回调函数打印出"Phase End"字样:


可以将barrier.SignalAndWait()注释掉再来运行看看,或许会是“呕哑嘲哳难为听”的......

    如果在局部代码块中使用了Barrier,最后应当总是记得将Barrier处理(Dispose)掉(上面的ManualResetEventSlim等也是这样!!!)。

    此外,与CountdownEvent类似,当很多任务在等待其他参与者发出到达屏障的信号时,如果有一个或多个参与者不发出信号,那么其他的任务就会一直等待下去。在这种时候,设置一个超时来防止任务或线程的无限制阻塞来说是非常重要的。好在Barrier.SignalAndWait()提供了这样一个重载,其接受一个毫秒值来作为等待的时间,它的bool返回值则表示所有参与者是否都在指定时间内到达了屏障。

6.ReadWriteLock和ReadWriteLockSlim类

    ReadWriteLockSlim是ReadWriteLock的轻量级版本。它们可以提供多线程同时读取和独占式写入的资源访问锁。其策略是:读锁允许多线程访问,而写锁直到释放前都会阻塞所有其他读线程。换言之,一旦得到写锁,会阻止读者读取数据,这可能会浪费读者大量的时间等待。因此ReadWriteLockSlim提供了一个EnterUpgradeableReadLock(),使得我们在进行写操作前可以把一些准备工作置于读锁中,当真的需要写时再使用EnterWriteLock()来执行一次快速的写入。

    ReadWriteLock同样提供了上述功能,不过其获得锁和释放锁的方法是Acquire和Release而非Enter和Exit,可以通过UpgradeToWriteLock()来将一个读锁升级为写锁。

7.SpinWait自旋类

    自旋:在循环中等待的过程称为自旋。对于SpinLock,当一个任务在自旋等待获得一个互斥锁的时候,意味着这个任务是在循环中等待,并不断地检测直到锁可用;对于SpinWait,当一个任务执行基于自旋的等待的时候,意味着这个任务是在循环中等待并不断地检测直到条件满足。这是一种“忙等待”。

    还记得之前提到过的“混合模式吗”?SpinWait类提供了一个混合同步构造,它被设计为先使用用户模式等待一段时间,然后切换到内核模式以节省CPU时间。换言之,长时间的自旋会使得SpinWait让出底层线程的时间片,并触发上下文切换。

    我们分别采用两种方式来执行一个无止境的循环。并通过任务管理器或VS的性能探查器来观察它们的CPU负载。


Main函数:


通过SpinWait方式占用的CPU负载非常小。通过打印出SpinWait.NextSpinWillYield属性可以发现,在9个迭代后CPu就将线程切换为了阻塞状态。(NextSpinWillYield就是用来指示下一次自旋是否会让出底层线程时间片并触发上下文切片的)


    SpinWait更重要的是其SpinUntil方法,该方法传入一个Func<bool>委托并反复执行,直到该委托方法返回true。(如果参数中有超时时间,则反复执行直到满足条件或超时时间到。


猜你喜欢

转载自blog.csdn.net/saasanken/article/details/79427361