MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Node.js 实现异步任务的取消与超时控制

2024-03-115.4k 阅读

Node.js 异步任务概述

在深入探讨 Node.js 中异步任务的取消与超时控制之前,我们先来回顾一下 Node.js 的异步编程模型。Node.js 基于事件驱动和非阻塞 I/O 模型构建,这使得它在处理大量并发 I/O 操作时表现出色。在 Node.js 中,许多操作,如文件系统 I/O、网络请求等,都是异步执行的。

异步任务的实现方式

  1. 回调函数:这是 Node.js 早期处理异步操作最常用的方式。例如,读取文件的操作:
const fs = require('fs');
fs.readFile('example.txt', 'utf8', (err, data) => {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});

在这个例子中,readFile 是一个异步函数,它接受文件名、编码格式以及一个回调函数作为参数。当文件读取操作完成后,会调用这个回调函数,并将可能出现的错误 err 和读取到的数据 data 作为参数传递进去。

  1. Promise:ES6 引入的 Promise 为异步操作提供了一种更优雅的处理方式。通过将异步操作封装成 Promise 对象,可以使用 .then() 方法来处理成功的结果,使用 .catch() 方法来处理错误。以下是用 Promise 重写上述文件读取操作:
const fs = require('fs').promises;
fs.readFile('example.txt', 'utf8')
  .then(data => {
        console.log(data);
    })
  .catch(err => {
        console.error(err);
    });

Promise 对象有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。一旦状态改变,就不会再变,这使得异步操作的状态管理更加清晰。

  1. Async/await:这是基于 Promise 的一种语法糖,它让异步代码看起来更像同步代码,大大提高了代码的可读性。同样是文件读取操作:
const fs = require('fs').promises;
async function readFileAsync() {
    try {
        const data = await fs.readFile('example.txt', 'utf8');
        console.log(data);
    } catch (err) {
        console.error(err);
    }
}
readFileAsync();

async 关键字用于定义一个异步函数,该函数总是返回一个 Promise。await 关键字只能在 async 函数内部使用,它会暂停函数的执行,直到 Promise 被解决(resolved)或被拒绝(rejected),然后返回解决的值或抛出拒绝的原因。

异步任务的取消

在某些情况下,我们可能需要取消正在进行的异步任务。例如,用户在网页上发起了一个长时间运行的网络请求,但在请求完成之前,用户改变了主意并取消了操作。在 Node.js 中实现异步任务的取消并非易事,因为 JavaScript 本身并没有原生支持取消异步操作的机制。然而,我们可以通过一些技巧来模拟取消功能。

使用 AbortController

从 Node.js v15.0.0 开始,引入了对 AbortController 的支持,这使得取消异步任务变得更加容易。AbortController 是 Web API 的一部分,它允许你在需要时中止一个或多个 DOM 操作、fetch 请求等。在 Node.js 中,我们可以利用它来取消异步任务。

  1. 基本使用
const { AbortController } = require('node:abort - controller');
const controller = new AbortController();
const signal = controller.signal;
setTimeout(() => {
    controller.abort();
}, 2000);
async function asyncTask() {
    try {
        await new Promise((resolve, reject) => {
            const timeoutId = setTimeout(() => {
                resolve('Task completed');
            }, 5000);
            signal.addEventListener('abort', () => {
                clearTimeout(timeoutId);
                reject(new Error('Task aborted'));
            });
        });
    } catch (err) {
        console.error(err.message);
    }
}
asyncTask();

在这个例子中,我们创建了一个 AbortController 实例 controller,并从中获取 signal。然后,我们设置一个定时器,两秒后调用 controller.abort() 来发出取消信号。在异步任务 asyncTask 中,我们创建了一个 Promise,该 Promise 模拟一个需要五秒才能完成的任务。同时,我们为 signal 添加了一个 abort 事件监听器,当接收到取消信号时,清除定时器并拒绝 Promise,抛出错误。

  1. 结合文件系统操作
const { AbortController } = require('node:abort - controller');
const fs = require('fs').promises;
const controller = new AbortController();
const signal = controller.signal;
setTimeout(() => {
    controller.abort();
}, 2000);
async function readFileWithAbort() {
    try {
        const data = await fs.readFile('largeFile.txt', { signal, encoding: 'utf8' });
        console.log(data);
    } catch (err) {
        if (err.name === 'AbortError') {
            console.error('File read aborted');
        } else {
            console.error(err);
        }
    }
}
readFileWithAbort();

这里,我们在调用 fs.readFile 时,传入了 signal 选项。如果在文件读取过程中发出了取消信号,fs.readFile 会抛出一个 AbortError,我们可以在 catch 块中捕获并处理这个错误。

手动实现取消机制

在 Node.js 早期版本或者某些不支持 AbortController 的环境中,我们可以手动实现取消机制。这通常涉及到在异步任务中定期检查一个标志变量,以决定是否需要取消任务。

  1. 基于回调函数的手动取消
let shouldCancel = false;
function asyncTaskWithCancel(callback) {
    let progress = 0;
    const intervalId = setInterval(() => {
        if (shouldCancel) {
            clearInterval(intervalId);
            callback(new Error('Task cancelled'));
            return;
        }
        progress += 1;
        if (progress >= 10) {
            clearInterval(intervalId);
            callback(null, 'Task completed');
        }
    }, 1000);
}
asyncTaskWithCancel((err, result) => {
    if (err) {
        console.error(err.message);
    } else {
        console.log(result);
    }
});
setTimeout(() => {
    shouldCancel = true;
}, 3000);

在这个例子中,我们定义了一个全局变量 shouldCancel 作为取消标志。asyncTaskWithCancel 函数模拟了一个异步任务,通过 setInterval 每隔一秒检查一次 shouldCancel。如果标志为 true,则清除定时器并调用回调函数,传递取消错误。如果任务正常完成,则在进度达到 10 时清除定时器并调用回调函数,传递成功结果。

  1. 基于 Promise 的手动取消
function asyncTaskWithCancelPromise() {
    return new Promise((resolve, reject) => {
        let shouldCancel = false;
        let progress = 0;
        const intervalId = setInterval(() => {
            if (shouldCancel) {
                clearInterval(intervalId);
                reject(new Error('Task cancelled'));
                return;
            }
            progress += 1;
            if (progress >= 10) {
                clearInterval(intervalId);
                resolve('Task completed');
            }
        }, 1000);
        setTimeout(() => {
            shouldCancel = true;
        }, 3000);
    });
}
asyncTaskWithCancelPromise()
  .then(result => {
        console.log(result);
    })
  .catch(err => {
        console.error(err.message);
    });

这里使用 Promise 封装了异步任务,同样通过检查 shouldCancel 标志来决定是否取消任务。如果取消,拒绝 Promise 并抛出错误;如果任务正常完成,解决 Promise 并传递成功结果。

异步任务的超时控制

超时控制是异步任务管理中另一个重要的方面。当一个异步任务运行时间过长时,我们可能希望自动终止它,以避免程序长时间无响应。

使用 setTimeout 实现超时

  1. 简单的 setTimeout 超时处理
function asyncTask() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Task completed');
        }, 5000);
    });
}
const timeoutId = setTimeout(() => {
    console.error('Task timed out');
}, 3000);
asyncTask()
  .then(result => {
        clearTimeout(timeoutId);
        console.log(result);
    })
  .catch(err => {
        console.error(err);
    });

在这个例子中,asyncTask 模拟了一个需要五秒才能完成的异步任务。我们设置了一个三秒的定时器 timeoutId,如果 asyncTask 在三秒内没有完成,定时器就会触发,打印出 “Task timed out”。如果 asyncTask 正常完成,我们会清除定时器并打印任务结果。

  1. 封装超时函数
function withTimeout(promise, ms) {
    return new Promise((resolve, reject) => {
        const timeoutId = setTimeout(() => {
            reject(new Error('Task timed out'));
        }, ms);
        promise
          .then(result => {
                clearTimeout(timeoutId);
                resolve(result);
            })
          .catch(err => {
                clearTimeout(timeoutId);
                reject(err);
            });
    });
}
function asyncTask() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Task completed');
        }, 5000);
    });
}
withTimeout(asyncTask(), 3000)
  .then(result => {
        console.log(result);
    })
  .catch(err => {
        console.error(err.message);
    });

这里我们封装了一个 withTimeout 函数,它接受一个 Promise 和一个超时时间 ms。在 withTimeout 函数内部,我们设置了一个定时器,当定时器触发时,拒绝传入的 Promise 并抛出超时错误。如果传入的 Promise 正常完成,我们清除定时器并解决 withTimeout 返回的 Promise。

使用 AbortController 实现超时

结合 AbortController,我们可以更优雅地实现超时控制。

  1. 基本实现
const { AbortController } = require('node:abort - controller');
function asyncTask() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Task completed');
        }, 5000);
    });
}
const controller = new AbortController();
const { signal } = controller;
const timeoutId = setTimeout(() => {
    controller.abort();
}, 3000);
asyncTask()
  .then(result => {
        clearTimeout(timeoutId);
        console.log(result);
    })
  .catch(err => {
        clearTimeout(timeoutId);
        if (err.name === 'AbortError') {
            console.error('Task timed out');
        } else {
            console.error(err);
        }
    });

在这个例子中,我们使用 AbortController 来实现超时控制。创建一个 AbortController 实例 controller,并获取其 signal。设置一个三秒的定时器,当定时器触发时,调用 controller.abort() 发出取消信号。在 asyncTaskcatch 块中,我们检查错误是否为 AbortError,如果是,则表示任务超时。

  1. 封装超时函数
const { AbortController } = require('node:abort - controller');
function withTimeoutAbort(promise, ms) {
    return new Promise((resolve, reject) => {
        const controller = new AbortController();
        const { signal } = controller;
        const timeoutId = setTimeout(() => {
            controller.abort();
        }, ms);
        promise
          .then(result => {
                clearTimeout(timeoutId);
                resolve(result);
            })
          .catch(err => {
                clearTimeout(timeoutId);
                if (err.name === 'AbortError') {
                    reject(new Error('Task timed out'));
                } else {
                    reject(err);
                }
            });
    });
}
function asyncTask() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Task completed');
        }, 5000);
    });
}
withTimeoutAbort(asyncTask(), 3000)
  .then(result => {
        console.log(result);
    })
  .catch(err => {
        console.error(err.message);
    });

这里我们封装了一个基于 AbortController 的超时函数 withTimeoutAbort。它接受一个 Promise 和一个超时时间 ms。在函数内部,创建一个 AbortController,设置定时器在超时时发出取消信号。如果 Promise 正常完成,清除定时器并解决返回的 Promise;如果捕获到 AbortError,则拒绝返回的 Promise 并抛出超时错误。

复杂场景下的取消与超时控制

在实际应用中,异步任务往往不是孤立存在的,可能涉及到多个异步任务的嵌套、并发执行等复杂场景。下面我们来看一些复杂场景下如何进行取消与超时控制。

并发任务的取消与超时

  1. 使用 Promise.all 并发执行任务
const { AbortController } = require('node:abort - controller');
function asyncTask1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Task 1 completed');
        }, 4000);
    });
}
function asyncTask2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Task 2 completed');
        }, 3000);
    });
}
const controller = new AbortController();
const signal = controller.signal;
const tasks = [asyncTask1(), asyncTask2()];
const timeoutId = setTimeout(() => {
    controller.abort();
}, 2500);
Promise.all(tasks.map(task => {
    return new Promise((resolve, reject) => {
        const taskWithSignal = () => task.then(resolve).catch(reject);
        signal.addEventListener('abort', () => {
            reject(new Error('Tasks aborted'));
        });
        taskWithSignal();
    });
}))
  .then(results => {
        clearTimeout(timeoutId);
        console.log(results);
    })
  .catch(err => {
        clearTimeout(timeoutId);
        if (err.name === 'AbortError') {
            console.error('Tasks timed out or aborted');
        } else {
            console.error(err);
        }
    });

在这个例子中,我们使用 Promise.all 并发执行 asyncTask1asyncTask2。同时,我们设置了一个 AbortController 并添加了一个两秒半的超时定时器。每个任务都添加了对 signalabort 事件监听器,当接收到取消信号时,拒绝相应的 Promise。如果任务正常完成,打印结果;如果超时或取消,捕获错误并处理。

  1. 使用 Promise.race 实现任务竞争并设置超时
const { AbortController } = require('node:abort - controller');
function asyncTask1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Task 1 completed');
        }, 4000);
    });
}
function asyncTask2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Task 2 completed');
        }, 3000);
    });
}
const controller = new AbortController();
const signal = controller.signal;
const timeoutId = setTimeout(() => {
    controller.abort();
}, 2500);
Promise.race([asyncTask1(), asyncTask2()].map(task => {
    return new Promise((resolve, reject) => {
        const taskWithSignal = () => task.then(resolve).catch(reject);
        signal.addEventListener('abort', () => {
            reject(new Error('Tasks aborted'));
        });
        taskWithSignal();
    });
}))
  .then(result => {
        clearTimeout(timeoutId);
        console.log(result);
    })
  .catch(err => {
        clearTimeout(timeoutId);
        if (err.name === 'AbortError') {
            console.error('Tasks timed out or aborted');
        } else {
            console.error(err);
        }
    });

这里使用 Promise.race,它会返回最先完成的任务的结果。同样设置了 AbortController 和超时定时器,当任何一个任务完成或者接收到取消信号时,相应地处理结果或错误。

嵌套异步任务的取消与超时

  1. 嵌套回调函数的情况
let shouldCancel = false;
function innerAsyncTask(callback) {
    let progress = 0;
    const intervalId = setInterval(() => {
        if (shouldCancel) {
            clearInterval(intervalId);
            callback(new Error('Inner task cancelled'));
            return;
        }
        progress += 1;
        if (progress >= 5) {
            clearInterval(intervalId);
            callback(null, 'Inner task completed');
        }
    }, 1000);
}
function outerAsyncTask(callback) {
    setTimeout(() => {
        innerAsyncTask((innerErr, innerResult) => {
            if (innerErr) {
                callback(innerErr);
                return;
            }
            console.log(innerResult);
            callback(null, 'Outer task completed');
        });
    }, 2000);
}
outerAsyncTask((err, result) => {
    if (err) {
        console.error(err.message);
    } else {
        console.log(result);
    }
});
setTimeout(() => {
    shouldCancel = true;
}, 3000);

在这个例子中,outerAsyncTask 内部调用了 innerAsyncTask。我们通过全局变量 shouldCancel 来控制任务的取消。innerAsyncTask 每隔一秒检查一次 shouldCancel,如果为 true 则取消任务。outerAsyncTask 在两秒后启动 innerAsyncTask,如果 innerAsyncTask 取消或完成,outerAsyncTask 相应地处理结果或错误。

  1. 嵌套 Promise 的情况
function innerAsyncTask() {
    return new Promise((resolve, reject) => {
        let shouldCancel = false;
        let progress = 0;
        const intervalId = setInterval(() => {
            if (shouldCancel) {
                clearInterval(intervalId);
                reject(new Error('Inner task cancelled'));
                return;
            }
            progress += 1;
            if (progress >= 5) {
                clearInterval(intervalId);
                resolve('Inner task completed');
            }
        }, 1000);
        setTimeout(() => {
            shouldCancel = true;
        }, 3000);
    });
}
function outerAsyncTask() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            innerAsyncTask()
              .then(innerResult => {
                    console.log(innerResult);
                    resolve('Outer task completed');
                })
              .catch(innerErr => {
                    reject(innerErr);
                });
        }, 2000);
    });
}
outerAsyncTask()
  .then(result => {
        console.log(result);
    })
  .catch(err => {
        console.error(err.message);
    });

这里使用 Promise 嵌套实现了类似的功能。innerAsyncTask 封装为 Promise,通过内部的 shouldCancel 标志控制取消。outerAsyncTask 在两秒后启动 innerAsyncTask,并根据 innerAsyncTask 的结果解决或拒绝自身的 Promise。

性能与资源管理

在实现异步任务的取消与超时控制时,性能与资源管理是不可忽视的方面。

资源释放

  1. 定时器资源:无论是在实现取消还是超时控制时,我们经常会使用 setTimeoutsetInterval 创建定时器。在任务完成或取消时,一定要记得清除这些定时器,以避免内存泄漏和不必要的资源消耗。例如:
const timeoutId = setTimeout(() => {
    console.log('Timeout');
}, 3000);
// 任务完成或取消时
clearTimeout(timeoutId);
  1. 文件描述符等资源:在涉及文件系统操作时,如果任务取消或超时,要确保正确关闭文件描述符,释放相关资源。例如:
const fs = require('fs');
const fd = fs.openSync('example.txt', 'r');
try {
    // 异步文件操作逻辑
} catch (err) {
    // 取消或超时处理
    fs.closeSync(fd);
}

性能优化

  1. 减少不必要的检查:在手动实现取消机制时,虽然定期检查取消标志是必要的,但过于频繁的检查会影响性能。应该根据任务的性质和预期运行时间,合理设置检查频率。例如,对于一个可能运行较长时间的任务,可以适当延长检查间隔:
let shouldCancel = false;
function asyncTask() {
    let progress = 0;
    const intervalId = setInterval(() => {
        if (shouldCancel) {
            clearInterval(intervalId);
            // 处理取消逻辑
            return;
        }
        progress += 1;
        // 任务逻辑
    }, 500); // 适当延长检查间隔
    return new Promise((resolve) => {
        // 任务完成逻辑
    });
}
  1. 合理使用并发与并行:在处理多个异步任务时,要根据任务的特点和系统资源情况,合理选择并发或并行执行方式。例如,对于 I/O 密集型任务,并发执行可以充分利用系统资源,提高整体效率;而对于 CPU 密集型任务,并行执行可能会导致系统资源过度消耗,反而降低性能。可以使用 cluster 模块在 Node.js 中实现多进程并行处理 CPU 密集型任务,同时结合 AbortController 等机制进行任务的取消与超时控制。

通过合理的资源释放和性能优化,可以确保在实现异步任务的取消与超时控制时,程序既能满足功能需求,又能保持良好的性能和资源利用率。

在 Node.js 开发中,熟练掌握异步任务的取消与超时控制,对于构建高效、稳定的应用程序至关重要。无论是简单的单任务场景,还是复杂的并发、嵌套任务场景,都可以通过合适的方法实现灵活的控制。同时,注重性能与资源管理,能够进一步提升应用程序的质量。希望通过本文的介绍,你对 Node.js 中异步任务的取消与超时控制有了更深入的理解和掌握。