单线程的JS

几个基本概念

进程和线程

进程是系统分配资源的最小单位,线程是系统运算和调度的最小单位。一个进程内可有单或多个线程,进程和线程都由操作系统管理。

通俗点说就是系统把内存资源分配给各进程,得到执行程序的空间,进程中的各线程再使用CPU资源,来实际执行程序。

进程切换消耗比线程切换大,涉及虚拟内存地址的转换。因为每个进程都有专属的、地址连续的内存空间,这块内存是由操作系统抽象给进程的,称为虚拟内存,可通过页表映射到物理内存。

协程

协程是更轻量的运算单位,由程序基于线程去创建和调度(一般在语言层实现,比如go、python等),操作系统对协程的存在无感知。可能是一个线程对应多个协程,也可能是多个线程对应多个协程,协程间的切换可以不依赖线程切换,取决于程序的设计。

go语言的协程,栈空间在KB级,而线程的栈空间一般在MB级。

并发、并行

并发是一定时间段内完成多条指令。比如有3个耗时0.5s的运算,程序通过资源调度在0.6s内完成了这3个原本总和需要1.5s的运算,称为并发。浏览器中,JS的事件循环通过对多线程的调度即可实现并发。

并行是同一时间点有多条指令在同时执行,所以其前提必须是多核CPU。NodeJS中可以通过cluster模块来利用多核CPU实现并行。

NodeJS中的并发

node中仅JS主线程是单线程,有自己的工作池线程,主线程遇到异步任务(定时器、磁盘IO、网络请求等),会交给工作池线程执行。

因为web服务并发的瓶颈是IO而非CPU计算,node对于IO的异步设计,天然可应对并发场景。

可以用cluster模块实现多进程,充分利用CPU核数。多个进程监听同一端口(其实只有一个主进程监听,其他进程和这个主进程IPC通信),把端口的请求负载均衡到各个进程。

worker线程

HTML标准open in new window中规定了worker线程的实现。node环境也实现了worker线程。worker是独立于主线程的线程。

各个worker线程间通信是靠postMessage方法,把数据结构化克隆后传递给另一线程,有些数据是无法克隆的(比如函数),因为它们有各自的执行内存(除了一些内存共享的特殊数据结构,比如SharedArrayBuffer)。

线程间通信需要复制数据,稍有些别扭,可能因为JS整个语言体系就是基于单线程的,每个JS线程都有自己的执行内存。不像一些天生被设计为支持多线程的语言(比如go),新线程默认共享当前的执行上下文,也就减少复制数据来的麻烦。

多进程的浏览器

浏览器一般是多进程的应用。以chrome为例

包含进程有(shift+esc可查看):

  • 浏览器主进程
  • GPU进程:GPU加速
  • tab页进程:每个tab页对应一个进程
  • 扩展程序(第三方插件)进程

页面进程中又包含多个线程:

  • JS引擎线程
  • GUI渲染线程:DOM渲染
  • 事件控制线程:实现event loop
  • 定时触发器线程:setTimeout、setInterval等计时器
  • 网络请求线程:如http请求
  • webWorker线程(sharedWorker线程归属浏览器主进程)

JS线程与GUI线程互斥(一个执行时另一个会被挂起),因为JS可以读取DOM的渲染数据,须保证读取到的数据准确。浏览器一般在JS线程空闲时执行layout&paint,如果在JS线程执行中触发layout,JS线程会阻塞,等其执行完毕再继续。

渲染相关详见【页面渲染】

事件循环

浏览器的事件循环

JS本身是单线程,但执行JS的环境不是。宿主一般会有JS主线程、调度线程、网络请求线程等多个线程配合来运行程序,所以单线程的JS能实现非阻塞。

浏览器中以事件循环模型来运行JS,如下图:

event loop

事件循环模型包含执行栈(JS stack)和事件队列(event queue),由调度线程来控制整体的调度。执行栈即JS主线程,在主线程运行中,一旦遇到异步操作(比如setTimeout/http请求等)时,会交给另外的线程处理(比如setTimeout交给定时器线程、http请求交给网络请求线程),当其他线程返回结果后,调度线程会将事件推入事件队列中,按先进先出原则待主线程处理。

每一轮循环称为一个task,每轮循环的末尾还会检查并执行微任务(micro task queue)队列。

主线程空闲时(即一轮事件循坏结束),会从事件队列取出事件(如果有的话)加入执行栈执行(即进入下轮事件循环)。如果执行栈再遇到异步操作,则重复上述调度行为。

NodeJS中的事件循环略有不同,详见【事件循环(NodeJS)】

task/microTask

事件循环中有task和microTask的概念

function repeat() {
	Promise.resovle().then(repeat);
}

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

repeat();
	
// 'setTimeout'永远不会被打印,因为无限执行microTask,不会进入下个loop

如果改成

function repeat() {
	setTimeout(repeat);
}

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

repeat();

// 'setTimeout'会被打印

执行顺序

loop ( 一轮循环开始 -> task -> microTask -> 一轮循环结束 ) => nextLoop ( 一轮循环开始 -> task -> microTask -> 一轮循环结束 ) => nextLoop => ...

task类型: setTimeout, MessageChannel

microTask类型: Promise, MutationObserver

setTimeout/setInterval

function loop1() {
	// do sth.
	setTimeout(loop1, 1000);
}

loop1();

function loop2() {
	setInterval(() => {
		// do sth.
	}, 1000);
}

loop2();

两者都表示1s执行一次的循环,区别在于这个间隔1s,它在loop1中是本次do sth.执行完才触发下次计时,而loop2是一直在计时(因为计时器是单独的线程,不被主线程阻塞),一次计时完毕后立刻开始下一次。当do sth.耗时越高它们的行为差别越明显,setInterval可能连续多次触发do sth.

WebWorker

Worker

用法:

// main.js
const worker = new Worker('./worker.js');
worker.postMessage('main msg');
worker.onmessage(msg => {
	msg; // 'worker msg'
	// workder.terminate();  
});

// worker.js
onmessage = msg => {
	msg; //  'main msg'
	postMessage('worker msg');
}

Worker会独立开启一个线程执行,不占用主线程资源,post出来的数据推入事件队列待主线程处理。

SharedWorker

用法同Worker,区别在于SharedWorker是独立进程,多tab页可共享一个