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

Node.js 异步编程与同步编程的对比分析

2021-04-011.9k 阅读

Node.js 中的同步编程

在 Node.js 开发中,同步编程是一种较为基础且直观的编程模式。在同步代码执行过程中,代码按照顺序依次执行,前一个任务完成后,才会执行下一个任务。这就好比我们日常生活中排队办事,只有前面的人办完了,后面的人才能接着办。

同步函数的执行流程

来看一个简单的文件读取示例,使用 Node.js 内置的 fs 模块(文件系统模块)的同步读取方法 fs.readFileSync

const fs = require('fs');

try {
    const data = fs.readFileSync('example.txt', 'utf8');
    console.log(data);
} catch (err) {
    console.error(err);
}

在上述代码中,fs.readFileSync 方法会同步读取 example.txt 文件的内容。这里 try - catch 块用于捕获可能出现的错误,比如文件不存在等情况。当执行到 fs.readFileSync 时,Node.js 会暂停后续代码的执行,直到文件读取操作完成。只有在文件成功读取并返回数据后,才会执行 console.log(data) 打印文件内容。如果文件读取过程中出现错误,会执行 catch 块中的代码,打印错误信息。

这种同步执行方式的优点在于代码逻辑简单清晰,非常容易理解和调试。开发人员可以很直观地知道代码的执行顺序,对于简单的、不涉及大量 I/O 操作的程序来说,同步编程是一个很好的选择。

然而,同步编程也存在明显的弊端。由于 Node.js 主要设计用于构建高性能的网络应用,而 I/O 操作(如文件读取、网络请求等)往往是比较耗时的。在同步执行 I/O 操作时,Node.js 进程会被阻塞,无法处理其他任务。例如在上面的文件读取示例中,如果 example.txt 文件很大,或者由于某些原因文件读取速度很慢,那么在文件读取的这段时间内,Node.js 就无法响应其他客户端的请求,这会严重影响应用的性能和响应能力。

Node.js 中的异步编程

为了解决同步编程在处理 I/O 操作时的阻塞问题,Node.js 采用了异步编程模型。异步编程允许 Node.js 在执行 I/O 操作等耗时任务时,不会阻塞主线程,而是继续执行后续代码,当 I/O 操作完成后,通过回调函数、Promise 或者 Async/Await 等方式来处理结果。

基于回调函数的异步编程

回调函数是 Node.js 中实现异步编程最基础的方式。继续以文件读取为例,使用 fs.readFile 这个异步方法:

const fs = require('fs');

fs.readFile('example.txt', 'utf8', (err, data) => {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});
console.log('This is printed before file reading is done');

在这段代码中,fs.readFile 是一个异步函数,它接受三个参数:要读取的文件名、编码格式以及一个回调函数。当调用 fs.readFile 时,Node.js 不会等待文件读取完成,而是立即执行后续的 console.log('This is printed before file reading is done'); 语句。当文件读取操作完成后,无论成功与否,都会调用传入的回调函数。如果读取成功,err 参数为 nulldata 参数就是文件的内容;如果读取失败,err 参数会包含错误信息。

这种基于回调函数的异步编程方式虽然解决了阻塞问题,但也带来了一些新的问题。其中最典型的就是“回调地狱”(Callback Hell),也称为“回调金字塔”。当有多个异步操作需要顺序执行,并且每个异步操作都依赖前一个操作的结果时,代码会变得非常嵌套和难以阅读维护。例如:

asyncFunction1((result1) => {
    asyncFunction2(result1, (result2) => {
        asyncFunction3(result2, (result3) => {
            asyncFunction4(result3, (result4) => {
                // 更多嵌套...
            });
        });
    });
});

使用 Promise 进行异步编程

Promise 是一种更优雅的处理异步操作的方式,它可以有效避免回调地狱的问题。Promise 表示一个异步操作的最终完成(或失败)及其结果值。一个 Promise 对象有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。

下面是使用 Promise 来封装文件读取操作的示例:

const fs = require('fs');
const { promisify } = require('util');

const readFilePromise = promisify(fs.readFile);

readFilePromise('example.txt', 'utf8')
   .then(data => {
        console.log(data);
    })
   .catch(err => {
        console.error(err);
    });

在上述代码中,util.promisify 方法将基于回调的 fs.readFile 函数转换为返回 Promise 的函数 readFilePromise。调用 readFilePromise 后,返回一个 Promise 对象。通过 .then 方法可以处理 Promise 成功的情况,即文件读取成功,data 就是文件内容;通过 .catch 方法可以处理 Promise 失败的情况,即文件读取过程中出现错误。

当有多个异步操作需要顺序执行时,使用 Promise 可以通过链式调用的方式,使代码更加清晰:

function asyncFunction1() {
    return new Promise((resolve, reject) => {
        // 模拟异步操作
        setTimeout(() => {
            resolve('Result of asyncFunction1');
        }, 1000);
    });
}

function asyncFunction2(result1) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(result1 +'and Result of asyncFunction2');
        }, 1000);
    });
}

asyncFunction1()
   .then(result1 => asyncFunction2(result1))
   .then(result2 => console.log(result2))
   .catch(err => console.error(err));

在这个示例中,asyncFunction1asyncFunction2 都是返回 Promise 的异步函数。通过链式调用 .then,可以顺序执行这些异步操作,并且每个操作的结果可以作为下一个操作的输入。

Async/Await 语法糖

Async/Await 是在 Promise 的基础上进一步发展而来的语法糖,它使得异步代码看起来更像同步代码,大大提高了代码的可读性。

const fs = require('fs');
const { promisify } = require('util');

const readFilePromise = promisify(fs.readFile);

async function readFile() {
    try {
        const data = await readFilePromise('example.txt', 'utf8');
        console.log(data);
    } catch (err) {
        console.error(err);
    }
}

readFile();

在上述代码中,定义了一个 async 函数 readFile。在 async 函数内部,可以使用 await 关键字暂停函数的执行,等待 Promise 被解决(resolved)或被拒绝(rejected)。await 只能在 async 函数内部使用。这里 await readFilePromise('example.txt', 'utf8') 会等待文件读取操作完成,只有读取完成后才会继续执行后续代码。如果读取过程中出现错误,会被 try - catch 块捕获。

当有多个异步操作需要顺序执行时,Async/Await 的优势更加明显:

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

async function asyncFunction2(result1) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(result1 +'and Result of asyncFunction2');
        }, 1000);
    });
}

async function main() {
    try {
        const result1 = await asyncFunction1();
        const result2 = await asyncFunction2(result1);
        console.log(result2);
    } catch (err) {
        console.error(err);
    }
}

main();

main 函数中,通过 await 依次等待 asyncFunction1asyncFunction2 执行完成,并获取它们的结果。代码结构清晰,与同步代码的写法非常相似,更易于理解和维护。

同步编程与异步编程的性能对比

简单 I/O 操作的性能测试

为了更直观地对比同步编程和异步编程在 I/O 操作中的性能,我们来进行一个简单的文件读取性能测试。假设我们有一个较大的文本文件 bigFile.txt,大小为 100MB。

首先是同步读取的测试代码:

const fs = require('fs');
const startSync = Date.now();

try {
    const data = fs.readFileSync('bigFile.txt', 'utf8');
    const endSync = Date.now();
    console.log(`Sync read time: ${endSync - startSync} ms`);
} catch (err) {
    console.error(err);
}

然后是异步读取的测试代码:

const fs = require('fs');
const { promisify } = require('util');
const readFilePromise = promisify(fs.readFile);

const startAsync = Date.now();

readFilePromise('bigFile.txt', 'utf8')
   .then(data => {
        const endAsync = Date.now();
        console.log(`Async read time: ${endAsync - startAsync} ms`);
    })
   .catch(err => {
        console.error(err);
    });

在实际测试中,多次运行这两段代码,会发现同步读取在读取大文件时,由于阻塞主线程,整个程序的响应时间明显变长。而异步读取不会阻塞主线程,在文件读取的同时,Node.js 可以继续处理其他任务,所以在整体性能上,异步读取更具优势。

复杂 I/O 操作场景下的性能

在更复杂的场景中,比如同时进行多个文件读取、网络请求等 I/O 操作时,异步编程的优势更加凸显。假设我们需要读取三个文件 file1.txtfile2.txtfile3.txt,并对它们的内容进行一些处理。

同步方式的代码如下:

const fs = require('fs');
const startSync = Date.now();

try {
    const data1 = fs.readFileSync('file1.txt', 'utf8');
    const data2 = fs.readFileSync('file2.txt', 'utf8');
    const data3 = fs.readFileSync('file3.txt', 'utf8');

    // 对文件内容进行处理
    const result = data1 + data2 + data3;

    const endSync = Date.now();
    console.log(`Sync total time: ${endSync - startSync} ms`);
} catch (err) {
    console.error(err);
}

异步方式(使用 Async/Await)的代码如下:

const fs = require('fs');
const { promisify } = require('util');
const readFilePromise = promisify(fs.readFile);

const startAsync = Date.now();

async function readFiles() {
    try {
        const data1 = await readFilePromise('file1.txt', 'utf8');
        const data2 = await readFilePromise('file2.txt', 'utf8');
        const data3 = await readFilePromise('file3.txt', 'utf8');

        // 对文件内容进行处理
        const result = data1 + data2 + data3;

        const endAsync = Date.now();
        console.log(`Async total time: ${endAsync - startAsync} ms`);
    } catch (err) {
        console.error(err);
    }
}

readFiles();

在这种情况下,同步方式会依次阻塞主线程读取每个文件,总时间是三个文件读取时间之和再加上处理时间。而异步方式可以并发地发起文件读取请求(虽然这里使用 await 看起来是顺序执行,但实际上 Node.js 内部会在等待一个 Promise 时去处理其他任务),总时间主要取决于耗时最长的那个文件读取操作加上处理时间,通常会比同步方式快很多。

同步编程与异步编程的适用场景

同步编程的适用场景

  1. 简单且不涉及 I/O 操作的程序:例如一些简单的数学计算、字符串处理等程序。在这些情况下,同步编程的简单性和直观性使得代码易于编写和维护。例如:
function addNumbers(a, b) {
    return a + b;
}

const result = addNumbers(5, 3);
console.log(result);
  1. 对性能要求不高,但对代码可读性和维护性要求较高的内部工具脚本:比如一些用于生成项目文档、格式化代码等小工具脚本,它们通常不会处理大量的并发请求或耗时的 I/O 操作,使用同步编程可以使代码更简洁易懂。

异步编程的适用场景

  1. I/O 密集型应用:这是异步编程最主要的应用场景,包括文件系统操作、网络请求(如 HTTP 请求)、数据库查询等。在 Node.js 开发的 Web 应用中,大量的用户请求需要处理,每个请求可能涉及到数据库查询、文件读取等 I/O 操作。通过异步编程,Node.js 可以在等待这些 I/O 操作完成的同时,继续处理其他请求,提高应用的并发处理能力和响应速度。例如一个简单的 Node.js HTTP 服务器,处理用户请求并读取数据库数据返回给用户:
const http = require('http');
const mysql = require('mysql');

const connection = mysql.createConnection({
    host: 'localhost',
    user: 'root',
    password: 'password',
    database: 'test'
});

connection.connect();

const server = http.createServer((req, res) => {
    const query = 'SELECT * FROM users';
    connection.query(query, (err, results) => {
        if (err) {
            res.writeHead(500, { 'Content-Type': 'text/plain' });
            res.end('Error querying database');
            return;
        }
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify(results));
    });
});

server.listen(3000, () => {
    console.log('Server running on port 3000');
});
  1. 需要处理大量并发任务的场景:比如在线游戏服务器,需要同时处理大量玩家的实时请求。异步编程可以有效地处理这些并发请求,避免因为某个请求的耗时操作而阻塞其他请求的处理,保证游戏的流畅运行。

错误处理在同步与异步编程中的差异

同步编程中的错误处理

在同步编程中,错误处理相对简单直接。由于代码是顺序执行的,当一个同步函数抛出错误时,可以使用 try - catch 块来捕获并处理错误。例如前面提到的 fs.readFileSync 的示例:

const fs = require('fs');

try {
    const data = fs.readFileSync('nonexistentFile.txt', 'utf8');
    console.log(data);
} catch (err) {
    console.error(err);
}

在这个例子中,如果 nonexistentFile.txt 文件不存在,fs.readFileSync 会抛出一个错误,try - catch 块会捕获这个错误,并在 catch 块中打印错误信息。

异步编程中的错误处理

  1. 基于回调函数的异步错误处理:在基于回调函数的异步编程中,错误处理通过回调函数的第一个参数来传递。例如 fs.readFile 的示例:
const fs = require('fs');

fs.readFile('nonexistentFile.txt', 'utf8', (err, data) => {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});

当文件读取失败时,err 参数会包含错误信息,在回调函数内部可以对错误进行处理。

  1. Promise 中的错误处理:在 Promise 中,可以通过 .catch 方法来处理 Promise 被拒绝时的错误。例如:
const fs = require('fs');
const { promisify } = require('util');

const readFilePromise = promisify(fs.readFile);

readFilePromise('nonexistentFile.txt', 'utf8')
   .then(data => {
        console.log(data);
    })
   .catch(err => {
        console.error(err);
    });

.catch 方法会捕获 readFilePromise 执行过程中抛出的错误,并且可以链式调用,方便统一处理多个 Promise 操作中的错误。

  1. Async/Await 中的错误处理:在 async 函数中,使用 try - catch 块来处理 await 操作抛出的错误,就像同步编程一样,但处理的是异步操作的错误。例如:
const fs = require('fs');
const { promisify } = require('util');

const readFilePromise = promisify(fs.readFile);

async function readFile() {
    try {
        const data = await readFilePromise('nonexistentFile.txt', 'utf8');
        console.log(data);
    } catch (err) {
        console.error(err);
    }
}

readFile();

这种方式使得异步操作的错误处理代码结构与同步操作的错误处理非常相似,提高了代码的可读性和可维护性。

总结同步编程与异步编程的对比

  1. 执行顺序:同步编程按照代码顺序依次执行,前一个任务完成后才执行下一个任务;异步编程不会阻塞主线程,在执行异步任务时可以继续执行后续代码,任务完成后通过回调函数、Promise 或 Async/Await 等方式处理结果。
  2. 性能:在 I/O 密集型操作中,异步编程具有明显优势,因为它不会阻塞主线程,能够提高应用的并发处理能力和响应速度;而同步编程在简单的、不涉及 I/O 操作的场景下,由于其简单直接,性能也能满足需求。
  3. 代码复杂度:同步编程代码逻辑简单清晰,易于理解和调试;异步编程在处理复杂的异步操作序列时,基于回调函数的方式容易出现回调地狱问题,而 Promise 和 Async/Await 虽然提高了代码的可读性,但相对于同步编程,其概念和语法还是更复杂一些。
  4. 错误处理:同步编程通过 try - catch 块捕获错误;异步编程在基于回调函数时通过回调函数的参数传递错误,在 Promise 中通过 .catch 方法处理错误,在 Async/Await 中又可以使用 try - catch 块来处理异步操作的错误。

在实际的 Node.js 开发中,需要根据具体的应用场景和需求来选择合适的编程方式。对于简单的、不涉及 I/O 操作的任务,可以选择同步编程;而对于 I/O 密集型、需要处理大量并发请求的应用,异步编程则是更好的选择。同时,熟练掌握异步编程的各种方式(回调函数、Promise、Async/Await),能够使我们更高效地开发出高性能、可维护的 Node.js 应用。