Boo's Blog

Stay foolish, Stay hungry

《构建高性能 Web 站点》读书笔记

绪论

等待的真相

当在浏览器中输入了一个地址,直到浏览器返回页面之前的那段时间里,都发生了一些什么呢?

大概经历了以下几部分时间:

  • 数据在网络上传输的时间
    • 客户端(浏览器)发出请求数据到达服务器的时间
    • 服务端(服务器)响应数据经过网络到达客户端的时间
  • 站点服务器处理请求并生成响应数据的时间
  • 浏览器本地计算和渲染的时间

“数据在网络上传输的时间”我们通常称之为响应时间,它的决定因素主要包括发送的数据量和网络宽带。

站点服务器处理请求并生成响应数据的时间主要消耗在服务端,其中包括非常多的环节,我们一般用另一个指标来衡量这部分时间,即每秒处理请求数,也称吞吐率,这里的吞吐率并不是指单位时间内处理的数据量,而是请求数。影响服务器吞吐率的因素非常多,比如:服务器的并发策略、I/O 模型、I/O 性能、CPU 核数等,当然也包括应用程序本身的逻辑复杂度等。

浏览器本地计算和渲染的时间自然消耗在浏览器端,它依赖的因素包括浏览器采用的并发策略、样式渲染方式、脚本解释器的性能、页面大小、页面组件(图片、CSS、JS等)数量、页面组件缓存状况、页面组件域名分布及DNS 解析等。

数据得网络传输

因为大多数开发者生活在应用层,这些似乎与他们毫无关系,然而一旦当你开始将注意力转向站点性能时,这些基础知识便是你不能不知道的。

如何计算响应时间

响应时间 = 发送时间 + 传播时间 + 处理时间

发送时间很容易计算,即”数据量/宽带“,比如要发送100Mbit 的数据,而且发送速度为 100Mbit/s,也就是宽带为 100M,那么发送时间便为 1s。值得注意的是,在两台主机之间往往存在多个交换节点,每次的数据转发,都需要花费发送时间,那么总的发送时间也包括这些转发时所花费的发送时间。

传播时间主要依赖于传播距离,因为传播速度我们可以近似认为约等于 2.0x108m/s,那么传播时间便等于传播距离除以传播速度。比如两个交换节点之前线路的长度为 1000km,相当于北京到上海的直线距离,那么一个比特信号从线路的一端到另一端的传播时间为 0.005s。

处理时间就是指数据在交换节点中为存储转发而进行一些必要的处理所花费的时间,其中的重要组成部分就是数据在缓冲区队列中排队所发送的时间。注意,准确地说应该是”你的数据“在队列中排队所花费的时间,因为在队伍中还有其他与你毫不相干的数据。

如果全世界只有你的服务器和你的用户在传输数据,那么用于排队处理时间可以忽略。

那么,我们可将响应时间的计算公式整理为:
响应时间 = (数据量比特数 / 带宽)+ (传播距离 / 传播速度)+ 处理时间

另外,下载速度的计算公式如下:
下载速度 = 数据量字节数 / 响应时间

服务器并发处理能力

吞吐率指的是单位时间内服务器的请求数。

吞吐率是指在一定并发用户数的情况下,服务器处理请求能力的量化体现。

我们要统计吞吐率,便存在一些潜在的前提,那就是压力的描述和请求性质的描述。

压力的描述一般包括两部分,即并发用户数和总请求数,也就是模拟多少个用户同时向服务器发送多少个请求。

请求性质则是堆请求的URL 所代表的资源的描述,比如 1KB 大小的静态文件,或者包含10 次数据库查询的动态内容等。

所以,吞吐率的前提包括如下几个条件:

  • 并发用户数
  • 总请求数
  • 请求资源描述

CPU 并发计算

服务器之所以可以同时处理多个请求,在于操作系统通过多执行流体希设计使得多个任务可以轮流使用系统资源,这些资源包括CPU、内存以及I/O 等。

进程

事实上,大多数进程的时间都主要消耗在了I/O操作上,现代计算机的DMA(Direct Memory Access 直接内存访问)技术可以让CPU 不参与I/O 操作的全过程,比如进程通过系统调用,使得CPU 向网卡或者磁盘等 I/O 设备发出指令,然后进程被挂起,释放出CPU 资源,等待 I/O 设备完成工作后通过中断来通知进程重新就绪。

每个进行都有自己独立的内存地址空间和生命周期。当子进程被父进程创建后,便将父进程地址空间的所有数据复制到自己的地址空间,完全继承父进程的所有上下文信息,它们之间可以通信,但是不互相依赖,也无权干涉彼此的地址空间。

进程调度器

在单CPU 的机器上,虽然我们感觉到很多任务在同时运行,但是从微观意义上讲,任何时刻只有一个进程处于运行状态,而其他的进程有的处于挂起状态并等待就绪,有的已经就绪但等待CPU 时间片,还有的处于其他状态。

内核中的进程调度器(Scheduler)维护着各种状态的进程队列。在 Linux 中,进程调度器维护着一个包括所有可运行进程的队列,称为“运行队列(Run Quere)”,以及一个包括所有休眠进程和僵尸进程的列表。

进程调度器的一项重要工作就是决定下一个运行的进程,如果运行队列中有不止一个进程,那就比较伤脑筋了,按照先来后到的顺序也许不是那么合理,因为运行在系统中的进程有着不同的工作需要,比如有些进程需要处理紧急的事件,有些进程只是在后台发送不太紧急的邮件,所以每个进程需要告诉进程调度器它们的紧急程度,这就是进程优先级

系统负载

在进程调度器维护的运行队列中,任何时刻至少存在一个进程,那就是正在运行的进程。
而当运行队列中有不止一个进程的时候,就说明此时CPU 比较抢手,其他进程还在等着呢,进程调度器应该尽快让正在运行的进程释放CPU。

通过在任何时刻查看 /proc/loadavg,可以了解到运行队列的情况。

1
2
ubuntu@localhost:~$ cat /proc/loadavg 
4.28 4.05 4.02 4/482 6246

注意 4/482这部分,其中的 4 代表此时运行队列中的进程个数,而 482 则代表此时的进程总数。

最右边的 6246 代表到此时为止,最后创建的一个进程ID。

接下来,左边的三个数值,分别是 4.28、4.05、4.02,它们就是我们常说的系统负载。
我们都知道,系统负载越高,代表CPU 越繁忙,越无法很好地满足所有进程的需要。

但是,系统负载是如何计算而来的呢?根据定义,它是在单位时间内运行队列中就绪等待的进程数平均值,所以当运行队列中就绪进程不需要等待就可以马上获得CPU 的时候,系统负载便非常低。当系统负载为 0.00 时,说明任何进程只要就绪后就可以马上获得 CPU,不需要等待,这时候系统响应速度最快。

那么,刚才提到的三个数值,便是系统最近 1 分钟、5 分钟、15 分钟分别计算得出的系统负载。

我们还可以通过其他方法获得系统负载,比如top、w 等工具,从实现方法上看,这些工具获得的系统负载都是来源于 /proc/loadavg

了解了这些内容后,要想提高服务器的系统负载,很简单,只需要编写一个没有任何 I/O 操作并且长时间占用 CPU 时间的PHP 脚本,比如一个循环累加器,如下所示:

1
2
3
4
5
6
7
8
<?php
$max = 100000000;
$sum = 0;
for ($i = 0; $i < $max; ++$i){
$sum += $i;
}

echo $sum;

然后用100 个并发用户请求这个脚本,进行压力测试,这时候查看系统负载就会看到如下:

1
load average: 98.26, 45.89, 17.94

进程切换

所以,如果我们希望服务器支持较大的并发数,那么就要尽量减少上下文切换次数,最简单的做法就是减少进程数,尽量使用线程并配合其他I/O 模型来设计并发策略。

I/O模型

对于网络 I/O和磁盘 I/O,它们的速度要慢很多。这些I/O 操作需要由内核系统调用来完成,同时系统调用显然需要CPU 来调度,而CPU 的速度毫无疑问是非常快的,这就使得CPU 不得不浪费宝贵的时间来等待慢速I/O 操作。

尽管我们通过多进程等方式来充分利用空闲的CPU 资源,但我们还是希望能够让CPU 花费足够少的时间在I/O 操作的调度上,这样就可以腾出更多的CPU 时间来完成更多的I/O 操作。

PIO与DMA

在介绍I/O 模型之前,有必要简单地说说慢速I/O 设备和内存之间的数据传输方式。

我们拿磁盘来说,很早以前,磁盘和内存之间的数据传输是需要CPU 控制的,也就是说如果我们读取磁盘文件到内存中,数据要经过CPU 存储转发,这种方式称为 PIO。显然这种方式非常不合理,需要占用大量的CPU 时间来读取文件,造成文件访问时系统几乎停止响应。

后来,DMA(Direct Memory Access 直接内存访问)取代了PIO,它可以不经过CPU 而直接进行磁盘和内存的数据交换。在DMA 模式下,CPU 只需要向DMA 控制器下达指令,让DMA 控制器来处理数据的传输即可,DMA 控制器通过系统总线来传输数据,传送完毕再通知CPU,这样就很大程度上降低了CPU 占有率,大大节省了系统资源,而它的传输速度与PIO 的差异并不是十分明显,因为这主要取决于慢速设备的速度。

opcode

缓存更加注重的是策略,也就是说缓存命中率,如果每次都能在缓存中找到需要的数据,那是最理想的结果,如果每次都在缓存中找不到需要的数据,那么缓存将变得毫无价值。

解释器核心引擎根本看不懂这些脚本代码,无法直接执行,所以需要进行一系列的代码分析工作,当解释器完成对脚本代码的分析后,便将它们生成可以直接运行的中间代码,也称为操作码(Operate Code,opcode)。

对于解释型语言而言,从程序代码到中间代码的这个过程,我们称为解释(parse),它由解释器来完成。对于编译型语言而言,从程序代码到中间代码的这个过程称为编译(compile)。

编译器和解释器的一个本质区别在于,解释器生成中间代码后,便直接执行它,所以运行时的控制权在解释器; 而编译器则将中间代码进一步优化,生成可以直接运行的目标程序,但不执行它,用户可以在随后的任意时间执行它,这时控制权在目标程序,和编译器没有任何关系。

事实上,就解释和编译本身而言,它们的原理是相似的,都包括词法分析、语法分析、语义分析等。

为什么开启 opcode,对性能的提升会巨大?
这是因为 PHP 在动态解析语法的过程中,会生成操作码,而打开opcode 缓存,就可以避免重复编译。

当然,并不是所有的动态内容都在应用了 opcode cache 之后有大幅度的性能提升,因为 opcode cache 的目的是减少CPU 和内存开销,如果动态内容的性能瓶颈不在于CPU 和内存,而在于I/O 操作,比如数据库查询带来的磁盘I/O 开销,那么opcode cache 的性能提升是非常有限的。

有意义的问题

Q:假如100 个用户同时向服务器分别进行 10次请求,与 1 个用户向服务器连续进行 1000 次请求,效果一样吗?也就是说给服务器带来的压力一样吗?
A:虽然看起来服务器都需要连续处理 1000 个请求,其实关键的区别就在于,是否真的”连续“。
首先有一点需要明白,对于压力测试中提到的每一个用户,连续发送请求实际上是指在发送一个请求并接受到响应数据后再发送下一个请求,这样一来,从微观层面来看,1 个用户向服务器连续进行 1000次请求的过程中,任何时刻服务器的网卡接收缓冲区中只有来自该用户的 1 个请求,而 100 个用户同时向服务器分别进行 10 次请求的过程中,服务器网卡接收缓冲区最多有 100 个等待处理的请求,显然这时服务器的压力更大。

Q:关于worker 进程的数量,既然可以由我们来设置,那么是不是越多越好呢?
A:显然不是,任何时刻从CPU 的角度来看,只有一个进程在运行。没有一个绝对的公式来告诉你如何选择worker 进程数,需要根据实际情况具体分析和调整。

Q:7ms 意味着什么呢?
A:一个比特通过光纤从北京传到西安,理论上只需要 5ms; 25 毫秒足以让比特传播接近地球赤道半径的距离。

Q:缓存的目的?
A:缓存的目的就是把需要花费昂贵开销的计算结果保存起来,在以来需要的时候直接取出,避免重复计算。

评论