JavaScript事件循环与任务队列

152 阅读11分钟

JavaScript 是一种基于原型、多范式、单线程的动态语言,并且支持面向对象、命令式和声明式(如函数式编程)风格。

JavaScript 从诞生起就是单线程,为什么不使用多线程呢?

如果 JavaScript 同时有两个线程,一个线程在网页 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?是不是还要有锁机制?

JavaScript 设计之初,就是为了实现网页的交互。多线程需要共享资源、且有可能修改彼此的运行结果,网页的交互并不需要多线程,所以才选择了单线程。

这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段 JavaScript 代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。

默认情况下,浏览器会为每个标签页开启一个新的渲染进程,以保证标签之间不相互影响。渲染进程会开启一个渲染主线程,负责执行HTML、CSS、JavaScript代码,渲染进程的线程数量是固定的,由浏览器决定。渲染主线程的任务包括但不限于:

20.jpg

  • 解析HTML
  • 解析CSS
  • 计算样式
  • 布局
  • 处理图层
  • 每秒把页面绘制60次
  • 执行全局JavaScript代码
  • 执行时间处理函数
  • 执行定时器的回调函数
  • ...

要处理这么多的任务,如果主线程一直处于忙碌状态,那么浏览器就会卡死;如果主线程一直处于闲置状态,那么浏览器就会一直等待主线程处理完任务。那么,主线程如何进行调度呢?

事件循环

JavaScript 有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。

在计算机领域中事件循环(event loop),又称为消息分发器(message dispatcher)、消息循环(message loop)、消息泵(message pump)或运行循环(run loop),是一种程序构造或设计模式,负责等待并分发程序中的事件或消息。 它的工作方式是向内部或者外部的“事件提供方”发出请求(请求通常会被阻塞,直到有新事件产生),待请求被处理后调用所获得的事件对应的回调函数(即“分发事件”)。

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

queue.waitForMessage() 会同步地等待消息到达 (如果当前没有任何消息等待被处理)。

队列

一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。

执行时期(Run time)在计算机科学中代表一个计算机程序从开始执行到终止执行的运作、执行的时期。 与执行时期相对的其他时期包括:设计时期(design time)、编译时期(compile time)、链接时期(link time)、与加载时期(load time)。 而执行环境是一种为正在执行的程序或程序提供软件服务的虚拟机械环境。它有可能是由操作系统自行提供,或由执行此程序的母程序提供。

在 事件循环 期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。

函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。

调用栈(英语:Call stack)别称有:执行栈(execution stack)、控制栈(control stack)、运行时栈(run-time stack)与机器栈(machine stack),是计算机科学中存储有关正在执行的子程序的消息的栈。

执行至完成

每一个消息完整地执行后,其他消息才会被执行。这为程序的分析提供了一些优秀的特性,包括:当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。调用一个函数总是会为其创造一个新的栈帧。

这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web 应用程序就无法处理与用户的交互,例如点击或滚动。

添加消息

每当一个事件发生并且有一个事件监听器绑定在该事件上时,一个消息就会被添加进消息队列。如果没有事件监听器,这个事件将会丢失。

函数 setTimeout 接受两个参数:待加入队列的消息和一个时间值(可选,默认为 0)。这个时间值代表了消息被实际加入到队列的最小延迟时间。如果队列中没有其他消息并且栈为空,在这段延迟时间过去之后,消息会被马上处理。但是,如果有其他消息,setTimeout 消息必须等待其他消息处理完。因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间。

零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数。其等待的时间取决于队列里待处理的消息数量。

运行时互相通信

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。

一个 web worker 或者一个跨域的 iframe 都有自己的栈、堆和消息队列。两个不同的运行时只能通过 postMessage 方法进行通信。如果另一个运行时侦听 message 事件,则此方法会向该运行时添加消息。

the_javascript_runtime_environment_example.png

在计算机科学中,消息队列(英语:Message queue)是一种进程间通信或同一进程的不同线程间的通信方式,软件的贮列用来处理一系列的输入,通常是来自用户。 消息队列提供了异步的通信协议,每一个贮列中的纪录包含详细说明的资料,包含发生的时间,输入设备的种类,以及特定的输入参数。 也就是说:消息的发送者和接收者不需要同时与消息队列交互。消息会保存在队列中,直到接收者取回它。

堆栈段(stack segment)通常是指采用堆栈方式工作的一段内存区域。当程序被执行时,程序可能会将其执行的状态加入栈的顶部;当程序结束时,它必须把栈顶的状态数据弹出(pop)。

堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。

任务队列

JavaScript在设计时,考虑到了这个问题,为了让后续的任务不被阻塞,于是将任务挂起,先运行后面的任务,等到请求执行完成有了结果,再回头执行挂起的任务,因此任务就被分为:

  • 同步任务(synchronous):没有被引擎挂起、在主线程上排队执行的任务。只有前一个任务执行完毕,才能执行后一个任务。
  • 异步任务(asynchronous):被引擎放在一边,不进入主线程、而进入任务队列的任务。

同步任务进入主线程中执行,形成一个执行栈。而异步任务则进入事件表中注册,当指定的事件完成时,事件表会将这个函数移入事件队列。一旦执行栈中的所有同步任务执行完成就会读取事件队列,从任务队列中获取一条可运行的异步任务,将其添加到执行栈中开始执行。

过去把任务队列分为宏任务和微任务,通常微任务队列先执行,宏任务队列后执行。

  • 宏任务(Macrotask):由宿主环境发起,整体代码script、setTimeout、setInterval、setImmediate、i/o操作(输入输出,比如读取文件操作、网络请求)、ui render(dom渲染,即更改代码重新渲染dom的过程)、异步ajax等。
  • 微任务(Microtask):微任务由 JavaScript 自身发起,Promise(then、catch、finally)、async/await、process.nextTick、Object.observe(⽤来实时监测对象属性的变化)、MutationObserver(监测dom变化)等。

然而这已经无法满足复杂的宿主环境,取而代之的是一种新的处理机制:不同的任务有不同的类型,同类型的任务会被添加到同一个队列中,而不同类型的任务会被添加到不同的队列中,不同的任务队列有不同的优先级。

  • 延时队列:用于存放定时器到达后的回调任务,优先级中
  • I/O队列:用于存放异步I/O的回调任务,优先级中
  • 交互队列:用于存放鼠标键盘等交互事件,优先级高
  • 动画队列:用于存放requestAnimationFrame的回调任务,优先级高
  • 微队列:用于存放需要尽快执行的微任务,优先级最高

异步的操作模式

  • 回调函数(callback):一个函数作为参数传递给另一个函数,当被调用时,会返回一个函数,这个返回的函数就是异步的回调函数。回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合(coupling),使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任务只能指定一个回调函数。
  • 事件监听(event listener):当一个事件发生时,执行一个函数。这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以“去耦合”(decoupling),有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。阅读代码的时候,很难看出主流程。
  • 发布订阅(publish-subscribe):发布者发布消息,订阅者订阅消息,当发布者发布消息时,订阅者就会收到消息。这种方法的性质与“事件监听”类似,但是明显优于后者。因为可以通过查看“消息中心”,了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行。
  • Promise:一个对象,用来传递异步操作的消息,Promise对象代表一个异步操作,有三种状态:pending(进行中)、resolved(已完成)、rejected(已失败)。
  • Generator函数:它的最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样。
  • async/await:它就是 Generator 函数的语法糖。
console.log('start')

// 回调函数(callback)
function callbackFn(callback) {
	callback()
	console.log('callbackFn')
}
callbackFn(function () {
	console.log('callback')
})

// 事件监听(event listener)
const button = document.querySelector('#button')
button.addEventListener('click', function () {
	console.log('eventListenerFn')
})
button.click()

// 定时器
setTimeout(() => {
	console.log('timerFn')
}, 0)

// 发布订阅(publish-subscribe)
class PublishSubscribe {
	constructor() {
		this.eventList = {}
	}
	on(event, callback) {
		this.eventList[event] = this.eventList[event] || []
		this.eventList[event].push(callback)
		console.log('PublishSubscribeFn on')
	}
	emit(event, ...params) {
		this.eventList[event].forEach(function (fn) {
			fn(...params)
		})
		console.log('PublishSubscribeFn emit')
	}
}
const ps = new PublishSubscribe()
ps.on('done', function () {
	console.log('PublishSubscribeCallback')
})
ps.emit('done')

// Generator 函数
function* generatorFn() {
	console.log('generatorFn')
	yield 'generatorCallback'
	return 'generatorFn return'
}
const gfn = generatorFn()
console.log(gfn.next().value)

// Promise
const promiseFn = new Promise(function (resolve, reject) {
	console.log('promiseFn')
	const num = Math.round(Math.random() * 100)
	return num > 5 ? resolve() : reject()
})
promiseFn.then(function () {
	console.log('promiseFn then')
}).catch(function () {
	console.log('promiseFn catch')
})

// async await
async function asyncFn() {
	return new Promise(function (resolve, reject) {
		console.log('asyncFn')
		const num = Math.round(Math.random() * 100)
		return num > 5 ? resolve() : reject()
	})
}
asyncFn().then(function () {
	console.log('asyncFn then')
}).catch(function () {
	console.log('asyncFn catch')
})

console.log('end')

/**
 * 执行结果:
 * start
 * callback
 * callbackFn
 * eventListenerFn
 * PublishSubscribeFn on
 * PublishSubscribeCallback
 * PublishSubscribeFn emit
 * generatorFn
 * generatorCallback
 * promiseFn
 * asyncFn
 * end
 * promiseFn catch
 * asyncFn then
 * timerFn
 */

异步操作的流程控制

  • 串行执行:编写一个流程控制函数,让它来控制异步任务,一个任务完成以后,再执行另一个。
  • 并行执行:所有异步任务同时执行,等到全部完成以后,才执行final函数。
  • 并行与串行结合:设置一个门槛,每次最多只能并行执行n个异步任务,这样就避免了过分占用系统资源。
let items = [ 1, 2, 3, 4, 5, 6 ]
let results = []

function async(arg, callback) {
	setTimeout(function () { 
		callback(arg * 2) 
	}, 1000)
}

function final(value) {}

// 串行执行
function series(item) {
	if(item) {
		async(item, function(result) {
			results.push(result)
			return series(items.shift())
		})
	} else {
		return final(results[results.length - 1])
	}
}
series(items.shift())

// 并行执行
items.forEach(function(item) {
	async(item, function(result){
		results.push(result)
		if(results.length === items.length) {
			final(results[results.length - 1])
		}
	})
})

// 并行与串行结合
function launcher() {
	while(running < limit && items.length > 0) {
		let item = items.shift()
		async(item, function(result) {
			results.push(result)
			running--
			if(items.length > 0) {
				launcher()
			} else if(running == 0) {
				final(results)
			}
		})
		running++
	}
}
launcher()