【问题标题】:Is C++ considered a Von Neumann programming language?C++ 被认为是冯诺依曼编程语言吗?
【发布时间】:2020-02-07 07:01:02
【问题描述】:

术语Von Neumann languages 应用于其计算模型基于Von Neumann computer architecture 的编程语言。

  • C++ 是否被视为冯诺依曼语言,或者如果不是(例如,由于线程的出现导致异步执行),它是否曾被视为冯诺依曼语言?
  • 是否存在 C++ 的计算模型/抽象机器所基于的架构,因此可以归类为该架构的语言?

【问题讨论】:

  • 您发布的维基百科链接指出:“通过以线程的形式添加对并行处理的支持,许多广泛使用的编程语言(如 C、C++ 和 Java)不再是严格的冯诺依曼语言。”
  • 为什么重要?
  • 人们可以非常关心语言的每一个细节以及如何使用它,而无需关心人们可能会用什么名字来描述他们认为体现的计算模型.
  • @101010: "在我看来,名字很重要。" 这个特殊名字的重要性是什么? “C++ 抽象机模拟的计算模型是什么?” 30 多年前激励你做出选择的东西,现在已经不那么重要了。重要的是选择,而不是为什么做出它。 C++ 标准定义了抽象机的工作方式; 那个是“计算模型”。
  • 我们抱怨这些天我们得到的基本上都是调试问题,但是当我们得到一个真正有趣的问题要回答时,我们关心的只是这个问题是否“重要”?你们对这个问题是否“重要”的标准是什么?

标签: c++ computer-science cpu-architecture language-design von-neumann


【解决方案1】:

TL:DR:C++ 抽象机是PRAM (Parallel Random Access Machine) 的一种类型。


来自您链接的 Von Neumann Languages 维基百科文章:

许多广泛使用的编程语言,如 C、C++ 和 Java,通过添加对并行处理的支持,以线程的形式不再严格地遵循冯诺依曼。

停止描述了从存在到不存在的转变。所以是的,根据维基百科,在 C++11 添加线程之前,C++严格是一种冯诺依曼语言。 (在它基本上仍然是一种 VN 语言之后;让多个线程共享相同的地址空间并不会从根本上改变 C++ 的工作方式。)

在这种情况下成为冯诺依曼架构的有趣部分:

  • 拥有可寻址的 RAM,允许随时高效访问(模缓存/分页)任何对象
  • 将程序存储在 RAM 中:函数指针是可行且高效的,无需解释器
  • 有一个程序计数器,可以逐步执行存储程序中的指令:自然模型是一种命令式编程语言,一次只做一件事。这是非常基本的,很容易忘记它不是唯一的模型! (与 FPGA 或 ASIC 或所有门可能在每个时钟周期并行执行某些操作的东西。或 MIMD GPU 中,您编写的计算“内核”可能并行运行在所有数据上,而没有隐式排序每个命令的顺序元素被处理。或者Computational RAM:将 ALU 放入内存芯片以绕过冯诺依曼瓶颈)

IDK 为什么 wiki 文章提到了自修改代码;与大多数语言一样,ISO C++ 没有对其进行标准化,并且与 split-bus / split-address-space Harvard architecture 的提前编译完全兼容。 (没有 eval 或其他任何需要解释器或 JIT 的东西。)或者在普通 CPU (Von Neumann) 上,严格的 W^X 内存保护并且从不使用 mprotect 将页面权限从可写更改为可执行。

当然,大多数真正的 C++ 实现确实提供了将机器代码写入缓冲区并转换为函数指针的定义良好的方法,作为扩展。 (例如 GNU C/C++ 的 __builtin___clear_cache(start, end) 以 I-cache 同步命名,但定义是为了安全地将数据调用为函数 wrt。死存储消除优化,因此代码可能在没有它的情况下中断即使在具有一致 I-caches 的 x86 上。)因此实现可以扩展 ISO C++ 以利用冯诺依曼架构的这一特性; ISO C++ 有意限制范围以允许操作系统和类似的东西之间存在差异。

请注意,作为冯诺依曼并严格暗示支持间接寻址模式。一些早期的 CPU 没有,而自修改代码(重写指令中硬编码的地址)对于实现我们现在使用间接的东西是必要的。

还要注意,约翰·冯·诺依曼是一个非常有名的人,他的名字与很多基本的东西有关。冯诺依曼架构(与哈佛相反)的一些内涵并非在所有情况下都真正相关。例如“冯诺依曼语言”一词并不太关心冯诺依曼与哈佛的对比;它关心带有程序计数器的存储程序与像元胞自动机或图灵机(带有真实磁带)之类的东西。通过使用单独的总线(或仅拆分缓存)来获取指令(哈佛)获得额外带宽只是一种性能优化,而不是根本性的变化。


什么是抽象机器模型/计算模型?

首先,有一些models of computation 比图灵机,比如Finite State Machines。还有非顺序的计算模型,例如Cellular Automata (Conway's Game of Life),其中每个“步骤”并行发生多件事。

Turing machine 是最广为人知的(数学上最简单的)连续abstract machine,正如我们所知道的那样“强大”。没有任何绝对的内存寻址,只是磁带上的相对移动,它自然地提供了无限的存储空间。这很重要,并且使所有其他类型的抽象机器在某些方面与真正的 CPU 非常不同。请记住,这些计算模型用于理论计算机科学,而不是工程。有限的内存或性能等问题与可计算的内容在理论上无关,仅在实践中。

如果您可以在图灵机上计算某些东西,那么您可以在任何其他图灵完备的计算模型上进行计算(根据定义),可能使用更简单的程序,也可能不使用。图灵机不是很好编程,或者至少与任何真正 CPU 的汇编语言非常不同。最值得注意的是,内存不是随机访问的。而且他们不能轻易地为并行计算/算法建模。 (如果你想在抽象中证明某个算法,那么为某种抽象机器实现它可能是件好事。)

证明抽象机器需要具备哪些特性才能图灵完备也可能很有趣,因此这是开发更多抽象机器的另一个动机。

在可计算性方面还有很多其他的等价物。 RAM machine model 最类似于具有内存阵列的真实 CPU。但作为一个简单的抽象机器,它不会打扰寄存器。事实上,为了让事情更混乱,它把它的内存单元称为 registers 数组。 RAM 机器支持间接寻址,因此与现实世界 CPU 的正确类比肯定是内存,而不是 CPU 寄存器。 (并且有无限数量的寄存器,每个寄存器的大小都是无限的。地址永远存在,每个“寄存器”都需要能够保存一个指针。)RAM 机器可以是哈佛:程序存储在一个单独的有限状态部分机器。把它想象成一台具有内存间接寻址模式的机器,因此您可以将“变量”保存在已知位置,并将其中一些用作指向无限大小数据结构的指针。

The program for an abstract RAM machine 看起来像汇编语言,带有 load/add/jnz 以及您希望它拥有的任何其他指令选择。操作数可以是立即数或寄存器数字(普通人称之为绝对地址)。或者,如果模型有一个累加器,那么你就有一个带有累加器的加载/存储机器,它更像是一个真正的 CPU。

如果您想知道为什么像 MIPS 这样的“3-address”机器被称为而不是 3-operand,它可能是 1。因为指令编码需要空间/I-fetch 带宽,因为 3 显式操作数位置(寄存器编号)和 2. 因为在 RAM 抽象机中,操作数是内存地址 = 寄存器编号。


C++ 不可能是图灵完备的:指针的大小是有限的。

当然,C++ 与 CS 抽象机器模型有巨大的不同:C++ 要求每种类型都有一个编译时间常数有限 sizeof,因此 C++ 可以如果包含无限存储要求,则不会是图灵完备的。 cs.SE 上Is C actually Turing-complete? 中的所有内容也适用于 C++:类型具有固定宽度的要求是无限存储的障碍。另见https://en.wikipedia.org/wiki/Random-access_machine#Finite_vs_unbounded


那么计算机科学抽象机很傻,那么 C++ 抽象机呢?

他们当然有自己的目的,但是我们可以说更多关于 C++ 的有趣的东西,以及如果我们稍微不那么抽象它假设什么样的机器,并谈论什么是机器可以有效地。一旦我们谈论有限机器机器和性能,这些差异就变得相关了。

首先,完全运行 C++,其次,在没有巨大和/或不可接受的性能开销的情况下运行。 (例如,硬件将需要相当直接地支持指针,可能不需要将指针值存储到使用它的每个加载/存储指令中的自修改代码。这在线程是其中一部分的 C++11 中不起作用语言:相同的代码可以同时操作 2 个不同的指针。)

我们可以更详细地了解 ISO C++ 标准所假设的计算模型,该标准描述了该语言如何根据抽象机上发生的情况来工作。真正的实现需要在真正的硬件上运行代码,运行“好像”抽象机器正在执行 C++ 源代码,再现任何/所有可观察的行为(无需调用 UB 即可被程序的其他部分观察到)。

C/C++ 有内存和指针,所以它绝对是一种 RAM 机器。

或者现在,Parallel random-access machine,将共享内存添加到 RAM 模型,并为每个线程提供自己的程序计数器。鉴于std::atomic<> release-sequences 使 所有 以前的操作对其他线程可见,“建立先发生关系”同步模型基于 一致 共享内存。在需要手动触发同步/刷新的东西之上模拟它对性能来说是可怕的。 (非常聪明的优化可以证明什么时候可以延迟,所以不是每个发布存储都必须受到影响,但 seq-cst 可能会很糟糕。seq-cst 必须建立一个所有线程都同意的全局操作顺序;这很难,除非一个商店同时对所有其他线程可见。)

但请注意,在 C++ 中,实际同时访问是 UB,除非您使用 atomic<T> 进行操作。这个allows the optimizer to freely use CPU registers 适用于本地人、临时人员,甚至是全局人员,而不会将寄存器作为语言功能公开。 UB allows optimization 一般;这就是现代 C/C++ 实现不是可移植汇编语言的原因。

C/C++ 中历史悠久的register 关键字意味着变量无法获取其地址,因此即使是非优化编译器也可以将其保存在 CPU 寄存器中,而不是内存中。我们'正在谈论 CPU 寄存器,而不是计算机科学 RAM 机器“寄存器 = 可寻址内存位置”。 (例如 x86 上的 rax..rsp/r8..r15 或 MIPS 上的 r0..r31)。现代编译器会逃避分析并自然地将本地人正常保存在寄存器中,除非他们必须溢出它们。其他类型的 CPU 寄存器也是可能的,例如像 x87 FP 寄存器这样的寄存器堆栈。 不管怎样,register 关键字的存在是为了针对这种类型的机器进行优化。但不排除在没有寄存器,只有内存-内存指令的机器上运行。

C++ 被设计为在具有 CPU 寄存器的冯诺依曼机器上运行良好,但 C++ 抽象机(该标准用于定义语言)不允许将数据作为代码执行,或者说关于寄存器的任何事情。但是,每个 C++ 线程都有自己的执行上下文,并且对 PRAM 线程/内核进行建模,每个线程/内核都有自己的程序计数器和调用堆栈(或实现用于自动存储和确定返回位置的任何东西。)在真实机器中对于 CPU 寄存器,它们对每个线程都是私有的。

所有现实世界的 CPU 都是 Random Access Machines,并且 CPU 寄存器与可寻址/可索引 RAM 分开。即使 CPU 只能使用单个累加器寄存器进行计算,通常也至少有一个指针或索引寄存器,至少允许一些有限的数组索引。至少所有 CPU 都可以很好地用作 C 编译器目标。

没有寄存器,每个机器指令编码都需要所有操作数的绝对内存地址。 (可能像 6502 一样,其中的“零页”,即内存的低 256 字节是特殊的,并且存在使用零页中的字作为索引或指针的寻址模式,以允许 16 位指针没有任何 16位架构寄存器。或类似的东西。)请参阅Why do C to Z80 compilers produce poor code? on RetroComputing.SE 了解有关真实世界 8 位 CPU 的一些有趣内容,其中完全兼容的 C 实现(支持递归和重入)实现起来非常昂贵。很多缓慢是因为 6502 / Z80 系统太小而无法承载优化编译器。但即使是假设的现代优化交叉编译器(如 gcc 或 LLVM 后端)也会在某些事情上遇到困难。另请参阅What is an unused memory address? 上的最新回答,以获得对 6502 的零页索引寻址模式的很好解释:来自内存中绝对 8 位地址的 16 位指针 + 8 位寄存器。

完全没有间接寻址的机器不能轻易地支持数组索引、链表,而且绝对不能将指针变量作为一等对象。 (反正效率不高)


真实机器上什么是有效的 -> 什么习语是自然的

大部分 C 的早期历史都在 PDP-11,这是一个普通的内存 + 寄存器机器,任何寄存器都可以作为指针工作。当需要溢出时,自动存储映射到寄存器或调用堆栈上的空间。内存是一个扁平的字节数组(或char 的块),没有分段。

数组索引只是根据指针算法定义的,而不是它自己的东西,也许是因为 PDP-11 可以有效地做到这一点:任何寄存器都可以保存地址并被取消引用。 (相对于一些机器只有几个指针宽度的特殊寄存器,其余的更窄。这在 8 位机器上很常见,但早期的 16 位机器(如 PDP-11)的 RAM 足够少,只有一个 16 位寄存器一个地址就够了)。

查看 Dennis Ritchie 的文章 The Development of the C Language 了解更多历史; C 在 PDP-7 Unix 上从 B 发展而来。 (第一个 Unix 是用 PDP-7 asm 编写的)。我对 PDP-7 了解不多,但显然 BCPL 和 B 也使用只是整数的指针,而数组是基于指针算术的。

PDP-7 is an 18-bit word-addressable ISA。这可能就是 B 没有 char 类型的原因。但是它的寄存器足够宽,可以保存指针,所以它自然支持 B 和 C 的指针模型(指针并不是很特别,你可以复制它们并取消引用它们,你可以获取任何东西的地址)。如此平坦的内存模型,没有像您在分段机器或一些零页的 8 位微控制器上找到的“特殊”内存区域。

诸如 C99 VLA(以及无限大小的局部变量)和无限重入和递归之类的东西意味着函数局部变量上下文(也就是使用堆栈指针的普通机器上的堆栈帧)的调用堆栈或其他分配机制。

【讨论】:

  • 警告:我对理论 CS 的东西很感兴趣,但我实际上并没有在那个领域工作,也没有特别注意很多这些东西。我可能歪曲了一些东西。如果我花更多的时间在上面,几乎可以肯定可以把它编辑成更少的词。我认为一些关键点的格式很好,并在这个版本的答案中呈现出来,尤其是顶部和底部关于 PDP-7 / PDP-11 支持指针与不支持的 8 位微控制器的部分t 几乎一样容易。
【解决方案2】:

我认为尝试将 C++(或大多数其他语言)固定到单一架构模型是最困难的。让我们考虑 C++ 98/03。正如问题所说,它们符合冯诺依曼模型。哦,但是等等——它们也非常适合(如果不是更好的话)哈佛架构。

就此而言,哈佛建筑实际上更像是一个模型家族,而不是单一模型。特别是,如果 CPU 对代码和数据有单独的缓存,则它通常被视为使用哈佛架构——即使它类似于 x86,硬件会尽最大努力将这种拆分从代码中隐藏起来(例如,您可以编写自修改代码,在你修改代码之后,你执行的将是新代码——尽管可能会有很大的损失,因为指令缓存没有优化来处理修改)。

但“哈佛架构”也可以用来描述一些 DSP,它们有两个(或三个)完全独立的内存总线连接到物理上独立的内存:

适应这种情况的语言规则实际上是相当微妙的——除非你正在寻找它们,否则很容易完全错过它们。例如,C 和 C++ 将指向函数的指针定义为与指向数据的指针不同的事物。他们还非常小心地避免对诸如地址可比之类的事情提供任何保证,除非在相当有限的情况下(例如,在 C++ 中,您无法保证将函数地址与数据地址进行比较)。

然而,自从 C++11 标准以来,情况发生了一些变化。虽然核心语言保留了以指定顺序执行一些指令流的基本特征,但该库增加了创建可以并行执行的多个线程的能力。这些允许通过共享内存进行通信,但您必须使用原子变量或内存栅栏来保证任何程度的成功。这允许在任何地方的机器上实现,从非常紧密耦合到相当松散耦合,其中(例如)看起来像共享内存的通信实际上可能涉及通过网络连接之类的东西发送数据,发送一个信号来告诉远端什么时候传输完成。

因此,语言的规范并没有真正与通常被视为硬件级别的单一架构相关联。相反,虽然它对于通常被认为是相当紧密耦合的机器可能效果更好,但我相信它可以在相当松散耦合的机器上实现,例如完全独立的、不同的机器集群。你通常需要(或至少想要)改变你编写代码的方式,但至少理论上你可以编写在其中任何一个上运行的可移植 C++ 代码。

【讨论】:

  • 如果 CPU 具有单独的代码和数据缓存,则通常将其视为使用哈佛架构 这种草率的术语(而不是修改后的哈佛)通常仅在谈论时使用带宽/性能,而不是可计算性。我拒绝将统一地址空间和单总线上的拆分 L1 缓存称为哈佛机器,其他人也应该如此!在这种情况下,哈佛(如您所说)是关于拆分地址空间或至少拆分总线,例如,允许闪存中的程序和 RAM 中的数据。
  • 硬件上的 C++,你必须假装与软件的一致性在理论上可能是可行的,但出于实际性能的原因并不合理。请记住,释放序列必须使 all 之前的原子和非原子操作对可能通过获取负载与之同步的其他线程可见。即它必须进行完全同步。此外,除非您在每次轻松存储后都刷新,否则您至少有可能违反说明存储应立即对其他线程可见的说明。 (就像正常的连贯共享内存一样,总是试图尽快耗尽其存储缓冲区)
  • 我也不确定您能否在具有超过 2 个节点的非连贯 SHM 上可靠地实现 seq-cst。所有线程必须就 seq_cst 加载/存储(跨对象)的全局操作顺序达成一致。如果您愿意在每个 seq_cst 存储之后等待网络 RTT,我想这可能是可行的,但这几乎不是一个可行的实现。 C++ 非常假设所有线程将共享一致内存。现实生活中具有非连贯共享内存的机器(某些集群)使用它在软件控制(例如 MPI)下进行快速消息传递,而不是用于单系统映像/线程。
  • @PeterCordes:好吧,我承认我还没有实现它以确保它会成功,但似乎可以进行一些优化。我们所说的基本上类似于分布式数据库更新,经过多年的研究,已经找到了避免大部分困难的相当有效的方法。
  • @PeterCordes:就拆分缓存(等等)是否是哈佛架构而言:我大多同意这是一个草率的术语,我希望从未使用过——但现在这种用法已经很普遍了如果我试图将哈佛架构视为仅指具有完全独立的数据和程序存储的机器,那么(充其量)误解几乎是不可避免的。我真正的意思是,这个名字被广泛滥用,没有太多意义——你需要指定更多细节,以确保你所说的没有被误解。
【解决方案3】:

C++ 是用英语编写的标准规范。请参阅 n3337 -C++11 的后期草案。

正如Jerry CoffinPeter Cordes所解释的,官方模型是并行随机机。

但您通常使用 C++ 编写代码,方法是使用编译器并在某些 operating system(例如 Windows 或 Linux;另请参阅 this)下运行程序(除非您编写嵌入式系统代码)。许多操作系统提供dynamic loading 设施(例如Linux 上的dlopen(3)),并且大多数计算机都可以有C++ 编译器。

然后您实际上可以在运行时生成 C++ 代码,将生成的 C++ 代码的编译分叉为plugin,然后生成插件的dlopen。在 Linux 上,您可以多次这样做(例如,有成千上万个这样的生成插件,请参阅我的 bismonmanydl.c 程序)。

您还可以找到几个 JIT 编译 C++ 库,例如 libgccjitLLVM

实际上,C++ 程序可以在运行时生成代码然后使用它(即使这超出了 C++ 标准)。这就是冯诺依曼机器的特点。

【讨论】:

  • 再想一想,我认为哈佛/冯诺依曼的区别在这种情况下并不有趣。与元胞自动机等根本不同的计算模型相比,程序存储为按顺序获取和执行的指令。也就是说,它是一种命令式计算模型,适用于 C 或 x86 汇编等顺序命令式语言。用一些理论上的 CS 内容和有趣的链接(如 C 不是图灵完备(有限存储))显着更新了我的答案。
猜你喜欢
  • 1970-01-01
  • 2010-10-20
  • 2023-03-02
  • 2015-01-05
  • 2011-02-16
  • 2015-04-03
  • 2020-08-03
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多