标题: 《大型》系列(三)——Cache & Buffer
奶瓶
新手上路
Rank: 1



UID 1217
精华 0
积分 0
帖子 0
阅读权限 10
注册 2007-6-9
发表于 2007-9-8 10:41  资料  个人空间  短消息  加为好友 
《大型》系列(三)——Cache & Buffer

原文地址:http://www.phpx.com/happy/viewthread.php?tid=138416


距离上一篇,大约有7个月了,因为家里有了点事,所以一直没有继续写什么。今天算是续写《大型》系列,这个算是第三部分,主要的侧重点是Cache和Buffer部分。

本篇特为祝贺phpx论坛9月1日聚会圆满成功,同时献给我刚刚过世的母亲,感谢phpx论坛的朋友长时间的关心和帮助。
在《大型》(一)中,我大概地阐述了一下Cache和Buffer的含义。Cache和Buffer是系统架构中非常重要甚至是相当核心的内容。Cache叫做“缓存”,而Buffer叫做“缓冲”,接下来我们分别讨论它们的性质。

概念描述
Cache
缓存在Web架构中扮演了重要的角色。其实,Cache是一个硬件概念。它表示在数据通路中连接两类不同速度的设备的中间部分。我们最直接的印象,应该就是在认识CPU参数时,看见的L2 Cache、这里的L表示Level,L2表示二层缓存(自然还有L1 Cache)。CPU的Cache实际是连接CPU总线和内存总线的中间部分,由于CPU对数据读写的速度要大于内存的读写速度,为了使通路顺畅,需要Cache这类设备来连接这两条不等速的总线,这样它们就不会直接接触。CPU(实际上是寄存器)从内存读取数据时,是先向Cache中查找的,如果命中的话,CPU就可以以全速(部分CPU的L2是半速的,比如经典的P2)地获取数据。这在对数据要做频繁读取的操作中(比如浮点运算)会获得极大的好处。(玩电脑的时间比较长的朋友可能会记得当年的超频极品——哥斯达黎加产的Celeron 300A,其实它的超频性能不一定好过Celeron 300,但是它有128K的全速L2,这样使得它的浮点性能大大好于普通的Celeron。而且有的时候L2也是超频的瓶颈)(有的系统上还有L3 Cache,大部分是集成在主板上的)

在CPU总线之下,还有很多设备之间使用了Cache。相对于CPU对内存的读Cache,有一类Cache是另一个方向为主的写Cache(假设我们按照从高速到低速设备流动为正方向),一个典型的例子就是刻录机。现在的刻录机一般都会有2MB的Cache,部分专业设备有8MB或更大,那么这个Cache是做什么的呢?它的一端是IDE总线(也可能是SCSI或别的),另一端是实际的写入设备(假设是激光头,实际上刻录机远没这么简单),那么当IDE总线以一个相对固定的速率向刻录机发送数据流的时候,激光头是无法保证按照这个原速去写光盘的。根据光盘的质量、震动、温度、偏转角度、电机状态等物理因素,刻录机无法保证写入速度恒定,更无法保证和IDE总线带宽保持一致(哪里有那么快的刻录机……),所以需要一个设备来平衡这之间的速率矛盾,这就是刻录机的写Cache。因为IDE总线只是一条通路,并不是一个存储设备,如果刻录机直接去向IDE总线“要”数据的话,是要不来的,或者得到的不知道是什么东西,这就像是一条窄胡同只能容许一个人通过,一大队人只能站着排一个接一个地走过去。在一个不固定的时间里想截获其中一个人,当然拦住的也不一定是谁,因为走过去的人就已经走过去了,拉不回来(听起来好像黄泉路啊……好可怕)。这样一个写Cache就会扮演一个临时集合的场所——这些人都走进了一个小广场,等待被抓。由于有了这样一个可以存储数据的设备,刻录机就可以主动地“申请”数据,而Cache又可以以很高的反应效率把数据提供给刻录机,保证连续的物理写入、那么当Cache中的有效数据越来越少(广场中的人越抓越少),它就会通知IDE总线继续向Cache中灌接下来的数据(通知那个胡同继续过人)直到保持Cache中的数据写满或基本写满(广场中站满了人)。这样IDE总线无需顾及这台刻录机的物理性能究竟怎样,稳定程度有多高,它只管在应该送的时候把数据送给Cache,别的事情就不归它管了。

另一类特殊的Cache是双向的,即它既用于读取,同时也用于写入。这一般都非纯硬件设备,一个非常典型的例子就是Ramdisk上的临时文件,具体过程大家自己去考虑一下就清楚了。虽然它用软件方法模拟了一种硬件模型,其实严格意义上来说,临时文件并不属于Cache,我们暂时不去考虑它。
这样,我们总结出Cache的几条规律:

1、Cache一般是连接两个设备,通常他们的速度是不同的,或者一方是不稳定的。
2、对于Cache两端设备的数据流通方向来看,Cache一般都是单向的。
3、Cache两端的设备中的高速(匀速)一方没有存储属性。


接下来,我们来讨论Cache的另外一些性质。
Cache有一个很表象的问题,就是Cache里的数据要保存多久。这就涉及到Cache的一个重要属性——有效期。这个属性在读Cache和写Cache中的意义是不同的。在读Cache结构里,我们当然希望可以在Cache中取得尽量多的有用信息,最佳状态是每次请求都可以从Cache中获得数据,而不是从数据源中。从Cache中取得数据的成功率叫做读Cache的命中率。在通常的结构中,Cache的大小都是小于数据源的总数据量的,假设Cache的大小大于数据源的数据总量,例如一个2MB的读Cache,但是数据总量只有1MB,那么所有数据都可以被送进Cache,在这之后,数据源的使命就结束了,读取一方总是可以从Cache中获取到数据,因为如果在Cache中无法得到需要的数据,在数据源中也一定无法得到,所以这时候的命中率是100%(这当然就是最优情况了)。那么在一般的结构中,数据总有在Cache中无法取得的时候,那么读取方就要向数据源获取。一般的操作将是读取方在获取到数据之后,还会把它写入Cache以备以后使用(这和Cache策略有关,即系统关于这笔数据是否有被缓存的必要的决定)。那么向Cache中写入数据的时候,如果Cache已经满了,那么通常就需要删除一部分数据,以腾出空间来供写入,这个时候就是要决定哪些数据已经“过期”了,或者说它现在看起来最“没用”(最没有可能被近期访问到)。如果我们在开始的时候设定Cache中数据在写入5分钟后过期,那么这个时候系统就会去扫描Cache中5分钟以上没有被touch过的数据,这部分数据即已“过期”的,如果没有过期数据,系统可能会根据一个既定的LRU算法来决定哪些数据比较无用。

在写Cache中,有效期的概念相对简单,因为一般来说,它都是被只使用一次的,比如刻录机的例子,在激光头正确地写入了Cache中取来的数据之后,它就无用了,也就可以简单地认为它过期了。大不了一次写失败了,它还可以再多“活”一次。当一笔数据“死”掉之后,系统会标记这部分空间为可覆盖的,那么IDE总线再送来的数据会毫不客气地覆盖在它上面(宣布广场上的一个位置没有人,那么胡同中挤进来的下一个人不会去理会那个位置是不是有人而毫不客气地踩上去)。
综合以上,Cache的另外一些性质:

4、读Cache中的数据总是希望被更多次地访问以代替从数据源中直接读取
5、写Cache中的数据一般在正确读取一次后就无效了
6、在读Cache结构中,向Cache中写入数据一般是由读取方决定并完成的
7、在写Cache结构中,向Cache中写入数据一般是由读取方决定,由发送方完成的。
8、在读Cache中,需要的数据在Cache中存在并被访问的几率叫做命中率,写Cache中一般没有命中率概念。
9、在读Cache中,数据被保存的有效时间叫做生存期,写Cache中一般没有生存期概念。


由以上的性质,我们发现,读Cache比写Cache看上去要复杂一些,而且在很多时候(尤其是我们要解释的Web架构中),使用更多的是读Cache,所以以后的Cache,如无特殊说明,均指读Cache。
那么Cache的一般定义,我们可以简单阐述为:

一块小于数据源的存储空间,系统希望更多地访问它获取数据以代替从数据源中直接获取。

根据我们上面的描述,我们将问题带入Web开发中。(费了半天劲,终于扯入了正题)

Web系统中,Cache的应用点很多,一般来说,Cache被安排在数据瓶颈点的前方,并以这个瓶颈点作为数据源。这也暗合了第一条性质。

比如,很多时候,数据库成为了系统瓶颈,因为它没有那么高的处理能力,可以让应用程序随时、实时地取得数据,所以我们需要Cache,将应用程序中需要数据读取的部分和数据库连接起来。根据Cache的定义描述,我们总是希望程序可以从Cache中直接读取到所需的数据,而不用去查询数据库。

再如,使用一块专用的内存作为磁盘的Cache,程序可以访问这块速度比较高的内存,来代替直接访问相对低速的磁盘设备,以提高整体的IO性能。

Buffer
Buffer其实起初也是一种硬件概念。它指一组固化在存储设备或数据传输设备上的一种特殊内存类元件。比如硬盘上的Buffer,路由器上的Buffer等。后来概念被更广泛地扩展到了软件结构中。八九十年代的玩家大概还记得DOS配置参数(CONFIG.SYS & AUTOEXEC.BAT)里的那句经典得很多人不知道是什么意思的“BUFFER = 9”。

Buffer和Cache有些地方是很像的,但是区别更明显:

首先,Buffer一般大小都是固定的,而且是整块的。也就是说Buffer中通常只有一路单一的数据,而不像Cache可以被很多应用共用(如果不这样,Windows就不能用了)。举例来说,一个socket程序中,接收方开启了一个8192字节的buffer,等待发送方的数据到达,数据是一个bit一个bit地通过网络流进buffer中去的(协议会把数据隐式地重组成字节码),等接收到8192个字节后,buffer满了,程序就把这8192个字节一次从buffer中取出,并把buffer清空,或者宣布它里面的数据无效。这个时候,这8192字节的空间只能做被这个程序做为接收特定地点流入的数据使用。如果另外有一个程序或同一个程序中的另外一段代码使用这块空间做别的事,那么接收程序就会出错,因为它总是整块地把buffer取出来。

另外,Buffer中的数据通常是无法预测的。不像Cache中的数据来自一个已知的数据源,Buffer一般都不能预测其中会接收什么。比如上例中的socket接收程序,很显然,发送方发什么,buffer就会接收什么,在接收到数据之前,接收程序不知道发送方会发些什么东西过来(这有点和写Cache类似)。

与Cache不同,Buffer中的数据通常都是一次性的。数据被取得一次之后就全部失效,而且Buffer的命中率必然100%,因为如果Buffer失效,我们无法再从数据源获取数据。

那么Buffer为什么被叫做“缓冲”呢,它有什么用呢?我们来使用一个实例来说明:

我们写几个简单的小程序,来完成同样的一个功能——复制文件,其中一个不使用Buffer,而另几个使用Buffer。为了描述得更底层,我们使用原始的非流式IO函数。(测试平台:双P3-550,1G ECC,10000RPM SCSI,RHEL3,2.4.1内核)
程序一:prog1.c

#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
    char c;
    int fd_src, fd_des;
    fd_src = open("test.mp3", O_RDONLY);
    fd_des = open("test1.mp3", O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR);
    while (1 == read(fd_src, &c, 1))
        write(fd_des, &c, 1);
    exit(0);
}
这里我们不使用Buffer,或者说只使用了1个字节的Buffer。这个程序很简单,它复制一个大小为4632576字节的MP3文件——test.mp3,复制到test1.mp3。我们在这个平台上编译执行这个小程序,看看它运行了多长时间:

time ./prog1

real    1m54.259s
user    0m5.000s
sys     1m48.790s

结果表明,这个程序花了近两分钟的时间复制一个正常大小的MP3文件,为什么会出现这种情况呢?因为这个程序每次从test1.mp3中读一个字节,立刻把这个字节写入test1.mp3,源文件有4632576个字节,这个程序就执行了4632576次read,4632576次write,一共执行了9265152次函数调用,即有9265152次函数开销,这简直是难以容忍的!

下面的程序,我们改造一下,使用一个8KB大小的Buffer。
程序二:prog2.c

#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>

#define BUFF_SIZE 8192

int main()
{
    char buff[BUFF_SIZE];
    int fd_src, fd_des;
    int nread;

    fd_src = open("test.mp3", O_RDONLY);
    fd_des = open("test1.mp3", O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR);
    while ((nread = read(fd_src, buff, sizeof(buff))) > 0)
        write(fd_des, buff, nread);
    exit(0);
}
除了Buffer大小之外,程序二和程序一几乎完全一样。那么它的测试结果怎样呢:

time ./prog2

real    0m0.110s
user    0m0.000s
sys     0m0.110s

我们得到了一个乍舌的结果:同样的一个mp3文件,复制只花了十分之一秒。那么是为什么呢?因为这个程序中,复制这个4632576字节的文件,只执行了1132次读写操作。

由此我们对于Buffer的作用给出了一个重要的概念:

Buffer的实现目标是为了在一定范围内有效地减少IO调用次数

这和Cache的实现目标有着截然的差别——Cache是为了提高IO的效率。那么为什么说是在一定范围内呢?我们来看看程序三,尽量地开大Buffer,大到可以一次性把文件装入,这样IO调用次数只有2次。我们修改程序三,把BUFF_SIZE改成5MB看看:
程序三:prog3.c

#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>

#define BUFF_SIZE 5 * 1024 * 1024

int main()
{
    char buff[BUFF_SIZE];
    int fd_src, fd_des;
    int nread;

    fd_src = open("test.mp3", O_RDONLY);
    fd_des = open("test1.mp3", O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR);
    while ((nread = read(fd_src, buff, sizeof(buff))) > 0)
        write(fd_des, buff, nread);
    exit(0);
}
在开了5MB的Buffer之后,这个程序的执行时间变为:

time ./prog3

real    0m0.133s
user    0m0.000s
sys     0m0.130s

我们发现,它居然比只有8KB缓冲的时候慢了一些,这是为什么呢?我们再贪心一点,打开128MB的Buffer(Am I crazy?)。
程序四:prog4.c

#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>

#define BUFF_SIZE 128 * 1024 * 1024

int main()
{
    char *buff;
    int fd_src, fd_des;
    int nread;

    buff = (char *) malloc(BUFF_SIZE);

    fd_src = open("test.mp3", O_RDONLY);
    fd_des = open("test1.mp3", O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR);
    while ((nread = read(fd_src, buff, sizeof(buff))) > 0)
        write(fd_des, buff, nread);
    exit(0);
}
程序四直接malloc了128MB的内存做Buffer来用,按照道理来说,128MB和5MB应该都是差不多的,都是一次就可以把文件装入,那么它的结果:

time ./prog4

real    0m29.312s
user    0m1.220s
sys     0m27.940s

结果让我们大跌眼镜,这个程序执行了近半分钟!这是为什么呢?不是说只做了两次IO动作么?

的确是只有2次IO调用,但是这个malloc占用了非常多的时间,因为malloc一块空间需要操作系统来提供一个足够大的连续空闲空间,同时给出一个首地址。这个地址一定要是段首。直接申请大的内存空间是非常耗时的,而且经常失败,因为操作系统要做很多碎片整理工作以保证有这样的一块连续空闲空间给我们使用。同时,大的内存段在IO操作中速度也会变慢。

所以,我们在选择Buffer大小的时候要权衡时空最优比,不要走入极端。

顶部
 



当前时区 GMT+8, 现在时间是 2008-11-21 01:08

    本论坛支付平台由支付宝提供
携手打造安全诚信的交易社区 Powered by Discuz! 5.5.0  © 2001-2007 Comsenz Inc.
清除 Cookies - 联系我们 - PHP开源项目网 - Archiver - WAP