警告:您提出的问题非常复杂——可能比您意识到的要复杂得多。结果,这是一个很长的答案。
从纯理论的角度来看,这可能有一个简单的答案:C#(可能)没有任何东西真正阻止它像 C++ 一样快。然而,尽管有理论,但有一些实际原因表明在某些情况下它在某些事情上较慢。
我将考虑三个基本方面的差异:语言特性、虚拟机执行和垃圾收集。后两者经常在一起,但可以独立,所以我将它们分开来看。
语言特点
C++ 非常强调模板和模板系统中的功能,这些功能主要是为了尽可能多地在编译时完成,因此从程序的角度来看,它们是“静态的”。模板元编程允许在编译时执行完全任意的计算(即,模板系统是图灵完备的)。因此,基本上任何不依赖于用户输入的东西都可以在编译时计算,所以在运行时它只是一个常量。但是,对此的输入可以包括类型信息之类的内容,因此您在 C# 中通过运行时反射所做的大部分工作通常是在编译时通过 C++ 中的模板元编程完成的。不过,在运行速度和多功能性之间肯定存在权衡——模板可以做什么,
语言特征的差异意味着几乎任何试图通过将一些 C# 音译为 C++(反之亦然)来比较两种语言的尝试都可能产生介于无意义和误导性之间的结果(对于大多数其他语言对也是如此)以及)。一个简单的事实是,对于任何超过几行代码左右的东西,几乎没有人可能会以相同的方式(或足够接近相同的方式)使用这些语言,这样的比较可以告诉你任何关于这些语言如何在现实生活中工作。
虚拟机
与几乎所有相当现代的 VM 一样,Microsoft 的 .NET 可以并且将会进行 JIT(又名“动态”)编译。不过,这代表了许多权衡。
首先,优化代码(像大多数其他优化问题一样)在很大程度上是一个 NP 完全问题。对于除了真正琐碎/玩具程序之外的任何东西,您几乎可以保证您不会真正“优化”结果(即,您不会找到真正的最佳值) - 优化器只会使代码比它更好以前是。然而,许多众所周知的优化需要花费大量时间(通常还需要内存)来执行。使用 JIT 编译器,用户正在等待编译器运行。大多数更昂贵的优化技术都被排除在外。静态编译有两个优点:首先,如果它很慢(例如,构建一个大型系统),它通常是在服务器上执行的,没有人花时间等待它。其次,可执行文件可以生成一次,并被多人多次使用。第一个最小化优化成本;第二个将小得多的成本摊销到更多的执行次数上。
正如原始问题(以及许多其他网站)中提到的那样,JIT 编译确实有可能更好地了解目标环境,这应该(至少在理论上)抵消这一优势。毫无疑问,这个因素至少可以抵消静态编译的部分劣势。对于一些相当特定类型的代码和目标环境,它可以甚至超过了静态编译的优势,有时甚至相当显着。然而,至少在我的测试和经验中,这是相当不寻常的。目标相关的优化大多似乎要么产生相当小的差异,要么只能(自动,无论如何)应用于相当特定类型的问题。很明显,如果您在现代机器上运行相对较旧的程序,就会发生这种情况。用 C++ 编写的旧程序可能已编译为 32 位代码,即使在现代 64 位处理器上也将继续使用 32 位代码。用 C# 编写的程序将被编译为字节码,然后 VM 将其编译为 64 位机器码。如果这个程序从作为 64 位代码运行中获得了实质性的好处,那可能会带来很大的优势。在 64 位处理器相当新的短时间内,这种情况发生得相当多。不过,最近可能受益于 64 位处理器的代码通常可以静态编译成 64 位代码。
使用 VM 还可以提高缓存的使用率。VM 的指令通常比本地机器指令更紧凑。它们中的更多可以放入给定数量的缓存内存中,因此您更有可能在需要时将任何给定代码置于缓存中。这有助于保持 VM 代码的解释执行比大多数人最初预期的更具竞争力(在速度方面)——您可以在一次缓存未命中所花费的时间内在现代 CPU 上执行大量指令。
还值得一提的是,这个因素在两者之间并不一定不同。没有什么可以阻止(例如)C++ 编译器生成旨在在虚拟机(有或没有 JIT)上运行的输出。事实上,微软的 C++/CLI几乎就是这样——一个(几乎)符合 C++ 编译器的编译器(尽管有很多扩展),它产生的输出旨在在虚拟机上运行。
反之亦然:微软现在拥有 .NET Native,它将 C#(或 VB.NET)代码编译为本机可执行文件。这提供了通常更像 C++ 的性能,但保留了 C#/VB 的特性(例如,编译为本机代码的 C# 仍然支持反射)。如果您有性能密集型 C# 代码,这可能会有所帮助。
垃圾收集
从我所见,我会说垃圾收集是这三个因素中理解最差的。举一个明显的例子,这里的问题提到:“GC 也不会增加很多开销,除非您创建和销毁数千个对象 [...]”。实际上,如果您创建和销毁数千个对象,垃圾收集的开销通常会相当低。.NET 使用了分代清道夫,它是多种复制收集器。垃圾收集器从指针/引用已知的“位置”(例如,寄存器和执行堆栈)开始工作可以访问。然后它“追逐”那些指向已在堆上分配的对象的指针。它检查这些对象是否有进一步的指针/引用,直到它跟随所有这些对象到达任何链的末端,并找到所有(至少可能)可访问的对象。在下一步中,它获取所有正在使用(或至少可能正在使用)的对象,并通过将所有对象复制到堆中管理的内存一端的一个连续块中来压缩堆。然后剩余的内存是空闲的(必须运行模终结器,但至少在编写良好的代码中,它们很少见,我暂时忽略它们)。
这意味着如果您创建和销毁大量对象,垃圾收集会增加很少的开销。垃圾回收周期所花费的时间几乎完全取决于已创建但未销毁的对象的数量。匆忙创建和销毁对象的主要后果只是 GC 必须更频繁地运行,但每个周期仍然会很快。如果您创建对象但不销毁它们,则 GC 将运行得更频繁,并且每个周期都会大大减慢,因为它会花费更多时间来追踪指向潜在活动对象的指针,并且会花费更多时间来复制仍在使用的对象。
为了解决这个问题,世代清除工作的假设是,已经“活着”了很长一段时间的物体很可能会继续存活很长一段时间。基于此,它有一个系统,在该系统中,在一定数量的垃圾回收周期中幸存下来的对象将获得“永久”,并且垃圾回收器开始简单地假设它们仍在使用中,因此不是在每个周期都复制它们,而是简单地离开他们一个人。这是一个有效的假设,因为分代清理的开销通常比大多数其他形式的 GC 低得多。
“手动”内存管理通常同样难以理解。仅举一个例子,许多比较尝试假设所有手动内存管理也遵循一种特定模型(例如,最佳匹配分配)。这通常比许多人对垃圾收集的信念(例如,它通常使用引用计数完成的普遍假设)更接近现实(如果有的话)。
考虑到垃圾收集和手动内存管理的各种策略,很难在整体速度方面比较两者。尝试比较分配和/或释放内存(本身)的速度几乎可以保证产生的结果充其量是毫无意义的,最坏的情况是完全误导。
奖金主题:基准
由于相当多的博客、网站、杂志文章等声称在一个方向或另一个方向上提供“客观”证据,我也会在这个主题上投入两分钱。
这些基准中的大多数有点像青少年决定比赛他们的汽车,谁赢了就可以保留两辆车。但是,这些网站在一个关键方面有所不同:发布基准的人可以同时驾驶这两款车。出于某种奇怪的机会,他的车总是赢,而其他人都不得不接受“相信我,我真的开着你的车开得很快。”
编写一个糟糕的基准很容易,它产生的结果几乎没有任何意义。几乎任何人只要具备设计产生任何有意义的基准所需的技能,也有能力产生一个能够给出他决定想要的结果的基准。事实上,编写代码来产生特定结果可能比真正产生有意义结果的代码更容易。
正如我的朋友 James Kanze 所说,“永远不要相信不是你自己伪造的基准。”
结论
没有简单的答案。我有理由确定我可以掷硬币来选择获胜者,然后选择(比如说)1 到 20 之间的一个数字作为获胜的百分比,然后编写一些看起来像一个合理且公平的基准的代码,并且产生了已成定局的结论(至少在某些目标处理器上——不同的处理器可能会稍微改变百分比)。
正如其他人所指出的,对于大多数代码来说,速度几乎是无关紧要的。对此的推论(更经常被忽略)是,在速度确实很重要的小代码中,它通常很重要。至少根据我的经验,对于真正重要的代码,C++ 几乎总是赢家。肯定有一些有利于 C# 的因素,但实际上它们似乎被有利于 C++ 的因素所压倒。您当然可以找到可以表明您选择的结果的基准,但是当您编写真正的代码时,您几乎总是可以在 C++ 中使其比在 C# 中更快。写作可能(或可能不会)需要更多的技巧和/或努力,但这几乎总是可能的。