nodejs学习
1.1 Node.js
中的事件驱动
事件驱动模式是Node.js实现高并发关键点之一。Node.js的事件机制由EventEmitter
模块实现,Node中很多可以发送事件的核心模块都继承自该模块。
在Web服务器中,我们可以使用以下方式来处理用户请求:
var http = require('http'); //创建一个HTTP服务器 var server = http.createServer(); //监听request事件 server.on('request', function (req, res) { //对request事件的处理 res.writeHead(200, {'Content-Type': 'text/plain'}); res.end('Hello World!') }); server.listen(3000)
在上例中,我们为server
添加了'request'
事件上监听。当收到用户请求时该事件对应的回调函数会被调用,在回调函数中可以进行用户请求的处理与响应等操作。
传统Web服务器处理用户请求时,用户请求只能依次处理,如果处理时间较长会造成请求的阻塞。不同于传统的Web服务器,事件驱动使得Node创建的Web服务器非常的高效,用户不必须等待上个用户请求处理完成,就可以接收新的请求,并在处理完成后调用对应回调函数响应请求即可。事件驱动
和回调机制
赋予了Node.js强大并发处理能力。
1.2. 事件循环机制
Node.js的事件循环
是靠一个单线程不断地查询队列中是否有事件,当读取到事件时,将调用与这个事件关联的回调函数。上面介绍的事件驱动编程是事件循环
的具体表现形式,如:在前面创建Web服务器中,回调函数是一个I/O操作;Node.js会监听3000端口是否有HTTP连接,当收到连接时会启动这个I/O操作,但不会等待操作的完成而是继续查看是否有下一个事件,如果有则继续依次处理。
Node.js的事件循环机制由以下几部分组成:
事件生产者:Node.js通过EventEmitter
模块发送事件,发送的事件会被放到事件队列中。
事件队列:事件队列(Event Queue)是一个FIFO
模型,一端用于接收推入的事件,另外一端拉出要处理的事件。
事件循环:事件循环(Event Loop)是Node.js事件机制的关键点,它一个单线程运行的任务,会不断轮询事件队列,并将轮询到的事件放到线程池中进行处理。
线程池:线程池(Thread Pool)是真正执行事件和任务处理的位置,比较耗时的操作如:网络I/O、文件操作I/O及其它会引起阻塞的操作都会在这里处理。处理完成后,会调用事件对应的回调函数。
结合上图及前面介绍可以认为,Node.js单线程运行最直接的体现,是指其在事件循环
的时候是单线程运行的。在事件循环的时候,Node线程会把当前事件的读出,并将其放入到线程池处理,然后开始下一事件处理。
在Node的底层有一个libuv库,它实现了事件循环
和线程池
等功能。线程池是系统级别任务处理,也就是说事件的实际处理里是在更底层系统级完成的,在这一过程中并不是单线程的。处理完成后,线程池会调用与之绑定的回调函数,这样处理结果又被传回到了Node中。
2、Libuv
Libuv 是 Node.js 关键的一个组成部分,它为上层的 Node.js 提供了统一的 API 调用,使其不用考虑平台差距,隐藏了底层实现。
具体它能做什么,官网的这张图体现的很好:
可以看出,它是一个对开发者友好的工具集,包含定时器,非阻塞的网络 I/O,异步文件系统访问,子进程等功能。它封装了 Libev、Libeio 以及 IOCP,保证了跨平台的通用性。
3、一些可能的瓶颈
这里只见到讨论下自己的理解,欢迎指正。
首先,文件的 I/O 方面,用户代码的运行,事件循环的通知等,是通过 Libuv 维护的线程池来进行操作的,它会运行全部的文件系统操作。既然这样,我们抛开硬盘的影响,对于严谨的 C/C++ 来说,这个线程池一定是有大小限制的。官方默认给出的大小是 4。当然是可以改变的。在启动时,通过设置 UV_THREADPOOL_SIZE
来改变这个值即可。不过,最大也只能是 128,因为这个是涉及到内存占用的。
这个线程池对于所有的事件循环是共享的。当一个函数要使用线程池的时候(比如调用 uv_queue_work
),Libuv
会预先分配并初始化 UV_THREADPOOL_SIZE
所允许的线程出来。而128 占用的内存大约是
1MB,如果设置的太高,当使用线程池频繁时,会因为内存占用过多而降低线程的性能。具体说明;
对于网络 I/O 方面,以 Linux 系统下来说,网络 I/O 采用的是 epoll 这个异步模型。它的优点是采用了事件回调的方式,大大降低了文件描述符的创建(Linux下什么都是文件)。
在每次调用 epoll_wait
时,实际返回的是就绪描述符的数量,根据这个值,去
epoll 指定的数组里面取对应数量的描述符,是一种 内存映射 的方式,减少了文件描述符的复制开销。
上面提到的 epoll 指定的数组,它的大小即可监听的数量大小,它在不同的系统下,有不同的默认值,可见这里 epoll create。
#ifndef __NR_epoll_create | |
# if defined(__x86_64__) | |
# define __NR_epoll_create 213 | |
# elif defined(__i386__) | |
# define __NR_epoll_create 254 | |
# elif defined(__arm__) | |
# define __NR_epoll_create (UV_SYSCALL_BASE + 250) | |
# endif | |
#endif
/* __NR_epoll_create
*/ |
有了大小的限制,还远不够,为了保证运行的稳定,防止你在调用 epoll 函数时,指针越界,导致内存泄漏。还会用到另外一个值 maxevents
,它是 epoll_wait
所能处理的最大数量,在调用 epoll_wait
时可以指定。一般情况下小于创建时(epoll_create)的数组大小,当然,也可以设置的比
size 大,不过应该没什么用。可以想到如果就绪的事件很多,超过了 maxevents
,那么超出的事件就要等待前面的事件处理完成,才可以继续,可能会导致效率的下降。
在这种情况下,你可能会担心事件会丢失。其实,是不会丢失的,它会通过 ep_collect_ready_items
将这些事件保存在一个队列中,在下一个 epoll_wait
再进行通知。