1. 多任务的实现
多任务的实现只有三种方式:
- 多进程
- 单进程+多线程
- 多进程+多线程
第三种过于复杂,实现很少。多进程和多线程都会消耗 cpu,在线程和进程之间切换也会消耗 cpu,但是进程的开销更大,所以,多任务的实现一般都是单进程下开启多个线程。
但是,起初大部分实现 I/O 操作的库都是阻塞型 I/O。因此,多线程下,某个线程进行 I/O 操作,当前线程就会被阻塞。虽然这种方式不会影响其他线程,最直观的就是多个用户连接之间不会因为一个连接进行 I/O 操作就阻塞了其他用户。但是这依然有一个问题,阻塞期间 cpu 等待 I/O 操作的结束,浪费了很多性能,导致这种方式实现的并发性能和瓶颈都不高。
2. 异步和非阻塞
阻塞和非阻塞是对于操作系统而言的,是操作系统执行 I/O 操作的两种方式。
内核实现非阻塞 I/O 的几种方法:
read 轮询模式
cpu 一直在重复执行 read 操作,直到 I/O 操作完成;select 轮询
轮询检查文件描述符中的状态,状态未读取完成了再调用 read 方法;poll
解决了select只能检查1024个文件的限制;epoll
收到 I/O 事件的通知之后才会执行相关操作,否则处于休眠状态;
异步和同步是结果,基于内核实现非阻塞 I/O 的原理,单线程模式下的非阻塞 I/O 的结果仍然是同步!
非阻塞 I/O 方式,虽然一定程度上减少了对 cpu 的损耗,但是其本质还是需要等待 I/O 操作的完成,只是等待期间,cpu 可能处于休眠,可能处于轮询,这个操作性能上可能比阻塞 I/O 更好,但是结果仍然是同步;
需要特别注意的是:
非阻塞 I/O 和阻塞 I/O 区别并不大!!!
所以,真正意义上的异步是通过多线程 + 事件响应实现的。Node.js 中的 libuv 就是封装了 Linux 和 Window 两种操作系统下异步 API 的实现。
再次提醒,异步 I/O 和非阻塞 I/O 是不同的概念。异步 I/O 是结果,非阻塞 I/O 是操作系统执行 I/O 的一种形式,且非阻塞 I/O 意义并不大。
3. I/O的概念
IO 在计算机中指 Input/Output,也就是输入和输出。由于程序和运行时数据是在内存中驻留,由CPU这个超快的计算核心来执行,涉及到数据交换的地方,通常是磁盘、网络等,就需要 I/O 接口。实现了 I/O 接口和功能的设备叫做 I/O 设备,比如磁盘、网卡等等。
代码的运行、计算、线程之间的切换等都是 cpu 来执行。cpu 执行的速度远远高于 I/O 设备的处理速度,内存的存取数据的速度也远远超出 I/O 设备存取数据的速度。
在同步 I/O 的框架中,一个线程中如果开始了 I/O 操作,那么这个线程中其他的操作都需要等待 I/O 操作的结束才能继续进行,也就是超高速的 cpu 等待龟速的 I/O 操作。因此,同步 I/O 的框架中存在的问题就是性能的瓶颈就是 I/O 操作的速度。
另外,I/O 的同步和异步并不是物理属性,也就是说和设备无关,不是说 I/O 设备就只支持同步 I/O 。I/O 的同步和异步与否取决于功能的实现,也就是 API 背后使用的是何种实现方式。
Ryan Dahl 就提出,大部分人不设计一种更简单有效的程序的原因是因为他们使用到了同步 I/O 的库。因为同步 I/O 只需要等待即可,而异步 I/O 涉及到事件、轮询、消息、通知等一系列开发任务。说白了就是懒😲~~所以,Ryan Dahl 就基于 Javascript ,使用异步 I/O 的方式实现了许多支持服务器开发的库和接口。
PS:虽然现在的程序员使用异步编程特别普遍,甚至如果你不使用异步编程,很可能会因为潜在的性能问题被人嘲讽。但是在若干年前,当时流行的 PHP 就是完全的阻塞编程,压根不提供异步和多线程的 Api。而当时的很多高级语言虽然提供异步的支持,但是程序员普遍更喜欢使用同步编程。在众多的高级语言中,将异步作为主要编程方式和设计理念的,Node是首个。所以,了解了当时的环境就能更好的了解 Node.js 诞生的意义和目的了。
4. 解决的问题
源起:基于(阻塞性I/O +多线程)原理的多任务服务器存在的问题
当遇到并发问题时,比如多个客户端向服务器发起了连接请求。其他语言比如 Java 等都是为一个新的客户连接创建一个线程,一个线程的开销大概是2M,所以一个内存为 8GB 的服务器大概支持的同时连接数为4000。因此更大的并发势必造成更大的硬件成本。不仅如此,多服务器存在时,一个请求可能被不同的服务器处理,所以所有服务器之间必须共享资源,由此会增加架构的复杂性。
也就是说,问题的核心是:
多线程原理的多任务系统随着并发数的提升会带来硬件成本和架构复杂性的提升。
5. 如何解决问题
V8 javascript 引擎本身性能就比较高;
V8 javascript 引擎由 C++ 编写,该引擎具备可移植性,Node.js 就将它用在了服务器开发上;非阻塞I/O;
javascript 本身是单线程了,V8 javascript 引擎也是如此,所以 Node.js 为 javascript 提供了非阻塞性 I/O 的特性。另外,javascript 在浏览器中应用时,Web 为其整合提供了诸如 DOM、BOM 等很多浏览器 Api。 Node.js 也一样,,除了 ECMAScript 之外,Node.js 提供了很多服务器开发相关的 Api;事件响应机制
事件响应说白了就是 event loop,在 Node.js 刚问世时,这种思想还很少,但是如今却是非常的常见,比如 iOS 中的 runtime、runloop。
综上,Node.js 使用上述的三种手段来解决了面临的问题,但是最关键的仍然是事件响应机制和线程池的管理,下面将会介绍真正的异步 I/O 的实现原理。
6. 真正的异步 I/O
Node.js 中实现异步 I/O 的框架如下:
Node.js 的本质也是开启多线程,至于执行 I/O 操作非阻塞还是阻塞,其实并不重要,估计大部分仍然是阻塞。但是 java 也是多线程,那么这两者有什么区别呢?为什么node.js 的并发数可以比 java 高那么多?
Java一个用户一个线程,但是 Node.js 中只有需要进行阻塞操作时,比如 I/O 操作、网络请求操作时,这个时候才会开启线程。也就是说,因为只有在需要阻塞时才会开启线程,所以相同的用户连接数,Node.js 中线程的开启时间会相对于 Java 中的少,那么最终就会造成最大并发数的区别。
其原理如下:
I/O 操作无论阻塞还是非阻塞,其实区别并不大。所以说,node.js 和 java 最大的不同就是:
- java 是为每个用户分配一个进程,进程中执行 I/O 操作时,就会阻塞,此时 cpu 就在等待?应该是在等待,如果不等待,那么就必须有通知等机制来完成通知和响应。
- Node.js 是所有的用户连接都在一个线程中,当需要 I/O 操作时,才从线程池中分配线程去进行 I/O 操作,此时主线程继续执行后面的操作,I/O 线程会阻塞。关键就在于,因为有了事件、通知等体系的支持,此时的阻塞不需要 cpu 的等待,所以,这里才是性能提高的关键。
另外,事件循环和通知机制就真的不再详细解释了,附图一张:
整个异步流程如下:
注:本章主要参考《深入浅出Node.js》
7. Node.js的局限性
Node.js 之所以广泛用于 Web 类的应用中,是因为这类应用在,服务器的主要任务是 I/O 密集型而不是计算密集型。
I/O 密集型而不是计算密集型就不详细介绍了,可以参考之前的文章,一言以蔽之:
- I/O 密集型服务器的主要任务是资源的存和取,单个任务对 cpu 的消耗不大;
- 计算密集型服务器的主要任务是执行代码中的运算,单个任务对 cpu 消耗大;
所以,Node.js 的服务器如果想大并发,那就不能有非常复杂的处理。
常见场景:
- IM 系统
在线人数很多,但是在服务端的处理上,基本只需要 get 和 insert 数据; - 电商系统
同时购物的人数很多,但是服务器上
8. 总结:
一般的服务器都是阻塞性 I/O +多线程来实现多任务体系以支持高并发。而 Node.js 采用的是 Javascript ,其本身就是单线程,支持各种异步操作,再配上 Node.js 自身实现的异步 I/O 的网络处理的各种库,最终完美实现了多线程+事件机制的高性能高并发服务器。
再次提醒:Node.js 异步的实现的关键不在于多线程,而在于将 I/O 操作进行多线程处理以达到不阻塞执行代码的线程的目的。Java 虽然也是多线程,但是代码执行和 I/O 操作总是处在一个线程。