当我们谈论cluster时我们在谈论什么

简介:

Node.js诞生之初就遭到不少这样的吐槽,当然这些都早已不是问题了。

1、可靠性低。
2、单进程,单线程,只支持单核CPU,不能充分的利用多核CPU服务器。一旦这个进程崩掉,那么整个web服务就崩掉了。

Node.js被这样吐槽与它最初的设计单线程模型密不可分,不像php每个request都在单独的线程中处理,即使某一个请求发生很严重的错误也不会影响到其它请求。但由于Node.js是单线程,如果处理某个请求时产生一个没有被捕获到的异常将导致整个进程的退出,已经接收到的其它连接全部都无法处理,对一个web服务器来说,这是致命的灾难。

当应用部署到多核服务器时,为了充分利用多核CPU资源一般启动多个Node.js进程提供服务,这时就会使用到Node.js内置的cluster模块了。相信大多数Node.js的开发者可能都没有直接使用到cluster,cluster模块对child_process模块提供了一层封装,可以说是为了发挥服务器多核优势而量身定做的。简单的一个fork,不需要开发者修改任何的应用代码便能够实现多进程部署,当下最热门的带有负载均衡功能的Node应用的进程管理器pm2便是最好的一个例子,开发的时候完全不需要关注多进程场景,剩余的一切都交给pm2处理,与开发者的应用代码完美的分离。

pm2确实非常强大,但本文不讲解pm2的工作原理,而是从更底层的进程通信讲起,为大家揭秘使用Node.js开发web应用时,使用cluster模块实现多进程部署的原理。

fork

fork是cluster模块中非常重要的一个方法,多个子进程便是通过在master进程中不断的调用cluster.fork方法构造出来。下面的结构图大家应该非常熟悉了。

多进程服务器模型
上面的图非常的粗糙, 并没有体现出master与worker到底是如何分工协作的。Node.js在这块做过比较大的改动,下面就细细的剖析开来。

惊群

继续讲解之前先解释下什么是惊群现象,和多进程有啥关系。

举一个很简单的例子,当你往一群鸽子中间扔一块食物,虽然最终只有一个鸽子抢到食物,但所有鸽子都会被惊动来争夺,没有抢到食物的鸽子只好回去继续睡觉,等待下一块食物到来。这样,每扔一块食物,都会惊动所有的鸽子,即为惊群。对于操作系统来说,多个进程/线程在等待同一资源是,也会产生类似的效果,其结果就是每当资源可用,所有的进程/线程都来竞争资源,造成的后果:
1)系统对用户进程/线程频繁的做无效的调度、上下文切换,系统系能大打折扣。
2)为了确保只有一个线程得到资源,用户必须对资源操作进行加锁保护,进一步加大了系统开销。

Node.js最初的多进程模型就是这样的,master进程创建socket,绑定到某个地址以及端口后自身不调用listen来监听连接以及accept连接,而是将该socket的fd传递到fork出来的worker进程,worker接收到fd后再调用listen,accept新的连接。但实际一个新到来的连接最终只能被某一个worker进程accpet再做处理,至于是哪个worker能够accept到,开发者完全无法预知以及干预。这势必就导致了当一个新连接到来时,多个worker进程会产生竞争,最终由胜出的worker获取连接。

之所以可以这么做,最核心的一点是文件描述符可以在进程间进行传递,对底层感兴趣的同学可以看下我收集的一些技术文章《进程通信》

为了进一步加深对这种模型的理解,我编写了一个非常简单的demo。

master进程

const net = require('net');
const fork = require('child_process').fork;

var handle = net._createServerHandle('0.0.0.0', 3000);

for(var i=0;i<4;i++) {
   fork('./worker').send({}, handle);
}

worker进程

const net = require('net');
process.on('message', function(m, handle) {
  start(handle);
});

var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;

function start(server) {
    server.listen();
    server.onconnection = function(err,handle) {
        console.log('got a connection on worker, pid = %d', process.pid);
        var socket = new net.Socket({
            handle: handle
        });
        socket.readable = socket.writable = true;
        socket.end(res);
    }
}

保存后直接运行node master.js启动服务器,在另一个终端多次运行ab -n10000 -c100 http://127.0.0.1:3000/

各个worker进程统计到的请求数分别为

worker 63999  got 14561 connections
worker 64000  got 8329  connections
worker 64001  got 2356  connections
worker 64002  got 4885  connections

相信到这里大家也应该知道这种多进程模型比较明显的问题了

  • 多个进程之间会竞争accpet一个连接,产生惊群现象,效率比较低。
  • 由于无法控制一个新的连接由哪个进程来处理,必然导致各worker进程之间的负载非常不均衡。

round-robin

上面的多进程模型存在诸多问题,于是就出现了基于round-robin的另一种模型。主要思路是master进程创建socket,绑定好地址以及端口后再进行监听。该socket的fd不传递到各个worker进程,当master进程获取到新的连接时,再决定将accept到的客户端socket fd传递给指定的worker处理。我这里使用了指定, 所以如何传递以及传递给哪个worker完全是可控的,round-robin只是其中的某种算法而已,当然可以换成其他的。

同样基于这种模型我也写来一个简单的demo。

master进程

const net = require('net');
const fork = require('child_process').fork;

var workers = [];
for(var i=0;i<4;i++) {
   workers.push(fork('./worker'));
}

var handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function(err,handle) {
    var worker = workers.pop();
    worker.send({},handle);
    workers.unshift(worker);
}

woker进程

const net = require('net');
process.on('message', function(m, handle) {
  start(handle);
});

var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;

function start(handle) {
    console.log('got a connection on worker, pid = %d', process.pid);
    var socket = new net.Socket({
        handle: handle
    });
    socket.readable = socket.writable = true;
    socket.end(res);
}

这种模型由于只有master进程接收客户端连接,并且能够按照特定的算法进行分配, 很好的解决了上面提出的多进程竞争导致各进程负载不均衡的硬伤。

nginx proxy

利用nginx强大的反向代理功能,可以启动多个独立的Node.js进程,分别绑定不同的端口,最后由nginx接收请求然后进行分配。

http { 
  upstream cluster { 
      server 127.0.0.1:3000; 
      server 127.0.0.1:3001; 
      server 127.0.0.1:3002; 
      server 127.0.0.1:3003; 
  } 
  server { 
       listen 80; 
       server_name www.domain.com; 
       location / { 
            proxy_pass http://cluster;
       } 
  }
}

这种方式缺点也非常明显了,一般不会被使用到

  • node进程没有守护,稳定性得不到保障。
  • 进程不好管理,不方便扩容。

进程守护

文章开头提到Node.js被吐槽稳定性差,进程发生未捕获到的异常就会退出,所以很需要一个强大的守护神来守护着这些worker进程,某个worker进程一旦退出就fork出一个新的子进程顶替上去。

这一切cluster模块都已经帮我做好处理了,当某个worker进程发生异常退出或者与master进程失去联系(disconnect)时,master进程都会收到相应的事件通知。

cluster.on('exit', function(){
    clsuter.fork();
});

cluster.on('disconnect', function(){
    clsuter.fork();
});

推荐使用第三方模块recluster,已经处理的很成熟了。

这样一来整个应用的稳定性重任就落在master进程上了,所以一定不要给master太多其它的任务,百分百保证它的健壮性,一旦master进程挂掉你的应用也就玩完了。

ipc

讲了这么多,到最本质的地方了,要用多进程模型就一定会涉及到ipc(进程间通信)了。Node.js中ipc都是在父子进程之间进行,按有无发送fd分为2种方式。linux系统进程间通信有多种方式,详细的可参考这里https://github.com/hustxiaoc/node.js/issues/5

发送fd

当进程间需要发生文件描述符fd时,libuv底层采用消息队列来实现ipc。其实还比较复杂,c语言层面的就不在这说了,上面提到的文章中有详细介绍。

不发送fd

这种情况父子进程之间只是发送简单的字符串,并且它们之间的通信是双向的。虽然pipe能够满足父子进程间的消息传递,但由于pipe是半双工的,也就是说必须得创建2个pipe才可以实现双向的通信,这无疑使得程序逻辑更复杂。

libuv底层采用socketpair来实现全双工的进程通信,父进程fork子进程之前会调用socketpair创建2个fd,下面是一个最简单的也最原始的利用socketpair来实现父子进程间双向通信的demo

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
#include <sys/socket.h>
#include <stdlib.h>

#define BUF_SIZE 100

int main(){
    int s[2];
    int w,r;
    char * buf = (char*)calloc(1 , BUF_SIZE);
    pid_t pid;

    if( socketpair(AF_UNIX,SOCK_STREAM,0,s) == -1 ){
        printf("create unnamed socket pair failed:%s\n",strerror(errno) );
        exit(-1);
    }

    if( ( pid = fork() ) > 0 ){
        printf("Parent process's pid is %d\n",getpid());
        close(s[1]);
    char *messageToChild = "a message to child  process!";
        if( ( w = write(s[0] , messageToChild , strlen(messageToChild) ) ) == -1 ){
            printf("Write socket error:%s\n",strerror(errno));
            exit(-1);
        }
        sleep(1);
        if( (r = read(s[0], buf , BUF_SIZE )) == -1){
            printf("Pid %d read from socket error:%s\n",getpid() , strerror(errno) );
            exit(-1);
    }
        printf("Pid %d read string : %s \n",getpid(),buf);

    }else if(pid == 0){
        printf("Fork child process successed\n");
        printf("Child process's pid is :%d\n",getpid());
        close(s[0]);
    char *messageToParent = "a message to parent process!";
    if( ( w = write(s[1] , messageToParent , strlen(messageToParent) ) ) == -1 ){
            printf("Write socket error:%s\n",strerror(errno));
            exit(-1);
        }
       sleep(1);
       if( (r = read(s[1], buf , BUF_SIZE )) == -1){
            printf("Pid %d read from socket error:%s\n",getpid() , strerror(errno) );
            exit(-1);
       }    
       printf("Pid %d read string : %s \n",getpid(),buf);   
    }else{
        printf("Fork failed:%s\n",strerror(errno));
        exit(-1);
    }

    exit(0);
}

保存为socketpair.c后运行 gcc socketpair.c -o socket && ./socket 输出

Parent process's pid is 52853
Fork child process successed
Child process's pid is :52854
Pid 52854 read string : a message to child  process! 
Pid 52853 read string : a message to parent process!

Node.js中的ipc

上面从libuv底层方面讲解了父子进程间双向通信的原理,在上层Node.js中又是如何实现的呢,让我们来一探究竟。

Node.js中父进程调用fork产生子进程时,会事先构造一个pipe用于进程通信,new process.binding('pipe_wrap').Pipe(true), 构造出的pipe最初还是关闭的状态,或者说底层还并没有创建一个真实的pipe,直至调用到libuv底层的uv_spawn, 利用socketpair创建的全双工通信管道绑定到最初Node.js层创建的pipe上。

管道此时已经真实的存在了,父进程保留对一端的操作,通过环境变量将管道的另一端文件描述符fd传递到子进程。

options.envPairs.push('NODE_CHANNEL_FD=' + ipcFd);

子进程启动后通过环境变量拿到fd

var fd = parseInt(process.env.NODE_CHANNEL_FD, 10);

并将fd绑定到一个新构造的pipe上

var p = new Pipe(true);
p.open(fd);

于是父子进程间用于双向通信的所有基础设施都已经准备好了。说了这么多可能还是不太明白吧? 没关系,我们还是来写一个简单的demo感受下。

Node.js构造出的pipe被存储在进程的_channel属性上

master.js

const WriteWrap = process.binding('stream_wrap').WriteWrap;
var cp = require('child_process');

var worker = cp.fork(__dirname + '/worker.js');
var channel = worker._channel;

channel.onread = function(len, buf, handle){
    if(buf){
        console.log(buf.toString())
        channel.close()
    }else{
        channel.close()
        console.log('channel closed');
    }
}

var message = { hello: 'worker',  pid: process.pid }
var req = new WriteWrap();
var string = JSON.stringify(message) + '\n';
channel.writeUtf8String(req, string, null);

worker.js

const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;

channel.ref()
channel.onread = function(len, buf, handle){
    if(buf){
        console.log(buf.toString())
    }else{
        process._channel.close()
        console.log('channel closed');
    }
}

var message = { hello: 'master',  pid: process.pid } 
var req = new WriteWrap();
var string = JSON.stringify(message) + '\n';
channel.writeUtf8String(req, string, null);

运行node master.js 输出

{"hello":"worker","pid":58731}
{"hello":"master","pid":58732}
channel closed

what is disconnect

在多进程服务器中,为了保障整个web应用的稳定性,master进程需要监控worker进程的exit以及disconnect事件,收到相应事件通知后重启worker进程。

exit事件不用说,disconnect事件可能有的人就不太明白了。其实就是父子进程用于通信的channel关闭了,此时父子进程之间失去了联系,自然是无法传递客户端接收到的连接了。失去联系不表示会退出,worker进程有可能仍然在运行,但此时已经无力接收请求了。所以当master进程收到某个worker disconnect的事件时,先需要kill掉worker,然后再fork一个worker。

下面是一个触发disconnect事件的简单demo

master.js

const WriteWrap = process.binding('stream_wrap').WriteWrap;
const net = require('net');
const fork = require('child_process').fork;

var workers = [];
for(var i=0;i<4;i++) {
    var worker = fork(__dirname + '/worker.js');
    worker.on('disconnect', function() {
        console.log('[%s] worker %s is disconnected', process.pid, worker.pid);
    });
   workers.push(worker);
}

var handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function(err,handle) {
    var worker = workers.pop();
    var channel = worker._channel;
    var req = new WriteWrap();
    channel.writeUtf8String(req, 'dispatch handle', handle);
    workers.unshift(worker);
}

worker.js

const net = require('net');
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;
var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;

channel.ref() //防止进程退出
channel.onread = function(len, buf, handle){
    console.log('[%s] worker %s got a connection', process.pid, process.pid);
    var socket = new net.Socket({
        handle: handle
    });
    socket.readable = socket.writable = true;
    socket.end(res);
    console.log('[%s] worker %s is going to disconnect', process.pid, process.pid);
    channel.close();
}

运行node master.js启动服务器后,在另一个终端执行多次curl http://127.0.0.1:3000,下面是输出的内容

[63240] worker 63240 got a connection
[63240] worker 63240 is going to disconnect
[63236] worker 63240 is disconnected

最简单的round-robin server

还记得前面讲的round-robin多进程服务器模型吧,用于通信的channel除了可以发送简单的字符串数据外,还可以发送文件描述符,

channel.writeUtf8String(req, string, null);

最后一个参数便是要传递的fd。round-robin多进程服务器模型的核心也正式依赖于这个特性。 在上面的demo基础上,我们再稍微加工一下,还原在Node.js中最原始的处理。

master.js

const WriteWrap = process.binding('stream_wrap').WriteWrap;
const net = require('net');
const fork = require('child_process').fork;

var workers = [];
for(var i=0;i<4;i++) {
   workers.push(fork(__dirname + '/worker.js'));
}

var handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function(err,handle) {
    var worker = workers.pop();
    var channel = worker._channel;
    var req = new WriteWrap();
    channel.writeUtf8String(req, 'dispatch handle', handle);
    workers.unshift(worker);
}

worker.js

const net = require('net');
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;
var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;

channel.ref()
channel.onread = function(len, buf, handle){
    var socket = new net.Socket({
        handle: handle
    });
    socket.readable = socket.writable = true;
    socket.end(res);
}

运行 node master.js, 一个简单的多进程Node.js web服务器便跑起来了。

小结

到此整个Node.js的多进程服务器模型,以及底层进程间通信原理就讲完了。也为大家揭开了cluster的神秘面纱, 相信大家对cluster有了更深刻的认识,在Node.js的开发旅途上玩得更愉快!

参考文档

该文章来自于阿里巴巴技术协会( ATA

作者:淘杰

目录
相关文章
|
4月前
|
算法 搜索推荐 安全
社交网络分析1:起源发展、不同领域的应用、核心概念
社交网络分析1:起源发展、不同领域的应用、核心概念
190 0
社交网络分析1:起源发展、不同领域的应用、核心概念
|
7月前
|
存储 Rust 自然语言处理
当谈论协程时,我们在谈论什么
「什么是协程?」几乎是现在面试的必考题。 一方面,Donald E. Knuth 说「子过程是协程的一种特殊表现形式」;另一方面,由于 coroutine 的中文翻译「协程」中包含有「程」字,因此一般会拿来与「进程」、「线程」进行比较,称为「轻量级线程」。
|
10月前
|
Web App开发 移动开发 前端开发
当我们在谈论前端的时候我们在谈些什么
当我们在谈论前端的时候我们在谈些什么
53 0
|
11月前
|
机器学习/深度学习 存储 人工智能
当我们在谈论AIGC时,我们到底在谈论些什么?
人工智能(Artificial Intelligence,简称AI)
|
Kubernetes 程序员 开发者
极客程序员“怒斥”:FAANG 科技巨头内部晋升机制正在“扼杀” Kubernetes!
极客程序员“怒斥”:FAANG 科技巨头内部晋升机制正在“扼杀” Kubernetes!
极客程序员“怒斥”:FAANG 科技巨头内部晋升机制正在“扼杀” Kubernetes!
|
弹性计算 运维 Kubernetes
当我们谈论不可变基础设施时,我们在谈论什么
拥抱托管后,你豁然开朗,原来事情本应如此简单,多年的摸索仿佛一颗种子,在看到托管节点池后瞬间发芽成果。从此,世界上又开始流传了新三板斧传统:等等自愈看看?Pod 删下看看?Node 删下看看?朴实无华且有效。
300046 3
当我们谈论不可变基础设施时,我们在谈论什么
|
开发框架 JavaScript 前端开发
记一次谈论心得
记一次谈论心得
122 0
记一次谈论心得
|
Kubernetes 监控 API
同事提出个我从未想过的问题,为什么Kubernetes要"多此一举"推出静态Pod概念?
我们知道k8s中Pod可以说是一个合格的容器小管家,Pod 被设计成支持多个容器可以一起进行调度,容器之间可以**共享资源和依赖、彼此通信、协调何时以及何种方式运行或终止自身**。
223 1
同事提出个我从未想过的问题,为什么Kubernetes要"多此一举"推出静态Pod概念?

热门文章

最新文章