建筑师的注解

一旦您开始使用Varnish源代码,您就会注意到Varnish并不是MILL应用程序的普通运行。

这不是巧合。

我花了很多年来研究FreeBSD内核,很少涉足用户领域的编程,但当我有机会这样做时,我总是发现人们编程的方式仍然是1975年的。

因此,当我被联系到Varnish项目时,我并不是真的感兴趣,直到我意识到这将是一个很好的机会,试图将我所有关于硬件和内核如何工作的知识很好地利用起来,现在我们已经到了阿尔法阶段,我可以说我真的很喜欢它。

那么1975年的编程有什么问题呢?

简而言之,计算机不再有两种存储方式。

它曾经是主要的存储设备,从充满汞的声学延迟线,到小的磁性面团,再到晶体管触发器,再到动态随机存取存储器。

然后是次要商店,纸带,磁带,房子大小的磁盘驱动器,然后是洗衣机的大小,现在小到让女孩们感到失望,如果她们认为她们拿到的不是你口袋里的MP3播放器。

人们就是这样编程的。

它们在“内存”中有变量,并将数据移入和移出“磁盘”。

以Squid为例,这是我见过的1975年的程序:你告诉它可以使用多少内存和多少磁盘。然后,它将花费大量时间跟踪RAM中的HTTP对象和磁盘上的HTTP对象,并根据流量模式来回移动这些对象。

那么,今天的计算机实际上只有一种存储,通常是某种磁盘,操作系统和虚拟内存管理硬件已经将RAM转换为缓存用于磁盘存储。

因此,鱿鱼精细的内存管理所发生的是,它与内核复杂的内存管理发生了争执,就像任何内战一样,永远不会完成任何事情。

发生的情况是这样的:Squid在“RAM”中创建一个HTTP对象,并在创建后迅速使用它。然后,一段时间后,它不再获得更多的点击,内核注意到了这一点。然后,有人试图从内核获取内存,然后内核决定将这些未使用的内存页推出以交换空间,并更明智地使用(缓存-RAM)来存储程序实际使用的一些数据。然而,这是在鱿鱼不知情的情况下完成的。Squid仍然认为这些http对象在RAM中,当它试图访问它们的那一刻,它们就会在RAM中,但在那之前,RAM被用来做一些有意义的事情。

这就是虚拟内存的意义所在。

如果Squid什么都不做,事情就会好起来,但这就是1975年的编程开始发挥作用的地方。

一段时间后,Squid还会注意到这些对象未被使用,并决定将它们移到磁盘上,以便RAM可以用于更繁忙的数据。因此Squid会创建一个文件,然后将http对象写入该文件。

在这里,我们切换到高速摄像机:Squid调用WRITE(2),我给出的地址是一个“虚拟地址”,内核将其标记为“不在家”。

因此,CPU硬件分页单元会触发一个陷阱,这是对操作系统的一种中断,告诉它“请修复内存”。

内核试图找到一个空闲页面,如果没有空闲页面,它将从某个地方获取一个很少使用的页面,很可能是另一个很少使用的Squid对象,将其写入磁盘上的分页轮询空间(“交换区”)。当写入完成时,它将从分页池中的另一个位置读取它“调出”到现在未使用的RAM页面的数据,修复分页表,并重试失败的指令。

鱿鱼对此一无所知,对于鱿鱼来说,这只是一次正常的内存访问。

因此,现在Squid将对象放在RAM中的页面中,并将其写入磁盘的两个位置:一个副本位于操作系统分页空间中,另一个副本位于文件系统中。

Squid现在将此RAM用于其他用途,但一段时间后,HTTP对象获得命中,因此Squid需要将其恢复。

首先Squid需要一些RAM,因此它可能决定将另一个HTTP对象推送到磁盘(重复上述步骤),然后将文件系统文件读回RAM,然后在网络连接套接字上发送数据。

你觉得这些听起来像是白费力气吗?

以下是Varnish如何做到这一点:

Varnish分配一些虚拟内存,它告诉操作系统用磁盘文件中的空间来支持这些内存。当它需要将对象发送到客户端时,它只是引用那块虚拟内存,而将其余部分留给内核。

如果/当内核决定它需要使用RAM来做其他事情时,该页将被写入到备份文件中,并且RAM页在其他地方被重用。

当Varnish下一次引用虚拟内存时,操作系统将找到一个RAM页,可能会释放一个页,并从备份文件中读取内容。

就是这样。Varnish实际上并不试图控制什么缓存在RAM中,什么不缓存,内核有代码和硬件支持来做好这方面的工作,而且它做得很好。

Varnish在磁盘上也只有一个文件,而Squid将一个对象放在它自己的单独文件中。不需要将HTTP对象作为文件系统对象,因此没有必要在文件系统名称空间(目录、文件名等)中为每个对象浪费时间,在Varnish中我们所需要的只是一个指向虚拟内存的指针和一个长度,其余的工作由内核完成。

虚拟内存是为了在数据大于物理内存时更容易编程,但人们仍然没有意识到这一点。

更多缓存

但是有更多的缓存,硅黑手党或多或少地在4 GHz的CPU时钟上停滞不前,为了达到这个目的,他们不得不在CPU和RAM(即4级缓存)之间放置1级、2级、有时3级缓存,还涉及写缓冲区、流水线和页面模式读取等内容,所有这些都是为了降低从内存中提取数据的速度。

由于它们已经达到了4 GHz的极限,但不断减小的硅特征尺寸使它们可以使用越来越多的晶体管,多CPU设计已经成为世界的幻想,尽管它们作为编程模型很糟糕。

多CPU系统并不是什么新鲜事,但编写同时使用多个CPU的程序一直很棘手,现在仍然是如此。

编写在多CPU系统上运行良好的程序就更难了。

假设我有两个统计计数器:

无符号n_foo;无符号n_bar;

因此,一个CPU正在缓慢运行,必须执行n_foo++

为此,它读取n_foo,然后写回n_foo。它可能涉及也可能不涉及到CPU寄存器的加载,但这并不重要。

读取内存位置意味着检查它是否在CPU的一级缓存中。除非它被非常频繁地使用,否则它不太可能是。接下来检查二级缓存,让我们假设这也是一次未命中。

如果这是一个单CPU系统,游戏在这里结束,我们从RAM中取出它并继续前进。

在多CPU系统上,如果CPU共享一个插槽或拥有自己的插槽并不重要,我们首先必须检查是否有任何其他CPU在其缓存中存储了n_foo的修改副本,因此一个特殊的总线事务会出去找出这一点,如果某个CPU返回并说“是的,我有它”,CPU就可以将其写入RAM。对于好的硬件设计,我们的CPU将在写操作期间监听总线,如果设计不好,它将不得不在之后进行内存读取。

现在,CPU可以递增n_foo的值,并将其写回。但它不太可能直接返回到内存,我们可能很快就会再次需要它,因此修改后的值存储在我们自己的一级缓存中,然后在某个时刻,它将最终存储在RAM中。

现在假设另一个CPU想要同时执行n_bar+,它能做到吗?不是的。缓存不是对字节进行操作,而是对某些“行大小”的字节进行操作,通常是每行8到128个字节。因此,由于第一个CPU忙于处理n_foo,第二个CPU将尝试获取相同的缓存线,因此它将不得不等待,即使它是一个不同的变量。

开始明白了吗?

是的,它很难看。

我们该如何应对呢?

如果可能的话,尽量避免内存操作。

以下是瓦尼什试图做到这一点的一些方法:

当我们需要处理HTTP请求或响应时,我们有一个指针数组和一个工作区。我们不会为每个标头调用Malloc(3)。我们为整个工作区调用它一次,然后从那里为页眉选择空间。这样做的好处是,我们通常一次释放整个头文件,只需重新设置指向工作区开始处的指针即可。

当我们需要将HTTP头从一个请求复制到另一个请求(或从响应复制到另一个请求)时,我们不复制字符串,只复制指向它的指针。如果我们不更改或释放源头,这是非常安全的,一个很好的例子是从客户端请求复制到我们将发送到后端的请求。

当新的头比源的生命周期更长时,我们必须复制它。例如,当我们将头存储在缓存对象中时。但在这种情况下,我们在工作区中构建新的标头,一旦我们知道它将有多大,我们就执行一次单个Malloc(3)来获得空间,然后我们将整个标头放在那个空间中。

我们还尝试重复使用缓存中可能存在的内存。

工作线程以“最近最忙”的方式使用,当工作线程空闲时,它转到队列的最前面,在那里它最有可能获得下一个请求,这样它已经缓存的所有内存、堆栈空间、变量等都可以在缓存中重复使用,而不是从RAM中进行昂贵的提取。

我们还为每个工作线程提供了它可能需要的一组私有变量,所有变量都分配在线程的堆栈上。这样,只要这个线程在自己的CPU上运行,我们就可以确定它们在RAM中占据了其他CPU都不会考虑接触的页面。那样的话,他们就不会为高架线而争吵了。

如果您对这一切感到陌生,让我向您保证它是有效的:我们在缓存命中服务上花费的系统调用不到18次,甚至其中许多调用都是为了获得统计数据的时间戳。

这些技术也不是什么新鲜事,我们在内核中已经用了十多年了,现在轮到你来学习了:-)

欢迎来到瓦尼什,这是一个2006年的建筑项目。

phk