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

JavaScript函数调用的并发处理

2021-07-166.4k 阅读

JavaScript 函数调用的并发处理基础概念

在 JavaScript 的编程世界中,并发处理是一个至关重要的话题,尤其是在函数调用的场景下。并发指的是计算机系统能够同时处理多个任务的能力。对于 JavaScript 这种单线程语言而言,理解和实现函数调用的并发处理显得更为特别。

JavaScript 运行在一个单线程的事件循环模型之上。这意味着在任何时刻,JavaScript 引擎只能执行一个任务。然而,通过异步编程技术,JavaScript 能够模拟并发行为。

回调函数与并发的初步尝试

回调函数是 JavaScript 中实现异步操作的基础方式之一。当一个函数执行某些可能耗时的操作(如网络请求、文件读取等)时,它可以接受一个回调函数作为参数。当操作完成后,回调函数会被调用。

function asyncOperation(callback) {
    setTimeout(() => {
        const result = 42;
        callback(result);
    }, 1000);
}

asyncOperation((data) => {
    console.log('The result is:', data);
});

在上述代码中,asyncOperation 函数使用 setTimeout 模拟一个异步操作,1 秒后调用回调函数并传入结果。这种方式在一定程度上实现了任务的“并发”,因为 setTimeout 开始计时后,JavaScript 引擎可以继续执行后续代码,而不会阻塞等待这 1 秒。

事件循环机制与并发的关系

事件循环是 JavaScript 实现并发的核心机制。JavaScript 引擎有一个调用栈,用于执行函数。当一个函数被调用时,它会被压入调用栈,执行完毕后再从调用栈弹出。

同时,还有一个任务队列(也叫消息队列)。异步任务(如 setTimeoutPromise 等产生的任务)完成后,会将其回调函数放入任务队列。事件循环会不断检查调用栈是否为空,如果为空,就从任务队列中取出一个任务(回调函数)放入调用栈执行。

例如:

console.log('Start');
setTimeout(() => {
    console.log('Timeout callback');
}, 0);
console.log('End');

在这段代码中,首先 console.log('Start') 被压入调用栈执行,然后 setTimeout 启动异步计时,其回调函数被放入任务队列。接着 console.log('End') 被压入调用栈执行。当调用栈为空时,事件循环从任务队列中取出 setTimeout 的回调函数放入调用栈执行,因此输出顺序是 StartEndTimeout callback

Promise 与并发处理

Promise 是 JavaScript 中用于处理异步操作的一种更强大的方式,相比回调函数,它能更好地管理异步操作的并发。

Promise 的基本概念与使用

Promise 代表一个异步操作的最终完成(或失败)及其结果值。它有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。

function asyncFunction() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = true;
            if (success) {
                resolve('Operation successful');
            } else {
                reject('Operation failed');
            }
        }, 1000);
    });
}

asyncFunction()
   .then((value) => {
        console.log(value);
    })
   .catch((error) => {
        console.log(error);
    });

在上述代码中,asyncFunction 返回一个 Promise。如果异步操作成功,调用 resolve 并传入结果;如果失败,调用 reject 并传入错误信息。通过 then 方法可以处理成功的情况,catch 方法处理失败的情况。

Promise.all 实现并发执行多个异步任务

Promise.all 方法用于将多个 Promise 实例包装成一个新的 Promise 实例。当所有传入的 Promise 都变为 fulfilled 状态时,新的 Promise 才会 resolve,并将所有 Promise 的 resolve 值组成一个数组作为新 Promise 的 resolve 值。如果其中任何一个 Promise 变为 rejected 状态,新的 Promise 就会立即 rejected,并将第一个 rejected 的 Promise 的错误信息作为新 Promise 的错误信息。

const promise1 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 1 result');
    }, 1000);
});

const promise2 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 2 result');
    }, 1500);
});

Promise.all([promise1, promise2])
   .then((results) => {
        console.log(results);
    })
   .catch((error) => {
        console.log(error);
    });

在这个例子中,promise1promise2 会并发执行,当它们都完成后,Promise.all 返回的 Promise 会 resolveresults 数组包含了两个 Promise 的 resolve 值。

Promise.race 实现竞争式并发

Promise.race 方法同样将多个 Promise 实例包装成一个新的 Promise 实例。但与 Promise.all 不同的是,只要其中任何一个 Promise 变为 fulfilledrejected 状态,新的 Promise 就会立即 resolverejected,并将第一个改变状态的 Promise 的值或错误信息作为新 Promise 的值或错误信息。

const promise3 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 3 result');
    }, 1000);
});

const promise4 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 4 result');
    }, 1500);
});

Promise.race([promise3, promise4])
   .then((value) => {
        console.log(value);
    })
   .catch((error) => {
        console.log(error);
    });

在这个例子中,promise3 会先完成,所以 Promise.race 返回的 Promise 会 resolve 并输出 Promise 3 result

async/await 与并发处理

async/await 是基于 Promise 的一种更简洁的异步编程语法糖,它让异步代码看起来更像同步代码,同时也为并发处理提供了便利。

async 函数的定义与特性

async 函数是一种异步函数,它始终返回一个 Promise。如果函数的返回值不是 Promise,JavaScript 会自动将其包装成一个已 resolve 的 Promise。

async function asyncFunction2() {
    return 'This is an async function result';
}

asyncFunction2()
   .then((value) => {
        console.log(value);
    });

在上述代码中,asyncFunction2 虽然返回的是一个字符串,但实际上返回的是一个已 resolve 的 Promise,其 resolve 值为该字符串。

await 与并发控制

await 只能在 async 函数内部使用。它会暂停 async 函数的执行,等待一个 Promise 被 resolverejected,然后继续执行 async 函数,并返回 Promise 的 resolve 值或抛出 rejected 的错误。

async function concurrentTasks() {
    const promise5 = new Promise((resolve) => {
        setTimeout(() => {
            resolve('Promise 5 result');
        }, 1000);
    });

    const promise6 = new Promise((resolve) => {
        setTimeout(() => {
            resolve('Promise 6 result');
        }, 1500);
    });

    const [result5, result6] = await Promise.all([promise5, promise6]);
    console.log(result5, result6);
}

concurrentTasks();

在这个例子中,通过 await Promise.allconcurrentTasks 函数会等待 promise5promise6 都完成,然后将它们的结果分别赋值给 result5result6 并输出。

错误处理与并发

在使用 async/await 进行并发处理时,错误处理变得更加直观。可以使用 try...catch 块来捕获 await 的 Promise 可能抛出的错误。

async function concurrentTasksWithError() {
    const promise7 = new Promise((resolve) => {
        setTimeout(() => {
            resolve('Promise 7 result');
        }, 1000);
    });

    const promise8 = new Promise((_, reject) => {
        setTimeout(() => {
            reject('Promise 8 error');
        }, 1500);
    });

    try {
        const [result7, result8] = await Promise.all([promise7, promise8]);
        console.log(result7, result8);
    } catch (error) {
        console.log(error);
    }
}

concurrentTasksWithError();

在这个例子中,由于 promise8rejectPromise.all 会立即 rejecttry...catch 块会捕获到错误并输出。

实际应用中的并发处理场景

网络请求并发

在前端开发中,经常需要同时发起多个网络请求,例如获取用户信息、用户设置和用户权限等。使用 Promise 或 async/await 可以很方便地实现并发网络请求。

假设我们使用 fetch 进行网络请求:

async function fetchUserData() {
    const userInfoUrl = 'https://example.com/api/user/info';
    const userSettingsUrl = 'https://example.com/api/user/settings';
    const userPermissionsUrl = 'https://example.com/api/user/permissions';

    const [infoResponse, settingsResponse, permissionsResponse] = await Promise.all([
        fetch(userInfoUrl),
        fetch(userSettingsUrl),
        fetch(userPermissionsUrl)
    ]);

    const infoData = await infoResponse.json();
    const settingsData = await settingsResponse.json();
    const permissionsData = await permissionsResponse.json();

    return { infoData, settingsData, permissionsData };
}

fetchUserData()
   .then((data) => {
        console.log(data);
    })
   .catch((error) => {
        console.log(error);
    });

在这个例子中,通过 Promise.all 并发发起三个网络请求,然后分别处理响应数据。

数据处理并发

在处理大量数据时,有时可以将数据分成多个部分并发处理,以提高处理效率。例如,对一个大数组进行某种计算:

function processChunk(chunk) {
    return new Promise((resolve) => {
        setTimeout(() => {
            const result = chunk.reduce((acc, num) => acc + num, 0);
            resolve(result);
        }, 1000);
    });
}

async function parallelDataProcessing() {
    const largeArray = Array.from({ length: 1000 }, (_, i) => i + 1);
    const chunkSize = 100;
    const chunks = [];
    for (let i = 0; i < largeArray.length; i += chunkSize) {
        chunks.push(largeArray.slice(i, i + chunkSize));
    }

    const results = await Promise.all(chunks.map(processChunk));
    const total = results.reduce((acc, res) => acc + res, 0);
    console.log('Total:', total);
}

parallelDataProcessing();

在这个例子中,将大数组分成多个小块,每个小块通过 processChunk 函数并发处理,最后汇总结果。

并发处理的性能与资源管理

并发对性能的影响

并发处理在很多情况下可以提高程序的性能,尤其是在处理 I/O 密集型任务(如网络请求、文件读取等)时。因为在等待这些任务完成的过程中,JavaScript 引擎可以执行其他任务,充分利用时间片。

然而,并发处理也并非总是有益的。对于 CPU 密集型任务,过多的并发可能会导致性能下降。因为在 JavaScript 的单线程模型下,并发任务实际上是通过事件循环轮流执行的,过多的任务切换会带来额外的开销。

例如,以下是一个简单的 CPU 密集型任务示例:

function cpuIntensiveTask() {
    let sum = 0;
    for (let i = 0; i < 1000000000; i++) {
        sum += i;
    }
    return sum;
}

async function concurrentCpuTasks() {
    const task1 = cpuIntensiveTask();
    const task2 = cpuIntensiveTask();
    const [result1, result2] = await Promise.all([task1, task2]);
    console.log(result1, result2);
}

concurrentCpuTasks();

在这个例子中,虽然使用了 Promise.all 来模拟并发,但由于是 CPU 密集型任务,实际上并没有提高性能,反而因为任务切换可能导致执行时间变长。

资源管理与并发

在进行并发处理时,还需要注意资源管理。例如,在进行网络请求并发时,如果同时发起过多请求,可能会耗尽网络资源,导致请求失败或性能下降。

可以通过设置并发限制来管理资源。以下是一个使用队列和 async/await 实现并发限制的示例:

function asyncTask(id) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log(`Task ${id} completed`);
            resolve(id);
        }, 1000);
    });
}

async function executeTasksWithLimit(tasks, limit) {
    const results = [];
    const queue = [];

    for (let i = 0; i < tasks.length; i++) {
        queue.push(asyncTask(tasks[i]));
        if (queue.length >= limit || i === tasks.length - 1) {
            const completedTasks = await Promise.all(queue);
            results.push(...completedTasks);
            queue.length = 0;
        }
    }

    return results;
}

const taskIds = [1, 2, 3, 4, 5, 6];
const concurrencyLimit = 3;
executeTasksWithLimit(taskIds, concurrencyLimit)
   .then((results) => {
        console.log('All tasks completed:', results);
    });

在这个例子中,executeTasksWithLimit 函数通过设置并发限制 limit,每次只执行 limit 个任务,当有任务完成后,再从队列中取出新的任务执行,从而有效地管理资源。

并发处理中的常见问题与解决方案

竞态条件

竞态条件是并发编程中常见的问题。当多个异步任务同时访问和修改共享资源时,可能会出现竞态条件,导致不可预测的结果。

例如:

let sharedValue = 0;

function increment() {
    sharedValue++;
}

async function concurrentIncrement() {
    const promises = [];
    for (let i = 0; i < 1000; i++) {
        promises.push(new Promise((resolve) => {
            setTimeout(() => {
                increment();
                resolve();
            }, 0);
        }));
    }

    await Promise.all(promises);
    console.log('Final value:', sharedValue);
}

concurrentIncrement();

在这个例子中,由于多个任务同时调用 increment 函数,可能会出现竞态条件,导致最终的 sharedValue 不是预期的 1000。

解决方案之一是使用互斥锁(Mutex)来保护共享资源。在 JavaScript 中,可以通过 Promise 来模拟互斥锁:

let sharedValue2 = 0;
let mutex = Promise.resolve();

function increment2() {
    return mutex.then(() => {
        mutex = new Promise((resolve) => setTimeout(resolve, 0));
        sharedValue2++;
        return sharedValue2;
    });
}

async function concurrentIncrement2() {
    const promises = [];
    for (let i = 0; i < 1000; i++) {
        promises.push(increment2());
    }

    const results = await Promise.all(promises);
    console.log('Final value:', results[results.length - 1]);
}

concurrentIncrement2();

在这个改进的代码中,通过 mutex 确保每次只有一个任务可以访问和修改 sharedValue2,避免了竞态条件。

死锁

死锁是另一个并发编程中可能出现的问题。当两个或多个任务相互等待对方释放资源时,就会发生死锁。

例如,假设我们有两个资源 resourceAresourceB,两个任务 taskAtaskB

const resourceA = { isLocked: false };
const resourceB = { isLocked: false };

function taskA() {
    if (resourceA.isLocked) {
        // 等待 resourceA 解锁
        return;
    }
    resourceA.isLocked = true;
    if (resourceB.isLocked) {
        // 等待 resourceB 解锁
        resourceA.isLocked = false;
        return;
    }
    resourceB.isLocked = true;
    // 使用资源 A 和 B
    resourceB.isLocked = false;
    resourceA.isLocked = false;
}

function taskB() {
    if (resourceB.isLocked) {
        // 等待 resourceB 解锁
        return;
    }
    resourceB.isLocked = true;
    if (resourceA.isLocked) {
        // 等待 resourceA 解锁
        resourceB.isLocked = false;
        return;
    }
    resourceA.isLocked = true;
    // 使用资源 A 和 B
    resourceA.isLocked = false;
    resourceB.isLocked = false;
}

// 模拟并发执行
setTimeout(taskA, 0);
setTimeout(taskB, 0);

在这个例子中,如果 taskA 先锁定 resourceAtaskB 先锁定 resourceB,然后它们都尝试锁定对方已锁定的资源,就会发生死锁。

避免死锁的方法包括按照固定顺序获取资源、设置超时等。例如,我们可以统一按照 resourceAresourceB 的顺序获取资源:

const resourceC = { isLocked: false };
const resourceD = { isLocked: false };

function taskC() {
    if (resourceC.isLocked) {
        // 等待 resourceC 解锁
        return;
    }
    resourceC.isLocked = true;
    if (resourceD.isLocked) {
        // 等待 resourceD 解锁
        resourceC.isLocked = false;
        return;
    }
    resourceD.isLocked = true;
    // 使用资源 C 和 D
    resourceD.isLocked = false;
    resourceC.isLocked = false;
}

function taskD() {
    if (resourceC.isLocked) {
        // 等待 resourceC 解锁
        return;
    }
    resourceC.isLocked = true;
    if (resourceD.isLocked) {
        // 等待 resourceD 解锁
        resourceC.isLocked = false;
        return;
    }
    resourceD.isLocked = true;
    // 使用资源 C 和 D
    resourceD.isLocked = false;
    resourceC.isLocked = false;
}

// 模拟并发执行
setTimeout(taskC, 0);
setTimeout(taskD, 0);

通过这种方式,就可以避免死锁的发生。

内存泄漏

在并发处理中,如果不正确地管理异步任务和资源,可能会导致内存泄漏。例如,没有正确取消不再需要的异步任务,可能会导致这些任务一直占用内存。

假设我们有一个定时器任务,在组件销毁时没有清除:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Memory Leak Example</title>
</head>

<body>
    <button id="startButton">Start Task</button>
    <script>
        let timer;
        document.getElementById('startButton').addEventListener('click', () => {
            timer = setTimeout(() => {
                console.log('Task running');
            }, 1000);
        });
        // 假设这里是组件销毁逻辑,但没有清除定时器
        // 如果多次点击按钮,会导致定时器任务不断累积,占用内存
    </script>
</body>

</html>

为了避免内存泄漏,在不需要异步任务时,应该正确取消它。例如,在上述代码中,在组件销毁时添加清除定时器的逻辑:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Memory Leak Example</title>
</head>

<body>
    <button id="startButton">Start Task</button>
    <script>
        let timer;
        document.getElementById('startButton').addEventListener('click', () => {
            timer = setTimeout(() => {
                console.log('Task running');
            }, 1000);
        });
        // 假设这里是组件销毁逻辑,添加清除定时器
        window.addEventListener('beforeunload', () => {
            if (timer) {
                clearTimeout(timer);
            }
        });
    </script>
</body>

</html>

通过这种方式,可以确保在不需要异步任务时释放相关资源,避免内存泄漏。

通过深入理解 JavaScript 函数调用的并发处理,包括基本概念、不同技术的应用、实际场景、性能与资源管理以及常见问题的解决,开发者可以编写出更高效、稳定的 JavaScript 代码,充分发挥 JavaScript 在异步和并发处理方面的能力。无论是前端开发中的网络请求并发,还是后端开发中的数据处理并发,这些知识都将是非常宝贵的。