查看原文
其他

【第2682期】前端场景下请求的Race Conditions

Elvira 前端早读课 2022-07-26

前言

今日前端早读课文章由网易伏羲 Web 开发团队 @Elvira 分享,由公号:@伏羲 web 授权。

@伏羲 Web 开发团队,负责网易伏羲全业务产品的工程开发,包括不限于元宇宙、人机协作 PaaS 平台、低代码、Web 3D 可视化等,致力于服务产研团队,提升产品品质、开发效率,创新以及前沿技术方向的探索与落地应用。

正文从这开始~~
race conditions 常被翻译为 “竞争条件”/“竞态问题”/“竞争冒险” 等,在多线程、分布式场景中较为常见。在前端日常开发工作中,请求也会遇到竞态的问题。本文就前端网络请求竞态的问题分析若干种解决方法,主要以 React 技术栈为背景。

一、问题描述

A race condition or race hazard is the condition of an electronics, software, or other system where the system's substantive behavior is dependent on the sequence or timing of other uncontrollable events. It becomes a bug when one or more of the possible behaviors is undesirable.
Race conditions can occur especially in logic circuits, multithreaded, or distributed software programs.
—— Wikipedia

竞争条件或者又叫竞争冒险问题,有些翻译也叫竞态条件,是指在电子 / 软件系统中出现的一种特定情况:该系统实质上的运行结果依赖于其他不可控事件的执行顺序或者时间。从软件开发的角度来讲,这就会让我们的程序运行结果变得不可预测,极易出现问题,而且通常是难复现的。(注:不可控指不是由开发者控制,可能是操作系统或其它第三程序等)

race conditions 经常出现在逻辑电路、多线程或分布式软件程序中,比如文件系统、分布式网络(存在延迟情况下)、还有计算机安全等方面;产生这种现象的过程,从操作系统层面看是这样的:

我们知道,在操作系统中,操作系统会为每个线程分配单独的寄存器。如下图中所示,第一 / 二列分别表示线程 1/2 的操作过程,写着数字的小方块代表了该线程所用的寄存器。第三列表示内存中的一个存储单元,存放了经两个线程操作后的结果。

开始时,数据都是放在内存中的,通过 LOAD 指令,把数据加载到寄存器里,再执行相应的操作指令,这里 SUB 表示指令减。指令执行结束后,结果是存储在寄存器里的,这时候内存里的数值是没有改变的。最后再执行 STORE 指令后,寄存器中的数值被存储到内存中。(注:Load/Store 指令用于寄存器和内存间数据的传送)

但如果两个线程在没有任何约束的情况下,稍微改变读写的顺序,就会出现意想不到的错误。如下图所示,线程 2 会覆盖线程 1 的执行结果,这也就是两个线程抢占使用临界区资源而带来的数据不一致问题。

具体在生活中的例子,就比如火车站售票系统,同一时刻有两个人各买了一张车票,这时候余票的数量就会发生错误。

主要的预防手段有信号量、锁、乐观 / 悲观并发控制等,大部分可用于并发场景的语言也会内置加锁的能力。同时,也有一些工具是可以静态或动态分析是否存在 race condition 的,gcc 里就有这样的内存和线程检查调试工具。

总而言之,要阻止出现 race condition 的关键就是不能让多个线程或者进程同时访问那块共享内存,所有的解决方法都是围绕这个临界区来设计的。

二、常见场景

对于前端来说,js 引擎执行 js 时只分了一个线程,高并发和分布式的场景也不太常见。但在网络通信的时候,可能会出现 race condition。比如下面这个例子,它是来源于 Redux 作者 Dan 的一篇介绍 useEffect 的文章,里面也有提及 race condition 及解决方案,代码片段如下:

首先,这是一个经典的用 react 类组件方式写的发请求的代码片段(因为这篇文章是在对比 react hooks 写法对于类组件写法的优势,所以用的类组件写法作为示例)。state 里面存了一个 article 变量,另外定义了一个 fetchData 的方法,里面做了异步的发送请求和 setState 两个操作,并在 componentDidMound 中调用这个 fetchData 方法。

细心的朋友们肯定已经发现了,这显然是有问题的。这段逻辑只会在第一次初始化完后发送一次请求,如果 id 有更新就没法处理了。于是,又有了下面这段也很经典的代码,在 componentDidUpdate 里面比较上一次和本次的 id 再决定是否需要发送请求更新 state 中的数据。这样就显得非常合理了。

然而,这段代码还是隐藏着 bug 的。因为网络请求过程是复杂的,响应时间并不确定。访问一个目的地址,受各种外部因素的影响,先发出的请求不一定会先响应。但如果前端以先发请求先响应的规则来开发的话,可能会导致数据的错误使用。比如现在已经发送了 id:10 的请求,但还没收到响应。切换到 id:20,又发了一次请求。这次 id:20 的请求先返回,之后 id:10 的请求结果才返回来。这样先发送而后收到的请求响应会把 state 里的值错误地覆盖掉。也就是说在等待 asnyc/await 异步返回的过程中,state 或者 props 是可以被改变的,就会产生竞态问题。

三、解决方案

解决的办法有很多种,最容易想到的是可以在类组件的基础上给每次操作加一个 index 编号,但如果处理不当的话可能造成内存泄漏,即组件销毁后还在 setState。以下介绍几种比较推荐的方案。

借助 useEffect

比较推荐的第一种解决方法是,借助 useEffect 来实现。首先我们看一下 react hooks 的执行流程,可以发现在每次 Update 阶段,都会先执行一次 cleanUp Effects,再执行 Effects 函数。

所以借助这个效果,我们可以对数据做取舍。

仍然以开篇的代码片段为例,具体操作是定义一个变量 didCancel,在每次切换 id 获取新文章时,执行 useEffect 返回的函数,didCancel 设置为 true,setArticle 的时候判断 didCancel 如果为 true,就不更新了。

可以想象一下,访问 id:10,请求未响应,数据未被存到 state 里;访问 id:20,重新执行前先执行上一个 effect 的 clean up 函数,它对应的 didCancel 被置为 true。id:20 响应了,本次的更新成功了。这之后 id:10 再响应,由于它的 didCancel 已经是 true 了,所以不会再触发 setState 了。也就满足了我们的需求。

更进一步呢,针对每一次这样的请求都要写一遍重复代码,也可以把这个功能提取成一个单独的 hook 使用,使用函数返回值的方式保证原来的数据,如果返回了清理函数,就放在 cleanup 阶段执行。

使用时,直接获取最新的 current 值即可:

useRequest

另外还可以直接用 ahooks 提供的 useRequest 这个 hook,本身还有很多其他额外的功能,比如防抖节流缓存等。其中,在它的官方文档取消请求这一节有介绍到,useRequest 会在以下时机自动取消当前的请求,比如竞态取消,当上一次请求还没返回时,又发起了下一次请求。

具体的实现是这样的:首先 useRequest 整个初始化的代码在 useRequest.ts 中,并不多,只有几行,各个功能都被抽取成插件的方式在不同生命周期里实现了。

useReuqestImplement 是具体的实例化方法,其中使用 Fetch 类创建的请求实例。

在 Fetch 类里,有这三个属性:pluginImpls 代表所有插件,count 是计数器,再加一个存放了初始请求相关数据的 state;除去 constructor 外,还有这几个方法:自定义了一个 setState,模拟 react 类组件里的 setState 实现。runPluginHandler 是调用插件各个生命周期的公共方法,run 是执行请求,cancel 取消请求,两个 refresh 刷新请求,mutate 手动更改返回的数据。

其中 run 和 refersh 都是调用了这个 runAsnyc 方法的,它是真正处理所有逻辑的地方。

在这个 runAsync 方法的实现里,找到相关的代码片段。它是维护一个 count 全局变量和 currentCount 局部变量来做数据取舍的。在每次执行 run 和 cancel 的时候都会 count+1,如果在 run 之前没主动或被动执行 cancel,那么两次 count 是相等的;如果是不等,就不执行 run 的 then 逻辑。

AbortController

前面的方法基本都只是忽略前一次请求,浏览器仍然会等待请求完成,占用资源。那么还可以更直接一些,在下次请求发起前,先取消上一次未完成的请求。说到取消请求,对于 XMLHttpRequest 来说可以直接调用 abort 方法终止请求,它的 readyState 会被置为 XMLHttpRequest (0),并且请求的 state 会被置为 0;对于 fetch api 来可以使用 AbortController 这个控制器对象。

值得一提的是 axios 基于 XMLHttpRequest,在搜索如何取消请求的时候绝大部分文章都在讲 cancelToken 这个方法,然而它官方的 readme 里已经把 cancelToken 标记为 deprecate 不推荐了,还存在只是为了兼容老代码。

axios 很早就已经提供了 AbortController 的用法,这样就能像 fetch api 的取消一样使用了。给的样例方法是通过 AbortController 构造函数来创建一个 controller 实例,然后通过 signal 属性获取到 AbortSignal 对象的引用。并把 signal 传入请求的可选参数里,这时 signal、ontroller 会和这个请求关联起来,就可以调用 abort 方法来取消请求了。

那么具体到 axios 的实现细节里,是位于 adapters/xhr.js 的 dispatchRequest 这个方法里,监听 abortController 的 abort 行为。在请求完成后还有个函数会把这个监听移除。整体机制是基于事件监听模式的。AbortController 还可以批量终止请求,以及其他异步操作。

开篇的例子借用 AbortController 可以这样写:

RxJS

RxJS 基于 Reactive Programming 响应式编程,可以很轻松地处理异步操作,而不依赖 React hooks,直接使用 switchMap 操作符即可完成。

它的特点是在每次发出时,会取消前一个 observable 的订阅,然后订阅一个新的 observable。这个思想和利用 useEffect 的 clearup 类似,因此也是一样的实现思路。

redux-saga

最后,redux-saga 这个库也提供了一个 takeLatest 方法,可以做请求竞态情况下的取舍。

四、总结

请求的 race conditions 解决思路可以分为两种:一是控制请求的处理时机,比如忽略请求,只处理最新一次的请求,或者直接取消未返回的请求。上面介绍的几种方法大部分都是这一个思路。另外还有一种是控制请求的时机,比如很常见的加防抖,对于频繁操作,只在最后一次动作时发出请求,或者锁状态,直接禁止很频繁的操作,从交互层面杜绝该情况的发生。

围绕这两个思路,相信在日常工作中可以很好地处理 race conditions 问题。

关于本文
作者:@Elvira
原文:https://mp.weixin.qq.com/s/lzSvAaQIAfNPhFVgVKnr-g

关于【场景】相关阅读。欢迎读者自荐投稿,前端早读课等你


曾经看到一句话分享在朋友圈过:读一本书就存一块钱。每天早晨会阅读一个小时,时常对有所感触的文字进行截图分享到朋友圈,顺道存入早说库。

前段时间出去活动的时候,刚好阅读了这本书《阿里人的答案书》,做了摘录有兴趣可以看看。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存