javascript
关于JavaScript并发、竞态场景下的一些思考和解决方案
前言
時間是程序里最復雜的因素
編寫 Web 應用的時候,一般來說,我們大多時候處理的都是同步的、線性的業務邏輯。但是正如開篇所說的“時間是程序里最復雜的因素”,應用一旦復雜,往往會遭遇很多異步問題,如果代碼中涉及到到多個異步的時候,這時候就需要慎重考慮了,我們需要的意識到的是:
到底我們的異步邏輯是易讀的么?可維護的么?哪些是并發場景,哪些是競態場景,我們有什么對策么?注意提提神!以下全程需要集中精神思考 ?
解決問題之前
在拋出具體的解決問題的技術方案之前。首先探討一下我們常見的請求會遇到的問題。
請求時序問題
一般而言,在前端而言我們經常遇到的異步場景,是請求問題。(當然對應到后端,有可能是各種 IO 操作,比如讀寫文件、操作數據庫等)。
那筆者為何談到請求,因為大多人都會忽略此類問題。我們往往有時候會發出多個同類型的請求(不一定符合我們意愿),但是每每覺得自己的應用十分健壯,實際上如果沒有當心控制“野獸”的話,實際上應用也會相當脆弱!
如下圖,應用依照 A1 -> A2 -> A3 順序發起請求,我們也期望的是 A1 -> A2 -> A3 的順序返回響應給應用。
但實際上呢。但是每個請求都是十分野性的。我們根本無法把控它哪時候回來!請求的響應順序極大程度依賴用戶的網絡環境。比如上圖的響應順序實際上就是 A3 -> A1 -> A2,此時應用將有概率會變得一團糟!
不過也不用擔心,實際上,一旦當你注意問題的時候,其實就離解決問題不遠了。
那么我們常見的做法會有什么呢?
結束標記
通過應用中的標記狀態,在需求請求完成后,標記成功,忽略多余請求,可以巧妙避開請求競態的陷阱。由于此寫法比較常見,不再贅述。
隊列化
將請求串行!某些特殊場景下可以使用。在時間線上將多個異步拍平成一條線。野獸請求們依序進入隊列(相當于我們給請求們拉起了韁繩,劃好了奔跑的道路),如下圖:
只有當 A1 請求響應時,才進行 A2 請求,A2 響應成功時,進行 A3 請求。同理以此類推。(注意雖然請求的順序強行被修改為串行,但并不意味這發起請求的動作也是串行)。因此在從時間維度上大大簡化了場景,極大的減少了 bug 的發生概率。
缺點也很明顯,請求串行后阻塞了,某些場景下也許做了很多無用功。
取消請求 + 最新
有同學們就會覺得,效率是否略顯低下,既然我們前面的請求雖然依序生效了,但是最終很快都會被最新的請求結果所替換,那么還做那么多無用功干嘛?是的,的確不應當這么做!如圖:
凡是有新的請求產生,取消上一個還在路上的請求(原生的 XMLHttpRequest.abort()、axios 的 cancelToken),然后只取最新的一個請求,靜靜等待它的響應。比如 redux-saga 中 takeLatest。
(但是請同學們注意,如果需要每一個請求都對服務器產生效果,比如 POST 請求等,有時候隊列也不失為一個好的解決方式)
問題以及背景
上文其實算是一個引子,接下來我將并發競態的問題抽象簡化為以下代碼,請看:
// 模擬了一個 ajax 請求函數,對于每一個請求有一個隨機延時 function ajax(url, cb) {let fake_responses = {file1: "The first text",file2: "The middle text",file3: "The last text"};let wait = (Math.round(Math.random() * 1e4) % 8000) + 1000;console.log("Requesting: " + url + `, time cost: ${wait} ms`);setTimeout(() => {cb(fake_responses[url]);}, wait); }function output(text) {console.log(text); } 復制代碼那么如何實現一個 getFile 函數,使得可以并行請求,然后依照請求順序打印響應的值,最終異步完成后打印完成。(注意,此處考慮并發場景)
getFile("file1"); getFile("file2"); getFile("file3"); 復制代碼期望結果:
Requesting: file1, time cost: 8233 ms Requesting: file2, time cost: 2581 ms Requesting: file3, time cost: 7334 ms The first text The middle text The last text Complete! get files total time: 8247.093ms 復制代碼下文將和大家介紹從編寫實現上如何解決并發競態的問題的幾種方案!
解決方案:Thunks
什么是 Thunk
Thunk 這個詞是起源于“思考”的幽默過去式的意思。它本質上就是一個延遲執行計算的函數。比如下述:
// 對于下述 1 + 2 計算是即時的 // x === 3 let x = 1 + 2;// 1 + 2 的計算是延遲的 // 函數 foo 可以稍后調用進行值的計算 // 所以函數 foo 就是一個 thunk let foo = () => 1 + 2; 復制代碼那么我們來實現一個 getFile 函數如下:
function getFile(file) {let resp;ajax(file, text => {if (resp) resp(text);else resp = text;});return function thunk(cb) {if (resp) cb(resp);else resp = cb;}; } 復制代碼注意我們如上有一個很有趣的實現,實際上在調用 getFile 函數的時候,內部就已經發生了 ajax 請求(因此請求并沒有被阻塞),但是真正返回響應的邏輯放在了 thunk 中。
因此,業務邏輯如下:
let thunk1 = getFile("file1"); let thunk2 = getFile("file2"); let thunk3 = getFile("file3");thunk1(text => {output(text);thunk2(text => {output(text);thunk3(text => {output(text);output("Complete!");});}); }); 復制代碼調用后,很好實現了我們的需求!但是!但是同學們也發現了,還是難免陷入了回調地獄,寫法還是不好維護,換而言之,還是不夠優雅~
嗯...有什么辦法呢?
中間件
近幾年,中間件的思想和使用十分流行,或者我們可以嘗試使用中間件方式實現一下?
首先我們寫一個簡單的 compose 函數如下(當然此場景下我們并不關注中間件的上下文,因此簡化其實現):
function compose(...mdws) {return () => {function next() {const mdw = mdws.shift();mdw && mdw(next);}mdws.shift()(next);}; } 復制代碼那我們的 getFile 函數實現也得稍微改一下,讓返回的 thunk 函數可以交由中間件的 next 控制:
function getFileMiddleware(file, cb) {let resp;ajax(file, function(text) {if (!resp) resp = text;else resp(text);});return next => {const _next = args => {cb && cb(args);next(args);};if (resp) {_next(resp);} else {resp = _next;}}; } 復制代碼基于上述兩個實現。我們最終的寫法可以修改為以下形式:
const middlewares = [getFileMiddleware("file1", output),getFileMiddleware("file2", output),getFileMiddleware("file3", resp => {output(resp);output("Complete!");}) ];compose(...middlewares)(); 復制代碼最終輸出結果仍然滿足我們對并發控制的需求!但是寫法上優雅了不少!篇幅有限,就不貼上結果了,同學們可驗證一下~
解決方案:Promises
到目前為止。我們都沒有好好利用 JavaScript 送給我們的禮物“Promise”。Promise 是一個對未來的值的容器。利用 Promise 也能很好的完成我們的需求。
如下,實現 getFile 函數:
function getFile(file) {return new Promise(function(resolve) {ajax(file, resolve);}); } 復制代碼來來來,調用一下
const p1 = getFile("file1"); const p2 = getFile("file2"); const p3 = getFile("file3");p1.then(t1 => {output(t1);p2.then(t2 => {output(t2);p3.then(t3 => {output(t3);output("Complete!");});}); }); 復制代碼一樣滿足,但是?我們又陷入了 Promise 地獄...
對 Promise 地獄 Say NO
如果寫出了上述的 Promise 地獄,證明對 Promise 的了解還不夠,事實上也背離了 Promise 的設計初衷。我們可以改為下述寫法:
const p1 = getFile("file1"); const p2 = getFile("file2"); const p3 = getFile("file3"); const constant = v => () => v;p1.then(output).then(constant(p2)).then(output).then(constant(p3)).then(output).then(() => {output("Complete!");}); 復制代碼嗯哼~又更加優雅了點。Promise 地獄不見啦~
更加函數式的 Promise 方式
首先我要承認。我現在是,未來也是函數式編程的忠實擁護者。因此上述寫法雖然減少了嵌套,但是還是覺得略顯無聊,如果有一百個文件等待請求,難道我們還有手寫一百個 getFile,還有數不清的 then 么?
問題來了,如何再一步改進呢?我們好好思考一下。
首先他們是一個重復的事情,既然重復那就可以抽象,在加上我們函數式工具 reduce 方法,改進如下:
const urls = ["file1", "file2", "file3"]; const getFilePromises = urls.map(getFile); const constant = v => () => v;getFilePromises.concat(Promise.resolve("Complete!"), Promise.resolve()).reduce((chain, filePromise) => {return chain.then(output).then(constant(filePromise));}); 復制代碼問題解決,并且優雅~(同學們可能留意到我 concat 了一個 Promise.resolve,是因為此處 reduce 中總需要下個 Promise 承接上一個的值進行執行,細節實現問題,無需介意)。
解決方案:Generators
Generator 是狀態機的一種語法形式。
ES6 中還有一個解決異步問題的新朋友 generator。同理我們來用 generator 來實現需求。這里我們使用 co 來簡化 generator 的調用。
const co = require("co");function getFile(file) {return new Promise(function(resolve) {ajax(file, resolve);}); }function* loadFiles() {const p1 = getFile("file1");const p2 = getFile("file2");const p3 = getFile("file3");output(yield p1);output(yield p2);output(yield p3);output("Complete!"); }co(loadFiles); 復制代碼一樣的完成了需求,我們又多了一種解決問題的思路對吧~ generator 其實在解決異步問題上的能量超乎想象。值得我們花費多點時間學習!
等等,貌似我們在硬編碼,再改進一下吧~
function loadFiles(urls) {const getFilePromises = urls.map(getFile);return function* gen() {do {output(yield getFilePromises.shift());} while (getFilePromises.length > 0);output("Complete!");}; }co(loadFiles(["file1", "file2", "file3"])); 復制代碼好啦!Perfect~
解決方案:async/await
既然寫到了這里,我們也用 ES7 中出現的 async/await 寫一下實現方案吧!
async function loadFiles(urls) {const getFilePromises = urls.map(getFile);do {const res = await getFilePromises.shift();output(res);} while (getFilePromises.length > 0);output("Complete!"); }loadFiles(["file1", "file2", "file3"]); 復制代碼當然,其實和 generator 的實現寫法上大致無什么差異,但是在寫法上提升了可讀性~
小結
關于異步請求,是明顯的副作用,可謂名副其實的“野獸”。除了上述提到的一些方法外,我們應該永不停止尋找更好更優雅的范式去處理這類情況,比如響應式編程、亦或者函數式編程中的 IO functor 等。
對異步的掌控也許還需要我們了解 JavaScript 事件循環、任務隊列、RxJS 等相關知識、還是要去學習更多范式和思維方式,與時間交朋友,而不是與之為敵。
以上。對大家如有助益,不勝榮幸。
參考資料
- what-is-a-thunk
- Stack Overflow: Dispatching Redux Actions with a Timeout
- Stack Overflow: Why do we need middleware for async flow in Redux?
- co
- ES6 Generators: Complete Series
- 3 cases where JavaScript generators rock (+ understanding them)
- the-definitive-guide-to-the-javascript-generators
- ES6 generators in depth
- Race Conditions in JavaScript Apps by Thai Pangsakulyanont | JSConf.Asia 2019
- redux-thunk
- What the heck is the event loop anyway? - Philip Roberts - JSConf EU 2014
- Further Adventures of the Event Loop - Erin Zimmer - JSConf EU 2018
轉載于:https://juejin.im/post/5d29e35e51882557bd7cebde
總結
以上是生活随笔為你收集整理的关于JavaScript并发、竞态场景下的一些思考和解决方案的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 货车轮胎冒烟等多久才能开?
- 下一篇: gradle idea java ssm