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

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

  •  1
     
  •   pursuer ·
    partic2 · 4 天前 · 1232 次点击

    写过 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 条附言  ·  2 天前
    目前有两个严重问题

    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 条附言  ·  1 天前
    折腾一个周末觉得应该是是没法解决上面的问题的。直接放个 2.0 版改成 Generator 实现了。但是这就和原生 async/await 不兼容,可能要考虑类似 babel 的方式将源码转换出同时兼容 Thenable 和 Generator 的 async/await 才行了。
    24 条回复    2024-10-19 14:13:04 +08:00
    rppig42
        1
    rppig42  
       4 天前   ❤️ 4
    👍

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

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

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

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

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

    复写 then 不足以中断执行流,你的 example 之所以能够 work 是因为 `task.locals()!.count=i;` 这句在 cancel 之后抛了异常,起到了 abort error 的效果。。。
    nomagick
        13
    nomagick  
       4 天前
    ```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
       4 天前
    因为我是用了 super.then 的,所以原本 promise 的内部处理应该遵照原有的实现,只是在 onfulfilled 前检测中止状态,转为调用 onrejected
    pursuer
        15
    pursuer  
    OP
       4 天前
    @nomagick 抛出异常符合预期的,Python 也是抛出 CancelError ,只有抛出异常才能保证类似 try{}finally 的资源正常释放。
    nomagick
        16
    nomagick  
       4 天前
    不啊,你这言之凿凿的都给我整不自信了,你这真的是没有中断任何代码的执行,而是你的 cancel 操作,造成了 task.locals.count 的赋值失败,这产生了
    TypeError: Cannot set properties of undefined (setting 'count')

    这才中断了执行,如果你不操作 task.locals ,task1 就会一直运行下去
    pursuer
        17
    pursuer  
    OP
       4 天前
    @nomagick 我这边有 node 和 Chrome 测试是正常的,输出是
    { count: 0 }
    { count: 1 }
    { count: 2 }
    abort task 1
    AbortError: This operation was aborted
    不知道你那边用的什么运行时,可能哪里还有瑕疵,试试看 task.locals.count 赋值删除能不能抛出异常?
    nomagick
        18
    nomagick  
       4 天前
    我这边是 node.js 20.11.1
    我觉得可能和原生 async function 有关,如果是原生的 async function 你这个 hack 就中断不了,但如果是 babel 之类的编译器模拟出来的,打断 then 链条就可以。
    9ki
        19
    9ki  
       4 天前
    pursuer
        20
    pursuer  
    OP
       4 天前
    @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
       4 天前
    @nomagick 浏览器上复现了你的问题,初步推断原因是 onfulfilled 后的代码被放到下一 tick 运行了,虽然可以简单通过移除
    finally{task.currentTask='';}解决,但可能造成 task 泄露,我还得再看下
    pursuer
        22
    pursuer  
    OP
       4 天前
    @nomagick 推了新版本解决了这个 BUG ,原来是我脑抽本地两份代码撤销的时候不一致了。

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