ajax|程式
隨著AJAX範例得到越來越廣泛的應用,瀏覽器頁面可以在向後台伺服器請求資料的同時保持前端使用者介面的活躍性(因此在AJAX中稱為非同步)。然而,當這兩個活動同時訪問共用的JavaScript和DOM資料結構時就會引發問題。JavaScript沒有提供針對該並發程式問題的經典解決方案。本文描述了作者在互斥機制方面的新見解,該經過驗證的互斥機制在JavaScript中能發揮良好的作用。
為什麼需要互斥?
當多個程式邏輯線程同時訪問相同資料的時候,問題便產生了。程式通常假定與其互動的資料在互動過程中不發生改變。訪問這些共用資料結構的代碼稱為臨界區,一次只允許一個程式訪問的機制被稱為互斥。在AJAX應用程式中,當對來自XMLHttpRequest的應答進行非同步處理的代碼同時操縱正在被使用者介面使用的資料時,便會發生這種情況。這個共用的資料可能是用於實現MVC資料模型的JavaScript和/或web頁面自身的DOM。如果二者中的任一個對共用資料做了不協調的更改,那麼二者的邏輯都將中斷。
也許您會說“等等,為什麼我沒有遇到過這種問題?”。遺憾的是,這種問題是同步依賴的(也叫做競態條件),因此它們並不總是發生,或者也許從不發生。它們的或然性基於許多因素。基於健壯性考慮,富internet應用程式應該通過確保這些問題不會發生來阻止出現這種情況。
因此,需要一種互斥機制來確保同時只能開啟一個臨界區,並且在它結束之後才能開啟另一個。在大多數主StreamCompute機語言和執行架構中,都提供互斥機制(經常是幾種),但是應用於瀏覽器端的JavaScript卻沒有提供這種互斥機制。雖然存在一些無需專門的語言或環境支援的經典互斥實現演算法,但是即使這樣還是需要一些JavaScript和瀏覽器(如Internet Explorer)所缺少的要素。接下來介紹的經典演算法在這些瀏覽器和語言中能發揮良好的作用。
麵包店演算法
在電腦科學文獻中的幾種互斥演算法中,所謂的Lamport麵包店演算法可以有效地用於多個相互競爭的控制線程,該演算法中線程之間的通訊只能在共用記憶體中進行(即,不需要諸如訊號量、原子性的set-and-test之類的專門機制)。該演算法的基本思想源於麵包店,因為麵包店需要先取號然後等候叫號。清單1給出了該演算法的架構(引自Wikipedia),該演算法可以使各線程進出臨界區而不產生衝突。
清單1. Lamport麵包店演算法虛擬碼
// declaration & initial values of global variables
Enter, Number: array [1..N] of integer = {0};
// logic used by each thread...
// where "(a, b) < (c, d)"
// means "(a < c) or ((a == c) and (b < d))"
Thread(i) {
while (true) {
Enter [i] = 1;
Number[i] = 1 + max(Number[1],...,Number[N]);
Enter [i] = 0;
for (j=1; j<=N; ++j) {
while (Enter[j] != 0) {
// wait until thread j receives its number
}
while ((Number[j]!=0) && ((Number[j],j) < (Number[i],i))) {
// wait until threads with smaller numbers
// or with the same number, but with higher
// priority, finish their work
}
}
// critical section...
Number[i] = 0;
// non-critical section...
}
}
如上所示,該演算法假定各線程清楚自己的線程編號(常量i)和當前正在活動的線程總數(常量N)。此外,還假定存在一種等待或休眠方式,例如:暫時將CPU釋放給其他線程。遺憾的是,Internet Explorer中的JavaScript沒有這種能力。雖然如此,如果實際運行在同一線程上的多個代碼部分表現為各自運行在獨立的虛擬線程上,那麼該麵包店演算法不會中斷。同樣,JavaScript具有一種在指定延遲後調度函數的機制,所以,可以使用下面的這些方法來最佳化麵包店演算法。
Wallace變體
在JavaScript中實現Lamport麵包店演算法的主要障礙在於缺少線程API。無法確定當前正在哪個線程上運行以及當前正在活動的線程數目,也無法將CPU釋放給其他的線程,無法建立新的線程來管理其他線程。因此,無法查證如何將特定的瀏覽器事件(例如:單擊按紐、可用的XML應答等)分配到線程。
克服這些障礙的一種方法是使用Command設計模式。通過將所有應該進入臨界區的邏輯以及所有啟動該邏輯所需的資料一起放入到command 對象中,可以在負責管理command的類中重寫麵包店演算法。該互斥類僅在沒有其他臨界區(封裝為獨立的command對象方法)在執行時調用臨界區,就像它們各自運行在不同的虛擬線程中一樣。JavaScript的setTimeout()機制用於將CPU釋放給其他正在等待的command。
為command對象假定一個簡單的基類(見清單2中的Command),可以定義一個類(見清單3中的Mutex)來實現麵包店演算法的Wallace變體。注意,雖然可以通過很多方式在JavaScript中實現基類對象(為了簡潔起見,這裡使用一種簡單的方式),但是只要各個command對象擁有某個惟一的id,而且整個臨界區被封裝在單獨的方法中,那麼任何對象模式都可以使用這種方法。
清單2. 用於 Command 對象的簡單基類
1 function Command() {
2 if (!Command.NextID) Command.NextID = 0;
3 this.id = ++Command.NextID;
4 // unsynchronized API
5 this.doit = function(){ alert("DOIT called"); }
6 this.undo = function(){ alert("UNDO called"); }
7 this.redo = function(){ this.doit(); }
8 // synchronized API
9 this.sDoIt = function(){ new Mutex(this,"doit"); }
10 this.sUnDo = function(){ new Mutex(this,"undo"); }
11 this.sReDo = function(){ new Mutex(this,"redo"); }
12 }
Command類示範了三個臨界區方法(見5-7行),但是只要預先將對該方法的調用封裝在Mutex中(見9-11行),那麼就可以使用任何方法。有必要認識到,常規方法調用(例如非同步的方法調用)與同步方法調用之間存在著重要的區別:具有諷刺意味的是,必須保證同步方法不同步運行。換句話說,當調用sDoIt()方法時,必須確保方法doit()還未運行,即使方法sDoIt()已經返回。doit()方法可能已結束,或者直到將來的某一時間才開始執行。也就是說,將對Mutex的執行個體化視為啟動一個新的線程。
清單3.作為類 Mutex實現的 Wallace 變體
1 function Mutex( cmdObject, methodName ) {
2 // define static field and method
3 if (!Mutex.Wait) Mutex.Wait = new Map();
4 Mutex.SLICE = function( cmdID, startID ) {
5 Mutex.Wait.get(cmdID).attempt( Mutex.Wait.get(startID) );
6 }
7 // define instance method
8 this.attempt = function( start ) {
9 for (var j=start; j; j=Mutex.Wait.next(j.c.id)) {
10 if (j.enter
11 || (j.number && (j.number < this.number ||
12 (j.number == this.number
13 && j.c.id < this.c.id))))
14 return setTimeout
15 ("Mutex.SLICE("+this.c.id+","+j.c.id+")",10);
16 }
17 //run with exclusive access
18 this.c[ this.methodID ]();
19 //release exclusive access
20 this.number = 0;
21 Mutex.Wait.remove( this.c.id );
22 }
23 // constructor logic
24 this.c = cmdObject;
25 this.methodID = methodName;
26 //(enter and number are "false" here)
27 Mutex.Wait.add( this.c.id, this );
28 this.enter = true;
29 this.number = (new Date()).getTime();
30 this.enter = false;
31 this.attempt( Mutex.Wait.first() );
32 }
Mutex類的基本邏輯是將每個新的Mutex執行個體放入主等待清單,然後將其在等待隊列中啟動。因為每次到達“隊首”的嘗試都需要等待(除了最後一次),所以使用setTimeout來調度每次在當前嘗試停止的位置啟動的新嘗試。到達隊首時(見17行),便實現了互斥性訪問;因此,可以調用臨界區方法。執行完臨界區後,釋放互斥性訪問並從等待清單中移除Mutex執行個體(見20-21行)。
Mutex建構函式(見23-31行)記錄其Command對象和方法名參數,然後寄存在一個運行中臨界區的稀疏數組中(Mutex.Wait),這通過清單4中所示的Map類來實現。然後建構函式獲得下一個編號,並在隊尾開始排隊。由於等待編號中的間隔或副本不存在問題,所以實際上使用當前的時間戳記作為下一個編號。
attempt()方法將初始虛擬碼中的兩個wait迴圈組合成一個單獨的迴圈,該迴圈直到隊首時才對臨界區失效。該迴圈是一種忙碌-等待迴圈檢測方式,可以通過在setTimeout()調用中指定延遲量來終止該迴圈。由於setTimeout需要調用“無格式函數”,所以在第4-6行定義了靜態協助器方法(Mutex.SLICE)。SLICE在主等待清單中尋找指定的Mutex對象,然後調用其attempt()方法,用start參數指定到目前為止其所獲得的等待清單的長度。每次SLICE()調用都像獲得了“一塊CPU”。這種(通過setTimeout)適時釋放CPU的協作方式令人想到協同程式。
清單4. 作為 Map資料結構實現的稀疏數組
function Map() {
this.map = new Object();
// Map API
this.add = function( k,o ){
this.map[k] = o;
}
this.remove = function( k ){
delete this.map[k];
}
this.get = function( k ){
return k==null ? null : this.map[k];
}
this.first = function(){
return this.get( this.nextKey() );
}
this.next = function( k ){
return this.get( this.nextKey(k) );
}
this.nextKey = function( k ){
for (i in this.map) {
if ( !k ) return i;
if (k==i) k=null; /*tricky*/
}
return null;
}
}
富Internet應用程式整合
由於Mutex所處理的線程(虛擬或者非虛擬)數量是動態變化的,所以可以確定一個基本事實:無法通過像瀏覽器為各個瀏覽器事件分配單獨的線程那樣的方式來獲得線程標識符。這裡做了一個類似的假定,那就是每個完整的事件處理常式組成一個完整的臨界區。基於這些假定,每個事件處理函數都可以轉變成一個command對象,並使用Mutex對其進行管理。當然,如果未將代碼明確組織成事件處理函數,那麼將需要重構。換句話說,不是直接在HTML事件屬性中進行邏輯編碼(例如:和function FOO(){++var;})。
清單5. 使用了非同步事件處理常式的樣本web頁面
<html>
<script language="JavaScript">
function newState(){
if (XMLreq.readyState==4) processReply();
}
function requestData(){
...set up asynchronous XML request...
XMLreq.onreadystatechange = newState;
...launch XML request...
}
function processReply(){
var transformedData = ...process data to HTML...
OutputArea.innerHTML = transformedData + "<br>";
}
function clearArea(){
OutputArea.innerHTML = "cleared<br>";
}
</script>
<body >
<input type="button" value="clear" >
<div id="OutputArea"/>
</body>
</html>
例如,假設有三個事件處理常式函數,它們操縱清單5所示的共用資料。它們處理頁面載入事件、單擊按鈕事件和來自XML請求的應答事件。頁面載入事件發出某個非同步請求來要求擷取資料並指定請求-應答事件處理常式,該處理常式處理接收到的資料,並將其載入到共用資料結構。單擊按鈕事件處理常式也影響共用資料結構。為了避免這些事件處理常式發生衝突,可以通過清單6所示的Mutex將它們轉變成command並加以調用(假設JavaScript include檔案mutex.js中包含Map和Mutex)。注意,雖然可以使用優美的類繼承機制來實現Command子類,但是該代碼說明了最簡單的方法,該方法僅需要全域變數NEXT_CMD_ID。
清單6. 轉化為同步事件處理常式的web頁面
<html>
<script src="mutex.js"></script>
<script language="JavaScript">
function requestData (){
new Mutex(new RequestDataCmd(),"go"); }
function processReply(){
new Mutex(new ProcessReplyCmd(),"go"); }
function clearArea (){
new Mutex(new ClearAreaCmd(),"go"); }
function newState (){
if (XMLreq.readyState==4) processReply(); }
var NEXT_CMD_ID = 0;
function RequestDataCmd(){
this.id = ++NEXT_CMD_ID;
this.go = function(){
...set up asynchronous XML request...
XMLreq.onreadystatechange = NewState;
...launch XML request...
}
}
function ProcessReplyCmd(){
this.id = ++NEXT_CMD_ID;
this.go = function(){
var transformedData = ...process data to HTML...
OutputArea.innerHTML = transformedData + "<br>";
}
}
function ClearAreaCmd(){
this.id = ++NEXT_CMD_ID;
this.go = function(){
OutputArea.innerHTML = "cleared<br>"; }
}
</script>
<body >
<input type="button" value="clear" >
<div id="OutputArea"/>
</body>
</html>
已經通過Mutex將這三個事件處理常式函數轉變為調用它們的初始邏輯(當前都被預封裝於command類中)。各個command類定義一個獨特的標識符和一個包含臨界區邏輯的方法,從而滿足了command介面的要求。
結束語
藉助於AJAX和RIA,構建複雜的動態使用者介面的推動力正在促使開發人員使用先前與胖GUI用戶端緊密聯絡的設計模式(例如:模型-視圖-控制器)。隨著視圖和控制器的定義模組化,且每一個都帶有自己的事件和事件處理常式(除了共用資料模型),發生衝突的機率成倍提高。通過把事件處理邏輯封裝到Command類中,不僅可以使用Wallace變體,而且為提供豐富的撤消/重做功能、指令碼編寫介面和單元測試工具創造了條件。