转载

[译] Node.js 中的多线程问题

[译] Node.js中的多线程问题

原文地址: http://www.future-processing.pl/blog/on-problems-with-threads-in-node-js/

译者注:最近愚安我入职新公司,准备对Node.js稍作推广,在解释任务队列和底层多线程回调原理时,用到了这篇文章中得栗子,感觉十分巧妙,遂斗胆翻译一下,词不达意,还请见谅。

下文中的所有例子均运行在64位的Ubuntu 14.04下,node.js的版本为 0.12.0

我要讲的这个栗子是关于我在开发node.js应用时遇到的一些多线程问题并且我将告诉你为什么这些问题值得你去关注。请注意,接下来我们将要讲解一点实打实的干货,如果你是你一个node初学者,这篇文章也许并不适合作为一个很好的学习切入点。

“稍等一下up主,你确定你要讲得是node中得多线程?别逗了好么!我们都知道node.js是单线程滴!”。当然,从某种程度上讲,你可能是对的。诚然,所有你写的Javascript代码讲被运行在单一线程里。但是,你写的javascript代码并不是所有node需要解决的问题。这里就不过多解释,我们来直接看代码。

下面这段代码非常短。它三次从当前目录读取内容,不管读到得是什么,程序只是简单地打印出从运行开始到循环中每一个回调函数运行所消耗的时间。

var fs = require('fs);   var util = require('util'); var start = process.hrtime(); for (var i = 0; i < 3; ++i) {    (function (id){   fs.readdir('.', function() {    var end = process.hrtime(start);    console.log(util.format('readdir %d finished in %ds', id, end[0] + end[1]/1e9));   });  })(i) }   

执行代码之后的输出结果如下:

readdir 1 finished in 1.005665125s   readdir 2 finished in 1.036768961s   readdir 0 finished in 1.040409237s   

这里的输出再寻常不过了。每一个循环消耗大致相同的时间执行到回调函数。但是,我们来试一下如果将重复的次数扩大为原来的两倍来看一下会发生什么有意思的事情:

readdir 1 finished in 1.003170344s   readdir 0 finished in 1.052704191s   readdir 2 finished in 1.058100525s   readdir 3 finished in 1.060514229s   readdir 4 finished in 2.003446385s   readdir 5 finished in 2.007682862s   

哦,天哪,这一定是错的吧!最后两轮竟然消耗了另外部分两倍的时间才执行完成。但事实确实如此,我相信这已经激起了你对此的兴趣。别着急,下文我将阐述下为什么会发生这种情况。

但是,首先...

我骗了你

不是很严重,不过我确实骗了你,就一点点。你可能很想知道,在计算机硬件发展如此迅速地今天,在这么现代的PC上,读取一个目录竟然要花费一秒钟之久的时间。谎言就在于此,我故意让一切看起来像现在这样。我事先准备了一个小的共享库,它会将上文中得操作,延迟一秒。

#define _GNU_SOURCE #include <dlfcn.h> #include <unistd.h> #include <dirent.h> int scandir64(const char *dirp,    struct dirent64 ***namelist,  int (*filter)(const struct dirent64 *),  int (*compar)(const struct dirent64 **, const struct dirent64 **)) {   int (*real_scandir)(const char *dirp,          struct dirent64 ***namelist,          int (*filter)(const struct dirent64 *),          int (*compar)(const struct dirent64 **, const struct dirent64 **));   real_scandir = dlsym(RTLD_NEXT, "scandir64");   sleep(1);   return real_scandir(dirp, namelist, filter, compar); }  

这里没有什么花哨的东西,就是简单的在调用实际的 scandir() 之前,sleep了一秒。再将上面代码编译为一个共享库之后,我用它运行了node程序。

$&gt; LD_PRELOAD=./scandir.so node index.js

做这个小魔法的唯一目的就是以最小的修改在代码一致的情况下,让结果显的更加明显。没有它,测试代码二中的差异将很不明显,你很难发现这个问题。

有了这样的方法,我们就可以来实际讲一下这里面的原理。

底层原理小窥

某些类型的操作,诸如访问文件系统或网络,相对于简单地访问RAM需要消耗更多地CPU时间片,尤其是当我们希望组合他们成为一些更大型的功能时,一个简单地例子就是node的 fs.readFile() 方法去读取整个文件的内容。你可以想象,如果这个方法不是一个异步方法,试图用这个方法一次性去读取GB级别的数据时(这本身似乎并不是一个好主意,但这不是我们要关心的重点),我们的应用程序将在一段时间内完全“未响应”,显然,这是无法接受的。

但既然这个方法是异步的,那一切都会正常地运行。当我们在加载文件到内存的同时,我们可以很方便地做其他我们想做的事。当加载文件完成的时候,我们将得到一个非常优雅的回调提醒。但问题是,它到底是怎么做到的呢?以我还算不错的有关Node.js的知识看来,并不是由仙尘或魔法为其提供动力。答案如谜一般,但也已经用标题和引子揭示过——它就是多线程。

Node.js由很多部分构成。今天,我们共同学习的就是其中的一个为node提供异步I/O的库—— libuv 。我们发现,在它的特性列表的底部,有一个特性叫做——线程池。至此,我们的焦点将由javascript转移到神秘的C++大陆。

Libuv库负责维护一个帮助node.js运行一些需要长时间执行的操作(译者注:如访问文件,发送HTTP请求),同时不会阻塞主线程的线程池。事实上,拨开重重包裹,node.js是基于线程实现的,不管你是否愿意去接受,这都是一个事实。

这里的线程池负责提交一个工作请求到队列中。这个工作请求提供:

  • 一个在独立线程中执行的方法
  • 一个包含上述方法所需数据的结构体
  • 一个在当前独立线程中负责收集执行结果的方法

这里的描述有所简化,因为指导运用 libuv编程 ,并不是本文的目的。

一般情况下,在提交工作请求之前,你先要将你的js代码中调用得方法接受到得v8引擎的javascript对象(诸如数字,字符串对象等),转化为其对应的C/C++形式,并将其封装到对应的结构体内。因为V8引擎并不是线程安全的,所以这种在方法外部转化的方式相比在方法内有一定的优越性。当这个方法在独立的线程内执行结束之后,libuv将从包含处理结果的工作请求中调用第二个方法。由于这个方法在主线程中执行返回,所以可以很安全地再次使用V8。在这里,我们将返回的结果封装为V8对象的形式,并且调用原来js代码中的回调函数。

现在我们回到刚刚简要提及过的工作队列。当线程池中至少有一个一个线程是空闲的,那么工作队列中的第一个工作请求将被分配到这个线程。否则,工作请求将等待线程池内的线程完成它们当前的任务。这就清晰地解释了最初的例子中到底是怎么回事。

[译] Node.js 中的多线程问题

* 使用线程池的方法执行流程图 *

libuv的默认线程池大小是4。这也就解释了我们6次调用 fs.readdir 方法时,其中的两个在两秒后执行结束而不是一秒。由于所有线程池内的线程在一整秒内都处于忙碌状态来等待sleep()的调用,在工作队列中得剩余任务必须等待这些忙碌线程中的某一个执行完成,然后完成sleep方法,最终在两秒之后结束。

常规情况下,没有人会故意造成这种延迟,所以这种结果并不会引人注意。但是,在某些情况下,这种现象又明显到值得被关注。确定的时,它可能发生在任何情况下,那么如果发生了,我们该如何解决它呢?

在这里需要指出的是,并不是所以的异步操作都需要通过线程池来处理。这种机制主要运用于:

  • 处理文件系统的相关操作,按照libuv文档的解释,不同操作系统间的异步文件系统访问API有着明显的差距
  • 一些对我们很重要的用户代码(译者注:这里的user code不知道怎么翻)

[译] Node.js 中的多线程问题

* libuv架构 – 源码: http://docs.libuv.org/ *

那么何时它将变得有关系呢?

想象一下,你正在编写一个需要重度使用数据库的应用。比如Oracle,那么你将使用最新的 Oracle官方驱动 。你打算执行大量的查询,所以你决定使用一个连接池,用它来创建最多20个数据库连接。然后你觉得“不错,这样并行运行20个查询应该是绰绰有余了”。但由于上面阐述的原理,你将永远也不会真的得到20个并行查询。正如你所看到的,执行查询将要提交一个工作请求。因为你正在运行着默认线程数量线程池的node.js,你将永远不会有超过4个的并行查询。另外,如果你的数据库很慢,甚至会导致你的部分应用程序无法访问数据库,这可能导致他们无法响应,因为他们的异步任务停留在了开始数据库查询的工作队列之后。

当然,对于此,我们并不一定素手无策。比如,我们可以降低数据库连接池的大小。但很明显,我们并不希望这样做。幸运的是,这里还有另一种解决方案——利用 UV_THREADPOOL_SIZE 环境变量。通过改变它的值,我们可以改变线程池中得线程数量。你可以选择1到128之间的任意整数。让我们用一段简单地代码试一试:

$> UV_THREADPOOL_SIZE=5 LD_PRELOAD=./scandir.so node index.js readdir 2 finished in 1.005758445s   readdir 0 finished in 1.046712749s   readdir 3 finished in 1.056222923s   readdir 1 finished in 1.057267272s   readdir 4 finished in 1.05897112s   readdir 5 finished in 2.007336631s   

正如你所看到的,这个设置是有作用的。现在,仅仅有一个工作请求必须要去等待空闲线程。除了上面这种命令行形式,你还可以在node.js程序代码中改变这个值,具体做法是对 process.env.UV_THREADPOOL_SIZE 变量赋值。但是,请注意,这种做法是有一点局限性的。一旦你的线程池已经创建并开始工作,再去改变这个值就没有任何作用的。也就是说,必须在你的第一个需要用到线程池的工作请求被提交之前,改变这个值才是有效的。(译者注:这里有一个node在设计上引出的问题,线程池只有在要被用到的时候才创建,并不是程序启动就创建,你可以观察下一个有线程池的简单程序和一个没有线程池的简单程序,两者在内存占用上有多少差距,愚安这里就不贴测试代码了。)

var fs = require('fs');  process.env.UV_THREADPOOL_SIZE = 10; // This will work  // First time thread pool is required fs.readdir('.', function () {     process.env.UV_THREADPOOL_SIZE = 20; // This won't    fs.readdir('.', function () {    }); }); 

好了,这里仅仅给了你一些面对这种由实际应用中可能引发的关于线程池使用的这种问题的想法。这里有一个我曾经解决的不同寻常的相关问题的一个例子。

我们的应用需求要求我们有终止当前正在执行的查询的能力。这个功能实际实施起来是非常简单的,仅仅是对已有的一个C++模块进行扩展。经过初步的测试,一切运行良好,这项需求也被标记为“已完成”。然而,没过多久,我们就收到一份表明这个程序偶尔也出一些小篓子的报告。

避开这个漫长调查的细节不谈,这个问题的解释其实是极其简单的。既然我们允许多个查询并行执行,那么就一定会经常碰到四个线程都处于繁忙状态而必须等到这四个查询执行结束的情况。而且因为我们是用异步函数的方式实现的终止查询功能,此时,终止查询的请求就必须耐心地在任务队列中等待。最终的结果就是,有时我们无法终止查询因为要等到某个特定的查询执行结束。(译者注:有点悖论的味道)。

值得一提的是,事实上,我写这篇文章的原因在于在解决上述问题过程中我所收集的知识并不常见,值得拿出来与各位分享。

我希望这篇文章让你有足够的观察力去发现应用程序中的由上述代码引起的问题。如果你觉得我已经走出泥潭或想多了解一些关于我所提到的话题,欢迎在下面评论里留言。

正文到此结束
Loading...