V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
pursuer
V2EX  ›  分享创造

写了一个有趣的 JS/TS 的任务管理库,可以帮助您中止异步函数/Promise 运行

  •  1
     
  •   pursuer ·
    partic2 · 21 天前 · 1722 次点击

    写过 JS 的都知道 JS/TS 的异步方案采用 async,await,Promise 的无栈协程方案。Python 也是采用的类似方案,但 Python 提供更多的控制包括 cancel 一个 task ,可以尝试提前结束一个异步任务,同时 JS/TS 也没有 current_task 或者类似 Java 的 ThreadLocal 获取上下文变量的方案。

    通常的实现中断执行的写法是手动判断类似 AbortSignal.throwIfAborted 的方案,但写起来没那么舒心。

    于是我想到是否可以通过改写 Promise.then 实现自动中断 await 执行?实际写下来以后的发现居然真的可以,于是有了下面的这个库。

    https://github.com/partic2/protask

    https://gitee.com/partic/protask

    使用示例:

    
    import {task} from 'protask'
    
    function sleep<T>(milliSeconds: number, arg?: T): Promise<T> {
        return new Promise(function (resolve, reject) {
            setTimeout(resolve, milliSeconds, arg)
        });
    }
    
    async function printTaskLocal(){
        await sleep(100);
        console.info(task.locals());
    }
    
    task.create('test task 1',async ()=>{
        try{
            for(let i=0;i<100;i++){
                task.locals()!.count=i;
                await printTaskLocal();
                await sleep(1000)
            }
        }catch(e:any){
            console.info(e.toString());
        }
    });
    
    task.create('test task 2',(async ()=>{
        await sleep(3000);
        console.info('abort task 1')
        task.abort('test task 1');
    }));
    
    

    上面的代码创建了一个 task1 然后在 task2 中中断了 task1 的运行。同时在 task1 中打印 task local 。

    目前只是一个非常简单实验性质的库,并未完善测试过,请注意使用风险。

    觉得有意思的话可以点个 star ,万一以后就进 tc39 了呢?(笑

    第 1 条附言  ·  19 天前
    目前有两个严重问题

    1. currentTask 标记在任务切出时不会清空,会导致创建的 Promise 被意外设置 taskId ,可以考虑添加 setTimeout(()=>task.currentTask='',0)缓解,但是不是彻底的解决方案。

    2. native code 中(例如 fetch)创建的 promise 不会使用 Hooked 的 Promise ,这会导致 taskId 丢失,需要外面再封装一层的方式调用,如下
    new Promise((resolve,reject)=>{fetch(‘index.html').then(resolve,reject);});
    但因为很多时候并不知道哪里会进入 native code ,这也使得这个库现在基本没法用的状态,看看有没有大家有没有想到解决方法。
    第 2 条附言  ·  18 天前
    折腾一个周末觉得应该是是没法解决上面的问题的。直接放个 2.0 版改成 Generator 实现了。但是这就和原生 async/await 不兼容,可能要考虑类似 babel 的方式将源码转换出同时兼容 Thenable 和 Generator 的 async/await 才行了。
    24 条回复    2024-10-19 14:13:04 +08:00
    rppig42
        1
    rppig42  
       21 天前   ❤️ 4
    👍

    有正经这个需求的可以了解下 RxJS
    nomagick
        2
    nomagick  
       21 天前   ❤️ 1
    不是这样的,你这没有作用。。

    主动中断同步代码目前只能通过操作系统,中断线程或中断进程
    主动中断异步代码,可以通过 iterator 在 yield 的节点中断,但在 js 语法之外需要魔改

    你这只是在上游 then 之后选择是否往下游返回,没有中断任何代码的执行,掩耳盗铃了属于是
    pursuer
        3
    pursuer  
    OP
       21 天前
    @nomagick 实现 Python 的 cancel 类似的机制,中断异步传递抛出异常,同步代码都是没法中断的,确实像你说的可以魔改为 await 为 iterator 模式, 但我写的这个方法可以不用魔改 js 就可以实现这个效果。
    dapang1221
        4
    dapang1221  
       21 天前
    啊?你们前端终于把浏览器搞成了操作系统了吗
    nomagick
        5
    nomagick  
       21 天前
    @pursuer 不一样,通过 iterator 实现代码是中断了的,运行时知道现在代码已经 throw 或者 return ,但你现在这样,通过 hack 阻止 Promise 结算,Promise 是一直吊在 pending 状态的,运行时也不知道你这部分代码不会再执行了,只知道 Proimse 没有 resolve 。

    具体运行时有没有足够聪明能够解开这个泄漏局我不太了解,总之你这操作非常危险,很有可能解不开,而且即便能解开,我看你代码一旦 cancel, 因为你阻断了 Promise 结算,所以 cleanup 的步骤就永远不会执行,但你对这些 AbortSignal 却有全局的引用,这部分也会泄露。
    pursuer
        6
    pursuer  
    OP
       21 天前
    @nomagick Promise 不会 pending ,abort 的情况会直接传递到 onrejected ,抛出 AbortError 。在 taskMain 函数返回后会做 task 的清理工作。当然,如果 taskMain 返回后有其他继承同一 task 的 Promise 尝试访问 task 上下文会得到 undefined ,这算是一个小问题。
    nomagick
        7
    nomagick  
       21 天前
    @pursuer 所以你至少在 cancel 之后需要 reject cancel error, 这样下游的代码路径才能继续结算,所以下游也需要再在某个地方 catch cancel error ,对代码的入侵性不亚于显式 if (await jobCancelled) return;

    说到底对代码执行流程的操作,还是要交给语言和运行时层面去解决,如果一个函数流程,是不是被完整执行,还可以被外部代码莫名其妙地影响,这对整个系统来讲完全就是一个灾难
    nomagick
        8
    nomagick  
       21 天前
    @pursuer 没有啊,在 cancel 的情况,不是 reject 的情况 https://github.com/partic2/protask/blob/b22d446a33cf47e34f3aa4e6d4244185aa75d9cf/src/index.ts#L57-L60
    你在这 catch 了之后没有做任何操作,Promise 就吊在这了,这也是你能看起来中断执行的所在
    nomagick
        9
    nomagick  
       21 天前
    @nomagick 哦,对不起,是我搞混了
    pursuer
        10
    pursuer  
    OP
       21 天前
    @nomagick 这个方法确实比较 hacky ,里面也可能埋藏着尚未发现的坑,所以我也指明这是一个实验性的项目。只是有时候确实想要个这样的控制 async/await 运行流的工具,不知道 tc39 以后会不会搬出类似的东西。
    nomagick
        11
    nomagick  
       21 天前
    @pursuer 打扰了,看起来真的可以,进入了我的知识盲区,原来复写 promise.then 就能中断执行流,那我就比较好奇了,async function 原本的那个 promise 后来怎么样了,这个 promise 是不是吊起来了
    nomagick
        12
    nomagick  
       21 天前
    不对,我真的下载下来运行了。

    复写 then 不足以中断执行流,你的 example 之所以能够 work 是因为 `task.locals()!.count=i;` 这句在 cancel 之后抛了异常,起到了 abort error 的效果。。。
    nomagick
        13
    nomagick  
       21 天前
    ```typescript
    import { task } from 'protask';

    function sleep<T>(milliSeconds: number, arg?: T): Promise<T> {
    return new Promise(function (resolve, reject) {
    setTimeout(resolve, milliSeconds, arg);
    });
    }

    async function printTaskLocal() {
    await sleep(100);
    console.info(task.locals());
    }

    task.create('test task 1', async () => {
    try {
    for (let i = 0; i < 100; i++) {
    // task.locals().count = i;
    console.log('task 1 running');
    await printTaskLocal();
    await sleep(1000);
    }
    console.log('Task 1 resolve');
    } catch (e: any) {
    console.log('Task 1 error');
    console.info(e.toString());
    } finally {
    console.log('Task 1 finally');
    }
    });

    task.create('test task 2', (async () => {
    await sleep(3000);
    console.info('abort task 1');
    task.abort('test task 1');
    }));
    ```
    pursuer
        14
    pursuer  
    OP
       21 天前
    因为我是用了 super.then 的,所以原本 promise 的内部处理应该遵照原有的实现,只是在 onfulfilled 前检测中止状态,转为调用 onrejected
    pursuer
        15
    pursuer  
    OP
       21 天前
    @nomagick 抛出异常符合预期的,Python 也是抛出 CancelError ,只有抛出异常才能保证类似 try{}finally 的资源正常释放。
    nomagick
        16
    nomagick  
       21 天前
    不啊,你这言之凿凿的都给我整不自信了,你这真的是没有中断任何代码的执行,而是你的 cancel 操作,造成了 task.locals.count 的赋值失败,这产生了
    TypeError: Cannot set properties of undefined (setting 'count')

    这才中断了执行,如果你不操作 task.locals ,task1 就会一直运行下去
    pursuer
        17
    pursuer  
    OP
       21 天前
    @nomagick 我这边有 node 和 Chrome 测试是正常的,输出是
    { count: 0 }
    { count: 1 }
    { count: 2 }
    abort task 1
    AbortError: This operation was aborted
    不知道你那边用的什么运行时,可能哪里还有瑕疵,试试看 task.locals.count 赋值删除能不能抛出异常?
    nomagick
        18
    nomagick  
       21 天前
    我这边是 node.js 20.11.1
    我觉得可能和原生 async function 有关,如果是原生的 async function 你这个 hack 就中断不了,但如果是 babel 之类的编译器模拟出来的,打断 then 链条就可以。
    9ki
        19
    9ki  
       21 天前
    pursuer
        20
    pursuer  
    OP
       21 天前
    @nomagick
    使用同一版本依然无法复现问题。。。用的 windows x64 ,运行你给的代码如下

    sh-3.1$ node -v
    v20.11.1
    sh-3.1$ npm run build && npm run test

    > [email protected] build
    > tsc


    > [email protected] test
    > node dist/index.js

    task 1 running
    {}
    task 1 running
    {}
    task 1 running
    {}
    abort task 1
    Task 1 error
    AbortError: This operation was aborted
    Task 1 finally
    pursuer
        21
    pursuer  
    OP
       21 天前
    @nomagick 浏览器上复现了你的问题,初步推断原因是 onfulfilled 后的代码被放到下一 tick 运行了,虽然可以简单通过移除
    finally{task.currentTask='';}解决,但可能造成 task 泄露,我还得再看下
    pursuer
        22
    pursuer  
    OP
       21 天前
    @nomagick 推了新版本解决了这个 BUG ,原来是我脑抽本地两份代码撤销的时候不一致了。

    不过就像我前面说的,当前写法会导致 task 标记泄露污染不在 taskMain 里创建的 Promise ,不够完美。但目前想不到更好的办法了。
    pursuer
        23
    pursuer  
    OP
       20 天前
    @nomagick 测试了下还真是,我发布的第一个有 BUG 的版本在被 tsc 编译为 yield 模拟 await 的代码里是正常运行的。使用原生 js 的 await 时,onfulfilled 的行为有点奇怪,不会立即运行 await 后面的代码。尝试 queueMicrotask 和 Promise.then 清除 currentTask 均不能按预期位置运行,唯一稍微可用方案是 setTimeout(0)但存在可能 4ms 限制且这个也不能确定执行时间点,只能在较大程度上缓解 currentTask 泄漏到其他 Promise 的问题。
    1una0bserver
        24
    1una0bserver  
       20 天前 via Android
    @dapang1221 大前端不是早就搞成操作系统了吗🤔
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2509 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 01:26 · PVG 09:26 · LAX 17:26 · JFK 20:26
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.