1. 程式人生 > 其它 >【js重學系列】非同步程式設計

【js重學系列】非同步程式設計

js非同步

學習js開發,無論是前端開發還是node.js,都避免不了要接觸非同步程式設計這個問題,就和其它大多數以多執行緒同步為主的程式語言不同,js的主要設計是單執行緒非同步模型。正因為js天生的與眾不同,才使得它擁有一種獨特的魅力,也給學習者帶來了很多探索的道路。本文就從js的最初設計開始,整理一下js非同步程式設計的發展歷程。

什麼是非同步

在研究js非同步之前,先弄清楚非同步是什麼。非同步是和同步相對的概念,同步,指的是一個呼叫發起後要等待結果返回,返回時候必須拿到返回結果。而非同步的呼叫,發起之後直接返回,返回的時候還沒有結果,也不用等待結果,而呼叫結果是產生結果後通過被呼叫者通知呼叫者來傳遞的。
舉個例子,A想找C,但是不知道C的電話號碼,但是他有B的電話號碼,於是A給B打電話詢問C的電話號碼,B需要查詢才能知道C的電話號碼,之後會出現兩種場景看下面兩個場景:

A不掛電話,等到B找到號碼之後直接告訴A
A掛電話,B找到後再給A打電話告訴A

能感受到這兩種情況是不同的吧,前一種就是同步,後一種就是非同步。

為什麼是非同步的

先來看js的誕生,JavaScript誕生於1995年,由Brendan Eich設計,最早是在Netscape公司的瀏覽器上實現,用來實現在瀏覽器中處理簡單的表單驗證等使用者互動。至於後來提交到ECMA,形成規範,種種歷史不是這篇文章的重點,提到這些就是想說一點,js的最初設計就是為了瀏覽器的GUI互動。對於圖形化介面處理,引入多執行緒勢必會帶來各種各樣的同步問題,因此瀏覽器中的js被設計成單執行緒,還是很容易理解的。但是單執行緒有一個問題:一旦這個唯一的執行緒被阻塞就沒辦法工作了--這肯定是不行的。由於非同步程式設計可以實現“非阻塞”的呼叫效果,引入非同步程式設計自然就是順理成章的事情了。
現在,js的執行環境不限於瀏覽器,還有node.js,node.js設計的最初想法就是設計一個完全由事件驅動,非阻塞式IO實現的伺服器執行環境,因為網路IO請求是一個非常大的效能瓶頸,前期使用其他程式語言都失敗了,就是因為人們固有的同步程式設計思想,人們更傾向於使用同步設計的API。而js由於最初設計就是全非同步的,人們不會有很多不適應,加上V8高效能引擎的出現,才造就了node.js技術的產生。node.js擅長處理IO密集型業務,就得益於事件驅動,非阻塞IO的設計,而這一切都與非同步程式設計密不可分。

執行緒和程序

程序是具有一定獨立功能的程式關於某個資料集合上的一次執行活動,程序是系統進行資源分配和排程的一個獨立單位。

執行緒是程序的一個實體,是CPU排程和分派的基本單位,它是比程序更小的能獨立執行的基本單位。執行緒自己基本上不擁有系統資源,只擁有一點在執行中必不可少的資源(如程式計數器,一組暫存器和棧),但是它可與同屬一個程序的其他的執行緒共享程序所擁有的全部資源

一個程序可以建立和撤銷另一個執行緒;同一個程序中的多個執行緒之間可以併發執行。

相對程序而言,執行緒是一個更加接近於執行體的概念,它可以與同進程中的其他執行緒共享資料,但擁有自己的棧空間,擁有獨立的執行序列。
程序和執行緒的主要差別在於它們是不同的作業系統資源管理方式。程序有獨立的地址空間,一個程序崩潰後,在保護模式下不會對其它程序產生影響,而執行緒只是一個程序中的不同執行路徑。執行緒有自己的堆疊和區域性變數,但執行緒之間沒有單獨的地址空間,一個執行緒死掉就等於整個程序死掉,所以多程序的程式要比多執行緒的程式健壯,但在程序切換時,耗費資源較大,效率要差一些。但對於一些要求同時進行並且又要共享某些變數的併發操作,只能用執行緒,不能用程序。

  1. 簡而言之,一個程式至少有一個程序,一個程序至少有一個執行緒.

  2. 執行緒的劃分尺度小於程序,使得多執行緒程式的併發性高。

  3. 另外,程序在執行過程中擁有獨立的記憶體單元,而多個執行緒共享記憶體,從而極大地提高了程式的執行效率。

  4. 執行緒在執行過程中與程序還是有區別的。每個獨立的執行緒有一個程式執行的入口、順序執行序列和程式的出口。但是執行緒不能夠獨立執行,必須依存在應用程式中,由應用程式提供多個執行緒執行控制。

  5. 從邏輯角度來看,多執行緒的意義在於一個應用程式中,有多個執行部分可以同時執行。但作業系統並沒有將多個執行緒看做多個獨立的應用,來實現程序的排程和管理以及資源分配。這就是程序和執行緒的重要區別。

引入同步和非同步

Javascript語言的執行環境是"單執行緒"(single thread,就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務,以此類推)。

這種模式的好處是實現起來比較簡單,執行環境相對單純;壞處是隻要有一個任務耗時很長,後面的任務都必須排隊等著,會拖延整個程式的執行。常見的瀏覽器無響應(假死),往往就是因為某一段Javascript程式碼長時間執行(比如死迴圈),導致整個頁面卡在這個地方,其他任務無法執行。

為了解決這個問題,Javascript語言將任務的執行模式分成兩種:同步(Synchronous)和非同步(Asynchronous)。

同步模式" 就是上一段的模式,後一個任務等待前一個任務結束,然後再執行,程式的執行順序與任務的排列順序是一致的、同步的;"非同步模式"則完全不同,每一個任務有一個或多個回撥函式(callback),前一個任務結束後,不是執行後一個任務,而是執行回撥函式,後一個任務則是不等前一個任務結束就執行,所以程式的執行順序與任務的排列順序是不一致的、非同步的。

非同步模式" 非常重要。在瀏覽器端,耗時很長的操作都應該非同步執行,避免瀏覽器失去響應,最好的例子就是Ajax操作。在伺服器端,"非同步模式"甚至是唯一的模式,因為執行環境是單執行緒的,如果允許同步執行所有http請求,伺服器效能會急劇下降,很快就會失去響應。

js非同步原理

棧 堆 佇列 事件迴圈
這個佇列就是非同步佇列,它是處理非同步事件的核心,整個js呼叫時候,同步任務和其他程式語言一樣,在棧中呼叫,一旦遇上非同步任務,不立刻執行,直接把它放到非同步佇列裡面,這樣就形成了兩種不同的任務。由於主執行緒中沒有阻塞,很快就完成,棧中任務邊空之後,就會有一個事件迴圈,把佇列裡面的任務一個一個取出來執行。只要主執行緒空閒,非同步佇列有任務,事件迴圈就會從佇列中取出任務執行。
說的比較簡單,js執行引擎設計比這複雜的多得多,但是在js的非同步實現原理中,事件迴圈和非同步佇列是核心的內容。

非同步程式設計實現

回撥函式(callback)

A callback is a function that is passed as an argument to another function and is executed after its parent function has completed.
翻譯:回撥是一個函式被作為一個引數傳遞到另一個函式裡,在那個函式執行完後再執行。( 也即:B函式被作為引數傳遞到A函式裡,在A函式執行完後再執行B )


callback方式利用了函數語言程式設計的特點,把要執行的函式作為引數傳入,由被呼叫者控制執行時機,確保能夠拿到正確的結果。這種方式初看可能會有點難懂,但是熟悉函數語言程式設計其實很簡單,很好地解決了最基本的非同步問題,早期非同步程式設計只能通過這種方式。
然而這種方式會有一個致命的問題,在實際開發中,模型總不會這樣簡單,下面的場景是常有的事:

fun1(data => {
	// ...
	fun2(data, result => {
		// ...
		fun3(result, () => {
			// ...
		});
	});
});

整個隨著系統越來越複雜,整個回撥函式的層次會逐漸加深,裡面再加上覆雜的邏輯,程式碼編寫維護都將變得十分困難,可讀性幾乎沒有。這被稱為毀掉地獄,一度困擾著開發者,甚至是曾經非同步程式設計最為人詬病的地方

promise

使用回撥函式來程式設計很簡單,但是回撥地獄實在是太可怕了,巢狀層級足夠深之後絕對是維護的噩夢,而promise的出現就是解決這一問題的。promise是按照規範實現的一個物件,ES6提供了原生的實現,早期的三方實現也有很多。在此不會去討論promise規範和實現原理,重點來看promise是如何解決非同步程式設計的問題的。
Promise物件代表一個未完成、但預計將來會完成的操作,有三種狀態:

pending:初始值,不是fulfilled,也不是rejected
resolved(也叫fulfilled):代表操作成功
rejected:代表操作失敗

整個promise的狀態只支援兩種轉換:從pending轉變為resolved,或從pending轉變為rejected,一旦轉化發生就會保持這種狀態,不可以再發生變化,狀態發生變化後會觸發then方法。這裡比較抽象,我們直接來改造上面的例子:

		new Promise((reso, rej) => {
			setTimeout(() => {
				reso('hello')
			}, 2000)
		}).then(res => {
			console.log(res);
			return new Promise(reso => {
				setTimeout(() => {
					reso('world')
				}, 2000)
			})
		}).then(res => {
			console.log(res);
		})

Promise是一個建構函式,它建立一個promise物件,接收一個回撥函式作為引數,而回調函式又接收兩個函式做引數,分別代表promise的兩種狀態轉化。resolve回撥會使promise由pending轉變為resolved,而reject 回撥會使promise由pending轉變為rejected。
當promise變為resolved時候,then方法就會被觸發,在裡面可以獲取到resolve的內容,then方法。而一旦promise變為rejected,就會產生一個error。無論是resolve還是reject,都會返回一個新的Promise例項,返回值將作為引數傳入這個新Promise的resolve函式,這樣就可以實現鏈式呼叫,對於錯誤的處理,系統提供了catch方法,錯誤會一直向後傳遞,總是能被下一個catch捕獲。用promise可以有效地避免回撥巢狀的問題,程式碼會變成下面的樣子:

fun1().then(data => {
	// ...
	return fun2(data);
}).then(result => {
	// ...
	return fun3(result);
}).then(() => {
	// ...
});

generator

async,await

總結

  1. js的非同步:我們從最開頭就說javascript是一門單執行緒語言,不管是什麼新框架新語法糖實現的所謂非同步,其實都是用同步的方法去模擬的,牢牢把握住單執行緒這點非常重要
  2. javascript是一門單執行緒語言,在最新的HTML5中提出了Web-Worker,但javascript是單執行緒這一核心仍未改變。所以一切javascript版的"多執行緒"都是用單執行緒模擬出來的,一切javascript多執行緒都是紙老虎!
  3. 事件迴圈Event Loop:事件迴圈是js實現非同步的一種方法,也是js的執行機制。
  4. javascript的執行和執行:執行和執行有很大的區別,javascript在不同的環境下,比如node,瀏覽器,Ringo等等,執行方式是不同的。而執行大多指javascript解析引擎,是統一的。
  5. setImmediate:微任務和巨集任務還有很多種類,比如setImmediate等等,執行都是有共同點的,有興趣的同學可以自行了解。
  6. 最後的最後:牢牢把握兩個基本點,以認真學習javascript為中心,早日實現成為前端高手的偉大夢想!
    1. javascript是一門單執行緒語言
    2. Event Loop是javascript的執行機制

JS為什麼是單執行緒的

  1. 最初設計JS是用來在瀏覽器驗證表單操控DOM元素的是一門指令碼語言,如果js是多執行緒的,那麼兩個執行緒同時對一個DOM元素進行了相互衝突的操作,那麼瀏覽器的解析器是無法執行的。
  2. 單執行緒就是同一個時間只能做一件事。多執行緒就是同一個時間可以做很多事情。
  3. JavaScript是單執行緒的。舉個很簡單的例子你就明白了,假定JavaScript同時有兩個執行緒,一個執行緒在某個DOM節點上新增內容,另一個執行緒刪除了這個節點,那瀏覽器要怎麼顯示,是不是亂套了。所以JavaScript只能是單執行緒的。
  4. 也許會有人說再HTML5中可以用new Worker(xxx.js)在JavaScript中建立多個執行緒。但是子執行緒完全受主執行緒控制,且不得操作DOM。所以JavaScript還是單執行緒的。在最新的HTML5中提出了Web-Worker,但javascript是單執行緒這一核心仍未改變。所以一切javascript版的"多執行緒"都是用單執行緒模擬出來的。

javascript的同步和非同步

  1. 單執行緒就意味著,所有任務需要排隊,前一個任務結束,才會執行後一個任務。如果前一個任務耗時很長,後一個任務就不得不一直等著。
  2. 如果排隊是因為計算量大,CPU忙不過來,倒也算了,但是很多時候CPU是閒著的,因為IO裝置(輸入輸出裝置)很慢(比如Ajax操作從網路讀取資料),不得不等著結果出來,再往下執行。
  3. JavaScript語言的設計者意識到,這時主執行緒完全可以不管IO裝置,掛起處於等待中的任務,先執行排在後面的任務。等到IO裝置返回了結果,再回過頭,把掛起的任務繼續執行下去。
  4. 於是,所有任務可以分成兩種,一種是同步任務(synchronous),另一種是非同步任務(asynchronous)。同步任務指的是,在主執行緒上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務;非同步任務指的是,不進入主執行緒、而進入"任務佇列"(task queue)的任務,只有"任務佇列"通知主執行緒,某個非同步任務可以執行了,該任務才會進入主執行緒執行
  5. 怎麼知道主執行緒執行棧為空啊?js引擎存在monitoring process程序,會持續不斷的檢查主執行緒執行棧是否為空,一旦為空,就會去Event Queue那裡檢查是否有等待被呼叫的函式

js為什麼需要非同步

  1. 單執行緒就意味著,所有任務需要排隊,前一個任務結束,才會執行後一個任務。這就是JavaScript中的同步任務。
  2. 如果js中不存在非同步,只能自上而下執行,如果上一行解析時間很長,那麼下面的程式碼就會被阻塞。 對於使用者而言,阻塞就以為著“卡死”,這樣就導致了很差的使用者體驗。比如在進行ajax請求的時候如果沒有返回資料後面的程式碼就沒辦法執行
  3. 但是同步任務有個很大的缺點,如果前一個任務執行了很長時間還沒結束,那下一個任務就不能執行,舉個簡單的例子,頁面某個區域渲染過程中需要用Ajax去請求資料,如這個請求很長時間都請求不到資料,那下個任務就不能執行,也就說頁面其他區域不能渲染。於是就有了JavaScript非同步任務來解決這個缺點。
  4. 非同步任務可以單獨執行,不要等前一個任務結束後再執行。但是非同步任務執行結束後就會在那邊等待,直到執行緒裡面沒有任務了。才會喊非同步任務的回撥函式過來執行。

js單執行緒又是如何實現非同步的呢

  1. js中的非同步以及多執行緒都可以理解成為一種“假象”,就拿h5的WebWorker來說,子執行緒有諸多限制,不能控制DOM,不能修改全域性物件等等,通常只用來做計算做資料處理。
  2. 這些限制並沒有違揹我們之前的觀點,所以說是“假象”。JS非同步的執行機制其實就是事件迴圈(eventloop),理解了eventloop機制,就理解了js非同步的執行機制。

JS的事件迴圈(eventloop)(執行機制)是怎麼運作的

  1. 同步和非同步任務分別進入不同的執行"場所",同步的進入主執行緒,非同步的進入事件列表(Event Table)並註冊回撥函式。
  2. 當滿足觸發條件後,(觸發條件可能是延時也可能是ajax回撥),會將這個回撥函式新增事件佇列(Event Queue)。
  3. 主執行緒內的任務執行完畢後,會去事件佇列(Event Queue)中詢問有沒有要執行的任務,如果有,那就按先新增先執行的順序進入任務執行棧,然後按之前步驟繼續執行。
  4. 上述過程會不斷重複,也就是常說的事件迴圈(Event Loop)。

JavaScript中的巨集任務和微任務

  1. 巨集任務(macro-task):整體程式碼script、setTimeout、setInterval、setImmediate

  2. 微任務(micro-task):Promise、process.nextTick

  3. 巨集任務和微任務是對JavaScript非同步任務再次細分。非同步事件佇列分為巨集任務事件佇列和微任務事件佇列。

4.

  1. 同步和非同步任務分別進入不同的執行"場所",同步的進入主執行緒,非同步的進入事件列表並註冊回撥函式。

    當非同步任務執行結束後,判斷該非同步任務是巨集任務還是微任務,將巨集任務的回撥函式新增巨集任務事件佇列,將微任務的回撥函式新增到微任務事件佇列。

    主執行緒內的任務執行完畢後。

    • 先去微任務事件佇列中詢問有沒有要執行的任務,如果有,那就按先新增先執行的順序進入任務執行棧。
    • 如果沒有,再去巨集任務事件佇列中詢問有沒有要執行的任務。如果有,那就按先新增先執行的順序進入任務執行棧。
    • 如果沒有,那任務都執行完畢。
    • 上述過程會不斷重複,也就是常說的事件迴圈(Event Loop)。

程式碼練習:

  1. /*
        以下這段程式碼的執行結果是什麼?
        如果依照:js是按照語句出現的順序執行這個理念,
        那麼程式碼執行的結果應該是:
            //"定時器開始啦"
            //"馬上執行for迴圈啦"
            //"執行then函式啦"
            //"程式碼執行結束"
        但結果並不是這樣的,得到的結果是:
            //"馬上執行for迴圈啦"
            //"程式碼執行結束"
            //"執行then函式啦"
            //"定時器開始啦"
    */
    setTimeout(function(){
        console.log('定時器開始啦')
    });
    
    new Promise(function(resolve){
        console.log('馬上執行for迴圈啦');
        for(var i = 0; i < 10000; i++){
            i == 99 && resolve();
        }
    }).then(function(){
        console.log('執行then函式啦')
    });
    
    console.log('程式碼執行結束');
    
  2. let data = [];
    $.ajax({
        url:www.javascript.com,
        data:data,
        success:() => {
            console.log('傳送成功!');
        }
    })
    console.log('程式碼執行結束');
    複製程式碼
    上面是一段簡易的`ajax`請求程式碼:
    
    //- ajax進入Event Table,註冊回撥函式`success`。
    //- 執行`console.log('程式碼執行結束')`。
    //- ajax事件完成,回撥函式`success`進入Event Queue。
    //- 主執行緒從Event Queue讀取回調函式`success`並執行。
    
  3. setTimeout(function() {
        console.log('setTimeout');
    })
    
    new Promise(function(resolve) {
        console.log('promise');
    }).then(function() {
        console.log('then');
    })
    
    console.log('console');
    
    //這段程式碼作為巨集任務,進入主執行緒。
    //先遇到setTimeout,那麼將其回撥函式註冊後分發到巨集任務Event Queue。(註冊過程與上同,下文不再描述)
    //接下來遇到了Promise,new Promise立即執行,then函式分發到微任務Event Queue。
    //遇到console.log(),立即執行。
    //好啦,整體程式碼script作為第一個巨集任務執行結束,看看有哪些微任務?我們發現了then在微任務Event Queue裡面,執行。
    //ok,第一輪事件迴圈結束了,我們開始第二輪迴圈,當然要從巨集任務Event Queue開始。我們發現了巨集任務Event Queue中setTimeout對應的回撥函式,立即執行。
    //結束。
    
    
  4. console.log('1');
    
    setTimeout(function() {
        console.log('2');
        process.nextTick(function() {
            console.log('3');
        })
        new Promise(function(resolve) {
            console.log('4');
            resolve();
        }).then(function() {
            console.log('5')
        })
    })
    process.nextTick(function() {
        console.log('6');
    })
    new Promise(function(resolve) {
        console.log('7');
        resolve();
    }).then(function() {
        console.log('8')
    })
    
    setTimeout(function() {
        console.log('9');
        process.nextTick(function() {
            console.log('10');
        })
        new Promise(function(resolve) {
            console.log('11');
            resolve();
        }).then(function() {
            console.log('12')
        })
    })
    
    // 第一輪事件迴圈流程分析如下:
    
    // 整體script作為第一個巨集任務進入主執行緒,
    // 遇到console.log,輸出1。
    // 遇到setTimeout,其回撥函式被分發到巨集任務Event Queue中。我們暫且記為setTimeout1。
    // 遇到process.nextTick(),其回撥函式被分發到微任務Event Queue中。我們記為process1。
    // 遇到Promise,new Promise直接執行,輸出7。then被分發到微任務Event Queue中。我們記為then1。
    // 又遇到了setTimeout,其回撥函式被分發到巨集任務Event Queue中,我們記為setTimeout2。
    
    
    // 巨集任務Event Queue   微任務Event Queue            
    // setTimeout1		   process1
    // setTimeout2		   then1
    // 上表是第一輪事件迴圈巨集任務結束時各Event Queue的情況,此時已經輸出了1和7。
    
    // 我們發現了process1和then1兩個微任務。
    
    // 執行process1,輸出6。
    
    // 執行then1,輸出8。
    
    // 好了,第一輪事件迴圈正式結束,這一輪的結果是輸出1,7,6,8。那麼第二輪事件迴圈從setTimeout1巨集任務開始:
    
    // 首先輸出2。接下來遇到了process.nextTick(),同樣將其分發到微任務Event Queue中,記為process2。new Promise立即執行輸出4,then也分發到微任務Event Queue中,記為then2。
    
    // 巨集任務Event Queue  微任務Event Queue          
    // setTimeout2		  process2
    // 				 then2
    // 第二輪事件迴圈巨集任務結束,我們發現有process2和then2兩個微任務可以執行。
    // 輸出3。
    // 輸出5。
    // 第二輪事件迴圈結束,第二輪輸出2,4,3,5。
    // 第三輪事件迴圈開始,此時只剩setTimeout2了,執行。
    // 直接輸出9。
    // 將process.nextTick()分發到微任務Event Queue中。記為process3。
    // 直接執行new Promise,輸出11。
    // 將then分發到微任務Event Queue中,記為then3。
    // 巨集任務Event Queue  微任務Event Queue    
    //     			  process3 
    //     			  then3
    // 第三輪事件迴圈巨集任務執行結束,執行兩個微任務process3和then3。
    // 輸出10。
    // 輸出12。
    // 第三輪事件迴圈結束,第三輪輸出9,11,10,12。
    
    // 整段程式碼,共進行了三次事件迴圈,完整的輸出為1,7,6,8,2,4,3,5,9,11,10,12。
    // (請注意,node環境下的事件監聽依賴libuv與前端環境不完全相同,輸出順序可能會有誤差)