一旦您开始使用 Varnish 源代码,您会注意到 Varnish 不是您的工厂应用程序的平均运行。我在 FreeBSD 内核上工作了很多年,我很少涉足用户态编程,但是当我有机会这样做时,我总是发现人们编程就像 1975 年一样。所以当我接触到 Varnish 项目时,我是直到我意识到这将是一个很好的机会来尝试充分利用我对硬件和内核如何工作的一些知识时才真正感兴趣,现在我们已经达到了 alpha 阶段,我可以说我真的很喜欢它。真正简短的回答是计算机不再有两种存储方式。曾经是你拥有主要存储,它是任何东西,从充满水银的声学延迟线到小型磁性甜甜圈,再到晶体管触发器,再到动态 RAM。然后是二级商店,纸带,磁带,房子大小的磁盘驱动器,然后是洗衣机的大小,现在小到女孩们如果认为她们拥有的不是你口袋里的 MP3 播放器的东西,就会感到失望.它们在“内存”中有变量,并将数据移入和移出“磁盘”。
以 Squid 为例,如果我见过一个 1975 年的程序:你告诉它它可以使用多少 RAM 以及它可以使用多少磁盘。然后它将花费过多的时间来跟踪哪些 HTTP 对象在 RAM 中以及哪些在磁盘上,并且它将根据流量模式来回移动它们。好吧,今天的计算机实际上只有一种存储,通常是某种磁盘,操作系统和虚拟内存管理硬件已将 RAM 转换为磁盘存储的缓存。因此,squid 精心设计的内存管理会发生什么,它会与内核精心设计的内存管理发生冲突,就像任何内战一样,它永远不会完成任何事情。发生的事情是这样的:squid 在“RAM”中创建了一个 HTTP 对象,并在创建后迅速使用了几次。然后一段时间后它不再有点击并且内核会注意到这一点。然后有人试图从内核中获取内存用于某些东西,内核决定将那些未使用的内存页面推出以交换空间,并更明智地将(缓存 RAM)用于某些程序实际使用的数据。然而,这是在squid 不知道的情况下完成的。squid 仍然认为这些http 对象在RAM 中,并且它们将在它尝试访问它们的那一秒中存在,但在那之前,RAM 用于一些有生产力的事情。如果squid什么都不做,一切都会好起来的,但这就是1975年的编程开始的地方。一段时间后,squid也会注意到这些对象没有被使用,它决定将它们移动到磁盘,以便RAM可以用于更繁忙的数据.所以squid 出去,创建一个文件,然后将http 对象写入文件。这里我们切换到高速摄像头:squid 调用 write(2),我给出的地址是“虚拟地址”,内核将其标记为“不在家”。
因此,CPU 硬件分页单元将引发陷阱,一种对操作系统的中断,告诉它“请修复内存”。内核试图找到一个空闲页面,如果没有,它会从某处获取一些使用过的页面,可能是另一个使用过的小对象,当写入完成时将其写入磁盘上的分页轮询空间(“交换区”) ,它将从分页池的另一个地方读取它“调出”到现在未使用的RAM页中的数据,修复分页表,并重试失败的指令。所以现在squid在RAM中的一个页面中拥有对象并写入磁盘的两个位置:操作系统分页空间中的一个副本和文件系统中的一个副本。 Squid 现在将此 RAM 用于其他用途,但一段时间后,HTTP 对象受到攻击,因此 squid 需要将其取回。首先squid 需要一些RAM,因此它可能决定将另一个HTTP 对象推送到磁盘(重复上述操作),然后将文件系统文件读回RAM,然后在网络连接套接字上发送数据。 Varnish 分配一些虚拟内存,它告诉操作系统用磁盘文件中的空间来备份该内存。当它需要将对象发送给客户端时,它只是引用那块虚拟内存,而将其余部分留给内核。如果/当内核决定它需要将 RAM 用于其他用途时,页面将被写入后备文件,并且 RAM 页面将在其他地方重用。
当 Varnish 下次引用虚拟内存时,操作系统会找到一个 RAM 页,可能会释放一个,并从备份文件中读取内容。就是这样。 Varnish 并没有真正尝试控制什么是缓存内存,什么不是,内核有代码和硬件支持来做好这方面的工作,而且它做得很好。 Varnish 在磁盘上也只有一个文件,而 squid putsone 对象在它自己的单独文件中。 HTTP 对象不需要作为文件系统对象,所以没有必要在文件系统命名空间(目录、文件名和所有这些)中为每个对象浪费时间,我们在 Varnish 中需要的只是一个指向虚拟内存的指针和一个长度,内核剩下的。当数据大于物理内存时,虚拟内存旨在使编程更容易,但人们仍然没有接受。但是周围有更多的缓存,硅黑手党更多或更少地停滞在 4GHz 的 CPU 时钟上,为了达到那个程度,他们不得不在 CPU 和 RAM 之间放置 1、2 甚至 3 级缓存(这是 4 级缓存),还涉及写入缓冲区、管道和页面模式提取等内容,所有这些都是为了降低从内存中获取内容的速度。由于它们已经达到了 4GHz 的极限,但是不断减小的硅特征尺寸使它们可以使用越来越多的晶体管,因此多 CPU 设计已经成为世界的幻想,尽管它们作为一种编程模型很糟糕。多 CPU 系统并不是什么新鲜事,但是编写一次使用多个 CPU 的程序一直很棘手,现在仍然如此。
为此,它读取 n_foo,然后将 n_foo 写回。它可能会也可能不会涉及到 CPU 寄存器的加载,但这并不重要。读取内存位置意味着检查我们是否在 CPU 级别 1 缓存中。除非经常使用,否则不太可能。接下来检查二级缓存,让我们假设它也有问题。如果这是一个单 CPU 系统,游戏到此结束,我们从 RAM 中取出它并继续。在多 CPU 系统上,无论 CPU 是共享套接字还是拥有自己的套接字都没有关系,我们首先必须检查其他 CPU 中是否有任何其他 CPU 的缓存中存储了 n_foo 的修改副本,因此特殊的总线事务会发生找出这一点,如果某些 cpu 返回并说“是的,我有它”,则 cpu 可以将其写入 RAM。在良好的硬件设计中,我们的 CPU 将在写入操作期间监听总线,在糟糕的设计中,它必须在之后进行内存读取。现在 CPU 可以增加 n_foo 的值,并将其写回。但它不太可能直接回到内存,我们可能很快又需要它,所以修改后的值会存储在我们自己的 L1 缓存中,然后在某个时候,它会最终在 RAM 中。现在想象另一个 CPU 同时想要 n_bar+++,它可以做到吗?不。缓存不是在字节上运行,而是在一些“行大小”的字节上运行,通常每行从 8 到 128 个字节。因此,由于第一个 CPU 忙于处理 n_foo,第二个 CPU 将尝试获取相同的缓存行,所以它必须等待,即使它是一个不同的变量。当我们需要处理 HTTP 请求或响应时,我们有一个指针数组和一个工作区。我们不会为每个标头调用 malloc(3)。我们为整个工作区调用一次,然后从那里为标题选择空间。这样做的好处是我们通常一次性释放整个标题,我们可以简单地通过重置指向工作区开头的指针来做到这一点。
当我们需要将 HTTP 标头从一个请求复制到另一个(或从响应到另一个)时,我们不复制字符串,我们只是复制指向它的指针。如果我们不更改或释放源头,这是完全安全的,一个很好的例子是从客户端请求复制到我们将发送到后端的请求。当新头的生命周期比源头的生命周期长时,我们必须复制它。例如,当我们将标头存储在缓存对象中时。但是在这种情况下,我们在工作区中构建新头,一旦我们知道它将有多大,我们就执行单个 malloc(3) 来获取空间,然后我们将整个头放在该空间中。工作线程以“最近最忙”的方式使用,当工作线程空闲时,它会进入最有可能获得下一个请求的队列的前面,以便它已经缓存的所有内存、堆栈空间、变量等,可以在缓存中重用,而不是从 RAM 中进行昂贵的提取。我们还为每个工作线程提供了一组可能需要的私有变量,所有这些变量都分配在线程的堆栈上。这样我们就可以确定它们在 RAM 中占据了一个页面,只要该线程在自己的 CPU 上运行,其他 CPU 都不会考虑接触该页面。这样他们就不会因为缓存线而争吵。如果所有这些对你来说听起来很陌生,让我向你保证它是有效的:我们花费不到 18 个系统调用来处理缓存命中,甚至其中许多都是调用 tog get timestamps for statistics。这些技术也不是什么新鲜事,我们已经在内核中使用它们十多年了,现在轮到你学习它们了 :-)