前言
故事發(fā)生的時(shí)間距離今天其實(shí)已經(jīng)快一個月了,由于一些特(tuo)殊(yan)的原因,今天終于動筆開始寫(shui)下了這篇博文, 再不寫都?明年了 。
ChatGPT 想必是過去的一個月里相當(dāng)火熱的話題了,抱著一些好奇心,我也注冊并嘗試了搜索了一些內(nèi)容。讓我覺得相當(dāng)驚艷的是它可以通過不斷的反問來驗(yàn)證自己對于一些概念理解的正確性,十分 nice。
今天要聊的這部分內(nèi)容也是在使用 ChatGPT 過程中發(fā)現(xiàn)的,文章內(nèi)容較主觀,歡迎討論。
1. 起因
事情的起因是某一天我在群里提起 ChatGPT ,順手截了幾張圖,這個時(shí)候有一位群友 Billdex 提到搜一下 singleflight 。
我:GoLang 中的 Singleflight 是什么?
ChatGPT: Singleflight 是一個 Go 語言庫, 它提供了一種方法來管理并發(fā)的函數(shù)調(diào)用,并確保只有一個函數(shù)在同一時(shí)間被調(diào)用。它的目的是為了防止并發(fā)的函數(shù)調(diào)用之間的競爭條件,從而避免不必要的資源浪費(fèi)和性能問題。
我:它的實(shí)現(xiàn)原理是什么
ChatGPT: Singleflight 庫實(shí)現(xiàn)的原理是通過一個獨(dú)占鎖來控制對函數(shù)的并發(fā)調(diào)用。當(dāng)調(diào)用singleflight.Do() 函數(shù)時(shí),它會對函數(shù)進(jìn)行攔截,并將它們放到一個隊(duì)列中,并通過獨(dú)占鎖來確保只有一個函數(shù)在同一時(shí)間被調(diào)用。如果有其他的并發(fā)調(diào)用請求,它們會被掛起直到當(dāng)前的函數(shù)調(diào)用完成。這樣就可以避免多個函數(shù)在同一時(shí)間被調(diào)用,從而避免競爭條件的產(chǎn)生。
我對 GoLang 可謂一竅不通,本來這件事到這里就該結(jié)束了,隨后會淹沒在更多的消息記錄的海洋里。
但好巧不巧群里有個寫 CPP 的小伙伴 hajimeru 碰巧路過,于是群里出現(xiàn)了這樣一段對話:
hajimeru: 這是事件隊(duì)列的思路?
Billdex: 不太一樣,倒是有點(diǎn)類似于前端的接口防抖,用在后端做并發(fā)控制
如前面所說,我既不會 GoLang 對后端了解的也不多,但你要說前端的防抖函數(shù)那我可不困了,這段對話一下就勾起了我的好奇心。
2. 防抖函數(shù)
函數(shù)防抖是前端常用的優(yōu)化手段之一,如果一個函數(shù)在 n 秒內(nèi)被反復(fù)調(diào)用,函數(shù)防抖會重新計(jì)算函數(shù)的執(zhí)行時(shí)間以確保在指定的時(shí)間內(nèi)只執(zhí)行一次。
一個比較常見的例子是搜索框?qū)崿F(xiàn),從產(chǎn)品的角度來說,產(chǎn)品每多一步操作都可能導(dǎo)致用戶的流失,因而許多產(chǎn)品都將搜索設(shè)計(jì)為隨著用戶輸入自動響應(yīng)搜索結(jié)果的交互形式。
做前端的同學(xué)都知道,實(shí)現(xiàn)這樣的功能是一定少不了防抖函數(shù)的,通過監(jiān)聽搜索框的輸入事件進(jìn)行請求會產(chǎn)生多次無用的請求,而用戶往往只需要最后一次請求作為結(jié)果,這樣很容易帶來服務(wù)的性能浪費(fèi)。
于是在這里,我過往對于防抖函數(shù)的一個錯覺就產(chǎn)生了: 防抖函數(shù)的作用是不斷的推后函數(shù)執(zhí)行的時(shí)間直到超時(shí) 。
那么自然而然的,我也認(rèn)為它與 Singleflight 用來規(guī)避條件競爭不是一碼事。
3. 問問 AI 吧
我:可以認(rèn)為 Singleflight 避免函數(shù)調(diào)用條件競爭的策略與 UI 編程中的函數(shù)防抖類似嗎?
ChatGPT: 是的,Singleflight 避免函數(shù)調(diào)用條件競爭的策略與 UI 編程中的函數(shù)防抖類似。函數(shù)防抖也是一種控制函數(shù)調(diào)用的方法,它的目的是為了避免頻繁的函數(shù)調(diào)用,從而降低系統(tǒng)的性能。它的實(shí)現(xiàn)原理是通過設(shè)置一個定時(shí)器, 在函數(shù)調(diào)用之后延遲一段時(shí)間來執(zhí)行函數(shù)。如果在定時(shí)器超時(shí)前再次調(diào)用該函數(shù),定時(shí)器會被重置,直到超時(shí)時(shí)才真正執(zhí)行函數(shù)。這樣就可以避免頻繁的函數(shù)調(diào)用,提高系統(tǒng)的性能。Singleflight 庫也是通過類似的方法來避免函數(shù)調(diào)用條件競爭,它通過獨(dú)占鎖來控制函數(shù)調(diào)用,確保只有一個函數(shù)在同一時(shí)間被調(diào)用,從而避免競爭條件的產(chǎn)生。
在看到這個答案以后的最初幾秒里,我是詫異的。隨后我的腦子里閃現(xiàn)出另外一件事情,我忽然意識到我可能錯過了什么東西,進(jìn)一步的,我意識到過去對函數(shù)防抖的理解可能是片面的。
4. 條件競態(tài)
提到條件競態(tài),長期使用 JavaScript 的我總是覺得與我無瓜,畢竟 JavaScript 是單線程的語言,而我通常見到談?wù)摋l件競態(tài)的場景幾乎都是多線程場景。
隨便翻閱了幾篇某金,某乎,某SDN的文章,大家都在用一個共同的例子來演示條件競態(tài)的現(xiàn)象,兩個線程交替執(zhí)行的過程中,由于 執(zhí)行順序是不可預(yù)估的 ,且兩者 都會修改各自持有的內(nèi)存里的同一個變量 ,于是最后的代碼執(zhí)行結(jié)果與執(zhí)行順序一樣變成了不可預(yù)估的。
稍等一下。
我們都知道,JavaScript 的異步任務(wù)執(zhí)行時(shí)機(jī)是不可預(yù)估的,假設(shè)有如下代碼,回調(diào)函數(shù)一定會在 1000ms 后執(zhí)行:
setTimeout(() => console.log('run!'), 1000);
但如果位于它上方的代碼是這樣的呢:
while (true) {}
setTimeout(() => console.log('run!'), 1000);
恐怕這個 setTimeout 這輩子都不會執(zhí)行了。
放在網(wǎng)絡(luò)請求場合下,這件事情就變得更加沒譜了,按順序發(fā)送的 n 個請求響應(yīng)的時(shí)間是不可預(yù)估的。
這也許是一個大家都知道,但又比較容易被忽視的細(xì)節(jié):JavaScript 不僅有能力,還非常容易達(dá)成這種 執(zhí)行順序不可預(yù)估 的情況。
另一方面,JavaScript 數(shù)據(jù)區(qū)分引用類型和值類型。被常用來做為業(yè)務(wù)數(shù)據(jù)抽象的對象,數(shù)組等均屬于引用類型,以下代碼很好的說明了問題:
const obj = {
name: 'yuchi',
};
const copy1 = obj;
copy1.name = 'xiaoming';
console.log(copy1); // { name: 'xiaoming' }
console.log(obj); // { name: 'xiaoming' }
接下來讓我們把這兩者組合在一起,當(dāng)多個 不可預(yù)估執(zhí)行順序 的異步任務(wù)同時(shí)在回調(diào)函數(shù)里修改同一個 引用類型 時(shí),會發(fā)生什么?
fetchA().then(res => {
setData(res);
});
fetchB().then(res => {
setData(res);
});
fetchC().then(res => {
setData(res);
});
fetchD().then(res => {
setData(res);
});
由于多次請求的執(zhí)行順序是不可預(yù)估的,最終 data 的值可能是四個請求中的任意一個,也可能是請求發(fā)送前的初始值(四個請求都失敗了)。
當(dāng)自動搜索的需求不添加防抖的策略時(shí),其執(zhí)行過程本質(zhì)上與上述代碼是幾乎一致的。
具體來說的表現(xiàn)為:
- 伴隨用戶每一次輸入,
keyword: abc最終產(chǎn)生了 3 次請求,我們分別標(biāo)記為search('a') | search('ab') | search('abc'),它們對應(yīng)的響應(yīng)回調(diào)函數(shù)我們標(biāo)記為callback1 | callback2 | callback3 - 由于接口請求返回的順序不可預(yù)估,則最后一次執(zhí)行的回調(diào)函數(shù)可能是
callback1 | callback2 | callback3中的任意一個。 - 變更
UI,最終呈現(xiàn)出的檢索結(jié)果可能是search('a') | search('ab') | search('abc')中的任意一個。
發(fā)現(xiàn)了嗎,這和前面提到的多個線程交替執(zhí)行修改共享內(nèi)存的變量導(dǎo)致結(jié)果不可預(yù)估的例子如出一轍。
也就是說,單線程異步的 JavaScript 代碼 不僅可能出現(xiàn)條件競態(tài),還很容易 。
5. 再看防抖函數(shù)
回頭過來再看看防抖函數(shù),我忽然間意識到,不斷推遲函數(shù)執(zhí)行時(shí)機(jī)的防抖策略實(shí)際上也一同幫我解決了這種異步函數(shù)高頻觸發(fā)場景下的條件競態(tài)問題。
當(dāng)然,這并不意味著在前端的條件競態(tài)場景下,我們只能或應(yīng)該通過函數(shù)防抖來解決,具體場景有具體的解決方案。
它帶給我的啟發(fā)是:一直以來我可能都忽略了 防抖函數(shù)通過放棄抖動請求規(guī)避條件競態(tài) 的這一使用場景。
也就是說,ChatGPT 的回答和 Billdex 的理解是對的,這很有趣??。