
在计算世界中,效率和速度至关重要。但是,独立的程序或成千上万个微小的处理线程,如何在不互相拖慢的情况下协同解决一个共同的问题呢?答案在于一个强大而优雅的概念:共享内存。它就像一块共享的黑板,允许不同的计算实体读取和写入一个公共空间,将孤立的工作转变为同步的协作努力。这个概念看似简单,却解决了计算机体系结构中的根本性挑战,从操作系统的资源管理到克服并行计算中的性能瓶颈。理解共享内存是揭示现代系统如何同时实现效率和高性能的关键。本文将探讨共享内存在两个关键领域的多面性。在“原理与机制”部分,我们将深入探讨操作系统层面实现共享内存的奥秘,探索虚拟内存、写时复制以及使进程能够通信的机制,然后进入 GPU 的高速世界,了解它是如何为大规模并行任务构建的。随后,“应用与跨学科联系”部分将展示这些原理如何应用于高性能计算、科学模拟和系统安全,揭示这个单一概念对数字世界的深远影响。
想象你是一个计算机程序。当你开始运行时,操作系统会给你一片看似广阔而纯净的内存空间,完全供你使用。这是你的虚拟地址空间,你自己的私有宇宙。另一个同时运行的程序,就在你旁边,也有它自己独立的宇宙。你可以在你的内存地址 $1000$ 上写入数据,你的邻居也可以在它的地址 $1000$ 上写入数据,而你们彼此之间绝不会互相干扰。
当然,这只是一个美丽的幻象。一台典型的计算机只有一个物理内存池——插在主板上的随机存取存储器(RAM)芯片。为每个正在运行的程序(即进程)维持这个幻象的魔术师就是操作系统(OS)。操作系统在一种名为内存管理单元(MMU)的硬件单元的帮助下,扮演着一位总绘图师的角色。它为每个进程维护一组称为页表的地图。这些地图将进程私有虚拟宇宙中的地址转换为 RAM 中的实际物理地址。这种转换是以块(通常是 或 千字节大小)为单位进行的,这些块被称为页面。
从虚拟页面到物理页面的映射是我们整个故事背后的秘密。因为如果操作系统是绘制地图的人,它就可以发挥创意。如果对于两个不同的进程,操作系统绘制的地图中,两个不同的虚拟页面指向 RAM 中完全相同的物理页面,会发生什么?突然之间,两个看似分离的宇宙有了一个共享的现实。一个进程在该内存区域所做的更改会立即对另一个进程可见,不是因为数据被发送了,而是因为它们实际上在查看和修改同一块物理内存。这就是共享内存的本质。
共享内存最广泛的用途之一,可能是你每天都在使用却从未想过的。当你打开网页浏览器、文字处理器和音乐播放器时,你是否想象过你的计算机会为每个程序加载三份所有通用代码的独立副本——比如打开文件、绘制窗口和连接网络的基本例程?那将是惊人的内存浪费。
实际上,操作系统做了一件更聪明的事。现代程序是使用共享库(如 Linux 上的 .so 文件或 Windows 上的 .dll 文件)构建的。当你启动一个程序时,操作系统的动态加载器并不会为它加载每个库的全新副本到 RAM 中。相反,它会检查该库的副本是否已经因为其他程序而存在于 RAM 中。如果是,操作系统只需将那个现有库代码的物理页面映射到新程序的虚拟地址空间中。
这就是默认共享,其影响是巨大的。如果有 个进程都在使用一个大小为 的相同共享库,一个朴素的设计将消耗 字节的 RAM。通过共享该库,系统只需要在 RAM 中保留一个大小为 的副本。总共节省的 RAM 高达 字节。这就是你的计算机可以同时运行数十个复杂应用程序而不会立即耗尽内存的原因。这是一种默默无闻的、后台的效率,使得现代计算成为可能。
但这引出了一个有趣的问题。这些库中的代码被标记为只读。如果一个调皮的程序试图在这本共享的“教科书”的页边空白处“写字”会发生什么?它绝不能被允许污损其他人都在阅读的主副本。操作系统通过一种名为写时复制(Copy-on-Write, COW)的优雅机制来处理这个问题。
当操作系统首次共享库页面时,它会在每个进程的地图中将它们标记为“只读”。如果一个进程试图写入这样一个页面,硬件会触发一种称为页错误的特殊警报,将控制权交给操作系统。操作系统看到有进程试图对一个共享的只读页面进行写操作。然后,它会迅速施展一个戏法:它分配一个新的、私有的物理 RAM 页面,将原始共享页面的内容复制到其中,并更新那个“顽皮”进程的页表,使其虚拟页面映射到这个新的、私有的、可写的副本上。然后,写操作就可以顺利进行了。其他进程完全不受影响;它们的地图仍然指向原始的、纯净的共享页面。这个原则是 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 系统调用的根基,它通过与子进程共享父进程的所有内存,直到其中一方写入为止,从而高效地创建新进程。写时复制完美地平衡了共享的效率与隔离的安全性。
共享库是一种被动的、自动的优化。但如果进程想要协作呢?如果它们需要一个共享的黑板来共同解决问题呢?这时,共享内存就从一种节省资源的技巧转变为一种强大的进程间通信(IPC)工具。事实上,它是最快的 IPC 形式,因为没有消息的“发送”或“接收”——进程只是在读写同一块内存。
但这又引出了一个新问题:两个独立的进程如何找到同一个黑板?主要有两种策略。
一种方式是通过继承。一个父进程可以向操作系统请求一个新的、匿名的共享内存区域。它是“匿名的”,因为它在文件系统中没有名字;它只是一块内存。然后,父进程可以使用 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 创建子进程。这些子进程继承了父进程地址映射的副本,随之也继承了到这个共享黑板的映射。它们“天生”就知道它在哪里。然而,一个不相关的进程,一个不是由这个父进程创建的进程,无法找到这个匿名区域。这是一个私密的家族事务。
为了让不相关的进程能够协作,它们需要一个公共的会面地点。这通过文件支持的共享内存来实现。一个进程可以创建一个特殊的对象,通常在虚拟文件系统中显示为一个文件(如使用 shm_open 的 POSIX 共享内存),并给它一个众所周知的名字,比如 /my_blackboard。任何其他知道这个名字并拥有正确权限的进程都可以“打开”这个对象,并将其映射到自己的地址空间中。
这又引出了另一个微妙之处:黑板的名字和黑板本身之间的区别。想象我们的进程 和 都已经打开并正在使用 /my_blackboard。如果进程 决定“删除”名字 /my_blackboard(使用 shm_unlink),会发生什么?黑板会消失,导致 的程序崩溃吗?不会。unlink 操作只从目录中移除了名字。物理内存对象——黑板本身——只要还有任何进程持有对它的引用(一个活动的映射或一个打开的文件句柄),它就会持续存在。操作系统会维护一个引用计数,只有当该计数降至零时,它才会擦除黑板并回收内存。这确保了共享资源不会在仍在合法使用它的进程脚下消失。
现在,让我们从 CPU 进程的世界飞跃到图形处理单元(GPU)的疯狂并行宇宙。在这里,参与者不是几十个复杂的进程,而是成千上万个协同工作的简单线程。这个宇宙的首要目标不仅仅是节省内存,而是实现令人难以置信的计算吞吐量。
在 GPU 上,主内存被称为全局内存,它容量巨大,但相对而言速度极慢。访问它就像要离开主芯片进行一次漫长而缓慢的旅行。性能的关键是尽可能避免这次旅行。为此,GPU 提供了一种特殊的内存:片上共享内存。
这时,我们从 CPU 世界带来的直觉可能会误导我们。GPU 共享内存不像 CPU 的 L1 或 L2 缓存。缓存是自动的,由硬件管理。GPU 共享内存是一个由程序员管理的暂存空间(scratchpad)。它是一小块( 可能在 96 或 128 千字节左右)但速度极快的内存,由你,程序员,完全控制。你就是图书管理员。你决定放什么进去,什么时候放,什么时候拿出来。
使用这种暂存空间的经典策略被称为分块(tiling)。想象一组线程(一个线程块)需要在一个大网格上执行“模板”计算,其中每个输出都需要查看其在输入中的邻居。线程块中的比如说 个线程,不会各自缓慢地访问全局内存来获取它们的输入,而是进行协作。
其效果是惊人的。对于一个由 个线程组成的线程块计算宽度为 的简单一维模板,朴素的方法需要 次缓慢的全局内存读取。分块策略将此减少到仅需 次读取来加载初始块。这可能导致在软件管理的缓存上的“命中率”达到 ,随着块大小和模板宽度的增长,这个值会迅速接近 。
但这个强大的工具也有其物理现实。这个片上暂存空间不是一块简单的硅片。它被组织成多个独立的存储体(banks)(通常是 个)。把它想象成一个有 条结账通道的杂货店。如果一个组(一个线程束 (warp))中的 个线程都去不同的通道,它们都可以并行结账。但如果它们都试图在同一个通道排队,就必须一个接一个地被服务。这就是存储体冲突(bank conflict),它会使内存访问串行化,从而摧毁性能。
因此,GPU 程序员必须像交通工程师一样思考。对于一个 的数据块,按列访问会导致所有 个线程都访问同一个存储体,造成大规模的交通堵塞。标准的解决方案非常反直觉:浪费一点内存以换取更快的速度。通过在块上增加一个填充列,使其变为 ,内存步幅改变了。现在,按列访问神奇地分布到不同的存储体上,消除了冲突,恢复了并行吞吐量。这引入了一个有趣的权衡:填充提高了性能,但也增加了每个线程块的共享内存使用量。如果使用过多,你可能无法在处理核心上容纳同样多的线程块,这可能会降低整体占用率以及 GPU 隐藏其他延迟的能力。
最后,让我们回到操作系统,看看这些思想在现代容器世界中是如何应用的。由操作系统级虚拟化技术驱动的容器,旨在为进程提供一个隔离的环境。这种隔离是通过使用一种称为命名空间的功能来划分内核资源实现的。
有用于进程 ID(PID)的命名空间(因此每个容器都可以有自己的 1 号进程),有用于网络栈的命名空间,有用于文件系统挂载的命名空间,以及对我们的故事至关重要的,用于 IPC 资源的命名空间。
默认情况下,当你启动一个容器时,它会获得自己私有的 IPC 命名空间。这意味着即使两个容器运行在同一台机器上,其中一个容器中的 System V 共享内存段、信号量和消息队列集合对另一个容器是完全不可见的。它们处于不同的 IPC 宇宙中。容器 A 中的进程试图发现容器 B 中创建的共享内存段将会失败,不是因为权限问题,而是因为在容器 A 的宇宙中,那个段根本不存在。
这是一个强大的安全和隔离特性。然而,系统是灵活的。如果你希望两个容器紧密协作,你可以明确地将它们启动到同一个 IPC 命名空间中。现在,它们共享内核的 IPC 对象列表。一个容器创建的共享内存段可以立即被另一个容器发现和附加(前提是符合正常的用户权限)。它们现在有了一个双方都能看到和使用的共享黑板。
从共享库的静默效率,到 GPU 上高风险的显式协作,再到容器化世界的可配置边界,共享内存的原则揭示了计算中一个深刻而美丽的方面。这是一个关于巧妙幻象、严谨规则以及隔离与合作之间不断博弈的故事,所有这一切都是为了让我们的计算机变得更强大、更高效。
在计算领域,有一个简单而优美的思想:共享空间的概念。想象一群杰出的数学家正在解决一个巨大的问题。他们可以在一个巨大的大厅里互相喊出结果,这是一个缓慢而繁琐的过程。或者,他们可以聚集在一个小而方便的黑板周围,潦草地写下他们的中间发现,供所有人即时查看和使用。这块黑板是一个催化剂;它将一系列个体努力转变为一个强大的协作整体。
“共享内存”的概念正是这块黑板在硅片世界中的体现。令人着迷的是,这个单一而优雅的思想在两个截然不同但同等重要的领域中得以体现。第一个是并行处理器的闪电般快速的微观世界,比如驱动现代人工智能和创造惊人虚拟世界的图形处理单元(GPU)。在这里,共享内存是为一支紧密协作的工人团队准备的微小、超快的黑板。第二个领域是我们操作系统的基础架构,正是这些软件管理着我们的计算机。在这里,共享内存更像城镇广场上的公共布告栏,允许独立的程序进行交流与合作。
让我们在这两个领域中进行一次旅行。我们将看到这一个概念如何成为从科学发现到我们数字生活安全等一切事物的关键,揭示了复杂系统设计中非凡的统一性。
每个 GPU 的核心是成千上万个协同工作的简单处理器或线程。它们速度惊人,但面临一个根本瓶颈:从主计算机内存(程序员称之为“全局内存”)中获取数据,就像一次漫长而艰苦的步行,去往一个遥远的图书馆。如果每个线程需要的每一片数据都必须走这么一趟,那么处理器大部分时间都会在等待中度过,它们的集体力量将被浪费。这就是臭名昭著的“内存墙”。
共享内存是巧妙的解决方案。它是芯片上一小块内存,一个暂存空间,其速度比全局内存快几个数量级。策略简单而深刻:让线程合作,将一块数据从遥远的“图书馆”加载到它们本地的“黑板”上,只需一次。然后,所有线程都可以以极快的速度读取和操作这些数据,最后再将最终结果写回。这种数据复用的原则是高性能计算的基石。
也许最经典的例子是两个矩阵的乘法,这是深度学习和科学模拟中的一个基本操作。一个线程块将协同加载每个矩阵的一个小*块(tile)*到共享内存中。一旦这些块成为本地数据,线程就可以执行数十或数百次乘法和加法运算来计算结果矩阵的相应块,所有这些都无需再次缓慢地访问全局内存。通过仔细选择块的大小,程序员可以最大化每次获取字节所完成的计算量,这是一个称为计算强度的关键指标。
这种“分块”(tiling)策略远远超出了简单的矩阵运算。考虑应用图像滤镜的问题,其中每个像素的新颜色取决于其邻居。这是一个*模板计算*的例子。没有共享内存,计算一个像素值的每个线程都必须从全局内存中获取自己的一组邻居,导致大量的冗余读取。一个更优雅的方法是让一个线程块将图像的一个更大的块——包括一个包含所有必要邻居像素的“晕轮”(halo)——加载到共享内存中。突然之间,每个线程所需的所有数据都触手可及。这项技术不仅用于你手机上的照片滤镜,还用于模拟天气模式和机翼上的气流,其中空间中任何一点的状态都取决于其周围环境。通过巧妙地将多个滤镜阶段融合在一起,甚至可以将中间图像完全保留在芯片上,从而完全消除阶段之间的全局内存流量,节省宝贵的带宽 [@problem-id:3644529]。
其应用之广泛,如同科学本身。在分子动力学中,科学家模拟数百万个原子的复杂舞蹈。要计算给定原子上的力,只需考虑其附近的邻居。通过将原子分组到单元格网格中,可以将一个线程块分配给一个单元格中的原子。为了找到邻居,这些线程必须检查相邻的单元格。块中的每个线程不是独立地读取那些相同相邻单元格的数据,而是协同地将它们一次性加载到共享内存中。性能提升不仅仅是增量的;它可以将所需的内存带宽减少 90% 以上,将一个棘手的问题变成一个可行的模拟。从快速傅里叶变换(FFT)到求解偏微分方程的高级数值方法,共享内存是促成这一切的推动者,是使这些复杂算法在并行硬件上飞速运行的本地黑板。
但共享内存不仅仅是一个由程序员管理的缓存,它还是一个通信中心。想象一下需要对一个数字列表求和,每个数字由不同的线程持有。线程们可以使用共享内存以树状方式高效地组合它们的值,相互传递部分和,直到一个线程持有最终答案。这种模式非常普遍,以至于 GPU 设计师在看到它在共享内存中实现的威力后,最终创造了更快、更直接的线程间通信路径,例如直接在寄存器之间工作的线程束洗牌指令。这展示了一种美丽的共同进化:一个由共享内存实现的软件模式变得如此重要,以至于它激发了新的硬件来做得更好。
当然,天下没有免费的午餐。黑板是有限的。如果一个工人团队试图使用太多的黑板空间,就没有足够的地方供其他团队工作。在 GPU 上,每个线程块使用大量共享内存会减少可以并发运行在处理器上的线程块数量。这被称为降低占用率。GPU 编程的艺术在于达到一种微妙的平衡:使用足够的共享内存来隐藏全局内存的延迟,但又不能多到让 GPU 缺乏可执行的并行工作。
现在让我们把视角从芯片上线程的微秒级舞蹈,放大到由操作系统管理的独立程序(或进程)的宏大尺度。为了安全和稳定,操作系统在进程之间筑起了坚固的墙;你的网页浏览器不能简单地伸入你的文字处理器的内存中。但如果它们需要合作呢?它们可以通过操作系统发送消息,但这涉及到操作系统为每一条消息充当缓慢而谨慎的信使。
操作系统级的共享内存是进程间通信(IPC)的快车道。两个或多个进程请求操作系统留出一块内存区域,并将其“映射”到它们各自的私有地址空间中。完成这个初始设置后,操作系统就退开了。这些进程现在有了一个共享空间,一个共同的平台。当一个进程写入它时,其他进程几乎可以立即看到变化,无需操作系统干预。这相当于数字世界的公共布告栏。
当我们引入现代虚拟化和容器时,美丽的复杂性就出现了。如果两个程序运行在不同的容器中,它们是真的分离的吗?它们如何共享?答案在于,究竟共享的是什么。
考虑两个实验。在一个实验中,不同容器中的两个进程被告知要内存映射主机计算机上的同一个文件。因为操作系统知道它是同一个底层文件(通过其唯一的“inode”识别),它巧妙地将两个进程都映射到页面缓存中的相同页面。它们在不知不觉中在同一块画布上书写,它们的变化对彼此可见。文件系统充当了最终的真理来源。
在另一个实验中,进程试图用相同的名字创建一个 POSIX 共享内存对象。默认情况下,每个容器都有自己私有的内存中文件系统(/dev/shm)。所以,即使名字相同,它们也是在两个不同的地方创建了两个不同的对象。没有发生共享。要使其工作,你必须明确配置容器以使用来自主机的同一个 /dev/shm 挂载。这揭示了一个深刻的原则:“共享内存”不是一种抽象的魔法;它的行为由具体的实现决定,无论它是磁盘上的一个 inode 还是临时文件系统中的一个文件,以及像命名空间这样的操作系统隔离特性如何与之交互。
这种能力和性能也带来了代价。因为操作系统不调节对话,共享内存可能成为一个安全盲点。一个恶意程序可能利用它与另一个程序进行秘密通信,或窃取数据,绕过可能被监控的正常渠道。这导致了系统安全领域一场有趣的猫鼠游戏。如果一项策略规定某个应用程序不应与其他程序通信,我们如何强制执行?入侵检测系统可以像侦探一样,定期检查操作系统自己的记录(如 Linux 中的 /proc 文件系统和 ipcs 命令),查看哪些进程附加到了哪些共享内存段。如果发现一个来自本应隔离的服务的进程正在使用一个共享段,就会发出警报。操作系统为透明性提供的工具本身,可以被用来在其最强大的功能上强制执行安全。
从一个协调每秒数万亿次计算的微小片上暂存空间,到一个受安全策略约束的全系统通信渠道,共享内存的原则始终如一:它是一个用于合作的共同平台。它的美在于这种简单性。通过将这个简单的黑板放置在计算层级的不同层面,工程师和程序员已将其转变为数字时代的基石。它是我们游戏中惊人图形、模拟我们世界的科学突破,以及我们每天使用的操作系统中错综复杂的多进程架构背后的沉默推动者。