給程式設計師看的Javascript攻略(完結)- 非同步

古鐵發表於2017-09-15

給程式設計師看的Javascript攻略(完結)- 非同步

原文發表在: holmeshe.me , 本文是漢化重製版。

本系列在 Medium上同步連載。

用ajax胡亂做專案的時候踩過好多坑,然後對JS留下了“非常詭異”的印象。系統學習後,發現這個構建了整個網際網路表層的語言其實非常666。這次的學習已經告一段落,本篇也是這個系列的最後一部分。回頭看來,把學習記錄發出來這個經歷挺奇特的,以前是寫了給自己看,現在隨便搞搞發來掘金就3000+的總閱讀,頓時感覺有意義了很多。所以我也想明白了,你看,我就有動力寫。

其實沒啥新鮮的

簡單來講,非同步有兩層含義,1)讓慢操作不要阻塞;2)非線性觸發事件。稍稍講深一點,在作業系統裡,事件也叫中斷,這裡一次中斷可以代表一個網路收包,一次時鐘,或者一次滑鼠點選,等。那從技術上層面看,一個事件可以中斷當前程式,掛起下一條指令,並且“非同步地”呼叫一個預設好的程式碼塊(事件處理函式)。
應用層也一樣。

阻塞操作的問題

狹義來說,非同步可以解決應用阻塞(一般是I/O)的問題。為啥要聊非同步一定要說阻塞呢?那我們從頭來看看。每一個帶UI的應用(無論是嵌入式的,還是APP,遊戲還是一個網頁),底下都一個迴圈在非常快的重新整理螢幕,那如果這個迴圈被阻塞了,比如在這個迴圈上進行了一次網路請求,UI就卡了,使用者也就跑了。而JavaScript就跑在這個迴圈上。
這次要先做點實驗前準備。
首先,下載Moesif Origin & CORS Changer。這個用來讓Chrome給我們的跨站請求放行。
然後,我們用Python來實現一個慢服務(API):
from flask import Flask
import time
app = Flask(__name__)
@app.route("/lazysvr")
def recv():
  time.sleep(10)
  return "ok"
if __name__ == "__main__":
  app.run(host='***.***.***.***', threaded=True)複製程式碼
然後我們開啟Moesif Origin & CORS Changer,(不然請求直接失敗返回了),然後我們跑例子:
<html>
<head>
</head>
<body>
  <button type="button">Click Me!</button> 
  <script>
    var xmlHttp = new XMLHttpRequest();
    xmlHttp.open( "GET", "http://***.***.***.***:5000/lazysvr", false ); // false for synchronous request
    xmlHttp.send( null ); // the thread is suspended here
    alert(xmlHttp.responseText);
  </script>
</body>
</html>複製程式碼
如果我們開啟開發者皮膚,可以很容易觀察到,程式碼會卡在下面這行:
xmlHttp.send( null ); // it is the aforementioned blocking operation複製程式碼
在卡住的這10秒左右,按鈕是點不動的,然後瀏覽器才會跳出彈窗:
ok複製程式碼
並且,Chrome會抱怨:
[Deprecation] Synchronous XMLHttpRequest on the main thread is deprecated because of its detrimental effects to the end user’s experience. For more help, check https://xhr.spec.whatwg.org/.複製程式碼
暫且把這個當作對這個問題的官方描述吧。

來一波非同步

廣義來講,以下的都屬於非同步操作:
1)把慢操作放到其它執行緒執行;
2)由外部觸發的事件;
3)兩者的混合。
下面我會舉三個例子來說明
第一個?,收包

給程式設計師看的Javascript攻略(完結)- 非同步
這個例子的程式碼也可以解決上節的阻塞問題,
打上碼:
<html>
<head>
</head>
<body>
  <button type="button">Click Me!</button> 
  <script>
    var xmlHttp = new XMLHttpRequest();
--  xmlHttp.open( "GET", "http://192.241.212.230:5000/lazysvr", false );
++  xmlHttp.open( "GET", "http://192.241.212.230:5000/lazysvr", true ); // 1) change the param to "true" for asynchronous request

++  xmlHttp.onreadystatechange = function() { // 2) add the callback
++    if(xmlHttp.readyState == 4 && xmlHttp.status == 200) {
++      alert(xmlHttp.responseText);
++    }
    }

    xmlHttp.send(); 
--  alert(xmlHttp.responseText);
  </script>
</body>
</html>複製程式碼
在上面的程式碼裡,我們1)把open()的第二個引數變成“true”,這樣可以把慢操作負載到其它執行緒上去;2)註冊一個回撥函式來監聽收報事件。這個回撥函式在網路互動完成後會被立即執行。
這次按鈕就可以點了,然後
ok複製程式碼
也按照預期彈出。
再來一個,時鐘週期

給程式設計師看的Javascript攻略(完結)- 非同步
直接先打上碼:
setTimeout(callback, 3000);
function callback() {
  alert(‘event triggered’);
}複製程式碼
注意1,JS從一開始就沒有同步的sleep()函式;
注意2,和開始說的OS不一樣,這個時鐘是絕對不會觸發程式排程的,正如之前提到,所有的JS程式碼都是執行在一條執行緒上。
第三個,點選滑鼠

給程式設計師看的Javascript攻略(完結)- 非同步
<html>
<head>
</head>
<body>
  <button type=”button” onclick=”callback()”>Click Me!</button> 
  <script>
    function callback() {
      alert(‘event triggered’);
    }
  </script>
</body>
</html>複製程式碼
在上面的三個例子中,我們都給特定的事件(由非主迴圈觸發)註冊了回撥函式。在第一個例子裡,我們還把一個慢操作負載到了其它執行緒來解決卡死的問題。所有這些操作都可以用一個詞來概括,非同步!

給程式設計師看的Javascript攻略(完結)- 非同步

新的fetch()介面

在第一個?中,我用回撥來舉例是因為比較直觀。其實更好的辦法是用fetch()來進行網路請求。這個函式會返回一個Promise物件,再用這個物件呼叫then()函式的話:

1. 非同步操作的程式碼就可以變成線性(更像同步)了;

2. 回撥地獄的問題可以得到解決了;

3. 所有的相關異常,可以在一個程式碼塊裡處理了:

<html>
<head>
</head>
<body>
  <button type=”button” onclick=”callback()”>Click Me!</button>
   <script>
    fetch(‘http://***.***.***.***:5000/lazysvr')
    .then((response) => {
      return response.text();
    }).then((text) => {
       alert(text);
    }).catch(function(error) {
      console.log(‘error: ‘ + error.message);
    });
 </script>
</body>
</html>複製程式碼

執行結果和第一個?一樣,我還是留了按鈕給你試UI有沒有卡。

底層機制,多執行緒+事件迴圈

JS不是單執行緒嗎?

答案是,即是也不是。什麼意思?

var i;
for (i = 0; i < 1000; i++) {
  var xmlHttp = new XMLHttpRequest();
  xmlHttp.open( "GET", "http://***.***.***.***:5000/lazysvr", true );
  xmlHttp.onreadystatechange = function() {
     if (xmlHttp.readyState == 4 && xmlHttp.status == 200) {
        alert(xmlHttp.responseText);
    }
  } // end of the callback
  xmlHttp.send( null );
}複製程式碼

假設瀏覽器的pid是666(巧了,我做這個測試的時候還真是),我們用一小段指令碼(環境是Mac)本來觀察執行緒狀態:

#!/bin/bash
while true; do ps -M 666; sleep 1; done複製程式碼

初始值(我把無關的列和行都幹掉了):

USER     PID ... STIME    UTIME
holmes   666 ... 0:00.42  0:01.47
 ......
         666     0:00.20  0:00.64複製程式碼

結束的時候:

USER     PID ... STIME    UTIME
holmes   666 ... 0:00.50  0:01.88
 ......
         666     0:00.37  0:01.28複製程式碼

除了主執行緒,還有一條非常活躍的執行緒,我估摸著這條是用來監聽網路的(多路複用)套接字。

所以JS程式碼確實是執行在一條執行緒裡。但是如果從應用程式的角度來看,它其實是多執行緒。用同樣的辦法測一下Node吧。

“粗”暴的事件迴圈

上文提到,作業系統的中斷是以指令為粒度的,但是這個傳說中的事件迴圈,粒度就有點大了:

var i;
for (i = 0; i < 3; i++) {
  alert(i);
}
setTimeout(callback, 0);
function callback() {
  alert(‘event triggered’);
}複製程式碼

我們都知道結果是:

1
2
3
event triggered複製程式碼

簡單來說呢,雖然我們註冊了一個定時事件,並且指定它立即執行,但是JS引擎還是在執行時忠實的把本次迴圈跑完,才會去理剛剛註冊的那個事件。

這個代表一般事件中斷是以指令週期為單位,而JS是以迴圈週期為單位的。

有點尷尬了,這麼大粒度的事件處理會不會導致UI響應時間長呢?我覺得其實不會。即使在以指令週期為單位的事件響應裡,使用者的操作還是需要在本次"迴圈週期"結束放到主執行緒來,然後反映到UI。因為一切UI更新都要在主執行緒。所以,這個極其簡化的單執行緒設計本身並不會對UI效能造成影響。你覺得呢?

遲到的總結

這個系列中,我覆蓋了在JS裡被細化的 "等於" 操作符和 "null" 值被簡化的 字串,陣列,物件和字典。然後我在這篇這篇裡深入到prototype這一層進一步討論了一下物件。最重要的是,我三次提及了this的坑:

第一次

第二次

第三次

說明真的很重要。

最後就是本篇了,用我理解的角度聊了一下非同步。

如果你還記得的話,這個系列是我為新工作(臨時)學JS準備的。以現在上手程度來看,我覺得這個底子打的還不錯,希望對你也一樣。但是這個文章並不全面,所以我準備瞭如下的附加閱讀:

JavaScript types

Closure

The debug technique 我用來除錯的方法

這篇很有趣,我第一次讀到,希望有機會能翻譯 interesting topic

Another place to understand “this

More about event loop

A good blog, 最重要的事,

常來掘金看篇。

最後要承認第一段的結構是模仿喬幫主在第一次蘋果(iPhone1)釋出會的經典段式。(寫這篇文章的時候,實在被最新的釋出會感動了一把)。如果沒看過去找找吧。

感謝閱讀,後會有期!


相關文章