Net托管世界的应用程序域和线程
Managed Host CLR(Common Language Runtime) AppDomain Assembly Thread
一、 引子
.Net框架提供了全新的计算平台,给出了一致性的面向对象的编程环境,解决了安全、版本控制等原来系统平台中存在的问题,通过公用语言运行库(CLR)提供了一个高效、安全的程序执行环境,也就是托管(也称作受控,Managed)环境。在这个类似虚拟机环境下,我们编写的程序是如何运行、如何“托管”的呢?这个托管的世界如何同非托管的世界相互联系呢?
二、 如何进入“托管”世界
首先,我们要了解,到目前为止,还没有“纯天然的”.net执行环境(不排除类似Java芯片的.net芯片将来会有),所谓托管的环境(CLR)需要运行在当前已存非托管的系统上。要进入托管的.net世界,需要有一个称为宿主(Host)的程序为将要运行的.net托管代码准备执行环境—也就是要加载.net世界的基础CLR。在目前的windows系统上,能够担负这个重任的有3类已存程序:
1、 shell(通常是Explorer),提供从用户桌面启动.net程序,创建一个进程,启动此进程建立CLR
2、 浏览器宿主(Internet Explorer),处理从web下载的.net代码执行。
3、 服务器宿主(如IIS的辅助进程aspnet_wp.exe)
在执行任何托管代码之前,宿主必须首先加载并初始化公共语言运行库。假设一个.net可执行程序(prj1.exe)从shell启动,操作系统会首先建立一个进程,也就是宿主进程。装载的程序文件包括了在执行配置信息和执行代码,代码入口通常会被(创建此.net应用程序的编译器)放置一个 Stub,这个Stub实际上就是一个6字节的本机代码:
jmp _CorExeMain
而_CorExeMain是从外部库MSCorEE.dll导出,由prj1.exe引入的函数,于是操作系统会装入MSCorEE.dll(进入.net世界的序曲),修正_CorExeMain的运形时实际位置。
MSCorEE.dll实际上是一个COM组件库。调用_CorExeMain后开始初始化CLR,并察看prj1.exe的CLR相关数据结构,确定执行.net托管代码的入口。宿主调用.net 支持API CorBindToRuntimeEx来装载CLR,并且根据配置初始化CLR的运行特征,譬如垃圾回收策略等,这是因为不同的宿主面临的应用需求不一样,一个服务宿主同普通的工作站宿主的“垃圾回收”(GC)机制显然不一样,所以启动CLR时的参数也不一样。
宿主装载的是一个符合COM规范要求的组件库文件MSCorEE.dll,也就是CLR,一般存在于操作系统目录(便于装载)。有关mscoree.dll更多的了解,建议可看看FrameworkSDK目录中的头文件mscoree.h。MSCorEE.dll通过提供启动CLR的接口给宿主,譬如ICorRuntimeHost接口可用来配置运行库的各个方面(如垃圾回收),以将其加载到进程中或注册附加的事件,其中的start/stop方法可以让宿主控制CLR在宿主进程的生存期。
其实我们可以利用CorBindToRuntimeEx编写实现自己的宿主,关于.net宿主的实现可以单独作为一个题目,在此给出几个URL供大家参考。
http://www.codeproject.com/dotnet/simpleclrhost.asp
http://www.elitevb.com/content/print.aspx?contentid=95
至此,我们明白, CLR——无所不能的虚拟机以DLL形式“寄生”于某个非托管世界的进程!所谓托管世界实际上实指在Mscoree.dll建立的可控制环境下。难怪精通VCL的用户说CLR就是MS版的VCL!
三、 应用程序域(AppDomain)和域中的线程(Thread)
一旦CLR加载并初始化完成,即宣告进入.net世界。
在执行第一个入口函数(通常是编译时指定的Main函数)之前,CLR会检测该函数引用到的所有类型,并且由CLR从堆中申请一个内部数据结构,通过这个数据结构CLR管理所有引用到的类型的访问,这也就是托管的根本机制。每一个类型的方法都会有一个条目供检索和引用,在条目中保存该方法的代码位置。在第一次执行某个函数时,属于CLR服务的JITCompiler函数被调用,负责根据条目得到代码,且将所有IL代码验证后编译成本机代码。.net中每一个对象无论大小,都存在一个在当前CLR中的唯一hash码,可以将此hash码作为类似数据库关键字段来区分代表每一个对象,.net的基类Object. GetHashCode()可以帮助获得当前对象的hash码。如果以后调用此方法函数,那么将会直接调用这些本机代码。此过程就是所谓的“即时编译”。每一个托管对象的方法都会在调用前被CLR的Jit机制编译成本地代码,然后交给操作系统调度CPU执行。理解托管代码在运行时的细节,可以帮助我们深入了解域和线程的本质以及他们相互之间的关系。
所谓IL验证主要做安全检查,大体上是根据托管代码元数据及CLR规则来检验IL代码,譬如某个变量是否初始化,调用某个函数时携带的参数是否正确,方法调用是否总能够返回等等。通常情况下,IL验证会在遇到它认为不安全的代码时抛出VerificationException异常,阻止代码继续执行。
初始化同时,CLR建立第一个AppDomain,即默认应用程序域,在此域中执行入口代码。从概念上讲,应用程序域是.net 托管世界中的“应用程序在其中执行的独立环境”,是要执行或引用的多个程序集的容器(一个应用程序域肯定不止加载一个程序集)。千万不要理解成进程的概念,应用程序域存在于CLR中,而CLR属于宿主进程,应用程序域同进程属于不同层次上的概念。但的确,.net的设计者是仿照操作系统的进程概念来设计应用程序域的,使得AppDomain成为.net世界的执行单位,相互之间代码执行隔绝。大体上,下图可以帮助理解宿主、CLR、应用程序域之间的关系。
图一
为了管理装载的程序集,每一个应用程序域都有自己的配置信息和存储区域、引用、执行边界,另外重要的是有自己的安全策略。安全策略存储在证据(evidence)中,所有在同一AppDomain内的程序集都会共享这些资源和安全指示信息。前面讲到的IL验证会根据这些配置、程序集中的元数据和证据检验代码,确保应用程序域的代码不会“越界”,不会破坏另外域的对象,也确保不会有C++世界中可怕的无效、错误指针的危险。
通常情况下,只有一个应用程序域的.net程序大多数行为类似传统的非托管世界而执行,在默认应用程序域结束后,进程结束,CLR像其他DLL一样被卸载。然而如果存在多个域,那么情况就要复杂些——起码,CLR必须等到所有的应用程序域都结束后才可能按照DLL规则释放。另外,不像进程间那样通信艰难,同一个操作系统进程内的应用程序域间可以进行花费较低的交互,但是受限应用程序域的分隔特性,不可以在一个应用程序域直接操作另外一个域的实例对象(对象同程序集、应用程序域紧密相关,CLR会“照管”他们,确保应用程序域代码执行安全)。
域间的对象引用有两种情况:如果域间引用对象自身是传值的,那么对象必须支持序列化(实现接口System.ISerializable), 在穿越域引用被另外域的对象时,会被序列化,到达目标域后反序列化,此时,会因反序列化从而加载该对象定义所在的程序集。如果是传递引用类型的对象,在目的域(需要引用该对象的域,非生成此对象的容器域)的边界会建立一个实例对象的代理,原来的对象仍然存在,“安然”存活于被引用域,但是彼域中的实例对象代理(也就是封装器wrapper)知道如何同此对象实例交流,此代理是通过CLR的提供的服务来调度实例对象的方法。如果对象并非支持序列化的传值或者支持传引用的类型,或者由于加载对象定义的所在程序集失败(该程序集无法定位或者由于应用程序域安全限制而无法加载),会导致抛出异常,通常表示这是一个不合法的跨域对象引用操作。尽管可能所有的应用程序域都是存在于同一个操作系统进程,但是仍然由于CLR对于应用程序域的安全隔离而导致一些损耗,如果没有必要,要尽可能避免这样做。
应用程序域是.net CLR世界的轻量子进程,而线程却是操作系统分配处理器时间的基本单元。线程们根据一定的优先级规则被操作系统调度,切换时都需要保持线程的执行上下文。所谓上下文是使线程在线程的宿主进程地址空间中无缝地继续执行所需的所有信息,包括线程的 CPU 寄存器组和堆栈。在应用程序域同样可以在主线程外创建多个子线程,.Net的Thread 和ThreadPool类提供了对操作系统的线程的包装,大大简化了对于线程的使用难度。当我们在一个应用程序域的主线程创建了其他子线程时,这些线程属于当前进程。从操作系统角度来看,线程无所谓专属于某个应用程序域,一个.net程序创建的进程中的线程实际上都是属于同一个进程的,这也导致线程不必唯一存在于某个应用程序域中,而是可以根据需要在线程执行生命期间处于多个应用程序域(但任一时刻只能属于某一个特定域)。应用程序域和线程不存在一对一的关系,一个线程可以跨越多个应用程序域,而一个应用程序域可以通过加载程序集的类实例对象而创建多个线程。应用程序域的方法调用、对象解析、IL的验证执行等受CLR的控制,所以应用程序域之间可以做到相互阻隔。但线程属于操作系统调度的,没有应用程序域的负担,可以迅速切换被系统调度执行。
当有需要线程从一个域访问另外一个域的对象或者执行另外域的对象的代码时,实际上属于域间通信问题。因此,会在当前域创建一个ObjectHandle类型的代理(见上面的讨论),然后利用ObjectHandle的成员方法Unwrap得到目标对象的引用,通过引用执行目标代码。由于所有的托管代码执行实际上都经过了CLR的检查,此时,CLR会判断到域间操作是同一个(宿主)进程,因而允许当前线程代码执行,执行目标域的对象的方法代码,从而实现了“穿越”域。实际上,如果发生对象域间调/引用的域不是属于同一个进程,那么域间的通信采用.net Remotting技术而不是直接通过当前线程代码跳转到另一个域。如果抛开CLR的角度,从操作系统角度看,这一切纯粹是普通的进程内部的线程调度。域间的“消耗”完全是因为CLR这个.net世界的“上帝”在检查代码的安全执行(但是是必须的),具体的细节大致是:因为所有托管的对象、方法之类的都在CLR中存在条目,执行一个对象的方法,会自动由CLR查找条目,受到CLR的管理监控,然后决定执行本地代码。CLR判断出当前条目属于哪个域,就将域的私有存储和资源分配给当前线程(线程利用静态方法AppDomain.CurrentDomain可以得到当前所处应用程序域对象)。实际上,涉及到跨域的引用和执行,应用程序域之间的交互,无论是同一个进程的多个域还是不同进程间的域,乃至不同机器间域,通信通常根据域的分布区分对待,由于CLR能够区分要跳转执行的代码是同一进程还是同一机器,所以同一进程的跨域线程调度实际上使得线程在边界代理经过CLR的检查后就跳转到目标域,执行相应的本机代码。
我们可以结合下图进一步理解线程同域之间的关系:
注意图中其中Thread2在生命期间“穿越“了主AppDomain和多个子AppDomain。Thread3整个生命周期却只存在AppDomain1中。AppDomain1有3个线程运行,Thread1和Thread2执行期间转移到AppDomain2执行,其间Thread1和Thread2通过代理执行引用对象的代码,从而将线程切换到AppDomain2。Thread2最初从默认域执行,执行期间跨越默认域、AppDomain1和AppDomain2。
可能影响代码执行流的还有一个因素——异常。当应用程序域需要被卸载时,应用程序域的所有线程会被CLR通知,CLR会在当前域的线程强行发出一个ThreadAbortException异常,迫使他们退出该应用域。另一方面,CLR会检查所有涉及到此域的其他域中的代理对象,将其设定为无效。以后,凡是想通过这些代理实现其他域对象的调用或引用,将会引发AppDomainUnloadedException异常。应用程序域的卸载可以由自身或者自身的子线程发出,也可以由其他域的代码发出(譬如在一个主应用程序域创建子域后然后卸载子域)。如果由于种种原因不能够卸载应用程序域,那么会产生CannotUnloadAppDomainException异常。如果是应用程序域自身(包括应用程序域创建的子线程)发出卸载命令,那么会由CLR来创建一个新的线程执行卸载应用程序域,产生的异常由CLR发出的线程捕捉;如果是另外的域中代码发出卸载命令,那异常会转交给发出卸载命令的线程。清楚了解异常的抛送路线,可以确保我们写出安全可靠稳定的应用程序。
四、 AppDomain小结
实际上我们看到,.net的托管环境CLR是通过将COM DLL文件msCorEE.dll装入当前操作系统进程来建立的。托管世界的执行对象提供元数据和IL代码以及安全证据等在CLR的内存对执行,所有的托管代码经过Jit编译成本地代码,由于CLR的一切对象被“托管”可以确保AppDomain的实现类似进程环境的执行分隔作用,CLR可以根据应用程序域的边界阻止任何不安全的访问。CLR也跟踪管理托管线程,线程可以通过域间的通信功能实现线程在多个应用程序域上的移动。进程和线程属于操作系统的调度执行单元,但应用程序域属于.net的执行逻辑单元,通过“托管”实现应用程序域的代码执行以及域间数据访问的分隔。
通常情况下,我们的.net应用程序仅仅需要一个缺省的应用程序域,但也有一些情况考虑建立其他应用程序域可能更好一些:
1、 需要隔离的程序集,譬如一些特别容易引起崩溃的代码可以考虑单独运行于一个特定的AppDomain
2、 不同安全级别的程序集,如果需要为自己的代码划分安全执行的边界,可以考虑将不同安全级别的代码单独创建于某个设定了不同安全信息的appDomain
3、 从性能上考虑,有些程序集可能会消耗大量资源,尽管在托管环境下,基本上不存在资源消耗漏洞,但是总会存在特定时间访问密集造成消耗大量资源的情况,这时可以考虑创建单独的AppDomain,在资源消耗超过临界点后进行AppDomain的卸载,适应系统运行要求。
4、 不同版本的同一应用程序集的同时运行。这个在COM时代是一个大问题,现在通过AppDomain,实现了在一个进程中执行版本不同的两个程序集,可以做到良好的兼容性。
5、动态加载一些程序。可以将一些不经常使用的程序集动态载入,(为了效率)经常使用的程序集则可以动态加载,甚至单独加载到应用程序域中去。
6、共用程序集,提高.net的执行效率。譬如我们用到的System.object System.Int32 等对象往往多个应用程序域都需要,会造成资源浪费,为减少资源使用,含有这些常用.net类的程序集MSCorLib.dll会以单独的中立域的方式加载,CLR会为其维护一个特殊的加载器,使得这个程序集只有在进程中断时才会被卸载,从而提高了速度,减少了内存等资源的浪费。
五、 参考
本文大致讨论了.net CLR托管的执行机制,以及程序集如何加载到应用程序域执行、线程等如何在托管集之下的执行,试图帮助读者理解托管的计算环境代码执行的较为详细情形。但是,并没有试图详细阐述.net框架的体系细节,甚至连具体的.net代码都没有提供分析,如果您需要进一步了解.net的更多细节,请参考阅读以下的资料:
1、《.net 框架程序设计(修订版)》 Jeffrey Richter 著/李建中 译 清华大学出版社。
2、《.net核心技术—原理与架构》 刘晓华 编著 电子工业出版社
3、MSDN .net 框架SDK