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