使用非同步程式設計

Ninja_Lu發表於2014-12-03

導言

現代的應用程式面臨著諸多的挑戰,如何構建具有可伸縮性和高效能的應用成為越來越多軟體開發者思考的問題。隨著應用規模的不斷增大,業務複雜性的增長以及實時處理需求的增加,開發者不斷嘗試榨取硬體資源、優化。

在不斷的探索中,出現了很多簡化場景的工具,比如提供可伸縮計算資源的Amazon S3Windows Azure,針對大資料的資料探勘工具MapReduce,各種CDN服務,雲端儲存服務等等。還有很多的工程實踐例如敏捷DDD等提供了指導。可以看到,將每個關注層面以服務的方式提供,成為了越來越流行的一種模式,或許我們可以激進的認為,這就是SOA

開發者需要將不同的資源粘合在一起來提供最終的應用,這就需要協調不同的資源。

我們可以設想一個大的場景,開發者正在開發的一個用例會從使用者的瀏覽器接收到請求,該請求會先從一個開放主機服務(OHS)獲取必要的資源res1,然後呼叫本機的服務s1對資源res1進行適應的轉換產生資源res2,接著以res2為引數呼叫遠端的資料倉儲服務rs1獲取業務資料bs1,最後以bs1為引數呼叫本機的計算服務calc並經過10s產生最終的資料。

簡單的用ASP.NET MVC 5表示就是這樣的(這些程式碼是我瞎掰的):

// notes: ASP.NET vNext changed MVC 5 usage, 
// ActionResult now became IActionResult
public IActionResult CrazyCase(UserData userData) {
    var ticket = CrazyApplication.Ticket;

    var ohsFactory = new OpenHostServiceFactory(ticket);
    var ohs = ohsFactory.CreateService();

    var ohsAdapter = new OhsAdapter(userData);

    var rs1 = ohs.RetrieveResource(ohsAdapter);
    var rs2 = _localConvertingService.Unitize(rs1);
    var bs1 = _remoteRepository.LoadBusinessData(rs2);
    var result = _calculationService.DoCalculation(bs1);

    return View(result);
}

這可能是中等複雜度的一個場景,但是相信開發者已經意識到了這其中所涉及的複雜度。我們看到每一步都是依賴於前者所產生的資料,在這樣一種場景之下,傳統的多執行緒技術將極度受限,並且最頂層的協調服務將始終佔用一個執行緒來協調每一步。

執行緒是要增加開銷的,尤其是上下文的轉換,別扯什麼執行緒池了,建立執行緒的開銷是節省了,上下文切換的開銷才是要命的。

經濟不景氣,能省點兒資源就省點兒吧。


所以我們該怎麼辦?縱向擴充套件給伺服器加多點記憶體?橫向擴充套件上負載均衡?別鬧了我們又不是民工,想問題不要太簡單粗暴。解決的辦法就是,非同步,而且我們這篇也只討論非同步這一種技術。

為什麼使用非同步

那麼,非同步的優勢在哪裡?這首先要和同步做一個對比。

還是開頭那個場景,示例程式碼所展示的是使用同步阻塞的方式來一步一步的執行,如下示意:

main) +++$----$------$--------$----------$+++
         |   /|     /|       /|         /
ohs )    $++$ |    / |      / |        /
              |   /  |     /  |       /
rs1 )         $++$   |    /   |      /
                     |   /    |     /
s1  )                $++$     |    /
                              |   /
calc)                         $++$

notes:
$ code point
+ thread busy
- thread blocked(means, wasted)

可以明顯的看到,當主執行緒發起各個service請求後,完全處於閒置佔用的狀態,所做的無非是協調任務間的依賴順序。這裡所說的佔用,其實就是CPU的時間片。

我們為什麼要等所有的子任務結束?因為任務間有先後順序依賴。有沒有更好的方式來規避等待所帶來的損耗呢?考慮一個場景,正上著班呢,突然想起要在網上買個東西,那麼開啟京東你就順利的下單了,事情並沒有結束,你不會等快遞的小哥給你送來東西以後再接著今天的工作吧?你會給快遞留下你的聯絡方式,讓他到了給你打電話(耗時的I/O任務),然後你繼續今天燒腦的程式設計任務(CPU密集型)。從人類的角度來看,這一定是最正常不過的,也就是要討論的非同步的方式。

一定有人會提議單開一個執行緒做收快遞的任務,我同意這是一種解決方案,但是如果用等效的人類角度的語言來說,就是你將大腦的資源分成了兩半,一半在燒腦程式設計,一半在盯著手機發呆,腦利用率下降太明顯。而用非同步的方式,你不需要關注手機,因為手機響了你就自然得到了通知。 當然,你也可以任性的說,我就喜歡等快遞來了再幹活。if so,我們就不要做朋友了。

所以我們可以有一個推論:非同步所解決的,就是節省低速的IO所阻塞的CPU計算時間。

轉換一下思路,我們使用非同步非阻塞的方式來構建這段業務,並藉助非同步思想早已深入人心的javascript語言來解釋,可以是這樣的:

// express

var ohs = require('./anticorruption/OpenHostService');
var localConvertingService = require('./services/LocalConverting');
var remoteRepository = require('./repositories/BusinessData');
var calculationService = require('./services/Calculation');

function(req, res) {
    var userData = req.body;

    // level1 nest
    ohs.retrieveResource(userData, function(err, rs1) {
        if(err) {
            // error handling
        }
        // level2 nest
        localConvertingService.unitize(rs1, function(err, rs2) {
            if(err) {
                // error handling
            }
            //level3 nest
            remoteRepository.loadBusinessData(rs2, function(err, bs1) {
                if(err) {
                    // error handling
                }
                //level4 nest
                calculationService.doCalculation(bs1, function(err, result) {
                    if(err) {
                        // error handling
                    }
                    res.view(result);
                });
            });
        });
    });
}

看著一層又一層的花括號也是醉了,我們之後會討論如何解巢狀。那麼這段程式碼所反應的是怎樣的事實呢?如下示意:

main) +++$                           $+++
          \                         /
ohs )      $++$                    /
               \                  /
rs1 )           $++$             /
                    \           /
s1  )                $++$      /
                         \    /
calc)                     $++$

notes:
$ code point
+ thread busy
- thread blocked(means, wasted)

由於非同步解放了原始的工作執行緒,使CPU資源可以不被執行緒的阻塞而被浪費,從而可以有效的提高吞吐率。

非同步的使用場景

技術和選擇和使用場景有著很大的關係,每項技術不都是銀彈,使用對的工具/技術解決對的問題是開發者的義務。

開發者最多關注的是計算密集和I/O密集這兩個維度,對於這兩個維度往往有著不同的技術選型。

計算密集型應用

何為計算密集型應用?下面兩個人畜皆知的函式都是計算密集型的。

// F#
let fibonacci n =
    let rec f a b n =
        match n with
        | 0 -> a
        | 1 -> b
        | n -> (f b (a + b) (n - 1))
    f 0 1 n

let rec factorial n = 
    match n with
    | 0 -> 1
    | n -> n * factorial (n - 1)

尤其是第二個階乘函式,如果在呼叫的時候不小心手抖多加了幾個0,基本上可以出去喝個咖啡談談理想聊聊人生玩一天再回來看看有沒有算完了。

簡而言之,計算密集型的任務是典型的重度依賴CPU/GPU,不涉及磁碟、網路、輸入輸出的任務。遊戲中場景渲染是計算密集的,MapReduce中的Reduce部分是計算密集的,視訊處理軟體的實時渲染是計算密集的,等等。

在這樣的場景之下,非同步是沒有太大的優勢的,因為計算資源就那麼多,不增不減,用多執行緒也好用非同步流也好,CPU永遠處於高負荷狀態,這病不能治,解決方案只能是:

  • 橫向的叢集方案
  • 縱向的升級主機CPU或採用更快的GPU
  • 優化演算法,使之空間/時間成本降低

但是有一種場景是可以考慮使用非同步的,考慮一個分散式的計算場,一個計算任務發起後,協調者需要等待所有的計算節點子結果集返回後者能做最後的結果化簡。那麼此時,雖然場景是計算密集的,但是由於涉及到任務的依賴協調,採用非同步的方式,可以避免等待節點返回結果時的阻塞,也可以避免多執行緒方式的上下文切換開銷,要知道在這樣的場景下,上下文切換的開銷是可以大的驚人的。

相似的場景還有,一個桌面應用,假設點選介面上一個按鈕之後會進行大量的計算,如果採用同步阻塞的方式,那麼當計算完成之前UI是完全阻塞的跟假死一樣,但是如何使用非同步的方式,則不會發生UI阻塞,計算在結束後會以非同步的方式來更新介面。還記得WinForm程式設計中的BeginInvokeEndInvoke嗎?雖然它們的實現方式是以單獨執行緒的方式來實現非同步操作的,但是這仍然屬於非同步流控制的範疇。

非同步的實現方式有很多,可以使用已有的執行緒技術(Rx和C#的async/await就是使用這種方式),也可以使用類似於libuv之類的I/O非同步封裝配合事件驅動(node就是使用這種方式)。並於非同步流控制的部分我們之後會討論。

所以如果你的應用是計算密集型的,在充分分析場景的前提下可以適當的採用非同步的方式。大部分的計算密集型場景是不用介入非同步控制技術的,除非它可以顯著改善應用的流程控制能力。

I/O密集型應用

何為I/O密集型應用?Web伺服器天然就是I/O密集型的,因為有著高併發量與網路吞吐。檔案伺服器和CDN是I/O密集型的,因為高網路吞吐高磁碟訪問量。資料庫是I/O密集型的,涉及磁碟的訪問及網路訪問。說到底,一切和輸入輸出相關的場景都是I/O密集型的。

I/O囊括的方面主要是兩方面:

  • 網路訪問
  • 磁碟讀寫

簡單粗暴的解釋,就是接在主機板南橋上的裝置的訪問都屬於I/O。多提一句,記憶體是直接接在北橋上的,這貨,快。

開發者遇到最多的場景便是Web應用和資料庫的高併發訪問。其它的服務呼叫都屬於網路I/O,可歸為一類。

典型的就是Web伺服器接收到了HTTP請求,然後具體的Web框架會單開一個執行緒服務這個請求。因為HTTP是構建在TCP之上的,所以在請求結束返回結果之前,socket並沒有關閉,在windows系統上這就是一個控制程式碼,在*nix之類的posix系統上這就是一個檔案描述符,都是系統資源緊張的很。這是硬性的限制,能開啟多少取決與記憶體與作業系統,我們暫且不關注這部分。該執行緒如果採用同步的方式,那麼它程的生命週期會吻合socket的生命週期,期間不管是訪問檔案系統花了10s導致cpu空閒10s的時間片,還是訪問資料庫有3s的時間片空隙,這個執行緒都不會釋放,就是說,這個執行緒是專屬的,即便是使用執行緒池技術,該佔還得佔。

這有點像是銀行的VIP專線,服務人員就那麼多,如果每人服務一個VIP且甭管人家在聊人生聊理想還是默默注視,後面人就算是VIP也得等著,因為沒人可以服務你了。

那麼我們繼續深入,執行緒也是一種相對昂貴的資源,雖然比建立程式快了太多,但是仍然有限制。windows的32位作業系統預設每程式可使用2GB使用者態記憶體(64bit是8Tb使用者態記憶體, LoL),每個執行緒有1Mb的棧空間(能改,但不建議。);*nix下是8Mb棧空間,32位的程式空間是4Gb,64位則大到幾近沒有使用者態記憶體限制。我們可以假定32位系統下一個合理的單程式執行緒數量:1500。那麼一個程式最大的併發量就是1500請求了,拋開多核不談,這1500個執行緒就算輪班倒,併發量不會再上去了,因為一個socket一個執行緒。如果每個請求都是web伺服器處理1s加訪問資料庫伺服器3s,那麼時鐘浪費率則大的驚人。況且,1500個執行緒的上下文切換想想都是開心,開了又開

不幸的是,之前的web伺服器都是這麼幹的。此時我們思考,如果採用非同步的方式,那3s的阻塞完全可以規避,從而使執行緒輪轉的更快,因為1s的處理時間結束後執行緒返回執行緒池然後服務於另一個請求,從而整體提高伺服器的吞率。

事實上,node壓根就沒有多執行緒的概念,使用事件迴圈配合非同步I/O,一個執行緒總夠你甩傳統的Web伺服器吞吐量幾條街。沒錯,請叫我node雷鋒。

再繼續深入非同步程式設計前,我們先理一理幾個經常混淆的概念。

一些概念的區別

多核與多執行緒

多核是一種物理上的概念,即指主機所擁有的物理CPU核心數量,總核心數 = CPU個數 * 每個CPU的核心數。每個核心是獨立的,可以同時服務於不同的程式/執行緒。

多執行緒是一種作業系統上的概念,單個程式可能建立多個執行緒來達到細粒度進行流程控制的目的。作業系統的核心態排程程式與執行緒,在使用者態之下其實還可以對單個執行緒有更細粒度的控制,這稱之為協程(coroutine)纖程(fibers)

多執行緒是指在單個程式空間內通過作業系統的排程來達到多流程同時執行的一種機制,當然,單個CPU核心在單位時間內永遠都只是執行一個執行緒的指令,所以需要以小的時間片段雨露均沾的執行每個執行緒的部分指令。在切換執行緒時是有上下文的切換的,包括暫存器的儲存/還原,執行緒堆疊的儲存/還原,這就是開銷。

並行與併發

關於並行,真相只有一個,單個CPU核心在單位時間內只能執行一個執行緒的指令,所以如果總核心數為20,那麼我們可以認為該主機的並行能力為20,但是使用者態的並行能力是要比這個低的,因為作業系統服務和其它軟體也是要用cpu的,因此這個數值是達不到的。

一個題外話,如果並行能力為20,那麼我們可以粗略的認為,該主機一次可以同時執行20個執行緒,如果程式的執行緒使用率健康的話,保持執行緒池為20左右的大小可以做到完全的執行緒並行執行沒有上下文切換。

那麼併發則關注於應用的處理能力。這是一個更加側重網路請求/服務響應能力的概念,可以理解為單位時間內可以同時接納並處理使用者請求的能力。它和多少CPU沒有必然的關係,單純的考量了伺服器的響應回覆能力。

阻塞與非阻塞

阻塞/非阻塞與同步/非同步是經常被混淆的。同步/非同步其實在說事件的執行順序,阻塞/非阻塞是指做一件事能不能立即返回。

我們舉個去KFC點餐的例子。點完餐交完錢了,會有這麼幾種情況:

  • 服務人員直接把東西給我,因為之前已經做好了,所以能馬上給我,這叫做非阻塞,我不需要等,結果立即返回。這整個過程是同步完成的。
  • 服務人員一看沒有現成的東西了,跑去現做,那麼我就在這兒一直等,沒刷微信沒做別的乾等,等到做出來拿走,這叫阻塞,因為我傻到等結果返回再離開點餐檯。這整個過程是同步完成的。
  • 服務人員一看沒有現成的東西了,跑去現做,並告訴我說:先去做別的,做好了我叫你的號。於是我開心的找了個座位刷微信,等叫到了我的號了取回來。這叫做非阻塞,整個過程是非同步的,因為我還刷了微信思考了人生。

非同步是非阻塞的,但是同步可以是阻塞的也可以是非阻塞的,取決於消費的資源。

非同步程式設計的挑戰

非同步程式設計的主要困難在於,構建程式的執行邏輯時是非線性的,這需要將任務流分解成很多小的步驟,再通過非同步回撥函式的形式組合起來。在非同步大行其道的javascript界經常可以看到很多層的});,簡單酸爽到妙不可言。這一節將討論一些常用的處理非同步的技術手段。

回撥函式地獄

開頭的那個例子使用了4層的巢狀回撥函式,如果流程更加複雜的話,還需要巢狀更多,這不是一個好的實踐。而且以回撥的方式組織流程,在視覺上並不是很直白,我們需要更加優雅的方式來解耦和組織非同步流。

使用傳統的javascript技術,可以展平回撥層次,例如我們可以改寫之前的例子:

var ohs = require('./anticorruption/OpenHostService');
var localConvertingService = require('./services/LocalConverting');
var remoteRepository = require('./repositories/BusinessData');
var calculationService = require('./services/Calculation');

function(req, res) {
    var userData = req.body;

    ohs.retrieveResource(userData, ohsCb);

    function ohsCb(err, rs1) {
        if(err) {
            // error handling
        }
        localConvertingService.unitize(rs1, convertingCb);
    }

    function convertingCb(err, rs2) {
        if(err) {
            // error handling
        }
        remoteRepository.loadBusinessData(rs2, loadDataCb);
    }

    function loadDataCb(err, bs1) {
        if(err) {
            // error handling
        }
        calculationService.doCalculation(bs1 , calclationCb);
    }

    function calclationCb(err, result) {
        if(err) {
            // error handling
        }
        res.view(result);
    }
}

解巢狀的關鍵在於如何處理函式作用域,之後金字塔厄運迎刃而解。

還有一種更為優雅的javascript回撥函式處理方式,可以參考後面的Promise部分。

而對於像C#之類的內建非同步支援的語言,那麼上述問題更加的不是問題,例如:

public async IActionResult CrazyCase(UserData userData) {
    var ticket = CrazyApplication.Ticket;

    var ohsFactory = new OpenHostServiceFactory(ticket);
    var ohs = ohsFactory.CreateService();

    var ohsAdapter = new OhsAdapter(userData);

    var rs1 = await ohs.RetrieveResource(ohsAdapter);
    var rs2 = await _localConvertingService.Unitize(rs1);
    var bs1 = await _remoteRepository.LoadBusinessData(rs2);
    var result = await _calculationService.DoCalculation(bs1);

    return View(result);
}

async/await這糖簡直不能更甜了,其它C#的編譯器還是生成了使用TPL特性的程式碼來做非同步,說白了就是一些Task<T>在做後臺的任務,當遇到async/await關鍵字後,編譯器將該方法編譯為狀態機,所以該方法就可以在await的地方掛起和恢復了。整個的開發體驗幾乎完全是同步式的思維在做非同步的事兒。後面有關於TPL的簡單介紹。

異常處理

由於非同步執行採用非阻塞的方式,所以當前的執行執行緒在呼叫後捕獲不到非同步執行棧,因此傳統的非同步處理將不再適用。舉兩個例子:

try {
    Task.Factory.StartNew(() => {
        throw new InvalidOperationException("diablo coming.");
    });
} catch(InvalidOperationException e) {
    // nothing captured.
    throw;
}

try {
    process.nextTick(function() {
        throw new Error('diablo coming.');
    });
} catch(e) {
    // nothing captured.
    throw e;
}

在這兩個例子中,try語句塊中的呼叫會立即返回,不會觸發catch語句。那麼如何在非同步中處理異常呢?我們考慮非同步執行結束後會觸發回撥函式,那麼這便是處理異常的最佳地點。node的回撥函式幾乎總是接受一個錯誤作為其首個引數,例如:

fs.readFile('file.txt', 'utf-8', function(err, data) { });

其中的err是由非同步任務本身產生的,這是一種自然的處理非同步異常的方式。那麼回到C#中,因為有了好用的async/await,我們可以使用同步式的思維來處理異常:

try {
    await Task.Factory.StartNew(() => {
        throw new InvalidOperationException("diablo coming.");
    });
} catch(InvalidOperationException e) {
    // exception handling.
}

編譯器所構建的狀態機可以支援異常的處理,簡直是強大到無與倫比。當然,對於TPL的處理也有其專屬的支援,類似於node的處理方式:

Task.Factory.StartNew(() => {
    throw new InvalidOperationException("diablo coming.");
})
.ContinueWith(parent => {
    var parentException = parent.Exception;
});

注意這裡訪問到的parent.Exception是一個AggregateException型別,對應的處理方式也較傳統的異常處理也稍有不同:

parentException.Handle(e => {
    if(e is InvalidOperationException) {
        // exception handling.
        return true;
    }

    return false;
});

非同步流程控制

非同步的技術也許明白了,但是遇到更復雜的非同步場景呢?假設我們需要非同步並行的將目錄下的3個檔案讀出,全部完成後進行內容拼接,那麼就需要更細粒度的流程控制。

我們可以借鑑async.js這款優秀的非同步流程控制庫所帶來的便捷。

async.parallel([
    function(callback) {
         fs.readFile('f1.txt', 'utf-8', callback)
    },
    function(callback) {
         fs.readFile('f2.txt', 'utf-8', callback)
    },
    function(callback) {
         fs.readFile('f3.txt', 'utf-8', callback)
    }
], function (err, fileResults) {
    // concat the content of each files
});

如果使用C#並配合TPL,那麼這個場景可以這麼實現:

public async void AsyncDemo() {
    var files = new []{
        "f1.txt",
        "f2.txt",
        "f3.txt"
    };

    var tasks = files.Select(file => {
        return Task.Factory.StartNew(() => {
            return File.ReadAllText(file);
        });
    });

    await Task.WhenAll(tasks);

    var fileContents = tasks.Select(t => t.Result);

    // concat the content of each files
}

我們再回到我們開頭遇到到的那個場景,可以使用async.jswaterfall來簡化:

var ohs = require('./anticorruption/OpenHostService');
var localConvertingService = require('./services/LocalConverting');
var remoteRepository = require('./repositories/BusinessData');
var calculationService = require('./services/Calculation');
var async = require('async');

function(req, res) {
    var userData = req.body;

    async.waterfall([
        function(callback) {
            ohs.retrieveResource(userData, function(err, rs1) {
                callback(err, rs1);
            });
        },
        function(rs1, callback) {
            localConvertingService.unitize(rs1, function(err, rs2) {
                callback(err, rs2);
            });
        },
        function(rs2, callback) {
            remoteRepository.loadBusinessData(rs2, function(err, bs1) {
                callback(err, bs1);
            });
        },
        function(bs1, callback) {
            calculationService.doCalculation(bs1, function(err, result) {
                callback(err, result);
            });
        }
    ],
    function(err, result) {
        if(err) {
            // error handling
        }
        res.view(result);
    });
}

如果需要處理前後無依賴的非同步任務流可以使用async.series()來序列非同步任務,例如先開電源再開熱水器電源最後亮起紅燈,並沒有資料的依賴,但有先後的順序。用法和之前的parallel()waterfall()大同小異。另外還有優秀的輕量級方案step,以及為javascript提供monadic擴充套件的wind.js(特別像C#提供的方案),有興趣可以深入瞭解。

反人類的程式設計思維

非同步是反人類的

人類生活在一個充滿非同步事件的世界,但是開發者在構建應用時卻遵循同步式思維,究其原因就是因為同步符合直覺,並且可以簡化應用程式的構建。

究其深層原因,就是因為現實生活中我們是在演繹,並通過不同的口頭回撥來完成一系列的非同步任務,我們會說你要是有空了來找我聊人生,貨到了給我打電話,小紅你寫完文案了交給小明,小麗等所有的錢都到了通知小強……而在做開發時,我們是在列清單,我們的說法就是:我等著你有空然後開始聊人生,我等著貨到了然後我就知道了,我等著小紅文案寫完了然後開始讓她交給小明,我等著小麗確認所有的錢到了然後開始讓她通知小強……

同步的思維可以簡化程式設計的關注點,但是沒有將流程進行現實化的切分,我們總是傾向於用同步阻塞的方式來將開發變成簡單的步驟程式化,卻忽視了用動態的視角以及訊息/事件驅動的方式構建任務流程。

非同步在程式設計看來是反人類的,但是從業務角度看卻是再合理不過的了。通過當的工具及技術,使用非同步並不是難以企及的,它可以使應用的資源利用更加的高效,讓應用的響應性更上一個臺階。

擴充套件閱讀

Promise/Deferred

在一般情況下,Promise、Deferred、Future這些詞可以當做是同義詞,描述的是同一件事情。

jQuery 1.5+之後出現了一種新的API呼叫方式,相比於舊的API,新的方式更好的解耦了關注點,並帶來了更好的組合能力。

我們看一個傳統的使用ajax的例子:

$.get('/api/service1', {
    success: onSuccess,
    failure: onFailure,
    always:  onAlways
});

使用新的API後,呼叫的方式變成了:

$.get('/api/service1')
    .done(onSussess)
    .fail(onFailure)
    .always(onAlways);

get方法返回的是一個promise物件,表示這個方法會在未來某個時刻執行完畢。

PromiseCommonJS提出的規範,而jQuery的實現在其基礎上有所擴充套件,旗艦級的實現可以參考Kris KowalQ.js

我們使用jQuery來構建一個promise物件:

var longTimeOperation = function() {
    var deferred = $.Deferred();

    // taste like setTimeout()
    process.nextTick(function() {
        // do operation.
        deferred.resolve();
        // if need error handling, use deferred.reject();
    });

    return deferred.promise();
}

$.when(longTimeOperation())
    .done(success)
    .fail(failure);

由於jQuery生成的Deferred可以自由的進行resolve()reject(),所以在返回時我們使用.promise()生成不含這個兩方法的物件,從而更好的封裝邏輯。

那麼Promise究竟帶給我們的便利是什麼?Promise表示在未來這個任務會成功或失敗,可以使用1和0來表示,那麼開發者馬上就開始歡呼了,給我布林運算我能撬動地球!於是,我們可以寫出如下的程式碼:

$.when(uploadPromise, downloadPromise)
    .done(function() {
        // do animation.
    });

對於開頭的那個例子我們說過有著更優雅的解回撥函式巢狀的方案,那就是使用promise,我們來嘗試改寫開頭的那個例子:

var ohs = require('./anticorruption/OpenHostService');
var localConvertingService = require('./services/LocalConverting');
var remoteRepository = require('./repositories/BusinessData');
var calculationService = require('./services/Calculation');
var $ = require('jquery');

function(req, res) {
    var userData = req.body;

    function deferredCallback(deferred) {
        return function(err) {
            if(err) {
                deferred.reject(err);
            } else {
                var args = Array.prototype.slice.call(arguments, 1);
                deferred.resolve(args);
            }
        };
    }

    function makeDeferred(fn) {
        var deferred = $.Deferred();
        var callback = deferredCallback(deferred);
        fn(callback);
        return deferred.promise();
    }

    var retrieveResourcePromise = makeDeferred(function(callback) {
        ohs.retrieveResource(userData, callback);
    });

    var convertingPromise = makeDeferred(function(callback) {
        localConvertingService.unitize(rs1, callback);
    });

    var loadBusinessDataPromise = makeDeferred(function(callback) {
        remoteRepository.loadBusinessData(rs2, callback);
    });

    var calculationPromise = makeDeferred(function(callback) {
        calculationService.doCalculation(bs1 , callback);
    });

    var pipedPromise = retrieveResourcePromise
        .pipe(convertingPromise)
        .pipe(loadBusinessDataPromise)
        .pipe(calculationPromise);

    pipedPromise
        .done(function(result) {
            res.view(result);
        })
        .fail(function(err) {
            // error handling
        });
}

我們使用了一個高階函式來生成可以相容deferred構造的回撥函式,進而使用jQuerypipe特性(在Q.js裡可以使用then()組合每個promise),使解決方案優雅了很多,而這個工具函式在Q.js裡直接提供,於是新的解決方案可以如下:

var ohs = require('./anticorruption/OpenHostService');
var localConvertingService = require('./services/LocalConverting');
var remoteRepository = require('./repositories/BusinessData');
var calculationService = require('./services/Calculation');
var Q = require('q');

function(req, res) {
    var userData = req.body;

    var retrieveResourceFn = Q.denodeify(ohs.retrieveResource)
    var convertingFn = Q.denodeify(localConvertingService.unitize);
    var loadBusinessDataFn = Q.denodeify(remoteRepository.loadBusinessData);
    var calculationFn = Q.denodeify(calculationService.doCalculation);

    retrieveResourceFn(userData)
        .then(convertingFn)
        .then(loadBusinessDataFn)
        .then(calculationFn)
        .then(function(result) {
            res.view(result);
        }, function(err) {
            // error handling
        });
}

那我們如何看待TPL特性呢?我們看看TPL可以做什麼:

  • Task為基本構造單位,執行時不阻塞呼叫執行緒
  • 每個Task是獨立的,Task有不同的狀態,可以使用Task.Status獲取
  • Task可以組合,使用類似.ContinueWith(Task))以及.WhenAll(Task[]).WhenAny(Task[])的方式自由組合。

對比一下Promise

  • Promise為基本構造單位,表示一個將來完成的任務,呼叫時立即返回
  • 每個Promise是獨立的,Promise有不同的狀態,可以使用.state獲取
  • Promise可以組合,使用.then().pipe()以及.when()來組合執行流程

可以看到,不論是Promise還是TPL,在設計上都有著驚人的相似性。我們有理由猜想在其它的的語言或平臺都存在著類似的構造,因為非同步說白了,就是讓未來完成的事情自己觸發後續的步驟。

Pull vs. Push

GoF32中沒有提到迭代器模式(Iterator)與觀察者模式(Observer)的區別和聯絡,其實這兩個模式有著千絲萬縷的聯絡。

Iterator反映的是一種Pull模型,資料通過同步的方式從生產者那裡拉過來,我們通過它的定義便可看到這一事實:

interface IEnumerator<out T>: IDisposable
{
    bool MoveNext();
    T Current { get; }
}

通過阻塞的方式呼叫MoveNext(),資料一個一個的拉取到本地。

而Observer反映的是一種Push模型,通過註冊一個觀察者(類似於回撥函式),當生產者有資料時,主動的推送到觀察者手裡。觀察者註冊結束後,原生程式碼沒有阻塞,推送資料的整個過程是非同步執行的。我們通過它的定義來對比Iterator:

interface IObserver<in T>
{
    void OnCompleted();
    void OnError(Exception exception);
    void OnNext(T value);
}

我們發現,其實這兩個介面是完全對偶的(參見Erik Meijer大大的論文Subject/Observer is Dual to Iterator):

  • MoveNext()拉取下一個資料,OnNext(T)推送下一個資料
  • MoveNext()返回值指示了有無剩餘資料(完成與否),OnCompleted()指示了資料已完成(推送資料完成的訊息)
  • Iterator是同步的,所以出現了異常直接在當前執行棧上,Observer是非同步的,所以需要另一種方式來通知發生了異常(參見上文中的非同步處理一節),於是有了OnError(Exception)

那麼事情就變的有意思了,我們知道Enumerable的資料可以任意的方式組合,於是產生了像LINQ之類的庫可供我們使用,但是這是一種阻塞的方式,因為Iterator本身就是一種Pull模型,這造就了同步等待的結果。

沒錯你是對的,如果使用EF之類的框架來查詢資料庫,大部分的操作是延遲執行的,表明操作並沒有發生而是像佔位符一樣在那裡。但是別忘了,你最終需要去查詢資料庫的,在查詢的一剎那,世界還是阻塞的,等結果吧親。

而Observer是非同步Push的,有點像是事件驅動,有事件了觸發,沒事件了也不干擾訂閱者的執行。

你是不是也隱隱的覺得事件也可以和Push模式一樣有統一的模型?而且不只一次?

好,我們重複一遍:事件,非阻塞觸發(並帶有事件資料)。Push,非阻塞通知訂閱者。

其實,這是同一種模式,語言中對事件(就是event關鍵字)的支援其實就是對Observer模式的支援,而foreach則實現了對Iterator模式的語言內建支援。所謂設計模式,就是因為語言的內建支援不夠而出現的,說白了,是語言的補丁。

那麼我們來看一看異常強大的Rx如何改變事件。

// unitized event
var mouseDown = Observable
    .FromEventPattern<MouseEventArgs>(this.myPictureBox, "MouseDown")
    .Select(x =>x.EventArgs);

// unitized APM model
var request = WebRequest.Create("http://www.shinetechchina.com");
var webRequest = Observable
    .FromAsyncPattern<WebResponse>(request.BeginGetResponse, request.EndGetResponse);

最後我們看一個更加複雜的組合事件的例子,也就是之前一直討論的非同步流組合問題。Drag and Drop這個場景做Winform的同學不會陌生,需要多少程式碼冷暖自知,如果藉助Rx,那麼事情就簡單很多:

var mouseDown = Observable
    .FromEventPattern<MouseEventArgs>(this.controlSource, "MouseDown")
    .Select(x => x.EventArgs.GetPosition(this));
var mouseUp = Observable
    .FromEventPattern<MouseEventArgs>(this.controlSource, "MouseUp")
    .Select(x => x.EventArgs.GetPosition(this));
var mouseMove = Observable
    .FromEventPattern<MouseEventArgs>(this.controlSource, "MouseMove")
    .Select(x => x.EventArgs.GetPosition(this));
var dragandDrop = 
    from down in mouseDown
    from move in mouseMove.StartWith(down).TakeUntil(mouseUp)
    select new {
        X = move.X - down.X,
        Y = move.Y - down.Y
    };

dragandDrop.Subscribe(value =>
{
    DesktopCanvas.SetLeft(this.controlSource, value.X);
    DesktopCanvas.SetTop(this.controlSource, value.Y);
});

Rx也提供了javascript擴充套件,有興趣可以深入研究。

(完)

相關文章