NodeJS是基于chrome浏览器的V8
引擎构建的,也就说明它的模型与浏览器是类似的。我们的javascript会运行在单个进程的单个线程上。这样有一个好处:
状态单一
没有锁
不需要线程间同步
减少系统上下文的切换
有效提高单核CPU的使用率
但是V8引擎的单进程单线程并不是完美的结构,现如今CPU基本上都是多核的。真正的服务器往往有好几个CPU(像我们的线上物理机有12个核),所以,这就将抛出NodeJS实际应用中的第一个问题:“如何充分利用多核CPU服务器?”
另外,由于Node执行在单线程上,一旦单线程出现未捕获的异常,就会造成这个进程crash。所以就遇到了第二个问题:“如何保证进程的健壮性和稳定性?”
从严格意义上来讲,Node其实并不是真正的单线程架构,因为Node自身还有I/O
线程存在(网络I/O、磁盘I/O),这些I/O线程是由更底层的libuv
处理,这部分线程对于JavaScript开发者来说是透明的。JavaScript代码永远运行在V8上,是单线程的。所以表面上来看NodeJS是单线程的。
服务器进程模型的进化
1、同步单进程服务器
这类服务器是最早出现的,其执行模型是同步的(基于read或select I/O模型
),它的服务模式是一次只能处理一个请求,其他的请求都需要按照顺序依次等待接受处理。这就意味着除了当前的请求被处理之外,剩下的请求都是处于阻塞等待的状态。
所以,它的处理能力特别的低下。
假如服务器每次响应请求处理的时间为N秒,那么这类服务器的QPS为1/N
。
2、同步多进程服务器
为了解决上面的同步单进程服务器无法处理的并发问题,这类服务器通过进程的复制
同时服务更多的请求和用户。一个请求需要一个进程来服务,也就是100个请求就需要100个进程来进行服务,这需要很大的代价。因为在进程的复制总会复制进程内部的状态,对于每个连接都进行这样的复制的话,相同的状态会在内存中存在很多份,造成浪费。同时这个过程会因为复制很多个进程影响进行的启动时间。而且服务器的进程数量也是有上限的。
所以,这个模型并没有实质上解决并发问题。
假如这类服务器的进程数上限为M,每个请求处理的时间为N秒,那么这类服务器的QPS为M*1/N
。
3、同步多进程多线程服务器
为了解决进程复制中的资源浪费问题,多线程被引入了服务模型
,从一个进程处理一个请求改为一个线程处理一个请求。线程相对于进程的开销要小许多,而且线程之间可以共享数据。此外可以利用线程池来减少创建和销毁线程的开销。但是多线程所面临的并发问题只能说比多进程好点而已,因为每个线程需要一定内存来存放自己的堆栈。另外一个CPU核心只能处理一件事,系统是通过将CPU切分为时间片的方法来让线程可以均匀地使用CPU资源,在系统切换线程的过程中也会进行线程的上下文切换(切换为当前线程的堆栈),当线程数量过多时进行上下文切换会非常耗费时间。
所以在大的并发量下,多线程结构还是无法做到强大的伸缩性。大名鼎鼎的Apache
服务器就是采用了这样的架构,所以出现了著名的C10K
问题。
我们忽略系统进行线程的上下文切换的开销,假如这类服务器可以创建M个进程,一个进程可以使用L个线程,每个请求处理的时间为N秒,那么它的QPS为M*L/N
。
4、单进程单线程下基于事件驱动的服务器
为了解决C10K以及解决更高并发的问题,基于epoll(效率最高的I/O事件通知机制)
的事件驱动模型出现了。采用单线程避免了不必要的内存开销和上下文切换开销。
不过这种基于事件的服务器模型存在的文章刚开始提出两个问题:“CPU的利用率和健壮性”。
另外,所有的请求处理都在单线程上进行,影响事件驱动服务模型性能的只有CPU的计算能力,它的上限决定了这类服务器的性能上限,但它不受多进程多线程模式中资源上限的影响,可伸缩性比前两者都高。如果可以解决多核CPU的利用问题,那么带来的性能提升是非常高的。
NodeJS的多进程架构
面对单进程单线程对多核使用率不高的问题,按照之前的经验,每个进程各使用一个CPU即可,以此实现多核CPU的利用。Node提供了child_process
模块,并且也提供了fork()
方法来实现进程的复制(只要是进程复制,都需要一定的资源和时间。Node复制进程需要不小于10M的内存和不小于30ms的时间
)。
这样的解决方案就是*nix
系统上最经典的Master-Worker模式
,又称为主从模式
。这种典型并行处理业务模式的分布式架构具备较好的可伸缩性(可伸缩性实际上是和并行算法以及并行计算机体系结构放在一起讨论的。某个算法在某个机器上的可扩放性反映该算法是否能有效利用不断增加的CPU。
)和稳定性。主进程不负责具体的业务处理,而是负责调度和管理工作进程,工作进程负责具体的业务处理,所以,工作进程的稳定性是开发人员需要关注的。
通过fork()复制的进程都是一个独立
的进程,这个进程中有着独立而全新的V8实例。虽然Node提供了fork()
用来复制进程使每个CPU内核都使用上,但是依然要记住fork()
进程代价是很大的。好在Node通过事件驱动在单个线程上可以处理大并发的请求。
注意:这里启动多个进程只是为了充分将CPU资源利用起来,而不是为了解决并发问题。
Node创建子进程的4种方式
1、spawn()
创建一个子进程来执行命令
2、exec()
创建一个子进程来执行命令,和spawn()不同的是方法参数不同,它可以传入回调函数来获取子进程的状态
3、execFile()
启动一个子进程来执行指定文件。注意,该文件的顶部必须声明SHEBANG符号(#!)用来指定进程类型。
4、fork()
和spawn()类似,不同点在于它创建Node的子进程只需要执定要执行的JavaScript文件模块即可。
注意:后面的3种方法都是spawn()的延伸应用。
Node进程间通信
在Master-Worker
模式中,要实现主进程管理和调度工作进程的功能,需要主进程和工作进程之间的通信。它们通过消息来传递内容,而不是共享文件或直接操作相关资源,这是比较轻量和无依赖的做法。
通过fork()或者其他的API创建子进程之后,为了实现父子进程之间的通信,父进程与子进程之间将会创建IPC通道。通过IPC通道,父子进程之间才可以传递消息。
IPC(进程间通信)原理
IPC全称 Inter-Process Communication
,也就是进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作。Node中的IPC创建和实现过程如下:
父进程在实际创建子进程之前,会先创建IPC通道并监听它,然后才真正创建出子进程。子进程在启动的过程中会去链接这个已存在的IPC通道,从而完成了父子进程之间的连接。
句柄传递
创建好进程之间的IPC之后,如果仅仅只用来发送一些简单数据,显然不够我们的实际使用。
理想情况下,不管服务启动了多少个进程都应该经过同一个Master进程来进行控制和调度。所以所有请求都应该先经过同一个端口,然后通过Master进程交由具体的Worker进程处理。
代理模式
让每个进程监听不同的端口,其中主进程监听主端口(80端口),主进程对外接受所有的网络请求,再将这些请求代理到不同的端口进程上。
通过代理,可以避免端口不能重复监听的问题,也可以在代理进程做适当的负载均衡,这样每个子进程都可以均衡的处理服务。
由于进程每接受到一个连接,将会用到一个文件描述符,因此代理模式连接工作进程的过程需要用到两个文件描述符。操作系统的文件描述符是有限的。所以这种方案影响了系统的扩展能力
句柄共享模式
Nodejs提供了进程间发送句柄的功能。有了这个功能我们可以不使用代理模式
方案,使主进程接受到socket请求之后,将这个socket对象直接转发给工作进程,而不是重新遇工作进程之间创建新的socket连接来转发数据。这样的话,文件描述符浪费的问题可以轻轻松解决。
在程序设计中,句柄(handle)是一种特殊的智能指针。当一个应用程序要引用其他系统(如数据库、操作系统)所管理的内存块或对象时,就要使用句柄。
这样所有的请求都是由子进程处理了。整个过程中,服务的过程发生了一次改变如下图:
主进程发送完句柄并关闭监听之后,就成了下图的机构。
多个应用监听相同的端口时,文件描述符同一时间只能被某个进程所用,也就是网络请求发送的服务器端时,只有一个幸运的进程能够抢到连接,只有它能为这个请求进行服务。所以这些进程服务是抢占式
的。
至此,以此介绍了创建子进程、进程间通信的IPC通道实现、句柄在进程间的发送和使用原理、端口共用等细节。通过这些基础技术,在多核的CPU服务器上,让Node进程能够充分利用资源不是难题。
Node服务稳定性
搭建好了集群,充分利用了多核CPU的资源,但是在迎接大量的客户端请求之前,还有很多稳定性的问题亟待解决。
工作进程存活状态管理
工作进程平滑重启
工作进程限量重启
工作进程性能问题
工作进程负载均衡
工作进程状态共享
这些问题下次再写。。。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。