Машина на глиняних ногах
11 / 18

Розділ 10. Примара PDP-11

Машина, яку всі забули

У 1970-му на ринку з’явився комп’ютер під назвою DEC PDP-11. Виробник — Digital Equipment Corporation, тоді друга за розміром комп’ютерна компанія у світі (після IBM). PDP-11 був міні-комп’ютером — тобто меншим за мейнфрейм, але більшим за персональний комп’ютер (яких ще практично не існувало). Розмір — як два-три холодильники, поставлені поруч. Ціна — від $20 000 (тоді — приблизно $150 000 у сучасних доларах).

PDP-11 продавали до 1990 року. Виробили близько 600 000 штук. Це був найпопулярніший міні-комп’ютер свого часу.

А потім він помер. До 2000-х про нього вже мало хто пам’ятав. Музейні експонати, ретро-обчислювальні клуби, ностальгія старожилів IT. Залізо PDP-11 фізично пішло на склад брухту або в пам’ять про колишню епоху.

Але.

Кожна сучасна мова програмування — Python, Java, C#, JavaScript, Go, Ruby, навіть Rust — носить у собі ментальну модель PDP-11. Щоразу, коли ти оголошуєш змінну, передаєш функцію, читаєш масив, виділяєш пам’ять — ти програмуєш у віртуальному PDP-11, навіть не підозрюючи про це.

Це найдивніший факт усієї книги. Машина, якої немає вже сорок років, через мову C, яка була під неї заточена, досі форматує те, як думає кожен програміст планети.

Як це сталося — і чому це проблема — і є тема цього розділу.

Що такого було особливого в PDP-11

З погляду інженера 1970-х, PDP-11 був найкраще спроектованим міні-комп’ютером свого часу. Архітектура чиста, симетрична, зручна. Він мав:

Плоску, байт-адресовану пам’ять. На відміну від багатьох інших машин того часу (включно з тогочасним IBM 360, де адресація була складна), PDP-11 мав один простір адрес. Кожен байт мав одну адресу. Можна було взяти будь-який байт і заадресувати його прямо. Зараз це здається очевидним. Тоді — революційно.

Універсальні регістри. Вісім 16-бітних регістрів (R0-R7), всі однотипні. Можна використовувати будь-який для будь-чого. На відміну від 8086 (про який ми говорили в Розділі 5), де AX, BX, CX мали кожен своє призначення.

Стек на апаратному рівні. R6 — це stack pointer. Інструкції PUSH і POP — частина базового набору. Виклики функцій робляться через стек “нативно”. Це звучить очевидно, але багато архітектур до того стек не підтримували.

Потужні режими адресації. Можна було адресувати пам’ять різними способами: пряме, через регістр, зі зміщенням, з пост-інкрементом, з пре-декрементом. Останні два — особливо цікаві.

Що означає “пост-інкремент”? Інструкція типу MOV (R0)+, R1 робить таке: бере значення з пам’яті за адресою у R0, кладе у R1, і автоматично збільшує R0 на розмір переміщеного значення. Тобто проходить по масиву на одну позицію автоматично.

Звучить знайомо? Так — це те саме, що в C *p++. Або те, що в C++ *p++. Або те, що означає i++ у будь-якій мові C-подібного синтаксису.

Інкремент-декремент ++ і -- у C існують саме тому, що PDP-11 мав ці режими адресації. Деніс Рітчі взяв їх з заліза і виніс у мову. Кожна мова, яка скопіювала C-синтаксис (а це більшість сучасних мов), успадкувала ++ і --і не знає чому.

Як C формалізував ментальну модель PDP-11

Деніс Рітчі, проектуючи C у 1972 році, мав одну задачу: зробити мову, на якій можна писати операційну систему. Тобто мову, яка дає прямий доступ до заліза, але при цьому переноситься з машини на машину.

Він зробив компроміс. Мова C офіційно не прив’язана до жодної архітектури. Але за замовчуванням вона побудована на припущеннях, які точно відповідають PDP-11.

Які саме припущення?

Припущення №1: Пам’ять — це лінійний байт-адресований масив. В C є оператор * (розіменування покажчика) і & (взяти адресу). Покажчики — це фактично прямі адреси у пам’яті. Можна додавати, віднімати — і це робиться як арифметика з адресами. Це чітко відповідає моделі пам’яті PDP-11.

На сучасному залізі (з кешем, NUMA, віртуальною пам’яттю) це вже неправда. Адреса в RAM — це абстракція над фізичною пам’яттю. Доступ до сусідніх адрес може бути в 100 разів швидшим за доступ до далеких. Але C-код пишеться так, ніби пам’ять плоска.

Припущення №2: Програма — це послідовність інструкцій, які виконуються у порядку. В C є цикли, умови, виклики функцій. Усе у порядку, як написано. Один потік виконання.

На сучасному залізі це теж неправда. Сучасний CPU виконує десятки інструкцій паралельно і поза порядком (out-of-order execution). Спекулює, переставляє, кешує. Але C прикидається, що це не так.

Припущення №3: Один процесор, одна пам’ять. C нічого не знає про паралелізм. Стандарт мови до 2011 року взагалі не мав поняття про потоки. У C11 додали _Atomic і атомарні операції — але це костилі, не основа мови.

На сучасному залізі ми маємо багатоядерні процесори, GPU, NUMA — все паралельне. C прикидається, що сидить за єдиним процесором.

Припущення №4: Якщо щось можна зробити з пам’яттю, його треба зробити. В C можна писати куди завгодно через покажчики. Можна виходити за межі масиву. Можна звільняти пам’ять і потім її використовувати. Усе це — легальний код, який компілятор не зупинить. Хоча наслідки катастрофічні.

На PDP-11 це було природно: захисту пам’яті між процесами не було, можна було робити що завгодно. На сучасному залізі це корінь усіх проблем безпеки: переповнення буфера, use-after-free, off-by-one — це все слова про те, як C-код пише туди, куди не повинен.

Девід Чізнал і його стаття-вибух

У 2018 році Девід Чізнал, англійський інженер з Університету Кембриджа і компанії Microsoft Research, опублікував у журналі ACM Queue статтю з провокаційним заголовком: “C Is Not a Low-Level Language” (“C — не мова низького рівня”).

Стаття стала бомбою. Її прочитали мільйони програмістів. Вона змінила розмову у спільноті.

Аргумент Чізнала був простий: C є низькорівневою лише відносно PDP-11. Відносно сучасного заліза — C високорівнева. Між тим, що ти пишеш у C, і тим, що відбувається в процесорі, є кілька шарів абстракцій:

  1. Компілятор C, який перетворює твій лінійний код у щось зовсім інше — переставляє інструкції, об’єднує цикли, видаляє “зайвий” код.
  2. Декодер CPU, який бере результат компілятора і знову переставляє його у мікрооперації.
  3. Виконавчий блок CPU, який виконує мікрооперації не по порядку, спекулятивно, паралельно.
  4. Кеш-ієрархія, яка вирішує, де насправді живуть твої дані.
  5. Контролер пам’яті, який оптимізує доступ до DRAM.

C дає тобі ілюзію, що ти “близький до заліза”. Насправді ти далеко від нього. Між тобою і реальною роботою CMOS — п’ять шарів інтерпретації. І ти не контролюєш жоден.

Більше того: щоб C-код працював добре на сучасному залізі, процесори мусять докладати героїчних зусиль — спекулятивне виконання, гілкове передбачення, складна кеш-логіка. Усі ці зусилля створюють складність, яка створює вразливості (Spectre, Meltdown — пам’ятаєш?).

Якби ми не намагалися зробити вигляд, що PDP-11 ще існує — нам не потрібна була б більшість цієї складності. Простіша архітектура. Швидша. Безпечніша.

Але вийти з цієї петлі неможливо. Бо весь код — на C. Або на мовах, успадкованих з C. Або на мовах, які компілюються через C.

Які мови успадкували PDP-11

Подивимось на сім’ю.

Прямі нащадки (синтаксис і семантика від C): - C++ (1985, Бьярн Страуструп) — C з об’єктами. Уся семантика C, плюс класи зверху. Те ж саме припущення про пам’ять і потоки. - Objective-C (1984) — C з об’єктами в стилі Smalltalk. Apple використовує його з 1988-го (NeXT, потім macOS, потім iOS). - Java (1995, Джеймс Гослінг) — синтаксис від C, семантика своя (з GC). Але ментальна модель пам’яті — все одно “масив байтів”. - C# (2000, Microsoft) — Java для Microsoft. Те саме. - JavaScript (1995, Брендан Айк за 10 днів) — синтаксис від C, динамічна типізація, але цикли, умови, функції — все зі світу C. - PHP, Perl, Python (синтаксично), Ruby — усі носять відбитки C-стилю. - Go (2009, Google) — Роб Пайк і Кен Томпсон (так, той самий!) свідомо повернулись до простоти C, додавши горутини. - Rust (2010, Mozilla) — спроба зробити “C без помилок” через систему типів. Але ментальна модель — все та сама. - Swift (2014, Apple) — заміна Objective-C, але семантика близька.

З цього списку — майже всі мови, якими ти будь-коли користувався. Усі вони — генеалогічні нащадки C, отже, нащадки PDP-11. Усі вони бачать комп’ютер як “процесор + плоска пам’ять + послідовні інструкції”.

Мови, які пробували бути іншими: - Lisp (1958) — функціональна, з рекурсією, без присвоєнь. Старша за C, але стала маргінальною у 1980-х. Сучасні нащадки: Clojure, Racket. - Smalltalk (1972) — повністю об’єктно-орієнтована, з повідомленнями. Вплинула на Objective-C і Ruby, але сама не вижила. - Erlang (1986) — для розподілених систем, з акторами замість потоків. Жива, але нішева. - Haskell (1990) — чисто функціональна, без побічних ефектів. Її люблять академіки, мало використовується у продакшні. - Prolog (1972) — логічна. Жива в окремих нішах (експертні системи, теоретичний ШІ).

Усі вони — набагато менше використовуються, ніж нащадки C. Чому?

Чому PDP-11-парадигма виграла

Кілька причин.

1. Вона звучить просто. “Машина — це процесор, який виконує інструкції одну за одною з пам’яті”. Це інтуїтивно. Це те, що людина може уявити в голові. Функціональне програмування (“все є вираз, без побічних ефектів”) вимагає іншого способу мислити, який не приходить природно.

2. C переміг через Unix. Unix став стандартом, Unix був на C, отже всі вчили C. Усе наступне покоління мов створювалося людьми, які виросли на C — і копіювали з нього синтаксис, семантику, модель пам’яті.

3. Залізо з 1970-х підлаштовувалося під C, а не навпаки. Кожне нове покоління процесорів було спроектоване так, щоб швидко виконувати C-код. Якби індустрія обрала Lisp як стандарт — процесори мали б тегову арифметику і апаратний garbage collector. Якби Smalltalk — апаратні повідомлення. Замість цього процесори оптимізують саме під C-абстракції.

4. Ефект мережі. Кожна нова мова, яка хоче бути успішною, мусить взаємодіяти з C-кодом (бо вся ОС, всі бібліотеки — на C). Тому простіше зробити нову мову C-подібною — вона легше інтегрується.

5. Програмісти ліниві. Перевчатися на іншу парадигму — болить. Краще лишитись з тим, що знаєш.

Ці п’ять сил разом створили самопідсилювальний цикл, з якого вийти майже неможливо. PDP-11 помер, а його мова жива. Жива тому, що іншого способу думати про обчислення індустрія не вивчила.

А що зараз і що далі

Останні десять років з’явилися мови, які намагаються визнати реальність сучасного заліза.

Rust додає до C-моделі систему власності (ownership system). Кожен шматок пам’яті має одного власника. Коли власник зникає — пам’ять автоматично звільняється. Не можна одночасно мати кілька покажчиків на одні й ті самі мінливі дані. Це усуває цілий клас помилок — переповнення, use-after-free, race conditions.

Rust популярний останні п’ять років. Linux-ядро (!) почало приймати модулі на Rust у 2022-му. Microsoft переписує частини Windows на Rust. Це найреальніший претендент на “C наступного покоління”.

Zig — більш скромна спроба. “C без помилок, але без зайвої магії Rust”. Ручне керування пам’яттю, але з кращими інструментами. Все ще ранній.

Mojo — від компанії Modular, заснованої Крісом Латтнером (творцем LLVM і Swift). Це Python з типами і компіляцією. Обіцяє бути в тисячі разів швидшим за Python для AI-задач. Запущений у 2023-му, ще ранній.

Roc, Gleam, Elm — функціональні мови з дружнім синтаксисом, які намагаються зробити функціональну парадигму доступною звичайному програмісту.

Lean 4 — мова програмування і одночасно система формальних доведень. Теоретично можна написати програму і математично довести, що вона правильна. Поки що академічна, але напрямок цікавий.

Усі ці спроби — рух назад від PDP-11 до сучасної реальності. Кожна вирішує одну проблему. Жодна поки не змогла стати масовою заміною C-парадигми.

Висновок: довге прощання

Ми живемо у дивному переломному моменті. Залізо сучасне — паралельне, гетерогенне, з кешами і прискорювачами. А мови, якими ми це залізо програмуємо, застрягли в 1972 році. Між тим, як ми думаємо, і тим, що насправді відбувається — все більший розрив.

Це коштує нам: - Швидкості. Більшість програм використовує менше 10% теоретичної потужності заліза, бо мова не дозволяє ефективно описувати, що насправді треба робити. - Енергії. Усі ці спекуляції, кеш-промахи, неоптимальні доступи — це електрика, яка перетворюється на тепло. - Безпеки. Більшість CVE — це наслідки C-моделі пам’яті: переповнення, use-after-free, race conditions. - Часу розробки. Програмісти витрачають жахливо багато часу на дебагінг проблем, які в кращій моделі взагалі не виникали б.

Чи вийдемо ми з цієї пастки? Можливо. Rust — найбільш реалістична надія. Можливо, через 20 років мовний ландшафт буде значно іншим. А можливо — через AI, який сам пише код, абстрагуючи нас від мов взагалі.

Але поки що — PDP-11 живий. У кожному int x = 5;. У кожному i++. У кожному printf. У кожному malloc/free. У кожній C-подібній мові. У кожному програмістові, який її вивчає.

У наступному розділі — найвідоміший флейм в історії IT. Лінус Торвальдс проти Ендрю Танненбаума, 1992 рік. Сперечалися: яку архітектуру повинна мати операційна система — моноліт чи мікроядро? Сперечалися грубо, на всю мережу, обидва впевнені у своїй правоті.

Через тридцять років ми бачимо: обидва були по-своєму праві. І ця битва триває досі — вже в умовах, де від її результату залежить, чи зможемо ми довірити автономним системам керувати літаками, машинами і ядерними реакторами.