同步、异步、阻塞、非阻塞
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 #
- 异步与非阻塞
异步与非阻塞是两个概念。 非阻塞是由操作系统来支持的,在非阻塞模式下,应用程序向操作系统发起一个请求,如果数据还没有准备好,操作系统不会让应用程序一直等待,而是立即返回一个错误码,应用程序继续执行其他任务。操作系统在后台继续处理请求,并在数据准备好后通知应用程序。这种方式下,应用程序并不会被阻塞,而是在等待数据的同时可以继续执行其他任务,提高了程序的并发性能。
✨底层实现非阻塞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 会触发一个事件,通过事件循环将结果传递给相应的回调函数。
- 为什么异步发起网络响应时间过长,主线程也会被阻塞?
在 Node.js 中,异步 I/O 操作通常是由 libuv 库进行调度和处理的。当我们发起一个异步操作时,例如向网络发起请求,Node.js 会将请求委托给 libuv,然后立即返回并继续执行下一条语句。当 libuv 完成请求并返回结果时,Node.js 将其放入一个任务队列中等待执行。 由于 JavaScript 的单线程特性,当主线程执行到需要等待异步操作结果的语句时,会停下来等待任务队列中的结果,直到结果返回才会继续执行下一条语句。这就意味着,如果异步操作的响应时间过长,主线程就会一直等待,造成阻塞。