3月阅读周·你不知道的JavaScript | 异步编程,桥接程序运行的现在与未来

举报
叶一一 发表于 2024/03/26 09:47:59 2024/03/26
【摘要】 背景去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。没有计划的阅读,收效甚微。新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。这个“玩法”虽然常见且板正,但是有效,已经坚持阅读两个月。《你不知道的JavaScript》分上中下三卷,内容相对较多。3月份,我计划先读前面两卷。已读完书籍:《架构简洁之道》、《深入浅出的...

背景

去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。

没有计划的阅读,收效甚微。

新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。

这个“玩法”虽然常见且板正,但是有效,已经坚持阅读两个月。

《你不知道的JavaScript》分上中下三卷,内容相对较多。3月份,我计划先读前面两卷。

已读完书籍《架构简洁之道》、《深入浅出的Node.js》

当前阅读周书籍《你不知道的JavaScript(上卷)》、《你不知道的JavaScript(中卷)》

异步编程

异步

分块的程序

可以把JavaScript程序写在单个.js文件中,但是这个程序几乎一定是由多个块构成的。这些块中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。

从现在到将来的“等待”,最简单的方法是使用一个通常称为回调函数的函数:

// ajax(..)是某个库中提供的某个Ajax函数
ajax('http://some.url.1', function myCallbackFunction(data) {
  console.log(data); // 耶!这里得到了一些数据!
});

任何时候,只要把一段代码包装成一个函数,并指定它在响应某个事件(定时器、鼠标点击、Ajax响应等)时执行,你就是在代码中创建了一个将来执行的块,也由此在这个程序中引入了异步机制。

事件循环

程序通常分成了很多小块,在事件循环队列中一个接一个地执行。

下面是一段伪代码:

// eventLoop是一个用作队列的数组
//(先进,先出)
var eventLoop = [];
var event;

// “永远”执行
while (true) {
  // 一次tick
  if (eventLoop.length > 0) {
    // 拿到队列中的下一个事件
    event = eventLoop.shift();

    // 现在,执行下一个事件
    try {
      event();
    } catch (err) {
      reportError(err);
    }
  }
}

有一个用while循环实现的持续运行的循环,循环的每一轮称为一个tick。对每个tick而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是回调函数。

setTimeout(..)并没有把你的回调函数挂在事件循环队列中。它所做的是设定一个定时器。当定时器到时后,环境会把回调函数放在事件循环中,这样,在未来某个时刻的tick会摘下并执行这个回调。

并行线程

事件循环把自身的工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改。通过分立线程中彼此合作的事件循环,并行和顺序执行可以共存。

并行线程的交替执行和异步事件的交替调度,其粒度是完全不同的。

function later() {
  answer = answer * 2;
  console.log('Meaning of life:', answer);
}

考虑到这段代码是运行在一个线程中,实际上可能有很多个不同的底层运算。比如,answer =answer * 2需要先加载answer的当前值,然后把2放到某处并执行乘法,取得结果之后保存回answer中。

在单线程环境中,线程队列中的这些项目是底层运算确实是无所谓的,因为线程本身不会被中断。但如果是在并行系统中,同一个程序中可能有两个不同的线程在运转,这时很可能就会得到不确定的结果。

var a = 20;

function foo() {
  a = a + 1;
}

function bar() {
  a = a * 2;
}

// ajax(..)是某个库中提供的某个Ajax函数
ajax('http://some.url.1', foo);
ajax('http://some.url.2', bar);

上面的代码,根据JavaScript的单线程运行特性,如果foo()运行在bar()之前,a的结果是42,而如果bar()运行在foo()之前的话,a的结果就是41。

并发

两个或多个“进程”同时执行就出现了并发,不管组成它们的单个运算是否并行执行(在独立的处理器或处理器核心上同时运行)。可以把并发看作“进程”级(或者任务级)的并行,与运算级的并行(不同处理器上的线程)相对。

1、非交互

两个或多个“进程”在同一个程序内并发地交替运行它们的步骤/事件时,如果这些任务彼此不相关,就不一定需要交互。如果进程间没有相互影响的话,不确定性是完全可以接受的。

var res = {};

function foo(results) {
  res.foo = results;
}

function bar(results) {
  res.bar = results;
}

// ajax(..)是某个库提供的某个Ajax函数
ajax('http://some.url.1', foo);
ajax('http://some.url.2', bar);

foo()和bar()是两个并发执行的“进程”,按照什么顺序执行是不确定的。

2、交互

并发的“进程”需要相互交流,通过作用域或DOM间接交互。正如前面介绍的,如果出现这样的交互,就需要对它们的交互进行协调以避免竞态的出现。

可以协调交互顺序来处理这样的竞态条件:

var res = [];

function response(data) {
  if (data.url == 'http://some.url.1') {
    res[0] = data;
  } else if (data.url == 'http://some.url.2') {
    res[1] = data;
  }
}

// ajax(..)是某个库中提供的某个Ajax函数
ajax('http://some.url.1', response);
ajax('http://some.url.2', response);

3、协作

还有一种并发合作方式,称为并发协作。目标是取到一个长期运行的“进程”,并将其分割成多个步骤或多批任务,使得其他并发“进程”有机会将自己的运算插入到事件循环队列中交替运行。

举例来说,考虑一个需要遍历很长的结果列表进行值转换的Ajax响应处理函数。

var res = [];

// response(..)从Ajax调用中取得结果数组
function response(data) {
  // 一次处理1000个
  var chunk = data.splice(0, 1000);

  // 添加到已有的res组
  res = res.concat(
    // 创建一个新的数组把chunk中所有值加倍
    chunk.map(function (val) {
      return val * 2;
    }),
  );

  // 还有剩下的需要处理吗?
  if (data.length > 0) {
    // 异步调度下一次批处理
    setTimeout(function () {
      response(data);
    }, 0);
  }
}

// ajax(..)是某个库中提供的某个Ajax函数
ajax('http://some.url.1', response);
ajax('http://some.url.2', response);

我们把数据集合放在最多包含1000条项目的块中。这样,我们就确保了“进程”运行时间会很短,即使这意味着需要更多的后续“进程”,因为事件循环队列的交替运行会提高站点/App的响应(性能)。

语句顺序

JavaScript引擎在编译这段代码之后可能会发现通过(安全地)重新安排这些语句的顺序有可能提高执行速度。重点是,只要这个重新排序是不可见的,一切都没问题。

比如,引擎可能会发现,其实这样执行会更快:

var a, b;

a = 10;
a++;

b = 30;
b++;

console.log( a + b ); // 42

JavaScript引擎在编译期间执行的都是安全的优化,最后可见的结果都是一样的。

但是这里有一种场景,其中特定的优化是不安全的,因此也是不允许的(当然,不用说这其实也根本不能称为优化):

var a, b;

a = 10;
b = 30;

// 我们需要a和b处于递增之前的状态!
console.log( a * b ); // 300

a = a + 1;
b = b + 1;

console.log( a + b ); // 42

回调

回调是编写和处理JavaScript程序异步逻辑的最常用方式。

continuation

回调函数包裹或者说封装了程序的延续(continuation)。

// A
ajax( "..", function(..){
    // C
} );
// B

// A和// B表示程序的前半部分(也就是现在的部分),而// C标识了程序的后半部分(也就是将来的部分)。前半部分立刻执行,然后是一段时间不确定的停顿。在未来的某个时刻,如果Ajax调用完成,程序就会从停下的位置继续执行后半部分。

顺序

listen('click', function handler(evt) {
  setTimeout(function request() {
    ajax('http://some.url.1', function response(text) {
      if (text == 'hello') {
        handler();
      } else if (text == 'world') {
        request();
      }
    });
  }, 500);
});

这段代码得到了三个函数嵌套在一起构成的链,其中每个函数代表异步序列(任务,“进程”)中的一个步骤。

这种代码常常被称为回调地狱(callback hell),有时也被称为毁灭金字塔(pyramid of doom,得名于嵌套缩进产生的横向三角形状)。

回调方式最主要的缺陷:对于它们在代码中表达异步的方式,我们的大脑需要努力才能同步得上。

尝试挽救回调

有一种常见的回调模式叫作“error-first风格”(有时候也称为“Node风格”,因为几乎所有Node.js API都采用这种风格),其中回调的第一个参数保留用作错误对象(如果有的话)。如果成功的话,这个参数就会被清空/置假(后续的参数就是成功数据)。不过,如果产生了错误结果,那么第一个参数就会被置起/置真(通常就不会再传递其他结果):

function response(err, data) {
  // 出错?
  if (err) {
    console.error(err);
  }
  // 否则认为成功
  else {
    console.log(data);
  }
}

ajax('http://some.url.1', response);

这并没有涉及阻止或过滤不想要的重复调用回调的问题。

总结

我们来总结一下本篇的主要内容:

  • JavaScript程序总是至少分为两个块:第一块现在运行;下一块将来运行,以响应某个事件。尽管程序是一块一块执行的,但是所有这些块共享对程序作用域和状态的访问,所以对状态的修改都是在之前累积的修改之上进行的。
  • 一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个tick。用户交互、IO和定时器会向事件队列中加入事件。
  • 并发是指两个或多个事件链随时间发展交替执行,以至于从更高的层次来看,就像是同时在运行(尽管在任意时刻只处理一个事件)。
  • 回调函数是JavaScript异步的基本单元。但是随着JavaScript越来越成熟,对于异步编程领域的发展,回调已经不够用了。

作者介绍
非职业「传道授业解惑」的开发者叶一一。
《趣学前端》、《CSS畅想》等系列作者。华夏美食、国漫、古风重度爱好者,刑侦、无限流小说初级玩家。
如果看完文章有所收获,欢迎点赞👍 | 收藏️ | 留言📝

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。