进程和线程之间的关联关系?

在父进程中通过fork()函数可以创建子进程,如果返回值==0,为子进程;否则是为父进程。子进程得到的除了代码段是与父进程共享以外,其他所有的都是父进程的一个副本,子进程的所有资源都继承父进程,得到父进程资源的副本,但是二者并不共享地址空间。两个是单独的进程,继承了以后二者就没有什么关联,子进程单独运行。采用写时复制技术。

关于文件描述符:继承父进程的文件描述符时,相当于调用了dup函数,父子进程共享文件表项,即共同操作同一个文件,一个进程修改了文件,另一个进程也知道此文件被修改。

父进程通过wait()和waitpid()函数可以监控子进程。

join()函数是一个等待线程完成函数,主线程需要等待子线程运行结束了才可以结束。当构造线程时,入口函数的参数类型即使参数类型是引用,但是在线程中还是作为值传递。

  t.join(); //等待子线程结束后才进入主线程   t.join(); //等待子线程结束后才进入主线程
  t.join(); //等待子线程结束后才进入主线程

称为分离线程函数,使用detach()函数会让线程在后台运行,即说明主线程不会等待子线程运行结束才结束
通常称分离线程为守护线程(daemon threads),UNIX中守护线程是指,没有任何显式的用户接口,并在后台运行的线程。这种线程的特点就是长时间运行;线程的生命周期可能会从某一个应用起始到结束,可能会在后台监视文件系统,还有可能对缓存进行清理,亦或对数据结构进行优化。另一方面,分离线程的另一方面只能确定线程什么时候结束,发后即忘(fire andforget)的任务就使用到线程的这种方式。

当构造线程时,入口函数的参数类型即使参数类型是引用,但是在线程中还是作为值传递。

我在多年的工程生涯中发现很多工程师碰到一个共性的问题:Linux工程师很多,甚至有很多有多年工作经验,但是对一些关键概念的理解非常模糊,比如不理解CPU、内存资源等的真正分布,具体的工作机制,这使得他们对很多问题的分析都摸不到方向。比如进程的调度延时是多少?Linux能否硬实时?多核下多线程如何执行?系统的内存究竟耗到哪里去了?我写的应用程序究竟耗了多少内存?什么是内存泄漏,如何判定内存是否真的泄漏?CPU速度、内存大小和系统性能的关联究竟是什么?内存和I/O存在着怎样的千丝万缕的联系?

若不能回答上述问题,势必造成Linux开发过程中的抓瞎,出现关键bug和性能问题后丈二摸不着。从某种意义上来说,进程调度和内存管理之于Linux,类似任督两脉之于人体。任督两脉属于奇经八脉,任脉主血,为阴脉之海;督脉主气,为阳脉之海。任督两脉分别对十二正经脉中的手足六阴经与六阳经脉起着主导作用,任督通则百脉皆通。对进程调度和内存管理的理解,可以极大地打通我们对Linux系统架构,性能瓶颈,进程资源消耗等一系列问题的理解。

但是,对这两个知识点的理解,本身有一定的难度,尤其是内存管理,看资料都很难看懂。若调度器是悬疑惊悚片鬼才大卫·林奇的《穆赫兰道》,内存管理则极似他的《内陆帝国》,为Linux最晦涩的部分。坦白讲,《穆赫兰道》给我的感觉是晦涩而惊艳,而《内陆帝国》让我感觉到自己在吃屎,实在是只有阴暗、晦涩、看不到希望。

我在学习Linux内存管理的时候,同样有看《内陆帝国》的强烈不愉悦感,整部电影构造的弗洛伊德《梦的解析》的世界有太多苍白的细节,沉闷的对白,阴暗的画面,而没有一个最初层叠的整体概念。逃离这个噩梦,唯一的方法,我们势必应该以一种最简单可靠地方式来理解进程调度和内存管理的精髓,这个时候,细节已经显得不那么重要,而concept则需要吃透再吃透。很多人读Linux的书陷入了纷繁芜杂的细节,而没有理解concept,这个时候,细节会显得那么苍白无力和流离失所。所以,我们更有必要明确每一个工作机制,以及这些工作机制背后的原因,此后,细节只是一个具体的实现。细节是会变的,唯概念不破。

一切的学习都是为了解决问题,而不是为了学习而学习。为了学习而学习,这种行为实在是太傻了,因为最终也学不好。所以我们要弄清楚进程调度和内存管理究竟能解决什么样的问题。

Linux进程调度以及配套的进程管理回答如下问题:

1.Linux进程和线程如何创建、退出?进程退出的时候,自己没有释放的资源(如内存没有free)会怎样?

3.Linux的线程如何实现,与进程的本质区别是什么?

4.Linux能否满足硬实时的需求?

5.进程如何睡眠等资源,此后又如何被唤醒?

6.进程的调度延时是多少?

7.调度器追求的吞吐率和响应延迟之间是什么关系?CPU消耗型和I/O消耗型进程的诉求?

8.Linux怎么区分进程优先级?实时的调度策略和普通调度策略有什么区别?

9.nice值的作用是什么?nice值低有什么优势?

10.Linux可以被改造成硬实时吗?有什么方案?

11.多核、多线程的情况下,Linux如何实现进程的负载均衡?

12.这么多线程,究竟哪个线程在哪个CPU核上跑?有没有办法把某个线程固定到某个CPU跑?

13.多核下如何实现中断、软中断的负载均衡?

14.如何利用cgroup对进行进程分组,并调控各个group的CPU资源?

15.CPU利用率和CPU负载之间的关系?CPU负载高一定用户体验差吗?

Linux内存管理回答如下问题:

1.Linux系统的内存用掉了多少,还剩余多少?下面这个free命令每一个数字是什么意思?

3.系统的内存是如何被内核和应用瓜分掉的?

4.底层的内存管理算法buddy是怎么工作的?它和内核里面的slab分配器是什么关系?

5.频繁的内存申请和释放是否会导致内存的碎片化?它的后果是什么?

6.Linux内存耗尽后,系统会发生怎样的情况?

7.应用程序的内存是什么时候拿到的?malloc()成功后,是否真的拿到了内存?应用程序的malloc()与free()与内核的关系究竟是什么?

8.什么是lazy分配机制?应用的内存为什么会延后以最懒惰的方式拿到?

9.我写的应用究竟耗费了多少内存?进程的vss/rss/pss/uss分别是什么概念?虚拟的,真实的,共享的,独占的,究竟哪个是哪个?

10.内存为什么要做文件系统的缓存?如何做?缓存何时放弃?

11.Free命令里面显示的buffers和cached分别是什么?二者有何区别?

12.交换分区、虚拟内存究竟是什么鬼?它们针对的是什么性质的内存?什么是匿名页?

13.进程耗费的内存、文件系统的缓存何时回收?回收的算法是不是类似LRU?

14.怎样追踪和判决发生了内存泄漏?内存泄漏后如何查找泄漏源?

15.内存大小这样影响系统的性能?CPU、内存、I/O三角如何互动?它们如何综合决定系统的一些关键性能?

以上问题,如果您都能回答,那么恭喜您,您是一个概念清楚的人,Linux出现吞吐低、延迟大、响应慢等问题的时候,你可以找到一个可能的方向。如果您只能回答低于1/3的问题,那么,Linux对您仍然是一片空白,出现问题,您只会陷入瞎猫子乱抓,而捞不到耗子的困境,或者胡乱地意测问题,陷入不断的低水平重试。

本文的目的不是回答这些问题,因为回答这些问题,需要洋洋洒洒数百页的文档,而本文档不会超过10页。所以,本文的目的是试图给出一个回答这些问题的思考问题的出发点,我们倡导面对任何问题的时候,先要弄明白系统的设计目标。

首先我们在思考调度器的时候,我们要理解任何操作系统的调度器设计只追求2个目标:吞吐率大和延迟低。这2个目标有点类似零和游戏,因为吞吐率要大,势必要把更多的时间放在做真实的有用功,而不是把时间浪费在频繁的进程上下文切换;而延迟要低,势必要求优先级高的进程可以随时抢占进来,打断别人,强行插队。但是,抢占会引起上下文切换,上下文切换的时间本身对吞吐率来讲,是一个消耗,这个消耗可以低到2us或者更低(这看起来没什么?),但是上下文切换更大的消耗不是切换本身,而是切换会引起大量的cache miss。你明明weibo跑的很爽,现在切过去微信,那么CPU的cache是不太容易命中微信的。

不抢肯定响应差,抢了吞吐会下降。Linux不是一个完全照顾吞吐的系统,也不是一个完全照顾响应的系统,它作为一个软实时的操作系统,实际上是想达到某种平衡,同时也提供给用户一定的配置能力,在内核编译的时候,Kernel Features ---> Preemption Model选项实际上可以让我们编译内核的时候,是倾向于支持吞吐,还是支持响应:

越往上面选,吞吐越好,越好下面选,响应越好。服务器你一个月也难得用一次鼠标,而桌面则显然要求一定的响应,这样可以保证UI行为的表现较好。但是Linux即便选择的是最后一个选项“Preemptible Kernel (Low-Latency Desktop)”,它仍然不是硬实时的。因为,在Linux有三类区间是不可以抢占调度的,这三类区间是:

  • 持有类似spin_lock这样的锁而锁住该CPU核调度的情况

如下图,一个绿色的普通进程在T1时刻持有spin_lock进入一个critical section(该核调度被关),绿色进程T2时刻被中断打断,而后T3时刻IRQ1里面唤醒了红色的RT进程(如果是硬实时RTOS,这个时候RT进程应该能抢入),之后IRQ1后又执行了IRQ2,到T4时刻IRQ1和IRQ2都结束了,红色RT进程仍然不能执行(因为绿色进程还在spin_lock里面),直到T5时刻,普通进程释放spin_lock后,红色RT进程才抢入。从T3到T5要多久,鬼都不知道,这样就无法满足硬实时系统的“可预期”的确定性的延迟性,因此Linux不是硬实时操作系统。

Linux的preempt-rt补丁试图把中断、软中断线程化,变成可以被抢占的区间,而把会关本核调度器的spin_lock替换为可以调度的mutex,它实现了在T3时刻唤醒RT进程的时刻,RT进程可以立即抢占调度进入的目标,避免了T3-T5之间延迟的非确定性。

在Linux运行的进程,分为2类,一类是CPU消耗型(狂算),一类是I/O消耗型(狂睡,等I/O),前者CPU利用率高,后者CPU利用率低。一般而言,I/O消耗型任务对延迟比较敏感,应该被优先调度。比如,你正在疯狂编译安卓,而等鼠标行为的用户界面老不工作(正在狂睡),但是鼠标一点,我们应该优先打断正在编译的进程,而去响应鼠标这个I/O,这样电脑的用户体验才符合人性。

Linux的进程,对于RT进程而言,按照SCHED_FIFO和SCHED_RR的策略,优先级高先执行;优先级高的睡眠了后优先级的执行;同等优先级的SCHED_FIFO先ready的跑到睡,后ready的接着跑;而同等优先级的RR则进行时间片轮转。比如Linux存在如下4个进程,T1~T4(内核里面优先级数字越低,优先级越高):

那么它们在Linux的跑法就是:

RT的进程调度有一点“恶霸”色彩,我高优先级的没睡,低优先级的你就靠边站。但是Linux的绝大多数进程都不是RT的进程,而是采用SCHED_NORMAL策略(这符合蜘蛛侠法则)。NORMAL的人比较善良,我们一般用nice来形容它们的优先级,nice越高,优先级越低(你越nice,就越喜欢在地铁让座,当然越坐不到座位)。普通进程的跑法,并不是nice低的一定堵着nice高的(要不然还说什么“善良”),它是按照如下公式进行:

在RT进程都睡过去之后(有一个特例就是RT没睡也会跑普通进程,那就是RT加起来跑地实在太久太久,普通进程必须喝点汤了),Linux开始跑NORMAL的,它倾向于调度vruntime(虚拟运行时间)最小的普通进程,根据我们小学数学知识,vruntime要小,要么分子小(喜欢睡,I/O型进程,pruntime不容易长大),要么分母大(nice值低,优先级高,权重大)。这样一个简单的公式,就同时照顾了普通进程的优先级和CPU/IO消耗情况。

比如有4个普通进程,如下表,目前显然T1的vruntime最小(这是它喜欢睡的结果),然后T1被调度到。

所以,普通进程的调度,是一个综合考虑你喜欢干活还是喜欢睡和你的nice值是多少的结果。鉴于此,我们去问一个普通进程的调度延迟究竟有多大,这个问题,本身意义就不是特别大,它完全取决于当前的系统里面还有谁在跑,取决于你唤醒的进程的nice和它前面喜欢不喜欢睡觉。

明白了这一点,你就不会在Linux里面问一些让回答的人吐血的问题。比如,一个普通进程多久被调度到?明确地说,不知道!装逼的说法,就是“depend on …”,依赖的东西太多。再装逼的说法,就是“一言难尽”,但这也是大实话。

Linux作为一个把应用程序员当傻逼的操作系统,它必须允许应用程序犯错。所以这类问题就不要问了:进程malloc()了内存,还没有free()就挂了,那么我前面分配的内存没有释放,是不是就泄漏掉了?明确的说,这是不可能的,Linux内核如果这么傻,它是无法应付乱七八糟的各种开源有漏洞软件的,所以进程死的时候,肯定是资源皆被内核释放的,这类傻问题,你明白Linux的出发点,就不会再去问了。

同样的,你在应用程序里面malloc()成功的一刻,也不要以为真的拿到了内存,这个时候你的vss(虚拟地址空间,Virtual Set Size)会增大,但是你的rss(驻留在内存条上的内存,Virtual Set Size)内存会随着写到每一页而缓慢增大。所以,分配成功的一刻,顶多只是被忽悠了,和你实际占有还是不占有,暂时没有半毛钱关系。

如下图,最初的堆是8KB,这8KB也写过了,所以堆的vss和rss都是8KB。此后我们调用brk()把堆变大到16KB,但是实际上它占据的内存rss还是8KB,因为第3页还没有写,根本没有真正从内存条上拿到内存。直到写第3页,堆的rss才变为12KB。这就是Linux针对app的lazy分配机制,它的出发点,当然也是防止应用程序傻逼了。

代码段的内存、堆的内存、栈的内存都是这样懒惰地拿到,demanding page。

我们有一台1GB内存的32位Linux系统,我们关闭swap,同时透过修改overcommit_memory为1来允许申请不超过进程虚拟地址空间的内存:

此后,我们的应用可以申请一个超级大的内存(比实际内存还大):

上述程序在1GB的电脑上面运行,申请2GB内存可以申请成功,但是在写到一定程度后,系统出现out-of-memory,上述程序对应的进程作为oom_score最大(最该死的)的进程被系统杀死。

Linux进程究竟耗费了多少内存,是一个非常复杂的概念,除了上面的vss, rss外,还有pss和uss,这些都是Linux不同于RTOS的显著特点之一。Linux各个进程既要做到隔离,但是隔离中又要实现共享,比如1000个进程都用libc,libc的代码段显然在内存只应该有一份。

下面的一幅图上有3个进程,pid为1044的 bash、pid为1045的 bash和pid为1054的 cat。每个进程透过自己的页表,把虚拟地址空间指向内存条上面的物理地址,每次切换一个进程,即切换一份独特的页表。

仅从此图而言,进程1044的vss和rss分别是:

但是是不是“4+5+6”就是1044这个进程耗费的内存呢?这显然也是不准确的,因为4明显被3个进程指向,5明显被2个进程指向,坏事是大家一起干的,不能1044一个人背黑锅。这个时候,就衍生出了一个pss(按比例计算的驻留内存,

最后,还有进程1044独占且驻留的内存uss(Unique Set Size ),仅从此图而言,

所以,分析Linux,我们不能模棱两可地停留于表面,或者想当然地说:“Linux的进程耗费了多少内存?”因为这个问题,又是一个要靠装逼来回答的问题,“dependon…”。坦白讲,每次当我问到老外问题,老外第一句话就是“depend on…”的时候,我就想上去抽他了,但是我又抑制了这个冲动,因为,很多问题,不是简单的0和1问题,正反问题,黑白问题,它确实是一个“depend on

有时候,小白问大拿一个问题,大拿实在是无法正面回答,于是就支支吾吾一番。这个时候小白会很生气,觉得大拿态度不好,或者在装逼。你实际上,明白很多问题不是简单的0与1问题之后,你就会理解,他真的不是在装逼。这个时候,我们要反过来检讨自己,是不是我们自己问的问题太LOW逼了?

我们前面提出了30个问题,而本文也仅仅只是回答了其中极少的一部分。此文的目的在于建立思维,导入方向,而不是洋洋洒洒地把所有问题回答掉,因为哥确实没有时间写个几百页的文档来一一回答这些问题。很多事情,用口头描述,比直接写冗长地文档要更加容易也轻松。

最后,我仍然想要强调的一个观点是,我们在思维Linux的时候,更多地可以把自己想象成Linus Torvalds,如果你是Linus Torvalds,你要设计Linux,你碰到某个诉求,比如调度器和内存方面的诉求,你应该如何解决。我们不是被动地接受“是什么”,更多地要思考“为什么”,“怎么办”。

如果你是Linus Torvalds,有个傻逼应用程序员要申请1GB内存,你是直接给他,还是假装给他,但是实际没有给他,直到它写的时候再给他?

如果你是Linus Torvalds,有个家伙打开了串口,然后进程就做个1/0运算或者访问空指针挂了,你要不要在这个进程挂的时候给它关闭串口?

如果你是Linus Torvalds,你是要让nice值低(优先级高)的普通进程在睡眠前一直堵着nice值高的进程,还是虽然它优先级高,但是由于跑的时间比较长后,也要让给优先级低(nice值高)的进程?如果你认为nice值低的应该一直跑,那么如何照顾喜欢睡觉的I/O消耗型进程?万一nice值低的进程有bug,进入死循环,那么nice高的进程岂不是丝毫机会都没有?这样的设计,是不是反人类?

当你带着这些思考,武装这些concept,再去看Linux的时候,你就从被动的“接受”,变成了主动地“思考”,这正好是任何一个优秀程序员都具备的品质,也是打通进程调度和内存管理任督二脉的关键。


原来便在这顷刻之间,张无忌所练的九阳神功已然大功告成,水火相济,龙虎交会。要知布袋内真气充沛,等于是数十位高手各出真力,同时按摩挤逼他周身数百处穴道,他内内外外的真气激荡,身上数十处玄关一一冲破,只觉全身脉络之中,有如一条条水银在到处流转,舒适无比。

——金庸 《倚天屠龙记》


最近发现有不少介绍JS单线程运行机制的文章,但是发现很多都仅仅是介绍某一部分的知识,而且各个地方的说法还不统一,容易造成困惑。
因此准备梳理这块知识点,结合已有的认知,基于网上的大量参考资料,
从浏览器多进程到JS单线程,将JS引擎的运行机制系统的梳理一遍。

展现形式:由于是属于系统梳理型,就没有由浅入深了,而是从头到尾的梳理知识体系,
重点是将关键节点的知识点串联起来,而不是仅仅剖析某一部分知识。

内容是:从浏览器进程,再到浏览器内核运行,再到JS引擎单线程,再到JS事件循环机制,从头到尾系统的梳理一遍,摆脱碎片化,形成一个知识体系

目标是:看完这篇文章后,对浏览器多进程,JS单线程,JS事件循环机制这些都能有一定理解,
有一个知识体系骨架,而不是似懂非懂的感觉。

另外,本文适合有一定经验的前端人员,新手请规避,避免受到过多的概念冲击。可以先存起来,有了一定理解后再看,也可以分成多批次观看,避免过度疲劳。

    • 浏览器都包含哪些进程?
    • 重点是浏览器内核(渲染进程)
    • Browser进程和浏览器内核(Renderer进程)的通信过程
  • 梳理浏览器内核中线程之间的关系
    • GUI渲染线程与JS引擎线程互斥
  • 简单梳理下浏览器渲染流程
    • css加载是否会阻塞dom树渲染?
    • 事件循环机制进一步补充

线程和进程区分不清,是很多新手都会犯的错误,没有关系。这很正常。先看看下面这个形象的比喻:

- 进程是一个工厂,工厂有它的独立资源
- 线程是工厂中的工人,多个工人协作完成任务
- 工厂内有一个或多个工人
 
- 工厂的资源 -> 系统分配的内存(独立的一块内存)
- 工厂之间的相互独立 -> 进程之间相互独立
- 多个工人协作完成任务 -> 多个线程在进程中协作完成任务
- 工厂内有一个或多个工人 -> 一个进程由一个或多个线程组成
- 工人之间共享空间 -> 同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)

如果是windows电脑中,可以打开任务管理器,可以看到有一个后台进程列表。对,那里就是查看进程的地方,而且可以看到每个进程的内存资源信息以及cpu占有率。

所以,应该更容易理解了:进程是cpu资源分配的最小单位(系统会给它分配内存)

最后,再用较为官方的术语描述一遍:

  • 进程是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位)
  • 线程是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)
  • 不同进程之间也可以通信,不过代价较大
  • 现在,一般通用的叫法:单线程与多线程,都是指在一个进程内的单和多。(所以核心还是得属于一个进程才行)

理解了进程与线程了区别后,接下来对浏览器进行一定程度上的认识:(先看下简化理解)

  • 浏览器之所以能够运行,是因为系统给它的进程分配了资源(cpu、内存)
  • 简单点理解,每打开一个Tab页,就相当于创建了一个独立的浏览器进程。

关于以上几点的验证,请再第一张图

图中打开了Chrome浏览器的多个标签页,然后可以在Chrome的任务管理器中看到有多个进程(分别是每一个Tab页面有一个独立的进程,以及一个主进程)。
感兴趣的可以自行尝试下,如果再多打开一个Tab页,进程正常会+1以上

注意:在这里浏览器应该也有自己的优化机制,有时候打开多个tab页后,可以在Chrome任务管理器中看到,有些进程被合并了
(所以每一个Tab标签对应一个进程并不一定是绝对的)

浏览器都包含哪些进程?

知道了浏览器是多进程后,再来看看它到底包含哪些进程:(为了简化理解,仅列举主要进程)

  1. Browser进程:浏览器的主进程(负责协调、主控),只有一个。作用有
  • 负责浏览器界面显示,与用户交互。如前进,后退等
  • 负责各个页面的管理,创建和销毁其他进程
  • 将Renderer进程得到的内存中的Bitmap,绘制到用户界面上
  • 网络资源的管理,下载等
  • 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建
  • GPU进程:最多一个,用于3D绘制等
  • 浏览器渲染进程(浏览器内核)(Renderer进程,内部是多线程的):默认每个Tab页面一个进程,互不影响。主要作用为
    • 页面渲染,脚本执行,事件处理等

    强化记忆:在浏览器中打开一个网页相当于新起了一个进程(进程内有自己的多线程)

    当然,浏览器有时会将多个进程合并(譬如打开多个空白标签页后,会发现多个空白标签页被合并成了一个进程),如图

    另外,可以通过Chrome的更多工具 -> 任务管理器自行验证

    相比于单进程浏览器,多进程有如下优点:

    • 避免单个page crash影响整个浏览器
    • 避免第三方插件crash影响整个浏览器
    • 多进程充分利用多核优势
    • 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性

    简单点理解:如果浏览器是单进程,那么某个Tab页崩溃了,就影响了整个浏览器,体验有多差;同理如果是单进程,插件崩溃了也会影响整个浏览器;而且多进程还有其它的诸多优势。。。

    当然,内存等资源消耗也会更大,有点空间换时间的意思。

    重点是浏览器内核(渲染进程)

    重点来了,我们可以看到,上面提到了这么多的进程,那么,对于普通的前端操作来说,最终要的是什么呢?答案是渲染进程

    可以这样理解,页面的渲染,JS的执行,事件的循环,都在这个进程内进行。接下来重点分析这个进程

    请牢记,浏览器的渲染进程是多线程的(这点如果不理解,请回头看进程和线程的区分

    终于到了线程这个概念了?,好亲切。那么接下来看看它都包含了哪些线程(列举一些主要常驻线程):

    • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
    • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
    • 注意,GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
    • 也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)
    • JS引擎线程负责解析Javascript脚本,运行代码。
    • JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
    • 同样注意,GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
    • 归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)
    • 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中
    • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
    • 注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)
    • 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
    • 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
    • 注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。
    • 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
    • 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。

    看到这里,如果觉得累了,可以先休息下,这些概念需要被消化,毕竟后续将提到的事件循环机制就是基于事件触发线程的,所以如果仅仅是看某个碎片化知识,
    可能会有一种似懂非懂的感觉。要完成的梳理一遍才能快速沉淀,不易遗忘。放张图巩固下吧:

    再说一点,为什么JS引擎是单线程的?额,这个问题其实应该没有标准答案,譬如,可能仅仅是因为由于多线程的复杂性,譬如多线程操作一般要加锁,因此最初设计时选择了单线程。。。

    Browser进程和浏览器内核(Renderer进程)的通信过程

    看到这里,首先,应该对浏览器内的进程和线程都有一定理解了,那么接下来,再谈谈浏览器的Browser进程(控制进程)是如何和内核通信的,
    这点也理解后,就可以将这部分的知识串联起来,从头到尾有一个完整的概念。

    如果自己打开任务管理器,然后打开一个浏览器,就可以看到:任务管理器中出现了两个进程(一个是主控进程,一个则是打开Tab页的渲染进程)
    然后在这前提下,看下整个的过程:(简化了很多)

    • Browser进程收到用户请求,首先需要获取页面内容(譬如通过网络下载资源),随后将该任务通过RendererHost接口传递给Render进程
    • Renderer进程的Renderer接口收到消息,简单解释后,交给渲染线程,然后开始渲染
      • 渲染线程接收请求,加载网页并渲染网页,这其中可能需要Browser进程获取资源和需要GPU进程来帮助渲染
      • 当然可能会有JS线程操作DOM(这样可能会造成回流并重绘)
    • Browser进程接收到结果并将结果绘制出来

    这里绘一张简单的图:(很简化)

    看完这一整套流程,应该对浏览器的运作有了一定理解了,这样有了知识架构的基础后,后续就方便往上填充内容。

    这块再往深处讲的话就涉及到浏览器内核源码解析了,不属于本文范围。

    如果这一块要深挖,建议去读一些浏览器内核源码解析文章,或者可以先看看参考下来源中的第一篇文章,写的不错

    梳理浏览器内核中线程之间的关系

    到了这里,已经对浏览器的运行有了一个整体的概念,接下来,先简单梳理一些概念

    GUI渲染线程与JS引擎线程互斥

    由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JS线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。

    因此为了防止渲染出现不可预期的结果,浏览器设置GUI渲染线程与JS引擎为互斥的关系,当JS引擎执行时GUI线程会被挂起,
    GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。

    从上述的互斥关系,可以推导出,JS如果执行时间过长就会阻塞页面。

    譬如,假设JS引擎正在进行巨量的计算,此时就算GUI有更新,也会被保存到队列中,等待JS引擎空闲后执行。
    然后,由于巨量计算,所以JS引擎很可能很久很久后才能空闲,自然会感觉到巨卡无比。

    所以,要尽量避免JS执行时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

    前文中有提到JS引擎是单线程的,而且JS执行时间过长会阻塞页面,那么JS就真的对cpu密集型计算无能为力么?

    Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面
    这个文件包含将在工作线程中运行的代码; workers 运行在另一个全局上下文中,不同于当前的window
    因此,使用 window快捷方式获取当前全局的范围 (而不是self) 在一个 Worker 内将返回错误
    • 创建Worker时,JS引擎向浏览器申请开一个子线程(子线程是浏览器开的,完全受主线程控制,而且不能操作DOM)
    • JS引擎线程与worker线程间通过特定的方式通信(postMessage API,需要通过序列化对象来与线程交互特定的数据)

    所以,如果有非常耗时的工作,请单独开一个Worker线程,这样里面不管如何翻天覆地都不会影响JS引擎主线程,
    只待计算出结果后,将结果通信给主线程即可,perfect!

    而且注意下,JS引擎是单线程的,这一点的本质仍然未改变,Worker可以理解是浏览器给JS引擎开的外挂,专门用来解决那些大量计算问题。

    其它,关于Worker的详解就不是本文的范畴了,因此不再赘述。

    既然都到了这里,就再提一下SharedWorker(避免后续将这两个概念搞混)

    • WebWorker只属于某个页面,不会和其他页面的Render进程(浏览器内核进程)共享
    • SharedWorker是浏览器所有页面共享的,不能采用与Worker同样的方式实现,因为它不隶属于某个Render进程,可以为多个Render进程共享使用

    看到这里,应该就很容易明白了,本质上就是进程和线程的区别。SharedWorker由独立的进程管理,WebWorker只是属于render进程下的一个线程

    简单梳理下浏览器渲染流程

    本来是直接计划开始谈JS运行机制的,但想了想,既然上述都一直在谈浏览器,直接跳到JS可能再突兀,因此,中间再补充下浏览器的渲染流程(简单版本)

    为了简化理解,前期工作直接省略成:(要展开的或完全可以写另一篇超长文)

    - 浏览器输入url,浏览器主进程接管,开一个下载线程,
    然后进行 http请求(略去DNS查询,IP寻址等等操作),然后等待响应,获取内容,
    - 浏览器渲染流程开始

    浏览器器内核拿到内容后,渲染大概可以划分成以下几个步骤:

    1. 解析css构建render树(将CSS代码解析成树形的数据结构,然后结合DOM合并成render树)
    2. 绘制render树(paint),绘制页面像素信息
    3. 浏览器会将各层的信息发送给GPU,GPU会将各层合成(composite),显示在屏幕上。

    所有详细步骤都已经略去,渲染完毕后就是load事件了,之后就是自己的JS逻辑处理了

    既然略去了一些详细的步骤,那么就提一些可能需要注意的细节把。

    这里重绘参考来源中的一张图:(参考来源第一篇)

    上面提到,渲染完毕后会触发load事件,那么你能分清楚load事件与DOMContentLoaded事件的先后么?

    很简单,知道它们的定义就可以了:

    • 当 DOMContentLoaded 事件触发时,仅当DOM加载完成,不包括样式表,图片。

    (譬如如果有async加载的脚本就不一定完成)

    • 当 onload 事件触发时,页面上所有的DOM,样式表,脚本,图片都已经加载完成了。

    css加载是否会阻塞dom树渲染?

    这里说的是头部引入css的情况

    首先,我们都知道:css是由单独的下载线程异步下载的。

    • css加载不会阻塞DOM树解析(异步加载时DOM照常构建)
    • 但会阻塞render树渲染(渲染时需等css加载完毕,因为render树需要css信息)

    这可能也是浏览器的一种优化机制。

    因为你加载css的时候,可能会修改下面DOM节点的样式,
    如果css加载不阻塞render树渲染的话,那么当css加载完之后,
    render树可能又得重新重绘或者回流了,这就造成了一些没有必要的损耗。
    所以干脆就先把DOM树的结构先解析完,把可以做的工作做完,然后等你css加载完之后,
    在根据最终的样式来渲染render树,这种做法性能方面确实会比较好一点。

    渲染步骤中就提到了composite概念。

    可以简单的这样理解,浏览器渲染的图层一般包含两大类:普通图层以及复合图层

    首先,普通文档流内可以理解为一个复合图层(这里称为默认复合层,里面不管添加多少元素,其实都是在同一个复合图层中)

    其次,absolute布局(fixed也一样),虽然可以脱离普通文档流,但它仍然属于默认复合层

    然后,可以通过硬件加速的方式,声明一个新的复合图层,它会单独分配资源
    (当然也会脱离普通文档流,这样一来,不管这个复合图层中怎么变化,也不会影响默认复合层里的回流重绘)

    可以简单理解下:GPU中,各个复合图层是单独绘制的,所以互不影响,这也是为什么某些场景硬件加速效果一级棒

    如下图。可以验证上述的说法

    如何变成复合图层(硬件加速)

    将该元素变成一个复合图层,就是传说中的硬件加速技术

    • opacity属性/过渡动画(需要动画执行的过程中才会创建合成层,动画没有开始或结束后元素还会回到之前的状态)
    • will-chang属性(这个比较偏僻),一般配合opacity与translate使用(而且经测试,除了上述可以引发硬件加速的属性外,其它属性并不会变成复合层),

    作用是提前告诉浏览器要变化,这样浏览器会开始做一些优化工作(这个最好用完后就释放)

    • 其它,譬如以前的flash插件

    可以看到,absolute虽然可以脱离普通文档流,但是无法脱离默认复合层。
    所以,就算absolute中信息改变时不会改变普通文档流中render树,
    但是,浏览器最终绘制时,是整个复合层绘制的,所以absolute中信息的改变,仍然会影响整个复合层的绘制。
    (浏览器会重绘它,如果复合层中内容多,absolute带来的绘制信息变化过大,资源消耗是非常严重的)

    而硬件加速直接就是在另一个复合层了(另起炉灶),所以它的信息改变不会影响默认复合层
    (当然了,内部肯定会影响属于自己的复合层),仅仅是引发最后的合成(输出视图)

    一般一个元素开启硬件加速后会变成复合图层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能

    但是尽量不要大量使用复合图层,否则由于资源消耗过度,页面反而会变的更卡

    硬件加速时请使用index

    使用硬件加速时,尽可能的使用index,防止浏览器默认给后续的元素创建复合层渲染

    **webkit CSS3中,如果这个元素添加了硬件加速,并且index层级比较低,
    那么在这个元素的后面其它元素(层级比这个元素高的,或者相同的,并且releative或absolute属性相同的),
    会默认变为复合层渲染,如果处理不当会极大的影响性能**

    简单点理解,其实可以认为是一个隐式合成的概念:如果a是一个复合图层,而且b在a上面,那么b也会被隐式转为一个复合图层,这点需要特别注意

    另外,这个问题可以在这个地址看到重现(原作者分析的挺到位的,直接上链接):

    到此时,已经是属于浏览器页面初次渲染完毕后的事情,JS引擎的一些运行机制分析。

    注意,这里不谈可执行上下文VOscop chain等概念(这些完全可以整理成另一篇文章了),这里主要是结合Event Loop来谈JS代码是如何执行的。

    读这部分的前提是已经知道了JS引擎是单线程,而且这里会用到上文中的几个概念:(如果不是很理解,可以回头温习)

    • JS分为同步任务和异步任务
    • 同步任务都在主线程上执行,形成一个执行栈
    • 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件。
    • 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行。

    看到这里,应该就可以理解了:为什么有时候setTimeout推入的事件不能准时执行?因为可能在它推入到事件列表时,主线程还不空闲,正在执行其它代码,

    事件循环机制进一步补充

    这里就直接引用一张图片来协助理解:(参考自Philip Roberts的演讲《》)

    • 主线程运行时会产生执行栈,

    栈中的代码调用某些api时,它们会在事件队列中添加各种事件(当满足触发条件后,如ajax请求完毕)

    • 而栈中的代码执行完毕,就会读取事件队列中的事件,去执行那些回调
    • 注意,总是要等待栈中的代码执行完毕后才会去读取事件队列中的事件

    上述事件循环机制的核心是:JS引擎线程和事件触发线程

    但事件上,里面还有一些隐藏细节,譬如调用setTimeout后,是如何等待特定时间后才添加到事件队列中的?

    是JS引擎检测的么?当然不是了。它是由定时器线程控制(因为JS引擎自己都忙不过来,根本无暇分身)

    为什么要单独的定时器线程?因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确,因此很有必要单独开一个线程用来计时。

    什么时候会用到定时器线程?当使用setTimeoutsetInterval,它需要定时器线程计时,计时完成后就会将特定的事件推入事件队列中。

    这段代码的作用是当1000毫秒计时完毕后(由定时器线程计时),将回调函数推入事件队列中,等待主线程执行

    这段代码的效果是最快的时间内将回调函数推入事件队列中,等待主线程执行

    • 虽然代码的本意是0毫秒后就推入事件队列,但是W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。

    (不过也有一说是不同浏览器有不同的最小时间设定)

    • 就算不等待4ms,就算假设0毫秒就推入事件队列,也会先执行begin(因为只有可执行栈内空了后才会主动读取事件队列)

    因为每次setTimeout计时到后就会去执行,然后执行一段时间后才会继续setTimeout,中间就多了误差
    (误差多少与代码执行时间有关)

    而setInterval则是每次都精确的隔一段时间推入一个事件
    (但是,事件的实际执行时间不一定就准确,还有可能是这个事件还没执行完毕,下一个事件就来了)

    而且setInterval有一些比较致命的问题就是:

    • 累计效应(上面提到的),如果setInterval代码在(setInterval)再次添加到队列之前还没有完成执行,

    就会导致定时器代码连续运行好几次,而之间没有间隔。
    就算正常间隔执行,多个setInterval的代码执行时间可能会比预期小(因为代码执行需要一定时间)

    • 譬如像iOS的webview,或者Safari等浏览器中都有一个特点,在滚动的时候是不执行JS的,如果使用了setInterval,会发现在滚动结束后会执行多次由于滚动不执行JS积攒回调,如果回调执行时间过长,就会非常容器造成卡顿问题和一些不可知的错误(这一块后续有补充,setInterval自带的优化,不会重复添加回调)
    • 而且把浏览器最小化显示等操作时,setInterval并不是不执行程序,

    它会把setInterval的回调函数放在队列中,等浏览器窗口再次打开时,一瞬间全部执行时

    补充:JS高程中有提到,JS引擎会对setInterval进行优化,如果当前事件队列中有setInterval的回调,不会重复添加。不过,仍然是有很多问题。。。

    这段参考了参考来源中的第2篇文章(英文版的),(加了下自己的理解重新描述了下),
    强烈推荐有英文基础的同学直接观看原文,作者描述的很清晰,示例也很不错,如下:

    上文中将JS事件循环机制梳理了一遍,在ES5的情况是够用了,但是在ES6盛行的现在,仍然会遇到一些问题,譬如下面这题:

    嗯哼,它的正确执行顺序是这样子的:

    为什么呢?因为Promise里有了一个一个新的概念:microtask

    它们的定义?区别?简单点可以按如下理解:

    • macrotask(又称之为宏任务),可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)
      • 每一个task会从头到尾将这个任务执行完毕,不会执行其它
      • 浏览器为了能够使得JS内部task与DOM任务能够有序的执行,会在一个task执行结束后,在下一个 task 执行开始前,对页面进行重新渲染
    • microtask(又称为微任务),可以理解是在当前 task 执行结束后立即执行的任务
      • 也就是说,在当前task任务后,下一个task之前,在渲染之前
      • 也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)

    __补充:在node环境下,process.nextTick的优先级高于Promise__,也就是可以简单理解为:在宏任务结束后会先执行微任务队列中的nextTickQueue部分,然后才会执行微任务中的Promise部分。

    • macrotask中的事件都是放在一个事件队列中的,而这个队列由事件触发线程维护
    • microtask中的所有微任务都是添加到微任务队列(Job Queues)中,等待当前macrotask执行完毕后执行,而这个队列由JS引擎线程维护

    (这点由自己理解+推测得出,因为它是在主线程下无缝执行的)

    所以,总结下运行机制:

    • 执行一个宏任务(栈中没有就从事件队列中获取)
    • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
    • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
    • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
    • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

    另外,请注意下Promisepolyfill与官方版本的区别:

    • 官方版本中,是标准的microtask形式

    注意,有一些浏览器执行结果不一样(因为它们可能把microtask当成macrotask来执行了),
    但是为了简单,这里不描述一些不标准的浏览器下的场景(但记住,有些浏览器可能并不标准)

    它是HTML5中的新特性,作用是:监听一个DOM变动,

    像以前的Vue源码中就是利用它来模拟nextTick的,
    具体原理是,创建一个TextNode并监听内容变化,
    然后要nextTick的时候去改一下这个节点的文本内容,
    如下:(Vue的源码,未修改)

    (当然,默认情况仍然是Promise,不支持才兼容的)。

    看到这里,不知道对JS的运行机制是不是更加理解了,从头到尾梳理,而不是就某一个碎片化知识应该是会更清晰的吧?

    同时,也应该注意到了JS根本就没有想象的那么简单,前端的知识也是无穷无尽,层出不穷的概念、N多易忘的知识点、各式各样的框架、
    底层原理方面也是可以无限的往下深挖,然后你就会发现,你知道的太少了。。。

    另外,本文也打算先告一段落,其它的,如JS词法解析,可执行上下文以及VO等概念就不继续在本文中写了,后续可以考虑另开新的文章。

我要回帖

更多关于 简述进程和线程的区别 的文章

 

随机推荐