一、多线程术语

1. CPU(中央处理器)或者核心/内核

CPU是实际执行程序的硬件单元。每台机器至少一个CPU,也有不少多CPU的机器。许多现代CPU都支持同时多线程(Intel称为超线程),使一个CPU能表现为多个“虚拟”CPU,就是常见的多核CPU。

2. 进程(process)

进程是某个程序当前正在执行的实例(比如我们运行word,excel,记事本或者我们的gshis客房管理系统,操作系统都会首先分配一些内存给到这个进程,然后加载运行程序的代码到这些内存中,然后调度代码到cpu上进行运行)。
操作系统的一项基本功能就是管理进程。每个进程都包含一个或多个线程。每个进程至少拥有一个线程,称为主线程或者UI线程,是进程刚启动时操作系统自动创建的,默认所有代码都运行在这个线程里面(除非明确使用多线程编程技术或类似技术告诉操作系统,创建一个新线程)。
.net程序中可用System.Diagnostics命名空间的Process类的实例来访问进程。

3. 线程(thread)

线程是操作系统调度的最小执行单元,所有的代码都是在线程上进行执行的,可以理解线程就是线程上要执行的所有代码的控制点。
在语句和表达式的级别上,编程本质上就是在描述控制流(flow of control)。每个线程上都有一个“控制点”。可想象程序启动后,主线程的控制点像“光标”(cursor)一样进入Main方法,并随着各种条件、循环、方法等的执行在程序中移动。

3.1 单线程

单线程程序的进程仅包含一个线程,就是操作系统创建进程时自动创建的主线程或者UI线程。

3.2 多线程

多线程程序的进程则包含两个或更多线程。
在多线程程序中运行具有正确的行为,就说代码是线程安全的。
代码的线程处理模型是指代码向调用者提出的一系列要求,只有满足这些要求才能保障线程安全。例如,许多类的线程处理模型都是“静态方法可从任意线程调用,但实例方法只能从分配实例的那个线程调用”,更新界面,只有在主线程上才能进行更新。
System.Threading命名空间包含用于处理线程(具体就是System.Threading.Thread类)的API。

3.3 任务

任务是可能出现高延迟的工作单元,作用是产生结果值或者希望的副作用。
任务和线程的区别是:任务代表需要执行的一件工作,而线程代表做这件工作的工作者。
任务的意义在于其运行结果,由Task类的实例表示。生成给定类型的值的任务用Task类表示,后者从非泛型Task类型派生。它们都在System.Threading.Tasks命名空间中。

3.4 线程池

线程池是多个线程的集合,通过一定逻辑决定如何为线程分配工作。有任务要执行,它分配池中的一个工作者线程执行任务,并在任务结束后解除分配。
在windows上,每分配一个线程,就需要大约1M的额外内存来记录和跟进线程信息,频繁的创建和销毁线程对cpu和内存都是一种浪费,所以通过线程池来减少创建和销毁工作。

二、为什么需要多线程?

1.实现多任务

大家在使用电脑的时候,都知道可以同时打开多个程序,比如打开微信,qq,打开网页查看debug系统,打开visual studio编写代码等,这是由操作系统通过进程间调度来实现的多任务。
但同一个程序内部,也是可以通过多线程来实现类似的多任务,比如一个音乐播放工具,可以在后台播放你选择好的音乐文件,同时允许你在前端重新选择其他播放列表,还可以在前端显示当前正在播放的音乐的歌词等。这些也是通过多线程技术解决的。

2.解决延迟

我想大家可能遇到过“程序未响应”或者界面上的按钮等点了没反应,鼠标指针一直显示正在运行状态的情况。比如我们使用记事本打开一个很大的文件的时候,在文件没有完全打开前,只能等着,什么也做不了。即使发现是选择错了文件,想取消,也取消不了,只能强制结束进程。
为什么会出现上面描述的现象呢?
就是由于默认情况下,像记事本等程序都是单线程的,当执行打开一个很大的文件的时候,这个线程在等待从磁盘上读取文件到内存,被阻塞了,不能执行其他代码,只有等到文件完全读取完成后才能继续执行后面的代码或响应,所以这种现象有时也称为界面冻结。
那如何解决这个问题呢?
就是使用多线程,在另外一个单独的线程里面来读取文件,而UI主线程继续响应,就不会出现这个情况,等另外一个线程读取完成后,把内存中的内容显示出来即可。在等待过程中,用户也可以取消打开操作,此时只要通知另外一个线程不再读取文件,并且释放已经加载的内存即可。

三、多线程使用疑问

1.是不是线程越多越好?

答案是显面易见的,不是。
相信大家很清楚,电脑同时打开的程序越多,每个程序使用起来就会越慢。
这是由于每增加一个进程,操作系统为了管理进程,就必须要分配一些内存来记录进程信息,当打开的进程越来越多,记录这些信息占用的内存也越多,能够给进程使用的内存就越少,必然会导致程序变慢。线程也是一样的。
前面提到过,所有的代码都必须由CPU来执行,但电脑中的CPU始终是有限的,远远少于进程数或线程数,为缓解粥(CPU核心)少僧(线程)多的矛盾,操作系统通过称为时间分片(time slicing)的机制来模拟多个线程并发运行。操作系统以极快的速度从一个线程切换到另一个,给人留下所有线程都在同时执行的错觉。处理器执行一个线程的时间周期周期称为时间片(time slice)或量子(quantum)。在某个核心上更改执行线程的行动称为上下文切换(contextswitch)。
但上下文切换有代价;必须将CPU当前的内部状态保存到内存,还必须加载与新线程关联的状态。类似地,如线程A正在用一些内存做大量工作,线程B正在用另一些内存做大量工作,在两者之间进行上下文切换,可能造成从线程A加载到缓存的全部数据被来自线程B的数据替换(或相反)。
如线程太多,切换开销就会开始显著影响性能。添加更多线程会进一步降低性能,直到最后处理器的大量时间被花在从一个线程切换到另一个线程上,而不是主要花在线程的执行上。
所以线程数应该控制在一定范围内,以便可以充分利用cpu资源,同时又不会浪费太多cpu资源。

2.是不是多线程一定能使代码运行更快?

如果要运行的代码是cpu密集型的,则多线程不会使代码运行更快。即使忽略上下文切换的开销,时间分片本身对性能也有巨大影响。
例如,假定有两个处理器受限的高延迟任务,分别计算10亿个数的平均值。假定处理器每秒能执行10亿次运算。如两个任务分别和一个线程关联,且两个线程分别有自己的核心,那么显然能在1秒钟之内获得两个结果。但是,如一个处理器由两个线程共享,时间分片将在一个线程上执行几十万次操作,再切换到另一个线程,再切换回来,如此反复。每个任务都要消耗总共1秒钟的处理器时间,所以两个结果都要在2秒钟之后才能获得,造成平均完成时间是2秒。(同样地,这里忽略了上下文切换的开销)。如分配两个任务都由一个线程执行,而且严格按前后顺序执行,则第一个任务的结果在1秒后获得,第二个在第2秒后获得,造成平均完成时间是1.5秒。(一个任务要么1秒完成,要么2秒完成,所以平均1.5秒完成。)
如果要运行的代码是IO密集型的,则CPU在等待期间会执行其他线程的工作,会使代码运行更快。
执行I/O受限操作的线程会被操作系统忽略,直到I/O子系统返回结果。所以,从I/O受限线程切换到处理器受限线程能提高处理器利用率,防止处理器在等待I/O操作完成期间闲置。

四、常见的线程处理问题

1.大多数操作不是原子的

原子操作要么尚未开始,要么已经完成。从外部看,其状态永远不会是“进行中”。例如以下代码:

  1. if(bankAccounts.Checking.Balance >= 1000.00m)
  2. {
  3. bankAccounts.Checking.Balance -= 1000.00m;
  4. bankAccounts.Savings.Balance += 1000.00m;
  5. }

上述代码检查银行账户余额,条件符合就从中取钱,向另一个账户存钱。这个操作必须是原子性的。换言之,为了使代码能正确执行,永远不能发生操作只是部分完成的情况。
例如,假定两个线程同时运行,可能两个都验证账户有足够的余额,所以两个都执行转账,而剩余的资金其实只够进行一次转账。事实上,局面会变得更糟。在上述代码中,没有任何一个操作是原子性的。就连复合加/减(或读/写)decimal类型的属性在C#中都不属于原子操作。因此,它们在多线程的情况下全都属于“部分完成”——只是部分递增或递减。因为部分完成的非原子操作而造成不一致状态,这是竞态条件的一种特例。

2.竞态条件所造成的不确定性

如前所述,一般通过时间分片来模拟并发性。在缺少线程同步构造的情况下,操作系统会在它认为合适的任何时间在任何两个线程之间切换上下文。结果是当两个线程访问同一个对象时,无法预测哪个线程“竞争胜出”并抢先运行。
例如,假定有两个线程运行上述代码段,可能一个胜出并一路运行到尾,第二个线程甚至还没有开始。也可能在第一个执行完余额检查后立即发生上下文切换,第二个胜出,一路运行到尾。对于包含竞态条件的代码,其行为取决于上下文切换时机。这造成了程序执行的不确定性。一个线程中的指令相对于另一个线程中的指令,两者的执行顺序是未知的。最糟的情况是包含竞态条件的代码99.9%的时间都具有正确行为。1000次只有那么一次,另一个线程在竞争中胜出。正是这种不确定性使多线程编程显得很难。
由于竞态条件难以重现,所以为保证多线程代码的品质,主要依赖于长期压力测试、专业的代码分析工具以及专家对代码进行的大量分析和检查。
此外,比这些更重要的是“越简单越好”原则。为追求极致性能,本来一个锁就能搞定的事情,有的开发人员会诉诸于像互锁(Interlocked类)和易变(Volatile类)这样的更低级的基元构造。这会使局面复杂化,代码更容易出错。在好的多线程编程中,“越简单越好”或许才是最重要的原则。

3.内存模型的复杂性

竞态条件(两个控制点以无法预测且不一致的速度“竞争”代码的执行)本来就很糟了,但还有更糟的。假定两个线程在两个不同的进程中运行,但都要访问同一个对象中的字段。现代处理器不会在每次要用一个变量时都去访问主内存。相反,是在处理器的“高速缓存”(cache)中生成本地拷贝。该缓存定时与主内存同步。这意味着在两个不同的处理器上,两个线程以为自己在读写同一个位置,实际看到的可能不是对方对那个位置的实时更新,获得的结果可能不一致。简单地说,这里是因为处理器同步缓存的时机而产生了竞态条件。

4.锁定造成死锁

显然,肯定有什么机制能将非原子操作转变成原子操作,要求操作系统对线程进行调度以防止竞态条件,并确保处理器的高速缓存在必要时同步。C#程序解决所有这些问题的主要机制是lock语句。它允许开发者将一部分代码设为“关键”(critical)代码,一次只能有一个线程执行它。如多个线程试图进入关键区域,操作系统只允许一个,其他将被挂起。操作系统还确保在遇到锁的时候处理器高速缓存正确同步。但锁自身也有问题(另外还有性能开销)。最容易想到的是,假如不同线程以不同顺序来获取锁,就可能发生死锁。这时线程会被冻结,彼此等待对方释放它们的锁,如图19.2所示。
image.png
此时,每个线程都只有在对方释放了锁之后才能继续,线程阻塞,造成代码彻底死锁。下一章将讨论各种锁定技术。

五、多线程设计规范

  • 不要以为多线程必然会使代码运行更快
  • 要在通过多线程来加快解决CPU密集问题时谨慎衡量性能
  • 不要无根据地以为普通代码中原子性操作在多线程代码中也是。
  • 不要以为所有线程看到的都是一致的共享内存。
  • 要确保同时拥有多个锁的代码总是以相同的顺序获取它们。
  • 避免所有竞态条件,程序行为不能受操作系统调度线程的方式的影响。

    六、下期预告

    讨论了这些基础概念后,那如果想在c#中实现多线程的编程以及异步任务等,应该如何做呢?我们下次再继续,敬请期待。