关于JavaScript并发、竞态场景下的一些思考和解决方案

前言

时间是程序里最复杂的因素javascript

编写 Web 应用的时候,通常来讲,咱们大多时候处理的都是同步的、线性的业务逻辑。可是正如开篇所说的“时间是程序里最复杂的因素”,应用一旦复杂,每每会遭遇不少异步问题,若是代码中涉及到到多个异步的时候,这时候就须要慎重考虑了,咱们须要的意识到的是:html

到底咱们的异步逻辑是易读的么?可维护的么?哪些是并发场景,哪些是竞态场景,咱们有什么对策么?注意提提神!如下全程须要集中精神思考 🤔前端

解决问题以前

在抛出具体的解决问题的技术方案以前。首先探讨一下咱们常见的请求会遇到的问题。java

请求时序问题

通常而言,在前端而言咱们常常遇到的异步场景,是请求问题。(固然对应到后端,有多是各类 IO 操做,好比读写文件、操做数据库等)。ios

那笔者为什么谈到请求,由于大多人都会忽略此类问题。咱们每每有时候会发出多个同类型的请求(不必定符合咱们意愿),可是往往以为本身的应用十分健壮,实际上若是没有小心控制“野兽”的话,实际上应用也会至关脆弱!git

以下图,应用依照 A1 -> A2 -> A3 顺序发起请求,咱们也指望的是 A1 -> A2 -> A3 的顺序返回响应给应用。es6

但实际上呢。可是每一个请求都是十分野性的。咱们根本没法把控它哪时候回来!请求的响应顺序极大程度依赖用户的网络环境。好比上图的响应顺序实际上就是 A3 -> A1 -> A2,此时应用将有几率会变得一团糟!github

不过也不用担忧,实际上,一旦当你注意问题的时候,其实就离解决问题不远了。ajax

那么咱们常见的作法会有什么呢?数据库

结束标记

经过应用中的标记状态,在需求请求完成后,标记成功,忽略多余请求,能够巧妙避开请求竞态的陷阱。因为此写法比较常见,再也不赘述。

队列化

将请求串行!某些特殊场景下可使用。在时间线上将多个异步拍平成一条线。野兽请求们依序进入队列(至关于咱们给请求们拉起了缰绳,划好了奔跑的道路),以下图:

只有当 A1 请求响应时,才进行 A2 请求,A2 响应成功时,进行 A3 请求。同理以此类推。(注意虽然请求的顺序强行被修改成串行,但并不意味这发起请求的动做也是串行)。所以在从时间维度上大大简化了场景,极大的减小了 bug 的发生几率。

缺点也很明显,请求串行后阻塞了,某些场景下也许作了不少无用功。

取消请求 + 最新

有同窗们就会以为,效率是否略显低下,既然咱们前面的请求虽然依序生效了,可是最终很快都会被最新的请求结果所替换,那么还作那么多无用功干吗?是的,的确不该当这么作!如图:

凡有新的请求产生,取消上一个还在路上的请求(原生的 XMLHttpRequest.abort()、axios 的 cancelToken),而后只取最新的一个请求,静静等待它的响应。好比 redux-saga 中 takeLatest。

(可是请同窗们注意,若是须要每个请求都对服务器产生效果,好比 POST 请求等,有时候队列也不失为一个好的解决方式)

问题以及背景

上文其实算是一个引子,接下来我将并发竞态的问题抽象简化为如下代码,请看:

// 模拟了一个 ajax 请求函数,对于每个请求有一个随机延时
function ajax(url, cb) {
  let fake_responses = {
    file1: "The first text",
    file2: "The middle text",
    file3: "The last text"
  };
  let wait = (Math.round(Math.random() * 1e4) % 8000) + 1000;

  console.log("Requesting: " + url + `, time cost: ${wait} ms`);

  setTimeout(() => {
    cb(fake_responses[url]);
  }, wait);
}

function output(text) {
  console.log(text);
}
复制代码

那么如何实现一个 getFile 函数,使得能够并行请求,而后依照请求顺序打印响应的值,最终异步完成后打印完成。(注意,此处考虑并发场景)

getFile("file1");
getFile("file2");
getFile("file3");
复制代码

指望结果:

Requesting: file1, time cost: 8233 ms
Requesting: file2, time cost: 2581 ms
Requesting: file3, time cost: 7334 ms
The first text
The middle text
The last text
Complete!
get files total time: 8247.093ms
复制代码

下文将和你们介绍从编写实现上如何解决并发竞态的问题的几种方案!

解决方案:Thunks

什么是 Thunk

Thunk 这个词是起源于“思考”的幽默过去式的意思。它本质上就是一个延迟执行计算的函数。好比下述:

// 对于下述 1 + 2 计算是即时的
// x === 3
let x = 1 + 2;

// 1 + 2 的计算是延迟的
// 函数 foo 能够稍后调用进行值的计算
// 因此函数 foo 就是一个 thunk
let foo = () => 1 + 2;
复制代码

那么咱们来实现一个 getFile 函数以下:

function getFile(file) {
  let resp;

  ajax(file, text => {
    if (resp) resp(text);
    else resp = text;
  });

  return function thunk(cb) {
    if (resp) cb(resp);
    else resp = cb;
  };
}
复制代码

注意咱们如上有一个颇有趣的实现,实际上在调用 getFile 函数的时候,内部就已经发生了 ajax 请求(所以请求并无被阻塞),可是真正返回响应的逻辑放在了 thunk 中。

所以,业务逻辑以下:

let thunk1 = getFile("file1");
let thunk2 = getFile("file2");
let thunk3 = getFile("file3");

thunk1(text => {
  output(text);
  thunk2(text => {
    output(text);
    thunk3(text => {
      output(text);
      output("Complete!");
    });
  });
});
复制代码

调用后,很好实现了咱们的需求!可是!可是同窗们也发现了,仍是不免陷入了回调地狱,写法仍是很差维护,换而言之,仍是不够优雅~

嗯...有什么办法呢?

中间件

近几年,中间件的思想和使用十分流行,或者咱们能够尝试使用中间件方式实现一下?

首先咱们写一个简单的 compose 函数以下(固然此场景下咱们并不关注中间件的上下文,所以简化其实现):

function compose(...mdws) {
  return () => {
    function next() {
      const mdw = mdws.shift();
      mdw && mdw(next);
    }
    mdws.shift()(next);
  };
}
复制代码

那咱们的 getFile 函数实现也得稍微改一下,让返回的 thunk 函数能够交由中间件的 next 控制:

function getFileMiddleware(file, cb) {
  let resp;

  ajax(file, function(text) {
    if (!resp) resp = text;
    else resp(text);
  });

  return next => {
    const _next = args => {
      cb && cb(args);
      next(args);
    };
    if (resp) {
      _next(resp);
    } else {
      resp = _next;
    }
  };
}
复制代码

基于上述两个实现。咱们最终的写法能够修改成如下形式:

const middlewares = [
  getFileMiddleware("file1", output),
  getFileMiddleware("file2", output),
  getFileMiddleware("file3", resp => {
    output(resp);
    output("Complete!");
  })
];

compose(...middlewares)();
复制代码

最终输出结果仍然知足咱们对并发控制的需求!可是写法上优雅了很多!篇幅有限,就不贴上结果了,同窗们可验证一下~

解决方案:Promises

到目前为止。咱们都没有好好利用 JavaScript 送给咱们的礼物“Promise”。Promise 是一个对将来的值的容器。利用 Promise 也能很好的完成咱们的需求。

以下,实现 getFile 函数:

function getFile(file) {
  return new Promise(function(resolve) {
    ajax(file, resolve);
  });
}
复制代码

来来来,调用一下

const p1 = getFile("file1");
const p2 = getFile("file2");
const p3 = getFile("file3");

p1.then(t1 => {
  output(t1);
  p2.then(t2 => {
    output(t2);
    p3.then(t3 => {
      output(t3);
      output("Complete!");
    });
  });
});
复制代码

同样知足,可是?咱们又陷入了 Promise 地狱...

对 Promise 地狱 Say NO

若是写出了上述的 Promise 地狱,证实对 Promise 的了解还不够,事实上也背离了 Promise 的设计初衷。咱们能够改成下述写法:

const p1 = getFile("file1");
const p2 = getFile("file2");
const p3 = getFile("file3");
const constant = v => () => v;

p1.then(output)
  .then(constant(p2))
  .then(output)
  .then(constant(p3))
  .then(output)
  .then(() => {
    output("Complete!");
  });
复制代码

嗯哼~又更加优雅了点。Promise 地狱不见啦~

更加函数式的 Promise 方式

首先我要认可。我如今是,将来也是函数式编程的忠实拥护者。所以上述写法虽然减小了嵌套,可是仍是以为略显无聊,若是有一百个文件等待请求,难道咱们还有手写一百个 getFile,还有数不清的 then 么?

问题来了,如何再一步改进呢?咱们好好思考一下。

首先他们是一个重复的事情,既然重复那就能够抽象,在加上咱们函数式工具 reduce 方法,改进以下:

const urls = ["file1", "file2", "file3"];
const getFilePromises = urls.map(getFile);
const constant = v => () => v;

getFilePromises
  .concat(Promise.resolve("Complete!"), Promise.resolve())
  .reduce((chain, filePromise) => {
    return chain.then(output).then(constant(filePromise));
  });
复制代码

问题解决,而且优雅~(同窗们可能留意到我 concat 了一个 Promise.resolve,是由于此处 reduce 中总须要下个 Promise 承接上一个的值进行执行,细节实现问题,无需介意)。

解决方案:Generators

Generator 是状态机的一种语法形式。

ES6 中还有一个解决异步问题的新朋友 generator。同理咱们来用 generator 来实现需求。这里咱们使用 co 来简化 generator 的调用。

const co = require("co");

function getFile(file) {
  return new Promise(function(resolve) {
    ajax(file, resolve);
  });
}

function* loadFiles() {
  const p1 = getFile("file1");
  const p2 = getFile("file2");
  const p3 = getFile("file3");

  output(yield p1);
  output(yield p2);
  output(yield p3);

  output("Complete!");
}

co(loadFiles);
复制代码

同样的完成了需求,咱们又多了一种解决问题的思路对吧~ generator 其实在解决异步问题上的能量超乎想象。值得咱们花费多点时间学习!

等等,貌似咱们在硬编码,再改进一下吧~

function loadFiles(urls) {
  const getFilePromises = urls.map(getFile);
  return function* gen() {
    do {
      output(yield getFilePromises.shift());
    } while (getFilePromises.length > 0);

    output("Complete!");
  };
}

co(loadFiles(["file1", "file2", "file3"]));
复制代码

好啦!Perfect~

解决方案:async/await

既然写到了这里,咱们也用 ES7 中出现的 async/await 写一下实现方案吧!

async function loadFiles(urls) {
  const getFilePromises = urls.map(getFile);
  do {
    const res = await getFilePromises.shift();
    output(res);
  } while (getFilePromises.length > 0);

  output("Complete!");
}

loadFiles(["file1", "file2", "file3"]);
复制代码

固然,其实和 generator 的实现写法上大体无什么差别,可是在写法上提高了可读性~

小结

关于异步请求,是明显的反作用,可谓名副其实的“野兽”。除了上述提到的一些方法外,咱们应该永不中止寻找更好更优雅的范式去处理这类状况,好比响应式编程、亦或者函数式编程中的 IO functor 等。

对异步的掌控也许还须要咱们了解 JavaScript 事件循环、任务队列、RxJS 等相关知识、仍是要去学习更多范式和思惟方式,与时间交朋友,而不是与之为敌。

以上。对你们若有助益,不胜荣幸。

参考资料

相关文章
相关标签/搜索