异步编程

222 阅读7分钟

背景

提到异步编程,可能每个人都会想到很多概念:
1 ajax;
2 单线程;
3 promise;
4 async await;
5 dom事件绑定;
... 感觉自己对异步编程非常熟悉了,但在复杂的项目中还是会遇到一些问题。这篇文章因为是自己在不同的开发框架(包括前端,服务端)下的总结,比较适合有前端开发经验和Nodejs开发经验的童鞋阅读;

Blocking or Non-Blocking

我们常说 javascript 是异步非阻塞的,但实际上在 javascript 中我们常写很多同步的代码,e.g.

let name = 'vb'
let age

for(let i = 0; i < 100; i++) {
    if(i === 18) {
        age = i
    }
}
console.log({
    name,
    age
})

这就是一段同步的代码,它是按顺序执行,前面代码会阻塞后面代码的执行。
javascript 是支持异步非阻塞的,但也可以同步阻塞。是否阻塞跟同步/异步并不是全等的。
我们常说的异步非阻塞是指,当我们执行一个异步任务时,event-loop会将这个任务放到异步队列(宏任务/微任务)中,继续执行同步任务,等同步任务执行完毕后,再去检查异步队列是否完成,如果已完成,再去执行异步回调。e.g.

function myAsyncFn() {
    console.log(1)
    axios.get('http://google.com',(data) => {
        console.log(3)
    })
    console.log(2)
}
myAsyncFn()
// 1
// 2
// 3

call stack

其实大家日常开发大量都是同步代码,而且不是像上面的例子这么简单,而是一个函数调用另外一个函数一直嵌套调用的,而这些函数的嵌套调用是通过一个stack的数据结构进行存储的。
stack的特点就是先进后出,e.g.

function a() {
    console.log(1)
}
function b() {
    a()
    console.log(2)
}
function c() {
    b()
    console.log(3)
}
c()
// 结果如下
// 1
// 2
// 3

那这个事件模型是如何实现的呢?通过stack实现的,实现的过程如下(我用一个单向链表的数据结构表示):

// instack
// 调用 c 函数, c instack
{head: {next: {fn: c, next: null}}}
// 调用 b 函数, b instack
{head: {next: {fn: c, next: {fn: b, next: null}}}}
// 调用 a 函数, a instack
{head: {next: {fn: c, next: {fn: b, next: {fn: a, next: null}}}}}
// outstack
// 执行 a 函数, a outstack
{head: {next: {fn: c, next: {fn: b, next: null}}}}
// 执行 b 函数, b outstack
{head: {next: {fn: c, next: null}}}
// 执行 c 函数, c outstack
{head: {next: null}}

那调用栈(call stack)和事件模型除了让我们了解代码的执行过程之外,在实际开发中对我们有什么作用呢?例如在错误代码追踪时候,可以分同步调用和异步调用两种:
1 同步调用,我们可以修改下上面的例子,例如在 a 函数中抛出错误

function a() {
    throw new Error('oops')
}
// 可以看到报错内容如下, index.js:12为栈低入口函数,最上面的a (index.js:2)是报错的地方
index.js:2 Uncaught Error: oops
    at a (index.js:2)
    at b (index.js:5)
    at c (index.js:9)
    at index.js:12

2 异步调用,因为异步执行的时候,调用函数已经 outstack 了,所以是以异步回调函数重新入栈执行的(也引发了另外一个问题,异步代码的报错无法捕获,e.g. setTimeout的报错无法在外层通过try catch捕获)。

function a() {
    try {
        setTimeout(() => {
            throw new Error('sync oops!')
        }, 1000)
    }catch(err) {
        console.log(err)
    }
}
// 报错信息如下,无法在同步代码的 call stack中捕获错误
Uncaught Error: sync oops!
    at index.js:3

其实这里有三种错误捕获方式:

1 全局错误捕获window.onerror;  
2 通过async await + promise包裹实现同步;  
3setTimeout的回调函数里面加try catch进行错误捕获;  

单线程异步非阻塞 vs 多线程

其实很多前端开发者包括我自己,在刚开始用node进行服务端代码开发时候都会有点发虚,虽然网络上也有很多对比nodejs和其他老牌或者新的服务端开发语言,例如java, php, python等,性能相差并不大,有些场景nodejs甚至更好。后来有了解过,例如php/python也是单线程的,但也拓展支持多线程(nodejs也可以通过thread worker实现多线程)。那多线程跟异步非阻塞的单线程相比有什么优缺点呢?
1 单线程在某种程度上更加简单一点,不涉及到线程的创建/销毁(实际上,多线程的创建销毁也是通过框架已实现的线程池来进行操作的,对开发者而言没有太多差别),也不涉及到不同线程计算结果的聚合(多线程在一些复杂场景,需要考虑不同线程计算结果先后顺序可能导致的问题);
2 如果有大量复杂的计算,尽量选择多线程,因为单线程的同步计算时间会阻塞后面程序的运行;
3 在大量IO情况下,nodejs可以非阻塞处理多个IO,java是阻塞的(当然java也支持非阻塞),需要等待前一个任务完成(这里涉及到线程数量的问题,线程数不是越多越好),在大量IO而且没有复杂计算的情况下选择nodejs是一个很好的方案; 综上所述,其实我们需要考虑好场景,在不涉及大量计算时候使用异步单线程并不会跟多线程有性能上的多大差距,反而会更加简单。在涉及大量计算的时候,我们也可以通过nodejs提供的thread worker实现多线程管理(也有现成的npm插件node-worker-threads-pool)。

问题记录

1 一次异步灾难记录,用户新建聊天会话,六次并发请求服务端数据; 背景:通过electron开发的客户端IM聊天应用; 需求:在一个IM即时聊天的开发工具上,用户在同意好友请求后,会建立一个会话,同时系统会自动发送多条聊天记录, 例如是我是谁,我已同意你的聊天, etc. nodejs代码 :

// 若会话已存在,则存储消息,若会话不存在则请求web server获取会话信息
if(!conversationId) {
    let conversation = await getConversationFromServer()
    await saveConversation(conversation)
}
await saveMessage(message)

乍一看好像没毛病,但实际上,没等第一次服务器数据返回,已经有五六条消息过来了。这个问题引发了我对异步的进一步思考: 1 我们在异步编程的时候怎么保证先后执行顺序呢?
2 为什么在纯前端页面开发的时候没有遇到过这样的问题呢?
这里其实并不是简单的异步编程问题,它涉及到异步嵌套的问题:
1 保存会话和保存消息两个异步操作的先后顺序,这里是通过await将异步代码改为同步执行解决先后顺序问题;
2 第一条消息和后面的消息先后顺序问题,他的执行顺序是这样的(这里只有第一条和第二条消息做例子):

第一条消息进入,判断没有会话信息,向服务器请求数据(进入nodejs异步队列)
第二条数据进入,判断没有会话信息,向服务器请求数据(进入nodejs异步队列)
....
第一条消息获取服务端数据成功返回,再进入保存会话消息进程
...

这里可以看到问题,是我们没办法控制消息的顺序进入。找到问题就比较好解决了,我想到的方法有三种:
1 控制消息的执行顺序(在消息进入之前建立一个消息执行队列),但这种方式会增加用户响应时间,例如上个例子中,我们是 在保存会话之后就可以响应下一条消息了,并且大部分消息是不需要控制执行顺序的(当然也可以在队列中进一步优化)。这种方式其实是以时间换空间,在牺牲部分性能的情况下控制消息的执行顺序;
2 控制向服务端请求的次数,其实这个代码是可以正确执行的,只是会向服务端请求多次。那我们可以通过前端的开发思路(防抖/节流),在一定时间内我们只向服务器发送一次请求就好了(这个有实践过,但还是会有坑,后面会nodejs文章中再详细说明)。
3 抛弃向服务端请求会话信息,而在本地数据库生成会话记录。我最终也是选择这个方案,这个也很好的解释了为什么我们在页面开发中不会遇到这样的问题,因为建立会话表->再发送消息,这是强相关的操作,一般在服务端都需要保证顺序执行,而不会分开让用户来请求操作 ,所以我们在前端开发过程中不会遇到这样的问题。其实这里可以更加深入的再深入强相关的关联数据(强相关这个概念我暂时没有在相关文档上找到 ,临时这个词表示,后面如果看到了会修改)。

参考文档

1 Help, I'm stuck in an event-loop: krasimirtsonev.com/blog/articl…