1. 程式人生 > >Mono概述及部分原始碼解析

Mono概述及部分原始碼解析

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

長期以來.NET框架都被認為是Windows下的專利,而Mono可以讓.NET程式跑在其它的平臺(如Linux, OS X等)上。近幾年由於移動裝置的興起,Mono的衍生專案(MonoTouch和Mono for Android)還可以讓基於.NET框架的程式輕易的移植到Android和iOS裝置上,這讓原本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