者以前面試的時候經常遇到寫一堆setTimeout,setImmediate來問哪個先執行。本文主要就是來講這個問題的,但是不是簡單的講講哪個先,哪個后。籠統的知道setImmediate比setTimeout(fn, 0)先執行是不夠的,因為有些情況下setTimeout(fn, 0)是會比setImmediate先執行的。要徹底搞明白這個問題,我們需要系統的學習JS的異步機制和底層原理。本文就會從異步基本概念出發,一直講到Event Loop的底層原理,讓你徹底搞懂setTimeout,setImmediate,Promise, process.nextTick誰先誰后這一類問題。
同步異步簡單理解就是,同步的代碼都是按照書寫順序執行的,異步的代碼可能跟書寫順序不一樣,寫在后面的可能先執行。下面來看個例子:
const syncFunc = () => {
const time = new Date().getTime();
while(true) {
if(new Date().getTime() - time > 2000) {
break;
}
}
console.log(2);
}
console.log(1);
syncFunc();
console.log(3);
上述代碼會先打印出1,然后調用syncFunc,syncFunc里面while循環會運行2秒,然后打印出2,最后打印出3。所以這里代碼的執行順序跟我們的書寫順序是一致,他是同步代碼:
再來看個異步例子:
const asyncFunc = () => {
setTimeout(() => {
console.log(2);
}, 2000);
}
console.log(1);
asyncFunc();
console.log(3);
上述代碼的輸出是:
可以看到我們中間調用的asyncFunc里面的2卻是最后輸出的,這是因為setTimeout是一個異步方法。他的作用是設置一個定時器,等定時器時間到了再執行回調里面的代碼。所以異步就相當于做一件事,但是并不是馬上做,而是你先給別人打了個招呼,說xxx條件滿足的時候就干什么什么。就像你晚上睡覺前在手機上設置了一個第二天早上7天的鬧鐘,就相當于給了手機一個異步事件,觸發條件是時間到達早上7點。使用異步的好處是你只需要設置好異步的觸發條件就可以去干別的事情了,所以異步不會阻塞主干上事件的執行。特別是對于JS這種只有一個線程的語言,如果都像我們第一個例子那樣去while(true),那瀏覽器就只有一直卡死了,只有等這個循環運行完才會有響應。
我們都知道JS是單線程的,那單線程是怎么實現異步的呢?事實上所謂的"JS是單線程的"只是指JS的主運行線程只有一個,而不是整個運行環境都是單線程。JS的運行環境主要是瀏覽器,以大家都很熟悉的Chrome的內核為例,他不僅是多線程的,而且是多進程的:
上圖只是一個概括分類,意思是Chrome有這幾類的進程和線程,并不是每種只有一個,比如渲染進程就有多個,每個選項卡都有自己的渲染進程。有時候我們使用Chrome會遇到某個選項卡崩潰或者沒有響應的情況,這個選項卡對應的渲染進程可能就崩潰了,但是其他選項卡并沒有用這個渲染進程,他們有自己的渲染進程,所以其他選項卡并不會受影響。這也是Chrome單個頁面崩潰并不會導致瀏覽器崩潰的原因,而不是像老IE那樣,一個頁面卡了導致整個瀏覽器都卡。
對于前端工程師來說,主要關心的還是渲染進程,下面來分別看下里面每個線程是做什么的。
GUI線程就是渲染頁面的,他解析HTML和CSS,然后將他們構建成DOM樹和渲染樹就是這個線程負責的。
這個線程就是負責執行JS的主線程,前面說的"JS是單線程的"就是指的這個線程。大名鼎鼎的Chrome V8引擎就是在這個線程運行的。需要注意的是,這個線程跟GUI線程是互斥的。互斥的原因是JS也可以操作DOM,如果JS線程和GUI線程同時操作DOM,結果就混亂了,不知道到底渲染哪個結果。這帶來的后果就是如果JS長時間運行,GUI線程就不能執行,整個頁面就感覺卡死了。所以我們最開始例子的while(true)這樣長時間的同步代碼在真正開發時是絕對不允許的。
前面異步例子的setTimeout其實就運行在這里,他跟JS主線程根本不在同一個地方,所以“單線程的JS”能夠實現異步。JS的定時器方法還有setInterval,也是在這個線程。
定時器線程其實只是一個計時的作用,他并不會真正執行時間到了的回調,真正執行這個回調的還是JS主線程。所以當時間到了定時器線程會將這個回調事件給到事件觸發線程,然后事件觸發線程將它加到事件隊列里面去。最終JS主線程從事件隊列取出這個回調執行。事件觸發線程不僅會將定時器事件放入任務隊列,其他滿足條件的事件也是他負責放進任務隊列。
這個線程負責處理異步的ajax請求,當請求完成后,他也會通知事件觸發線程,然后事件觸發線程將這個事件放入事件隊列給主線程執行。
所以JS異步的實現靠的就是瀏覽器的多線程,當他遇到異步API時,就將這個任務交給對應的線程,當這個異步API滿足回調條件時,對應的線程又通過事件觸發線程將這個事件放入任務隊列,然后主線程從任務隊列取出事件繼續執行。這個流程我們多次提到了任務隊列,這其實就是Event Loop,下面我們詳細來講解下。
所謂Event Loop,就是事件循環,其實就是JS管理事件執行的一個流程,具體的管理辦法由他具體的運行環境確定。目前JS的主要運行環境有兩個,瀏覽器和Node.js。這兩個環境的Event Loop還有點區別,我們會分開來講。
事件循環就是一個循環,是各個異步線程用來通訊和協同執行的機制。各個線程為了交換消息,還有一個公用的數據區,這就是事件隊列。各個異步線程執行完后,通過事件觸發線程將回調事件放到事件隊列,主線程每次干完手上的活兒就來看看這個隊列有沒有新活兒,有的話就取出來執行。畫成一個流程圖就是這樣:
流程講解如下:
主線程每次執行時,先看看要執行的是同步任務,還是異步的API同步任務就繼續執行,一直執行完遇到異步API就將它交給對應的異步線程,自己繼續執行同步任務異步線程執行異步API,執行完后,將異步回調事件放入事件隊列上主線程手上的同步任務干完后就來事件隊列看看有沒有任務主線程發現事件隊列有任務,就取出里面的任務執行主線程不斷循環上述流程
Event Loop的這個流程里面其實還是隱藏了一些坑的,最典型的問題就是總是先執行同步任務,然后再執行事件隊列里面的回調。這個特性就直接影響了定時器的執行,我們想想我們開始那個2秒定時器的執行流程:
主線程執行同步代碼遇到setTimeout,將它交給定時器線程定時器線程開始計時,2秒到了通知事件觸發線程事件觸發線程將定時器回調放入事件隊列,異步流程到此結束主線程如果有空,將定時器回調拿出來執行,如果沒空這個回調就一直放在隊列里。
上述流程我們可以看出,如果主線程長時間被阻塞,定時器回調就沒機會執行,即使執行了,那時間也不準了,我們將開頭那兩個例子結合起來就可以看出這個效果:
const syncFunc = (startTime) => {
const time = new Date().getTime();
while(true) {
if(new Date().getTime() - time > 5000) {
break;
}
}
const offset = new Date().getTime() - startTime;
console.log(`syncFunc run, time offset: ${offset}`);
}
const asyncFunc = (startTime) => {
setTimeout(() => {
const offset = new Date().getTime() - startTime;
console.log(`asyncFunc run, time offset: ${offset}`);
}, 2000);
}
const startTime = new Date().getTime();
asyncFunc(startTime);
syncFunc(startTime);
執行結果如下:
通過結果可以看出,雖然我們先調用的asyncFunc,雖然asyncFunc寫的是2秒后執行,但是syncFunc的執行時間太長,達到了5秒,asyncFunc雖然在2秒的時候就已經進入了事件隊列,但是主線程一直在執行同步代碼,一直沒空,所以也要等到5秒后,同步代碼執行完畢才有機會執行這個定時器回調。所以再次強調,寫代碼時一定不要長時間占用主線程。
前面的流程圖我為了便于理解,簡化了事件隊列,其實事件隊列里面的事件還可以分兩類:宏任務和微任務。微任務擁有更高的優先級,當事件循環遍歷隊列時,先檢查微任務隊列,如果里面有任務,就全部拿來執行,執行完之后再執行一個宏任務。執行每個宏任務之前都要檢查下微任務隊列是否有任務,如果有,優先執行微任務隊列。所以完整的流程圖如下:
上圖需要注意以下幾點:
一個Event Loop可以有一個或多個事件隊列,但是只有一個微任務隊列。微任務隊列全部執行完會重新渲染一次每個宏任務執行完都會重新渲染一次requestAnimationFrame處于渲染階段,不在微任務隊列,也不在宏任務隊列
所以想要知道一個異步API在哪個階段執行,我們得知道他是宏任務還是微任務。
常見宏任務有:
script (可以理解為外層同步代碼)setTimeout/setIntervalsetImmediate(Node.js)I/OUI事件postMessage
常見微任務有:
Promiseprocess.nextTick(Node.js)Object.observeMutaionObserver
上面這些事件類型中要注意Promise,他是微任務,也就是說他會在定時器前面運行,我們來看個例子:
console.log('1');
setTimeout(() => {
console.log('2');
},0);
Promise.resolve().then(() => {
console.log('5');
})
new Promise((resolve) => {
console.log('3');
resolve();
}).then(() => {
console.log('4');
})
上述代碼的輸出是1,3,5,4,2。因為:
先輸出1,這個沒什么說的,同步代碼最先執行console.log('2');在setTimeout里面,setTimeout是宏任務,“2”進入宏任務隊列console.log('5');在Promise.then里面,進入微任務隊列console.log('3');在Promise構造函數的參數里面,這其實是同步代碼,直接輸出console.log('4');在then里面,他會進入微任務隊列,檢查事件隊列時先執行微任務同步代碼運行結果是“1,3”然后檢查微任務隊列,輸出“5,4”最后執行宏任務隊列,輸出“2”
Node.js是運行在服務端的js,雖然他也用到了V8引擎,但是他的服務目的和環境不同,導致了他API與原生JS有些區別,他的Event Loop還要處理一些I/O,比如新的網絡連接等,所以與瀏覽器Event Loop也是不一樣的。Node的Event Loop是分階段的,如下圖所示:
timers: 執行setTimeout和setInterval的回調pending callbacks: 執行延遲到下一個循環迭代的 I/O 回調idle, prepare: 僅系統內部使用poll: 檢索新的 I/O 事件;執行與 I/O 相關的回調。事實上除了其他幾個階段處理的事情,其他幾乎所有的異步都在這個階段處理。check: setImmediate在這里執行close callbacks: 一些關閉的回調函數,如:socket.on('close', ...)
每個階段都有一個自己的先進先出的隊列,只有當這個隊列的事件執行完或者達到該階段的上限時,才會進入下一個階段。在每次事件循環之間,Node.js都會檢查它是否在等待任何一個I/O或者定時器,如果沒有的話,程序就關閉退出了。我們的直觀感受就是,如果一個Node程序只有同步代碼,你在控制臺運行完后,他就自己退出了。
還有個需要注意的是poll階段,他后面并不一定每次都是check階段,poll隊列執行完后,如果沒有setImmediate但是有定時器到期,他會繞回去執行定時器階段:
上面的這個流程說簡單點就是在一個異步流程里,setImmediate會比定時器先執行,我們寫點代碼來試試:
console.log('outer');
setTimeout(() => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
}, 0);
上述代碼運行如下:
和我們前面講的一樣,setImmediate先執行了。我們來理一下這個流程:
外層是一個setTimeout,所以執行他的回調的時候已經在timers階段了處理里面的setTimeout,因為本次循環的timers正在執行,所以他的回調其實加到了下個timers階段處理里面的setImmediate,將它的回調加入check階段的隊列外層timers階段執行完,進入pending callbacks,idle, prepare,poll,這幾個隊列都是空的,所以繼續往下到了check階段,發現了setImmediate的回調,拿出來執行然后是close callbacks,隊列時空的,跳過又是timers階段,執行我們的console
但是請注意我們上面console.log('setTimeout')和console.log('setImmediate')都包在了一個setTimeout里面,如果直接寫在最外層會怎么樣呢?代碼改寫如下:
console.log('outer');
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
我們來運行下看看效果:
好像是setTimeout先輸出來,我們多運行幾次看看:
怎么setImmediate又先出來了,這代碼是見鬼了還是啥?這個世界上是沒有鬼怪的,所以事情都有原因的,我們順著之前的Event Loop再來理一下。在理之前,需要告訴大家一件事情,node.js里面setTimeout(fn, 0)會被強制改為setTimeout(fn, 1),這在官方文檔中有說明。(說到這里順便提下,HTML 5里面setTimeout最小的時間限制是4ms)。原理我們都有了,我們來理一下流程:
外層同步代碼一次性全部執行完,遇到異步API就塞到對應的階段遇到setTimeout,雖然設置的是0毫秒觸發,但是被node.js強制改為1毫秒,塞入times階段遇到setImmediate塞入check階段同步代碼執行完畢,進入Event Loop先進入times階段,檢查當前時間過去了1毫秒沒有,如果過了1毫秒,滿足setTimeout條件,執行回調,如果沒過1毫秒,跳過跳過空的階段,進入check階段,執行setImmediate回調
通過上述流程的梳理,我們發現關鍵就在這個1毫秒,如果同步代碼執行時間較長,進入Event Loop的時候1毫秒已經過了,setTimeout執行,如果1毫秒還沒到,就先執行了setImmediate。每次我們運行腳本時,機器狀態可能不一樣,導致運行時有1毫秒的差距,一會兒setTimeout先執行,一會兒setImmediate先執行。但是這種情況只會發生在還沒進入timers階段的時候。像我們第一個例子那樣,因為已經在timers階段,所以里面的setTimeout只能等下個循環了,所以setImmediate肯定先執行。同理的還有其他poll階段的API也是這樣的,比如:
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
這里setTimeout和setImmediate在readFile的回調里面,由于readFile回調是I/O操作,他本身就在poll階段,所以他里面的定時器只能進入下個timers階段,但是setImmediate卻可以在接下來的check階段運行,所以setImmediate肯定先運行,他運行完后,去檢查timers,才會運行setTimeout。
類似的,我們再來看一段代碼,如果他們兩個不是在最外層,而是在setImmediate的回調里面,其實情況跟外層一樣,結果也是隨緣的,看下面代碼:
console.log('outer');
setImmediate(() => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
原因跟寫在最外層差不多,因為setImmediate已經在check階段了,里面的循環會從timers階段開始,會先看setTimeout的回調,如果這時候已經過了1毫秒,就執行他,如果沒過就執行setImmediate。
process.nextTick()是一個特殊的異步API,他不屬于任何的Event Loop階段。事實上Node在遇到這個API時,Event Loop根本就不會繼續進行,會馬上停下來執行process.nextTick(),這個執行完后才會繼續Event Loop。我們寫個例子來看下:
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
process.nextTick(() => {
console.log('nextTick 2');
});
});
process.nextTick(() => {
console.log('nextTick 1');
});
});
這段代碼的打印如下:
我們還是來理一下流程:
我們代碼基本都在readFile回調里面,他自己執行時,已經在poll階段遇到setTimeout(fn, 0),其實是setTimeout(fn, 1),塞入后面的timers階段遇到setImmediate,塞入后面的check階段遇到nextTick,立馬執行,輸出'nextTick 1'到了check階段,輸出'setImmediate',又遇到個nextTick,立馬輸出'nextTick 2'到了下個timers階段,輸出'setTimeout'
這種機制其實類似于我們前面講的微任務,但是并不完全一樣,比如同時有nextTick和Promise的時候,肯定是nextTick先執行,原因是nextTick的隊列比Promise隊列優先級更高。來看個例子:
const promise = Promise.resolve()
setImmediate(() => {
console.log('setImmediate');
});
promise.then(()=>{
console.log('promise')
})
process.nextTick(()=>{
console.log('nextTick')
})
代碼運行結果如下:
本文從異步基本概念出發一直講到了瀏覽器和Node.js的Event Loop,現在我們再來總結一下:
于超時,可以把開發者分為兩類:一類是了解超時多么難以捉摸的人,另一類是正在感受超時如何難以捉摸的人。
超時既難以捉摸,卻又真實地存在于我們生活的由網絡連接的世界中。在我寫這篇文章的同時,隔壁兩個同事正在用他們的智能手機打字,也許是在跟與他們相距萬里的人聊天。網絡使這一切變為可能。
這里要說的是網絡及其復雜性,作為寫網絡服務的我們,必須掌握如何高效地駕馭它們,并規避它們的缺陷。
閑話少說,來看看超時和它們是如何影響我們的 net/http 服務的。
web 編程中,超時通常分為客戶端和服務端超時兩種。我之所以要研究這個主題,是因為我自己遇到了一個有意思的服務端超時的問題。這也是本文我們將要重點討論服務側超時的原因。
先解釋下基本術語:超時是一個時間間隔(或邊界),用來標識在這個時間段內要完成特定的行為。如果在給定的時間范圍內沒有完成操作,就產生了超時,這個操作會被取消。
從一個 net/http 的服務的初始化中,能看出一些超時的基礎配置:
srv := &http.Server{
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
IdleTimeout: 30 * time.Second,
ReadHeaderTimeout: 2 * time.Second,
TLSConfig: tlsConfig,
Handler: srvMux,
}
http.Server 類型的服務可以用四個不同的 timeout 來初始化:
對上述超時的圖表展示:
服務生命周期和超時
當心!不要以為這些就是你所需要的所有的超時了。除此之外還有很多超時,這些超時提供了更小的粒度控制,對于我們的持續運行的 HTTP 處理器不會生效。
請聽我解釋。
如果我們查看 net/http 的源碼,尤其是看到 `conn` 類型[1] 時,我們會發現 conn 實際上使用了 net.Conn 連接,net.Conn 表示底層的網絡連接:
// Taken from: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L247
// A conn represents the server-side of an HTTP connection.
type conn struct {
// server is the server on which the connection arrived.
// Immutable; never nil.
server *Server
// * Snipped *
// rwc is the underlying network connection.
// This is never wrapped by other types and is the value given out
// to CloseNotifier callers. It is usually of type *net.TCPConn or
// *tls.Conn.
rwc net.Conn
// * Snipped *
}
換句話說,我們的 HTTP 請求實際上是基于 TCP 連接的。從類型上看,TLS 連接是 *net.TCPConn 或 *tls.Conn 。
serve 函數[2]處理每一個請求[3]時調用 readRequest 函數。readRequest 使用我們設置的 timeout 值[4]來設置 TCP 連接的 deadline:
// Taken from: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L936
// Read next request from connection.
func (c *conn) readRequest(ctx context.Context) (w *response, err error) {
// *Snipped*
t0 := time.Now()
if d := c.server.readHeaderTimeout(); d != 0 {
hdrDeadline = t0.Add(d)
}
if d := c.server.ReadTimeout; d != 0 {
wholeReqDeadline = t0.Add(d)
}
c.rwc.SetReadDeadline(hdrDeadline)
if d := c.server.WriteTimeout; d != 0 {
defer func() {
c.rwc.SetWriteDeadline(time.Now().Add(d))
}()
}
// *Snipped*
}
從上面的摘要中,我們可以知道:我們對服務設置的 timeout 值最終表現為 TCP 連接的 deadline 而不是 HTTP 超時。
所以,deadline 是什么?工作機制是什么?如果我們的請求耗時過長,它們會取消我們的連接嗎?
一種簡單地理解 deadline 的思路是,把它理解為對作用于連接上的特定的行為的發生限制的一個時間點。例如,如果我們設置了一個寫的 deadline,當過了這個 deadline 后,所有對這個連接的寫操作都會被拒絕。
盡管我們可以使用 deadline 來模擬超時操作,但我們還是不能控制處理器完成操作所需的耗時。deadline 作用于連接,因此我們的服務僅在處理器嘗試訪問連接的屬性(如對 http.ResponseWriter 進行寫操作)之后才會返回(錯誤)結果。
為了實際驗證上面的論述,我們來創建一個小的 handler,這個 handler 完成操作所需的耗時相對于我們為服務設置的超時更長:
package main
import (
"fmt"
"io"
"net/http"
"time"
)
func slowHandler(w http.ResponseWriter, req *http.Request) {
time.Sleep(2 * time.Second)
io.WriteString(w, "I am slow!\n")
}
func main() {
srv := http.Server{
Addr: ":8888",
WriteTimeout: 1 * time.Second,
Handler: http.HandlerFunc(slowHandler),
}
if err := srv.ListenAndServe(); err != nil {
fmt.Printf("Server failed: %s\n", err)
}
}
上面的服務有一個 handler,這個 handler 完成操作需要兩秒。另一方面,http.Server 的 WriteTimeout 屬性設為 1 秒。基于服務的這些配置,我們猜測 handler 不能把響應寫到連接。
我們可以用 go run server.go 來啟動服務。使用 curl localhost:8888 來發送一個請求:
$ time curl localhost:8888
curl: (52) Empty reply from server
curl localhost:8888 0.01s user 0.01s system 0% CPU 2.021 total
這個請求需要兩秒來完成處理,服務返回的響應是空的。雖然我們的服務知道在 1 秒之后我們寫不了響應了,但 handler 還是多耗了 100% 的時間(2 秒)來完成處理。
雖然這是個類似超時的處理,但它更大的作用是在到達超時時間時,阻止服務進行更多的操作,結束請求。在我們上面的例子中,handler 在完成之前一直在處理請求,即使已經超出響應寫超時時間(1 秒)100%(耗時 2 秒)。
最根本的問題是,對于處理器來說,我們應該怎么設置超時時間才更有效?
我們的目標是確保我們的 slowHandler 在 1s 內完成處理。如果超過了 1s,我們的服務會停止運行并返回對應的超時錯誤。
在 Go 和一些其它編程語言中,組合往往是設計和開發中最好的方式。標準庫的 `net/http` 包[5]有很多相互兼容的元素,開發者可以不需經過復雜的設計考慮就可以輕易將它們組合在一起。
基于此,net/http 包提供了`TimeoutHandler`[6] — 返回了一個在給定的時間限制內運行的 handler。
函數簽名:
func TimeoutHandler(h Handler, dt time.Duration, msg string) Handler
第一個參數是 Handler,第二個參數是 time.Duration (超時時間),第三個參數是 string 類型,當到達超時時間后返回的信息。
用 TimeoutHandler 來封裝我們的 slowHandler,我們只需要:
package main
import (
"fmt"
"io"
"net/http"
"time"
)
func slowHandler(w http.ResponseWriter, req *http.Request) {
time.Sleep(2 * time.Second)
io.WriteString(w, "I am slow!\n")
}
func main() {
srv := http.Server{
Addr: ":8888",
WriteTimeout: 5 * time.Second,
Handler: http.TimeoutHandler(http.HandlerFunc(slowHandler), 1*time.Second, "Timeout!\n"),
}
if err := srv.ListenAndServe(); err != nil {
fmt.Printf("Server failed: %s\n", err)
}
}
兩個需要留意的地方是:
如果我們再啟動服務,當程序運行到 slow handler 時,會有如下輸出:
$ time curl localhost:8888
Timeout!
curl localhost:8888 0.01s user 0.01s system 1% CPU 1.023 total
1s 后,我們的 TimeoutHandler 開始執行,阻止運行 slowHandler,返回文本信息 ”Timeout!“。如果我們設置信息為空,handler 會返回默認的超時響應信息,如下:
<html>
<head>
<title>Timeout</title>
</head>
<body>
<h1>Timeout</h1>
</body>
</html>
如果忽略掉輸出,這還算是整潔,不是嗎?現在我們的程序不會有過長耗時的處理;也避免了有人惡意發送導致長耗時處理的請求時,導致的潛在的 DoS 攻擊。
盡管我們設置超時時間是一個偉大的開始,但它仍然只是初級的保護。如果你可能會面臨 DoS 攻擊,你應該采用更高級的保護工具和技術。(可以試試 Cloudflare[7] )
我們的 slowHandler 僅僅是個簡單的 demo。但是,如果我們的程序復雜些,能向其他服務和資源發出請求會發生什么呢?如果我們的程序在超時時向諸如 S3 的服務發出了請求會怎么樣?
會發生什么?
我們稍微展開下我們的例子:
func slowAPICall() string {
d := rand.Intn(5)
select {
case <-time.After(time.Duration(d) * time.Second):
log.Printf("Slow API call done after %s seconds.\n", d)
return "foobar"
}
}
func slowHandler(w http.ResponseWriter, r *http.Request) {
result := slowAPICall()
io.WriteString(w, result+"\n")
}
我們假設最初我們不知道 slowHandler 由于通過 slowAPICall 函數向 API 發請求導致需要耗費這么長時間才能處理完成,
slowAPICall 函數很簡單:使用 select 和一個能阻塞 0 到 5 秒的 time.After 。當經過了阻塞的時間后,time.After 方法通過它的 channel 發送一個值,返回 "foobar" 。
(另一種方法是,使用 sleep(time.Duration(rand.Intn(5)) * time.Second),但我們仍然使用 select,因為它會使我們下面的例子更簡單。)
如果我們運行起服務,我們預期超時 handler 會在 1 秒之后中斷請求處理。來發送一個請求驗證一下:
$ time curl localhost:8888
Timeout!
curl localhost:8888 0.01s user 0.01s system 1% CPU 1.021 total
通過觀察服務的輸出,我們會發現,它是在幾秒之后打出日志的,而不是在超時 handler 生效時打出:
$ Go run server.go
2019/12/29 17:20:03 Slow API call done after 4 seconds.
這個現象表明:雖然 1 秒之后請求超時了,但是服務仍然完整地處理了請求。這就是在 4 秒之后才打出日志的原因。
雖然在這個例子里問題很簡單,但是類似的現象在生產中可能變成一個嚴重的問題。例如,當 slowAPICall 函數開啟了幾個百個協程,每個協程都處理一些數據時。或者當它向不同系統發出多個不同的 API 發出請求時。這種耗時長的的進程,它們的請求方/客戶端并不會使用服務端的返回結果,會耗盡你系統的資源。
所以,我們怎么保護系統,使之不會出現類似的未優化的超時或取消請求呢?
Go 有一個包名為 `context`[8] 專門處理類似的場景。
context 包在 Go 1.7 版本中提升為標準庫,在之前的版本中,以golang.org/x/net/context[9] 的路徑作為 Go Sub-repository Packages[10] 出現。
這個包定義了 Context 類型。它最初的目的是保存不同 API 和不同處理的截止時間、取消信號和其他請求相關的值。如果你想了解關于 context 包的其他信息,可以閱讀 Golang's blog[11] 中的 “Go 并發模式:Context”(譯注:Go Concurrency Patterns: Context) .
net/http 包中的的 Request 類型已經有 context 與之綁定。從 Go 1.7 開始,Request 新增了一個返回請求的上下文的 `Context` 方法[12]。對于進來的請求,在客戶端關閉連接、請求被取消(HTTP/2 中)或 ServeHTTP 方法返回后,服務端會取消上下文。
我們期望的現象是,當客戶端取消請求(輸入了 CTRL + C)或一段時間后 TimeoutHandler 繼續執行然后終止請求時,服務端會停止后續的處理。進而關閉所有的連接,釋放所有被運行中的處理進程(及它的所有子協程)占用的資源。
我們把 Context 作為參數傳給 slowAPICall 函數:
func slowAPICall(ctx context.Context) string {
d := rand.Intn(5)
select {
case <-time.After(time.Duration(d) * time.Second):
log.Printf("Slow API call done after %d seconds.\n", d)
return "foobar"
}
}
func slowHandler(w http.ResponseWriter, r *http.Request) {
result := slowAPICall(r.Context())
io.WriteString(w, result+"\n")
}
在例子中我們利用了請求上下文,實際中怎么用呢?`Context` 類型[13]有個 Done 屬性,類型為 <-chan struct{}。當進程處理完成時,Done 關閉,此時表示上下文應該被取消,而這正是例子中我們需要的。
我們在 slowAPICall 函數中用 select 處理 ctx.Done 通道。當我們通過 Done 通道接收一個空的 struct 時,意味著上下文取消,我們需要讓 slowAPICall 函數返回一個空字符串。
func slowAPICall(ctx context.Context) string {
d := rand.Intn(5)
select {
case <-ctx.Done():
log.Printf("slowAPICall was supposed to take %s seconds, but was canceled.", d)
return ""
//time.After() 可能會導致內存泄漏
case <-time.After(time.Duration(d) * time.Second):
log.Printf("Slow API call done after %d seconds.\n", d)
return "foobar"
}
}
(這就是使用 select 而不是 time.Sleep -- 這里我們只能用select 處理 Done 通道。)
在這個簡單的例子中,我們成功得到了結果 -- 當我們從 Done 通道接收值時,我們打印了一行日志到 STDOUT 并返回了一個空字符串。在更復雜的情況下,如發送真實的 API 請求,你可能需要關閉連接或清理文件描述符。
我們再啟動服務,發送一個 cRUL 請求:
# The cURL command:
$ curl localhost:8888
Timeout!
# The server output:
$ Go run server.go
2019/12/30 00:07:15 slowAPICall was supposed to take 2 seconds, but was canceled.
檢查輸出:我們發送了 cRUL 請求到服務,它耗時超過 1 秒,服務取消了 slowAPICall 函數。我們幾乎不需要寫任何代碼。TimeoutHandler 為我們代勞了 -- 當處理耗時超過預期時,TimeoutHandler 終止了處理進程并取消請求上下文。
TimeoutHandler 是在 `timeoutHandler.ServeHTTP` 方法[14] 中取消上下文的:
// Taken from: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L3217-L3263
func (h *timeoutHandler) ServeHTTP(w ResponseWriter, r *Request) {
ctx := h.testContext
if ctx == nil {
var cancelCtx context.CancelFunc
ctx, cancelCtx = context.WithTimeout(r.Context(), h.dt)
defer cancelCtx()
}
r = r.WithContext(ctx)
// *Snipped*
}
上面例子中,我們通過調用 context.WithTimeout 來使用請求上下文。超時值 h.dt (TimeoutHandler 的第二個參數)設置給了上下文。返回的上下文是請求上下文設置了超時值后的一份拷貝。隨后,它作為請求上下文傳給 r.WithContext(ctx)。
context.WithTimeout 方法執行了上下文取消。它返回了 Context 設置了一個超時值之后的副本。當到達超時時間后,就取消上下文。
這里是執行的代碼:
// Taken from: https://github.com/golang/go/blob/bbbc6589/src/context/context.go#L486-L498
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
// Taken from: https://github.com/golang/go/blob/bbbc6589/src/context/context.go#L418-L450
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
// *Snipped*
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
// *Snipped*
if c.err == nil {
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}
這里我們又看到了截止時間。WithDeadline 函數設置了一個 d 到達之后執行的函數。當到達截止時間后,它調用 cancel 方法處理上下文,此方法會關閉上下文的 done 通道并設置上下文的 timer 屬性為nil。
Done 通道的關閉有效地取消了上下文,使我們的 slowAPICall 函數終止了它的執行。這就是 TimeoutHandler 終止耗時長的處理進程的原理。
(如果你想閱讀上面提到的源碼,你可以去看 `cancelCtx` 類型[15]和 `timerCtx` 類型[16])
連接截止時間提供了低級的細粒度控制。雖然它們的名字中含有“超時”,但它們并沒有表現出人們通常期望的“超時”。實際上它們非常強大,但是使用它們有一定的門檻。
另一個角度講,當處理 HTTP 時,我們仍然應該考慮使用 TimeoutHandler。Go 的作者們也選擇使用它,它有多種處理,提供了如此有彈性的處理以至于我們甚至可以對每一個處理使用不同的超時。TimeoutHandler 可以根據我們期望的表現來控制執行進程。
除此之外,TimeoutHandler 完美兼容 context 包。context 包很簡單,包含了取消信號和請求相關的數據,我們可以使用這些數據來使我們的應用更好地處理錯綜復雜的網絡問題。
結束之前,有三個建議。寫 HTTP 服務時,怎么設計超時:
更多關于此主題的文章:
via: https://ieftimov.com/post/make-resilient-golang-net-http-servers-using-timeouts-deadlines-context-cancellation/
作者:Ilija Eftimov[21]譯者:lxbwolf[22]校對:polaris1119[23]
本文由 GCTT[24] 原創編譯,Go 中文網[25] 榮譽推出
[1]
conn 類型: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L248
[2]
函數: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L1765
[3]
處理每一個請求: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L1822
[4]
timeout 值: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L946-L958
[5]
net/http 包: https://golang.org/pkg/net/http
[6]
TimeoutHandler: https://golang.org/pkg/net/http/#TimeoutHandler
[7]
Cloudflare: https://www.cloudflare.com/ddos/
[8]
context: https://golang.org/pkg/context/
[9]
golang.org/x/net/context: https://godoc.org/golang.org/x/net/context
[10]
Go Sub-repository Packages: https://godoc.org/-/subrepo
[11]
Golang's blog: https://blog.golang.org/context
[12]
Context 方法: https://golang.org/pkg/net/http/#Request.Context
[13]
Context 類型: https://golang.org/pkg/context/#Context
[14]
timeoutHandler.ServeHTTP 方法: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L3217-L3263
[15]
cancelCtx 類型: https://github.com/golang/go/blob/bbbc6589dfbc05be2bfa59f51c20f9eaa8d0c531/src/context/context.go#L389-L416
[16]
timerCtx 類型: https://github.com/golang/go/blob/bbbc6589dfbc05be2bfa59f51c20f9eaa8d0c531/src/context/context.go#L472-L484
[17]
Cloudflare's blog: https://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/
[18]
Cloudflare's blog: https://blog.cloudflare.com/exposing-go-on-the-internet/
[19]
Stackoverflow: https://stackoverflow.com/questions/51258952/use-http-timeouthandler-or-readtimeout-writetimeout
[20]
Simon Frey's blog: https://blog.simon-frey.eu/go-as-in-golang-standard-net-http-config-will-break-your-production
[21]
Ilija Eftimov: https://ieftimov.com/
[22]
lxbwolf: https://github.com/lxbwolf
[23]
polaris1119: https://github.com/polaris1119
[24]
GCTT: https://github.com/studygolang/GCTT
[25]
Go 中文網: https://studygolang.com/
Axios 是一個基于 promise 的 HTTP 庫,可以用在瀏覽器和 node.js 中。我們知道 Promise 是 js 異步的一種解決方案,它最大的特性就是可以通過 .then 的方式來進行鏈式調用。
其實說白了axios是對ajax的封裝,axios有的ajax都有,ajax有的axios不一定有,總結一句話就是axios是ajax,ajax不止axios。
axios的使用比較簡單,文檔講得也非常清晰,你應該先閱讀axios的官方文檔:axios文檔。
在html頁面中直接引入使用:
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
以下案例中的get請求地址為crmeb相關演示站地址,可用于測試獲取!
<script>
const url = 'https://store.crmeb.net/api/pc/get_category_product'
axios({
url: url,
method: 'get', // 這里可以省略,默認為get
}).then(res => {
// 返回請求到的數據
console.log(res)
}).catch(err => {
// 返回錯誤信息
console.log(err)
})
</script>
<script>
const url = 'https://store.crmeb.net/api/pc/get_category_product'
axios({
url: url,
method: 'get', // 這里可以省略,默認為get
// 這里的鍵值對會拼接成這樣url?page=1&limit=3
params: {
page: 1,
limit: 3
}
}).then(res => {
// 返回請求到的數據
console.log(res)
}).catch(err => {
// 返回錯誤信息
console.log(err)
})
</script>
<script>
axios({
method: 'post',
url: '/user/12345',
data: {
firstName: 'Fred',
lastName: 'Flintstone'
}
}).then(res => {
// 返回請求到的數據
console.log(res)
}).catch(err => {
// 返回錯誤信息
console.log(err)
});
</script>
如果在開發中需要等到多個接口的數據同時請求到后才能繼續后邊的邏輯,那么即可使用并發請求,axios并發請求,使用all方法,all方法的參數為一個數組,數組的每個值可以為一次請求,請求完成后直接.then即可合并兩次請求的數據,返回結果為一個數組!
<script>
axios.all([
axios({
url: 'https://store.crmeb.net/api/pc/get_products',
params: {
page: 1,
limit: 20,
cid: 57,
sid: 0,
priceOrder: '',
news: 0,
}
}),
axios({
url: 'https://store.crmeb.net/api/pc/get_company_info',
})
]).then(results => {
console.log(results)
})
</script>
如果你想自動把這個數組展開的話在then()方法中傳入axios.spread()方法即可,如下所示:
<script>
axios.all([
axios({
url: 'https://store.crmeb.net/api/pc/get_products',
params: {
page: 1,
limit: 20,
cid: 57,
sid: 0,
priceOrder: '',
news: 0,
}
}),
axios({
url: 'https://store.crmeb.net/api/pc/get_company_info',
})
]).then(axios.spread((res1, res2) => {
console.log(res1);
console.log(res2);
}))
</script>
但在使用vue組件化開發的時候一般我們會通過npm安裝,引入項目!
npm install axios --save
一般在實際項目中我們并不會像上邊這樣直接去使用axios請求數據,而是將axios封裝在一個單獨的文件,這樣做的目的主要是用來抽取公共邏輯到一個配置文件里,對這些公共邏輯做一個封裝,即使某一天這個axios框架不維護了,或者出現了重大bug也不再修復的時候,我們可以只修改配置文件即可達到全局修改的目的,如果把每次請求邏輯都寫到對應的組件中,那修改起來簡直就是一個噩夢!
在項目的src目錄下創建一個network文件夾,再在其中創建一個request.js文件,路徑為:src/network/request.js
// src/network/request.js
// 引入axios
import axios from 'axios'
// 這里未使用default導出,是為了以后的擴展,便于導出多個方法
export function request(config){
// 創建axios實例
const instance = axios.create({
// 這里定義每次請求的公共數據,例如全局請求頭,api根地址,過期時間等
// 具體參數可參考axios的官方文檔
baseURL: 'http://demo26.crmeb.net/api',
timeout: 5000
})
// 攔截請求,如果獲取某個請求需要攜帶一些額外數據
instance.interceptors.request.use(
config => {
console.log(config);
return config;
}, err => {
console.log(err);
})
// 攔截響應
instance.interceptors.response.use(
res => {
console.log(res)
return res.data
}, err => {
console.log(err)
}
)
// 發送請求
return instance(config)
一般我們會將所有的請求放在一個api.js文件中,將每次請求封裝為一個方法,比如我這里會在request.js的同目錄創建一個api.js文件封裝我們所有的請求。
import { request } from '../api/request'
// 獲取分類
export const getHomeCategory = () => {
return request({
url: '/category'
})
}
// 獲取banner圖
export const getHomeBanner = () => {
return request({
url: '/pc/get_banner'
})
}
之后再在組件中引入調用導出的相關接口方法即可,如:
import { getHomeBanner } from "../network/api"
getHomeBanner().then(res => {
console.log(res)
})
以上就是一個簡單的封裝,其中有個攔截請求和攔截響應,可能很多初學的人理解起來有點吃力,我在這里以個人淺見闡述,希望能帶給你些許啟發!
還是發揮閱讀理解能力,攔截攔截其實就是此路是我開,此樹是我栽,要想過此路,留下買路錢,攔截請求就是比如某些請求需要攜帶一些額外的信息才能訪問,實際項目中最常見的就是需要登錄后才能查看的信息,請求中就必須攜帶token才能訪問,就可以在這里處理,還有攔截響應,比如請求到數據之后,發現不符合要求,先攔下來處理一下,再返回給前端,這就是一個攔截器的基本工作流程!
如下所示:
// 攔截請求,如果獲取某個請求需要攜帶一些額外數據
instance.interceptors.request.use(
config => {
console.log(config);
return config;
}, err => {
console.log(err);
})
// 攔截響應
instance.interceptors.response.use(
res => {
console.log(res)
return res.data
}, err => {
console.log(err)
}
)
axios還為我們提供了一些全局配置,如下:
axios.defaults.baseURL = 'https://api.example.com';
axios.defaults.headers.common['Authorization'] = AUTH_TOKEN;
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
當然也可以將其配置在我們之前創建的axios實例中,使其只作用于某個實例!
然后來看一下 axios 的所有配置信息:
數據來自axios中文文檔
*請認真填寫需求信息,我們會在24小時內與您取得聯系。