Gahing's blog Gahing's blog
首页
知识体系
  • 前端基础
  • 应用框架
  • 工程能力
  • 应用基础
  • 专业领域
  • 业务场景
  • 前端晋升 (opens new window)
  • Git
  • 网络基础
  • 算法
  • 数据结构
  • 编程范式
  • 编解码
  • Linux
  • AIGC
  • 其他领域

    • 客户端
    • 服务端
    • 产品设计
软素质
  • 面试经验
  • 人生总结
  • 个人简历
  • 知识卡片
  • 灵感记录
  • 实用技巧
  • 知识科普
  • 友情链接
  • 美食推荐 (opens new window)
  • 收藏夹

    • 优质前端信息源 (opens new window)
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

Gahing / francecil

To be best
首页
知识体系
  • 前端基础
  • 应用框架
  • 工程能力
  • 应用基础
  • 专业领域
  • 业务场景
  • 前端晋升 (opens new window)
  • Git
  • 网络基础
  • 算法
  • 数据结构
  • 编程范式
  • 编解码
  • Linux
  • AIGC
  • 其他领域

    • 客户端
    • 服务端
    • 产品设计
软素质
  • 面试经验
  • 人生总结
  • 个人简历
  • 知识卡片
  • 灵感记录
  • 实用技巧
  • 知识科普
  • 友情链接
  • 美食推荐 (opens new window)
  • 收藏夹

    • 优质前端信息源 (opens new window)
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 如何检测并解决 JS 代码中的死循环

    • 背景
      • 死循环 Case
        • 无限循环
        • 无限递归调用
        • 无限渲染
      • 检测死循环
        • 运行时判断
          • 用频率代替时长
        • Babel 处理
          • 最后
            • 拓展阅读
            gahing
            2023-09-10
            随笔 2023
            目录

            如何检测并解决 JS 代码中的死循环

            # 背景

            之前做的一个需求,需要探测用户 js 代码是否存在死循环。若发现死循环,则提前抛错,而不是继续执行直至线程卡死。

            业界也有挺多类似的需求,比如 CodeSandbox 沙盒的 Infinite Loop Protection,可以避免用户在调试代码时写了死循环导致页面标签崩溃。

            Alt text

            能否通过静态分析的方式检测出死循环?如果不能,我们又应该如何在不借用其他线程的情况下,解决死循环卡住问题?

            下面就让我们一起来分析下这些问题吧。

            # 死循环 Case

            什么情况下会导致死循环?列举了常见的几种情况:

            1. 无限循环:循环条件始终为正 ,且循环体中没有中断语句
            2. 无限递归调用
            3. 无限渲染:表现在 React 等视图框架,渲染函数执行时又触发了数据变动
            4. ...

            # 无限循环

            while (true) { // 循环条件也可能是一个很复杂、有外部入参的判断语句,但始终为正
              // 死循环
              if(1 !== 2) { // 中止条件永不触发
                  return
              }
            }
            
            1
            2
            3
            4
            5
            6

            这类场景,循环条件始终为正,而在循环体中,要么没有中止条件,要么中止条件永远不触发,进而导致线程卡死。

            # 无限递归调用

            (function recursive() {
              recursive(); // 死循环
            })();
            
            1
            2
            3

            对于这类情况,执行引擎在达到最大递归调用栈深度后,便会抛出 RangeError ,我们无需主动处理。

            RangeError: Maximum call stack size exceeded
            
            1

            # 无限渲染

            这里以 React 框架为例,在 render 函数中又触发了数据的变更。这边的用例比较直白,现实中的用例可能会非常隐蔽。

            import React from "react";
            
            export default class App extends React.Component {
              constructor() {
                super();
                this.state = {
                  num: 1
                };
              }
              render() {
                this.setState((state) => ({ state: state + 1 }));
                return <div>{this.state.num}</div>;
              }
            }
            
            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            14
            import React, { useState, useEffect } from "react";
            
            export default function App() {
              const [count, setCount] = useState(0);
              useEffect(() => {
                setCount(count + 1); // infinite loop
              }, [count]);
              return <div>hello</div>;
            }
            
            1
            2
            3
            4
            5
            6
            7
            8
            9

            第二个用例,控制台输出了以下报错,并且渲染卡死。

            Alt text

            # 检测死循环

            能否通过静态分析的方式,检测出一段代码存在死循环?

            先考虑第一种 「无限循环」 场景,如果我们发现循环条件执行结果始终为 true ,且循环体中没有中止语句(throw/return/break),那么这类用例必定是死循环。

            while(true) {
                // 死循环
            }
            
            1
            2
            3

            然而这样的代码毕竟是少数,大部分用例是在不经意间写出死循环的,比如

            while (x > y && (x % 2 === 0 || y % 2 === 1)) {
              // 死循环,复杂条件难以分析
            }
            
            1
            2
            3

            判断复杂、涉及外部输出,需要运行时分析,故纯静态分析难以判断

            该问题在可计算性领域被称为停机问题 (opens new window),已被证明无法通过一个通用算法分析出一段代码是否存在死循环

            # 运行时判断

            既然静态分析无法解决,那么是否换个思路:给循环体加点判断代码,当循环次数过多或者循环执行过久的时候,就认为是死循环,并抛出异常。

            我们先以执行过久作为死循环判断条件 (后面会继续优化) :

            • 对于无限循环的场景,可以这么处理:
            while(true) {
                // 死循环
            }
            
            // 调整为
            
            let _loopStart = Date.now() 
            while(true) {
                if(Date.now() - _loopStart > MAX_TIMEOUT) {
                    throw new RangeError('Potential infinite loop: exceeded')
                }
                // 死循环
            }
            
            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13

            for 循环、do...while 循环同理转换。

            • 对于循环的场景,可以这么处理:
            import React from "react";
            
            let _loopStart = Date.now() 
            export default class App extends React.Component {
              constructor() {
                super();
                this.state = {
                  num: 1
                };
              }
              render() {
                if(Date.now() - _loopStart > MAX_TIMEOUT) {
                    console.warn('Potential infinite loop: exceeded')
                    return;
                }
                this.setState((state) => ({ state: state + 1 }));
                return <div>{this.state.num}</div>;
              }
            }
            
            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            14
            15
            16
            17
            18
            19

            现在,我们就拥有了中止无限循环代码的能力。

            至于代码是如何插入的,下一节会给出 babel 插件代码。

            现在的问题是,使用执行时长作为判断条件,是否合理?上面的第二个用例「无限渲染」很明显就不正确,另外涉及异步场景,也依然有问题。

            for(let i=0;i<10;i++){
                await fetch('/xxx')
            }
            
            1
            2
            3

            # 用频率代替时长

            我们可以换个思路,统计两次循环之间的间隔。若足够小,说明是同步代码死循环;若足够大,说明是异步循环调用,可以不用考虑。

            关于足够小,我们可以粗浅的以 4ms 作为界限。通常来说, 1ms 能够执行数百次指令,只要循环体中的代码不是非常复杂,通常都能够在 4ms 内返回。再加入最大执行次数进行综合判断

            while(true) {
                // 死循环
            }
            
            // 调整为
            const MAX_ITERATIONS = 2000 // 最大可循环次数
            const MAX_INTERVAL = 4 // 最大执行间隔
            let lastDate = Date.now() 
            let loopCount = 0
            while(true) {
                loopCount++
                if(Date.now() - lastDate <= MAX_INTERVAL && loopCount % MAX_ITERATIONS === 0) {
                    throw new RangeError('Potential infinite loop: exceeded')
                } else {
                    lastDate = Date.now()
                }
                // 死循环
            }
            
            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            14
            15
            16
            17
            18

            # Babel 处理

            根据上面的分析,我们可以使用 babel 写一个插件快速验证

            关于 babel 插件的知识,可以查看中文官方文档 (opens new window)

            const MAX_ITERATIONS = 2000; // 最大迭代次数
            const MAX_INTERVAL = 4; // 最大执行间隔
            
            module.exports = ({ types: t, template }) => {
              // 生成循环体判断条件
              const buildGuard = template(`
                %%iterator%%++
                if (%%iterator%% % %%maxIterations%% === 0 && Date.now() - %%lastDate%% <= %%maxInterval%%) {
                  throw new RangeError('Potential infinite loop: exceeded ');
                } else {
                    %%lastDate%% = Date.now()
                }
              `);
            
              return {
                visitor: {
                  "WhileStatement|ForStatement|DoWhileStatement": (path) => {
                    // 新增变量:执行次数
                    const iterator = path.scope.parent.generateUidIdentifier("loopIt");
                    const iteratorInit = t.numericLiteral(0);
                    path.scope.parent.push({
                      id: iterator,
                      init: iteratorInit,
                    });
                    // 新增变量:上次执行时间
                    const lastDate = path.scope.parent.generateUidIdentifier("lastDate");
                    const lastDateInit = t.callExpression(
                      t.memberExpression(t.identifier("Date"), t.identifier("now")),
                      []
                    );
                    path.scope.parent.push({
                      id: lastDate,
                      init: lastDateInit,
                    });
            
                    // 插入循环体
                    const guard = buildGuard({
                      iterator,
                      maxIterations: t.numericLiteral(MAX_ITERATIONS),
                      lastDate,
                      maxInterval: t.numericLiteral(MAX_INTERVAL) 
                    });
                    // 处理 No block statement 的情况,比如 `while (1) 1;`
                    if (!path.get("body").isBlockStatement()) {
                      const statement = path.get("body").node;
                      path.get("body").replaceWith(t.blockStatement([guard, statement]));
                    } else {
                      path.get("body").unshiftContainer("body", guard);
                    }
                  },
                  // 类组件函数,略
                  ClassDeclaration: (path, file) => {},
                  // 箭头函数组件,略
                  VariableDeclaration: (path, file) => {
                    // 判断是否为 JSX 函数,可以通过 ReturnStatement 是否为 JSXFragment/JSXElement 进行判断
                  },
                  // 普通函数组件,略
                  FunctionDeclaration: (path, file) => {},
                },
              };
            };
            
            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            14
            15
            16
            17
            18
            19
            20
            21
            22
            23
            24
            25
            26
            27
            28
            29
            30
            31
            32
            33
            34
            35
            36
            37
            38
            39
            40
            41
            42
            43
            44
            45
            46
            47
            48
            49
            50
            51
            52
            53
            54
            55
            56
            57
            58
            59
            60
            61

            测试一下

            const babel = require("@babel/core");
            
            // 测试插件
            const code = `
            
            while(true){
                for(;;) {
            
                }
            }
            `;
            
            const result = babel.transformSync(code, {
              plugins: [require("./plugin")],
              // presets: ["@babel/preset-env"],
            });
            
            console.log(result.code);
            
            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            14
            15
            16
            17
            18

            得到如下输出

            "use strict";
            
            var _loopIt = 0,
              _lastDate = Date.now();
            while (true) {
              var _loopIt2 = 0,
                _lastDate2 = Date.now();
              _loopIt++;
              if (_loopIt % 2000 === 0 && Date.now() - _lastDate <= 4) {
                throw new RangeError('Potential infinite loop: exceeded ');
              } else {
                _lastDate = Date.now();
              }
              for (;;) {
                _loopIt2++;
                if (_loopIt2 % 2000 === 0 && Date.now() - _lastDate2 <= 4) {
                  throw new RangeError('Potential infinite loop: exceeded ');
                } else {
                  _lastDate2 = Date.now();
                }
              }
            }
            
            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            14
            15
            16
            17
            18
            19
            20
            21
            22

            正好满足我们的需求。

            # 最后

            需要再次声明的是,本文提供的方案仅处理了常见了无限循环用例。

            在实际项目中,用户可以通过 eval 、 new Function 等各种方案脱离这个检测机制,难以完全避免。

            此时可能需要想的是,用户都这么写了,那我们还需要为他考虑么?

            # 拓展阅读

            • https://repl.it/site/blog/infinite-loops
            • https://github.com/freeCodeCamp/loop-protect
            • https://github.com/facebook/react/blob/main/scripts/babel/transform-prevent-infinite-loops.js
            • 如何探测 JS 代码中的死循环 (opens new window)
            • 停机问题 - wiki (opens new window)
            编辑 (opens new window)
            上次更新: 2024/09/01, 23:56:56
            最近更新
            01
            浅谈代码质量与量化指标
            08-27
            02
            快速理解 JS 装饰器
            08-26
            03
            Vue 项目中的 data-v-xxx 是怎么生成的
            09-19
            更多文章>
            Theme by Vdoing | Copyright © 2016-2024 Gahing | 闽ICP备19024221号-1
            • 跟随系统
            • 浅色模式
            • 深色模式
            • 阅读模式