瞎鸡巴忙了一个多月之后,对node终于有一点点认识了,我对于一个新东西的学习一向主张先搞个东西出来。这个过程中我很可能完全不懂,什么都乱用,但是这个过程之后,再次进行系统的学习的时候,有很多东西,你就可以结合当时自己做的实例来分析。当然仅供个人扯淡。
如果对javascript有深入的了解,那node简直是分分钟入门。所以最好在学习node之前温补一下javascript的基础知识,又把javascript 高级程序设计看了一遍,尽管一半时间都在打呼呼,但是还是算翻了一遍吧,至少对其中的一些大概了解了一下。真希望那本书能越翻越薄,越读越快。前期还做了一些笔记,后来觉得很多知识需要融会贯通才行,看那上面的知识完全不够。扯这些,就是想在学习node之前能把基础学习扎实了。
阻塞与非阻塞IO
程序执行过程中会有很多I/O操作,如读写文件、输入输出、请求响应等,当I/O操作时整个线程都会暂停下来等待,当前I/O操作完成后再继续造成了浪费等待时间。非阻塞I/O起实质是利用CPU最小时间片来处理事务的,发起I/O操作之后,就利用闲置时的时间片来询问内核是否处理完成,直到得到完整的数据。
对于一组互不相关的任务需要完成,现在主流的方式有两种:单线程串行依次执行、多线程并行执行。
单线程串行执行:比较符合逻辑思维,很显然,单线程容易发生阻塞,性能有瓶颈且既然是单线程就会对CPU资源利用率不高。
多线程并行执行:如果多线程开销小于并行执行的开销,这是一种非常好的方式,缺点也很明显消耗非常大,消耗主要在于线程创建及线程上下文切换。而且一个独立任务非常大时,这个独立任务切给多个线程进行计算,这样会面临资源浪费,如果切给多线程,会面临死锁,状态同步等问题。
node本身是单线程的,单线程是很容易造成阻塞的。但是node也拥有异步I/O的机制,可以在单进程的情况下尽可能的高利用线程。这个机制的好处在于:避免了单线程的阻塞,也避免了多线程的死锁和状态同步的问题。而且如果有多核CPU,node还提供子进程来对多核的利用。但是也会有个问题,由于CPU执行任务是分时间片的。所以每次时间片,显然需要一个确认结果是否完成的机制。CPU会长期处于判断阶段,判断任务有没有执行完成,这个会造成资源的浪费。node肯定希望减少这个浪费,我们常见的方式就是轮询。
node的高效性
我们所知道的是I/O操作是最消耗时间的,特别是阻塞I/O,更会使得造成等待I/O执行浪费时间,node的异步I/O机制使得js线程在执行I/O操作时不会去等待,所以尽管只有一个js 线程,执行效率依旧是相当高的。当然,如果我们的服务器是多核的,node也提供开启子进程的方式去多核利用,同时我们还可以利用多核来做负载均衡。node由于是基于异步I/O的实现,所以本身就有着单线程能处理高并发的能力,多进程的使用只是提高了对多核的利用率,并非是更高效的处理并发。
异步I/O的处理
事件的处理大致有四个重要点:事件循环、观察者、请求对象、执行回调。
事件循环:进程开启时node便会创建一个类似while(true)的循环。最小的一次执行,就是一次Tick(这个时间片和cpu不同,是把所有事件循环一次的时间),每个请求对象被推入线程池的时候会挂到时间循环的loop上的pending_reqs_tail属性上,每个 Tick 的过程就是查看自己的loop上的pending_reqs_tail属性上有没有待处理的事件,如果有就取出并和相应的观察者去匹配I/O操作结果(去匹配,成功则拿到请求对象,执行其回调)。如果不再有事件处理就退出。
观察者:判断这个事件是否需要处理是由一个叫观察者的事务来决定的,异步I/O,网络I/O不断的将自己在线程池中的执行结果告诉不同的观察者,而事件循环则是看自己有没有与其相匹配的事件,事件循环去去询问观察者,观察者对这些事件对应自己手中的I/O操作结果进行匹配。匹配完成交给事件循环。
请求对象:当js线程接受到I/O操作请求时,到最终内核执行完I/O操作时会产生一个中间产物叫请求对象。这个请求对象在线程池中没有得到结果之前,保存着当前所有的状态,这个请求对象在完成I/O操作之后存储着对于回调的处理。
整个处理流程就是js线程接到了I/O操作,将调用底层方法(libuv的实现,还要分平台,linux和windows)创建出了一个中间产物,就是请求对象,上面有各种信息,将js线程将这个请求对象推入线程池中,js线程就释放了,就可以去做下一个I/O操作,事件循环也将这个请求对象放在了loop属性上,开始进行事件循环,线程池空闲时执行请求对象中的I/O操作,将执行完成结果放在请求对象中(这里是I/O执行的结果),通知观察者搞定了,事件循环就不断的循环自己loop上某个属性上的事件,拿着事件去询问观察者,观察者匹配完成之后事件循环从loop上拿出请求对象,执行请求对象上的回调函数,完成整个异步I/O的操作。
异步方法
异步处理的方法有四种:setTimeout、setInterval、process.nextTick、setImmediate
setTimeout:延时处理事件。
setInterval:没隔一段时间执行。
process.nextTick:延时最小时间片执行。效率比setTimeout(function(){},0)要高。
setImmediate:与process.nextTick的功能相似,但是没有process.nextTick优先级高。因为事件循环对观察者的检查是有先后顺序的,process.nextTick是idle观察者,setImmediate是check的观察者,被通知的优先级是:idle观察者 -> I/O观察者 -> check观察者
setImmediate(function(){
console.log('setImmediate');
})
process.nextTick(function(){
console.log('nextTick');
})
console.log('正常执行')
// 结果
// 正常执行
// nextTick
// setImmediate
// 在 深入浅出 书中,下面这个例子的执行顺序是
// 正常执行 -> nextTick1 -> nextTick2 -> setImmediate1 -> nextTick3 -> setImmediate2
process.nextTick(function(){
console.log('nextTick1');
})
process.nextTick(function(){
console.log('nextTick2');
})
setImmediate(function(){
console.log('setImmediate1');
process.nextTick(function(){
console.log('nextTick3');
})
})
setImmediate(function(){
console.log('setImmediate2');
})
console.log('正常执行')
// 我得到的结果却是
// 正常执行 -> nextTick1 -> nextTick2 -> setImmediate1 -> setImmediate2 -> nextTick3
模块系统
整个模块结构:内建模块(C/C++) -> 核心模块(javascript) -> 文件模块。
node的核心模块系统分为两部分(前两部分)。内建模块(C/C++)和核心模块(javascript),javascript开发速度快于静态语言,但是性能不如静态语言。node采用复合开发的方式,希望在这里寻求一个平衡点。
通常我们把用纯C/C++编写的部分统一称为内建模块,node的buffer、fs、crypto等是C/C++开发的。存在node_module_list数组中。取的时候也非常好取。在编译时会被编译到二进制文件中,一旦node被执行这些会被直接加载到内存中,直接可执行。
模块化对于前端肯定不陌生,为了避免过多使用全局变量,也为了能更好的组织自己的代码,将一个文件拆成多个模块,进行开发,由此有两个模块化模式产生,AMD和CMD。在node中,称这部分为文件模块
引入模块时又分为相对模块与绝对模块。node的模块化引入了requireJs的AMD类型模块化。你可以将其他模块预先加载进来,然后直接对其中的API进行使用,如果是node_modules中的模块或者是系统模块,可以直用require不加任何后缀和路径进行引用称之为绝对模块,如:引入文件系统var fs = require('fs');自己写了一个文件要引入依赖的话需要加上相对的路径。如:引入一个同级目录的module.js文件var module = require('./module.js');
暴露:module.js需要暴露出一些方法供引入者使用的话,直接写exports.name = 'karyn';外界引入时var module = require('./module.js'),module.name === 'karyn'了,exports`就是暴露的一个对象,可以将暴露的方法挂载到该对象上,外界引用的时候可以获取到。
整个过程是怎么进行的呢?分为三个过程路径分析、文件定位、编译执行。
路径分析
分析路径肯定会有标识符,require()就是一个标志,拿到这样的标志之后我们就需要分析括号里面的东西了,里面就是路径,路径一般分为四种:
- 核心模块,如:http、fs等
- .或者..开始的相对路径文件模块
- 以/开始的绝对路径文件模块
- 非路径形式的文件模块,如 express。不是核心模块,但也不是使用路径方式引用的
查找的优先级是:缓存加载 -> 核心模块 -> 路径文件 -> 非路径形式的文件模块。
文件定位
前面三种文件的定位都比较容易找到其路径。显然前三种都可以是很快的,因为有准确的路径。但是最后一种怎么保证其效率呢。沿着当前目录往上寻找名为node_modules的文件夹,直至找到文件。文件目录越深,越不容易找到,这就是为什么会慢。
编译
可以不用些扩展名,如:require('index');,node会按照.js、.node、.json的方式补足。但是显然写完整是更有效率的,也不会出错的。
我们在书写代码的过程中,并没有发现exports、require等参数,为什么还能引用到呢?如果放到浏览器中看的话,就会是全局变量,但node是在编译的时候增加了头尾。这样的好处是显而易见的,可以避免文件间的变量相互影响污染。
(function (exports, require, module, __filename, __dirname){
// 业务代码
});
包装之后会用vm.runInThisContext方法执行。该方法类似于eval,使用如:vm.runInThisContext('console.log(1)')
多个模块同时引入一个文件不会被编译多次,node内部有缓存机制。二次引入会走缓存加载。就不会再去编译文件了。
不同文件的结尾会使用不同的编译方式:.js的文件编译就是上面所说的编译流程。增加头尾,执行代码;.node结尾的文件不需要编译,因为本身就是C/C++写的,只需要执行;.json文件的编译只需要读取文件,使用JSON.parse()方法转换成js对象就行了。
编写一个核心模块
尽管我也对C/C++也一点都不懂。当服务器出现性能瓶颈的时候,可以考虑这种方式来解决。简单的模块通过javascript来编写可以大大提高生产效率。
有这样的方法,我并没有去验证。
// 将一下代码保存为 node_hello.h,存放到 Node 的 src 目录下:
#ifndef NODE_HELLO_H_
#define NODE_HELLO_H_
#include <v8.h>
namespace node{
v8::Handle<v8::Value> SayHello(const v8::Arguments& args);
}
#endif
// 编写 node_hello.cc,并存储到 src 目录下
#include <node.h>
#include <node_hello.h>
#include <v8.h>
namespace node{
using namespace v8;
Handle<Value> SayHello(const Arguments& args) {
HandleScope scope;
return scope.Close(String::New("Hello world!"));
}
void Init_Hello(Handle<Object> target){
target->Set(String::NewSymbol("sayHello"), FunctionTemplate::New(SayHello)->GetFunction());
}
}
NODE_MODULE(node_hello, node::Init_Hello)
事件
事件的机制贯穿整个nodeJs,事件是Node非阻塞设计的重要体现。Node通常不会直接返回数据,而是采用分发事件来传递数据的方式。非阻塞的体现在于,我监听了一个事件之后我就不管了,知道有事件通知我,然后我再做回馈。分发事件也是一样的,分发了事件之后我也不管了,所以两者之间也不会是谁等谁,事件的这种机制就决定了非阻塞。使用方式如下:
var EventEmitter = require('events').EventEmitter,
thisEvents = new EventEmitter();
thisEvents.on('hello', function(){
console.log('hello')
});
thisEvents.emit('hello')
前面写过一个对于javascript事件的理解,事件的基本运行机制和怎样实现一个自定义事件。
异步编程解决方案
在node中大量使用异步编程来提升性能。我们大致使用三种方案使用在异步编程中:事件机制、Promise、流程控制库。
事件机制:使用方法如上。原理实现事件,EventProxy是对于单纯事件的一个扩展。
Promise:使用方法Promise,也是一种非常好的异步编程的方法。
流程控制库:主要是通过流程控制模块来解决。
掌握这种编程方式会有非常多的体验。至少你不用担心你的大型计算会因为阻塞而变得非常慢。
buffer
Buffer是一个像Array的对象,但它主要用于操作字节。是挂载在全局对象上的。并且是非V8分配的内存而是在node的C++层面实现内存的申请,属于堆外内存。
var str = '我是宋奇',
buf = new Buffer(str, 'utf-8');
console.log(buf) // <Buffer e6 88 91 e6 98 af e5 ae 8b e5 a5 87>
// buffer 受数组影响很大,声明方式,长度,访问方式都很类似
var buf = new Buffer(100);
console.log(buf.length); // 100
console.log(buf[10]); // 0
// 赋值
buf[20] = -100;
console.log(buf[20]); // 156 = 256 - 100
buf[21] = 300;
console.log(buf[21]); // 44 = 300 - 256
buf[22] = 3.1415
console.log(buf[22]); // 3 = 省略小数点后面的数
Buffer以8KB为界限来区分Buffer是大对象还是小对象。
Buffer可以与字符串之间相互转换,目前转换的字符串类型有:ASCII、UTF-8、UTF-16LE/UCS-2、Base64、Hex。
new Buffer(str, [encoding]) // 编码类型,默认按 UTF-8 编码进行转码和存储
new Buffer('aaa', 'utf-8')
buf.toString([encoding], [start], [end])
var buf = new Buffer('my name is karyn', 'utf-8')
buf.toString('utf-8', 0, 2) // 'my'
Buffer.isEncoding('GBK')
编码类型转换类库iconv-lite。无法转换就是乱码,会有降级处理。多字节是�,单字节是?,
var iconv = require('iconv-lite'),
buf = iconv.encode('my name is karyn', 'utf-8'),
str = iconv.decode(buf, 'GBK');
流式读文件中的编码应用。
var fs = require('fs');
var data = '';
fs.createReadStream('/home/q/log/data/output/20150923.log').on('data', function(chunk){
data += chunk;
}).on('end', function(){
console.log(data)
})
// 上面的操作 data += chunk 隐藏了一个操作
data = data.toString() + chunk.toString(); // 默认是 utf-8
// 如果将上面的文件方法改为下面这种就会出现很多 �
createReadStream('/home/q/log/data/output/20150923.log', {highWaterMark: 11})
// 原因在于 UTF-8 占3个字节,只能显示3个字符。构造了11这个限制随时可能被截断,就会转换出乱码了
// 解决方案是 rs.setEncoding('utf-8');
// 这样的流式读法就不会产生刚才的问题了。但是这只能处理少数字符集的数据
// 正确的使用方式
var iconv = require('iconv-lite'),
fs = require('fs');
var data = '',
rs = fs.createReadStream('/home/q/log/data/output/20150923.log'),
chunks = [],
size = 0;
rs.on('data', function(chunk){
chunks.push(chunk);
size += chunk.length;
}).on('end', function(){
var buf = Buffer.concat(chunks, size),
str = iconv.decode(buf, 'utf8');
console.log(str.split('\n'))
})
Buffer.concat = function(list, length){
if(!Array.isArray(list)) {
throw new Error('Usage: Buffer.concat(list, [length])');
}
if(list.length === 0){
return new Buffer(0);
}else if(list.length === 1){
return list[0];
}
if(typeof length !== 'number'){
length = 0;
for(var i=0; i<list.length; i++){
var buf = list[i];
length += buf.length;
}
}
var buffer = new Buffer(length),
pos = 0;
for(var i=0; i<list.length; i++){
var buf = list[i];
buf.copy(buffer, pos);
pos += buf.length;
}
return buffer;
}
在网络传输的时候选用Buffer作为数据传输媒介,要更快,本身就是二进制文件,可以避免在传输前进行转化被损耗。
在读取文件时设置适当的参数会加快读取的速度。如:highWaterMark、start、end。比如第一个参数就很有考究,如果将第一个数取值为8KB的大小是最有利于读取文件的。具体为什么请了解buffer的内存分配
// 以下是读取测试,文件大小为 6MB
var iconv = require('iconv-lite'),
fs = require('fs');
var data = '',
rs = fs.createReadStream('/home/q/log/data/output/20150923.log', {
flags: 'r',
encoding: null,
fd: null,
mode: 0666,
highWaterMark: 1 * 1024
}),
chunks = [],
size = 0;
var timer = +new Date()
rs.on('data', function(chunk){
chunks.push(chunk);
size += chunk.length;
}).on('end', function(){
var buf = Buffer.concat(chunks, size),
str = iconv.decode(buf, 'utf8');
console.log(+new Date() - timer);
})
// highWaterMark: 1 * 1024 -> 145 ms
// highWaterMark: 4 * 1024 -> 87 ms
// highWaterMark: 8 * 1024 -> 75 ms
// highWaterMark: 64 * 1024 -> 67 ms
// highWaterMark: 1024 * 1024 -> 60 ms
// 实际上结论可以看出这个值取得越大是读取的速度越快。当然过大也会开始下降,内存的也是一个考量方向,所以可以综合可以来看