说说 Nodejs 高并发的原理
写在前面
我们先来看几个常见的说法
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 应用示例伪代码
多线程应用实际上和多进程类似,只不过将一个请求分配一个进程换成了一个请求分配一个线程。线程对比进程更轻量,在系统资源占用上更少,上下文切换(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 服务端应用常见的代码,
可以看到在 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 底层实现的伪代码帮助大家理解
通过 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(比如读写内存)
评论