读《Google Chrome源码剖析》笔记,学习Chrome中优秀的设计思想。
零、【序】
1.关于开源
“开源是口好东西,它让这个充斥着大量工业垃圾代码和教材玩具代码的行业,多了一些艺术气息和美的潜质。它使得每个
人,无论你来自米国纽约还是中国铁岭,都有机会站在巨人的肩膀上”。
教材玩具的确铺天盖地的,到处的Hello World示例和各种教材习题。工业垃圾代码充斥?想想四年来的所谓企业级开发,也确
实如此。或是为了适应快速变更的业务开发充满Bug的代码,或是小心翼翼地担心影响所谓的Business而不敢冒险,一味地拷贝
从前的代码。企业级开发这样光鲜的名字背后却是我这样的小螺丝钉书写着微不足道的代码。不能仔细设计,因为没有时间,
因为销售人员不会等我们程序员。不能有所创新,因为怕出错影响Business...
2.取其精华
“我已经开始遛Chrome这头驴了,确切一点, 是头壮硕的肥驴,项目总大小接近2G。这样的庞然大物要从头到脚每个毛孔的
大量一遍,那估计不咽气也要吐血的,咱又不是做Code review,不需要如此拼命。每一个好的开源项目,都像是一个美女,
这世界没有十全十美的美女,自然也不会有样样杰出的开源项目。每个美女都有那么一两点让你最心动不已或者倍感神秘的,
你会把大部分的注意力都放在上面细细品味,看开源,也是一样。Chrome对我来说,有吸引力的地方在于(排名分先后…)”
1. 它是如何利用多进程(其实也会有多线程一起)做并发的,又是如何解决多进程间的一些问题的,比如进程间通信,进程的开销;
2. 做为一个后来者,它的扩展能力如何,如何去权衡对原有插件的兼容,提供怎么样的一个插件模型;
3. 它的整体框架是怎样,有没有很NB的架构思想;
4. 它如何实现跨平台的UI控件系统;
5. 传说中的V8,为啥那么快。
3.核心架构
Chrome精华的缩影,包含Browser和Render(可能有多个)两种主要进程。
还有每个插件对应一个Plugin进程在这幅图中没有画出。
一、【多线程模型】
1.进程模型
Google在宣传的时候一直都说,Chrome是one tab one process的模式,其实,这只是为了宣传起来方便如是说而已,
基本等同广告,实际疗效,还要从代码中来看。实际上,Chrome支持的进程模型远比宣传丰富,你可以参考一下这里 ,
简单的说,Chrome支持以下几种进程模型:
1. Process-per-site-instance:就是你打开一个网站,然后从这个网站链开的一系列网站都属于一个进程。这是Chrome的默认模式。
2. Process-per-site:同域名范畴的网站放在一个进程,比如www.google.com和www.google.com/bookmarks就属于一个域名内(google有 自己的判定机制),不论有没有互相打开的关系,都算作是一个进程中。用命令行–process-per-site开启。
3. Process-per-tab:这个简单,一个tab一个process,不论各个tab的站点有无联系,就和宣传的那样。用–process-per-tab开启。
4. Single Process:这个很熟悉了吧,传统浏览器的模式,没有多进程只有多线程,用–single-process开启。
2.主要线程
Render进程中的主要线程:对于Renderer进程,它们通常有两个线程,一个是Main
thread,它负责与老大进行联系,
有一些幕后黑手的意思;另一个是Render thread,它们负责页面的渲染和交互,一看就知道是这个帮派的门脸级人物。
Browser进程中的主要线程:相比之下,Browser进程既然是老大,小弟自然要多一些,除了大脑般的
Main thread,
和负责与各Renderer帮派通信的IO thread,其实还包括负责管文件的file thread,负责管数据库的db thread等等。
3.并发处理
“仔细回忆一下我们大部分时候是怎么来用线程的, 在我足够贫瘠的多线程经历中,往往都是这样用的:起一个线程,
传入一个特定的入口函数,看一下这个函数是否是有副作用的(Side Effect),如果有,并且还会涉及到多线程的数据
访问,仔细排查,在可疑地点上锁伺候。”
“Chrome的线程模型走的是另一个路子,即,极力规避锁的存在。
换更精确的描述方式来说,Chrome的线程模型,
将锁限制了极小的范围内(仅仅在将Task放入消息队列的时候才存在…),并且使得上层完全不需要 关心锁的问题。”
每一个Chrome线程的入口函数都是启动一个消息循环,等待并执行任务。所以锁只需加在向线程的任务队列加入Task
时。不同的线程有不同的的消息循环类,如负责进程间通信的线程启用MessagePumpForIO类,处理UI的线程用的是
MessagePumpForUI类,一般的线程用MessagePumpDefault类。
不同消息循环类的区别是:
1.消息循环中需要处理什么样的消息和任务。见下图示,包含了处理Windows消息、各种Task、各个信号量Watcher。
2.循环流程的不同。例如是死循环还是阻塞在某信号量上。
当然,不是每一个消息循环类都需要跑那么一大圈的,有些线程,它不会涉及到那么多的事情和逻辑。
在实现中,不同的MessagePump类,实现是有所不同的,详见下表:
MessagePumpDefault | MessagePumpForIO | MessagePumpForUI | |
是否需要处理系统消息 | 否 | 是 | 是 |
是否需要处理Task | 是 | 是 | 是 |
是否需要处理Watcher | 否 | 是 | 否 |
是否阻塞在信号量上 | 否 | 是 | 是 |
由此可见,不论哪种消息循环都必须处理Task,接下来看什么是Task。
4.Task任务
“Chrome的线程从实现层面来看没有任何区别,它们的区别只是职责的不同,即处理不同的Task。当你期望,
你的一个逻辑在某个线程内执行的时候,你可以派生一个Task,把你的逻辑封装在 Run方法中,然后实例一个
对象,调用期望线程中的PostTask方法,将该Task对象放入到其Task队列中去,等待执行。
这种手法实在是太常见了,就不是一个简单的依赖倒置,在线程池、UndoRedo等模块的实现中,用的太多了。
但在Chrome中,线程模型是统一且唯一的,这就相当于有了一套标准,它需要满足在各个线程上执行的几十上百种
任务的需求,因此,必须在灵活行和易用性上有良好的表现,这就是设计标准的难度。为了满足这些需求,Chrome
在底层库上做了足够的功夫:”
- 它提供了一大套的模板封装(参见task.h),可以将Task摆脱继承结构、函数名、函数参数等限制(就是基于模板的伪function实现,想要更深入了解,建议直接看鼻祖《Modern C++》和它的Loki库…);
- 同时派生出CancelableTask、ReleaseTask、DeleteTask等子类,提供更为良好的默认实现;
- 在消息循环中,按逻辑的不同,将Task又分成即时处理的Task、延时处理的Task、Idle时处理的Task,满足不同场景的需求;
- Task派生自tracked_objects::Tracked,Tracked是为了实现多线程环境下的日志记录、统计等功能,使得Task天生就有良好的可调试性和可统计性;
我们将任务封装成Runnable或Callable放入队列中,线程池中的线程从队列中取出任务并执行。这叫做Command命令模式。
“Command模式,是一种看上去很酷的模式,传统的面向对象编程,我们封装的往往都是数据,在Command模式下,我们希望封装的是行为。
这件事在函数式编程中很正常,封装一个函数作为参数,传来传去,稀疏平常的事儿;但在面向对象的编程中,我们需要通过继承、模板、
函数指针等手法,才能将其实现。
的时候,会把在任一一个环境中创建的Command,放到一个队列环境中去,供统一的调度;在Chrome中,也是如此,我们在一个线程环境中
创建了 Task,却把它放到别的线程中去执行,这种寄居蟹似的生活方式,在很多场合都是有用武之地的。”
二、【进程间通信】
1.基本模式
“进程间通信叫做IPC。进程与进程间通信,需要仰仗操作系统的特性,能玩的花着实不多,在Chrome中,
用到的就是有名管道(Named Pipe),
只不过,它用一个IPC::Channel类,封装了具体的实现细节。
Channel可以有Client和Server两种工作模式,Server和Client分属两个进程,维系一个共同的管道名。
Channel本身派生自Sender和Watcher,身兼两角,而Listener是一个抽象类,具体由Channel的使用者来实现。
当有消息被Send到一个发送进程的 Channel的时候,Channel会把它放在发送消息队列中,如果此时还正在发送
以前的消息(发送端被阻塞…),则看一下阻塞是否解除(用一个等 待0秒的信号量等待函数…),然后将消息
队列中的内容序列化并写道管道中去。操作系统会维护异步模式下管道的这一组信号量,当消息从发送进程缓冲区
写到接收进程的缓冲区后,会激活接收端的信号量。当接收进程的消息循环,循到了检查Watcher这一步,并发现
有信号量激活了,就会调用该Watcher 相应的OnObjectSignaled方法,通知接受进程的Channel,有消息来了!
Channel会尝试从管道中收字节,组消息,并调用 Listener来解析该消息。
从上面的描述不难看出,Chrome的进程通信,最核心的特点,就是利用消息循环来检查信号量,而不是直接让
管道阻塞在某信号量上。这样就与其多线程模型紧密联系在了一起,用一种统一的模式来解决问题。”
2.非IO线程
“在每一个进程中,只能有一个线程来负责操作Channel,这个线程叫做IO线程(名不符实真是一件悲凉的事情…)。
我们需要从非IO线程与别的进程相通信,这该如何是好?如果,你有看过我前面写的线程模型,你一定可以想到,
做法很简单,先将对Channel的操作放到Task中,将此Task放到IO线程队列里,让IO线程来处理即可。当然,由于
这种事情发生的太频繁,每次都人肉做一次颇为繁琐,于是有一个代理类,叫做ChannelProxy,来帮助你完成这一切。”
三、【进程模型】
1.Render进程
“在Browser进程中,有xxxProcessHost,每一个host,都对应着一个Process,比如RenderProcessHost对应着
RenderProcess,PluginProcessHost对应着PluginProcess,有多少个host的实例,就有多少个进程在运行。这是一个
比较典型的代理模式,Browser对Host的操作,都会被Host封装成IPC消息,传递给对应的Process来处理。
在 Chrome中,用RenderView表示一个web页面,每一个RenderView可以寄宿在任一一个RenderProcess中,它只是
依托 RenderProcess帮助它进行通信。每一个RenderProcess进程都可以有1到N个RenderView实例。
当需要创建一个新的RenderView的时候,Chrome会尝试进行选择或者是创建进程。比如,在one
site one process
的模式下,如果存在此site,就会选择一个已有的RenderProcessHost,让它管理这个新的RenderView,否则,会创建一个
RenderProcessHost(同时也就创建了一个Process),把RenderView交给它。
在默认的one site instance one process的模式中,Chrome会为每个新的site instance创建一个进程(从一个页面链开来的页面,
属于同一个site instance)。但Render进程总数是有个上限的,当达到这个上限后,你再开新的网站,Chrome会随机为你
选择一个已有的进程,把这个网站对应的RenderView给扔进去。”
2.进程开销控制
“当一个页面不再直接面对用户的时候,Chrome会将它的进程优先级切到Below Normal的级别,反之,则切回Normal级别。
Chrome的Render进程工作集调整,除了发生在tab切换(或新页面建立)的时候,还会发生在整个Chrome的idle事件触发后。
Chrome有个计时器,统计Chrome空闲的时长,当时长超过30s后(此工作会反复进行…),Chrome会做一系列工作,其中
就包括,调整进 程的工作集。被调整的进程,不仅仅是Render进程,还包括Plugin进程和Browser进程,换句话描述,就是
所有Chrome进程。”
四、【UI绘制】
1.窗口控件
“用Chrome自己的话来说,我觉得市面上的七荤八素的图形控件库都不好用,于是自己倒腾倒腾实现了一套。广告虽如此说,
不过,Chrome的图形控件结构,我还未发现有啥非常非常特别的地方。Chrome的窗口、按钮、菜单之类的控件,都直接或间接
派生自View,这个是控件基类。Chrome的View具有树形结构,其内部有一个子View数组,由此构成一个控件常用的组合模式。
在Chrome中,一个正确的树形的控件结构,必须由RootView作为根。之所以要这样设计,是因为RootView有一个比较特殊的功能,
那就是分发消息。Chrome中的View只是保存控件相关信息和绘制控件,里面没有HWND句柄,因此不能够捕获系统消息。在Chrome中,
完整的控件架构是这样的,首先需要有一个ViewContainer,它里面包含一个RootView。Windows对应的子类是HWNDViewContainer
它同时还是MessageLoopForUI::Observer的子类。
当有系统消息进入此线程消息循环 后,HWNDViewContainer会监听到这个情况,如果和View相关的消息,它就会调用RootView
的相关方法,传递给控件。在 RootView的内部,会遍历整个控件树上的控件,将消息传递给各个控件。当然,有的消息是可以独占的,
比如鼠标移动发送在某个View所管辖的范围 内,它会告知RootView(通过方法的返回值…),这个消息我要了,那么RootView会
停止遍历。
每一个View的子类控件,比如Button之类的,会存储一些数据,根据消息做一些行为,并且绘制出自己。在Chrome中,画图的东西是ChromeCanvas这个类,在其内部,通过Skia和GDI实现绘制。
Skia是Android团队开发的一个跨平台的图形引擎,在Chrome中负责
2.页面绘制
“虽说Chrome和WebKit都是开源 的,并联手工作。但是,Chrome还是刻意的和WebKit保持了距离,为其始乱终弃埋下了伏笔。
Chrome在WebKit上封装了一层,称为 WebKit Glue。Glue层中,大部分类型的结构和接口都和WebKit类似,Chrome中依托WebKit
的组件,都只是调用WebKit Glue层的接口,而不是直接调用WebKit中的类型。按照Chrome自己文档中的话来说,就是,虽然我们再
用WebKit实现页面的渲染,但通过这 个设计(加一个间接层…)已经从某种程度大大降低了与WebKit的耦合,使得可以很容易将WebKit
换成某个未来可能出现的更好的渲染引擎。
给Render进程让它们自行下载(你会越来越发现,Render进程绝对是100%的名符其实,除了绘制,几乎啥多余的事情都不会 干的…)。
主要有三个优点:
1.一个是避免子进程与网络通信,从而将网络通信的权限牢牢握在主进程手中,Render进程能力弱了,想造反干坏事的可能性就降低了
(可以更好控制各个Render进程的权限…);
2.另一个是有利于Cookie等持久化资源在不同页面中的共享,否则在不同Render进程中传递 Cookie这样的事情,做起来更麻烦;
3.还有一点很重要的,是可以控制与网络建立HTTP连接的数量,以Browser为代表与网络各方进行通信,各种优 化策略都比较好开展
(比如池化)。。。”
“RenderView接收到页面信息,会一边绘 制一边等待更多的资源到来,在用户看来,所请求的页面正在一点一点显示出来。当然,
如果是一个通知传输开始、传输结束这样的消息,通过序列化到消息参数里 面,经由IPC发过来,代价还是可以承受的,但是,
想资源内容这样大段大段的字节流,如果通过消息发过来,浪费两边进程大量空间和时间,就不合适了。于是 这里用到了共享内存。
Browser进程将下载到的资源写到共享内存 中,并将共享内存的句柄和共享区域的大小序列化在消息中发送给Render进程。Render
进程拿到这个句柄,就可以通过它访问到共享内存相关的区域, 读取信息并进行绘制。”
进程间通信的方法主要有以下几种:
(1)管道(Pipe):管道可用于具有亲缘关系进程间的通信,允许一个进程和另一个与它有共同祖先的进程之间进行通信。
(2)命名管道(named pipe):命名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系
(1)管道(Pipe):管道可用于具有亲缘关系进程间的通信,允许一个进程和另一个与它有共同祖先的进程之间进行通信。
(2)命名管道(named pipe):命名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系
进程间的通信。命名管道在文件系统中有对应的文件名。命名管道通过命令mkfifo或系统调用mkfifo来创建。
(3)信号(Signal):信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送
(3)信号(Signal):信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送
信号给进程本身;linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数
是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数)。
(4)消息(Message)队列:消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加
(4)消息(Message)队列:消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加
消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区
大小受限等缺
(5)共享内存:使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与
(5)共享内存:使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与
其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。
(6)内存映射(mapped memory):内存映射允许任何多个进程间通信,每一个使用该机制的进程通过把一个共享的文件映射到自己的
(6)内存映射(mapped memory):内存映射允许任何多个进程间通信,每一个使用该机制的进程通过把一个共享的文件映射到自己的
进程地址空间来实现它。
(7)信号量(semaphore):主要作为进程间以及同一进程不同线程之间的同步手段。
(8)套接口(Socket):更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Unix系统的BSD分支开发出来的,
(7)信号量(semaphore):主要作为进程间以及同一进程不同线程之间的同步手段。
(8)套接口(Socket):更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Unix系统的BSD分支开发出来的,
但现在一般可以移植到其它类Unix系统上:Linux和System V的变种都支持套接字。