Попередження: Питання, яке ви задали, насправді досить складне - можливо, набагато більше, ніж ви собі уявляєте. Як результат - це дійсно довга відповідь.
З чисто теоретичної точки зору на це є, мабуть, проста відповідь: у C # немає (мабуть) нічого, що справді заважає йому бути таким швидким, як C ++. Однак, незважаючи на теорію, є деякі практичні причини того, що за певних обставин у деяких випадках це відбувається повільніше.
Я розгляну три основні області відмінностей: особливості мови, виконання віртуальної машини та збір сміття. Останні двоє часто йдуть разом, але можуть бути незалежними, тому я розгляну їх окремо.
Мовні особливості
C ++ робить великий акцент на шаблонах та функціях в системі шаблонів, які в основному призначені для того, щоб якомога більше робити під час компіляції, тому з точки зору програми вони "статичні". Метапрограмування шаблону дозволяє проводити абсолютно довільні обчислення під час компіляції (тобто система шаблонів є повною за Тьюрінгом). Таким чином, по суті все, що не залежить від вводу від користувача, може бути обчислене під час компіляції, тому під час виконання це просто константа. Вхідні дані до цього можуть включати такі речі, як інформація про тип, тому велика частина того, що ви робите за допомогою відображення під час виконання в C #, зазвичай робиться під час компіляції за допомогою метапрограмування шаблону в C ++. Безумовно, існує компроміс між швидкістю виконання та універсальністю - що можуть зробити шаблони,
Відмінності в особливостях мови означають, що майже будь-яка спроба порівняння двох мов шляхом простої транслітерації деякої мови C # на C ++ (або навпаки), ймовірно, дасть результати десь між безглуздими та оманливими (і те саме стосується більшості інших пар мов також). Простий факт полягає в тому, що для будь-чого, що перевищує пару рядків коду або близько того, майже ніхто взагалі не може використовувати мови однаково (або досить близько до того самого шляху), що таке порівняння говорить вам щось про те, як ці мови робота в реальному житті.
Віртуальна машина
Як і майже будь-яка досить сучасна віртуальна машина, Microsoft для .NET може і буде робити компіляцію JIT (вона ж "динамічна"). Це представляє низку компромісів.
В першу чергу, оптимізація коду (як і більшість інших задач оптимізації) є в основному проблемою, що повна NP. Для будь-чого, крім справді тривіальної / іграшкової програми, ви майже гарантовані, що не будете по-справжньому "оптимізувати" результат (тобто ви не знайдете справжнього оптимуму) - оптимізатор просто зробить код кращим за нього було раніше. Проте досить багато відомих оптимізацій займають значну кількість часу (і, часто, пам’яті). За допомогою компілятора JIT користувач чекає, поки компілятор працює. Більшість більш дорогих методів оптимізації виключаються. Статична компіляція має дві переваги: перш за все, якщо вона повільна (наприклад, побудова великої системи), вона зазвичай виконується на сервері і ніхтовитрачає час на його очікування. По-друге, виконуваний файл може бути створений один раз і використаний багатьма людьми багатьма людьми. Перший мінімізує витрати на оптимізацію; другий амортизує набагато меншу вартість за значно більшу кількість страт.
Як згадувалося в оригінальному питанні (та багатьох інших веб-сайтах), компіляція JIT має можливість більш глибокої обізнаності про цільове середовище, що повинно (принаймні теоретично) компенсувати цю перевагу. Немає сумнівів, що цей фактор може компенсувати хоча б частину недоліків статичної компіляції. Для декількох досить специфічних типів коду та цільових середовищ це моженавіть переважують переваги статичної компіляції, іноді досить різко. Однак, принаймні на моє тестування та досвід, це досить незвично. Цілеспрямована оптимізація, в основному, або робить досить незначні відмінності, або може бути застосована (у будь-якому випадку автоматично) до досить конкретних типів проблем. Очевидно, що це могло б статися, якби ви запускали відносно стару програму на сучасній машині. Стара програма, написана на C ++, напевно, була б скомпільована до 32-розрядного коду і продовжувала б використовувати 32-розрядний код навіть на сучасному 64-розрядному процесорі. Програма, написана на C #, була б скомпільована в байтовий код, який потім ВМ скомпілювала б у 64-розрядний машинний код. Якби ця програма отримала значну вигоду від роботи в якості 64-розрядного коду, це могло б дати значну перевагу. За короткий час, коли 64-розрядні процесори були досить новими, цього трапилось немало. Останній код, який, ймовірно, виграє від 64-розрядного процесора, як правило, буде доступний, статично скомпільований у 64-розрядний код.
Використання віртуальної машини також має можливість покращити використання кешу. Інструкції для ВМ часто є більш компактними, ніж власні машинні інструкції. Більше з них може поміститися до заданого обсягу кеш-пам'яті, тому ви маєте більше шансів, що будь-який даний код буде в кеш-пам'яті, коли це потрібно. Це може допомогти зберегти інтерпретоване виконання коду віртуальної машини більш конкурентоспроможним (з точки зору швидкості), ніж більшість людей спочатку очікували - ви можете виконати багато інструкцій на сучасному центральному процесорі за час, який займає одна помилка кешу.
Варто також зазначити, що цей фактор взагалі не обов’язково відрізняється між собою. Ніщо не заважає (наприклад) компілятору C ++ видавати вихідні дані, призначені для роботи на віртуальній машині (з JIT або без нього). Насправді, C ++ / CLI від Microsoft є майже цим - (майже) відповідним компілятором C ++ (хоча і з великою кількістю розширень), який видає вихідні дані, призначені для роботи на віртуальній машині.
Зворотне також справедливо: Microsoft тепер має .NET Native, який компілює код C # (або VB.NET) до власного виконуваного файлу. Це дає продуктивність, яка, як правило, набагато більше схожа на C ++, але зберігає функції C # / VB (наприклад, C #, скомпільований у власний код, все ще підтримує відображення). Якщо у вас є інтенсивний код C #, це може бути корисно.
Вивіз сміття
З того, що я бачив, я б сказав, що збір сміття є найбіднішим з цих трьох факторів. Тільки для наочного прикладу, у запитанні тут згадується: "GC також не додає багато накладних витрат, якщо ви не створюєте та не знищуєте тисячі об'єктів [...]". Насправді, якщо створити та знищити тисячі об'єктів, накладні витрати на збір сміття, як правило, будуть досить низькими. .NET використовує генератор сміття, який є різновидом збирача копій. Колекціонер сміття працює, починаючи з "місць" (наприклад, реєстрів та стеку виконання), що відомі вказівники / посиланнябути доступним. Потім він "переслідує" ці вказівники на об'єкти, які були виділені в купі. Він досліджує ці об'єкти для подальших покажчиків / посилань, поки не пройшов за ними до кінців будь-яких ланцюжків, і не знайшов усіх об'єктів, які є (принаймні потенційно) доступними. На наступному кроці він бере всі об'єкти, які використовуються (або, принаймні, можуть бути ), і ущільнює купу, копіюючи всі їх у суміжний шматок на одному кінці пам'яті, якою управляється в купі. Решта пам'яті тоді вільна (модулі-фіналізатори потрібно запускати, але принаймні у добре написаному коді вони досить рідкісні, що я їх ігнорую на даний момент).
Це означає, що якщо ви створюєте та знищуєте багато об’єктів, збір сміття додає дуже мало накладних витрат. Час циклу вивезення сміття майже повністю залежить від кількості об’єктів, які були створені, але не знищені. Основним наслідком створення та знищення об’єктів поспіхом є просто те, що GC повинен працювати частіше, але кожен цикл все одно буде швидким. Якщо ви створюєте об'єкти і не знищуєте їх, GC запускатиметься частіше, і кожен цикл буде значно повільнішим, оскільки він витрачає більше часу, переслідуючи покажчики на потенційно діючі об'єкти, і витрачає більше часу на копіювання об'єктів, які все ще використовуються.
Для боротьби з цим винищення поколінь працює на припущенні, що об’єкти, які протягом деякого часу залишались «живими», ймовірно, продовжуватимуть залишатися живими ще деякий час. Виходячи з цього, у нього є система, коли об'єкти, які переживають деяку кількість циклів збору сміття, "стають", і збирач сміття починає просто припускати, що вони все ще використовуються, тому замість того, щоб копіювати їх на кожному циклі, він просто залишає їх поодинці. Це досить слушне припущення, що, як правило, видалення поколінь має значно нижчі накладні витрати, ніж у більшості інших форм ЖК.
"Ручне" управління пам'яттю часто настільки ж погано розуміється. Тільки для одного прикладу, багато спроб порівняння припускають, що все управління пам’яттю вручну також слідує одній конкретній моделі (наприклад, найкраще розподілене розподіл). Це часто мало (якщо взагалі є) ближче до реальності, ніж переконання багатьох людей щодо збору сміття (наприклад, поширене припущення, що це зазвичай робиться за допомогою підрахунку посилань).
З огляду на різноманітність стратегій як для збору сміття, так і для управління пам’яттю вручну, досить важко порівняти ці два за загальною швидкістю. Спроба порівняти швидкість розподілу та / або звільнення пам’яті (сама по собі) майже гарантовано дає результати, які в кращому випадку безглузді, а в гіршому - відверто оманливі.
Бонусна тема: Тести
Оскільки чимало блогів, веб-сайтів, статей у журналах тощо стверджують, що надають "об'єктивні" докази в тому чи іншому напрямку, я також вкладу свої два центи на цю тему.
Більшість з цих орієнтирів трохи схожі на підлітків, які вирішили змагатись на своїх автомобілях, і той, хто виграє, повинен зберегти обидва автомобілі. Хоча веб-сайти різняться одним вирішальним чином: вони, хто публікує еталон, отримують можливість їздити на обох автомобілях. Якимсь дивним випадком його машина завжди виграє, і всі інші повинні погодитися з тим, що "повірте мені, я справді їхав на вашій машині так швидко, як вона могла б піти".
Легко написати поганий орієнтир, який дає результати, які майже нічого не означають. Майже будь-хто, хто має будь-яку близьку майстерність, необхідну для розробки орієнтира, який дає щось значуще, також має навик створити той, який дасть результати, які він вирішив, що хоче. Насправді, напевно, простіше написати код для отримання конкретного результату, ніж той, який справді дасть значущі результати.
Як сказав мій друг Джеймс Канце, "ніколи не довіряйте еталону, який ви не фальсифікували самі".
Висновок
Простої відповіді немає. Я досить впевнений, що міг би перегорнути монету, щоб вибрати переможця, потім вибрати число від (скажімо) 1 до 20 для відсотка, на який вона виграє, і написати якийсь код, який виглядав би розумним і справедливим еталоном, і зробив такий упереджений висновок (принаймні на якомусь цільовому процесорі - інший процесор може трохи змінити відсоток).
Як зазначали інші, для більшості кодів швидкість майже не має значення. Наслідком цього (який набагато частіше ігнорується) є те, що в маленькому коді, де швидкість має значення, зазвичай це має велике значення . Принаймні на моєму досвіді, для коду, де це насправді важливо, С ++ майже завжди виграє. Безумовно, є фактори, які сприяють C #, але на практиці вони, здається, переважають фактори, що сприяють C ++. Ви, звичайно, можете знайти контрольні показники, які будуть вказувати на результат вашого вибору, але коли ви пишете реальний код, ви майже завжди можете зробити це швидше на C ++, ніж на C #. Для написання може знадобитися (а може і не бути) більше навичок та / або зусиль, але це практично завжди можливо.