在折腾小工具的时候有产生了奇怪的需求,Chrome 插件需要监听任意网页中某个变量的变化,或是访问其中的值。但默认注入的 content.js 和原网页 Javascript 脚本并不运行在一个相同的环境中,无法相互访问。

搜索和自己尝试了如下的解决方法,现罗列如下。

访问到目标变量

这个很简单。虽然默认注入的content.js不能访问到原网页的变量,但可以访问和修改Dom,所以再注入一个脚本就好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function loadScript(url) {
var elem = document.createElement('script');
elem.type = 'text/javascript';
elem.charset = 'utf-8';
elem.addEventListener('load', doCallback, false);
elem.src = url;
document.getElementsByTagName('head')[0].appendChild(elem);
}

function url(file) {
return chrome.extension.getURL(file);
}

loadScript(url('inject.js'));

```

在inject.js中便是和原网页一样的javascript环境了,但是问题来了,怎样才能将在此环境下的变量发送到后台插件中呢?

建立原网页作用域与插件后台页面的双工通信

目标很明确,在inject.js中建立与后台background.js的双工通信,这样我们可以实时将网页中值的变化发送到插件中进行分析……

不过阻碍也很明显,由于chrome的安全策略,inject.js和后台插件虽然能直接连通,但受域名限制,而content.js则可以很轻易连通。所以如果打通inject.js与content.js就好了。

外部服务器中转

inject.js和background.js连上相同的websocket服务器中转,问题解决。不过还要多加一个外部服务器。

inject.js直连后台插件

的确,Chrome提供了直连的方法。

首先在manifest.json中申明externally_connectable,需要申明域名限制。正当我兴高采烈地输入*://*/*全匹配后,发现出错,chrome文档如是说:

This will expose the messaging API to any page which matches the URL patterns you specify. The URL pattern must contain at least a second-level domain - that is, hostname patterns like ““, “.com”, “.co.uk”, and “.appspot.com” are prohibited.

没有办法做到让所有域名发起的连接都与manifest.json匹配,但对于认可的域名,只需要在inject.js中调用如下API,

1
2
3
4
5
var ExtensionID = 'nnhoaecbdmfokhcnldeiadllnjeebhcb';

// Make a simple request:
chrome.runtime.sendMessage(ExtensionID, {openUrlInEditor: url}, function(response) {});
```

inject.js注入Dom事件

这是网上一个比较通行的办法,可以约定一个Dom元素,再使用MutationObserver监控这个Dom元素即可。
不过修改Dom比较重量级,不是很喜欢这种做法。

轮询localStorage等公共可访问变量

两个脚本均可访问到localStorage,location等变量,所以一方修改一方轮询也是一种办法,不过如果变量发生了多次修改,每次的修改事件就很难及时发出。

SharedWorker转发

最终试验成功了一种还比较满意的方法,可由 content.js 创建一个 SharedWorker的函数,并将其转换为 Blob ,再使用URL.createObjectURL创建为共享链接存入localStorage , inject.js 读取后此链接后,两个脚本同时连上同一个 SharedWorker,由 worker 转发消息,可以很好双向通信。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// background.js
//----------------------------------------------------------------
// 简单监听来自content.js的转发的内容
chrome.extension.onMessage.addListener(
function(request, sender, sendResponse) {
console.log('bg', request);
}
);

// content.js
//----------------------------------------------------------------
// SharedWorker执行的逻辑,广播信息
var worker_function = function() {
var ports = []
onconnect = function(e) {
if (e.ports && e.ports.length > 0) {
for (var i = 0, j = e.ports.length; i < j; i++) {
e.ports[i].onmessage = function(e) {
for (var m = 0, n = ports.length; m < n; m++) {
ports[m].postMessage(e.data);
}
}
ports.push(e.ports[i]);
}
}
};
}

// 将上述函数转为可访问的URL, 并存入localStorage
localStorage.sharedWorkerURL = URL.createObjectURL(new Blob(["(" +
worker_function.toString() + ")()"
], {
type: 'text/javascript'
}));

var shared = new SharedWorker(localStorage.sharedWorkerURL);
shared.port.start();
shared.port.onmessage = function(e) {
console.log('content', e);
// 向background.js转发
chrome.extension.sendMessage({
greeting: e.data
}, function(response) {
console.log('content get bg', response);
});
}

// inject.js
//----------------------------------------------------------------
var shared = new SharedWorker(localStorage.sharedWorkerURL);
shared.port.start();
shared.port.onmessage = function(e) {
console.log('inject', e);
}

```

使用window.postMessage

经网友提醒,参考Chrome开发文档中针对content script通信的说明。使用window.postMessage可以很好进行双向通信。

0x00 折腾的缘由

不知什么时候看到这篇文章后,我便对其中主人公对一个模拟的邻居进行攻击的过程产生了好奇,由于原文没有详细描述攻击的每一个步骤,便想着什么时候在一个安全的环境下重现一下。

首先明确接下来的操作都是安全无害的,虽然的确劫持到了其他人的一些流量,但并没有继续解密流量了,尝试修改流量也只是针对自己设备。

然后我们来假象这么一个目标,劫持到邻居的流量,这可以用来分析ta,并修改流量,达到浏览器弹窗、修改网页布局的效果。

这简单的实现可以使用古老但好用的ARP攻击,将目标的流量欺骗到攻击人的网卡上,便可以开展中间人攻击了。不过这要求我们在同一个局域网中,比如连接了同一个无线路由器。一般可以搜索到邻居的无线路由器,一旦破解了其登陆密码,便可以扫描到目标的设备,进行攻击了。

0x01 尝试破解Wifi

一般我们的设备和邻居的设备不在同一内网中,所以第一难关就是攻克无线路由器。如果像我一样,插上网线发现就已经和邻居在同一内网中,那么就可以跳过这一步了。

使用的武器

Aircrack-ng是常用的用于破解无线802.11WEP及WPA-PSK加密的工具。Linux用户下载源码编译安装即可。
其中需要Ubuntu用户预先apt-get install libnl-3-dev libnl-genl-3-dev安装几个依赖。

Mercury 150Mbps MW150U是一个无线USB网卡,Ubuntu免驱使用,由于可以开启Monitor模式,既可以用于台式机的无线上网,也可以偶尔做做无线网络的实验。

查看网卡后发现我的笔记本网卡和USB网卡都可以用于实验:

1
2
3
4
5

phy0 wlan0 ath9k Qualcomm Atheros AR9285 Wireless Network
phy1 wlan1 rt2800usb Ralink Technology, Corp. RT5370

```

选择攻击目标

一切就绪,首先让网卡进入Monitor模式,这运行程序获取到更底层的无线网络数据,

1
2
sudo airmon-ng start wlan1
```

ifconfig后发现wlan1mon

继续开始审查周围的无线网络,

1
2
sudo airmon-ng start wlan0
```

search

可以看到周边的无线热点,按照信号强度由强到弱排序,其中BSSID是热点的Mac地址,CH是热点的信道(WIFI2.4Ghz分有13个互相有重叠的信道),ENC是加密方式,可以发现基本都是WPA2加密,ESSID是名称,好了,我们基本就只需要关注这些。

挑选一个victim,记下它的信息。

捕获WPA的握手包

要想破解无线密码,我们先捕捉两台设备成功握手的包。虽然其中也不含密码,但我们可以用这一信息来暴力验证密码字典中的密码是否正确。

1
2
sudo airodump-ng --ivs --ignore-negative-one --bssid 目标热点MAC -w 保存文件的名称 -c 信道 wlan1mon
```

现在我们看到开始抓包,

waiting-for-handshake

不过是否能快速捕获握手包取决于两个因素,信号强弱、是否现在有活跃的设备正在和热点通信。为了尽快获取到握手包,常规做法是广播中断连接的信号,强制踢设备下线,再连接就有我们想要的握手包了。

1
2
sudo aireplay-ng -0 30 -a 目标热点MAC  wlan1mon
```

幸运地话,很快就能看到握手包捕捉成功,

get-handshake

离线破解,拼算力的时候到了

目前WPA的加密是捕获热点和设备间的握手信息,再离线用密码字典爆破,按理来说都是可以破解的,不过时间不允许我们这么做,由于一般密码很弱,比如8位纯数字,或者是非常常见,如password。一个好的字典囊括了最常见的密码,让我们能瞬间破解很多热点。

1
2
aircrack-ng -w ~/Downloads/dict/0-9.8位纯数密码.txt my-01.ivs
```

4T 2.2GHz的破解速度只有90000个/秒的破解速度,把0-9的8位纯数密码扫描一遍要1000分钟了。

不过常见密码是很好破解的,

get-passwd

所以,一定要把无线密码设置得非常特殊,长度12位以上,切记切记,还有防止软件偷走密码去分享。

0x02 扫描内网设备

成功登入后,首要访问一下192.168.1.1192.168.1.253,这是路由器管理界面的常见入口,比如我登的就是admin/admin口令,控制了路由器就更加自由了。可以进行DNS攻击,刷路由器固件等。

如何在不登陆路由器管理界面的情况下,看看内网内的其他设备的情况呢?nmap登场,神级工具之前一直没用过,最近才看到其他人使用。

简单用法如下,更多请参阅他人的简单的教程

1
2
3
4
5
6
7
8
9
10
➜  ~  nmap -sP 192.168.1.1-254

Nmap scan report for xxxxxxxdeiPhone (192.168.1.7)
Host is up (0.031s latency).
All 100 scanned ports on feixiandeiPhone (192.168.1.7) are closed
MAC Address: XX:XX:XX:XX:XX (Apple)
Too many fingerprints match this host to give specific OS details
Network Distance: 1 hop

```

哈,发现一个人的iPhone。

0x03 简单进行Arp攻击

现在,真正的攻击上演了。我们要将上面找到的victim的流量欺骗到我们的网卡上,ettercap登场,apt-get安装即可。

1
2
sudo ettercap -i wlan1 -T -M arp:remote /192.168.1.1// /192.168.1.7//
```

执行中间人攻击,欺骗路由器和目标设备,这样本来由目标发下路由器的流量就发给了我方网卡,中转后再发向路由器。

0x04 审查流量信息

配合wireshark审查浏览,不过现在HTTPS加密已经流行起来,似乎只有HTTP明文可以被我们分析了。

wireshark

似乎到了饭点,在定外卖……

0x05 修改流量信息

接下来攻击自己手机,尝试在HTTP明文中注入脚本。

还是使用ettercap,其中的fliter功能可以进行简单的文本查找和替换,虽然十分低效。高效的方法需要加入HTTP解析,这样方便过滤和注入。

fliter代码,

1
2
3
4
5
6
7
8
9
10
11
12
13
14

if (ip.proto == TCP && tcp.dst == 80) {
if (search(DATA.data, "Accept-Encoding")) {
replace("Accept-Encoding", "Accept-Nothing"); # 防止GZIP压缩
}

}
if (ip.proto == TCP && tcp.src == 80) {
replace("<title>", "<script type=\"text/javascript\">alert('Hack')</script><title>");
replace("<title>","<style>html{overflow-y:scroll;filter:progid:DXImageTransform.Microsoft.BasicImage(grayscale=1);-webkit-filter:grayscale(100%);}</style><title>");

}

```

预先编译为ettercap可加载的二进制模块,

1
2
etterfilter f.filter -o fun.ef
```

重新进行攻击,这时候流量被匹配后修改,

1
2
3
sudo ettercap -i wlan0 -T -M arp:remote -F fun.ef /192.168.31.1// /192.168.31.155//

```

攻击效果,

v2ex

脚本注入

smzdm

哈,灰色默哀

不好的Log习惯带来了哪些问题?

通常创新项目初期,我的确不是很注意编码规范,测试以及日志。由于需求和技术实现都不明确,如果太注重测试可能会框住自己的手脚。不过编码规范和日志是什么项目都应该注意的,一开始注意便能提升整个项目的效率,避免后期重构。

项目各方面逐渐明确后,各种测试也是很重要的一环。在重构之前确定一些自动的回归测试和单元测试,能避免出现很多低级错误,这些错误一旦在集成时出现,可能会花费半天进行调试,最近苦不堪言下定决心改正。

之前打log一般便就是printf大法,总之输出各种变量的值,完全就是debug时的调试,项目后期便会各种调试信息混杂在一起,非得手动注释才行。在log中使用各种“奇技淫巧”,比如输出’=’组成的分割线,使用特殊符号的数量方便搜索,不断地改log、调试、再改,最后调试出了bug,log之后再也看不懂了。

最近问题来了,服务器代码需要发布和部署,后期我也没有机会维护了,重构后的代码虽然可读性好了很多,但log还是一团糟。最后花了一天时间统一了log输出的规范,方便进行fliter,调试起来也开心和效率很多。而且设置断点,单步调试的效率往往比printf大法好很多,应该少使用log进行调试。

一个入门级别的log规范

Javascript && Android

框架已经提供了足够好的log工具,console.log/info/warning/error/table自带分级,并且结合Chrome的fliter,十分易用。在代码中加入debugger;便能让Chrome陷入断点,变量查看等都非常方便,Web程序员十分幸福。
Android也提供了类似的Logger,设置Tag并分级,Logcat中也可以进行搜索。在JNI开发中,通过#define ALOGD(...) __android_log_print(ANDROID_LOG_DEBUG,ALOG,__VA_ARGS__)定义宏,也能很方便使用。之前一直通过stdout打印,非常混乱。

Golang

最后发现服务器的Log更是乱成一团,所有输出都是printf,不带分级和Tag,看起来混乱且不方便发布。Golang自带的Log包也不存在这些复杂的功能,最后并不想再依赖其他的包,还是简单封装一下为好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

var logger *log.Logger
var LogLevel int = INFO

func init() {
logger = log.New(os.Stdout, "", log.LstdFlags)
}

func Debugf(tag string, argv ...interface{}) {
if LogLevel <= DEBUG {
logger.Println("DEBUG: ["+tag+"]", argv)
}
}

......

// 从命令行读取log level
log_level := flag.Int("loglevel", INFO, "the log level")
webanalyzer.LogLevel = *log_level

func server(...){
defer func() {
if err := recover(); err != nil {
// 在数据处理函数中,接下所有的panic,并将它们发给browser
// 这样服务器panic后,客户端可以显示一个比较友好的消息
}
}()

...
}

```

最后可以通过命令行参数控制log等级,方便发布,可以直接log到文件中,并可以在这里实现一个缓存(计划中),这样混乱的Debug信息部署时就不可见了。

看到其他程序很有条理的log信息,才意识到自己这里做得非常不足,养成这一习惯,应该能提升接下来的开发与调试效率。

你使用浏览器进行会话的全过程

通过前文我们已经能在心中绘制出Chrome网络栈的大致图样,现在让我们来详细了解一下浏览器中一系列面向用户的优化。现在,让我们想想我们刚刚创建了一个Chrome的用户档案,并准备开始上网冲浪了。

优化游览器的冷启动体验

pagespeed

第一次启动Chrome的时候,它对你喜爱的网站、浏览习惯还了解甚少。但是,大多数人在浏览器冷启动后还是遵循着一个特定的使用习惯,比如打开电邮、新闻头条、社交网络和门户网站等等。具体的网站可能不同,但是它们其中的相似性可以帮助预测器来加速你每天一开始打开浏览器的体验!

Chrome总是记着用户打开浏览器后最爱去的十大网站,不过需要注意的是这并不是全局浏览的前十,而仅仅是启动后的。浏览器加载的时候,Chrome就可以为这些站点发起DNS的预加载。好奇的你可以通过chrome://dns来查看你自己的启动域名列表。在这个网页的最上方,你会找到你账号的十大启动候选网站。

startup-dns

以我账号举个栗子吧,我一般如何上网的呢?如果我是在写一篇文章的时候,我会直接导航去Google Docs。如你所料,我常去一些Google站点。

优化与Omnibox的互动

chrome-omnibox

推出Omnibox是Chrome的一大创举,和先前的其他浏览器相比,Chrome的Omnibox可不仅仅只能输入目标URL。除了记住用户之前浏览过的URL以外,它也支持历史浏览记录的全文搜索,(小技巧:输入网站的名称而不是URL),更可与你选择搜索引擎进行深度整合。

Omnibox会自动相应用户的输入,这可以是根据你历史的一个URL,或者是一个搜索查询。底层实现中,每个提示的动作都是按照和输入的相关程度进行排序,并参考之前的记录。事实上,你可以在chrome://predictors中查询到这些数据。

xomnibox

Chrome记忆着用户输入前缀词的一份历史记录,其提示的动作和成功执行的比例。对于我的历史来说,可以发现我每次敲入g后,我有76%的机率是向打开Gmail。而当我再输入”m”后(“gm”的缩写),这一动作的可信率升到了99.8%。事实上,在412次我输入”gm”后,我只有一次并不是想打开Gmail。

但是,你可能会想,这一切关网络栈什么事情?这些黄黄绿绿的候选数据其实都是ResourceDispatcher的重要参考信号!如果我们产生了一个黄色信号,Chrome可能会发出一个DNS预查询。如果是一个绿色的更高可信度的信号,Chrome可能在DNS解析后发出一个TCP预连接。如果这些都做好了,但用户还没有做出决定,Chrome甚至会默默在后台页面渲染好这个网页。

还有一种可能是,如果就历史数据来看,当前输入的查询词没有很好的匹配,那么Chrome会对你搜索引擎进行DNS/TCP预加载,这是预测你很有可能发出一个搜索请求。

对于一般用户来说,他们需要数百毫秒的时间来输入查询,并评估自动弹出的提示。在后台的Chrome便能从容地预加载、预连接甚至于在某些情况下预渲染这个网页,这样等用户敲击下”enter”键的时候,网络带来的延时影响已经没有了。

优化缓存的性能

最快的Request,是这个请求根本没有发生。当我们谈及性能的时候,怎能不谈及缓存呢。作为网站开发人员,你得通过Expires,ETag,Last-Modified和Cache-Control这些response headers来表明你服务器上资源文件的缓存状态,对吧?如果没有的话,快行动起来,我们等等没关系。

chrome-incognito

Chrome对于内部缓存有两种不同的实现:一种是本地磁盘缓存,一种使用内存。内存缓存是为incognito浏览模式准备的,当你关闭窗口后,一切干干净净。两种模式都实现了相同的接口(disk_cache::Backenddisk_cache::Entry),这极大简化了架构设计。如果你对此有想法,可以很轻易地实验你的缓存实现。

从内部来说,磁盘缓存实现了自有的一套数据结构,它们都存放在你Profile目录下的文件夹中。其中,索引文件在浏览器启动时被直接映射入内存,而数据文件存放真实的数据,比如说HTTP头和其他统计信息。这里值得一提的是,最大不超过16KB的文件都存放在共享的数据块文件中,大文件直接存放在专有文件中。磁盘缓存需要实验LRU测量进行清扫,LRU的通常参考使用频率和资源的年龄。

internals-cache

如果你对Chrome缓存感兴趣的话,不妨看看chrome://net-internals/#httoCache。如果你想查看实际的HTTP元数据和缓存的Response,你可以访问chrome://cache,它们会列出缓存中所有的资源详情。你可以搜索,并点击URl查看。

使用预加载优化DNS

之前我们已经几次提到了DNS预解析,那在我们展开实现细节前,我们看看哪些情况下会触发DNS解析,和触发理由:

  • 在Render进程中运行的Blink文档解析器,可以提供其页面的所有URL中的域名,Chrome可以选择预先解析与否。
  • 在用户发出请求之前,Render进程便可能触发一个鼠标悬浮事件,或是一个按钮点击事件。
  • 在高匹配的情况下,Omnibox可能触发一个查询请求。
  • Chrome预测器在综合历史浏览记录和资源请求数据后,发出域名解析请求。
  • 页面的开发者可以显式告诉Chrome,哪些域名可以预先解析。

在所有以上的情况下,DNS预解析都只是被当做暗示来处理。Chrome不保证预解析一定会执行,而是通过综合判断所有的信号,使用预测器来判断是否执行。最“糟糕”的情况下,如果我们不能提前解析DNS,用户就必须要等待一个DNS解析时间,然后才是TCP连接和资源加载。不过,如果这一切发生的话,预测器会进行记录并以此优化未来的决策。这就是你越用,Chrome越快越聪明。

之前我们没有提到的一个优化是,Chrome会发现每个网站的拓扑结构,并用这一信息为未来的浏览加速。让我们回忆一下每个网页平均由88个资源的事实,其中资源从30+个独立的域名加载而来。这说明你每次进行网页浏览的时候,Chrome可以记录下网页上流行的资源文件。在以后它便可选择为一些、或者是全部发出一个DNS预加载和TCP预连接。

xsubresource-stats

你可访问chrome://dns来查看Chrome缓存的子站点的域名,并可以查询一下你关心的域名。上例可看出,对于Google+来说,Chrome记得六个子站点和DNS预解析/TCP预连接的统计数据,其中还有请求的期望值。这一内部的统计数据让Chrome预测器可以进行预测优化。

除了上述的所有内部信号,站点开发者还可以通过以下方式,即嵌入附加的预解析信息来暗示Chrome:

1
2
3
4

<link rel="dns-prefetch" href="//host_name_to_prefetch.com">

```

那让浏览器自动来做不就好了吗?在某些情况下,可能有预解析一个文章中完全没有提及的域名。最经典的栗子当然就是跳转了:一个指向域名的链接,就比如一个追踪分析的服务,它功能就是将用户跳转到真实的地址下。Chrome仅仅依靠自己是没有办法做到的,而可以通过人工提供更好。

那么底层实现又是怎样的呢?不过这一答案恐怕和Chrome的版本有关,由于团队总是在实验更新更好的方法。不过整体上来看,Chrome的DNS组织结构有两大实现:之前Chrome使用平台无关的getaddrinfo系统调用,将这个查询全权交由系统进行,而现在逐步替换为Chrome自实现的异步DNS解析器

原本的实现的优点是,由于依赖操作系统,代码可以更简洁,并且还能利用上操作系统的DNS缓存。不过,getaddrinfo()是一个阻塞式的系统调用,这就意味着Chrome得创建并维护一个专有的线程池来进行并行的解析。这个线程池最大不超过6个worker线程,这是基于硬件最小公共分母的一个经验值,因为我们发现太高的并行请求会让一些用户的路由器过载!

对于使用worker池的预解析方案,Chrome就简单地调用getaddrinfo(),这会阻塞到response返回,在此之后他就丢弃了返回的结果并转而处理下一个请求。丢弃它?由于结果已经由系统DNS守护进程缓存了,这以后就能立即返回了。这足够简单和有效。

嗯,很有效,但这还远远不够!getaddrinfo()调用并不会告诉Chrome很多有用的信息,比如说每个记录的TTL,和DNS缓存自身的状态。为了提升性能,Chrome团队决定实现一个跨平台、异步的DNS解析器。

xasync-dns

Chrome自己进行DNS解析带来了以下的新优化:

  • 对于重传输计时器更好的控制,并且能并行多个查询
  • TTL信息可见,这让Chrome可以在失效之前就更新热门的记录
  • 对于IPv4和IPv6更好的支持
  • 基于RTT和其他信号转换去其他的服务器(DNS服务器)

以上,乃至还有更多,都是来源于Chrome持续不断的实验和优化。一个更加明显的问题是:我们是如何验证这些想法的效果的呢?,这很简单,Chrome对于每个用户,都对网络性能进行了细致的追踪和记录。你可以打开chrome://histograms/DNS来查看这些信息。

xdns-prefetch

上图显示了DNS预请求的时间延时分布情况:差不多半成的(右栏)都是在20毫秒内完成的(左栏)。这是基于最近的一次浏览回话(9869条记录)并为用户私有。如果用户选择向Chrome提交他们的使用情况数据的话,这些数据才会被匿名化后,周期性地提交到开发团队,这样我们就能看到试验成功并不断做出调整了。就这样反复迭代更新。

使用预连接来优化管理TCP连接

既然我们通过Omnibox和Chrome预测器猜测出了接下来用户的导航方向,并预先解析出了域名,那为什么不再提前一步,预先与目标域名建立TCP连接,在用户发出真实的请求前就完成TCP握手呢?这样的话,我们便又节省了一个完整的Roundtrip时延,为用户节省了数百毫秒的延时。这便是TCP预连接和它的工作原理了。

首先,Chrome会先检查其socket池,找找有没有此域名的可用的socket。重用已经保持一段时间的keep-alive的sockets可以防止TCP握手和慢启动惩罚。如果没有可用的socket,那再启动TCP握手,并将它放到池中。这样的话,当用户启动导航的时候,真实的HTTP请求就能立即发送出去了。

打开chrome://net-internals#sockets来查看Chrome中打开的socket详情统计。

xnetinternals-sockets

你还可以深入每个socket的详情并审查时间线:连接和代理时间,每个包的到达时间等等。你还可以将数据导出,用于离线分析和bug报告。一个好审视(instrumentation)系统的是性能优化的关键,而chrome://net-internals是Chrome网络的汇总点,如果你还没尝试它的话快去试试吧。

使用预加载提升来优化资源加载

有时,网页开发者基于站点的布局信息,可以提供附加的导航信息,或是page context(不知原文所指)。这能帮助浏览器优化用户体验。Chrome支持以下两种提示,内嵌在网页中即可:

1
2
3
4
5

<link rel="subresource" href="/javascript/myapp.js">
<link rel="prefetch" href="/images/big.jpeg">

```

子资源和预加载看起来没有什么不同,但却有着非常不同的语义。当一个链接声明自己与当前网页的关系是”prefetch”的时候,这只是暗示浏览器它是一个可能在未来需要打开的页面。换句话说,它只是一个跨页面提示。对比来看,”subresource”提示浏览器本资源是可能会被用于当前页面的,这需要在遇到这个资源之前发出request。

如你所料,不同的语义也带来了资源加载器非常不同的行为。标注为prefetch的资源只是被赋予低优先级,只有在本页面结束加载后才会考虑。而subresource资源则一遇到就加载,优先级很高。

这两个提示,如果在合适的环境下正确使用,可以显著地优化你站点的用户体验。最后,要提示大家prefetch是HTML5标准的一部分,现已经获得了Firefox和Chrome的支持,而subresource只是在Chrome中支持。(此信息可能已经过期)

使用浏览器预刷新来优化资源加载

不幸的是,不是所有的站点开发人员都可以或是愿意在网页标注出subresource。就算他们这么做了,我们必须等待HTML文档返回,才能解析到这些提示,并开始获取必要的subresource。这就要看服务器的响应时间,和延时了。通常数百甚至上千的延时都有可能出现。

但是,让我们眼光再放得长远一些,Chrome已经知道了热门资源的域名,并以此进行DNS的预解析。为什么我们不再向前推进一步,进行DNS查询,使用TCP预连接,并推测性地预先加载资源呢?这便是”预刷新”想做的。

  • 用户打开目标URL
  • Chrome查询预测器,得到和目标URL相关的子资源,并也开始DNS预解析-TCP预连接-资源预刷新
  • 如果子资源已经缓存,那么只要从磁盘或是内存中加载就好
  • 如果资源缺失,或是缓存到期,那么才发出网络请求

xchrome-experiment

资源预刷新是Chrome中性能优化的一个典型范例,理论上来说,它应该能带来更好的性能,不过这其中还是有很多折衷。只要一种方式来选择是否需要将这个功能融合入Chrome,就是实现它并在pre-release的Chrome中给实际用户、实际的网络和实际的浏览中进行A/B测试。

在2013年早期,Chrome团队就已经在讨论这一实现。如果收集的反馈好,我们就可以在2013年后期看到这一功能。提升Chrome网络性能的脚步从未停止,团队一直在尝试新的方法、想法和技术

使用预渲染优化浏览体验

我们以上提及的每一个优化都减少了用户真实请求发出的时间,最终让页面得以渲染。但是,一个真实的秒开的体验还需要什么呢?基于我们之前预先看到的UX数据,互动必须在100ms中结束,这便不给网络延时留多少空余时间了,我们要怎样才能在100ms中渲染好一个页面呢?

当然,你应该已经猜到了答案。这通常是大多数人的习惯:如果你打开了很多网页,并在其中切换,这就是秒开的体验,可比等待加载快多了。如果浏览器已经为你提供了API来这么做呢?

1
2
3
4

<link rel="prerender" href="http://example.org/index.html">

```

你猜对了,这边是Chrome中的预渲染。不像”prefetch”只是下载一个资源,”prerender”提示Chrome在加载其所有subresource后,在一个隐藏的标签页中渲染这个页面。隐藏的tab是不为用户所见的,但当用户触发导航的时候,此tab就被从后台交换出来,形成所谓的”秒开”

你可以访问prerender-test.appspot.com来体验一下,打开chrome://net-internals/#prerender来查看预渲染的历史和当前状态。

xnetinternals-prerender.png

如你所料,在后台tab中完整地渲染一个页面需要消耗大量的CPU和网络资源,因此我们只会在此页面非常可能用到的时候才会进行预渲染!比如之前提到的Omnibox的高可能性提示。相似的是,Google搜索有时也会为第一结果加入预渲染的提示(也称谓Google Instant Pages)。

视频地址

你也可以为你的网站添加预渲染的提示。不过,在你这么做之前,你需要知道预渲染由一些限制:

  • 在所有进程中,只允许一个预渲染的tab
  • 不支持HTTPS和需要认证的HTTP页面
  • 如果请求的资源,或其任何subresource需要进行一个非幂等(原文:nonidempotent)请求,(只允许GET)
  • 所有的资源都是以最低网络优先级发出的
  • 所有的页面都是以最低CPU优先级渲染的
  • 如果页面使用超出100MB的内存,就会终止
  • 推迟插件的初始化,如果存在HTML 5 媒体元素的话,也会终止

换句话说,不能保证预渲染一定进行,并只在安全的情况下进行。还需提到的是,隐藏的页面也会执行Javascript和其他逻辑,最佳实践是利用Page Visibility API来检查页面是否可见,这也是你一定会学的

Chrome越用越快

xchrome-speed-final

现在看来,Chrome的网络栈可比一个朴素的socket管理器复杂多了。我们这个简短地介绍了在浏览网页时你不可见的很多优化。Chrome越是学习网络的拓扑和你的习惯,做得越好。这就好像魔术一样,Chrome是越用越快的,不过它不是魔法,因为你已经知道它的内部机理了

最后,还是需要提到Chrome团队一直努力迭代和实验,来不断提高性能。在你读到这里的时候,还是由很多新实验和优化在开发、测试和部署中。兴许某一天,我们达到了我们秒开(小于100ms)的目标时,我们可以歇一歇脚,不过在那之前,还有很多路要走!

Ilya Grigorik,Google Web性能工程师,W3C Web性能小组的co-chair,High Performance Browser Networking (O’Reilly) 一书的作者,Twitter,Google+

看到Ilya Grigorik写的这篇高性能网络技术实践后,一直想找个机会好好阅读一下。之前遇到英文文章的话我还是喜欢偷个懒,直接找中文版本。借这个机会,第一次体会翻译英文文章。
还发现了全书免费线上阅读,准备细细地读一下。

Google Chrome的历史和指南

在2008的下半年,Chrome的Beta版本的登陆Windows平台。于此同时,Google将Chrome的核心代码以BSD许可开源,并称其为Chromium。对于关注它的人来说,这一事件引发了一个惊喜的猜想:浏览器大战再燃?Google真的可以做得更好吗?

它体验实在太好了,这让我改变了我最初的想法。
– Eric Schmidt, 最初他对开发Chrome是抱有抵触想法

现在看来,Chrome团队做到了。现在Chrome是全球最广泛使用的浏览器之一(依据StatCounter的统计,超过35%的市场占有率),并提供了Windows、Linux、OS X 桌面平台版本和Android、iOS移动平台版本。显然,解决了用户痛点的特性与功能,和众多的创新点让Chrome跻身流行浏览器排行。

这本38页的漫画书详细阐述了Chrome中创新的想法,这提供了一个绝佳的视角,供大家学习开发Chrome过程中思考和设计过程。不过这仅仅只是开始。驱动Chrome开发的核心原则并未改变,依旧是现在Chrome优化的指南:

  • 速度(Speed) :目标就是要做出最快的浏览器
  • 安全(Security) :为用户提供最安全的使用环境
  • 稳定(Stability) :提供稳定而有弹性的Web应用平台
  • 简洁(Simplicity):用复杂的技术驱动起上层简单的用户体验

据团队观察,很多我们现在使用的网站不是简简单单的网页了,而是应用。这样看来,越来越多野心勃勃的应用都把速度、安全、稳定和简洁作为追求,而对于每一个最求,都需要单独成文来说,本文的主题是高性能,所以主要讨论速度。

从多个方面来看高性能

现代浏览器是一个平台,就像操作系统一样,Chrome就是照此标准设计的。在Chrome之前,所有主流的浏览器都是单进程应用。所有打开的网页共用一个内存区域,并相互争夺共享的资源。任何页面中或是浏览器中的Bug,都带来了牺牲全局体验的风险。

而Chrome另辟蹊径,工作在一个多进程的模式下。它提供了进程和内存隔离,并将每一个页面运行在安全的沙箱环境中。在目前多核处理器流行的背景下,隔离进程和保护页面不受恶意网页攻击被证明是Chrome在激烈竞争中的很好抓手。值得注意的是,很多浏览器也迁移到了这一多进程的架构,或是正在迁移之中。

comic-multi-process

在多进程环境启动后,Web应用的执行主要包含一下三个任务:获取资源、页面布局与渲染、Javascript执行。渲染和脚本执行依照一个单线程、交叉执行的模型——这是由于不能对DOM进行并发修改,这也是Javascript自身单线程的特性造成的。因此,对于Web应用开发者和浏览器开发者,怎样在运行时协同优化渲染和脚本执行是关键一环。

Chrome使用Blink作为渲染引擎,它也是为速度而生,是一个开源的、标准支持良好的布局引擎。对于Javascript,Chrome推出了精心优化过的Javascript运行环境,V8),V8也独立作为项目开源,得到了广泛的使用,例如它也是Node.js的运行环境。不过,如果是浏览器阻塞在网络IO上,针对V8虚拟机优化,或者是对Blink解析和渲染流水线的优化不会产生很好的效果,因为大部分时间还是消耗在了等待网络资源上

总用户体验中的最关键因素之一,便是浏览器优化各种网络资源的加载顺序、优先级和延时的能力。你可能都注意不到它,但Chrome的网络栈便是每天逐步演化得更加聪明。它尝试隐去或是降低各种资源加载带来的时延:预先加载最可能的DNS查询、记住网络的拓扑结构、向可能的目标提前发起连接等等。从使用者的角度来看,它只是一个简单的资源加载工具,而从内部来看,它相当精巧和迷人地示范了怎样进行网络性能调优,以及如何带给用户最好的使用体验。

那就让我们一探究竟吧。

现代的Web应用是怎样的?

在我们接触如何对网络进行优化的技术细节之前,理解Web发展潮流和我们需要面对的问题会有所帮助。一个现代的Web页面,或者是一个Web应用究竟是怎样的?

HTTPArchive项目追踪着互联网是如何构建的,它能帮我们回答这些问题。它周期性地爬取最流行的站点,对它们使用的资源数量、文件类型、headers和各种元数据进行记录和聚合分析,而并不关注站点的内容数据。2013年一月的数据可能会让你惊讶,在最流行的300,000个站点中,平均下来一个网页的数据如下:

httparchive-jan2013

  • 大小约为1280KB
  • 共计88个资源文件
  • 共连接了超过15个独立的主机

让我们细细分析一下。平均大于1MB的数据传输,包含88个资源文件如图片、Javascript和CSS,并是从15个独立的主机或第三方主机加载而来!这些数字在过去几年中都在稳定增长,现在看来没有减缓停止的趋势。这说明我们在不断开发更大的Web应用。

稍加计算我们发现资源的平均大小是12KB(1045KB/84),这说明浏览器中大多数的网络传输是短小猝发的。因为使用的底层协议(TCP)是专为较大和流式文件传输优化的,这就带来了一系列并发问题。让我们逐步剥开一个网络Request看看。

一个Request的一生

W3C Navigation Timing specification提供了一个浏览器API来展示每一次Request的性能数据。让我们详细观察这些组成部分,因为它们都是优化的用户体验的一个重要组成部分。

request-life

给定一个网络资源的URL,浏览器从检查本地caches开始。如果之前已经加载过这份资源并且和cache控制相关的headers已经被设置(比如Expires, Cache-Control),接着便可使用本地的拷贝来回应这个Request-最快的Request便是不产生真实的Request。有时文件过期,我们需要重新让这个文件具有最新的时效,或是之前没有加载过它,那么一个网络Request必须被发出,不过这是代价高昂的。

给定一个Hostname和Resource Path,Chrome先检查是否有打开的连接可以被重用,Sockets以{scheme, host, port}的三元组被池化使用。如果Proxy被设置,或是使用了一个自动Proxy设置脚本(PAC),Chrome会通过合适的Proxy检测连接。PAC脚本允许针对URL路由不同的Proxy,这每一套规则都有其自己的Socket池。最终,如果以上条件都不满足,那么这个Request必须首先将主机名解析为IP地址,即DNS查询开始

如果幸运的话,这个Hostname已经被查询过并被缓存,那么我们距离Response就只有一个系统调用的距离了。不是这样的话,那么DNS查询必须完成,之后其他工作才能继续开展。DNS查询的时间依据网络提供商的不同,可能差距很大,比如网站的流行度和本Hostname在中间DNS服务器缓存的可能性,还有对应域名服务器的响应时间,都会影响这一时间消耗。换句话说便是变数很多,而且要知道一个数百毫秒的DNS查询也不是不常见,真是肉疼。

three-way-handshake

解析好的IP地址到手后,Chrome便可向目标打开一个新的TCP连接,这需要经过三次握手SYN > SYN-ARK > ACK。这次数据交换让每个新的TCP连接都背负了一个完整的Roundtrip时延,似乎没有捷径可走。依据客户端和服务端的距离与选择的路由路径,这可造成十至百,乃至上千毫秒的延时。这些工作都是在一个有效的Request数据传输前需要消耗的!

一旦TCP握手完成,如果我们是用HTTPS协议建立连接的话,SSL握手又将开始。这又将增加两个Roundtrip时延,如果SSL回话被缓存,那么我们可以开心地节省一次Roundtrip。

终于,Chrome可以发送HTTP Request(上图requestStart标识)。服务器收到回复后便处理Request并发送Response。这造成了一个Roundtrip,并加上服务器的处理时间。似乎我们终于结束了,不过如果如果返回了HTTP Redirect,我们还需要再重走一遍。所以如果你服务器上有好几个Redirect,最好优化一下这个实现。

你是不是已经开始计算总的延时了呢?我们在特定的带宽情况下来假设一个延时最大的情况,本地缓存失效,立刻执行一个较快的DNS查询(50ms),TCP握手,SSL握手和一个相对较快的服务器响应时间(100ms),并设定Roundtrip时间为80ms(一个跨越美洲的平均时间)。

  • 50ms DNS查询
  • 80ms TCP握手,(一次RTT)
  • 160ms SSL握手,(两次RTT)
  • 40ms 发送请求给服务器
  • 100ms 服务器处理请求
  • 40ms 服务器返回结果

本单次Request总计470毫秒,其中和真正的服务器处理请求的时间想比,80%的时间消耗在了网络延时上。我们得做点什么!事实上,470毫秒已经是一个乐观的估计了:

  • 如果服务器的响应不能被装入一个最先的TCP拥塞窗口 (4-15 KB),那么需要继续加上一个或多个Roundtrip延时。
  • 如果我们需要加载一个缺失的证书,或是进行一个在线证书状态检查(OCSP),SSL延时可能会更长。可能会增加数百上千的毫秒延时。

怎样算是“足够快”?

在我们之前的例子中,由于DNS、握手和Roundtrip造成的延时是影响总延时的大头,其中服务器造成的延时只有区区20%。但是,从更加宏观的角度来看,这个延时有造成影响吗?在读本文的你可能已经知道了答案:对,影响很大。

之前的用户体验调查展示了我们作为用户,对各种应用响应程度的需求:

延时 用户反馈
0 - 100ms 秒开啊
100 - 300ms 似乎顿了一下
300 - 1000ms 好吧还好不是死机
1s+ 我刚才想干什么来着
10s+ 容我去睡一会

上表也解释了Web性能社区的一个不成文的规定:渲染页面,或者至少在250ms内提供视觉反馈,来保持用户的注意力。这不能算是从源头上提高了速度。Google、Amazon、Microsoft等千家网站都发现额外的延时对网站有着直接影响:更快的网站意味着更多的PV,更高的参与度和更高的转化率

说到这你应该明白了,我们优化目标是250ms,而上述DNS查询,TCP/SSL握手和Request传输时间加在一起足足由370ms,我们已经超了50%的时间,并且我们甚至还没有考虑服务器的时间消耗!

对于绝大多数用户,甚至开发者而言,DNS、TCP和SSL延时都是完全透明的,并且是在网络层进行的,我们便较少深入和思考。但这些步骤却对总体用户体验有着极大的影响,因为这都可能带来十或百的毫秒延时。这就是为什么Chrome的网络栈比一个简单的Socket处理程序复杂很多的原因

现在我们已经认清了问题所在,让我们深入实现细节看看

从10,000英尺高度鸟瞰Chrome的网络栈

多进程架构

Chrome的多进程架构明显地暗示了在浏览器中一个网络Request是如何处理的。在底层,Chrome实际上支持四种不同的执行模型来确定使用哪一种进程分配模型。

默认情况下,桌面版的Chrome使用Process-per-site模型,这将不同网站隔离开来,并将同一网站的所有实例都组在一个进程中。不过,为了更加方便大家理解,让我们假设这样一个简单的情况:对于每一个打开的tab,都分配一个独立的进程。从网络性能角度来考虑,区别不是很大,但Process-per-tab的模型更加容易理解。

process-model

这一架构下,每一个渲染进程都对应与一个tab,其中都运行着开源Blink布局引擎,它是解释和进行HTML布局的(也就是图中的“HTML Renderer”),还运行着V8 Javascript虚拟机、桥接这两个引擎的代码和一些其他组件。如果你感兴趣的话,可以查看Chromium wiki中的介绍

每一个这些“render”进程都在沙盒环境下执行,这保证它们对用户电脑的访问受限,当然也包含了网络。为了获取这些资源,每一个render进程和Browser主进程进行通信,这就可以为每一个render加上安全的访问策略。

进程内通信(IPC)和多进程资源加载

在Render和Browser进程间的所有通信都是通过IPC完成的。在Linux和OS X中,一个socketpair()用来提供一个异步的、有标识的管道传输。每个来自render的消息都被序列化后,传递给一个专用的I/O线程,由I/O线程再向Browser进程分发。在接收端,Browser进程提供一个过滤器接口,这运行Chrome对资源IPC请求进行拦截处理(查看ResourceMessageFilter),转交给网络栈进行处理。

network-stack

一个本架构的好处是所有的资源Request都在I/O线程上处理,所以任何UI触发的活动或是网络事件会不相互干涉。资源过滤器在Browser进程的I/O线程中执行,拦截资源Request消息,并将它们转发给Browser进程中的ResourceDispatcherHost单实例对象。

单实例接口下,浏览器可控制每个render对网络的访问,这也让一个高效和一致的资源共享机制成为可能。

  • Socket池和连接数限制:浏览器可限制打开的sockets数量,默认为256每个profile,32每个Proxy,和6每个{scheme, host, port}三元组。这表明同时最大可以允许6个HTTP和6个HTTPS连接到同一个{host, port}
  • Socket重用:TCP连接在为某Request服务后可以不断开,被保存在Socket池中以重用,这也就避免了附加的DNS,TCP和SSL(如果需要的话)重新建立带来的开销。
  • Socket后期绑定:Requests只有在socket可以传输应用的Request的时候,才和一个底层的TCP连接绑定,这样便可更好进行Request的优先级调度(例如在socket建立连接的时候,产生了更高优先级的Request),更好的吞吐(比如在打开新的连接的时候,已有的socket变为可用状态,便可以重复使用“热”的TCP连接)。同时还有TCP预连接和一系列的其他优化。
  • 持续的会话状态:验证,Cookies和缓存数据可以在所有的render进程间共享。
  • 全局资源和网络优化:可以基于所有的请求进行决策,比如说,给予前台tab的网络请求更高的优先级。
  • 基于预测的优化:通过观察所有的网络流量,Chrome可以建立并改善预测模型来提升性能。
  • 还有很多

对于render进程来说,它需要做的仅仅是用一个独特的Request ID来标记一个Request请求消息,并通过IPC发送出去,然后一切交由Browser进程接手。

跨平台的资源获取

cross-platform

在实现Chrome的网络栈中,其中一个主要的关注点便是在多个平台间的可移植性:Linux, Windows, OS X, Chrome OS, Android, 和iOS。为了应对这一挑战,网络栈被实现为通常单线程工作(有分离的缓存和代理线程)的,跨平台的库,这使Chrome可重用相同的基础代码,并提供相同的性能优化,这也是跨平台优化的绝佳机会。

所有相关的网络的代码,都开源在src/net 子目录。在这我们不会详细讨论所有的模块,但你能从代码的布局看出它的结构和可以跨越的平台。举个例子:

net/android Android运行时的绑定
net/base 通用网络工具库,例如域名解析,cookies,网络环境变动侦测,SSL证书管理
net/cookies HTTP cookies的存储、管理和获取的实现
net/disk-cache 网络资源的磁盘和内存的缓存实现
net/dns 一个异步DNS解析器的实现
net/http HTTP协议的实现
net/proxy 代理(Socks和HTTP)配置,解析,脚本获取等等
net/socket 跨平台的TCP sockets实现,SSL流和Socket池
net/spdy SPDY协议实现
net/uri_request URLRequest, URLRequestContext, and URLRequestJob的实现
net/websocket Websockets协议实现

以上每一个子模块都适合好奇的你阅读,代码文档齐全,并且你会找到不少单元测试。

在移动平台上的架构和性能

mobile

即便是谨慎估计,移动浏览器的使用率也呈现出指数级的增长,可以预期在不远的将来,它将蚕食桌面浏览器的份额,令其黯然失色。因此对于Chrome团队,带给用户良好的移动浏览体验是具有极高优先级的。在2012年初,Chrome Android推出,几个月后,Chrome iOS也推向市场。

对于Chrome的移动版本,你需要了解的头一件事情便是它不是桌面浏览器的直接移植,如果那样将不能获得最好的用户体验。客观来说,移动环境资源更加紧缺,并有着更多不同的操作参数:

  • 桌面用户使用鼠标浏览,可能由重叠的窗口,有很大的显示屏,并且一半没有电池能耗限制,通常有一个稳定的网络连接,并有更大的磁盘存储和内存。
  • 移动用户使用触摸和手势进行浏览,有很小的屏幕,需要考虑能耗,网络连接不畅,只有受限的存储和内存。

更进一步来说,很难说一个设备是典型的“移动设备”,反倒是很大量的设备的性能都各不相同,Chrome必须对每个设备都能很好的适应,才能带来最佳的性能。幸运的是,正是因为多种多样的运行模型,Chrome才能做到这种适应性。

android-chrome

在Android设备上,Chrome也使用了和桌面版本相同的多进程架构,有一个Browser进程,和一个或多个Renderer进程。唯一的不同便是移动设备的内存容量限制,Chrome不太可能还是采取每个tab一个Renderer的策略。Chrome通过可用的内存,和设备的其他限制条件,来确定最优的Renderer数量。它会在多个tab中分享Renderer进程。

在只有很少的资源可用,或者是Chrome不能启动多进程架构的时候,它还是可以切换到一个单进程多线程的运行模型中。事实上,在iOS设备上,由于底层平台的沙箱限制,Chrome就是这么做的——单进程多线程运行

那网络性能呢?首先,在Android和iOS中,Chrome使用了同样的网络栈,其他平台也一样。这让Chrome可以在多平台下实现相同的网络优化,这对Chrome的性能优化工作来说十分重要。但是,某些参数比如预测优化的优先级、Socket的超时、管理逻辑和缓存大小,在不同平台间还是有所区别的,并随着设备的能力和使用的网络动态调整。

例如,为了节约电池,移动版Chrome有选择地推迟关闭空闲的Socket,仅仅当打开新的Socket后,才会关闭旧的,这样可以最小化发射功率。类似的是,我们上文中提到的预渲染,是需要一笔不菲的网络和处理资源支出的,这通常在用户处于WIFI环境下才会开启。

对于Chrome开发团队来说,优化移动浏览体验是处于最高优先级列表中的一个,可以预见的是,很多新的提升将不断推出。事实上,这个话题值得单独成文,兴许在POSA系列的下一篇中。

推测式的Chrome预测优化

chrome-wings

你越是使用,Chrome越快。这得归功于Predictor单例对象。它在Browser进程中国被初始化,而它的职责就是观察网络模式,学习并预测用户接下来可能的动作。比如说:

  • 当用户将鼠标置于某一个超链接上时,这说明他接下来可能会导航到那里,Chrome便可以预先开始进行DNS查询,甚至预先进行TCP握手。等当用户真正点击的时候,这通常已经过去了大约200ms,我们很可能已经完成了DNS和TCP阶段,为这次浏览节约了数百毫秒的额外时延。
  • 在Omnibox(URL)中输入时,会根据相似度提示,这也会激活类似的预测优化:DNS查询、TCP预连,甚至可以在后台预先渲染出这个页面。
  • 我们每人都有最爱的一些网站。Chrome可以从这些站点的资源中学习,并预测性地预先解析、获取这些资源来加速浏览体验。
  • 这个列表还能写很长…

Chrome会发现网络的拓扑,并在你使用时学习你独有的浏览习惯。如果一切顺利的话,它将为用户每次浏览节省下数百毫秒的延时,让用户接近“秒开”的畅快体验。为了做到这一点,Chrome利用了四个核心优化技术:

DNS 预解析 提前进行域名解析,来避免DNS延时
TCP 预连接 提前和目标服务器进行连接,来避免TCP握手延时
资源预加载 提前获取关键资源文件,来加速页面的渲染
页面预渲染 提前获取整个页面,包括所有的资源文件,当用户真正点击时,带来秒开的体验

每个优化动作在触发前,都要经过一系列的限制条件,毕竟,这仅仅只是通过预测来优化,如果预测失败的话,不必要的计算和网络浏览就白消耗了,甚至会对用户的实际浏览造成负面的影响。

那Chrome是如何解决这一问题的呢?预测器会处理尽量多的信号,其中包括了用户产生的动作、历史浏览记录和render、网络栈产生的信号

ResourceDispatcherHost,一个负责Chrome内部所有网络活动的实体,不同的是,Predictor对象对Chrome内部的用户和网络创建了一系列过滤器:

  • IPC管道过滤器,监测Render进程的信号
  • ConnectInterceptor实体被添加在每一个Request中,这样的话它便可以观察流量模式,并为每个Request记录下数据(success metrics)。

举个栗子来说,Render进程在如下情况发生时,都会向Browser进程发送消息。请查看在ResolutionMotivation(url_info.h)的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum ResolutionMotivation {
MOUSE_OVER_MOTIVATED, // 用户触发的鼠标经过事件Mouse-over initiated by the user.
OMNIBOX_MOTIVATED, // Omni-box 提示
STARTUP_LIST_MOTIVATED, // 本资源在10大最常访问之列
EARLY_LOAD_MOTIVATED, // 某些情况下我们使用预加载来在发射真实的Request前预热连接

// 如下和预测式预加载有关,由浏览触发
// The following involve predictive prefetching, triggered by a navigation.
STATIC_REFERAL_MOTIVATED, // 外界知识库
LEARNED_REFERAL_MOTIVATED, // 从之前的浏览习惯中总结得到
SELF_REFERAL_MOTIVATED, // 猜测会产生第二次连接

// <snip> ..,
};
```

获知一个信号后,预测器的目标便是评估其正确的可能性,若资源获取到后,激活事件。每个预测都有成功性,优先级和一个有时效的时间戳。组合起来可以建立一个内部的优先队列来优化预测。最终,对于每一个从此队列发出的Request,预测器还会持续追踪它的成功率,这让后续优化成为可能。

果壳中的Chrome网络架构

  • Chrome使用一个多进程架构,这将Render和Browser进程隔离
  • Chrome维护了一个资源调度分配的单例,它被所有Render进程公用,并运行在Browser进程中
  • 网络栈是一个快平台,大多数情况下单线程的库
  • 网络栈使用非阻塞的操作来管理所有的网络操作
  • 共享的网络栈让资源优先级、重用策略更加高效,并让跨进程全局优化成为可能
  • 每个Render进程都和资源分配器通过IPC通信
  • 预测器拦截资源Request和Response,学习并优化未来的Request
  • 预测器会安排DNS,TCP,甚至是资源请求,这基于学习到的网络模式,当用户浏览时能节省数百毫秒

(未完待续)