JS基础知识 —— 异步和单线程

JS三座大山——原型和原型链,作用域和闭包加上这次要总结的 异步和单线程。

同步和异步



同步和异步有什么区别呢?

  • 同步会阻塞代码的执行,而异步不会阻塞。
// 同步
console.log(100);
alert(200);
console.log(300);
// 只要alert的弹框还存在,代码就停留在这里,不会往下执行,也就是不会打印300

// 异步
console.log(100);
setTimeout(function () {
	console.log(200);
}, 1000);
console.log(300);
// 而setTimeout不会阻塞代码,会接着往下执行
  • JS会将所有任务分成两类:同步任务和异步任务

    • 同步任务:在主线程上执行的任务,只有前一个任务执行完成,才会执行下一个任务。

    • 异步任务:不进入主线程,而进入“任务队列”。当主线程上的任务执行完,JS才会去执行“任务队列”的任务。

      对于setTimeout(fn, 200),当到200ms时,fn会被放入“任务队列”,而任务队列必须等到主线程已有的代码执行完后才会执行fn。所以当程序执行到setTimeout(fn, 200)这一行代码时,时间就开始计算,但是fn实际执行并不一定是在200ms后,可能是在更久的时间后(取决于主线程的同步代码的执行时间)。

  • 发出调用,立即得到结果是为同步。发出调用,但无法立即得到结果,需要额外的操作才能得到预期的结果是为异步。 同步就是调用之后一直等待,直到返回结果。异步则是调用之后,不能直接拿到结果,通过一系列的手段才最终拿到结果(调用之后,拿到结果中间的时间可以介入其他任务)。


同步

如果在函数返回结果的时候,调用者能够拿到预期的结果(就是函数计算的结果),那么这个函数就是同步的。

同步就是调用之后一直等待,直到返回结果。

如果函数是同步的,即使调用函数执行任务比较耗时,也会一致等待直到得到执行结果。

function wait(){
    var time = (new Date()).getTime(); // 获取当前的unix时间戳
    while((new Date()).getTime() - time > 5000){}
    console.log('5秒过去了');
}
wait();
console.log('慢死了');

异步

如果在函数返回的时候,调用者还不能购得到预期结果,而是将来通过一定的手段得到(例如回调函数),这就是异步。

一个异步过程通常是这样的:主线程发起一个异步请求,异步任务接收请求并告知主线程已收到(异步函数返回);主线程可以继续执行后面的代码,同时异步操作开始执行;执行完成后通知主线程,主线程收到通知后,执行一定的动作(调用回调函数)。因此,一个异步过程包括两个要素:注册函数和回调函数,其中注册函数用来发起异步过程,回调函数用来处理结果。


什么时候用到异步呢?

  • 在可能发生等待的情况
  • 等待过程不能想alert一样阻塞代码的执行
  • 所有的“等待的情况”都需要异步

前端使用异步的场景

  • 定时任务:如setTimeout,setInterval
  • 网络请求:如ajax请求,动态<img>加载
  • 事件请求
  • IO操作:如readFile,readdir

异步有哪些优缺点

  • 优点:异步的代码可以大大提升系统的容量上限,因为充分利用了空闲的CPU时间。
  • 缺点:反直觉(回调流程控制问题复杂,所以出现promise,async/await这些东西)。
    • 嵌套层次深的,难以维护
    • 无法正常使用 returnthrow
    • 无法正常检索堆栈信息
    • 多个回调之间难以建立联系

单线程和多线程


浏览器

一个浏览器通常由以下几个常驻的线程:

  • 渲染引擎线程,负责页面的渲染
  • js引擎线程,负责js的解析和执行
  • 定时触发器线程,处理setInterval和setTimeout
  • 事件触发线程,处理DOM事件
  • 异步http请求线程,处理http请求

要注意的是渲染引擎和js引擎线程是不能同时进行的。渲染线程在执行任务的时候,js引擎线程会被挂起。因为若是在渲染页面的时候,js处理了DOM,浏览器就不知道该听谁的了


JS引擎

通常讲到浏览器的时候,我们会说到两个引擎:渲染引擎和 JS 引擎

1、渲染引擎: Chrome/Safari/Opera用的是 Webkit引擎,IE用的是 Trdent引擎,FireFox用的是 Gecko引擎。不同的引擎对同一个样式的实现不一致,就导致浏览器的兼容性问题。

2、JS引擎: js 引擎可以说是 js 虚拟机,负责解析 js 代码的解析和执行。不同浏览器的 js 引擎也各不相同,Chrome用的是 V8FireFox用的是 SpiderMonkeySafari用的是 JavaScriptCoreIE用的是 Chakra。通常有以下步骤:

  • 词法解析:将源代码分解位有意义的分词
  • 语法分析:用语法分析器将分词解析成语法树
  • 代码生成:生成机器能运行的代码
  • 代码执行

之所以说 js 是单线程就是因为浏览器运行时只开启一个 js 解释器,原因是若有两个线程操作 DOM,浏览器就会懵逼…。虽然 JavaScript 就是单线程的,但是浏览器不是单线程的。因为一些 I/O 操作,定时器计时和事件监听是有其他线程来完成的。


单线程

JavaScript的单线程(是这门语言的核心),与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途就是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

HTML5中提出web worker标准,允许JavaScript脚本创建多个线程,但是子进程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

  • JS代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序,还依靠任务队列(task queue)(或者说事件队列)来调控异步任务。

    和排队类似。哪个异步操作完成的早,就排在前面。不论异步操作何时开始执行,只要异步操作执行完成,就可以到任务队列中排队。这样,主线程在空闲的时候,就可以从任务队列中获取任务并执行。

    任务队列中放的任务具体是什么东西?任务的具体结构当然跟具体的实现有关。但是为了简单起见,可以认为:任务就是注册异步任务时添加的回调函数。

  • 任务队列分为macro-task(宏任务)micro-task(微任务),在最新标准中,它们被分别称为taskjobs

    • macro-task大概包括:script(整体代码),setTimeout,setInterval,I/O,UI rendering,setImmediate
    • micro-task大概包括:Promise,MutationObserver(html5新特性),Object.observe(已废弃),process.nextTick
    • 除了script整体代码(可以理解为待执行的所有代码),micro-task任务的优先级高于macro-task的任务优先级。

    任务队列存在多个,同一任务队列内,按队列顺序被主线程取走;不同任务队列之间,存在着优先级,优先级高的优先获取(如用户I/O)


一个很重要的概念:watcher(观察者)
在使用事件驱动的系统中,必然会有非常非常多的事件。如果事件都产生,都要主循环去处理,必然会导致主线程繁忙。那对应的应用层的代码而言,肯定有很多不关心的事件(比如只关心点击事件,不关心定时器事件)。这会导致一定浪费。

  • 事实上,不是所有的事件都放置在一个队列里。
  • 不同的事件,放置在不同的队列。
  • 当我们没有使用定时器时,则完成不关心定时器事件这个队列。
  • 当我们调用定时器时,首先会设置一个定时器 watcher。事件循环的过程中,会去调用该 watcher,检查它的事件队列上是否产生事件(比对时间的方式)。
  • 当我们进行 磁盘 IO的时候,则首先设置一个 io watcher磁盘 IO完成后,会在该 io watcher的事件队列上添加一个事件。事件循环的过程中从该 watcher上处理事件。处理完已有的事件后,处理下一个 watcher
  • 检查完所有的 watcher后,进入下一轮检查。
  • 对某类事件不关心时,则没有相关的 watcher

事件循环(Event-loop)—— 实现异步的一种机制


实现异步的方法包括 event-loop,轮询,事件等。

event-loopJavaScriptRuntime的一种执行机制(宿主环境来实现的)。


首先需要搞懂什么是 JavaScript 执行引擎JavaScript 执行环境。通常说的引擎指的是虚拟机,对于 Node来说是 V8、对 Chrome来说是 V8,对 Safari来说是 JavaScript Core,对 Firefox来说是 SpiderMonkey。JavaScript 的执行环境有很多,上面说的各种浏览器、Node、Ringo 等。前者是 Engine,后者是 Runtime


对于 Engine 来说,他们要实现的是 ECMAScript 标准。 对于什么是 event-loop,他们没兴趣也不关心。

主线程运行的时候,产生堆和栈,栈中的代码调用各种外部API,异步操作执行完成后,就在任务队列中排队。只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些异步任务所对应的回调函数。

一个宿主环境 只能有一个事件循环(Event loop),而一个事件循环可以多个任务队列(Task queue),每个任务都有一个任务源(Task source)。相同任务源的任务,只能放到一个任务队列中。不同任务源的任务,可以放到不同任务队列中。然后 js引擎做的事就是不断的去读取这些队列里面的任务来执行。

事件驱动的实现过程主要靠事件循环完成。 进程启动后就进入主循环。主循环的过程就是不停的从事件队列里读取事件。如果事件有关联的 handle(也就是注册的 callback),就执行 handle。一个事件并不一定有 callback。

详细步骤如下:

1、所有同步任务都在主线程上执行,形成一个执行栈。

2、主线程之外,还存在一个"任务队列"。只要异步操作执行完成,就在任务队列之中放置一个事件。

3、一旦执行栈中的所有同步任务执行完毕,系统就会按次序读取任务队列中的事件进入执行栈,开始执行。(也就是先放到队列里的事件先执行。)

4、主线程不断重复上面的第三步。



循环

从代码执行顺序的角度来看,程序最开始是按代码顺序执行代码的,遇到同步任务,立刻执行;遇到异步任务,则只是调用异步函数发起异步请求。此时,异步任务开始执行异步操作,执行完成后到任务队列中排队。程序按照代码顺序执行完毕后,查询任务队列中是否有等待的任务。如果有,则按照次序从任务队列中把任务放到执行栈中执行。执行完毕后,再从任务队列中获取任务,再执行,不断重复。

// demo1

console.log(1);
console.log(2);

setTimeout(function(){
    console.log(3);
    setTimeout(function(){
        console.log(6);
    })
},400)
setTimeout(function(){
    console.log(4);
    setTimeout(function(){
        console.log(7);
    })
},100)
console.log(5)

// 1 2 5 4 7 3 6

注意:同步代码先执行,执行是在栈中执行的,然后微任务会先执行,再执行宏任务。


问题: 如果在执行宏任务的过程中又发现了回调中有微任务,会把这个微任务提前到所有宏任务之前,等到这个微任务完成后再继续执行宏任务吗?

找个例子去搞搞看

console.log(1);
setTimeout(function(){
    console.log(2);
    Promise.resolve(1).then(function(){
        console.log('promise1')
    })
})
setTimeout(function(){
    console.log(3)
    Promise.resolve(1).then(function(){
        console.log('promise2')
    })
})
setTimeout(function(){
    console.log(4)
    Promise.resolve(1).then(function(){
        console.log('promise3')
    })
})

// 浏览器中  先走微任务
1
2
promise1
3
promise2
4
promise3

一个例子是创建WebQQ的QQ好友列表。列表中通常会有成百上千个好友,如果一个好友用一个节点来表示,在页面中渲染这个列表的时候,可能要一次性往页面中创建成百上千个节点。在短时间内往页面中大量添加DOM节点显然也会让浏览器吃不消,看到的结果往往就是浏览器的卡顿甚至假死。

var ary = [];
for ( var i = 1; i <= 1000; i++ ){
  ary.push( i );    // 假设 ary 装载了 1000 个好友的数据
};
var renderFriendList = function( data ){
  for ( var i = 0, l = data.length; i < l; i++ ){
    var div = document.createElement( 'div' );
    div.innerHTML = i;
    document.body.appendChild( div );
  }
};
renderFriendList( ary );

这个问题的解决方案之一是数组分块技术,下面的timeChunk函数让创建节点的工作分批进行,比如把1秒钟创建1000个节点,改为每隔200毫秒创建8个节点。

function chunk(array,process,context){
    setTimeout(function(){
        //取出下一个条目并处理
        var item = array.shift();
        process.call(context,item);
        //若还有条目,再设置另一个定时器
        if(array.length > 0){
            setTimeout(arguments.callee,100);
        }
    },100);    
}

var data = [1,2,3,4,5,6,7,8,9,0];
function printValue(item){
    var div = document.getElementById('myDiv');
    div.innerHTML += item + '<br>';
}
chunk(data.concat(),printValue);



setTimeout


现在HTML5规定setTimeout的最小间隔时间是4ms,也就是说0实际上也会别默认设置为最小值4ms。

JavaScript是单线程执行的,也就是无法同时执行多段代码,当某一段代码正在执行的时候,所有后续的任务都必须等待,形成一个队列。一旦当前任务执行完毕,再从队列中取出下一个任务。这也常被称为“阻塞式运行”。

假如当前JavaScript进程正在执行一段很耗时的代码,此时发生了一次鼠标点击,那么事件处理程序就被阻塞,用户也无法立即看到反馈,事件处理程序会被放入任务队列,直到前面的代码结束以后才会开始执行。

如果代码中设定了一个setTimeout,那么浏览器便会在合适的时间,将代码插入任务队列。如果这个时间设为0,就代表立即插入队列,但不是立即执行,仍然要等待前面代码执行完毕。所以setTimeout并不能保证执行的时间,是否及时执行取决于JavaScript线程是拥挤还是空闲。

setTimeout的回调函数的延时是相对于执行setTimeout这段代码这一刻
下面这段代码可以证明这个观点

// 延时
function sleep(n) {
  var start = new Date().getTime();
  while(true) {
    if (new Date().getTime() - start >= n) {
      break;
    }
  }
}

setTimeout(() => {
  console.log(100);
}, 500);

setTimeout(() => {
  console.log(200);
}, 1000);

sleep(1000);

大家可以试一试,刷新后,控制台会同时打印出100 200。如果将500和1000延时分别修改为1000和2000的话,控制台就会先打印100,再打印200。想必这足以证明setTimeout中回调函数的延时是相对于setTimeout代码执行那一刻的。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值