进程和线程
多线程可以并行处理任务,但是线程是不能单独存在的,它是由进程来启动和管理的。
一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。
单线程与多线程的进程对比图从图中可以看到,线程是依附于进程的,而进程中使用多线程并行处理能提升运算效率。
形象的比喻:
- 进程是一个工厂,工厂有它的独立资源
- 工厂之间相互独立
- 线程是工厂中的工人,多个工人协作完成任务
- 工厂内有一个或多个工人
- 工人之间共享空间
再完善完善概念:
- 工厂的资源 -> 系统分配的内存(独立的一块内存)
- 工厂之间的相互独立 -> 进程之间相互独立
- 多个工人协作完成任务 -> 多个线程在进程中协作完成任务
- 工厂内有一个或多个工人 -> 一个进程由一个或多个线程组成
- 工人之间共享空间 -> 同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)
总结来说,进程和线程之间的关系有以下 4 个特点。
- 进程中的任意一线程执行出错,都会导致整个进程的崩溃。
-
线程之间共享进程中的数据。
线程之间可以对进程的公共数据进行读写操作。
线程之间共享进程中的数据示意图
从上图可以看出,线程 1、线程 2、线程 3 分别把执行的结果写入 A、B、C 中,然后线程 2 继续从 A、B、C 中读取数据,用来显示执行结果。
- 当一个进程关闭之后,操作系统会回收进程所占用的内存。
- 进程之间的内容相互隔离。
进程隔离是为保护操作系统中进程互不干扰的技术,每一个进程只能访问自己占有的数据,也就避免出现进程 A 写入数据到进程 B 的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信(IPC)的机制了。
多进程浏览器时代
- 浏览器是多进程的
- 浏览器之所以能够运行,是因为系统给它的进程分配了资源(CPU、内存)
- 简单点理解,每打开一个Tab页,就相当于创建了一个独立的浏览器进程
浏览器多进程架构
Chrome 进程架构图从图中可以看出,Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。
这几个进程的功能:
- 浏览器主进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
- 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
- GPU进程:用于3D CSS 的效果。
- 渲染进程:核心任务是将HTML、CSS和JavaScript转换为用户可以与之交互的网页,排版引擎Blink和JavaScript引擎V8都是运行在该进程中,默认情况下,Chrome 会为每个Tab标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
- 网络进程。主要负责页面的网络资源加载。
打开一个页面需要启动多少进程
打开1个页面至少需要1个网络进程、1个浏览器进程、1个 GPU 进程以及1个渲染进程,共4个;如果打开的页面有运行插件的话,还需要再加上1个插件进程。
浏览器多进程的优势
相比于单进程浏览器,多进程有如下优点:
- 避免单个
page crash
影响整个浏览器 - 避免第三方插件
crash
影响整个浏览器 - 多进程充分利用多核优势
- 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性
当然,内存等资源消耗也会更大,有点空间换时间的意思。
简单点理解:如果浏览器是单进程,那么某个Tab页崩溃了,就影响了整个浏览器;同理如果是单进程,插件崩溃了也会影响整个浏览器。
重点是浏览器内核(渲染进程)
浏览器的渲染进程是多线程的。
可以这样理解,页面的渲染,JS的执行,事件的循环,都在这个进程内进行。
接下来列举一些主要常驻线程:
GUI渲染线程
负责渲染浏览器界面,解析HTML,CSS,构建DOM树和Render树,布局和绘制等。
当界面需要重绘(Repaint
)或由于某种操作引发回流(reflow
)时,该线程就会执行。
注意,GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
JS引擎线程
也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)
JS引擎线程负责解析Javascript脚本,运行代码。
JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer
进程)中无论什么时候都只有一个JS线程在运行JS程序。
同样注意,GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
事件触发线程
归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)。
当JS引擎执行代码块如setTimeout
时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中。
当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理。
注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)。
定时触发器线程
setInterval
与setTimeout
所在线程。
浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确)。
因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)。
注意,在HTML标准中规定,要求setTimeout
中低于4ms的时间间隔算为4ms。
异步http请求线程
在XMLHttpRequest
在连接后是通过浏览器新开一个线程请求。
将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。
Browser进程和浏览器内核(Renderer进程)的通信过程
打开任务管理器,然后打开一个浏览器,就可以看到:任务管理器中出现了两个进程(一个是主控进程,一个则是打开Tab页的渲染进程),然后在这前提下,看下整个的过程:(简化了很多)
-
Browser
进程收到用户请求,首先需要获取页面内容(譬如通过网络下载资源),随后将该任务通过RendererHost
接口传递给Render
进程 -
Renderer
进程的Renderer
接口收到消息,简单解释后,交给渲染线程,然后开始渲染- 渲染线程接收请求,加载网页并渲染网页,这其中可能需要
Browser
进程获取资源和需要GPU进程来帮助渲染 - 当然可能会有JS线程操作DOM(这样可能会造成回流并重绘)
- 最后
Render
进程将结果传递给Browser
进程
- 渲染线程接收请求,加载网页并渲染网页,这其中可能需要
-
Browser
进程接收到结果并将结果绘制出来
浏览器内核中线程之间的关系
GUI渲染线程与JS引擎线程互斥
由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JS线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。
因此为了防止渲染出现不可预期的结果,浏览器设置GUI渲染线程与JS引擎为互斥的关系,当JS引擎执行时GUI线程会被挂起,GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。
JS阻塞页面加载
从上述的互斥关系,可以推导出,JS如果执行时间过长就会阻塞页面。
譬如,假设JS引擎正在进行巨量的计算,此时就算GUI有更新,也会被保存到队列中,等待JS引擎空闲后执行。
然后,由于巨量计算,所以JS引擎很可能很久很久后才能空闲,自然会感觉到巨卡无比。
所以,要尽量避免JS执行时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。
从Event Loop谈JS的运行机制
任务队列
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就必须一直等到前一个任务结束才能执行。但这时CPU是闲着的,这样浪费了很多计算机的性能。
JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务,另一种是异步任务。
同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
异步任务指的是,不进入主线程、而进入"任务队列"(task queue
)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
- 所有同步任务都在主线程上执行,形成一个执行栈。
- 主线程之外,事件触发线程管理着一个"任务队列"(
task queue
)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。 - 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
我们不禁要问了,那怎么知道主线程执行栈为空啊?js引擎存在monitoring process
进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去"任务队列"那里检查是否有等待被调用的函数。这就是JavaScript的运行机制。这个过程会不断重复。
事件和回调函数
"任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。
"任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。
所谓"回调函数"(callback
),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。
Event Loop
主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop
(事件循环)。
上图中,主线程运行的时候,产生堆(heap
)和栈(stack
),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done
)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
执行栈中的代码(同步任务),总是在读取"任务队列"(异步任务)之前执行。
浏览器向我们提供了JS引擎不具备的特性:Web API。Web API包括DOM API、定时器、HTTP请求等特性,可以帮助我们实现异步、非阻塞的行为。
当我们调用一个函数时,函数会被放入一个叫做调用栈(call stack
,也叫执行上下文栈)的地方。调用栈是JS引擎的一部分,并非浏览器特有的。调用栈是一个栈数据结构,具有后进先出的特点(Last in, first out. LIFO)。当函数执行完毕返回时,会被弹出调用栈。
图例中的respond
函数返回一个setTimeout
函数调用,setTimeout
函数是Web API提供给我们的功能:它允许我们延迟执行一个任务而不用阻塞主线程。setTimeout
被调用时,我们传入的回调函数,即箭头函数()=>{return'hey'}
会被传递给Web API处理,然后setTimeout
和respond
依次执行完毕出栈。
在Web API中会执行定时器,定时间隔就是我们传入setTimeout
的第二个参数,也就是1000ms。计时结束后回调函数并不会立即进入调用栈执行,而是会被加入一个叫做 任务队列(Task Queue)的地方。
Event Loop的工作就是连接任务队列和调用栈,当调用栈中的任务均执行完毕出栈,调用栈为空时,Event Loop会检查任务队列中是否存在等待执行的任务,如果存在,则取出队列中第一个任务,放入调用栈。
我们的回调函数被放入调用栈中,执行完毕,返回其返回值,然后被弹出调用栈。
下面代码输出什么:
const foo = () => console.log('First');
const bar = () => setTimeout(() => console.log('Second'), 500);
const baz = () => console.log('Third');
bar();
foo();
baz();
-
bar
被调用,返回setTimeout
的调用; - 传入
setTimeout
的回调被传递给Web API处理,setTimeout
执行完毕出栈,bar执行完毕出栈; - 定时器开始运行,同时主线程中
foo
被调用,打印First
,foo
执行完毕出栈; -
baz
被调用,打印Third
,baz
执行完毕出栈; - 500ms后定时器运行完毕,回调函数被放入任务队列;
- Event Loop检测到调用栈为空,从任务队列中取出回调函数放入调用栈;
- 回调函数被执行,打印
Second
,执行完毕出栈。
定时器
除了放置异步任务的事件,"任务队列"还可以放置定时事件,即指定某些代码在多少时间之后执行。这叫做"定时器"功能,也就是定时执行的代码。
定时器功能主要由setTimeout()
和setInterval()
这两个函数来完成,它们的内部运行机制完全一样,区别在于前者指定的代码是一次性执行,后者则为反复执行。setTimeout()
接受两个参数,第一个是回调函数,第二个是推迟执行的毫秒数。
console.log(1);
setTimeout(function(){console.log(2);},1000);
console.log(3);
上面代码的执行结果是1,3,2。
如果将setTimeout()
的第二个参数设为0,就表示当前代码执行完(执行栈清空)以后,立即执行(0毫秒间隔)指定的回调函数。
setTimeout(function(){console.log(1);}, 0);
console.log(2);
总之,setTimeout(fn,0)
的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在"任务队列"的尾部添加一个事件,因此要等到同步任务和"任务队列"现有的事件都处理完,才会得到执行。
H5标准规定了setTimeout()
的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。在此之前,老版本的浏览器都将最短间隔设为10毫秒。另外,对于那些DOM的变动(尤其是涉及页面重新渲染的部分),通常不会立即执行,而是每16毫秒执行一次。这时使用requestAnimationFrame()
的效果要好于setTimeout()
。
需要注意的是,setTimeout()
只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()
指定的时间执行。
宏任务与微任务
除了广义的同步任务和异步任务之外,还有对任务更精细的划分,分为:
macrotask
(又称之为宏任务),可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)
- 每一个
task
会从头到尾将这个任务执行完毕,不会执行其它 - 浏览器为了能够使得JS内部
task
与DOM任务能够有序的执行,会在一个task
执行结束后,在下一个task
执行开始前,对页面进行重新渲染(task->渲染->task->...
)
microtask
(又称为微任务),可以理解是在当前task
执行结束后立即执行的任务
- 也就是说,在当前
task
任务后,下一个task
之前,在渲染之前 - 所以它的响应速度相比
setTimeout
(setTimeout
是task
)会更快,因为无需等渲染 - 也就是说,在某一个
macrotask
执行完后,就会将在它执行期间产生的所有microtask
都执行完毕(在渲染前)
在ECMAScript中,microtask
称为jobs
,macrotask
可称为task
。
形成macrotask
和microtask
的场景:
-
macrotask
:主代码块、setTimeout、setInterval
-
microtask
:Promise、process.nextTick
不同类型的任务会进入对应的任务队列,比如setTimeout
和setInterval
会进入相同的任务队列。
事件循环的顺序,决定js代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。
所以js运行过程:
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
网友评论