玩转进程
JavaScript运行在单个进程的单个线程上。它带来的好处是:程序状态是单一的,在没有多线程的情况下没有锁、线程同步问题,操作系统在调度时也因为较小上下文的切换。可以很好地提高CPU的使用率。
如何充分利用多核CPU服务器?
如何保证进程的健壮性和稳定性?
1. 服务模型的变迁
从“古”至今,Web服务器的架构以及经历了几次的变迁。服务器处理客户端请求的并发量,就是每个里程碑的见证。
1.1 石器时代:同步
最早的服务器,其执行模型是同步的,它的服务模式是一次只为一个请求服务,所有请求都得按次序等待服务。
1.2 青铜时代:复制进程
为了解决同步架构的并发问题,一个简单的改进是通过进程的复制同时服务更多的请求和用户。
为了解决启动缓慢的问题,预复制(prefork)被引入服务模型中,即预先复制一定数量的进程。
1.3 白银时代: 多线程
为了解决进程复制中的浪费问题,多线程被引入服务模型,让一个线程服务一个请求。
线程相对进程的开销要小许多,并且线程之间可以共享数据,内存浪费的问题可以得到解决,并且利用线程池可以减少创建和销毁线程的开销。
但是多线程所面临的并发问题只能说比多进程略好,因为每个线程都拥有自己独立的堆栈,这个堆栈都需要占用一定的内存空间。
另外。由于一个CPU核心在一个时刻只能做一件事情,操作系统只能通过CPU切分为时间片的方法,让线程可以较为均匀地使用CPU资源,但是操作系统内核在切换线程的同时也要切换线程的上下文,当线程数量过多时,时间将会被耗用在上下文切换中。
1.4 黄金时代:事件驱动
为了解决高并发问题,基于事件驱动的服务模型出现了,像Node与Nginx均是基于事件驱动的方式实现的,采用单线程避免了不必要的内存开销和上下文切换开销。
由于所有处理都在单线程上进行,影响事件驱动服务模型性能的点在于CPU的计算能力,它的上限决定这类服务模型的性能上限,但它不受多进程或多进程模式中资源上限的影响,可伸缩性远比前两者高。
2. 多进程架构
Master-Worker模式,又称为主从模式,其中进程分为两种:主进程和工作进程。
这是典型的分布式架构中用于并行处理业务的模式,具备较好的可伸缩性和稳定性。
主进程不负责具体的业务处理,而是负责调度或管理工作进程,趋于稳定。
工作进程负责具体的业务处理,因为业务的多种多样,甚至一项业务由多人开发完成,所以进程的稳定性值得开发者关注。
2.1创建子进程
child_process模块给予Node可以随意创建子进程(child_process)的能力。
它提供了4个方法用于创建子进程:
- spawn():启动一个子进程来执行命令;
- exec():启动一个子进程来执行命令,与spawn()不同的是其接口不同,它有一个回调函数获知子进程的状况;
- execFile():启动一个子进程来执行可执行文件;
- fork():与spawn()类似,不同点在于它创建的子进程只需指定执行的JavaScript文件模块即可;
2.2 进程间通信
在Master-worker模式中,要实现主进程管理和调度工作进程的功能,需要主进程和工作进程之间的通信。对于child_process模块,创建好了子进程,然后与父子进程间通信是十分容易的。
在前端浏览器中,JavaScript与主进程与UI渲染共用一个线程。执行JavaScript的时候UI渲染是停滞的,渲染UI时,JavaScript是停滞的,两者相互阻塞。长时间执行JavaScript将会造成UI停顿不响应。
为了解决这个问题HTML5提出了WebWorker API。WebWorker允许创建工作线程并在后台运行,使得一些较为严重的计算不影响主线程上的UI渲染。
主线程与工作线程之前通过onmessage()和postMessage()进行通信,子进程对象由send()方法实现主进程向子进程发送数据,message事件实现收听子进程发来的数据,与API在一定程度上相似。
通过fork()或者其他API,创建子进程之后,为了实现父子进程之间的通信,父进程与子进程之间会创建IPC通道。通过IPC通道,父子进程之间才能通过message()和send()传递消息。
- 进程间通信原理
IPC的全称是Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够相互访问资源并进行协调工作。
实现进程间的技术有很多,如命名管道、匿名管道、socket、信号量、共享内存、消息队列、Domain Socket等。
Node中实现IPC通道的是管道(pipe)技术。
IPC通道是用命名管道或Domain Socket创建的,它们与网络socket的行为比较类似,属于双向通信。
2.3 句柄传递
通过代理,可以避免端口不能重复监听的问题,甚至可以在代理进程上做适当的负载均衡,使得每个子进程可以较为均衡地执行任务。
由于进程每接收到一个连接,将会用掉一个文件描述符,因此代理方案中客户端连接到代理进程,代理进程连接到工作进程的过程需要用掉两个文件描述符。
操作系统的文件描述符是有限的,代理方案浪费掉一倍数量的文件描述符的做法影响了系统的扩展能力。为了解决这样的问题,Node在版本0.5.9引入了进程间发送句柄的功能。
send()方法除了能通过IPC发送数据外,还能发送句柄,带二个可选参数就是句柄。
child.send(message,[sendHandle])
句柄:是一个可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。
句柄可以用来标识一个服务器socket对象、一个客户端socket对象。一个UDP套接字、一个人管道等。
3. 集群稳定之路
搭建好了集群,充分利用了多核CPU资源,似乎就可以迎接客户端大量的请求。但需要考虑的细节:
- 性能问题
- 多个工作进程的存活状态管理
- 工作进程的平滑重启
- 配置或者静态数据的动态重新载入
- 其他细节
3.1 进程事件
Node的进程事件
- error
- exit
- close
- disconnect
3.2 自动重启
一旦有未捕获的异常出现,工作进程就会立即停止接收新的连接。当所有连接断开后,退出进程。主进程在侦听到工作进程的exit后,将会立即启动新的进程服务,以保证整个集群总是有进程为用户服务的。
- 自杀信号
自杀(suicide)信号:工作进程早得知要退出时,向主进程发送一个自杀信号,然后才停止接收新的连接,当所有连接断开后才退出。主进程在接收到自杀信号后,立即创建新的工作进程服务。
- 限量重启
为了消除这种无意义的重启,在满足一定规划的限制下,不应当反复重启。
3.3 负载均衡
在多进程之间监听相同的端口,使得用户请求能够分散到多个进程上进行处理,可以将CPU资源都调用起来。保证多个处理工作单元量公平的策略叫负载均衡。
Node默认提供的机制是采用操作系统的强占式策略,所谓强占式策略就是在一堆进程中,闲着的进程对到来的请求进行争抢,谁抢到谁服务。
一般而言,这种抢占式策略是公平的,各个进程可以根据自己的繁忙度来进行抢占。但对于Node而言,需要分清它的繁忙是由CPU、I/O两个部分构成的,影响抢占的是CPU的繁忙度。对于不同的业务,可能存在I/O繁忙,而CPU较为空闲的情况,这可能造成某个进程能够抢到较多请求,形成负载不均衡的情况。
Node提供的轮叫调度(Round-Robin):由主进程接收连接,将其依次分发给工作进程。
Round-Robin可以避免CPU和I/O繁忙差异导致的负载不均衡。Round-Robin策略也可以通过代理服务器来实现,但是它会导致服务器上消耗的文件描述符是平常方式的两倍。
3.4 状态共享
在不允许共享数据的情况下,来实现多个进程之前的共享。
-
第三方数据存储
将数据存放到数据库、磁盘文件、缓存服务(如Redis)中,所有工作进程启动时将其读取进内存中。
缺点:如果数据发生改变,还需要一种机制通知到各个子进程,使得它们的内部状态也得到更新。
状态同步的机制;
- 各个子进程去向第三方进行定时轮询
- 当数据发生更新时,主动通知子进程
- 主动通知
当数据发生更新时,主动通知子进程。
通知进程:用来发送通知和查询状态是否更改的进程。
4. Cluster模块
Cluster模块:用来解决多核CPU的利用率问题,同时也提供较完整的API,用以处理进程的健壮性问题。
4.1 Clister工作原理
Cluster模块就是child_process和net模块的组合应用。
4.2 Cluster事件
Cluster事件:
- fork
- online
- listening
- disconnect
- exit
- setup