Skip to main content

同步、异步、阻塞、非阻塞

计算机基础
Table of Contents
同步、异步、阻塞、非阻塞>

同步、异步、阻塞、非阻塞 #

在讨论 Node.js 等异步编程框架时,我们经常听到一些术语,如同步、异步、阻塞、非阻塞等。这些术语虽然有一定的关联性,但却有着不同的含义和用法,容易让人混淆。在本文中,我们将对这些概念进行解释和说明,以帮助读者更好地理解 Node.js 异步编程模型。

同步>

同步 #

同步是指代码按顺序执行,一行执行完后才能执行下一行,直到所有代码执行完毕。同步代码具有顺序性和可预测性,但执行时间较长,可能会导致线程阻塞。 下面是一个同步代码的例子,它会依次执行三个函数,并返回它们的结果。

function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

function divide(a, b) {
  return a / b;
}

const a = add(1, 2);
const b = multiply(a, 3);
const c = divide(b, 4);

console.log(c); // 输出 2.25

在上面的代码中,add、multiply 和 divide 函数都是同步函数,它们会在执行完毕后返回结果,并传递给下一个函数。整个代码的执行过程是线性的,可以按照代码的编写顺序进行理解。

异步>

异步 #

异步是指代码不按顺序执行,而是在需要的时候才执行,执行过程中可能会中断或者不断地检查某个条件,以便及时响应其他请求或事件。异步代码具有高效性和灵活性,但对执行过程的顺序和结果不可预测。 下面是一个异步代码的例子,它会读取一个文件并将其内容输出到控制台。

const fs = require('fs');

fs.readFile('example.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

console.log('读取文件中...');

在上面的代码中,fs.readFile 是一个异步函数,它会读取文件并在读取完成后调用回调函数。而 console.log(‘读取文件中…’) 语句则会在读取文件之前执行。因此,在控制台输出的结果中,读取文件中… 和文件的内容不是按照代码的编写顺序输出的。

阻塞/非阻塞>

阻塞/非阻塞 #

阻塞是指线程在执行某个操作时,会一直等待其结果返回,期间无法执行其他任务,直到操作完成才能继续执行。 一些常见的阻塞 I/O 操作包括:

  • 从磁盘读取数据(文件 I/O)
  • 网络通信(网络 I/O)
  • 数据库操作(数据库 I/O)

接下来我们将通过代码来演示同步阻塞、异步阻塞、同步非阻塞、异步非阻塞这四种类型的 I/O 操作。 首先是同步阻塞 I/O 操作,这是 Node.js 最初使用的 I/O 操作方式,当执行阻塞 I/O 操作时,Node.js 进程会停止执行任何其他操作,直到该操作完成并且结果可用。下面是一个使用同步阻塞 I/O 操作读取文件的例子:

同步阻塞>

同步阻塞 #

const fs = require('fs');

const data = fs.readFileSync('data.txt', 'utf-8');
console.log(data);

在这个例子中,fs.readFileSync() 会阻塞 Node.js 线程,直到文件读取完成并且数据被读取到 data 变量中,然后才会继续执行下面的代码。

异步阻塞>

异步阻塞 #

接下来是异步阻塞 I/O 操作,这是一种改进的 I/O 操作方式,它可以在等待 I/O 操作完成时,让 Node.js 进程继续执行其他操作。下面是一个使用异步阻塞 I/O 操作读取文件的例子:

const fs = require('fs');

fs.readFile('data.txt', 'utf-8', (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data);
});

在这个例子中,fs.readFile() 会立即返回,Node.js 进程可以继续执行下面的代码,而不必等待文件读取完成。一旦文件读取完成,回调函数会被调用,打印文件内容。

同步非阻塞>

同步非阻塞 #

然后是同步非阻塞 I/O 操作,这种操作方式不会阻塞 Node.js 线程,但仍然是同步操作。下面是一个使用同步非阻塞 I/O 操作读取文件的例子:

const fs = require('fs');

const fd = fs.openSync('data.txt', 'r');
const buffer = Buffer.alloc(1024);
fs.readSync(fd, buffer, 0, buffer.length, 0);
console.log(buffer.toString('utf-8'));
fs.closeSync(fd);

在这个例子中,fs.openSync() 和 fs.readSync() 都是同步操作,但它们不会阻塞 Node.js 线程。fs.openSync() 打开文件并返回文件描述符,fs.readSync() 读取文件内容并将其存储在缓冲区中。最后,使用 fs.closeSync() 关闭文件描述符。 阻塞式I/O和非阻塞式I/O的区别在于,在阻塞式I/O下,线程在读取或写入数据时会被阻塞,直到数据读取或写入完成;而在非阻塞式I/O下,线程会立即返回一个错误码,指示操作将会被阻塞,但线程并不会被阻塞,它可以继续做其他的工作,之后会通过轮询等方式来判断是否完成了I/O操作。 同步和异步的区别在于,同步调用会一直等待操作完成,直到操作完成后才返回结果,而异步调用不会等待操作完成,而是立即返回,当操作完成后,会通过回调或者其他机制来通知调用者。 下面是一些代码示例,以说明这些概念的具体应用: 同步阻塞读取文件:

const fs = require('fs');
const data = fs.readFileSync('test.txt', 'utf8');
console.log(data);

上述代码会阻塞线程,直到文件读取完成。 异步非阻塞读取文件:

const fs = require('fs');
fs.readFile('test.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

上述代码是异步非阻塞读取文件的例子,它通过回调函数的方式来获取读取文件的结果,线程不会被阻塞。 同步阻塞的HTTP服务器:

const http = require('http');

const server = http.createServer((req, res) => {
  fs.readFile('test.html', 'utf8', (err, data) => {
    if (err) throw err;
    res.end(data);
  });
});

server.listen(3000, () => {
  console.log('Server listening on port 3000');
});

上述代码是一个同步阻塞的HTTP服务器,每次请求都会阻塞线程,直到读取文件完成并返回结果。

异步非阻塞>

异步非阻塞 #

异步非阻塞的HTTP服务器:

const http = require('http');

const server = http.createServer((req, res) => {
  fs.readFile('test.html', 'utf8', (err, data) => {
    if (err) throw err;
    res.end(data);
  });
});

server.listen(3000, () => {
  console.log('Server listening on port 3000');
});

上述代码是一个异步非阻塞的HTTP服务器,每次请求不会阻塞线程,而是通过回调函数来获取文件读取结果,并返回给客户端。

总结>

总结 #

同步 同步指的是代码按照顺序执行,一些操作可能会阻塞主线程,但是主线程必须等待执行完成才可以继续往下执行
异步 异步的代码可以不按照书写的顺序执行,当遇到耗时的代码执行时,我们可以将任务交给其他线程执行,这个时候主线程就处于空闲状态,系统会通过轮询等方式在耗时操作完成之后再通知主线程读取结果
阻塞 调用者调用某个操作时被阻塞,会一直等待操作的返回结果,期间无法做其他事情
非阻塞 调用者调用某个操作时不会被阻塞,会直接返回,此时可以执行其他操作
FAQ>

FAQ #

  1. 异步与非阻塞

异步与非阻塞是两个概念。 非阻塞是由操作系统来支持的,在非阻塞模式下,应用程序向操作系统发起一个请求,如果数据还没有准备好,操作系统不会让应用程序一直等待,而是立即返回一个错误码,应用程序继续执行其他任务。操作系统在后台继续处理请求,并在数据准备好后通知应用程序。这种方式下,应用程序并不会被阻塞,而是在等待数据的同时可以继续执行其他任务,提高了程序的并发性能。

✨底层实现非阻塞I/O的方式之一是通过轮询(Polling)的方式。具体来说,它是通过调用操作系统提供的非阻塞I/O函数(如fcntl)将文件描述符设置为非阻塞模式,然后使用轮询(例如select、poll、epoll等系统调用)来查询I/O操作的状态,以判断是否有数据可读或可写,从而进行相应的操作。 当文件描述符被设置为非阻塞模式时,I/O操作将不会阻塞当前线程,而是立即返回I/O操作的状态,无论操作是否完成。这样,当前线程可以继续执行其他操作,而不必等待I/O操作完成。而通过轮询I/O操作状态,系统可以及时通知应用程序I/O操作已经就绪,使得应用程序可以在最短时间内处理已经准备好的数据。 虽然轮询的方式可以实现非阻塞I/O,但是由于需要不断地轮询I/O操作的状态,会占用大量的CPU资源,降低系统的性能。因此,操作系统提供了更为高效的I/O多路复用技术,如epoll、kqueue等,以提高I/O操作的效率。

异步操作的实现方式,不完全依赖于操作系统,而是依赖于编程语言本身的异步编程模型和相关库的支持。 在 Node.js 中,异步操作是通过使用事件循环和回调函数来实现的。当 Node.js 执行异步操作时,它将请求传递给底层操作系统(交给其他线程),并在等待结果时继续执行后续代码。当操作系统完成请求并返回结果时,Node.js 会触发一个事件,通过事件循环将结果传递给相应的回调函数。

  1. 为什么异步发起网络响应时间过长,主线程也会被阻塞?

在 Node.js 中,异步 I/O 操作通常是由 libuv 库进行调度和处理的。当我们发起一个异步操作时,例如向网络发起请求,Node.js 会将请求委托给 libuv,然后立即返回并继续执行下一条语句。当 libuv 完成请求并返回结果时,Node.js 将其放入一个任务队列中等待执行。 由于 JavaScript 的单线程特性,当主线程执行到需要等待异步操作结果的语句时,会停下来等待任务队列中的结果,直到结果返回才会继续执行下一条语句。这就意味着,如果异步操作的响应时间过长,主线程就会一直等待,造成阻塞。