背景
现有一个vue页面需求如下,页面上有一个列表(大约几百个列表项),当进入页面时,发起socket链接并接受上百条消息(列表项数量)。当第一个socket消息接收到后需要发起http请求获取详情数据。然后就会发现http请求处于挂起状态,需要等待socket几百条消息完全接收之后,这个http请求才会完成。
原因挖掘
考虑服务器以及浏览器原因,是否是socket发送频繁导致http响应推迟
根据我生平所学,socket消息和HTTP请求可以在同一程序中同时发送和接受,因为它们是两种不同的通信协议和机制。它们可以并存,因为它们通常监听不同的端口,且相互之间没有直接冲突。
又从理论上来讲,如果服务器资源有限,大量的消息传输可能会竞争服务器资源,占用网络带宽,确实导致HTTP请求的响应时间延长。但是,实际上服务器使用了多线程处理socket消息,并且网络情况良好,前端也是异步监听,加上chrome本身多进程的工作模式,浏览器也没有卡顿。
既然http响应没有推迟,浏览器也应该正常拿到响应结果,js在处理socket消息时也是监听message事件来执行异步回调,为什么控制台会显示挂起呢。我灵机一动,将目光移到js线程上,js是单线程的,当js主线程一直被占用,之前发起的http请求会怎么处理呢?
测试js线程被占用时http请求的状态
下面是一个小demo,发送xhr请求之后,js线程一直处于循环当中没有得到释放。
打开控制台可以看到,发送的http请求处于挂起状态,直到循环结束,状态才改为200,由此看来,控制台的网络请求状态不只是由浏览器控制,还跟js是否处理响应结果有关。
测试到此,基本可以断定是因为js主线程被占用才导致的http的请求没有被处理,有的同学会疑惑,之前不是说js是监听message事件来接收socket消息的吗,这是异步的呀,谁说的异步线程就不能堵塞了,这里不得不提js大名鼎鼎的事件循环和消息队列了。
结论
下面我将一步步分析js线程怎么被占用的。
这是画的一个不太准确的流程图。刚开始微任务队列为空,然后message事件加入并执行,在执行的时候发起了http请求,由于socket返回消息的频率很高,所以在http响应之前,微任务队列有好几个message事件,这时http响应并将回调事件加入宏任务队列。当前的消息队列就如下右图所示:
理论上来说,这几个微任务队列执行完就应该执行宏任务队列中的http回调了,但是,由于每一次message回调函数中的时间复杂度太高,加上修改了vue变量,触发多个深度watch,导致页面更新,而socket来的消息太快,导致上一次的message事件回调还未结束,新的message事件又进入了微任务队列,按照事件循环的说法,执行宏任务之前会清空微任务队列,所以js主线程一直在执行message事件,http请求回调得不到执行(此回调不是axios的回调,所以浏览器一直显示挂起)。
总的来说,js还是一门单线程的语言,不能简单的认为异步任务不会注册主线程,尽管有事件循环的存在,也要注意当存在大量的微任务时,可能无法进入下一个事件循环导致线程长时间被占用。
改进
使message事件在宏任务执行
message事件回调放入微任务中没有办法改变,但是,可以把回调函数做中的代码用setTimeout包裹起来。
socket.addEventListener('message', (event: any) => {
// 会放入宏任务队列
setTimeout(() => {
// 将复杂度高,js运算密集的代码放在这里
});
});
这样改写之后,微任务队列之前一样有很多但是会瞬间执行并不会影响事件循环(因为里面只有一个setTimeout),而真正的回调函数(setTimeout中的回调)处于了宏任务当中,当第一次message事件执行完,发送的http请求响应时,消息队列如下图所示:
然后js按照一个宏任务,清空微任务队列的循环处理,是可以执行http响应回调的,也就不会卡顿了。
第二次http请求挂起
然而上面并没有真正的解决卡顿的问题,当我点击列表项发起第二次http请求的时候又长时间处于挂起,需要等待socket大部分消息处理之后才行。有了上面的例子,这次情况就很明了,我们将真正的回调函数都放进了宏任务队列,socket返回消息极快(message事件只做了一件事将,函数放进宏任务队列),这就导致http响应的回调加入宏任务队列时,前面还有很多宏任务,js一直在处理前面的任务,所以请求挂起。
所以,既不能把所有任务放到宏任务队列中,这样会阻塞新的宏任务,又不能放任不管,socket返回消息又太频繁导致微任务过多,宏任务得不到执行。
使用缓存队列
将代码改写如下:
let waitList = [];
// 记录任务总数
const totalCount = 1000;
// 当前执行数量
let count = 0;
socket.addEventListener('message', (event: any) => {
count++;
// 会放入数组里
waitList.push(event.data);
});
const timer = setInterval(() => {
const list = waitList.splice(0, waitList.length);
for (const item of list) {
// 将复杂度高,js运算密集的代码放在这里
}
if(count === totalCount){
clearInterval(timer);
}
}, 1000);
message事件中将要处理的事情由setTimeout转移到数组中,和之前一样微任务队列不会有太多的任务,反观宏任务队列这边,也没有太多任务,而是每隔一秒钟将真正的回调函数放到宏任务队列执行,当http回调放入宏任务队列时可以有效的得到处理。下图是使用缓存的任务队列。
有的同学就有疑问了,定时器中不是把这一秒钟的所有任务的执行放到了宏任务队列吗,http的回调还是在宏任务队列中排队,还是在这一秒钟的所有任务之后执行。确实,不过和上面所有任务都在宏任务队列中不同的是,定时器的存在不会让任务直接全部放进宏任务队列,而是在一秒钟之后将任务放到宏任务队列,在此期间,http请求一旦响应,回调函数会立即进入宏任务队列,在下一次时间循环得到执行(也就是说最坏的情况下,最多等到执行完定时器一秒钟的任务之后,http的回调就可以得到执行),关键的是,在这一秒内,把主线程让出来了,事件循环一直良好,不会一直执行message事件里面的代码,让http请求或者是其他新的宏任务得到执行。