5.1 简介     

     进程(process)是一块包含了某些资源的内存区域。操作系统利用进程把它的工作划分为一些功能单元。

进程中所包含的一个或多个执行单元称为线程(thread)。进程还拥有一个私有的虚拟地址空间,该空间仅能被它所包含的线程访问。

     当运行.NET程序时,进程还会把被称为CLR的软件层包含到它的内存空间中。上一章曾经对CLR做了详细描述。该软件层是在进程创建期间由运行时宿主载入的(参见4.2.3节)。

     线程只能归属于一个进程并且它只能访问该进程所拥有的资源。当操作系统创建一个进程后,该进程会自动申请一个名为主线程或首要线程的线程。主线程将执行运行时宿主, 而运行时宿主会负责载入CLR。

    应用程序(application)是由一个或多个相互协作的进程组成的。例如,Visual Studio开发环境就是利用一个进程编辑源文件,并利用另一个进程完成编译工作的应用程序。

    在Windows NT/2000/XP操作系统下,我们可以通过任务管理器在任意时间查看所有的应用程序和进程。尽管只打开了几个应用程序,但是通常情况下将有大约30个进程同时运行。 事实上,为了管理当前的会话和任务栏以及其他一些任务,系统执行了大量的进程。

5.2 进程

5.2.1 简介

在运行于32位处理器上的32位Windows操作系统中,可将一个进程视为一段大小为4GB(232字节)的线性内存空间,它起始于0x00000000结束于0xFFFFFFFF。这段内存空间不能被其他进程所访问,所以称为该进程的私有空间。这段空间被平分为两块,2GB被系统所有,剩下2GB被用户所有。

如果有N个进程运行在同一台机器上,那么将需要N×4GB的海量RAM,还好事实并非如此。

  • Windows是按需为每个进程分配内存的,4GB是32位系统中一个进程所占空间的上限。
  • 将进程所需的内存划分为4KB大小的内存页,并根据使用情况将这些内存页存储在硬盘上或加载到RAM中,通过系统的这种虚拟内存机制,我们可以有效地减少对实际内存的需求量。当然这些对用户和开发者来说都是透明的。
5.2.2 System.Diagnostics.Process类

System.Diagnostics.Process类的实例可以引用一个进程,被引用的进程包含以下几种。

  • 该实例的当前进程。
  • 本机上除了当前进程的其他进程。
  • 远程机器上的某个进程。

通过该类所包含的方法和字段,可以创建或销毁一个进程,并且可以获得一个进程的相关信息。下面将讨论一些使用该类实现的常见任务。

5.2.3 创建和销毁子进程

下面的程序创建了一个称为子进程的新进程。在这种情况下,初始的进程称为父进程。子进程启动了一个记事本应用程序。父进程的线程在等待1秒后销毁该子进程。该程序的执行效果就是打开并关闭记事本。

例5-1

C# 进程和线程_应用程序

静态方法Start()可以使用已存在的Windows文件扩展名关联机制。例如,我们可以利用下面的代码执行同样的操作。

C# 进程和线程_优先级_02

默认情况下,子进程将继承其父进程的安全上下文。但还可以使用Process.Start()方法的一个重载版本在任意用户的安全上下文中启动该子进程,当然需要通过一个System.Diagnostics. ProcessStartInfo类的实例来提供该用户的用户名和密码

5.2.4 避免在一台机器上同时运行同一应用程序的多个实例

有些应用程序需要这种功能。实际上,通常来说在同一台机器上同时运行一个应用程序的多个实例并没有意义。

直到现在,为了在Windows下满足上述约束,开发者最常用的方法仍然是使用有名互斥体(named mutex)技术(参见5.7.2节)。然而采用这种技术来满足上述约束存在以下缺点:

  • 该技术具有使互斥体的名字被其他应用程序所使用的较小的、潜在的风险。在这种情况下该技术将不再有效并且会造成很难检测到的bug。
  • 该技术不能解决我们仅允许一个应用程序产生N个实例这种一般的问题。

幸而在System.Diagnostics.Process类中拥有GetCurrentProcess()(返回当前进程)和GetPro- cesses()(返回机器上所有的进程)这样的静态方法。在下面的程序中我们为上述问题找到了一个优雅且简单的解决方案。

例5-2

C# 进程和线程_多线程_03

通过方法参数指定了远程机器的名字后,GetProcesses()方法也可以返回远程机器上所有的进程。

5.2.5 终止当前进程

可以调用System.Environment类中的静态方法Exit(int exitCode)或FailFast(string message)终止当前进程。

Exit()方法是最好的选择,它将彻底终止进程并向操作系统返回指定的退出代码值。之所以称为彻底终止是因为当前对象的所有清理工作以及finally块的执行都将由不同的线程完成。当然,终止进程将花费一定的时间。

顾名思义,FailFast()方法可以迅速终止进程。Exit()方法所做的预防措施将被它忽略。只有一个包含了指定信息的严重错误会被操作系统记录到日志中。你可能想要在探查问题的时候使用该方法,因为可以将该程序的彻底终止视为数据恶化的起因。

 

5.3 线程

5.3.1 简介

一个线程包含以下内容。

  • 一个指向当前被执行指令的指令指针;
  • 一个栈;
  • 一个寄存器值的集合,定义了一部分描述正在执行线程的处理器状态的值;
  • 一个私有的数据区。

所有这些元素都归于线程执行上下文的名下。处在同一个进程中的所有线程都可以访问该进程所包含的地址空间,当然也包含存储在该空间中的所有资源。

我们不准备讨论线程在内核模式或者用户模式执行的问题。尽管.NET以前的Windows一直使用这两种模式,并且依然存在,但是对.NET Framework来说它们是不可见的。

并行使用一些线程通常是我们在实现算法时的自然反应。实际上,一个算法往往由一系列可以并发执行的任务组成。但是需要引起注意的是,使用大量的线程将引起过多的上下文切换,最终反而影响了性能。

同样,几年前我们就注意到,预测每18个月处理器运算速度增加一倍的摩尔定律已不再成立。处理器的频率停滞在3GHz~4GHz上下。这是由于物理上的限制,需要一段时间才能取得突破。同时,为了在性能竞争中不会落败,较大的处理器制造商如AMD和Intel目前都将目标转向多核芯片。因此我们可以预计在接下去的几年中这种类型的架构将广泛被采用。在这种情况下,改进应用性能的唯一方案就是合理地利用多线程技术。

5.3.2 受托管的线程与 Windows线程

必须要了解,执行.NET应用的线程实际上仍然是Windows线程。但是,当某个线程被CLR所知时,我们将它称为受托管的线程。具体来说,由受托管的代码创建出来的线程就是受托管的线程。如果一个线程由非托管的代码所创建,那么它就是非托管的线程。不过,一旦该线程执行了受托管的代码它就变成了受托管的线程。

一个受托管的线程和非托管的线程的区别在于,CLR将创建一个System.Threading.Thread类的实例来代表并操作前者。在内部实现中,CLR将一个包含了所有受托管线程的列表保存在一个叫做ThreadStore地方。

CLR确保每一个受托管的线程在任意时刻都在一个AppDomain中执行,但是这并不代表一个线程将永远处在一个AppDomain中,它可以随着时间的推移转到其他的AppDomain中。关于AppDomain的概念参见4.1。

从安全的角度来看,一个受托管的线程的主用户与底层的非托管线程中的Windows主用户是无关的。

5.3.3 抢占式多任务处理

我们可以问自己下面这个问题: 我的计算机只有一个处理器,然而在任务管理器中我们却可以看到数以百计的线程正同时运行在机器上!这怎么可能呢?

多亏了抢占式多任务处理,通过它对线程的调度,使得上述问题成为可能。调度器作为Windows内核的一部分,将时间切片,分成一段段的时间片。这些时间间隔以毫秒为精度且长度并不固定。针对每个处理器,每个时间片仅服务于单独一个线程。线程的迅速执行给我们造成了它们在同时运行的假象。我们在两个时间片的间隔中进行上下文切换。该方法的优点在于,那些正在等待某些Windows资源的线程将不会浪费时间片,直到资源有效为止。

之所以用抢占式这个形容词来修饰这种多任务管理方式,是因为在此种方式下线程将被系统强制性中断。那些对此比较好奇的人应该了解到,在上下文切换的过程中,操作系统会在下一个线程将要执行的代码中插入一条跳转到下一个上下文切换的指令。该指令是一个软中断,如果线程在遇到这条指令前就终止了(例如,它正在等待某个资源),那么该指定将被删除而上下文切换也将提前发生。

抢占式多任务处理的主要缺点在于,必须使用一种同步机制来保护资源以避免它们被无序访问。除此之外,还有另一种多任务管理模型,被称为协调式多任务管理,其中线程间的切换将由线程自己负责完成。该模型普遍认为太过危险,原因在于线程间的切换不发生的风险太大。如我们在4.2.8节中所解释的那样,该机制会在内部使用以提升某些服务器的性能,例如SQL Server2005。但Windows操作系统仅仅实现了抢占式多任务处理。

5.3.4 进程与线程的优先级

某些任务拥有比其他任务更高的优先级,它们需要操作系统为它们申请更多的处理时间。例如,某些由主处理器负责的外围驱动器必须不能被中断。另一类高优先级的任务就是图形用户界面。事实上,用户不喜欢等待用户界面被重绘。

那些从Win32世界来的用户知道在CLR的底层,也就是Windows操作系统中,可以为每个线程赋予一个0~31的优先级。但你无法在.NET的世界中也使用这些数值,因为:

  • 它们无法描述自身的含义。
  • 随着时间的流逝这些值是非常容易变化的。

1. 进程的优先级

可以使用Process类中的类型为ProcessPriorityClass的PriorityClass{get;set;}属性为进程赋予一个优先级。System.Diagnostics.ProcessPriorityClass枚举包含以下值:

C# 进程和线程_优先级_04

如果某个进程中属于Process类的PriorityBoostEnabled属性的值为true(默认值为true),那么当该进程占据前台窗口的时候,它的优先级将增加一个单位。只有当Process类的实例引用的是本机进程时,才能够访问该属性。

可以通过以下操作利用任务管理器来改变一个进程的优先级:在所选的进程上点击右键>设置优先级>从提供的6个值(和上图所述一致)中做出选择。

Windows操作系统有一个优先级为0的空闲进程。该进程不能被其他任何进程使用。根据定义,进程的活跃度用时间的百分比表示为:100%减去在空闲进程中所耗费时间的比率。

2. 线程的优先级

每个线程可以结合它所属进程的优先级,并使用System.Threading.Thread类中类型为ThreadPriority的Priority{get;set;}属性定义各自的优先级。System.Threading.Thread- Priority包含以下枚举值:

C# 进程和线程_应用程序_05

在大多数应用程序中,不需要修改进程和线程的优先级,它们的默认值为Normal。

5.3.5 System.Threading.Thread类

CLR会自动将一个System.Threading.Thread类的实例与各个受托管的线程关联起来。可以使用该对象从线程自身或从其他线程来操纵线程。还可以通过System.Threading.Thread类的静态属性CurrentThread来获得当前线程的对象。

C# 进程和线程_多线程_06

Thread类有一个功能使我们能够很方便的调试多线程应用程序,该功能允许我们使用一个字符串为线程命名:

C# 进程和线程_优先级_07

5.3.6 创建与Join一个线程

只需通过创建一个Thread类的实例,就可以在当前的进程中创建一个新的线程。该类拥有多个构造函数,它们将接受一个类型为System.Threading.ThreadStart或System.Threading.Parame- trizedThreadStart的委托对象作为参数,线程被创建出来后首先执行该委托对象所引用的方法。使用ParametrizedThreadStart类型的委托对象允许用户为新线程将要执行的方法传入一个对象作为参数。Thread类的一些构造函数还接受一个整型参数用于设置线程要使用的最大栈的大小,该值至少为128KB(即131072字节)。创建了Thread类型的实例后,必须调用Thread.Start()方法以真正启动这个线程。

例5-3

C# 进程和线程_C#_08

该程序输出:

C# 进程和线程_应用程序_09

在这个例子中,我们使用Join()方法挂起当前线程,直到调用Join()方法的线程执行完毕。该方法还存在包含参数的重载版本,其中的参数用于指定等待线程结束的最长时间(即超时)所花费的毫秒数。如果线程中的工作在规定的超时时段内结束,该版本的Join()方法将返回一个布尔量True。

5.3.7 挂起一个线程

可以使用Thread类的Sleep()方法将一个正在执行的线程挂起一段特定的时间,还可以通过一个以毫秒为单位的整型值或者一个System.TimeSpan结构的实例设定这段挂起的时间。该结构的一个实例可以设定一个精度为1/10 ms(100 ns)的时间段,但是Sleep()方法的最高精度只有1ms。

C# 进程和线程_应用程序_10

我们也可以从将要挂起的线程自身或者另一个线程中使用Thread类的Suspend()方法将一个线程的活动挂起。在这两种情况中,线程都将被阻塞直到另一个线程调用了Resume()方法。相对于Sleep()方法,Suspend()方法不会立即将线程挂起,而是在线程到达下一个安全点之后,CLR才会将该线程挂起。安全点的概念参见4.7.11节。

5.3.8 终止一个线程

一个线程可以在以下场景中将自己终止。

  • 从自己开始执行的方法(主线程中的Main()方法,其他线程中ThreadStart委托对象所引用的方法)中退出。
  • 被自己终止。
  • 被另一个线程终止。

第一种情况不太重要,我们将主要关注另两种情况。在这两种情况中,都可以使用Abort()方法(通过当前线程或从当前线程之外的一个线程)。使用该方法将在线程中引发一个类型为ThreadAbortException的异常。由于线程正处于一种被称为AbortRequested的特殊状态,该异常具有一个特殊之处:当它被异常处理所捕获后,将自动被重新抛出。只有在异常处理中调用Thread.ResetAbort()这个静态方法(如果我们有足够的权限)才能阻止它的传播。

例5-4 主线程的自杀

C# 进程和线程_多线程_11

当线程A对线程B调用了Abort()方法,建议调用B的Join()方法,让A一直等待直到B终止。Interrupt()方法也可以将一个处于阻塞状态的线程(即由于调用了Wait()、Sleep()或者Join()其中一个方法而阻塞)终止。该方法会根据要被终止的线程是否处于阻塞状态而表现出不同的行为。

  • 如果该方法被另一个线程调用时,要被终止的线程处于阻塞状态,那么会产生ThreadInterruptedException异常。
  • 如果该方法被另一个线程调用时,要被终止的线程不处于阻塞状态,那么一旦该线程进入阻塞状态,就会引发异常。这种行为与线程对自己调用Interrupt()方法是一样的。
5.3.9 前台线程与后台线程

Thread类提供了IsBackground{get;set}的布尔属性。当前台线程还在运行时,它会阻止进程被终止。另一方面,一旦所指的进程中不再有前台线程,后台线程就会被CLR自动终止(调用Abort()方法)。IsBackground的默认值为false,这意味着所有的线程默认情况处于前台状态。

5.3.10 受托管线程的状态图

Thread类拥有一个System.Threading.ThreadState枚举类型的字段ThreadState,它包含以下枚举值:

C# 进程和线程_应用程序_12

有关每个状态的具体描述可以在MSDN上一篇名为“ThreadState Enumeration”的文章中找到。该枚举类型是一个二进制位域,这表示一个该类型的实例可以同时表示多个枚举值。例如,一个线程可以同时处于Running、AbortRequested和Background这三种状态。二进制位域的概念参见10.11.3节。

根据我们在前面的章节中所了解的知识,我们定义了如图5-1所示的简化的状态图。

C# 进程和线程_优先级_13

图5-1 简化的托管线程状态图