前言

故事发生的时间距离今天其实已经快一个月了,由于一些特(tuo)殊(yan)的原因,今天终于动笔开始写(shui)下了这篇博文, 再不写都™明年了

ChatGPT 想必是过去的一个月里相当火热的话题了,抱着一些好奇心,我也注册并尝试了搜索了一些内容。让我觉得相当惊艳的是它可以通过不断的反问来验证自己对于一些概念理解的正确性,十分 nice。

今天要聊的这部分内容也是在使用 ChatGPT 过程中发现的,文章内容较主观,欢迎讨论。

1. 起因

事情的起因是某一天我在群里提起 ChatGPT ,顺手截了几张图,这个时候有一位群友 Billdex 提到搜一下 singleflight

我:GoLang 中的 Singleflight 是什么?

ChatGPT: Singleflight 是一个 Go 语言库, 它提供了一种方法来管理并发的函数调用,并确保只有一个函数在同一时间被调用。它的目的是为了防止并发的函数调用之间的竞争条件,从而避免不必要的资源浪费和性能问题。

我:它的实现原理是什么

ChatGPT: Singleflight 库实现的原理是通过一个独占锁来控制对函数的并发调用。当调用singleflight.Do() 函数时,它会对函数进行拦截,并将它们放到一个队列中,并通过独占锁来确保只有一个函数在同一时间被调用。如果有其他的并发调用请求,它们会被挂起直到当前的函数调用完成。这样就可以避免多个函数在同一时间被调用,从而避免竞争条件的产生。

我对 GoLang 可谓一窍不通,本来这件事到这里就该结束了,随后会淹没在更多的消息记录的海洋里。

但好巧不巧群里有个写 CPP 的小伙伴 hajimeru 碰巧路过,于是群里出现了这样一段对话:

hajimeru: 这是事件队列的思路?

Billdex: 不太一样,倒是有点类似于前端的接口防抖,用在后端做并发控制

如前面所说,我既不会 GoLang 对后端了解的也不多,但你要说前端的防抖函数那我可不困了,这段对话一下就勾起了我的好奇心。

2. 防抖函数

函数防抖是前端常用的优化手段之一,如果一个函数在 n 秒内被反复调用,函数防抖会重新计算函数的执行时间以确保在指定的时间内只执行一次。

一个比较常见的例子是搜索框实现,从产品的角度来说,产品每多一步操作都可能导致用户的流失,因而许多产品都将搜索设计为随着用户输入自动响应搜索结果的交互形式。

做前端的同学都知道,实现这样的功能是一定少不了防抖函数的,通过监听搜索框的输入事件进行请求会产生多次无用的请求,而用户往往只需要最后一次请求作为结果,这样很容易带来服务的性能浪费。

于是在这里,我过往对于防抖函数的一个错觉就产生了: 防抖函数的作用是不断的推后函数执行的时间直到超时

那么自然而然的,我也认为它与 Singleflight 用来规避条件竞争不是一码事。

3. 问问 AI 吧

我:可以认为 Singleflight 避免函数调用条件竞争的策略与 UI 编程中的函数防抖类似吗?

ChatGPT: 是的,Singleflight 避免函数调用条件竞争的策略与 UI 编程中的函数防抖类似。函数防抖也是一种控制函数调用的方法,它的目的是为了避免频繁的函数调用,从而降低系统的性能。它的实现原理是通过设置一个定时器, 在函数调用之后延迟一段时间来执行函数。如果在定时器超时前再次调用该函数,定时器会被重置,直到超时时才真正执行函数。这样就可以避免频繁的函数调用,提高系统的性能。Singleflight 库也是通过类似的方法来避免函数调用条件竞争,它通过独占锁来控制函数调用,确保只有一个函数在同一时间被调用,从而避免竞争条件的产生。

在看到这个答案以后的最初几秒里,我是诧异的。随后我的脑子里闪现出另外一件事情,我忽然意识到我可能错过了什么东西,进一步的,我意识到过去对函数防抖的理解可能是片面的。

4. 条件竞态

提到条件竞态,长期使用 JavaScript 的我总是觉得与我无瓜,毕竟 JavaScript 是单线程的语言,而我通常见到谈论条件竞态的场景几乎都是多线程场景。

随便翻阅了几篇某金,某乎,某SDN的文章,大家都在用一个共同的例子来演示条件竞态的现象,两个线程交替执行的过程中,由于 执行顺序是不可预估的 ,且两者 都会修改各自持有的内存里的同一个变量 ,于是最后的代码执行结果与执行顺序一样变成了不可预估的。

稍等一下。

我们都知道,JavaScript 的异步任务执行时机是不可预估的,假设有如下代码,回调函数一定会在 1000ms 后执行:

1
setTimeout(() => console.log('run!'), 1000);

但如果位于它上方的代码是这样的呢:

1
2
3
while (true) {}

setTimeout(() => console.log('run!'), 1000);

恐怕这个 setTimeout 这辈子都不会执行了。

放在网络请求场合下,这件事情就变得更加没谱了,按顺序发送的 n 个请求响应的时间是不可预估的。

这也许是一个大家都知道,但又比较容易被忽视的细节:JavaScript 不仅有能力,还非常容易达成这种 执行顺序不可预估 的情况。

另一方面,JavaScript 数据区分引用类型和值类型。被常用来做为业务数据抽象的对象,数组等均属于引用类型,以下代码很好的说明了问题:

1
2
3
4
5
6
7
8
9
const obj = {
name: 'yuchi',
};

const copy1 = obj;
copy1.name = 'xiaoming';

console.log(copy1); // { name: 'xiaoming' }
console.log(obj); // { name: 'xiaoming' }

接下来让我们把这两者组合在一起,当多个 不可预估执行顺序 的异步任务同时在回调函数里修改同一个 引用类型 时,会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fetchA().then(res => {
setData(res);
});

fetchB().then(res => {
setData(res);
});

fetchC().then(res => {
setData(res);
});

fetchD().then(res => {
setData(res);
});

由于多次请求的执行顺序是不可预估的,最终 data 的值可能是四个请求中的任意一个,也可能是请求发送前的初始值(四个请求都失败了)。

当自动搜索的需求不添加防抖的策略时,其执行过程本质上与上述代码是几乎一致的。

具体来说的表现为:

  1. 伴随用户每一次输入,keyword: abc 最终产生了 3 次请求,我们分别标记为 search('a') | search('ab') | search('abc') ,它们对应的响应回调函数我们标记为 callback1 | callback2 | callback3
  2. 由于接口请求返回的顺序不可预估,则最后一次执行的回调函数可能是 callback1 | callback2 | callback3 中的任意一个。
  3. 变更 UI ,最终呈现出的检索结果可能是 search('a') | search('ab') | search('abc') 中的任意一个。

发现了吗,这和前面提到的多个线程交替执行修改共享内存的变量导致结果不可预估的例子如出一辙。

也就是说,单线程异步的 JavaScript 代码 不仅可能出现条件竞态,还很容易

5. 再看防抖函数

回头过来再看看防抖函数,我忽然间意识到,不断推迟函数执行时机的防抖策略实际上也一同帮我解决了这种异步函数高频触发场景下的条件竞态问题。

当然,这并不意味着在前端的条件竞态场景下,我们只能或应该通过函数防抖来解决,具体场景有具体的解决方案。

它带给我的启发是:一直以来我可能都忽略了 防抖函数通过放弃抖动请求规避条件竞态 的这一使用场景。

也就是说,ChatGPT 的回答和 Billdex 的理解是对的,这很有趣🙌。