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

JavaScript函数方法的并发使用

2023-02-145.2k 阅读

JavaScript 中的并发概念

在 JavaScript 的世界里,并发(Concurrency)指的是在同一时间段内处理多个任务的能力。JavaScript 作为一门单线程语言,这一特性可能会让人疑惑它如何实现并发。然而,JavaScript 通过事件循环(Event Loop)机制以及异步编程模型,巧妙地模拟出了并发的效果。

单线程与事件循环

JavaScript 运行在单线程环境中,这意味着它一次只能执行一个任务。这种设计避免了多线程编程中常见的问题,比如竞态条件(Race Condition)和死锁(Deadlock)。但同时,也带来了一个挑战:如果一个任务执行时间过长,会阻塞后续任务的执行。

事件循环是 JavaScript 实现异步操作和并发的核心机制。它不断地检查调用栈(Call Stack)是否为空,当调用栈为空时,事件循环会从任务队列(Task Queue)中取出任务放入调用栈执行。任务队列中存放的是各种异步操作完成后产生的回调函数。

例如,当我们发起一个网络请求(如 fetch)时,这个请求并不会阻塞主线程,而是在后台执行。当请求完成后,相关的回调函数会被放入任务队列。事件循环检测到调用栈为空时,就会将这个回调函数从任务队列移到调用栈执行,从而实现了并发的效果。

异步任务分类

在 JavaScript 中,异步任务可以分为宏任务(Macrotask)和微任务(Microtask)。宏任务包括 setTimeoutsetIntervalI/O 操作、setImmediate(仅 Node.js 环境)等;微任务包括 Promise.thenprocess.nextTick(仅 Node.js 环境)等。

事件循环在处理任务时,会优先处理微任务队列中的任务。只有当微任务队列清空后,才会去处理宏任务队列中的任务。这种机制确保了某些重要的异步操作(如 Promise 的回调)能够在当前调用栈结束后尽快执行。

例如:

console.log('start');

setTimeout(() => {
    console.log('setTimeout');
}, 0);

Promise.resolve().then(() => {
    console.log('Promise.then');
});

console.log('end');

在上述代码中,输出结果会是 startendPromise.thensetTimeout。这是因为 console.log('start')console.log('end') 是同步任务,依次执行。setTimeout 是宏任务,被放入宏任务队列;Promise.resolve().then 是微任务,被放入微任务队列。事件循环先执行完同步任务,然后处理微任务队列,所以 Promise.then 的回调先于 setTimeout 的回调执行。

函数方法并发使用的场景

数据获取与处理

在实际开发中,经常会遇到需要从多个数据源获取数据,并对这些数据进行处理的场景。例如,一个电商应用可能需要同时从商品 API 获取商品列表,从用户 API 获取用户信息,然后根据用户信息对商品列表进行个性化推荐。

并行计算

在一些需要进行大量计算的场景下,我们可以将计算任务拆分成多个子任务,并并行执行这些子任务,最后合并结果。比如,对一个大数据集进行复杂的统计分析,我们可以将数据集分成多个小块,分别在不同的函数中进行计算,最后汇总结果。

提高应用响应性

当应用中有一些耗时较长的操作时,如果能将这些操作并发执行,就能避免主线程被长时间阻塞,提高应用的响应性。例如,在一个文件上传应用中,同时上传多个文件时,通过并发处理可以让用户在上传过程中继续进行其他操作。

实现函数方法并发的方式

使用 Promise.all

Promise.all 是 JavaScript 中用于并发执行多个 Promise 的方法。它接受一个 Promise 对象数组作为参数,并返回一个新的 Promise。当所有传入的 Promise 都成功时,这个新的 Promise 才会成功,并且其 resolve 值是一个包含所有传入 Promise 成功结果的数组。如果其中任何一个 Promise 失败,这个新的 Promise 就会失败,其 reject 值为第一个失败的 Promisereject 值。

例如,我们有两个异步函数 fetchData1fetchData2,分别从不同的 API 获取数据:

function fetchData1() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('Data from API 1');
        }, 1000);
    });
}

function fetchData2() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('Data from API 2');
        }, 1500);
    });
}

Promise.all([fetchData1(), fetchData2()])
   .then((results) => {
        console.log(results); // 输出: ['Data from API 1', 'Data from API 2']
    })
   .catch((error) => {
        console.error(error);
    });

在上述代码中,fetchData1fetchData2 会并发执行,Promise.all 等待它们都完成后,将结果数组传递给 then 回调。

使用 Promise.race

Promise.race 同样接受一个 Promise 对象数组作为参数,并返回一个新的 Promise。与 Promise.all 不同的是,Promise.race 只要数组中的任何一个 Promise 成功或失败,这个新的 Promise 就会立即以相同的状态和值进行解决或拒绝。

例如,我们有两个异步函数 fetchDataFastfetchDataSlowfetchDataFast 会更快地完成:

function fetchDataFast() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('Fast data');
        }, 1000);
    });
}

function fetchDataSlow() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('Slow data');
        }, 3000);
    });
}

Promise.race([fetchDataFast(), fetchDataSlow()])
   .then((result) => {
        console.log(result); // 输出: 'Fast data'
    })
   .catch((error) => {
        console.error(error);
    });

在这个例子中,Promise.race 会在 fetchDataFast 完成后立即返回其结果,而不会等待 fetchDataSlow 完成。

使用 async/await 结合 Promise.all

async/await 是 JavaScript 中用于异步编程的语法糖,它基于 Promise 并提供了更简洁的异步代码书写方式。当结合 Promise.all 时,可以更方便地实现并发操作。

例如,假设有三个异步函数 asyncFunction1asyncFunction2asyncFunction3

async function asyncFunction1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Result of asyncFunction1');
        }, 1000);
    });
}

async function asyncFunction2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Result of asyncFunction2');
        }, 1500);
    });
}

async function asyncFunction3() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Result of asyncFunction3');
        }, 2000);
    });
}

async function main() {
    const [result1, result2, result3] = await Promise.all([asyncFunction1(), asyncFunction2(), asyncFunction3()]);
    console.log(result1, result2, result3);
}

main();

在上述代码中,asyncFunction1asyncFunction2asyncFunction3 会并发执行,await Promise.all 会等待所有函数执行完毕,并将结果分别赋值给 result1result2result3

使用 setTimeout 模拟并发

虽然 setTimeout 本身是用于延迟执行任务,但可以通过巧妙地利用它来模拟并发效果。我们可以将多个任务通过 setTimeout 放入任务队列,从而让它们看起来像是并发执行。

例如,我们有三个简单的函数 task1task2task3

function task1() {
    console.log('Task 1 started');
    setTimeout(() => {
        console.log('Task 1 completed');
    }, 1000);
}

function task2() {
    console.log('Task 2 started');
    setTimeout(() => {
        console.log('Task 2 completed');
    }, 1500);
}

function task3() {
    console.log('Task 3 started');
    setTimeout(() => {
        console.log('Task 3 completed');
    }, 2000);
}

task1();
task2();
task3();

在上述代码中,task1task2task3 会依次开始执行,它们内部的 setTimeout 回调会在各自设定的延迟时间后执行,从而模拟出并发的效果。不过需要注意的是,这种方式并没有真正实现并行执行,只是利用事件循环让任务看起来像是并发执行。

使用 Web Workers(浏览器环境)

Web Workers 是浏览器提供的一项功能,允许在后台线程中运行脚本,从而实现真正的并行计算。通过 Web Workers,JavaScript 可以利用多核 CPU 的优势,提高应用的性能。

使用 Web Workers 时,首先需要创建一个新的 Worker 实例,并指定一个 JavaScript 文件作为工作线程的入口:

// main.js
const worker = new Worker('worker.js');

worker.onmessage = function (event) {
    console.log('Received from worker:', event.data);
};

worker.postMessage('Start calculation');
// worker.js
self.onmessage = function (event) {
    if (event.data === 'Start calculation') {
        // 执行一些耗时的计算任务
        let result = 0;
        for (let i = 0; i < 1000000000; i++) {
            result += i;
        }
        self.postMessage(result);
    }
};

在上述代码中,main.js 创建了一个 Worker 实例并向其发送消息。worker.js 作为工作线程,接收到消息后执行耗时计算,并将结果返回给主线程。这样,耗时计算不会阻塞主线程,实现了并行执行的效果。

使用 Cluster 模块(Node.js 环境)

在 Node.js 环境中,cluster 模块提供了一种简单的方式来创建多个工作进程,以利用多核 CPU 的优势。每个工作进程都可以独立执行 JavaScript 代码,从而实现并行处理任务。

以下是一个简单的 cluster 模块使用示例:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
    console.log(`Master ${process.pid} is running`);

    for (let i = 0; i < numCPUs; i++) {
        cluster.fork();
    }

    cluster.on('exit', (worker, code, signal) => {
        console.log(`worker ${worker.process.pid} died`);
    });
} else {
    http.createServer((req, res) => {
        res.writeHead(200, { 'Content-Type': 'text/plain' });
        res.end('Hello World\n');
    }).listen(8000, () => {
        console.log(`Worker ${process.pid} started`);
    });
}

在上述代码中,主进程(Master)根据 CPU 核心数量创建多个工作进程(Worker)。每个工作进程启动一个 HTTP 服务器,监听相同的端口。这样,多个请求可以被并行处理,提高了服务器的性能。

并发使用中的错误处理

Promise.all 的错误处理

当使用 Promise.all 时,如果其中任何一个 Promise 失败,整个 Promise.all 都会失败,并将第一个失败的 Promisereject 值传递给 catch 回调。

例如:

function successPromise() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Success');
        }, 1000);
    });
}

function failurePromise() {
    return new Promise((_, reject) => {
        setTimeout(() => {
            reject('Failure');
        }, 1500);
    });
}

Promise.all([successPromise(), failurePromise()])
   .then((results) => {
        console.log(results);
    })
   .catch((error) => {
        console.error(error); // 输出: 'Failure'
    });

在这个例子中,failurePromise 失败,Promise.all 捕获到这个错误并将其传递给 catch 回调。

async/await 中的错误处理

async 函数中使用 await 时,如果 awaitPromise 失败,会抛出错误。我们可以使用 try...catch 块来捕获这些错误。

例如:

async function asyncFunction() {
    try {
        const result1 = await successPromise();
        const result2 = await failurePromise();
        console.log(result1, result2);
    } catch (error) {
        console.error(error); // 输出: 'Failure'
    }
}

asyncFunction();

在上述代码中,await failurePromise() 抛出错误,被 try...catch 块捕获。

处理多个并发任务中的错误

在处理多个并发任务时,有时候我们可能需要更精细地处理每个任务的错误,而不仅仅是捕获第一个失败的任务。我们可以通过自定义错误处理逻辑来实现这一点。

例如,对于 Promise.all,我们可以对每个 Promise 进行包装,在 catch 中处理错误并返回一个包含错误信息的结果:

function successPromise() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Success');
        }, 1000);
    });
}

function failurePromise() {
    return new Promise((_, reject) => {
        setTimeout(() => {
            reject('Failure');
        }, 1500);
    });
}

const promises = [successPromise(), failurePromise()];
const wrappedPromises = promises.map(p => p.catch(error => ({ error })));

Promise.all(wrappedPromises)
   .then((results) => {
        results.forEach(result => {
            if ('error' in result) {
                console.error('Task failed:', result.error);
            } else {
                console.log('Task succeeded:', result);
            }
        });
    });

在这个例子中,wrappedPromises 对每个 Promise 进行了包装,无论任务成功还是失败,Promise.all 都会继续执行并返回所有结果,我们可以在 then 回调中对每个结果进行单独处理。

并发使用的性能优化

控制并发数量

在实际应用中,并发执行过多的任务可能会导致资源耗尽,影响性能。我们可以通过控制并发数量来优化性能。例如,在使用 Promise.all 时,可以将任务分成多个批次,每次只执行一定数量的任务。

以下是一个控制并发数量的示例:

function asyncTask(id) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log(`Task ${id} completed`);
            resolve(id);
        }, Math.floor(Math.random() * 2000));
    });
}

function runTasksInBatch(tasks, batchSize) {
    return new Promise((resolve) => {
        const results = [];
        let currentIndex = 0;

        function runBatch() {
            const batch = tasks.slice(currentIndex, currentIndex + batchSize);
            currentIndex += batchSize;

            Promise.all(batch.map(task => task()))
               .then(batchResults => {
                    results.push(...batchResults);
                    if (currentIndex < tasks.length) {
                        runBatch();
                    } else {
                        resolve(results);
                    }
                });
        }

        runBatch();
    });
}

const tasks = Array.from({ length: 10 }, (_, i) => () => asyncTask(i + 1));
runTasksInBatch(tasks, 3)
   .then(finalResults => {
        console.log('All tasks completed:', finalResults);
    });

在上述代码中,runTasksInBatch 函数将任务分成批次,每次执行 batchSize 个任务,从而控制并发数量,避免资源过度消耗。

优化异步操作

减少异步操作的时间开销也是性能优化的关键。例如,在进行网络请求时,可以优化请求参数、使用缓存等方式来减少请求时间。同时,对于一些不必要的异步操作,可以考虑将其转换为同步操作,以减少事件循环的压力。

避免阻塞操作

在并发执行任务时,要确保每个任务都不会长时间阻塞主线程。如果有一些耗时较长的计算任务,尽量将其放在 Web Workers(浏览器环境)或 cluster 模块(Node.js 环境)中执行,以避免影响其他任务的执行。

并发使用在不同场景下的注意事项

浏览器端

在浏览器端使用并发时,要注意内存管理。过多的并发任务可能会导致内存占用过高,影响页面的性能和响应速度。同时,要考虑不同浏览器对并发数量的限制,例如某些浏览器对同一域名下的并发请求数量有限制,超出限制可能会导致请求排队等待。

Node.js 端

在 Node.js 环境中,使用 cluster 模块时要注意进程间通信的开销。频繁的进程间通信可能会影响性能,因此要尽量减少不必要的通信。此外,要注意每个工作进程的资源使用情况,避免某个进程占用过多资源导致系统不稳定。

实时应用

在实时应用中,如 WebSocket 应用,并发操作可能会对实时性产生影响。例如,同时处理多个 WebSocket 消息可能会导致消息处理延迟。在这种情况下,需要根据应用的需求合理安排并发任务,确保实时性不受影响。

数据一致性

在并发操作涉及到数据读写时,要特别注意数据一致性问题。例如,多个并发任务同时读取和修改同一个数据,可能会导致数据不一致。可以使用锁机制(如 async-lock 库)或数据库事务来保证数据的一致性。

总结

JavaScript 通过事件循环、Promiseasync/await 以及各种并发工具,为开发者提供了丰富的并发编程能力。在实际应用中,我们需要根据具体场景选择合适的并发方式,并注意错误处理和性能优化。无论是浏览器端还是 Node.js 环境,合理使用并发可以显著提高应用的性能和响应性,为用户提供更好的体验。同时,并发编程也带来了一些挑战,如错误处理和数据一致性问题,需要开发者谨慎对待。通过不断地实践和学习,我们能够更好地掌握 JavaScript 中的并发编程技巧,开发出高效、稳定的应用程序。