第二、__declspec(dllexport)定義的導(dǎo)出多用于同一編譯器的隱式鏈接(靜態(tài)調(diào)用),而.def導(dǎo)出函數(shù)可以確定導(dǎo)出的函數(shù)名不會因為不同的編譯器而不同,可用于其它開發(fā)工具的調(diào)用。
有了上面的知識,我們再看JNI環(huán)境下的問題。
JNI定義了關(guān)鍵字JNIEXPORT,用于實現(xiàn)DLL中函數(shù)的導(dǎo)出的。實際在JNI中,JNIEXPORT被定義為,#define JNIEXPORT
__declspec(dllexport),也就是說JNI默認(rèn)的導(dǎo)出函數(shù)使用dllexport方式。我們知道,使用使用dllexport方式產(chǎn)生的導(dǎo)出函數(shù)名會根據(jù)編譯器發(fā)生變化,在這種情況下,當(dāng)Java程序通過Native接口調(diào)用DLL本地方法時,可能會發(fā)生找不到導(dǎo)出函數(shù)的問題。所以,在JNI的情況下,因此最好是定義一個.def文件來指明導(dǎo)出函數(shù),以避免發(fā)生UnSatisfiedLinkedException錯誤 。
Windows Native的c++應(yīng)用大量使用了DLL技術(shù)。"動態(tài)鏈接"這幾字指明了DLLs是如何工作的。對于常規(guī)的函數(shù)庫,鏈接器從中拷貝它需要的所有庫函數(shù),并把確切的函數(shù)地址傳送給調(diào)用這些函數(shù)的程序。而對于DLLs,函數(shù)儲存在一個獨立的動態(tài)鏈接庫文件中。在創(chuàng)建Windows程序時,鏈接過程并不把DLLs文件鏈接到程序上。直到程 序運行并調(diào)用一個DLLs中的函數(shù)時,該程序才要求這個函數(shù)的地址。此時Windows才在DLLs中尋找被調(diào)用函數(shù),并把它的地址傳送給調(diào)用程序。采用這種方法,DLLs達到了復(fù)用代碼的極限。
對于DLL, 關(guān)鍵一點是,所有run on windows system 的程序可以共用同一個DLL庫,從而達到最大限度的代碼復(fù)用。并且,由于DLL并不拷貝它需要的所有庫函數(shù) , 這樣的話Native的C++程序 executable image size 會比較小。
從modularity的角度,如果要在Java的應(yīng)用里尋找相對應(yīng)的DLL的概念,我們會自然地想到jar包。JAR包可以被 Class Loader動態(tài)裝載進JVM, 不過要幾點區(qū)別需要說明的是:
第一、從本質(zhì)上來講,JAR包是存在于磁盤上的一些data而已(由JVM解釋執(zhí)行),而DLL是executable 的image。
第二、Class Data Sharing (CDS)作為一個新的feature,在Java5才被引入,其做法就是:把 system jar 文件打包成為"shared archive",這些"shared archive"會作為memory-mapped in文件存在,共享于不同的JVM 進程間,以減少JVM的footprint,加快Java應(yīng)用的啟動時間。
值得一提的是:兩者都有所謂的HELL問題(JAR HELL vs DLL HELL),新老版本的兼容問題始終讓人頭疼。
詳見解釋:
http://en.wikipedia.org/wiki/DLL_hell
http://en.wikipedia.org/wiki/JAR_hell#JAR_hell
文章將從兩個方面進行闡述:
類TextWindow繼承與類Window。我想程序運行的結(jié)果,大多數(shù)熟悉C++的人都會知道,
win.oops()最終調(diào)用的是父類oops函數(shù),而tWin->oops()調(diào)用的是子類TextWindow的函數(shù)。
通過這個例子,我們先總結(jié)一下c++中的多態(tài)調(diào)用規(guī)則:
第一、對于指針和引用類型,當(dāng)消息調(diào)用的成員函數(shù)有可能被重寫時,最終被選擇調(diào)用的成員函數(shù)由消息接收者的動態(tài)類型確定(注意:在OO概念中,對某個對象成員函數(shù)進行調(diào)用,常常稱為給該對象發(fā)送消息,該對象就是消息的接收者)。
如上例:tWin->oops(),由于tWin的動態(tài)類型是子類TextWindow,而不是Windows,所以tWin->oops()調(diào)用的是子類TextWindow的函數(shù)。
第二、對于其它的變量,對虛擬函數(shù)調(diào)用綁定完全由該變量的靜態(tài)類型確定(即該變量的聲明),而不是該變量的真實類型確定。
如上例:win.oops(),由于win的聲明類型為Window類,所以其結(jié)果調(diào)用的是父類oops函數(shù)。
二) 探討。
接下來,我們要看的問題是,在c++中,為什么對于多態(tài)規(guī)則(或者說是動態(tài)函數(shù)綁定規(guī)則),做出了兩中不同的劃分,即:只有指針與引用類型,才進行函數(shù)的后期動態(tài)綁定,也就是多態(tài)。這或許也是許多c++初學(xué)者非常迷惑的地方。這種規(guī)則的不一致性,的確給c++的語法造成一定的復(fù)雜性。而這在Java,或者C#中是沒有的,后面我們會涉及到。
我們先來看例子。
在這里,如果我們假設(shè),c++的函數(shù)動態(tài)綁定規(guī)則是一致的,看看會發(fā)生什么問題???
現(xiàn)在win被聲明為Window類型,然而其真實的類型為TextWindow(因為win=*tWinPtr),由于我們的假設(shè),win現(xiàn)在是允許進行動態(tài)函數(shù)綁定的,所以當(dāng)執(zhí)行win.oops()時,實際上是調(diào)用子類TextWindow的成員函數(shù)。
現(xiàn)在,我們有必要來審視一下win變量的內(nèi)存布局。由于win變量是在棧上聲明的變量,其內(nèi)存也是從棧進行分配(這是c++從c語言那里繼承過來的優(yōu)良特質(zhì),從棧上分配內(nèi)存空間比動態(tài)分配內(nèi)存有更好的執(zhí)行速度),c++標(biāo)準(zhǔn)規(guī)定:給win變量分配內(nèi)存空間的大小,由其靜態(tài)的類型確定,即應(yīng)該是Window類所使用的內(nèi)存空間大小。在這種情況下,當(dāng)執(zhí)行win=*tWinPtr時,什么會發(fā)生?如下圖:
在默認(rèn)的拷貝構(gòu)造函數(shù)情況下,信息會出現(xiàn)丟失,這就是著名的slicing off現(xiàn)象。結(jié)果,變量cursorLocation在win的內(nèi)存空間里丟失了。然而,問題是:在我們假設(shè)下,我們要求win.oops()導(dǎo)致TextWindow的成員函數(shù)調(diào)用,而在這個函數(shù)中,訪問到的cursorLocation變量是不存在!win.oops()調(diào)用將導(dǎo)致內(nèi)存違例!
到這里,我們可以總結(jié)一下:c++標(biāo)準(zhǔn)基于的其特定的內(nèi)存分配規(guī)則,給出了以上,我們在前一節(jié)總結(jié)出的函數(shù)動態(tài)綁定規(guī)則。
三) 深入。
當(dāng)然,我們也可以說,c++也可以通過改變其內(nèi)存分配規(guī)則,來給出一個一致性的函數(shù)動態(tài)綁定規(guī)則。比如:可以考慮在給win變量分配內(nèi)存空間時,考慮其所有子類需求,然后分配最大數(shù)量的內(nèi)存空間給win變量。這種做法可行性很差,對于編譯器而言,需要掃描整個程序(確定該類的所有子類),才能確定最大的內(nèi)存空間是多少。在使用類庫或者框架的情況下,會引起整個類庫,框架的重新編譯,這是得不償失的!而這種做法,在oo的語言中,基本上是沒有的。這也是c++不得不基于其現(xiàn)有的內(nèi)存管理機制,而對多態(tài)規(guī)則作出的不一致的解釋。
對于這個c++現(xiàn)有的內(nèi)存管理機制,我們?nèi)绻麖牧硗饨嵌热ダ斫獾脑?是很合理的。當(dāng)win=*tWinPtr發(fā)生
時,我們可以類似地認(rèn)為:好比一個float類型的數(shù)賦給了一個interger類型的變量,其結(jié)果當(dāng)然是float的值被截斷了。
我們再來看其它語言,Java(或者C#)是怎么解決的。
最重要的一點是,在Java(C#)中只有引用的概念,所以在棧上聲明的類的變量,只需要分配一個指針大小的內(nèi)存空間就行了,而不需要給該變量分配空間來保存變量內(nèi)容本身,這其實就是我們現(xiàn)在看到的c++中指針和引用的情況。
當(dāng)我們編譯pd->f(10)操作時,編譯器報錯。按照我們常規(guī)的理解是:父類的函數(shù)void f(int x)與子類的函數(shù)void f(double*pd),由于參數(shù)類型不同,其函數(shù)簽名也是不一樣的,按照這樣的邏輯,在這個類繼承體系中,這兩個函數(shù)完全應(yīng)該是互不隱藏的,我們完全可以認(rèn)為是符合overloaded規(guī)則的兩個函數(shù)。
但是,在c++里,子類通過函數(shù)名字隱藏父類函數(shù),而不是通過函數(shù)簽名!c++給出的解釋也是合理的:試想一種情況:你使用了別人寫的類庫,繼承其中的某個類,寫了你自己的子類。
如上面的例子,你的子類就是Derived,而類庫中的父類就是Base.當(dāng)你根本不知道在父類中還有這樣一個f(int x)函數(shù)時,在調(diào)用子類Derived的f函數(shù)時,你犯了錯誤,參數(shù)類型傳成了int類型(或者不是你犯的錯誤,編譯器幫你自動轉(zhuǎn)化為int類型),結(jié)果是:程序可以正常運行,但是,執(zhí)行的結(jié)果卻不是你所期望的,是f(int x)調(diào)用,而不是你自己的實現(xiàn):f(double* pd)調(diào)用!
這就是c++為什么通過函數(shù)名字隱藏父類函數(shù)的原因。
說到這里,我們需要補充幾句:雖然c++在語言層面上給我們提供了這樣的保證,但是,子類hide父類的函數(shù),這是一個非常不好的設(shè)計。從OO的角度出發(fā),應(yīng)該講求的是Liskov Substitution Principle。即:suntypes must be substitutable fro their base types.很顯然,當(dāng)hide行為發(fā)生時,從接口的角度來講,子類與父類是不能互為替代的。父類的protected or public的方法,應(yīng)該很自然地由其所有子類所繼承,而不是被隱藏。隱藏行為的發(fā)生,相當(dāng)于在這套繼承體系中開的一個后門。很顯然,C++幫助我們自動隱藏了父類的方法,但是,作為程序開發(fā)的我們,應(yīng)該意識到這一點,也應(yīng)該避免這樣的設(shè)計。
二、c++的per-class allocator語法規(guī)則
在D&E of C++一書中,Stroustrup給出了幾點c++提供per-class allocator的理由,這些理由也是我們使用class level的allocator的原因,所以,有必要我們總結(jié)一下:
第一、許多程序應(yīng)用,需要在運行的過程中,大量地Create和Delete對象。這些對象,諸如:tree nodes,linked list nodes,messages等等。如果在傳統(tǒng)的heap完成這些對象的創(chuàng)建,銷毀,由于大量的內(nèi)存申請,釋放,勢必會造成內(nèi)存碎片。這種情況下,我們需要對內(nèi)存分配進行細(xì)粒度的控制。
第二、一些應(yīng)用需要長時間跑在內(nèi)存受限的裝置上,這也需要我們對內(nèi)存分配進行細(xì)粒度的控制,而不是無限制地分配,釋放。
主要基于以上的兩點,c++提供了per-class allocator語言支持。
如下例:
new操作符函數(shù)負(fù)責(zé)對象X的內(nèi)存分配。對這樣一個語法規(guī)則,我們好奇的是,為什么聲明了一個我們從來都不使用的參數(shù)size_t sz.我們的使用語法如下: X* px = new X;
C++也給出了解釋:per-class allocator機制將適用整個類的繼承體系。例如:
對于子類Y,其內(nèi)存分配函數(shù)也是X::operator new()。但是,在這里,內(nèi)存分配的大小,不應(yīng)該是sizeof(X),而是sizeof(Y).問題的關(guān)鍵在這里:C++通過提供多余的參數(shù)size_t sz,而給開發(fā)者提供了更大的靈活性,也即:per-class allocator是面向類的繼承體系的內(nèi)存管理機制,而不單單是面向單個類。
三、Koenig Lookup機制。
大家對Andrew Koenig應(yīng)該很熟悉,c++大牛,是AT&T公司Shannon實驗室大規(guī)模編程研究部門中的成員,同時他也是C++標(biāo)準(zhǔn)委員會的項目編輯。他擁有超過30年的編程經(jīng)驗,其中有15年的C++使用經(jīng)驗。
Koenig Lookup,就是以Andrew Koenig命名的查找規(guī)則。在看這個定義之前,我們先弄清楚函數(shù)所在的域的分類,一般來講,分為:
1:類域(函數(shù)作為某個類的成員函數(shù)(靜態(tài)或非靜態(tài)))
2:名字空間域
3:全局域(即C++默認(rèn)的namespace)
而Koenig Lookup機制,就是當(dāng)編譯器對無限定域的函數(shù)調(diào)用進行名字查找時,除了當(dāng)前名字空間域以外,也會把函數(shù)參數(shù)類型所處的名字空間加入查找的范圍。
如下例:
如上的代碼,使用operator<<操作符函數(shù),打印對象的狀態(tài),但是函數(shù)ostream& operator<<(ostream& out, const MyArg& myArg) 的定義域是處于名字空間Koenig中,為什么編譯器在解析main函數(shù)(全局域)里面的operator<<調(diào)用時,它能夠正確定位到Koenig名字空間里面的operator<<?這是因為根據(jù)Koenig查找規(guī)則,編譯器需要把參數(shù)類型MyArg所在的名字空間Koenig也加入對ostream& operator<<(ostream& out, const MyArg& myArg) 調(diào)用的名字查找范圍中。
如果沒有Koenig查找規(guī)則,我們就無法直接寫cout<<myArg;,而是需要寫類似Koenig::operator<<(std::cout, myArg); 這樣的代碼(使用完全限定名)。這樣的結(jié)果是,即不直觀也不方便。
其實在C++里,提供了很多類似于Koenig查找規(guī)則的機制,以保證程序語法上的簡潔,明了。例如:許多的操作符函數(shù),COPY構(gòu)造函數(shù)。而這些,也是我們寫出專業(yè)的C++程序的基本。
未完待續(xù):)
如圖所示:在IOCP中,主要有以下的參與者:
--》完成端口:是一個FIFO隊列,操作系統(tǒng)的IO子系統(tǒng)在IO操作完成后,會把相應(yīng)的IO packet放入該隊列。
--》等待者線程隊列:通過調(diào)用GetQueuedCompletionStatus API,在完成端口上等待取下一個IO packet。
--》執(zhí)行者線程組:已經(jīng)從完成端口上獲得IO packet,在占用CPU進行處理。
除了以上三種類型的參與者。我們還應(yīng)該注意兩個關(guān)聯(lián)關(guān)系,即:
--》IO Handle與完成端口相關(guān)聯(lián):任何期望使用IOCP的方式來處理IO請求的,必須將相應(yīng)的IO Handle與該完成端口相關(guān)聯(lián)。需要指出的時,這里的IO Handle,可以是File的Handle,或者是Socket的Handle。
--》線程與完成端口相關(guān)聯(lián):任何調(diào)用GetQueuedCompletionStatus API的線程,都將與該完成端口相關(guān)聯(lián)。在任何給定的時候,該線程只能與一個完成端口相關(guān)聯(lián),與最后一次調(diào)用的GetQueuedCompletionStatus為準(zhǔn)。
二、高并發(fā)的服務(wù)器(基于socket)實現(xiàn)方法
一般來講,實現(xiàn)基于socket的服務(wù)器,有三種實現(xiàn)的方式(thread per request的方式,我就不提了:)):
第一、線程池的方式。使用線程池來對客戶端請求進行服務(wù)。使用這種方式時,當(dāng)客戶端對服務(wù)器的連接是短連接(所謂的短連接,即:客戶端對服務(wù)器不是長時間連接)時,是可以考慮的。但是,如若客戶端對服務(wù)器的連接是長連接時,我們需要限制服務(wù)器端的最大連接數(shù)目為線程池線程的最大數(shù)目,而這應(yīng)用的設(shè)計本身來講,是不好的設(shè)計方式,scalability會存在問題。
第二、基于Select的服務(wù)器實現(xiàn)。其本質(zhì)是,使用Select(操作系統(tǒng)提供的API)來監(jiān)視連接是否可讀,可寫,或者是否出錯。相比于前一種方式,Select允許應(yīng)用使用一個線程(或者是有限幾個線程)來監(jiān)視連接的可讀寫性。當(dāng)有連接可讀可寫時,應(yīng)用可以以non-bolock的方式讀寫socket上的數(shù)據(jù)。使用Select的方式的缺點是,當(dāng)Select所監(jiān)視的連接數(shù)目在千的數(shù)量級時,性能會打折扣。這是因為操作系統(tǒng)內(nèi)核需要在內(nèi)部對這些Socket進行輪詢,以檢查其可讀寫性。另一個問題是:應(yīng)用必須在處理完所有的可讀寫socket的IO請求之后,才能再次調(diào)用Select,進行下一輪的檢查,否則會有潛在的問題。這樣,造成的結(jié)果是,對一些請求的處理會出現(xiàn)饑餓的現(xiàn)象。
一般common的做法是Select結(jié)合Leader-Follower設(shè)計模式使用。不過不管怎樣,Select的本質(zhì)造成了其在Scalability的問題是不如IOCP,這也是很多high-scalabe的服務(wù)器采用IOCP的原因。
第三、IOCP實現(xiàn)高并發(fā)的服務(wù)器。IOCP是實現(xiàn)high-scalabe的服務(wù)器的首選。其特點我們專門在下一小姐陳述。
三、IOCP開發(fā)的幾個概念
第一、服務(wù)器的吞吐量問題。
我們都知道,基于IOCP的開發(fā)是異步IO的,也正是這一技術(shù)的本質(zhì),決定了IOCP所實現(xiàn)的服務(wù)器的高吞吐量。
我們舉一個及其簡化的例子,來說明這一問題。在網(wǎng)絡(luò)服務(wù)器的開發(fā)過程中,影響其性能吞吐量的,有很多因素,在這里,我們只是把關(guān)注點放在兩個方面,即:網(wǎng)絡(luò)IO速度與Disk IO速度。我們假設(shè):在一個千兆的網(wǎng)絡(luò)環(huán)境下,我們的網(wǎng)絡(luò)傳輸速度的極限是大概125M/s,而Disk IO的速度是10M/s。在這樣的前提下,慢速的Disk 設(shè)備會成為我們整個應(yīng)用的瓶頸。我們假設(shè)線程A負(fù)責(zé)從網(wǎng)絡(luò)上讀取數(shù)據(jù),然后將這些數(shù)據(jù)寫入Disk。如果對Disk的寫入是同步的,那么線程A在等待寫完Disk的過程是不能再從網(wǎng)絡(luò)上接受數(shù)據(jù)的,在寫入Disk的時間內(nèi),我們可以認(rèn)為這時候Server的吞吐量為0(沒有接受新的客戶端請求)。對于這樣的同步讀寫Disk,一些的解決方案是通過增加線程數(shù)來增加服務(wù)器處理的吞吐量,即:當(dāng)線程A從網(wǎng)絡(luò)上接受數(shù)據(jù)后,驅(qū)動另外單獨的線程來完成讀寫Disk任務(wù)。這樣的方案缺點是:需要線程間的合作,需要線程間的切換(這是另一個我們要討論的問題)。而IOCP的異步IO本質(zhì),就是通過操作系統(tǒng)內(nèi)核的支持,允許線程A以非阻塞的方式向IO子系統(tǒng)投遞IO請求,而后馬上從網(wǎng)絡(luò)上讀取下一個客戶端請求。這樣,結(jié)果是:在不增加線程數(shù)的情況下,IOCP大大增加了服務(wù)器的吞吐量。說到這里,聽起來感覺很像是DMA。的確,許多軟件的實現(xiàn)技術(shù),在本質(zhì)上,與硬件的實現(xiàn)技術(shù)是相通的。另外一個典型的例子是硬件的流水線技術(shù),同樣,在軟件領(lǐng)域,也有很著名的應(yīng)用。好像話題扯遠(yuǎn)了,呵呵:)
第二、線程間的切換問題。
服務(wù)器的實現(xiàn),通過引入IOCP,會大大減少Thread切換帶來的額外開銷。我們都知道,對于服務(wù)器性能的一個重要的評估指標(biāo)就是:System\Context Switches,即單位時間內(nèi)線程的切換次數(shù)。如果在每秒內(nèi),線程的切換次數(shù)在千的數(shù)量級上,這就意味著你的服務(wù)器性能值得商榷。Context Switches/s應(yīng)該越小越好。說到這里,我們來重新審視一下IOCP。
完成端口的線程并發(fā)量可以在創(chuàng)建該完成端口時指定(即NumberOfConcurrentThreads參數(shù))。該并發(fā)量限制了與該完成端口相關(guān)聯(lián)的可運行線程的數(shù)目(就是前面我在IOCP簡介中提到的執(zhí)行者線程組的最大數(shù)目)。當(dāng)與該完成端口相關(guān)聯(lián)的可運行線程的總數(shù)目達到了該并發(fā)量,系統(tǒng)就會阻塞任何與該完成端口相關(guān)聯(lián)的后續(xù)線程的執(zhí)行,直到與該完成端口相關(guān)聯(lián)的可運行線程數(shù)目下降到小于該并發(fā)量為止。最有效的假想是發(fā)生在有完成包在隊列中等待,而沒有等待被滿足,因為此時完成端口達到了其并發(fā)量的極限。此時,一個正在運行中的線程調(diào)用GetQueuedCompletionStatus時,它就會立刻從隊列中取走該完成包。這樣就不存在著環(huán)境的切換,因為該處于運行中的線程就會連續(xù)不斷地從隊列中取走完成包,而其他的線程就不能運行了。
完成端口的線程并發(fā)量的建議值就是你系統(tǒng)CPU的數(shù)目。在這里,要區(qū)分清楚的是,完成端口的線程并發(fā)量與你為完成端口創(chuàng)建的工作者線程數(shù)是沒有任何關(guān)系的,工作者線程數(shù)的數(shù)目,完全取決于你的整個應(yīng)用的設(shè)計(當(dāng)然這個不宜過大,否則失去了IOCP的本意:))。
第三、IOCP開發(fā)過程中的消息亂序問題。
使用IOCP開發(fā)的問題在于它的復(fù)雜。我們都知道,在使用TCP時,TCP協(xié)議本身保證了消息傳遞的次序性,這大大降低了上層應(yīng)用的復(fù)雜性。但是當(dāng)使用IOCP時,問題就不再那么簡單。如下例:
三個線程同時從IOCP中讀取Msg1, Msg2,與Msg3。由于TCP本身消息傳遞的有序性,所以,在IOCP隊列內(nèi),Msg1-Msg2-Msg3保證了有序性。三個線程分別從IOCP中取出Msg1,Msg2與Msg3,然后三個線程都會將各自取到的消息投遞到邏輯層處理。在邏輯處理層的實現(xiàn),我們不應(yīng)該假定Msg1-Msg2-Msg3順序,原因其實很簡單,在Time 1~Time 2的時間段內(nèi),三個線程被操作系統(tǒng)調(diào)度的先后次序是不確定的,所以在到達邏輯處理層,
Msg1,Msg2與Msg3的次序也就是不確定的。所以,邏輯處理層的實現(xiàn),必須考慮消息亂序的情況,必須考慮多線程環(huán)境下的程序?qū)崿F(xiàn)。
在這里,我把消息亂序的問題單列了出來。其實在IOCP的開發(fā)過程中,相比于同步的方式,應(yīng)該還有其它更多的難題需要解決,這也是與Select方式相比,IOCP的缺點,實現(xiàn)復(fù)雜度高。
結(jié)束語:
ACE的Proactor Framework, 對windows平臺的IOCP做了基于Proactor設(shè)計模式的,面向?qū)ο蟮姆庋b,這在一定程度上簡化了應(yīng)用開發(fā)的難度,是一個很好的異步IO的開發(fā)框架,推薦學(xué)習(xí)使用。
Reference:
Microsoft Technet,Inside I/O Completion Ports