写点什么

说说 Nodejs 高并发的原理

作者:coder2028
  • 2022-11-10
    浙江
  • 本文字数:3504 字

    阅读完需:约 11 分钟

写在前面

我们先来看几个常见的说法


  • nodejs 是单线程 + 非阻塞 I/O 模型

  • nodejs 适合高并发

  • nodejs 适合 I/O 密集型应用,不适合 CPU 密集型应用


在具体分析这几个说法是不是、为什么之前,我们先来做一些准备工作

从头聊起

一个常见 web 应用会做哪些事情

  • 运算(执行业务逻辑、数学运算、函数调用等。主要工作在 CPU 进行)

  • I/O(如读写文件、读写数据库、读写网络请求等。主要工作在各种 I/O 设备,如磁盘、网卡等)

一个典型的传统 web 应用实现

  • 多进程,一个请求 fork 一个(子)进程 + 阻塞 I/O(即 blocking I/O 或 BIO)

  • 多线程,一个请求创建一个线程 + 阻塞 I/O




多进程 web 应用示例伪代码


listenFd = new Socket(); // 创建监听socketBind(listenFd, 80); // 绑定端口Listen(listenFd);   // 开始监听
for ( ; ; ) { // 接收客户端请求,通过新的socket建立连接 connFd = Accept(listenFd); // fork子进程 if ((pid = Fork()) === 0) { // 子进程中 // BIO读取网络请求数据,阻塞,发生进程调度 request = connFd.read(); // BIO读取本地文件,阻塞,发生进程调度 content = ReadFile('test.txt'); // 将文件内容写入响应 Response.write(content); }}
复制代码


多线程应用实际上和多进程类似,只不过将一个请求分配一个进程换成了一个请求分配一个线程。线程对比进程更轻量,在系统资源占用上更少,上下文切换(ps:所谓上下文切换,稍微解释一下:单核心 CPU 的情况下同一时间只能执行一个进程或线程中的任务,而为了宏观上的并行,则需要在多个进程或线程之间按时间片来回切换以保证各进、线程都有机会被执行)的开销也更小;同时线程间更容易共享内存,便于开发


上文中提到了 web 应用的两个核心要点,一个是进(线)程模型,一个是 I/O 模型。那阻塞 I/O 到底是什么?又有哪些其他的 I/O 模型呢?别着急,首先我们看一下什么是阻塞

什么是阻塞?什么是阻塞 I/O?

简而言之,阻塞是指函数调用返回之前,当前进(线)程会被挂起,进入等待状态,在这个状态下,当前进(线)程暂停运行,引起 CPU 的进(线)程调度。函数只有在内部工作全部执行完成后才会返回给调用者


所以阻塞 I/O 是,应用程序通过 API 调用 I/O 操作后,当前进(线)程将会进入等待状态,代码无法继续往下执行,这时 CPU 可以进行进(线)程调度,即切换到其他可执行的进(线)程继续执行,当前进(线)程在底层 I/O 请求处理完后才会返回并可以继续执行

多进(线)程 + 阻塞 I/O 模型有什么问题?

在了解了什么是阻塞和阻塞 I/O 后,我们来分析一下传统 web 应用多进(线)程 + 阻塞 I/O 模型有什么弊端。


因为一个请求需要分配一个进(线)程,这样的系统在并发量大时需要维护大量进(线)程,且需要进行大量的上下文切换,这都需要大量的 CPU、内存等系统资源支撑,所以在高并发请求进来时 CPU 和内存开销会急剧上升,可能会迅速拖垮整个系统导致服务不可用

nodejs 应用实现

接下来我们看看 nodejs 应用是如何实现的。


  • 事件驱动,单线程(主线程)

  • 非阻塞 I/O 在官网上可以看到,nodejs 最主要的两大特点,一个是单线程事件驱动,一个是“非阻塞”I/O 模型。单线程 + 事件驱动比较好理解,前端同学应该都很熟悉 js 的单线程和事件循环这套机制了,那我们主要来研究一下这个“非阻塞 I/O”是怎么一回事。首先来看一段 nodejs 服务端应用常见的代码,


const net = require('net');const server = net.createServer();const fs = require('fs');
server.listen(80); // 监听端口// 监听事件建立连接server.on('connection', (socket) => { // 监听事件读取请求数据 socket.on('data', (data) => { // 异步读取本地文件 fs.readFile('test.txt', (err, data) => { // 将读取的内容写入响应 socket.write(data); socket.end(); }) });});
复制代码


可以看到在 nodejs 中,我们可以以异步的方式去进行 I/O 操作,通过 API 调用 I/O 操作后会马上返回,紧接着就可以继续执行其他代码逻辑,那为什么 nodejs 中的 I/O 是“非阻塞”的呢?回答这个问题之前我们再做一些准备工作,

read 操作基本步骤

首先看下一个 read 操作需要经历哪些步骤


  • 用户程序调用 I/O 操作 API,内部发出系统调用,进程从用户态转到内核态

  • 系统发出 I/O 请求,等待数据准备好(如网络 I/O,等待数据从网络中到达 socket;等待系统从磁盘上读取数据等)

  • 数据准备好后,复制到内核缓冲区

  • 从内核空间复制到用户空间,用户程序拿到数据


接下来我们看一下操作系统中有哪些 I/O 模型


参考 nodejs 进阶视频讲解:进入学习

几种 I/O 模型

阻塞式 I/O





非阻塞式 I/O





I/O 多路复用(进程可同时监听多个 I/O 设备就绪)





信号驱动 I/O





异步 I/O





那么 nodejs 里到底使用了哪种 I/O 模型呢?是上图中的“非阻塞 I/O”吗?别着急,先接着往下看,我们来了解下 nodejs 的体系结构

nodejs 体系结构,线程、I/O 模型分析


最上面一层是就是我们编写 nodejs 应用代码时可以使用的 API 库,下面一层则是用来打通 nodejs 和它所依赖的底层库的一个中间层,比如实现让 js 代码可以调用底层的 c 代码库。来到最下面一层,可以看到前端同学熟悉的 V8,还有其他一些底层依赖。注意,这里有一个叫 libuv 的库,它是干什么的呢?从图中也能看出,libuv 帮助 nodejs 实现了底层的线程池、异步 I/O 等功能。libuv 实际上是一个跨平台的 c 语言库,它在 windows、linux 等不同平台下会调用不同的实现。我这里主要分析 linux 下 libuv 的实现,因为我们的应用大部分时候还是运行在 linux 环境下的,且平台间的差异性并不会影响我们对 nodejs 原理的分析和理解。好了,对于 nodejs 在 linux 下的 I/O 模型来说,libuv 实际上提供了两种不同场景下的不同实现,处理网络 I/O 主要由 epoll 函数实现(其实就是 I/O 多路复用,在前面的图中使用的是 select 函数来实现 I/O 多路复用,而 epoll 可以理解为 select 函数的升级版,这个暂时不做具体分析),而处理文件 I/O 则由多线程(线程池) + 阻塞 I/O 模拟异步 I/O 实现




下面是一段我写的 nodejs 底层实现的伪代码帮助大家理解


listenFd = new Socket();    // 创建监听socketBind(listenFd, 80); // 绑定端口Listen(listenFd);   // 开始监听
for ( ; ; ) { // 阻塞在epoll函数上,等待网络数据准备好 // epoll可同时监听listenFd以及多个客户端连接上是否有数据准备就绪 // clients表示当前所有客户端连接,curFd表示epoll函数最终拿到的一个就绪的连接 curFd = Epoll(listenFd, clients);
if (curFd === listenFd) { // 监听套接字收到新的客户端连接,创建套接字 int connFd = Accept(listenFd); // 将新建的连接添加到epoll监听的list clients.push(connFd); }
else { // 某个客户端连接数据就绪,读取请求数据 request = curFd.read(); // 这里拿到请求数据后可以发出data事件进入nodejs的事件循环 ... }}
// 读取本地文件时,libuv用多线程(线程池) + BIO模拟异步I/OThreadPool.run((callback) => { // 在线程里用BIO读取文件 String content = Read('text.txt'); // 发出事件调用nodejs提供的callback});
复制代码


通过 I/O 多路复用 + 多线程模拟的异步 I/O 配合事件循环机制,nodejs 就实现了单线程处理并发请求并且不会阻塞。所以回到之前所说的“非阻塞 I/O”模型,实际上 nodejs 并没有直接使用通常定义上的非阻塞 I/O 模型,而是 I/O 多路复用模型 + 多线程 BIO。我认为“非阻塞 I/O”其实更多是对 nodejs 编程人员来说的一种描述,从编码方式和代码执行顺序上来讲,nodejs 的 I/O 调用的确是“非阻塞”的

总结

至此我们应该可以了解到,nodejs 的 I/O 模型其实主要是由 I/O 多路复用和多线程下的阻塞 I/O 两种方式一起组成的,而应对高并发请求时发挥作用的主要就是 I/O 多路复用。好了,那最后我们来总结一下 nodejs 线程模型和 I/O 模型对比传统 web 应用多进(线)程 + 阻塞 I/O 模型的优势和劣势


  • nodejs 利用单线程模型省去了系统维护和切换多进(线)程的开销,同时多路复用的 I/O 模型可以让 nodejs 的单线程不会阻塞在某一个连接上。在高并发场景下,nodejs 应用只需要创建和管理多个客户端连接对应的 socket 描述符而不需要创建对应的进程或线程,系统开销上大大减少,所以能同时处理更多的客户端连接

  • nodejs 并不能提升底层真正 I/O 操作的效率。如果底层 I/O 成为系统的性能瓶颈,nodejs 依然无法解决,即 nodejs 可以接收高并发请求,但如果需要处理大量慢 I/O 操作(比如读写磁盘),仍可能造成系统资源过载。所以高并发并不能简单的通过单线程 + 非阻塞 I/O 模型来解决

  • CPU 密集型应用可能会让 nodejs 的单线程模型成为性能瓶颈

  • nodejs 适合高并发处理少量业务逻辑或快 I/O(比如读写内存)


用户头像

coder2028

关注

还未添加个人签名 2022-09-08 加入

还未添加个人简介

评论

发布
暂无评论
说说Nodejs高并发的原理_node.js_coder2028_InfoQ写作社区