多线程编程的基本概念

  下面的一些基本概念可能和.NET的联系并不大,但对于掌握.NET中的多线程开发来说却十分重要。我们在开始尝试多线程开发前,应该对这些基础知识有所掌握,并且能够在操作系统层面理解多线程的运行方式。

1.1 操作系统层面的进程和线程

  (1)进程

  进程代表了操作系统上运行着的一个应用程序。进程拥有自己的程序块,拥有独占的资源和数据,并且可以被操作系统调度。But,即使是同一个应用程序,当被强制启动多次时,也会被安放到不同的进程之中单独运行。

  直观地理解进程最好的方式就是通过进程管理器浏览,其中每条记录就代表了一个活动着的进程:

  (2)线程

  线程有时候也被称为轻量级进程,它的概念和进程十分相似,是一个可以被调度的单元,并且维护自己的堆栈和上下文环境。线程是附属于进程的,一个进程可以包含1个或多个线程,并且同一进程内的多个线程共享一块内存块和资源

  由此看来,一个线程是一个操作系统可调度的基本单元,但是它的调度受限于该线程所属的进程,也就是说操作系统首先决定执行下一个执行的进程,进而才会调度该进程内的线程。一个线程的基本生命周期如下图所示:

  (3)进程和线程的区别

  最大的区别在于隔离性,每个进程都会被单独隔离(进程拥有自己的内存、资源和运行数据,一个进程的崩溃不会影响到其他进程,因此进程间的交互也相对困难),而同一进程内的所有线程则共享内存和资源,并且一个线程可以访问和结束同一进程内的其他线程。

1.2 多线程程序在操作系统中是并行执行的吗?

  (1)线程的调度

  在计算机系统发展的早期,操作系统层面不存在并行的概念,所有的应用程序都在排队等候一个单线程的队列之中,每个程序都必须等到前面的程序都安全执行完毕之后才能获得执行的权利,一个小小的错误将会导致操作系统上的所有程序的阻塞。在后来的操作系统中,逐渐产生了分时和进程、线程的概念。

  多个线程由操作系统进行调度控制,决定何时运行哪个线程。所谓线程调度,是指操作系统决定如何安排线程执行顺序的算法。按常规分类,线程调度可以分为以下两种:

  ①抢占式调度

  抢占式调度是指每个线程都只有极少的运行时间(在Windows NT内核模式下这个时间不会超过20ms),而当时间片用完时该线程就会被强制暂停,保存上下文并把运行权利交给下一个线程。这样调度的结果就是:所有的线程都在被不停地快速切换运行,使得用户感觉所有的线程都在并行运行

  ②非抢占式调度

  非抢占式调度是指某个线程在运行时不会被操作系统强制暂停,它可以持续地运行直到运行告一段落并主动交出运行权。在这样的调度方式之下,线程的运行就是单队列的,并且可能产生恶意程序长期霸占运行权的情况。

PS:现在很多的操作系统(包括Windows在内),都同时采用了抢占式和非抢占式模式。对于那些优先级较高的线程,OS采用非抢占式来给予充分的时间运行,而对于普通的线程,则采用抢占式模式来快速地切换执行。

  (2)线程的并行问题

  在单核单CPU的硬件架构上,线程的并行运行完全是用户的主观体验。事实上,在任一时刻只可能存在一个处于运行状态的线程。但在多CPU或多核的架构上,情况则略有不同。多CPU多核的架构则允许系统完全并行地运行两个或多个无其他资源争用的线程,理论上这样的架构可以使运行性能整数倍地提高。

PS:微软公司曾经提出超线程技术,简单说来这是一种逻辑上模拟多CPU的技术,但实际上它们却共享物理处理器和缓存,超线程对性能的提高相当有限。

1.3 神马是纤程?

  (1)纤程的概念

  纤程是微软公司在Windows上提出的一个概念,其设计目的是用来方便地移植其他操作系统上的应用程序。一个线程可以拥有0个或多个纤程,一个纤程可以视为一个轻量级的线程,它拥有自己的栈和上下文状态。But,纤程的调度是由程序员编码控制的,当一个纤程所在线程得到运行时,程序员需要手动地决定运行哪一个纤程

PS:事实上,Windows操作系统内核是不知道纤程的存在的,它只负责调度所有的线程,而纤程之所以成为操作系统的概念,是因为Windows提供了关于线程操作的Win32函数,能够方便地帮助程序员进行线程编程。

  (2)纤程和线程的区别

  纤程和线程最大的区别在于:线程的调度受操作系统的管理,程序员无法进行完全干涉。但纤程却完全受控于程序员本身,允许程序员对多任务进行自定义的调度和控制,因此纤程带给程序员很大的灵活性。

  下图展示了进程、线程以及纤程三者之间的关系:

  (3)纤程在.NET中的地位

  需要谨记是的一点是:.NET运行框架没有做出关于线程真实性的保证!也就是说,我们在.NET程序中新建的线程并不一定是操作系统层面上产生的一个真正线程。在.NET框架寄宿的情况下,一个程序中的线程很可能对应某个纤程

PS:所谓CLR寄宿,就是指CLR运行在某个应用程序而非操作系统内。常见的寄宿例子是微软公司的SQL Server 2005。

二、.NET中的多线程编程

  .NET为多线程编程提供了丰富的类型和机制,程序员需要做的就是掌握这些类型和机制的使用方法和运行原理。

2.1 如何在.NET程序中手动控制多个线程?

  .NET中提供了多种实现多线程程序的方法,但最直接且灵活性最大的,莫过于主动创建、运行、结束所有线程。

  (1)第一个多线程程序

  .NET提供了非常直接的控制线程类型的类型:System.Threading.Thread类。使用该类型可以直观地创建、控制和结束线程。下面是一个简单的多线程程序:

 View Code

  在主线程中,该代码创建了10个新的线程,这个10个线程的工作互不干扰,宏观上来看它们应该是并行运行的,执行的结果也证实了这一点:

  

PS:这里再次强调一点,当new了一个Thread类型对象并不意味着生成了一个线程,事实上线程的生成是在调用Thread的Start方法的时候。另外在之前的介绍中,这里的线程并不一定是操作系统层面上产生的一个真正线程!

  (2)控制线程的状态

  很多时候,我们需要主动关心线程当前所处的状态。在任意时刻,.NET中的线程都会处于如下图所示的几个状态中的某一个状态上,该图也直观地展示了一个线程可能经过的状态转换过程(该图并没有列出所有的状态转换途径/原因):

  下面的示例代码则展示了我们如何手动地查看和控制一个线程的状态:

 View Code

  上述代码的执行结果如下图所示:

PS:为了演示方便,上述代码刻意地使线程处于各个状态并打印出来。在.NET Framework 4.0 及之后的版本中,已经不再鼓励使用线程的挂起状态,以及Suspend和Resume方法了。

2.2 如何使用.NET中的线程池?

  (1).NET中的线程池是神马

  我们都知道,线程的创建和销毁需要很大的性能开销,在Windows NT内核的操作系统中,每个进程都会包含一个线程池。而在.NET中呢,也有自己的线程池,它是由CLR负责管理的。

  线程池相当于一个缓存的概念,在该池中已经存在了一些没有被销毁的线程,而当应用程序需要一个新的线程时,就可以从线程池中直接获取一个已经存在的线程。相对应的,当一个线程被使用完毕后并不会立刻被销毁,而是放入线程池中等待下一次使用

  .NET中的线程池由CLR管理,管理的策略是灵活可变的,因此线程池中的线程数量也是可变的,使用者只需向线程池提交需求即可,下图则直观地展示了CLR是如何处理线程池需求的:

PS:线程池中运行的线程均为后台线程(即线程的 IsBackground 属性被设为true),所谓的后台线程是指这些线程的运行不会阻碍应用程序的结束。相反的,应用程序的结束则必须等待所有前台线程结束后才能退出。

  (2)在.NET中使用线程池

  在.NET中通过 System.Threading.ThreadPool 类型来提供关于线程池的操作,ThreadPool 类型提供了几个静态方法,来允许使用者插入一个工作线程的需求。常用的有以下三个静态方法:

  ① static bool QueueUserWorkItem(WaitCallback callback)

  ② static bool QueueUserWorkItem(WaitCallback callback, Object state)

  ③ static bool UnsafeQueueUserWorkItem(WaitCallback callback, Object state)

  有了这几个方法,我们只需要将线程要处理的方法作为参数传入上述方法即可,随后的工作都由CLR的线程池管理程序来完成。其中,WaitCallback 是一个委托类型,该委托方法接受一个Object类型的参数,并且没有返回值。下面的代码展示了如何使用线程池来编写多线程的程序:

 View Code

  上述代码执行后,如果不输入任何字符,那么会得到如下图所示的执行结果:

PS:事实上,UnsafeQueueWorkItem方法实现了完全相同的功能,二者的差别在于UnsafeQueueWorkItem方法不会将调用线程的堆栈传递给辅助线程,这就意味着主线程的权限限制不会传递给辅助线程。UnsafeQueueWorkItem由于不进行这样的传递,因此会得到更高的运行效率,但是潜在地提升了辅助线程的权限,也就有可能会成为一个潜在的安全漏洞。

2.3 如何查看和设置线程池的上下限?

  线程池的线程数是有限制的,通常情况下,我们无需修改默认的配置。但在一些场合,我们可能需要了解线程池的上下限和剩余的线程数。线程池作为一个缓冲池,有着其上下限。在通常情况下,当线程池中的线程数小于线程池设置的下限时,线程池会设法创建新的线程,而当线程池中的线程数大于线程池设置的上限时,线程池将销毁多余的线程

PS:在.NET Framework 4.0中,每个CPU默认的工作者线程数量最大值为250个,最小值为2个。而IO线程的默认最大值为1000个,最小值为2个。

  在.NET中,通过 ThreadPool 类型提供的5个静态方法可以获取和设置线程池的上限和下限,同时它还额外地提供了一个方法来让程序员获知当前可用的线程数量,下面是这五个方法的签名:

  ① static void GetMaxThreads(out int workerThreads, out int completionPortThreads)

  ② static void GetMinThreads(out int workerThreads, out int completionPortThreads)

  ③ static bool SetMaxThreads(int workerThreads, int completionPortThreads)

  ④ static bool SetMinThreads(int workerThreads, int completionPortThreads)

  ⑤ static void GetAvailableThreads(out int workerThreads, out int completionPortThreads)

  下面的代码示例演示了如何查询线程池的上下限阈值和可用线程数量:

 View Code

  该实例的执行结果如下图所示:

PS:上面代码示例在不同的计算机上运行可能会得到不同的结果,线程池中的可用数码不会再初始时达到最大值,事实上CLR会尝试以一定的时间间隔来逐一地创建新线程,但这个时间间隔非常短。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐