【转载】最后谈一次 JavaScript 异步编程

发布于 大漠

对于 JavaScript 的异步编程我们早就谈得太多了,我们为了处理 Callback Hell 问题已经做了太多太多了,从 Promise 到 generator 再到 async/await ,JavaScript 这个惊天巨坑算是勉强填完了。关于 Promise 、 generator 以及 aysnc/await 这些具体的技术怎么用,这里就不细谈了,我们更多谈谈 “形而上” 的东西。

JavaScript 的异步到底做了什么?

在我们传统的 “命令式” 编程语言中,我们的思考逻辑往往是像序列一样的一个事情做完了,然后再做下一个事情。翻译成人话就是,我们平常写代码的逻辑就是一行执行完了再执行下一行,一个程序就是由一组命令序列组成。

但是,现在问题来了,JavaScript 在设计阶段为了保证线程安全并且保证引擎不会被 IO 等待所阻塞导致页面失去响应,于是就向其他 GUI 程序学习了“异步事件模型”,所以“异步事件模型” 是一种解决多线程并行问题的模型。

【注】其实所谓的 “异步” 并不是 JavaScript 所独有的,而是一种非常成熟和非常古老的模型,并广泛用用在电子电路的设计上。计算机各种附属 IO 设备,尤其是慢速 IO (比如硬盘),都有异步事件响应的设计的影子。

好好写过多线程程序的小伙伴一定都曾被 “线程安全” 这四个字不断地按在地上摩擦,但也无非就是挣扎在线程和环境之间的爱恨情仇中,不断地折腾在 ”信号量“ 、”原子性“ 这些蛋疼的东西。但是 “异步事件模型” 很好得解决掉这个问题。 这种 “异步事件模型” 有两个核心的特性:

  • 只有主线程有权利改变环境。
  • 主线程不允许被阻塞。这样就非常好得把线程和环境之间的矛盾解决了。我们的 JavaScript 也遵循了这样的设计。

就如上面所说的一样,“异步事件模型” 是一个解决多线程并行的模型 (当然这种模型并不局限于多线程,多进程也能用,集群照样也能用),那就不难理解为什么一些连多线程都没搞懂的前端会觉得非常难以理解了。不过异步这样一个设计虽然已经把最恶心的线程管理,一个复杂切弹性的 GUI 应用很难不设计成这样过,但是同样也要了解这样的设计才能用得好。

被拉平的 “洋葱结构”“洋葱结构”

到这里我们就正式开始我们的主题了。在上古时期,我们的 JavaScript 异步编程往往是像洋葱一样一层括号包着一层括号的洋葱结构,同时也产生了我们耳熟能详的 “Callback Hell”。我现在去网上抄一段代码来看看。

fs.readdir(source, function (err, files) {
    if (err) {
        console.log('Error finding files: ' + err)
    } else {
        files.forEach(function (filename, fileIndex) {
            console.log(filename)
            gm(source + filename).size(function (err, values) {
                if (err) {
                    console.log('Error identifying file size: ' + err)
                } else {
                    console.log(filename + ' : ' + values)
                    aspect = (values.width / values.height)
                    widths.forEach(function (width, widthIndex) {
                        height = Math.round(width / aspect)
                        console.log('resizing ' + filename + 'to ' + height + 'x' + height)
                        this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
                            if (err) console.log('Error writing file: ' + err)
                        })
                    }.bind(this))
                }
            })
        })
    }
})

是不是一层包一层,像想从啊?这段代码有什么问题嘛?其实说实话也没什么太大的问题。其实说是话,Callback Hell 最大的问题真的不过就是不好写切不好看所衍生出来的不好管理罢了,但是代码却是要给人看给人维护给人管理的啊。于是大家就开始绞尽脑汁想办法。

上面说了,像洋葱一样一层包一层的结构是非常难写切非常难看的,随之而来的问题便是非常难以管理。所以无论是 Promise 还是 generator,亦或者是 async/await,他们所做的任务都是要千方百计地把这样的一个洋葱结构拉成平整的线性结构。

Promise

首先要讲的是 Promise,先用两份代码来展示 Promise 干了什么。

// 没有 promise
a(getResultFromA, (aResult, err) => {
    if (!err) {
        b(getResultFromB, (bResult, err) => {
            if (!err) {
                c(getResultFromC, (cResult, err) => {
                    if (!err) {
                        // do something
                    } else {
                        throw err
                    }
                })
            } else {
                throw err 
            }
        })
    } else {
        throw err
    }
})

// 用了 promise 后
new promise((resolve, reject) => {
    a(getResultFromA, (aResult, err) => {
        if (!err) resolve(aResult) else reject(err)
    })
})
.then(data => {
    return new Promise((resolve, reject) => {
        b(getResultFromB, (bResult, err) => {
            if (!err) resolve(bResult) else reject(err)
        })
    }
})
.then(data => {
    return new Promise((resolve, reject) => {
        c(getResultFromC, (cResult, err) => {
            if (!err) resolve(cResult) else reject(err)
        })
    }
})
.then(data => {
    // do something
})
.catch(err => {
    throw err
})

看到区别了嘛?Promise 的核心作用就是拉平 “洋葱结构”。不过错误处理以及 Promise 链这种用法当然也非常有意义的。但是,聪明的你一定发现了,好像用了 Promise 也没有让代码好看多少,好写多少啊?说得没错,所以才有接下来的 generatorasync/await 啊。说到这里, Promise 最大的贡献其实是统一了 JavaScript 异步编程的标准规范。

【注】曾经某知乎前端网红曾对我声称 “用了 Promise 就没有 callback 了”,“讨论” 一下后我果断拉黑了此网红。用了 Promise 以后如果没有 callback 了,那 Promise 的 then 方法的参数是个啥?Promise 并没有真正消除 callback ,Promise 只不过是用 then 方法来延迟了 callback 的绑定。

真正消灭 callback 的 generator 和 async/await

用了 Promise 其实并没有真正消灭 callback,并且还新增了很多 .then(data => { .... }) 这些很没有意义的 “模板代码”。所以爱折腾的人们终于搞出了generatorasync/awaitgenerator 有一些很神奇的特性这个就不多说了,自己看 MDN 上的文档就好,但是 generator 也能挺方便地处理异步(当然,要带上 tj/co 库才行),真正消灭了 callback 的存在。

// 使用 tj/co 库和 generator
co(function *() {
    try {
        const aResult = yield new Promise(/* getResultFromAPromise */)
        const bResult = yield new Promise(/* getResultFromBProimse */)
        const cResult = yield new Promise(/* getResultFromCPromise */)
        // do something
    } catch (err) {
        throw err
    }
})

看上去 generator 已经很好用了,但是用 generator 处理异步还是离不了 tj/co ,并且还有一些坑爹 bug 和兼容性问题。接下来再看 async/await

async function () {
    try {
        const aResult = await new Promise(/* getResultFromAPromise */)
        const bResult = await new Promise(/* getResultFromBProimse */)
        const cResult = await new Promise(/* getResultFromCPromise */)
        // do something
    } catch (err) {
        throw err
    }
}

总体来说 async/await 看上去和使用 co 库后的 generator 看上去很相似,不过反正 callback 是没有了。代码结构又回到了我们所熟悉并且好看的 “序列结构” 了。

结构与执行的耦合

说完了现有的各种 JavaScript 异步方案的优点,接下来就要说说现有的异步方案的不足了。那就是如小标题所说,结构与执行的高度耦合。这也是为什么我不喜欢 JavaScript 现有的异步方案。最典型的情况就是如果我需要动态生成一个异步序列,Promise 就没办法很好胜任。已经生成的 Promise 序列也没办增加头尾,或者增减中间的 Promise。当然,你要说完全不行也不是,我可以先写一堆 callback 然后在具体执行的时候再具体生成 Promise 链,但是这就失去了使用 Promise 的意义了。

比如下面是一段就是讲如何把结构和执行解耦的简单示意代码(我从以前一个回答里面抄回来的):

// 定义一个函数 sync 封装一个同步函数进入异步序列。
var sync = function(sync_func){
    return function(data, next){
        next(sync_func(data));
    };
};

// 将一个异步序列组合成一个可以执行的异步函数
var compose = function(async_function_list, callback){
    return async_function_list.reduceRight(function(left, right){
        return function(data){
            right(data, left);
        };
    }, callback);
};

// 异步函数序列
var job_list = [
    function(data, next){
        setTimeout(function(){
            next(data + 1);
        }, 1e3);
    },
    // 用 sync 将同步函数混入异步队列
    sync(function(data){
        return data + 2;
    }),
    function(data, next){
        setTimeout(function(){
            next(data + 3);
        }, 1e3);	
    },
];

// 可以非常方便的给异步函数序列加入新的特性,甚至是错误处理也可以这样加入
// 这里是在每一个异步操作前加入一个 console.log(data) 用来检测每一步的输出。
var job_list_with_process = job_list.map(function(async_job){
    return function(data, next){
        console.log(data);
        async_job(data,next);
    };
})

// 用 compose 函数将异步函数序列串成一个函数,并定义异步序列完成后的 callback
var asyncJob = compose(job_list_with_process, function(data){ 
    console.log('done with:', data)
});

// 执行
asyncJob(0);

上面的代码非常好的演示了 job_list 这样一个结构,和 asyncJob 这样一个具体执行的操作之间的区别。我可以给 job_list 这样的一个结构里面每一个操作添加新特性,可以加头,可以加尾,甚至可以反过来执行,随意复用里面的任何操作。这就是结构和具体执行解耦所带来的魔力。

虽然这里用了 List 这样的 “序列结构”,但是从设计思想上来说还是一致的。

再谈 Continuation Monad

以前我写文章谈过很多次 Monad,我接触 Monad 这个概念是从学习 Haskell 的时候开始的。很多听过 Monad 的人都被一个流传很广的话吓到过——“Monad 不过就是自函子范畴上一个幺半群罢了”。这句话完全是用来蒙外行的,这相当于说 “人不过就是一种两个眼睛一个鼻子的动物罢了”,是一句完全正确的废话。

其实 Monad 到底是什么?这个我的等级不够,真的说不清楚。但是 Monad 在作用上表现出的是什么,我还是能说清楚的。Monad 在实际应用上,所表现的就是一个能够蕴含自身的抽象结构,就如同封面图的洋葱一样。

说到这里,聪明的小伙伴们发现了嘛?JavaScript 的 Callback Hell 居然和 Monad 在结构上具有惊人的相似性,Monad 和异步简直是天造地设啊,所以就有了一个东西叫做 Continuation Monad 用来处理异步问题。Promise 链其实就是一种 Continuation Monad。

【注】Monad 只是一个结构,也仅仅是一个结构。比如我构建了一个 Continuation Monad ,那么这个 Monad 只是描述了一个结构而已,并没有真正执行。在具体执行的时候会有专门的一个 runXXXMonad 方法来一层一层 “剥洋葱皮” 然后执行。 【注】其实 List 也是一个 Monad 哦。

Monad 与 CPS 语法糖

上面我们说了 Monad 是一种洋葱结构,那 Monad 会不会在代码也会出现类似 Callback Hell 这样的问题呢?嗯,还真说对了,Monad 在写法上还真有类似 Callback Hell 这样的问题,比如写一段将 Haskell 的 Monad 一层一层包起来的代码来看看。

{-- 这是一段简单的 IO Monad,print "xx" 是一个 Monad --}
print "hello" >>= (\_ -> print "world" >>= (\_ -> print "haskell"))

这段将 Monad 一层一层包起来的代码完全就是 JavaScript Callback Hell 的一个翻版,虽然因为 Haskell 的函数定义更为简洁,所以看上去好看一些。于是再看看 Haskell 怎么解决自己的一个基本结构 Monad 的

{-- 这里用了 do 语法糖 --}
do
print "hello"
print "world"
print "haskell"

你们不是觉得 >>= \_ -> 这段东西看着累写着更累嘛?好,我造一个语法糖给你,在 do 语法块里面,一个回车就是一个 >>= \_ -> 你说吼不吼啊?这个 do 就是一个 CPS 语法糖。在引擎不改动一行代码的前提下,光是提供一个语法糖,就完美解决了 Callback Hell 的问题。所以这也是我为什么说,JavaScript 离最好的异步解决方案真的就只差一个语法糖而已啊。

总结

正如标题所示,这是我最后一次谈 JavaScript 的异步编程了,说实话真的已经谈到都谈烂了。上文说的那些,就是我对 JavaScript 异步编程折腾研究了几年的所有的心得体会,细节也许还有没说清楚的,但是主要的想法都说出来了。留作纪念吧。

本文转载@bramblesProgramming rocks的《最后谈一次 JavaScript 异步编程》一文。Nike Sb Blazer Zoom Mid Xt Orange White Circuit 876872-819