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

 更新时间:2023年09月11日 08:24:49   作者:francecil  
这篇文章主要想和大家来一起探讨一下能否通过静态分析的方式检测出死循环,如果不能,我们又应该如何在不借用其他线程的情况下,解决死循环卡住问题,感兴趣的可以了解下

背景

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

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

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

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

死循环 Case

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

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

无限循环

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

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

无限递归调用

(function recursive() {
  recursive(); // 死循环
})();

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

RangeError: Maximum call stack size exceeded

无限渲染

这里以 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>;
  }
}
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>;
}

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

检测死循环

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

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

while(true) {
    // 死循环
}

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

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

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

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

运行时判断

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

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

对于无限循环的场景,可以这么处理:

while(true) {
    // 死循环
}
// 调整为
let _loopStart = Date.now() 
while(true) {
    if(Date.now() - _loopStart > MAX_TIMEOUT) {
        throw new RangeError('Potential infinite loop: exceeded')
    }
    // 死循环
}

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>;
  }
}

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

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

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

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

用频率代替时长

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

关于足够小,我们可以粗浅的以 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()
    }
    // 死循环
}

Babel 处理

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

关于 babel 插件的知识,可以查看中文官方文档

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) => {},
    },
  };
};

测试一下

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);

得到如下输出

"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();
    }
  }
}

正好满足我们的需求。

最后

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

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

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

到此这篇关于一文详解如何检测并解决JS代码中的死循环的文章就介绍到这了,更多相关JS死循环内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • js实现坦克移动小游戏

    js实现坦克移动小游戏

    这篇文章主要为大家详细介绍了js实现坦克移动小游戏,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2019-10-10
  • Javascript模块模式分析

    Javascript模块模式分析

    javascritp模式讲解全局变量是魔鬼。在YUI中,我们仅用两个全局变量:YAHOO和YAHOO_config。YUI的一切都是使用YAHOO对象级的成员或这个成员作用域内的变量。我们建议在你的应用程序也使用类似的规则。
    2008-05-05
  • ES6新特性一: let和const命令详解

    ES6新特性一: let和const命令详解

    这篇文章主要介绍了ES6新特性中的let和const命令,结合实例形式分析了let和const命令的功能、使用方法与相关注意事项,需要的朋友可以参考下
    2017-04-04
  • 详解jQuery插件开发方式

    详解jQuery插件开发方式

    本文介绍了jQuery扩展、私有域、定义插件的基本步骤等知识,有需要的朋友可以看下
    2016-11-11
  • 利用hasOwnProperty给数组去重的面试题分享

    利用hasOwnProperty给数组去重的面试题分享

    obj.hasOwnProperty(attr) 判断是否是原型中的属性,false就是原型中的属性,下面这篇文章主要给大家介绍了一道利用hasOwnProperty给数组去重的面试题,文中通过示例代码介绍的非常详细,需要的朋友可以参考下
    2018-11-11
  • JS实现下拉菜单列表与登录注册弹窗效果

    JS实现下拉菜单列表与登录注册弹窗效果

    下面小编就为大家带来一篇JS实现下拉菜单列表与登录注册弹窗效果。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-08-08
  • JS阻止事件冒泡行为和闭包的方法

    JS阻止事件冒泡行为和闭包的方法

    这篇文章主要介绍了JS阻止事件冒泡行为和闭包的方法的相关资料,需要的朋友可以参考下
    2016-06-06
  • SWFObject Flash js调用类

    SWFObject Flash js调用类

    一直想为 SWFObject 这个JS的类库写一个推荐帖,因为他轻便,同时功能强大,为我们的开发带来了很大的便捷。
    2008-07-07
  • 使用typescript类型实现ThreeSum

    使用typescript类型实现ThreeSum

    这篇文章主要介绍了使用typescript类型实现ThreeSum,文章围绕主题展开详细的内容介绍,具有一定的参考价值,需要的小伙伴可以一下,希望对你学习又是帮助
    2022-08-08
  • JavaScript如何自定义trim方法

    JavaScript如何自定义trim方法

    本文介绍了如何自定义trim方法,trim的作用就是去除字符串前后空格,这个方法在字符串处理方面很有实用价值,需要的朋友可以参考下
    2015-07-07

最新评论