详解回调函数——以JS为例解读异步、回调和EventLoop

转自:http://blog.csdn.net/tywinstark/article/details/48447135#comments

很多人在问什么是回调?百度出来的答案基本都不正确,看了只会让人更加迷惑。下文试着用尽量简单的例子帮大家梳理清楚,因为回调并不是一句话下定义就能明白的概念,需要用一段文字像讲故事一样来说明,回调如同很多重要的计算机概念一样,它是有历史文化的,你需要知道它从哪里来,用来干什么,才能理解及在实际生产中应用。

回调,是非常基本的概念,尤其在现今NodeJS诞生与蓬勃发展中变得更加被人们重视。很多朋友学NodeJS,学很久一直摸不着门道,觉得最后在用Express写Web程序,有这样的感觉只能说明没有学懂NodeJS,本质上说不理解回调,就不理解NodeJS。

NodeJS有三大核心: 
CallBack回调 
Event事件 
Stream流

先来看什么不叫回调,下面是很多网友误认为的回调:

 1 /代码示例1
 2 //Foo函数意在接收两个参数,任意类型a,和函数类型cb,在结尾要调用cb()
 3 function Foo(a, cb){
 4     console.log(a);
 5     // do something else
 6     // Maybe get some parameters for cb
 7     var param = Math.random();
 8     cb(param);
 9 }
10 //定义一个叫CallBack的函数,将作为参数传给Foo
11 var CallBack = function(num){
12     console.log(num);
13 }
14 //调用Foo
15 Foo(2, CallBack);

以上代码不是回调,以下指出这里哪些概念容易混淆: 
变量CallBack,被赋值为一个匿名函数,但是不因为它名字叫CallBack,就称知为回调 
Foo函数的第二个形式参数名为cb,同理叫cb,和是不是回调没关系 
cb在Foo函数代码最后被以cb(param)的形式调用,不因为cb在另一个函数中被调用,而将其称之为回调

直白来讲,以上代码就是普通的函数调用,唯一特殊一点的地方是,因为JS有函数式语言的特性,可以接收函数作为参数。在C语言里可以用指向函数的指针来达到类似效果。

讲到这里先停一下,大家注意到本文的标题是解读异步、回调和EventLoop,回调之前还有异步呢,这个顺序对于理解很有帮助,可以说理解回调的前提,是理解异步。

说到异步,什么是异步呢?和分布、并行有什么区别?

回归原始,追根溯源是我们学习编程的好方法,不去想有什么高级的工具和概念,而去想如果我们只有一个浏览器做编译器和一个记事本,用plain JS写一段异步代码,怎么写?不能用事件系统,不能用浏览器特性。

小明:刚才上面那段代码是异步的吗? 
老袁:当然不是,即便把Foo改为AsyncFoo也不是。这里比较迷惑的是cb(param)是在Foo函数的最后被调用的。 
小明:好像觉得异步的代码,确实应该在最后调一个callback函数,因为之后的代码不会被执行到了。 
老袁:异步的一个定义是函数调用不返回原来代码调用处,而cb(params)调用完后,依旧返回到Foo的尾部,即便cb(params)后还有代码,它们也可以被执行到,这是个同步调用。

Plain JS 异步的写法有很多,以经典的为例:

 1 //代码示例2
 2 // ====同步的加法
 3 function Add(a, b){
 4     return a+b;
 5 }
 6 Add(1, 2) // => 3
 7
 8 // ====异步的加法
 9 function LazyAdd(a){
10     return function(b){
11         return a+b;
12     }
13 }
14 var result = LazyAdd(1); // result等于一个匿名函数,实际是闭包
15 //我们的目的是做一个加法,result中保存了加法的一部分,即第一个参数和之后的运算规则,
16 //通过返回一个持有外层参数a的匿名函数构成的闭包保存至变量result中,这部是异步的关键。
17 //极端的情况var result = LazyAdd(1)(2);这种极端情况又不属于异步了,它和同步没有区别。
18
19 // 现在可以写一些别的代码了
20     console.log(‘wait some time, doing some fun‘);
21 // 实际生产中不会这么简单,它可能在等待一些条件成立,再去执行另外一半。
22
23 result = result(2) // => 3

上述代码展示了,最简单的异步。我们要强调的事,异步是异步,回调是回调,他俩半毛钱关系都没有。

Ok,下面把代码改一改,看什么叫回调:

 1 //代码示例3
 2 //注意还是那个Add,精髓也在这里,随后说到
 3 function Add(a, b){
 4     return a+b;
 5 }
 6 //LazyAdd改变了,多了一个参数cb
 7 function LazyAdd(a, cb){
 8     return function(b){
 9         cb(a, b);
10     }
11 }
12 //将Add传给形参cb
13 var result = LazyAdd(1, Add)
14 // doing something else
15 result = result(2); // => 3

这段代码,看似简单,实则并不平凡。

小明:这代码给人的第一感觉就是脱裤子放屁,明明一个a+b,先是变成异步的写法就多了很多代码,人都看不懂了,现在的这个加了所谓的“回调”,更啰嗦了,最后得到的结果都是1+2=3,尼玛这不有病吗? 
老袁:你只看到了结果,却不知道为什么人家这么写,这样写为了什么。代码示例2和3中,同样的Add函数,作为参数传到LazyAdd中,此时它是回调。那为什么代码示例1中,Foo中传入的cb不是回调呢?要仔细体会这句话,需要带状态的才叫回调函数,own state,这里通过闭包保存的a就是状态。 
小明:我伙呆 
老袁:现在再说为什么要有回调,单看输出结果,回调除了啰嗦和难于理解之外没有任何意义。但是!!!

现在说吧,CallBack的好处是:保证API不撕裂 
也就是说,异步是很有需求的,处理的好能使计算效率提高,不至于卡在某处一直等待。但是异步的写法,我们看到了非常难看,把一个加法变成异步,都如此难看,何况其他。那么CallBack的妙处就是“保证API不撕裂”,代码中写到的精髓所在,还是那个Add,对,让程序员在写异步程序的时候,还能够像同步写法那样好理解,Add作为CallBack传入,保证的是Add这个方法好理解,作为API设计中的重要一个环节,保证开发者用起来方便,代码可读性高。

以NodeJS的readFile API为例进一步说明: 
fs.readFile(filename, [options], callback) 
有两个必填的参数filename和callback 
callback是实际程序员要写代码的地方,写它的时候假设文件已经读取到了,该怎么写还怎么写,是API历史上的一次大进步。

//读取文件‘etc/passwd‘,读取完成后将返回值,传入function(err, data) 这个回调函数。
fs.readFile(‘/etc/passwd‘, function (err, data) {
  if (err) throw err;
  console.log(data);
});

回调和闭包有一个共同的特性:在最终“回调 ”调用以前,前面所有的状态都得存着。

这段代码对于人们的疑惑常常是,我怎么知道callback要接收几个参数,参数的类型是什么? 
:是API提供者事先设计好的,它需要在文档中说明callback接收什么参数。

如代码3展示的那样,API设计者通过种种技巧,实现了回调的形式,这种种技巧写起来很痛苦。而fs.readFile看起来写的很轻巧,这是因为它不光包含异步、回调,还引入的新的概念EventLoop。

EventLoop是很早前就有的概念,如MFC中的消息循环,浏览器中的事件机制等等。

那为什么要有EventLoop,它的目的是什么呢?

我们用一个简单的伪示例,看没有EventLoop时是怎么工作:

 1 //代码示例4
 2 function Add(a, b){
 3     return a+b;
 4 }
 5
 6 function LazyAdd(a, cb){
 7     return function(b){
 8         cb(a, b);
 9     }
10 }
11
12 var result = LazyAdd(1, Add)
13 // 假设有一个变量button为false,我们继续调用result的条件是,当button为true的时候。
14 var button = false;
15
16 // 常用的办法是观察者模式,派一个人不断的看button的值,
17 //只要变了就开始执行result(2), 当然得有别人去改变button的值,
18 //这里假设有人有这个能力,比如起了另外一个线程去做。
19 while(true){
20     if(button){
21         result = result(2);
22         break;
23     }
24 }
25
26 result = result(2); // => 3

所以如果有很多这样的函数,每一个都要跑一个观察者模式,在一定条件下看上去比较费计算。这时EventLoop诞生了,派一个人来轮询所有的,其他人都可以把观察条件和回调函数注册在EventLoop上,它进行统一的轮询,注册的人越多,轮询一圈的时间越长。但是简化了编程,不用每个人都写轮询了,提供API变得方便,就像fs.readFile一样简单明白,fs.readFile读取文件’/etc/passwd’,将其注册到EventLoop上,当文件读取完毕的时候,EventLoop通过轮询感知到它,并调用readFile注册时带的回调函数,这里是funtion(err, data)

换一个说法再说一遍:在特定条件下,单台机器上用空间换计算。原本callback执行了就不等了,存在一个地方,其他依赖它的,用观察着模式一直盯着它,各自轮询各自的。现在有人出来替大家统一轮询。

再换一个说法说一遍,重要的事情,换着方法说3遍:在单台机器上,统一轮询看上去比较省,也带来了很多问题,比如NodeJS中单线程情况下,如果一个函数计算量非常复杂,会阻碍所有其他的事件,所以这种情况要将复杂计算交给其他线程或者是服务来做。 
我们一直在强调单台机器,如果是多台,用一个统一的人来轮询,就比较恐怖了,大家把事件都注册到一台机器上,它负责轮询所有的,这个namenode就容易崩溃。所以在多台机器上,又适合,每天机器各自轮询各自的,带来的问题是状态不一致了。好的,这才是程序有意思的地方,我们需要知道为什么发明EventLoop,也需要知道EventLoop在什么地方遇到问题。那些天才的程序员,又提出了各种一致性算法来解决这个问题,本文暂不讨论。

到目前为止,我们梳理了他们之间的关系: 
异步 –> 回调 –> EventLoop 
每一次进步都是上一个台阶,都需要智慧来解决。

回调还产生了很多问题,最严重的问题是callback hell回调地狱。

1 fs.readFile(‘/etc/password‘, function(err, data){
2     // do something
3     fs.readFile(‘xxxx‘, function(err, data){
4         //do something
5             fs.readFile(‘xxxxx‘, function(err, data){
6             // do something
7         })
8     })
9 })

这个例子可能不恰当,但也能理解,在类似这种情况会出现一层套一层的代码,可读性、维护性差。

在ES6 里面给出了Generator,来解决异步编程的问题,我们有空接着讨论。

时间: 2024-10-16 18:38:55

详解回调函数——以JS为例解读异步、回调和EventLoop的相关文章

ViewPager 详解(二)---详解四大函数

前言:上篇中我们讲解了如何快速实现了一个滑动页面,但问题在于,PageAdapter必须要重写的四个函数,它们都各有什么意义,在上节的函数内部为什么要这么实现,下面我们就结合Android的API说明,详细讲解一下. 相关文章: 1.<ViewPager 详解(一)---基本入门> 2.<ViewPager 详解(二)---详解四大函数> 3.<ViewPager 详解(三)---PagerTabStrip与PagerTitleStrip添加标题栏的异同> 4.<

详解JMeter函数和变量(转载)

详解JMeter函数和变量(1) JMeter函数可以被认为是某种特殊的变量,它们可以被采样器或者其他测试元件所引用.函数调用的语法如下: ${__functionName(var1,var2,var3)} 其中,__functionName匹配被调用的函数名称.用圆括号包含函数的形参,例如${__time(YMD)},不同函数要求的参数也不同.有些JMeter函数不要求参数,则可以不使用圆括号,例如${__threadNum}. 如果一个函数的参数中包含逗号,那么必须对逗号进行转义(使用"\&

详解JMeter函数和变量

详解JMeter函数和变量(1) JMeter函数可以被认为是某种特殊的变量,它们可以被采样器或者其他测试元件所引用.函数调用的语法如下: ${__functionName(var1,var2,var3)} 其中,__functionName匹配被调用的函数名称.用圆括号包含函数的形参,例如${__time(YMD)},不同函数要求的参数也不同.有些JMeter函数不要求参数,则可以不使用圆括号,例如${__threadNum}. 如果一个函数的参数中包含逗号,那么必须对逗号进行转义(使用"\&

PHP的ob_start函数用法详解[php函数]

用PHP的ob_start();控制您的浏览器cache Output Control 函数可以让你自由控制脚本中数据的输出.它非常地有用,特别是对于:当你想在数据已经输出后,再输出文件头的情况.输出控制函数不对使用 header() 或 setcookie(), 发送的文件头信息产生影响,只对那些类似于 echo() 和 PHP 代码的数据块有作用. 我们先举一个简单的例子,让大家对Output Control有一个大致的印象: Example 1. CODE<?php ob_start();

Net Core中数据库事务隔离详解——以Dapper和Mysql为例

Net Core中数据库事务隔离详解--以Dapper和Mysql为例 事务隔离级别 准备工作 Read uncommitted 读未提交 Read committed 读取提交内容 Repeatable read (可重读) Serializable 序列化 总结 事务隔离级别 .NET Core中的IDbConnection接口提供了BeginTransaction方法作为执行事务,BeginTransaction方法提供了两个重载,一个不需要参数BeginTransaction()默认事务

Java网络编程和NIO详解5:Java 非阻塞 IO 和异步 IO

Java网络编程和NIO详解5:Java 非阻塞 IO 和异步 IO Java 非阻塞 IO 和异步 IO 转自https://www.javadoop.com/post/nio-and-aio 本系列文章首发于我的个人博客:https://h2pl.github.io/ 欢迎阅览我的CSDN专栏:Java网络编程和NIO https://blog.csdn.net/column/details/21963.html 部分代码会放在我的的Github:https://github.com/h2p

C++学习45 流成员函数put输出单个字符 cin输入流详解 get()函数读入一个字符

在程序中一般用cout和插入运算符“<<”实现输出,cout流在内存中有相应的缓冲区.有时用户还有特殊的输出要求,例如只输出一个字符.ostream类除了提供上面介绍过的用于格式控制的成员函数外,还提供了专用于输出单个字符的成员函数put.如:    cout.put('a');调用该函数的结果是在屏幕上显示一个字符a.put函数的参数可以是字符或字符的ASCII代码(也可以是一个整型表达式).如    cout.put(65 + 32);也显示字符a,因为97是字符a的ASCII代码. 可以

Knowledge Point 20180303 详解main函数

学习Java的朋友想来都是从HelloWorld学起的,那么想来都对main函数不陌生了,但是main函数究竟是怎么回事呢?main函数中的参数是做什么的呢?main函数为什么能作为程序的入口呢?可不可以存在多个main函数呢?下面我们就来对main函数进行一些深入的了解. 1.Java中的main()方法详解 在Java中,main()方法是Java应用程序的入口方法,也就是说,程序在运行的时候,第一个执行的方法就是main()方法,这个方法和其他的方法有很大的不同,比如方法的名字必须是mai

Node.js详解整理:node.js的优势、特点、优缺点及适用场景,安装及基本使用

目录 Node.js基础 一.Node.js介绍 二.node.js的优势 三.NodeJS的特点 四.NodeJS带来的对系统瓶颈的解决方案 五.NodeJS的优缺点 六.适合NodeJS的场景 Node.js的安装及基本使用 Node.js 安装配置 第一个Node.js程序:Hello World! Node.js 创建第一个应用 express的使用(待整理) koa2的使用(待整理) Node.js基础 一.Node.js介绍 Node.js是一个javascript运行环境.它让ja