6.4 node.js
1990 年,我大学毕业后进入软件开发公司工作,到现在已经有 20 年了,不由得感叹日月如梭。
在这 20 年间,我从事软件开发工作的感受,就是无论是软件开发还是其他工作,最重要的就是提高效率。工作就像一座大山一样压在你面前,不解决掉它你就没饭吃。然而,自己所拥有的能力和时间都是有限的,要想在规定期限内解决所有的问题非常困难。
话说回来,在这 20 年的工作生涯中,我几乎没有开发过供客户直接使用的软件,这作为程序员似乎挺奇葩的。不过,我依然是一名程序员。从软件开发中,程序员能够学到很多丰富人生的东西。我从软件开发中学会了如何提高效率,作为应用,总结出了下面几个方法:
· 减负
· 拖延
· 委派
看起来这好像都是些浑水摸鱼的歪门邪道,其实这些方法对于提高工作效率是非常有用的。
减负
在计算机的历史上,提高处理速度的最有效手段,就是换一台新的电脑。计算机的性能不断提升,而且价格还越来越便宜,仅靠更新硬件就能够获得成倍的性能提升,这并不稀奇。
不过很遗憾,摩尔定律并不适用于人类,人类的能力不可能每两年就翻一倍,从工作的角度来看,上面的办法是行不通的。然而,如果你原地踏步的话,早晚会被更年轻、工资更便宜的程序员取代,效率先不说,至少项目的成本降低了,不过对于你来说这可不是什么值得高兴的事。
说点正经的,在软件开发中,如果不更换硬件,还可以用以下方法来改善软件的运行速度:
· 采用更好的算法
· 减少无谓的开销
· 用空间来换时间
如果将这些方法拿到人类的工作中来,那么“采用更好的算法”就相当于思考更高效的工作方式;“减少无谓的开销”则相当于减少低级重复劳动,用计算机来实现自动化。
“用空间来换时间”可能不是很容易理解。计算机即便进行重复劳动也不会有任何怨言,但还是需要人类来进行管理。如果能够将计算过一次的结果保存在某个地方,就可以缩短计算时间。
这样一来,所需要的内存空间增加了,但计算时间则可以得到缩短。在人类的工作中,应该是相当于将复杂的步骤和判断实现并总结成文档,从而提高效率的方法吧。
然而,在有限的条件下,提高工作效率的最好方法就是减负。我们所遇到的大部分工作都可以分为三种,即非得完成不可的、能完成更好但并不是必需的,以及干脆不做为好的。有趣的是,这三种工作之间的区别并非像外人所想象的那样简单。有一些工作虽然看起来绝对是必需的,但仔细想想的话就会发现也未必。
人类工作的定义比起计算机来说要更加模棱两可,像这样伴随不确定性,由惯性思维所产生的不必要不紧急的工作,如果能够砍掉的话,就能够大幅度提高工作效率。
拖延
减少不必要不紧急的工作,就能够更快地完成必要的工作,提高效率,关于这一点恐怕大家没有什么异议。不过,到底哪项工作是必要的,而哪项工作又不是必要的,要区分它们可比想象中困难得多。要找出并剔除不必要的工作,还真没那么容易。
其实,要做出明确的区分,还是有诀窍的,那就是利用人类心理上的弱点。人们普遍都有只看眼前而忽略大局的毛病,因此,当项目期限逼近时,就会产生“只要能赶上工期宁愿砸锅卖铁”这样的念头。
即便如此,估计也解决不了问题,还不如将计就计,干脆拖到不能再拖为止,这样一来,工期肯定赶不上了,只好看看哪些工作是真正必需的,剩下的那些就砍掉吧。换作平时,要想砍掉一些工作,总会有一些抵触的理由,如“这个说不定以后要用到”、“之前也一直这么做的”之类的,但在工期大限的压力面前,这些理由就完全撑不住了。这就是拖延的魔力。
不过,这个方法的副作用还是很危险的。万一估算错了时间,连必要的工作也完成不了,那可就惨了。所以说这只是个半开玩笑(但另一半可是认真的)的拖延用法,但除此之外,还有其他一些利用拖延的方法。
例如,每个任务都各自需要一定的时间才能完成,有些任务只要 5 分钟就能完成,而有些则需要几个月。如果能够实现列出每项任务的优先级和所需的时间,就可以利用会议之间的空档等碎片时间,来完成一些较小的工作。
这种思路,和 CPU 中的乱序执行如出一辙。进一步说,对于一项任务,还可以按照“非常紧急必须马上完成的工作”、“只要不忘记,什么时候完成都可以的工作”等细分成多个子任务。
这样,按照紧急程度从高到低来完成任务的话,就可以进一步提高自己的工作效率。在这里,和“乱序执行”一样,需要注意任务之间的相互依赖关系。当相互依赖关系很强时,即使改变这些任务的顺序,也无法提高效率,这一点无论在现实的工作中还是 CPU 中都是相通的。
委派
大多数人都无法同时完成多个任务,因此可以看成是只有单一核心的硬件。即便用拖延的手段提高了工作效率,但由于同时只能处理一项任务,而每天 24 小时这个时间是固定不变的,因此一个人能完成的工作总量是存在极限的。
这种时候,“委派”就成了一个有效的手段。“委派”这个词给人的印象可能不太好,说白了,就是当以自己的能力无法处理某项任务时,转而借用他人的力量来完成的意思。如果说协作、协调、团队合作的话,大概比委派给人的印象要好一些吧。说起来,这和用多核代替单核来提升处理能力的方法如出一辙。
不过,和多核一样,这种委派的做法也会遇到一些困难。多核的困难大概有下面几种:
· 任务分割
· 通信开销
· 可靠性
这些问题,无论是编程还是现实工作中都是共通的。
如何进行妥善的任务分割是一个难题。如果将处理集中在某一个核心(或者人员)上,效率就会降低,然而要想事先对处理所需要的时间做出完全的预测也是很困难的。尤其是某项任务和其他任务有相互依赖关系的情况下,可能无论如何分割都无法提高工作效率。
我们可以把任务分为两种:存在后续处理,在任务完成时需要获得通知的“同步任务”;执行开始后不必关心其完成情况的“异步任务”。同步任务也就意味着存在依赖关系,委派的效果也就不明显。因此,如何将工作分割成异步任务就成了提高效率的关键。
在有多名成员参与的项目中,通信开销(沟通开销)也不可小觑。在人类世界中,由于“想要传达而没能传达”、“产生了误会”等原因导致的通信开销,比编程世界中要更为显著。我认为导致软件开发项目失败的最大原因,正是由于没有对这种沟通开销引起足够的重视。
最后一个问题就是“可靠性”。固然,一个人工作的话,可能会因为生病而导致工作无法进行,这也是一种风险,但随着参与项目的人数增加,成员之中有人发生问题的概率也随之上升。这就好比只有一台电脑时,往往不太担心它会出故障,但在管理数据中心里上千台的服务器时,就必须要对每天都有几台机器会出故障的情况做好准备。
当项目规模增大时,万一有人中途无法工作,就需要考虑如何修复这一问题。当然,分布式编程也是一样的道理。
非阻塞编程
在编程世界中,减负、拖延和委派是非常重要的,特别是拖延和委派恐怕还不为大家所熟悉,但今后应该会愈发成为一种重要的编程技巧。下面我们先来介绍一下在编程中最大限度利用单核的拖延方法,然后再来介绍一下运用多核的委派方法。
如果对程序运行时间进行详细分析就可以看出,大多数程序在运行时,其中一大半的时间 CPU 都在无所事事。实际上,程序的一大部分运行时间都消耗在了等待输入数据等环节上,也就是说“等待”消耗了大量的 CPU 时间。
这样的等待被称为阻塞(blocking),而非阻塞编程的目的正是试图将阻塞控制在最低限度。下面我们来介绍一下作为非阻塞编程框架而备受瞩目的“node.js”。在这里,我们使用 JavaScript 来进行讲解。
node.js 框架
node.js 是一种用于 JavaScript 的事件驱动框架。提到 JavaScript,大家都知道它是一种嵌入在浏览器中、工作在客户端环境下的编程语言,而 node.js 却是在服务器端工作的。
默认嵌入到各种浏览器中的客户端语言,恐怕只有 JavaScript 这一种了,但在服务器端编程中,语言的选择则更为自由,那么为什么还要使用 JavaScript 呢?那是因为在服务器端使用 JavaScript 有它特有的好处。
首先是性能。随着 Google Chrome 中 v8 引擎的出现,各大浏览器在 JavaScript 引擎性能提升方面的竞争愈演愈烈。可以说,JavaScript 是目前动态语言中处理性能最高的一种,而 node.js 也搭载了以高性能著称的 Google Chrome v8 引擎。
其次,JavaScript 的标准功能很少,这也是一个好处。和其他一些独立语言,如 Ruby 和 Python 等不同,JavaScript 原本就是作为浏览器嵌入式语言诞生的,甚至都没有提供标准的文件 I/O 等功能。
然而,在事件驱动框架上编程时,通常输入输出等可能产生的“等待”是非常麻烦的,后面我们会详细讲解这一点。node.js 所搭载的 JavaScript 引擎本身就没有提供可能会产生阻塞的功能,因此不小心造成阻塞的风险就会相应减小。当然,像死循环、异常占用 CPU 等导致的“等待”还是无法避免的。
事件驱动编程
下面我们来介绍一下,在 node.js 这样的事件驱动框架中,应该如何编程。在传统的过程型编程中,各项操作是按照预先设定好的顺序来执行的(图 1)。这与人类完成工作的一般方式相同,因此很容易理解。
图 1 过程型编程
相对地,在事件驱动框架所提供的事件驱动编程中,不存在事先设定好的工作顺序,而是对来自外部的“事件”作出响应,并调用与该事件相对应的“回调函数”。这里所说的事件,包括“来自外部的输入”、“到达事先设定的时间”、“发生异常情况”等情况。在事件循环框架中,主循环程序一般是用一个循环来等待事件的发生,当检测到事件发生时,找到并启动与该事件相对应的处理程序(回调函数)。当回调函数运行完毕后,再次返回到循环中,等待下一个事件(图 2)。
图 2 事件驱动编程
我们可以认为,过程型编程类似于每个单独的员工完成工作的方式,而事件驱动编程则类似于公司整体完成工作的方式。当发生客户下订单的事件时,销售部门(事件循环)会在接到订单后将工作转交给各业务部门(回调函数)来完成,这和事件驱动编程的模型有异曲同工之妙。
事件循环的利弊
要实现和事件循环相同的功能,除了用回调函数之外,还可以采用启动线程的方式。不过,回调只是一种普通的函数调用,相比之下,线程启动所需要的开销要大得多。而且,每个线程都需要占用一定的栈空间(Linux 中默认为每个线程 8MB 左右)。
当然,我们可以使用线程池技术,事先准备好一些线程分配给回调函数来使用,但即便如此,在资源消耗方面还是单线程方式更具有优势。此外,使用单线程方式,还不必像多线程那样进行排他处理,由此引发的问题也会相对较少。
另一方面,单线程方式也有它的缺点。虽然单线程在轻量化方面具备优势,但也同时意味着无法利用多核。此外,如果回调函数不小心产生了阻塞,就会导致事件处理的整体停滞,但在多线程 / 线程池方式中就不存在这样的问题。
node.js 编程
无论是 Debian GNU/Linux 还是我所使用的 sid 1(开发版)中,都提供了 node.js 软件包,安装方法如下:
# apt-get install nodejs1 sid 是 Debian 不稳定版本(开发版本)的代号:http://www.debian.org/releases/sid/。
如果你所使用的发行版中没有提供软件包,就需要用源代码来安装。和其他大多数开源软件一样,node.js 的编译安装也是按 configure、make、make install 的标准步骤来进行的。
安装完毕后就可以使用 node 命令了,这是 node.js 的主体。不带任何参数启动 node 命令,就会进入下面这样的交互模式。
% node > console.log("hello world") hello worldconsole.log 是用于在交互模式下进行输出的函数。在非交互模式下则应该使用标准输出,因此可以认为,在正式环境下是不会使用它的。不过,如果要确认 node.js 是否工作正常,这个函数是非常方便的。在这里我们基本上是在交互模式下进行讲解的,因此会经常使用到 console.log 函数。
好,下面我们来引发一个事件试试看。要设置一个定时发生的事件及其相应的回调函数,可以使用 setTimeout() 函数。
> setTimeout(function(){ ... console.log("hello"); ... }, 2000)调用 setTimeout() 的作用是,在经过第二个参数指定的时间(单位为毫秒)之后引发一个事件,并在该事件发生时调用第一个参数所指定的回调函数。Timeout 事件是一个仅发生一次的事件。
function () { console.log("hello"); }这个部分是一个匿名函数,和 Ruby 中的 lambda 功能差不多。这里的重点是,调用 setTimeout() 函数之后,该函数马上就执行完毕并退出了。
setTimeout() 的作用仅仅是预约一个事件的发生,并设置一个回调函数,并没有必要进行任何等待。这与 Ruby 中的 sleep 方法是不同的,node.js 会持续对事件进行监视,基本上不会发生阻塞。
node.js 的对话模式,表面上看似乎是在一直等待标准输入,但实际上只是对标准输入传来数据这一事件进行响应,并设置一个回调函数,将输入的字符串作为 JavaScript 程序进行编译并执行。因此,在交互模式下输入 JavaScript 代码,就会被立即编译和执行,执行完毕后,会再度返回 node.js 事件循环,事件处理和对回调函数的调用,都是在事件循环中完成的。
setTimeout() 会产生一个仅发生一次的事件。如果要产生以一定间隔重复发生的事件,可以使用“setInterval()”函数来设置相应的回调函数。
> var iv = setInterval(functi on(){ ... console.log("hello"); ... }, 2000) hello hello通过 setInterval() 函数,我们设置了一个每 2000 毫秒发生一次的事件,并在发生事件时调用指定的回调函数。不过,每隔两秒就显示一条 hello 实在是太烦人了,我们还是把这个定期事件给取消掉吧。
为此我们需要使用 clearInterval() 函数。将 setInterval() 的返回值作为参数调用 clearInterval() 就可以解除指定的定期事件。
> clearInterval(iv);网络服务器才是发挥 node.js 本领的最好舞 台。我们先来实现一个最简单的服务器,即将从套接字接收的数据原原本本返回的“回声服务器”。用 node.js 实现的回声服务器如图 3 所示。node.js 网络编程
var net = require("net"); net.createServer(function(sock){ sock.on("data", function(data) { sock.write(data); });}).listen(8000);图 3 用 node.js 实现的回声服务器
作为对照,我们不用事件驱动框架,而是用 Ruby 实现另一个版本的回声服务器,如图 4 所示。
require "socket" svr = TCPServer.open(8000) socks = [svr] loop do result = select(socks); next if result == nil for s in result[0] if s == svr ns = s.accept socks.push(ns) else if s.eof? s.close socks.delete(s) elsif str = s.readpartial(1024) s.write(str) end end end end图 4 用 Ruby 实现的回声服务器
我们来连接一下试试看。在 node.js 中,要启动回声服务器,需要将图 3 中的程序保存到文件中,如 echo.js,然后执行:
% node echo.js就可以启动程序了。Ruby 版则可以将图 4 的程序保存为 echo.rb,并执行:
% ruby echo.rb客户端我们就偷个懒,直接用 netcat 了。无论是使用 node.js 版还是 Ruby 版,都可以通过下列命令来连接:
% netcat localhost 8000连接后,只要用键盘输入字符,就会得到一行和输入的字符相同的输出结果。要结束 telnet 会话,可以使用“Ctrl+C”组合键。
将图 3 程序和图 4 程序对比一下,会发现很多不同点。首先,图 4 的 Ruby 程序实际上是自行实现了相当于事件循环的部分。套接字的监听、注册、删除等管理工作也是自行完成的,这导致代码的规模变得相对较大。
node.js 版则比 Ruby 版要简洁许多。虽说采用 node.js 需要熟悉回调风格,但作为服务器的实现来说,显然还是事件驱动框架更加高效。
下面我们来详细看看 node.js 版和 Ruby 版之间的区别。
首先,node.js 版中,开头使用 require 函数引用了 net 库,net 库提供了套接字通信相关的功能。接下来调用的 net.createServer 函数,用于创建一个 TCP/IP 服务器套接字,并在该套接字上接受来自客户端的连接请求。在 createServer 的参数中所指定的函数,会被作为回调函数进行调用,当回调函数被调用时,会将客户端连接的套接字(sock)作为参数传递给它。sock 的 on 方法用于设置 sock 相关事件的回调函数。
当来自客户端的数据到达时会发生 data 事件,收到的数据会被传递给回调函数。这里我们要实现的是一个回声服务器,因此只需要将收到的数据原本返回即可。listen 方法用于在服务器套接字上监听指定的端口号。
随后,程序到达末尾,进入事件循环。需要注意的是,node.js 程序中,程序主体仅负责对事件和回调函数进行设置和初始化,实际的处理是在事件循环内完成的。
相对地,Ruby 版则需要自行管理事件循环和套接字,因此程序结构相对复杂一些。大体上是这样的:
1. 通过 TCPSever.open 打开服务器套接字。
2. 通过 select 等待事件。
3. 如果是对服务器套接字产生的事件,则通过 accept 打开客户端连接套接字。
4. 除此之外的事件,如遇到 eoffi(连接结束)则关闭客户端套接字。
5. 读取数据,并将数据原原本本回写至客户端。
在 node.js 中,上述 2、3、4 的部分已经嵌入在框架中,不需要以显式代码来编写,而且,程序员也不必关心资源管理等细节。正是由于这些特性,使得在回声服务器的实现上,node.js 的代码能够做到非常简洁。
node.js 回调风格
像图 3 这样将多个回调函数叠加起来的编程风格,恐怕还是要习惯一下才能上手。
下面我们通过实现一个简单的 HTTP 服务器,来仔细探讨一下回调风格。图 5 是运用 node.js 库实现的一个超简单的 HTTP 服务器。无论收到任何请求,它都只返回 hello world 这个纯文本字符串。
var http = require('http'); http.createServer(function(req, res) { res.writeHead(200, {'Content-Type':'text/plain'}); res.write("hello world¥n"); res.end(); }).listen(8000);图 5 用 node.js 编写的 HTTP 服务器(1)
我们来实际访问一下试试看。
% curl http://localhost:8000/ hello world很简单吧。
我们来思考一下回调风格。图 6 所示的,是一个读取当前目录下的 index.html 文件并返回其内容的 HTTP 服务器。index.html 的读取是用 fs 库的 readFile 函数来完成的。
var http = require("http"); var fs = require("fs"); http.createServer(function(req, res) { fs.readFile("index.html", function(err, content) { if (err) { res.writeHead(404, {"Content-Type":"text/plain"}); res.write("index.html: no such file¥n"); } else { res.writeHead(200, {"Content-Type":"text/html; charset=utf-8"}); res.write(content); } res.end(); }); }).listen(8000);图 6 用 node.js 编写的 HTTP 服务器(2)
这个函数会在文件读取完毕后调用回调函数,也就是说即便是简单的文件输入输出也采用了回调风格。传递给回调函数的参数包括是否发生错误的信息,以及读取到的字符串。
node.js 的 fs 库中,也提供了用于同步读取操作的 readFileSync 函数,但在 node.js 中,还是推荐采用无阻塞风险的回调风格。
像这样,随着接受请求、读取文件等操作的叠加,回调函数的嵌套也会越来越深,这是回调风格所面临的一个课题。当然,我们也有方法来应对,不过关于这个问题,我们还是将来有机会再讲吧。
node.js 的优越性
通过刚才的介绍,大家是不是能够感觉到,用 node.js 可以很容易地实现一个互联网服务器呢?即使必须要习惯 node.js 的回调风格,这样的特性也是非常诱人的。
不过,用 node.js 来实现服务器的优越性并非只有“容易”这一点而已。首先,在事件检测上,node.js 并没有采用随连接数的增长速度逐渐变慢的 select 系统调用这一传统方式,而是采用了与连接数无关,能够维持一定性能的 epoll(Linux)和 kqueue(FreeBSD)等方式。因此,在连接数上限值方面可以比较令人放心。但是,对于每个进程来说,文件描述符的数量在操作系统中是存在上限的,要缓解这一上限可能还需要一些额外的设置。
其次,node.js 的 http 库采用了 HTTP1.1 的 keep-alive 方式,来自同一客户端的连接是可以重复使用的。TCP 套接字的连接操作需要一定的开销,而通过对连接的重复使用,当反复访问同一台服务器时,就可以获得较高的性能。
再有,通过运用事件驱动模型,可以减少每个连接所消耗的资源。综合上述这些优势可以看出,同一客户端对同一服务器进行频繁连接,且连接数非常大的场景,例如网络聊天程序的实现,使用 node.js 是最合适的。
我们对图 3 的回声服务器进行些许改造,就实现了一个简单的聊天服务器(图 7)。这里所做的改造,只是将回声服务器返回输入的字符串的部分,改成了将字符串发送给当前连接的所有客户端。另外,我们还为连接中断时发生的 end 事件设置了一个回调函数,用于将客户端从连接列表中删除。
var net = require("net"); var clients = []; net.createServer(function(sock){ clients.push(sock); sock.on("data", function(data) { for (var i=0; i<clients.length; i++) { clients[i].write(data); } }); sock.on("end", function() { var i = clients.indexOf(sock); clients.splice(i, 1); }); }).listen(8000);图 7 用 node.js 编写的网络聊天程序
通过这样简单的修改,当多个客户端连接到服务器时,从其中任意客户端上输入的信息就可以在所有客户端上显示出来。当然,作为简易聊天程序来说,它还无法显示出某条消息是由谁发送的,因此还不是很实用。但如果能够从连接的套接字获取相关信息的话,修改起来也应该不难。
此外,我们在这里直接使用了 TCP 连接,但只要运用 keep-alive 和 Ajax(Asynchronous JavaScript and XML)技术,要用 HTTP 实现实时聊天(也就是所谓的 COMET)也并非难事。能够轻松开发出可负担如此大量连接的互联网服务器,正是 node.js 这一事件驱动框架的优势所在。
EventMachine 与 Rev
当然,面向 Ruby 的事件驱动框架也是存在的,其中最具代表性的当属 EventMachine 和 Rev。EventMachine 是面向 Ruby 的事件驱动框架中的元老,实际上,在 node.js 的官方介绍中,也用了“像 EventMachine”2这样的说法。之所以在这里没有介绍它们,是因为相比这些框架所提供的“为每个事件启动相应的对象方法”的方式来说,node.js 这样注册回调函数的方式更加容易讲解。
2 “Node is similar in design to and influenced by systems like Ruby's Event Machine or Python's Twisted.”(http://nodejs.org/about/)
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论