AYE89

coding & learning

导航

Mono概述及部分源码解析

Posted on 2017-07-21 14:23  AYE89  阅读(3644)  评论(0编辑  收藏  举报

原文地址:http://blog.csdn.NET/ariesjzj/article/details/9292467

长期以来.NET框架都被认为是Windows下的专利,而Mono可以让.NET程序跑在其它的平台(如Linux, OS X等)上。近几年由于移动设备的兴起,Mono的衍生项目(MonoTouch和Mono for Android)还可以让基于.NET框架的程序轻易的移植到androidiOS设备上,这让原本Windows上的C#程序员上手移动开发的周期大大缩短。

Mono主要包含了C#的编译器,CLI(Common Language Infrastructure)实现和一系统相关开发工具。本文将简要介绍Mono的安装,主要组件和大体工作流程。

一、 源码下载,编译,安装
要是怕麻烦的话,Ubuntu上有现成的安装包:
# apt-get install mono-runtime
其实源码安装也需要系统上已有C#编译器,所以至少得先装:
# apt-get install mono-gmcs
下载源码:
$ git clone https://github.com/mono/mono.git
然后checkout出想要的版本,这步可选。这里我checkout出了2.10版,因为传说最新monodroid用的就是这个版本
$ git checkout remotes/origin/mono-2-10
接着按README上的方法编译安装:
$ ./configure --prefix=/usr/local
$ make 
$ make install
然后就可以用了,mono/mono/tests下有很多例子,随便拿一个试试:
$ mcs threadpool.cs
$ /usr/local/bin/mono threadpool.exe

二、源码结构
这里仅列举几个重要的目录:
mcs:
    mcs: Mono实现的基于Ecma标准的C#编译器。
    class: CLI的C#级的实现。类似于Android中的Java层,应用程序看到的是这一层提供的接口。这一层是平台无关的。
    ilasm: 反汇编器,将Native code反汇编成bytecode。
mono:
    mini: JIT编译器,将bytecode编译成native code。
    metadata: Mono的runtime,CLI的Native级的实现。
    io-layer: 与操作系统相关的接口实现,像socket,thread,mutex这些。
libgc: GC实现的一部分。 

三、Mono主要工作框架
mini/main.c: main()
    mono_main_with_options()
        mono_main() 
            mini_init() 
            mono_assembly_open()
            main_thread_handler() // assembly(也就是bytecode)的编译执行
            mini_cleanup()
            
main_thread_handler()
    mono_jit_exec() 
        mono_assembly_get_image() // 得到image信息,如"test.exe"
        mono_image_get_entry_point() // 得到类,方法信息
        mono_runtime_run_main(method, argc, argv, NULL)
            mono_thread_set_main(mono_thread_current()) // 将当前线程设为主线程
            mono_assembly_set_main()
            mono_runtime_exec_main() // 编译及调用目标方法
            
mono_runtime_exec_main()
    mono_runtime_invoke(method, NULL, pa, exc) // 要调用的方法,如"ClassName::Main()"
        default_mono_runtime_invoke() // 实际上是调用了mono_jit_runtime_invoke()
            info->compiled_method = mono_jit_compile_method_with_opt(method) // 编译目标函数
            info->runtime_invoke = mono_jit_compile_method() // 编译目标函数的runtime wrapper
                mono_jit_compile_method_with_opt(method, default_opt, &ex)
            runtime_invoke = info->runtime_invoke
            runtime_invoke(obj, params, exc, info->compiled_method)  // 调用wrapper,wrapper会调用目标方法
            
mono_jit_compile_method_with_opt() 
    mono_jit_compile_method_inner() 
        mini_method_compile(method, opt, target_domain, TRUE, FALSE, 0) // 通过JIT编译给定方法
        mono_runtime_class_init_full() // 初始化方法所在对象
            method = mono_class_get_cctor() // 得到类的构造函数
            if (do_initialization) // 对象需要初始化
                mono_runtime_invoke() // 调用相应构造函数来构造对象,如"System.console:.cctor()"
                    mono_jit_runtime_invoke()


四、垃圾回收

垃圾回收(GC)是CLI中很重要的部分,针对这部分的开发仍然很活跃。现在默认的GC实现称为SGen(Simple Generational),除此之外的选择还有Boehm(http://jezng.com/2012/02/How-the-Boehm-Garbage-Collector-Works/),Boehm GC的基本思想是在malloc()时记录分配空间的元信息,然后在数据中保守地检查每个可能为指针的整数。其好处是只要截malloc()和free()两个接口即可,因此可被用于uncooperative环境(即C/C++这种指针和整数界限模糊的情况),缺点是由于做法保守可能会有垃圾无法被回收。另外Boehm中对象不能被移动,所以会有fragmentation。SGen的主要思想是将对象分为两个generation:较新的称为generation 0,较老的称为generation 1。这种设计是基于这样的一个事实:程序经常会申请一些小的临时对象,用完了马上就释放。而那些一段时间没释放的,往往很长时间都不会释放,如全局对象等。基于这个原则,SGen将GC分两个阶段:minor collection和major collection,分别用于回收nursery heap和major heap中的内存。

Minor collection(generation 0):又称为nursery collection,用来回收nursery heap(默认为4M)。采用的是copying collection。默认情况下,系统在启动时就申请了一块4M的连续内存,然后应用程序申请内存就从中申请,如果不够了就触发minor collection。发生minor collection时,第一步是找到哪些对象是还被引用的,那剩下的自然就是可以回收的“垃圾”了。如何找到那些还“活”着的对象呢?对象的引用关系可以抽象成一个图(准确地说是个“森林”)。首先是从一些特定对象(称为root)开始,找出它们引用的对象,然后继续找出这些这些被引用对象所引用的对象,以此类推,直到没有需要被遍历的对象为止。那么这些特定对象是什么呢?它们主要包含了静态成员引用,CPU寄存器,线程栈上的对象和runtime本身所引用的对象等。由于默认GC发生时是要"stop the world"的,因此CPU 寄存器会被压入栈中,这些寄存器中可能会有对对象的引用,这又衍生出两种对栈的扫描方法-conservative scan和precise scan。所谓conservative scan就是保守地把栈中的每个指针大小的值都认为是指针,一旦指向某个对象,那么那个对象就被认为是还活着的。precise scan中,寄存器中的对象引用和它们在栈上的位置都被记录下来,这样GC时就可以有的放矢了。扯远了,那么将这些对象标记完了之后做什么呢?对于被引用的对象,也就是”活”着的对象,它们被光荣地“升级”到genearation 1了。所谓的升级就是将这个对象从nursery heap拷到major heap。与此同时,引用它的对象也都得相应更新。Mono用了个trick,就是借用了对象起始处的虚函数表指针的后两位(因为虚函数表地址4 byte对齐,所以末两位必为0)的一位作为forwarding pointer(SGEN_OBJECT_IS_FORWARDED)的标志,即如果被置位,则该虚函数表指针指向的就不是虚函数表了,而是该对象在major heap中的新位置。其实有点想不通是,这样forwarding pointer自身还是在nursery heap中,时间长了不是也会引起fragmentation么。
Major collection(generation 1):回收major heap(这个大小可以是固定的也可以是动态分配的,固定的话默认为512M)。 这一阶段有几种实现-'marksweep','marksweep-fixed','marksweep-fixed-par'和'copying'  。mark&sweep是默认的实现。所谓mark&sweep,就是先mark然后sweep(废话)。mark阶段和minor collection无异。即从一些root对象开始,遍历所有它们引用的对象并且置标志位。完了以后,sweep阶段会线性扫描这些对象,将没有标志位的对象释放。如果这样就结束了,那自然会出现fragmentation的问题。所以实际上,当mark&sweep结束后,系统会检查每个块(major heap中的空间被分成固定大小的块)的碎片率,如果高于一定阀值,则标志该块以待copy压缩(实现在ms_sweep()中)。这种方案既不是每次GC时都去除全部fragmentation,也不是放任不管。可以看到,现实当中,好的设计往往需要折衷。

因为默认情况下GC发生时需要挂起其余进程,所以如果GC时间太长了就很影响用户体验。从上面的实现可以看出,nursery collection是轻量级的,很快就能完成,因此发生的频率也会高些,而major collection会慢得多,因此发生频率会低很多。当然,这都是针对被动调用GC而言的,也就是当申请对象内存不足时调用GC。除此之外,用户可以通过调用GC.Collect()或GC.Collect(GC.MaxGeneration)函数主动触发GC。

Mono中对GC的实现主要分为下面几步:
1. 初始化
mono_gc_init()
    mono_gc_base_init()
        mono_sgen_marksweep_init() // 设置major_collector里的一坨函数
        alloc_nursery() // 分配nursery heap
    mono_gc_init_finalizer_thread() // 起"Finalizer"线程,用来执行对象的Finalizer方法
        finalizer_thread()
            WaitForSingleObjectEx() // 阻塞住,等待finalizer_event
            ...

2. 申请内存,如
mono_object_allocate()
    ALLOC_OBJECT()
        mono_gc_alloc_obj()
            mono_gc_alloc_obj_nolock()
            
mono_gc_alloc_obj_nolock() 
    if (size > MAX_SMALL_OBJ_SIZE) { // 大对象,直接从OS申请
        mono_sgen_los_alloc_large_inner()
    } else {
        p = (void **)TLAB_NEXT; //尝试从TLAB中申请, TLAB(thread  local  allocation  buffer)是为了避免原子操作损耗而为每个线程从nursery heap中预先分配的线程私有缓存,默认为4K
        if (new_next < TLAB_TEMP_END) {  // 从TLAB申请成功
            return p;
        }

        if (TLAB_NEXT >= TLAB_REAL_END)    { // TLAB分配不了
            if (size > tlab_size) { // 要分配的空间比整个TLAB还大,只能nursery heap里分配了
                if (!search_fragment_for_size(size)) 
                    minor_collect_or_expand_inner(size) // 空间不够,调用GC
                p = (void *)nursery_next; //从nursery heap中分配
                nursery_next += size    
            } else { // 要分配的空间比TLAB最大容量小,TLAB虽分配失败但有希望分配
                if (alloc_size >= available_in_nursery) {// nursery heap中空间不够分配TLAB的
                    minor_collect_or_expand_inner(tlab_size) // 调用GC,整出TLAB大小的nursery heap空间
                } 
                // 从nursry heap中分配TLAB
                TLAB_START = nursery_next;
                ...
                // 再从TLAB分配空间
                p = (void *)TLAB_NEXT;
            }
        }
        return p; 
    }

3. 接下来就是高潮(GC)了:) 大概框架是先看看nursery collection能不能搞定,不行就换猛料major collection。

 

minor_collect_or_expand_inner()
    stop_world(0) // stop the world
    if (collect_nursery()) {
        major_collect() // minor collection不给力
            major_do_collection()    
    }
    restart_world(0) // 将其它线程唤醒, Finalizer线程随之开始工作

Minor collection主要流程:
collect_nursery()
    gray_object_queue_init()  // 广度优先遍历时需要用的列队
    // 从各种root开始广度优先遍历,所到之处就copy到major heap,然后压队列
    pin_from_roots() 
    scan_from_remsets()
    if (use_cardtable)
        scan_from_card_tables()
    scan_from_registered_roots()
    scan_from_registered_roots()
    
    finish_gray_stack() 
        finalize_in_range() // 将finalization queue里的对象也作为root
            queue_finalization_entry()  // 将“死”对象加入finalization queue,以待销毁
    build_nursery_fragments() // 建立起free list(nursery_fragments)以备下次申请内存时使用
    
    if (fin_ready_list || critical_fin_list)
        mono_gc_finalize_notify() //发信号给Finalizer线程,让阻塞等待信号的Finalizer线程开始工作
        
    // 看看nursery collection完了以后是不是满足内存申请需求
    need_major = need_major_collection(0) || objects_pinned
    return need_major;
    
major collection和minor collection做的事情类似,各种scan函数用的也是同一个,只是copy这个函数的行为不一样了,这通过将copy函数作为参数t加以区分(major_collector.copy_object和major_collector.copy_or_mark_object)来实现。
major_do_collection()
    // 和minor collection类似,root集合出发,广度优先遍历,标记“活”对象。这步即为mark阶段。
    ...
    major_collector.sweep() // 这步为sweep阶段
    
4. finalizer_thread() // 初始化时是被阻塞起来等待事件的,上面collection完了发事件后该线程会继续执行
    mono_gc_invoke_finalizers()
        while (fin_ready_list || critical_fin_list) { // finalization queue有对象要处理
            finalize_entry_set_object()
            mono_gc_run_finalize()
                runtime_invoke() // 调用对象的Finalize()方法
        }

上面的GC实现看起来似乎已经简单而华丽地解决了垃圾回收问题,但现实往往不会这般完美,总会有些不尽如人意的方面需要解决,如下面几点:
* Finalization:我们知道,C#中的Finalizer()方法(CLI生成,用户只能提供析构函数)用于销毁对象。Finalizer方法会调用析构函数,析构函数一般又会调Dispose函数。Dispose函数销毁对象中的unmanaged对象(如文件句柄,窗口句柄,socket连接或数据库连接等)。假设某类没有定义自己的Finalizer,那很好,GC在nursery collection阶段发现该对象没有被引用就可回收它。但如果不是这样,就麻烦得多了。GC会将该对象放到finalization queue里等待其Finalizer被调用。因为finalization queue里的对象还未被真正回收,因此它们此刻还是“活”的,因此它们会被升级成generation 1而且放到major heap,同时它们也作为下次collection时root的一部分。这个队列里的东西不是马上处理的,而是在单独的Finalizer线程(源码中为gc_thread,执行的线程函数为finalizer_thread())里处理的,所以放在这个队列里面的对象命运有两种:一种是处理的时候又被引用了,于是它们又“复活”(Resurrection)了。另一种就是还是没被引用,于是对象的Finalizer方法被执行,对象被销毁并标志清除。可以看到,有Finalizer的对象生命周期变长了,这会影响内存使用效率,另外Finalizer什么时候被调用难以控制,两个对象的Finalizer方法的执行顺序也难以保证,所以很多地方都建议能不要用Finalizer就不要用。那如果对象中有unmanaged对象咋办?有一个办法是写Dispose函数然后用完对象后手动调用,接着调用System.GC.SuppressFinalize (this),这样该对象就不会到finalization queue里去了。

Mono中Finalization的流程大体是:
mono_object_new_alloc_specific() // 对象在创建时就注册Finalizer
    if (vtable->klass->has_finalize) // 对象是否有Finalizer
        mono_object_register_finalizer() // 注册
            ...
                register_for_finalization() //将该对象的Finalizer放到全局哈希表中(minor_finalizable_hash或major_finalizable_hash)

GC发生时会将上面哈希表对象中“死”的那些放到finalization queue中:
finalize_in_range()
    if (object is dead)
        queue_finalization_entry() // 将对象放到finalization queue(critical_fin_list或fin_ready_list)中

GC末期会发事件给Finalizer线程让其处理finalization queue上的对象。
    
* Pinned object
pin住的对象,可以简单地理解为位置不能移动的对象。那么什么情况下会产生这种对象呢?一种是C#程序员显式标为fixed(fixed Statement: http://msdn.microsoft.com/en-us/library/f58wzh21%28v=vs.80%29.aspx)的对象,另一种是当对象传给unmanaged code时,因为Mono无法预测unmanaged code会对该对象作何种引用,因此不能轻易移动对象位置。对这类顽固派对象只能让它们pin在那,然后烧高香下次GC之前它们已经被unpin了-_-

* 大对象(Large object)
SGen中把超过8K bytes(SGEN_MAX_SMALL_OBJ_SIZE)的对象作为large object。这些对象就不走前面说的nursery和major heap了,而是直接从OS申请,用完后释放回OS了。实现参见mono_sgen_los_alloc_large_inner()。

* Write barrier
首先,这和编译器优化中的write barrier指令没有半毛钱关系。这个名字非常confusing的东西是为了解决GC中的一个问题:即major heap中的对象A引用nursery heap中的对象B咋办?这个引用可能在A刚被整到major heap时还木有,是后来才有的。因为如果当时就有,那么对象B也会被整到major heap中,也就没这个问题了。那要解决这个问题,很自然地想到,得在major heap中对象A引用nursery heap中对象B时,记录这种行为,日后GC才可以用这个信息保证nursery heap中的对象不会被误删。


Mono中提供了两种解决方案:Cardtable和Remset,目前默认采用Cardtable。首先介绍Remset,其工作原理很简单,当上面所说这种情况发生时,将这种新加的引用记录在全局的RememberedSet中,在GC时,将这集合中的元素也作为root的一部分即可。Cardtable更加粗粒度,它把major heap分为固定大小的块(SGen中定为512 bytes,分块的好处是我们能用bitmap记录其状态),称为card,然后只记录该card中是否有对象引用了nursery,有则置上标记位。这样的好处是提高了效率,坏处是不能准确地知道是哪个对象引用了nursery heap中的对象。接下来的故事和Remset差不多,nursery collection发生时,扫描这些有标记位的块,将它们引用的nursery heap中的对象移到major heap中(即generation 0升级到generation 1),然后将所有标志位清零,以备下一次使用。这种设计再一次体现了“好的设计需要折衷”这句老话。

* 并行优化
默认GC需要挂起除自己外的线程,直到整个GC过程结束。如果垃圾甚多,则会影响用户体验。如何尽可能减小其影响呢?我们知道major collection主要分为两个过程Mark和Sweep。Mono中分别对其进行了优化:一个是parallel Mark,另一个是concurrent Sweep。前者是基于每个线程的root都包含了TLS的对象,因此可以并发遍历扫描,对于那些共享的对象,只要有一个线程mark它,那就mark它。后者是基于mark完了之后,那些“垃圾”对象就被置上位,不会被使用了,那么sweep这个过程就可以和应用程序一起执行,互不干扰了。

五、托管代码和非托管代码的相互调用 
在CLI之上的如C#的产生的bytecode(CLI code)我们称之为managed code,是由虚拟机的JIT编译执行的,其中的对象无需手动释放,由GC管理。而C/C++写的以binary形式存在的code称为unmanaged code,其中的对象虚拟机无法track。这就像Android中的Java和Native code的区别。Java的bytecode跑在dalvik虚拟机上,而Native的code而直接跑在bare metal上。为了访问底层资源,Java中的接口很多最终还是要通过JNI调到Native code中来。Mono的框架其实也是类似的,CLI 代码要实现平台相关的调用或是调用已有的Native library,最终还是要通过一套类似于JNI的接口。

一般地,从托管到非托管代码的调用有两种方法:
* Internal call(icall):CLI中的很多C#实现最终就会以这种方式调到Mono的runtime中来。通过这种方式,Native端函数的参数只能使用Mono定义的类型。因此适用于CLI调用Mono runtime的情况。对于预定义的Internal call(metadata/icall-def.h),它们的信息记录在一系列静态数组(如icall_type_name, icall_names)中,由于icall-def.h中的类名和方法名都是字典排序的,因此查找时用二分法即可。对于其它的Internal call,可以用mono_add_internal_call()注册。该函数把指定的Internal call放入哈希表(icall_hash,启动时在mono_icall_init()中创建)中。
* P/invoke:在这种调用方式中,函数参数会被Marshal(即managed code转为unmanaged code中的等价对象)。前面的Internal call由于主要用于Mono内部调用,因此参数类型都可以是Mono的内部类型,调用者和被调用者都理解。但如果是外部库,里面都是C/C++的类型,这时就要做一层参数转化了,即前面说的Marshal。关于P/Invoke可以参见http://www.mono-project.com/DllImport

相反地,从非托管代码到托管代码一般是通过mono_runtime_invoke(),官方文档给了个例子:
clazz = mono_object_get_class (obj);  
method = mono_class_get_method_from_name (clazz, "Add", 1); 
mono_runtime_invoke (method, obj, args, &exception);
流程是不是和JNI很像?只是名字换了下而已。
    
六、线程池
应用程序或者Mono runtime中的一些异步任务可以交由单独线程完成。Mono中提供了两个线程池:async_tp和async_io_tp。往线程池里加线程的函数为threadpool_append_jobs(),当第一次试图往里边加线程时,会进行初始化,即起一个"Monitor"线程(该线程执行monitor_thread())。这个Monitor线程是做什么用的呢?一会儿会用到。现在假设程序调用了System.Threading.QueueUserWorkItem(),Mono要为其创建线程,于是调用threadpool_append_jobs(),但其实这时还不是真正创建了目标线程,只是加入到线程池队列(async_tp->queue或async_io_tp->queue)中而已。然后前面创建的Monitor线程会检查线程池队列,如果需要,这时候再创建线程。源码中流程如下:

当需要添加新线程执行任务时:
icall_append_job()/threadpool_thread_job() 
    threadpool_append_jobs()
        if (tp->pool_status == 0) // 未初始化
            mono_thread_create_internal() // 创建"Monitor"线程
        mono_cq_enqueue() // 加入到线程池队列

Monitor线程会从线程池队列中取出线程信息,然后创建线程:
monitor_thread()
    for (async_tp and async_io_tp)
        if (mono_cq_count(tp->queue) > 0) // 有线程需要创建
            threadpool_start_thread(); // 创建线程

一些参数资料
Working With SGen: http://www.mono-project.com/Working_With_SGen
Garbage Collection: http://msdn.microsoft.com/en-us/library/vstudio/0xy59wtx%28v=vs.100%29.aspx
Interop with Native Libraries: http://www.mono-project.com/DllImport
Generational GC: http://www.mono-project.com/Generational_GC#Fixed_Heap
Embedding Mono: http://www.mono-project.com/Embedding_Mono
Mostly Software: http://schani.wordpress.com/tag/mono/
Implementing a Dispose Method: http://msdn.microsoft.com/en-us/library/fs2xkftw.aspx
Implementing Finalize and Dispose to Clean Up Unmanaged Resources: http://msdn.microsoft.com/en-us/library/vstudio/b1yfkh5e%28v=vs.100%29.aspx
OpCodeEmulation: https://monoruntime.wordpress.com/tag/icall/
Debugging: http://www.mono-project.com/Debugging
Dtrace: http://www.mono-project.com/SGen_DTrace
Marshalling In Runtime: https://monoruntime.wordpress.com/tag/runtime-invoke-wrapper/
Performance Tips:http://www.mono-project.com/Performance_Tips