关于node.js:说说Nodejs高并发的原理

2次阅读

共计 3987 个字符,预计需要花费 10 分钟才能阅读完成。

写在后面

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

  • 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(); // 创立监听 socket
Bind(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 是“非阻塞”的呢?答复这个问题之前咱们再做一些筹备工作,参考 nodejs 进阶视频解说:进入学习

read 操作根本步骤

首先看下一个 read 操作须要经验哪些步骤

  • 用户程序调用 I / O 操作 API,外部收回零碎调用,过程从用户态转到内核态
  • 零碎收回 I / O 申请,期待数据筹备好(如网络 I /O,期待数据从网络中达到 socket;期待零碎从磁盘上读取数据等)
  • 数据筹备好后,复制到内核缓冲区
  • 从内核空间复制到用户空间,用户程序拿到数据

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

几种 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();    // 创立监听 socket
Bind(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 /O
ThreadPool.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(比方读写内存)
正文完
 0