В этой статье изложено всё, что нужно знать об устройстве компьютера с точки зрения программиста. А именно:
для чего нужен тактовый генератор, регистры, кэши и виртуальная память
что такое архитектура процессора
что такое машинный код и код ассемблера
чем отличается компиляция в машинный код в C, C++ или Rust от компиляции в байт-код виртуальной машины в языках типа Java и C#; в чём их отличие от интерпретируемых языков вроде JavaScript или Python
что такое динамические и статические библиотеки (.dll/.so, .lib/.a); что такое фреймворк
что такое API и web-API
что собой представляет параллельное программирование с использованием многоядерных процессоров, векторных регистров и видеокарт.
Как работает процессор
Все современные компьютеры построены на микропроцессорной архитектуре — за редкими и экзотическими исключениями. Сюда относятся ПК, ноутбуки, серверы, смартфоны, планшеты и микроконтроллеры. Основные части компьютера — это процессор и память. Центральный процессор (ЦП) читает данные из памяти, вычисляет новые данные и записывает их обратно в память — собственно, это всё, что он делает. Таким образом осуществляется вся логика, которую исполняет компьютер. Если к нему подключена периферия (экран, динамик, клавиатура и т. д.), то она взаимодействует с процессором, выставляя свои регистры в адресное пространство памяти компьютера. Различия в подходах заключаются только в том, что эти регистры могут располагаться в отдельном адресном пространстве устройств ввода-вывода (за счёт выделенной шины или дополнительного контакта на физическом интерфейсе ЦП), либо в пространстве основной памяти. Чтобы связаться с подключённым устройством, процессор записывает данные в ячейку памяти, которая соответствует одному из регистров устройства, и устройство реагирует на этот сигнал, отображая определённые цвета на экране, отправляя пакеты в локальную сеть, проигрывая определённый звук, и так далее. Похожий процесс происходит при взаимодействии с устройствами ввода, такими как контроллер клавиатуры — но помимо записи в специальные ячейки памяти, они ещё и генерируют сигналы прерываний, чтобы заставить процессор отложить свою текущую работу и отреагировать на ввод. В конце концов, всё взаимодействие процессора с внешним миром сводится к операциям чтения и записи в память.
Долговременные хранилища (жёсткие диски, SSD, карты памяти и пр.) относятся к периферии и не входят в систему памяти, с которой работает процессор. Это то место, где хранятся пользовательские файлы, библиотеки и исполняемые файлы установленных приложений. Но для того, чтобы любое из этих приложений заработало, сначала его нужно загрузить из долговременного хранилища в память.
Как программист, вы будете работать с процессором и памятью, а с периферией будете связываться через системные библиотеки.
Память компьютера — это многослойная система, состоящая из нескольких уровней кэша, расположенных прямо на кристалле процессора, и оперативной памяти (random-access memory — RAM), поставляемой на отдельных модулях. На картинке ниже видно расположение кэш-памяти 2-го и 3-го уровня — L2 и L3. Кэш 1-го уровня располагается в ядре (Core) — на этом рисунке он не показан. Видно, что у каждого ядра есть отдельный кэш 1-го и 2-го уровня, но кэш L3 общий для всех ядер. В других процессорах кэш L2 может быть общим для каждых 2 ядер из 8, например, либо для каждых 4 ядер из 12 — вариантов множество. Ядро — это основная часть процессора, которая выполняет всю программную логику. Если у вас многоядерный процессор, то можете считать, что у вас несколько процессоров на одном чипе.



Зачем нужна такая многоуровневая структура, и почему просто не хранить все данные в оперативной памяти? Это делается для ускорения работы. Точно так же, как ваши приложения кэшируют запросы к серверам и базам данных, ядро процессора кэширует запросы к памяти, потому что запрашивать данные напрямую у RAM — это слишком долго.
Представьте, что вы сидите в библиотеке и проводите научные вычисления с помощью логарифмической линейки и тетрадки в клеточку. В компании с логарифмической линейкой, вы исполняете роль вычислительных элементов ядра, а тетрадка — это ваш кэш L1. Вы записываете в неё результаты промежуточных расчётов и читаете ваши прошлые записи, которые вы успели забыть. В какой-то момент вам потребуются данные из толстого справочника, лежащего у вас на столе. Поиск информации в справочнике занимает больше времени, чем поиск информации в тетрадке, но зато справочник может хранить гораздо больше данных. Найденные сведения вы записываете в тетрадку, потому что вам бы не хотелось искать их заново. Справочник исполняет роль кэша второго уровня. Рано или поздно вам понадобится другой справочник, которого нет под рукой, и тогда вам придётся попросить помощи библиотекаря, чтобы он принёс нужную книгу с дальнего стеллажа. Секции книжных стеллажей в библиотеке — это кэш L3. Им могут пользоваться все остальные посетители библиотеки. Спустя какое-то время, вы находите в справочнике ссылку на некую диссертацию и понимаете, что она вам необходима для продолжения работы. К сожалению, в библиотеке её нет, но можно отправить запрос в книгохранилище, и её привезут на следующий день. Система книгохранилищ — это оперативная память.
Разные уровни кэшей работают на разных технологиях. Из всех уровней памяти, кэш L1 является самым дорогим в пересчёте на байт, поэтому он имеет наименьший объём — всего лишь до 256 килобайт у десктопов. Но при этом он обеспечивает наибольшую скорость доступа к данным. У кэшей L2 объём измеряется в мегабайтах, а кэш L3 может хранить десятки мегабайт, но скорость чтения и записи в L2 в разы меньше, чем в L1, а скорость L3 в разы меньше, чем в L2. В свою очередь, скорость доступа к оперативной памяти в разы меньше, чем к кэшам третьего уровня, но зато её размер огромен — стандартный объём RAM на современном десктопе составляет 16 гигабайт.
Кэш L1 содержит самые востребованные данные, которые нужны процессору для текущей работы. Если ядро запрашивает данные, которых там нет, то происходит «промах кэша» («cache miss»), и начинается поиск в L2. Если в L2 эти данные есть, то они копируются в L1, и ядро продолжает работу, а если нет, то начинается поиск в L3. Если данных нет даже на последнем уровне кэша (LLC — Last Level Cache), то приходится делать дорогостоящий запрос в оперативную память. В терминах компьютерной произ��одительности, «дорогостоящий» означает «медленный». Кэш мисс означает, что процессор будет простаивать без дела, пока система памяти не доставит нужные данные в L1.
На самом деле, процессоры устроены сложнее, и иногда они могут переупорядочивать инструкции, если видят, что это не ломает логику программы. Это называется «Out-of-order execution». Если процессор понимает, что произошёл кэш мисс, и у него просто нет данных для выполнения текущей инструкции, то он может выполнить несколько следующих инструкций. Но всё равно, промахи кэша замедляют работу компьютера, поэтому программисты их не любят и стараются от них избавляться при разработке высоконагруженных приложений и сервисов. Работая над такими приложениями, приходится думать о множестве разных вещей — и в частности, о том, как разместить данные в памяти и как организовать вычисления, чтобы количество кэш миссов было минимальным.
Данные между кэшами и RAM передаются не по одному байту, а целыми кэш-линиями. Размер кэш-линии зависит от модели процессора и обычно составляет 64 байта. Если ядро запросило байт памяти по адресу 50, то в кэш будут доставлены все байты с адресами от 0 до 63, а если был запрошен байт по адресу 90, то будут доставлены все байты с 64-го по 127-й. Это сделано исходя из того, что приложения обычно работают с близко расположенными данными, и если программная логика зачем-то запросила байт по адресу 50, то скорее всего в ближайшее время ей понадобится байт 51, или 49, или ещё какой-то лежащий рядом байт. Таким образом, загрузка данных блоками позволяет сократить количество промахов кэша.
Схемы памяти состоят из элементов, которые могут находиться в двух устойчивых состояниях. В чём заключаются эти состояния — зависит от конкретной технологии памяти. Например, в одном состоянии ячейка может пропускать ток, а в другом нет. В любом случае, первое состояние обозначает ноль, второе — единицу. Этих двух значений достаточно, чтобы на их основе построить полноценную систему счисления, которая называется двоичной. От десятичной системы она отличается только тем, что в её основе лежит число 2, а не 10. Любую информацию можно закодировать в виде двоичных чисел, будь то текст, изображение или трёхмерная модель сборки из инженерной CAD-системы. Любые данные в памяти представляют собой двоичные числа.
Минимальная адресуемая единица информации — это байт, который состоит из 8 бит. Это значит, что процессор не оперирует адресами отдельных битов, и когда он просит систему памяти доставить ему данные, лежащие по какому-то адресу, он указывает адрес байта. На ранних компьютерах размер байта ещё не пришёл к единому стандарту, и где-то равнялся 6 бит, где-то 32 бита, где-то 36 бит, и так далее. В конце концов, все производители пришли к единому стандарту в 8 бит, потому что такой размер наиболее удобен для хранения англоязычного текста в кодах ASCII.
Один байт может содержать либо беззнаковое число от 0 до 255, либо знаковое число от -128 до 127. В любом случае, с помощью 8 бит можно записать только 256 уникальных значений. Здесь действует простая комбинаторика — берём число значений бита (2), возводим его в степень количества бит (8), получаем 256. Точно так же, как в десятичной системе с помощью 3 десятичных цифр можно записать 103 = 1000 уникальных значений.
Если вы будете работать со статически типизированными языками, такими как C, C++, Rust, Java, C#, Go и пр., то вам придётся оперировать целочисленными или вещественными типами и знать их ограничения и диапазон величин. Типы целых чисел бывают знаковые (signed) и беззнаковые (unsigned). У первых половина диапазона расположена слева от нуля, в области отрицательных чисел, у вторых все значения неотрицательные. Международные стандарты определяют несколько видов целых чисел, из которых вы будете использовать только вот эти 8:
Размер |
Signed |
Unsigned |
8 bit |
−128 — 127 (± 27) |
0 — 255 (28) |
16 bit |
−32768 — 32767 (± 215) |
0 — 65535 (216) |
32 bit |
−2,147,483,648 — 2,147,483,647 (± 231) |
0 — 4,294,967,295 (232) |
64 bit |
−9,223,372,036,854,775,808 — 9,223,372,036,854,775,807 (± 263) |
0 — 18,446,744,073,709,551,615 (264) |
Если вам нужно сохранить или обработать большой контейнер целочисленных величин, и при этом вы заранее знаете, что ни одно из них не будет выходить за пределы диапазона 32-битных чисел, то вам невыгодно использовать 64-битный тип, потому что он занимает в 2 раза больше места. Кроме завышенного потребления памяти, это означает, что в 2 раза меньше таких чисел помещается в линию кэша, что увеличивает количество кэш миссов. Если вы используете векторные регистры для ускорения вычислений (или компилятор делает это за вас), то 32-битные числа могут быть упакованы в векторный регистр в 2 раза плотнее, чем 64-битные, что тоже улучшает производительность. Подробнее о векторных операциях я расскажу в конце статьи.
Арифметические операции с целыми числами могут вызывать переполнение. Если сложить 2 8-битных беззнаковых числа 200 и 200, то результат (400) не поместится в диапазон значений 0 — 255, поэтому на выходе вы получите 145 (после максимального порога отсчёт продолжается с 0 — такова особенность работы арифметико-логического устройства). Если вы знаете, что результат операции может вызвать переполнение, то предварительно вам придётся конвертировать числа в более высокую разрядность (в данном случае — в 16-битное число).
Давайте между делом ответим на вопрос, зачем вообще нужен байт, если есть бит? Причина в том, что адреса памяти, которыми оперирует процессор, представляются целыми числами с ограниченным диапазоном значений. В старых процессорах размер адреса мог составлять всего 16 бит, и если бы минимальной единицей адресации был бит, а не байт, то максимальный размер адресуемой памяти составлял бы 65536 битов, что эквивалентно 8192 байтам и слишком мало даже по меркам того времени. Размер адреса в современных компьютерах (за исключением микроконтроллеров) составляет 64 бита, но переходить на битовую адресацию всё равно не имеет смысла, потому что это сломает обратную совместимость, но никакой ощутимой пользы не принесёт. Программистам редко нужны данные размером меньше 8 бит, а при необходимости битовую адресацию можно имитировать с помощью битовых масок и сдвигов.
Стандарт IEEE-754 определяет 2 типа вещественных чисел — это 32-битные и 64-битные числа с плавающей точкой (floating point numbers). Первые называются числами одинарной точности (single precision), вторые — числами двойной точности (double precision). Кроме повышенной точности представления, 64-битные вещественные числа обеспечивают больший диапазон значений.

В обоих вариантах, число с плавающей точкой записывается в виде трёх целых чисел: знака (sign), порядка (exponent) и мантиссы (fraction). Его значение вычисляется по формуле (-1)знак · 1.мантисса · 2порядок. Формула степенная, поэтому с увеличением двоичных составляющих, значения растут экспоненциально, а точность представления падает. Для 32-битного вещественного числа, в районе нуля разница между ближайшими числами примерно равна 1,4 · 10−45. В районе миллиарда эта разница равна 64. Это значит, что если к миллиарду прибавить 32, то вы получите тот же миллиард, а если к миллиарду прибавить 33, то вы получите 1,000,000,064. Можете это проверить — откройте браузер, найдите любой онлайн-компилятор языка C и запустите вот этот код:
#include <stdio.h>
int main() {
printf("%f", 1000000000.f + 33.f);
return 0;
}
Для double precision такие же проблемы начинаются в районе 500 квадриллионов.
При работе с большими числами, их точность может упасть настолько, что это поломает всю логику вашей программы. Как правило, для решения этой проблемы достаточно перейти на числа с двойной точностью (64 bit).
Специальные значения в диапазоне порядков зарезервированы для денормализованных (subnormal) чисел, для бесконечности и для NaN. Денормализованные числа представляются по другой формуле и позволяют повысить точность вычислений при работе с малыми величинами в районе 0. Бесконечность (Infinity) бывает положительная и отрицательная, и образуется при переполнении, т. е. когда результат операции выходит за границы диапазона значений. Ещё она образуется при делении на 0 — в отличие от целых чисел, для которых результат деления на 0 зависит от процессора — на x86 возникнет аппаратное исключение, а PowerPC просто вернёт некорректный результат. Из-за такой несогласованности реализаций, в C и C++ целочисленное деление на 0 считается неопределённым поведением — смысл этих страшных слов я объясню в следующем разделе.
NaN расшифровывается как Not a Number («не число») и является результатом деления 0 на 0, т. е. оно появляется в том случае, когда результат операции вообще не имеет смысла.
Процессор не работает с числами, находящимися непосредственно в кэше L1. Сначала он должен загрузить число из кэша в один из своих регистров. Регистры — это ячейки памяти, напрямую связанные с логическими схемами процессора.
Давайте посмотрим на список регистров в архитектуре процессоров RISC-V.

Как видим, здесь есть 32 целочисленных регистра общего назначения и 32 регистра для чисел с плавающей точкой. В разных вариантах архитектуры размер одного регистра может быть 32, 64 или 128 бит.
Давайте возьмём воображаемый процессор с 32-битными регистрами и запишем число 123 в регистр x1 и число 321 в регистр x2. В двоичной системе содержимое регистров будет таким:
x1: 00000000000000000000000001111011
x2: 00000000000000000000000101000001
В каждом процессоре есть регистр инструкций (Instruction Register, IR), который полностью управляется самим процессором, и программисты не имеют к нему прямого доступа, поэтому в приведённом выше списке он не указан. Но мы сейчас работаем с воображаемым процессором, так что давайте нарушим это правило и запишем в этот регистр следующую инструкцию:
IR: 00000000000100010000000110110011
Как я уже сказал, регистры напрямую связаны с логическими схемами процессора. Если мы пропустим через эти схемы электрический импульс, то в регистре x3 появится число 444, которое является суммой чисел 123 и 321:
x3: 00000000000000000000000110111100
Только вообразите, что разработчики процессоров сумели собрать схему из полупроводников, которая суммирует двоичные числа при электрическом воздействии. Впечатляет, правда? Но почему процессор выполнил именно операцию суммирования? Почему он сложил именно числа из регистров x1 и x2? И почему он записал результат в x3? Чтобы это понять, нужно перевести нашу инструкцию в человекочитаемый вид, то есть перевести её с языка машинного кода на язык ассемблера:
ADD x3, x2, x1
Здесь буквально написано: «просуммируй 32-битные целые числа из регистров x1 и x2 и положи результат в x3 в виде 32-битного целого числа».
Язык ассемблера — это язык программирования, предназначенный для написания машинных инструкций под конкретный процессор. Этим языком пользуются программисты встраиваемых систем, операционных систем, загрузчиков, компиляторов, виртуальных машин, драйверов и прошивок. Разработчики систем, в которых требуется максимальная производительность, пользуются своим знанием ассемблера для низкоуровневых оптимизаций.
Ассемблером называется приложение, которое конвертирует текст программы с языка ассемблера в машинный код. Приложение, которое делает обратное — переводит машинные инструкции в код ассемблера — называется дисассемблером.
Кроме складывания двух целых чисел, существует множество инструкций на все случаи жизни — деление, умножение и взятие остатка, операции с вещественными числами, запись и чтение из памяти в регистр, логические операции и сравнения, битовые сдвиги, атомарные операции и так далее.
Ещё один специализированный регистр, к которому программист не имеет прямого доступа — это счётчик команд, он же program counter (PC), он же instruction pointer (IP). В нём хранится адрес инструкции, которую процессор должен выполнить после завершения текущей инструкции. В большинстве случаев, этот счётчик после каждой команды просто увеличивается на заданное число. Поскольку команды в памяти идут одна за другой, такое последовательное увеличение счётчика приводит к последовательному выполнению команд. Инструкции перехода позволяют программисту переместить этот счётчик в произвольное место, чтобы он указывал на определённую инструкцию. В зависимости от команды, такой переход происходит при выполнении условия или безусловно. Это позволяет реализовать ветвления, циклы и вызовы функций. Таким образом, ассемблер позволяет писать сколь угодно сложные программы, и когда-то давно программисты только тем и занимались, что программировали на ассемблере.
Помните, как мы пропустили через процессор электрический импульс, чтобы заставить его выполнить инструкцию ADD? Этот импульс называется тактом, или циклом процессора (clock cycle). Если вы видите в характеристиках процессора тактовую частоту в 2,5 ГГц, это значит, что тактовый генератор (clock generator) пропускает через процессор 2,5 миллиарда импульсов в секунду. Большинство инструкций требуют больше одного такта для выполнения, но каждый импульс меняет состояние логических схем и «продвигает» выполнение инструкции вперёд. Тактовый генератор заставляет все логические схемы действовать синхронно и отбивает для них ритм, как метроном.

Компьютерная программа представляет собой последовательность машинных инструкций, упакованных в бинарный исполняемый файл. На Windows такие файлы имеют расширение «.exe». Кроме машинного кода, исполняемый файл содержит служебную информацию, специфичную для той операционной системы, под которую его собирали, поэтому исполняемый файл Windows невозможно запустить на Linux (без дополнительных ухищрений), даже если обе операционные системы работают на одинаковых процессорах и соответственно, на одинаковом наборе машинных инструкций.
Разные модели процессоров имеют разный набор инструкций и разный набор доступных регистров. И то, и другое определяется архитектурой процессора. Каждая архитектура имеет свой язык машинных команд и свой язык ассемблера. Самые распространённые архитектуры процессоров на сегодня — это x86, ARM и RISC-V. x86 занимает нишу десктопов, ноутбуков, игровых приставок и серверов. ARM — это смартфоны, планшеты, персональные компьютеры Apple, микроконтроллеры и одноплатные компьютеры вроде Raspberry Pi. RISC-V набирает популярность благодаря открытой архитектуре, не требующей лицензирования, и применяется в микроконтроллерах и устройствах интернета вещей, хотя пока что он уступает ARM по распространённости.
Архитектура x86, в отличие от RISC, имеет переменную длину инструкций. Раньше она включала набор из 8 32-битных регистров общего назначения, из-за чего адресация памяти была ограничена диапазоном 32-битного беззнакового целого, и объём доступной памяти для компьютеров с такими процессорами не превышал 4 ГБ. Потом появилось расширение этой архитектуры под названием x86-64, благодаря которому процессоры x86 обзавелись 16 64-битными регистрами. Диапазон адресуемой памяти для этих процессоров на сегодняшний день можно считать неограниченным. Это расширение обладает обратной совместимостью с обычным x86 — старые программы используют половинки первых 8 64-битных регистров, так что для них это выглядит как старые добрые 8 32-битных регистров, под которые скомпилированы их машинные инструкции. Естественно, программы, написанные под 64-битную архитектуру, на старых 32-битных процессорах работать не будут. x86 — это проприетарная архитектура, и производить процессоры на её основе имеют право только 2 компании — Intel и AMD.
Архитектура ARM принадлежит британской компании Arm Holdings, которая живёт за счёт продажи лицензий на производство чипов.
Чтобы прочитать данные из ячейки памяти, процессор передаёт в систему памяти адрес этой ячейки. На современных машинах адрес — это, как правило, беззнаковое целое число, занимающее часть 64-битного регистра. Вся память представляется линейным адресным пространством, в котором адрес «0» указывает на 1-й байт, адрес «1» указывает на следующий байт, и так далее. Если вы работали с языками высокого уровня, такими как C, C++ или Rust, то вы должны были встретиться с указателями на память — они как раз используются для работы с этими адресами. Любая программа при запуске размещается в памяти, занимая выделенные ей диапазоны адресов. В этих диапазонах располагаются машинные инструкции, определяющие всю её логику, и данные процесса — глобальные переменные, куча (heap) и стеки потоков.
Что будет, если при написании машинных инструкций вы запрограммируете ваше приложение обращаться к адресам памяти за пределами выделенных вам диапазонов? Очевидно, что тогда вы сможете влиять на работу других программ, загруженных в систему, читая и корректируя их данные на своё усмотрение. Вы даже сможете залезть в код ОС и вызвать глобальный сбой. Чтобы закрыть эту брешь в безопасности и запретить вам доступ к памяти, занятой другими процессами, современные компьютеры используют систему виртуальной памяти — за исключением микроконтроллеров, в большинстве из которых аппаратная поддержка виртуальной памяти не оправдывает сопутствующих затрат. Часть процессора, ответственная за работу виртуальной памяти, называется MMU (memory management unit, «устройство управления памятью»). Кроме наличия MMU, для работы виртуальной памяти необходима операционная система, которая поддерживает эту функцию — переводит процессор в режим страничной адресации, создаёт в памяти таблицы страниц и загружает их адрес в специальный регистр.
Если не вдаваться в детали, то виртуальная память работает достаточно просто. Когда процессор обращается к памяти по адресу X, MMU заменяет его на адрес Y, и система памяти возвращает процессору данные, расположенные в физической памяти по адресу Y. Причём трансляция из X в Y происходит независимо для каждой запущенной программы, и таблица страниц у каждой программы своя. Благодаря этому, один процесс никак не может получить доступ к области физической памяти, выделенной другому процессу.
Если бы система хранила таблицы соответствия каждого байта виртуальной памяти каждому байту физической, то эти таблицы занимали бы гораздо б��льше места, чем та память, которой они управляют. Поэтому соответствие устанавливается не между отдельными байтами, а между страницами — непрерывными участками памяти, как правило, имеющими размер в 4 КБ.
Кроме очевидных преимуществ по безопасности, такой режим позволяет увеличить «виртуальный» объём оперативной памяти за счёт жёсткого диска. При нехватке памяти, на жёстком диске создаётся файл подкачки, в который выгружаются неиспользуемые страницы из RAM. Это добавляет ещё один уровень иерархии в систему памяти, и оперативная память начинает исполнять ту же роль по отношению к файлу подкачки, что и кэш по отношению к оперативной памяти. Если в кэш данные доставляются из RAM целыми кэш-линиями, а отсутствие нужных данных в кэше называется «cache miss», то из файла подкачки данные доставляются в RAM целыми страницами, а отсутствие нужной страницы в RAM называется «page fault» («отказ страницы»). Благодаря этой технологии, если ваши программы начинают потреблять больше памяти, чем располагаемый объём RAM, то система начинает дико тормозить, но продолжает работать. Скорость доступа к долговременному хранилищу гораздо медленнее, чем скорость доступа к оперативной памяти, поэтому page fault обрабатывается дольше, чем cache miss, и когда система переходит в такой режим, она сильно теряет в быстродействии.
Кроме изоляции адресного пространства и использования файла подкачки, виртуальная память открывает и другие возможности, но давайте на этом остановимся и перейдём к следующей теме.
Языки высокого уровня
Язык ассемблера — это полноценный язык программирования, на котором можно написать всё, что угодно. Но никому не придёт в голову разрабатывать на нём большие и сложные приложения. Во-первых, это долго, дорого и скучно. Во-вторых, код ассемблера пишется под конкретную архитектуру процессора, и если вы захотите перейти на другую аппаратную платформу, то вам придётся заново писать весь код на другом языке ассемблера. Чтобы облегчить себе жизнь, люди придумали писать программы на языках высокого уровня, в которых может разобраться человек, а потом компилировать их в машинный код, в котором может разобраться процессор.

В языках высокого уровня присутствуют удобные для программиста абстракции, которые невозможно реализовать в ассемблере:
Вызов функций с любым количеством аргументов без необходимости управления стеком
Запись сложных математических выражений с разными операторами и функциями и с использованием скобок для группировки
Возможность пользоваться множеством переменных и обращаться ко всей доступной памяти без необходимости оглядываться на ограниченное число регистров
Пользовательские структуры данных, созданные под конкретную задачу
Ветвление, циклы, наследование, статический и динамический полиморфизм, корутины и многое другое.
К языкам, компилируемым прямо в машинный код, относится множество примеров, в том числе легендарные FORTRAN, COBOL и Pascal, но актуальными на сегодняшний день считаются C, C++, Rust, Swift и Golang. Язык C занимает среди них особую позицию как самый низкоуровневый из высокоуровневых языков. Это самый популярный язык для разработки под микроконтроллеры, поскольку он наиболее «близок к железу» и обладает развитой экосистемой для embedded разработки. Эта экосистема включает большое количество библиотек, инструментов и компиляторов под любую платформу.
Раз уж речь зашла о библиотеках, давайте я объясню, что это такое. Кроме исполняемого файла, из машинных инструкций можно собрать файл динамической (разделяемой) библиотеки (.dll на Windows, .so на Linux и .dylib на macOS). Этот файл тоже содержит какую-то программную логику, но он не запускается как отдельная программа, а подключается к другим программам в качестве внешнего модуля и предоставляет им интерфейс для использования своих функций.
Представим, что вы пишете трёхмерный инженерный CAD, и для преобразования данных в подсистеме графического рендеринга вам понадобился алгоритм триангуляции Делоне. Вы можете сами написать реализацию этого алгоритма, если вы достаточно самоуверенны и хорошо разбираетесь в математике. На жаргоне программистов такой подход будет называться «изобрести велосипед». Борис Делоне придумал этот алгоритм в 1934 году, и логично предположить, что в мире уже существует куча его реализаций. Вам нужно только покопаться в Интернете и найти для себя подходящую библиотеку. Подключив эту библиотеку к своему проекту, вы сможете провести триангуляцию Делоне в любом месте вашего кода, просто добавив одну строчку вызова библиотечной функции.
Популярные библиотеки создаются и поддерживаются сообществом компетентных разработчиков, которые умеют писать надёжный и оптимизированный код. Они активно применяются в разных проектах и в течение многих лет собирают фидбэк и реагируют на сообщения об ошибках, присылаемых пользователями. Скорее всего, вы не сможете сделать более производительную и более надёжную реализацию нужного алгоритма, чем та, которую вы найдёте в одной из этих библиотек.
Библиотеки можно писать на любых компилируемых языках или даже на ассемблере. С точки зрения программиста, процесс их разработки выглядит так же, как разработка обычных программ, только с другими настройками проекта.
Распространяются библиотеки так же, как и обычные программы — иногда бесплатно, иногда по лицензии. Бывают библиотеки, которые доступны в виде кода, а не в виде бинарных файлов — например, header-only библиотеки для C и C++.
Библиотеки обычно хранятся в общих каталогах, доступ к которым имеют все программы, запущенные в операционной системе. Когда несколько процессов используют одну и ту же библиотеку, эта библиотека загружается ровно 1 раз, и каждый новый запущенный процесс просто обращается к её машинному коду, который уже загружен в память. Кроме экономии памяти, такой подход приводит к экономии дискового пространства. Код библиотеки не нужно хранить в каждом исполняемом файле. Он хранится в одном экземпляре, а исполняемые файлы просто содержат упоминание этой библиотеки в списке зависимостей. Но есть проблема: пользователь не сможет запустить программу, если на его машине не установлены необходимые библиотеки. Простым решением будет положить библиотеку в один каталог с программой и распространять их вместе, либо вместо динамической использовать статическую библиотеку — файл .lib на Windows или .a на Linux и macOS — такая библиотека не подключается к программе после запуска, а встраивается в сам исполняемый файл при компиляции (точнее, при сборке). Размер исполняемого файла при этом увеличивается, но в большинстве современных систем это не является проблемой.
Интерфейс, через который программист взаимодействует с библиотекой, называется API (Application Programming Interface). В общем смысле, API — это интерфейс для общения между программными сущностями — библиотеками, приложениями и веб-сервисами. В качестве примера: в библиотеке User32.dll на Windows есть функция SetCursorPos, которая устанавливает курсор мыши в указанную позицию:
BOOL SetCursorPos(int X, int Y);
Её сигнатура состоит в том, что она принимает координаты точки на экране в виде 2 целочисленных аргументов, и возвращает true или false — в зависимости от успеха операции по установке курсора. В системных библиотеках Windows (Kernel32.dll, Ntdll.dll, Advapi32.dll и т. д.) существуют тысячи подобных функций для взаимодействия с ОС, которые все вместе составляют WinAPI. В других операционных системах тоже есть подобные API со своим набором библиотек и своими сигнатурами функций. Код, использующий этот API, может работать только в одной ОС, так что если вы хотите создавать кроссплатформенные продукты, то старайтесь избегать использования этих API напрямую — только через «посредников» в виде кросс-платформенных библиотек и фреймворков вроде Qt, которые предоставляют унифицированный API для доступа к функциям разных операционных систем.
По аналогии с API библиотеки, интерфейс взаимодействия с веб-сервисом через Интернет называется web-API. Как правило, API сервиса заключается в том, чтобы отправить одиночный HTTP-запрос с параметрами на определённый домен и получить от сервера HTTP-ответ, содержащий запрашиваемую информацию. По сути, это то же самое, что и API библиотеки, только гораздо медленнее и работает только при наличии Интернета. Поставщик веб-API может контролировать доступ к своим ресурсам и брать деньги за каждый запрос, так что веб-API — это ещё и выгодный бизнес. Если вам интересно больше узнать про HTTP протокол, то у меня есть статья под названием «Как работает Интернет», которая даёт базовое понимание этой темы. Но давайте вернёмся к библиотекам, поставляемым в виде бинарных файлов.
Разные языки программирования и компиляторы имеют разные соглашения о вызове функций, включая то, какие регистры при этом использовать, и какой должен быть формат данных для аргументов и возвращаемого значения. Такие соглашения называются ABI (Application Binary Interface). Если API определяет, как следует вызывать функции библиотеки на высокоуровневом языке программирования, то от ABI зависит, как эти вызовы будут реализованы в машинных инструкциях бинарного файла. Библиотеку, написанную на одном языке программирования, нельзя подключить к проекту на другом языке, даже если они оба собраны под один и тот же процессор и одну и ту же ОС — просто потому что у них разные ABI. Более того — библиотеку нельзя даже подключить к проекту на том же самом языке программирования, если проект собран другим компилятором, потому что разные компиляторы используют разные ABI. Исключением является язык C. Стандарт ABI языка C существует для каждой платформы и не привязан к компилятору, поэтому его можно использовать как общий стандарт вызова функций между проектами на разных языках. Например, при написании функции для библиотеки на C++, к ней можно добавить ключевое слово «extern C», чтобы компилятор использовал C ABI для этой функции при сборке файла библиотеки. Тогда её можно будет использовать в проекте на Rust, Go, C или в проекте на C++ с другим компилятором. К сожалению, интерфейс вызова функций в C неудобен и небезопасен, так как часто требует передачи «сырых» указателей на память. Из-за этого не все разработчики библиотек предоставляют интерфейс, совместимый с C.
Ваш выбор языка программирования во многом будет зависеть от наличия на этом языке библиотек и фреймворков под вашу задачу. И раз уж мы заговорили о фреймворках…
Фреймворк — это инструментарий для разработки приложений какого-то определённого типа. Он включает в себя набор разнопрофильных библиотек и иногда — дополнительные утилиты или даже среды разработки. Если библиотека позволяет вам вызывать её функции по вашему усмотрению, то фреймворк, напротив, вызывает ваши функции по своему усмотрению. При использовании библиотек, вы сами проектируете глобальную логику (архитектуру) вашего приложения, но при использовании фреймворка, архитектура в значительной степени определяется фреймворком. Работая с веб-фреймворком типа ASP.NET, вы пишете обработчик GET-запроса, а фреймворк создаёт приложение, которое вызывает этот обработчик в нужный момент. Если коротко, то приложение, созданное фреймворком, запускает веб-сервер, слушает порт, при поступлении запроса парсит его текст, проверяет токен авторизации, находит подходящий обработчик и вызывает его, передавая ему данные запроса в виде аргументов. Большую часть логики сервера вам вообще не нужно писать — она уже реализована фреймворком.
Когда вы создаёте приложение с графическим интерфейсом (GUI — Graphical User Interface), вы рисуете форму с интерактивными элементами (кнопками, полями для ввода, выпадающими списками и т. п.) в специальной программе для создания форм, которая поставляется в комплекте с GUI-фреймворком. Потом вы пишете функцию-обработчик для нажатия на одну из нарисованных вами кнопок, и фреймворк создаёт приложение, в котором эта функция вызывается каждый раз, когда пользователь нажимает на эту кнопку.
Слово «framework» на русский переводится как «каркас», и это очень хорошо отражает суть понятия.
Исполняемый файл или библиотеку, собранные под одну операционную систему, невозможно запустить в другой операционной системе, даже если обе ОС работают на одной и той же модели процессора. Оба файла будут состоять из одинаковых машинных инструкций, но компоновка файла, служебная информация и ABI будут отличаться. Кроме того, почти любая программная логика постоянно вызывает специфичные для ОС библиотеки и системные вызовы для открытия файлов, работы с сетью и других задач. Интерфейс этих инструментов у разных операционных систем сильно отличается. Чтобы запустить вашу программу в другой ОС, вы должны её полностью перекомпилировать под эту ОС. Простая перекомпоновка бинарника здесь не поможет.
Не существует инструмента для автоматического переноса проекта на другую ОС, хотя кроссплатформенные фреймворки и библиотеки сильно облегчают эту задачу. Для языков, компилируемых в машинный код, поддержание версий для разных ОС требует отдельной компиляции, а зачастую — ещё и ручной работы с кодом.
Есть языки, у которых эта проблема практически отсутствует — и это следующая большая тема моего повествования. Их секрет в том, что они создают прослойку в виде виртуальной машины между исполняемым кодом и системой, на которой он работает. Код на этих языках компилируется не в машинные инструкции реального процессора, а в байт-код виртуальной машины. Сама виртуальная машина работает на обычном процессоре, поэтому она состоит из настоящих машинных инструкций (т. е. из нативного машинного кода), и её разработка ведётся на обычном компилируемом языке (как правило, на C/C++). Она компилируется отдельно под каждую платформу (процессор+ОС), но бинарники, состоящие из её байт-кода, без проблем запускаются на любой платформе, где стоит эта машина.
По крайней мере, в этом заключается идея «write once, run anywhere», сформулированная разработчиками Java. Но на практике есть более новые и более старые версии виртуальной машины, а также немного отличающиеся реализации виртуальных машин от разных поставщиков. Иногда приходится использовать нативные библиотеки или библиотеки, которые давно не обновлялись и не поддерживаются новыми версиями виртуальной машины. Но в целом, разрабатывать мультиплатформенные проекты на таких языках легче, чем на языках с компиляцией в нативный код.
Виртуальная машина языка C# называется CLR (Common Language Runtime). В её байт-код компилируется сам C#, а также F# и Visual Basic. Виртуальная машина Java называется JVM (Java Virtual Machine), и, кроме джавы, в её байт-код компилируются Clojure, Kotlin, Scala и ещё 8 языков. Библиотеки, написанные на одном из этих языков, можно использовать в проектах на других языках, потому что они все компилируются в байт-код одной и той же виртуальной машины. Нативные библиотеки в них тоже можно использовать, но это требует дополнительных инструментов и усложняет переносимость на другие платформы.
Языки программирования с компиляцией в байт-код появились в 1990-х. Их преимуществом была простота программирования без необходимости управления памятью. В C++ в то время не было «умных указателей», и неаккуратное использование аллокаций легко могло привести к утечкам памяти. Чтобы объяснить, о чём идёт речь, мне придётся рассказать про стек и кучу.

Стек — это память приложения (точнее, потока, но сейчас это не важно), в которой располагаются локальные данные вызванных функций. Стек работает одинаково на всех популярных языках, но давайте я расскажу о нём на примере C. Выполнение программы на C начинается с функции main. Значит, при старте программы, на стеке для неё выделяется «стек фрейм» («стековый кадр»). Допустим, что функция main вызвала функцию f1, а функция f1 вызвала функцию f2. Значит, когда поток выполнения зашёл в функцию f1, то в конец стека, прямо за стек фреймом функции main, был добавлен фрейм для функции f1. А когда поток из f1 зашёл в функцию f2, после фрейма f1 добавился ещё и фрейм для f2. При выходе из функции последний фрейм удаляется, и восстанавливается состояние предыдущей функции.
Стековый кадр, или стек фрейм — это кусок памяти достаточного размера, чтобы хранить все данные для работы функции:
локальные переменные и аргументы (те, которые не попали в регистры)
состояние регистров процессора, которое было до входа в функцию, и которое нужно восстановить при выходе из неё
адрес возврата (т. е. адрес машинной инструкции, на которую процессор должен перейти при выходе из функции).
Стековые кадры располагаются в памяти друг за другом. Управление стеком осуществляется с помощью указателя стека (Stack Pointer, или SP), который хранится в специальном регистре процессора (вы могли заметить его на гифке сверху). При запуске программы, ОС выделяет ей диапазон адресов виртуальной памяти, в котором она может располагать свой стек. В этот момент указатель стека указывает на начало выделенного диапазона. При входе в функцию, новый кадр добавляется в конец стека и располагается в памяти прямо за предыдущим кадром. Это значит, что указатель стека просто увеличивается на размер добавленного фрейма (точнее, уменьшается, потому что стек растёт вниз, а не вверх). При выходе из функции последний кадр освобождается — т. е. восстанавливается старое значение указателя стека, сохранённое в стек фрейме или в регистре.
Диапазон виртуальных адресов, выделяемый под стек, имеет фиксированный размер, который зависит от настроек операционной системы. Приложению не выделяется сразу весь объём физической памяти размером с этот диапазон. Память выделяется постранично, то есть новые блоки физической памяти выделяются при росте стека, по мере необходимости. Если ваша программа делает слишком много вложенных вызовов функций, то стек разрастается до границ выделенного диапазона, и возникает переполнение стека (stack overflow) — ошибка, которая приводит к остановке программы. Stack overflow происходит с теми, кто слишком сильно увлекается рекурсией, либо использует локальные переменные большого размера, что увеличивает размер фрейма. В других случаях достичь переполнения стека довольно сложно.
Чтобы хранить данные в стеке, их размер должен быть известен как минимум перед созданием фрейма. Если вам нужна структура переменного размера — строка, список или словарь — то вы можете обратиться к системной библиотеке и попросить её выделить нужный объём памяти из кучи (heap). Это то, что под капотом происходит при вызове malloc на C или оператора new на C++. Управление памятью в куче даётся системе сложнее, чем управление стеком, потому что в куче память может непредсказуемо выделяться и освобождаться в любом месте выделенных диапазонов, из-за чего возникает фрагментация памяти. В стеке действует простое правило LIFO (last in, first out — «последним зашёл, первым вышел»). Выделение и освобождение памяти всегда происходит в конце стека, данные всегда плотно упакованы и менее подвержены кэш миссам, а логика управления памятью заключается в элементарном смещении границы стека (SP) вверх и вниз. Из-за сложности управления кучей, запрос на выделение памяти в ней считается дорогостоящей операцией и может занимать сотни циклов процессора, из-за чего некоторые C++ программисты пишут свои аллокаторы памяти, чтобы избежать таких потерь. Обычно это происходит при разработке игровых движков и других систем, из которых нужно выжимать максимум производительности.
Если в языке программирования не реализована какая-то автоматизация управления кучей, то задача запрашивать освобождение памяти ложится на программиста. В C и старых версиях C++ программисту довольно легко забыть освободить память, которую он больше не использует. Он может вообще потерять указатели на эту память и таким образом потерять к ней доступ, но память всё равно остаётся закреплённой за программой, и системная библиотека не может выделить её какому-то другому процессу. Это и есть утечка памяти. Чем дольше такая программа работает, тем больше она накапливает утечек, и в конце концов, она начинает потреблять столько памяти, что вся система начинает тормозить. В такой ситуации помогает перезапуск программы, потому что закрытие процесса освобождает всю выделенную для него память.
Для избавления от утечек, в языках с байт-кодом появилась система под названием «Garbage collector» («Сборщик мусора»), которая периодически проходит по всем данным приложения, вычисляет, какие из них уже не используются, и возвращает системе занятую ими память. Из-за этого программист уже не может контролировать, какие объекты размещаются в куче, а какие на стеке.
При выполнении программы виртуальная машина «на лету» интерпретирует байт-код и формирует машинные инструкции, которые делают фактическую работу по выполнению этого байт-кода. Это ухудшает производительность по сравнению с языками, компилируемыми в нативный код, которые не делают дополнительной работы по интерпретации. Вдобавок, виртуальная машина периодически запускает сборку мусора, из-за чего приложение испытывает кратковременные просадки производительности или даже зависания. В C и C++ память освобождается тогда, когда этого требует логика программы — как правило, без необходимости в счетчиках ссылок или других методах слежения за использованием памяти, которые бы потребляли ресурсы процессора и замедляли бы исполнение кода.
Проклятием программистов C и C++ является неопределённое поведение (undefined behavior). Оно возникает, когда программист допускает ошибку вроде разыменования невалидного указателя, целочисленного переполнения знакового типа или использования неинициализированной переменной. Неопределённое поведение, как нетрудно догадаться, означает, что программа может делать всё что угодно. Например, она может работать нормально (потому что нормальная работа — это частный случай неопределённого поведения), но со следующим обновлением компилятора начать выдавать неправильные результаты или просто аварийно завершаться. Компилятор имеет право считать, что в коде нет ошибок, вызывающих неопределённое поведение — это развязывает ему руки и позволяет делать оптимизации, которые в противном случае были бы невозможны. Но если неопределённое поведение в коде всё-таки есть, то эти оптимизации начинают приводить к неожиданным результатам. В других языках тоже встречается поведение, зависящее от реализации, но в основном они действуют предсказуемо и облегчают создание безопасных и надёжных приложений. C и C++ имеют славу инструментов, требующих аккуратного обращения и жестоко наказывающих за ошибки. Чтобы находить эти ошибки на ранних этапах, рекомендуется использовать статические анализаторы и санитайзеры, а также добросовестно исправлять все предупреждения компилятора.
Чтобы запустить программу на Java или C#, нужно предварительно установить виртуальную машину определённой версии. Это не всегда удобно для пользователей, поэтому почти каждый язык с байт-кодом умеет упаковывать исполняемый файл и виртуальную машину в один бинарник, чтобы их можно было запустить на целевой системе «из коробки» без установки каких-либо служебных пакетов. Естественно, такой бинарь не будет переносимым — его можно будет запустить только на конкретной платформе. Ещё в таких языках есть опция ahead-of-time (AOT) компиляции, когда текст программы компилируется не в байт-код виртуальной машины, а в машинный код реального процессора. В результате получается такой же нативный исполняемый файл, как в каком-нибудь C++.
Если виртуальная машина может «на лету» интерпретировать и выполнять байт-код, то почему бы не пойти дальше и не сделать машину, которая «на лету» интерпретирует и выполняет обычный код, написанный на высокоуровневом языке программирования? Эта идея может показаться странной, но она стояла у истоков таких известных языков, как Python, JavaScript, PHP, Ruby и Lua. Такие языки относятся к типу интерпретируемых языков. Наверное, главной причиной появления их в таком виде было то, что реализацию интерпретатора сделать проще, чем реализацию компилятора, т. к. это не требует перевода высокоуровневой логики на довольно ограниченный язык ассемблера. К тому же, один раз написать интерпретатор и скомпилировать его для нескольких аппаратных платформ — занимает меньше времени, чем написать несколько компиляторов — по одному для каждой платформы. Небольшой команде разработчиков было проще создать язык с интерпретатором, чтобы быстро реализовать свои идеи и вывести его на рынок. Это было важно для исследовательских проектов и экспериментальных языков, предлагавших какие-то новые подходы к программированию. Примерно такой была история Python. Интерпретируемые языки изначально предоставляли более лёгкий стиль программирования: чтобы начать работу с Python, не нужно создавать проект и указывать путь для скомпилированных бинарников — просто пишем код, сохраняем его в файл, и можно запускать. Позднее Python обзавёлся своей виртуальной машиной и стал компилироваться в байт-код, но стиль работы с ним остался прежним.
В Python невозможно целочисленное переполнение, потому что где-то в глубине интерпретатора целое число представляется расширяемым массивом из 30-битных разрядов и может принимать любые значения, ограниченные только памятью системы. Питон вообще позиционируется как язык, не требующий глубоких знаний об устройстве компьютера, и это один из факторов его огромной популярности.
Особый шарм Питону придаёт так называемая «динамическая типизация», при которой объекты не имеют чётко определённых типов, а функции не имеют чётко определённых сигнатур. Любая переменная может иметь любой тип — от чисел и строк до структур и массивов. Это до какой-то степени упрощает код, но создаёт трудности при разработке сложных и больших проектов. В языках со статической типизацией, если вы попытаетесь поделить число на строку, то компилятор сразу сообщит вам об этой ошибке, и вы сможете быстро её исправить. В Питоне вы узнаете об этой ошибке во время тестирования, либо непосредственно от недовольных клиентов, у которых приложение будет падать по какой-то неясной причине. Интерпретатор Питона вынужден проверять типы аргументов во время каждой операции. Это замедляет работу программы и является одной из причин, по которым Питон не используется для создания быстрых и производительных систем.
В языке со статической типизацией, если вы попытаетесь передать в функцию аргументы неправильных типов, то ваша программа просто не скомпилируется. Правильные типы аргументов для функции на Питоне вы можете узнать либо из документации (в надежде, что документация верна и актуальна), либо из кода этих функций (но вы не хотите лишний раз читать чужой код). Гарантии от компилятора делают разработку намного проще.
В языках с нормальной, вменяемой типизацией, логика вашей программы держится на твёрдых контрактах. В языках с динамической типизацией она держится на честном слове и на русском авосе. Разработчики Python осознали эту проблему и, начиная с версии 3.5, ввели в язык подсказки типов (type hints), которые позволяют документировать типы объектов, чтобы статические анализаторы могли выявлять такие ошибки без необходимости запускать код.
В середине 90-х разработчики веб-браузеров захотели оживить интерфейс сайтов и перенести часть логики на сторону клиента, чтобы интернет меньше тормозил. Для этого они создали JavaScript — ��нтерпретируемый язык программирования для браузеров. Основным требованием к нему была простота освоения для людей без технических навыков (например, для веб-дизайнеров). Скрипты на этом языке хранятся или генерируются на серверах, а исполняются интерпретатором интернет-браузера, когда пользователь заходит на сайт. В условиях ограниченного времени, разработчики из Netscape не придумали ничего лучше, чем сделать интерпретируемый скриптовый язык. Через полтора десятка лет браузеры начали компилировать его в специальный браузерный байт-код, чтобы повысить производительность скриптов.
JavaScript — это язык с динамической типизацией, как и Python. В нём нет аналога type hints, но зато в 2012 появилось расширение этого языка под названием TypeScript, в котором добавилась статическая типизация и ещё несколько синтаксических улучшений. Код TypeScript компилируется в код JavaScript, поэтому на нём можно писать скрипты для любых браузеров, и при этом не страдать от проблем динамической типизации.
Python и JavaScript создавались для того, чтобы быть простыми. Естественно, что в них реализована сборка мусора, и их пользователям не приходится задумываться об управлении памятью.

Один из критериев выбора языка программирования — это отношение производительности программ к производительности разработчика. Написать проект на C++ — это долго и сложно, а на изучение C++ уходит больше времени, чем на изучение других языков. Зато он открывает широкие возможности для оптимизаций производительности. На нём пишут тогда, когда убытки от медленного и неоптимизированного кода превышают расходы на зарплату программистов. На Python программировать легко и просто, но нельзя использовать его там, где требуется оптимизированный, высокопроизводительный код.
Если вам нужно написать скрипт для анализа корпоративной статистики и подготовки отчётов, который будет запускаться раз в неделю, то совершенно безразлично, будет он выполняться 5 миллисекунд или 500 миллисекунд — вы всё равно никакой разницы не почувствуете. Чтобы сэкономить своё время, такой проект лучше сделать на Python.
К проектам, требующим высокой производительности, относятся высоконагруженные веб-сервисы, в которых даже 1% ускорения означает кругленькую сумму, сэкономленную на датацентрах. Критические элементы таких сервисов следует писать на C, C++ или Rust.
Параллелизм
Примерно с середины 60-х до середины 2000-х скорость одноядерных процессоров увеличивалась по экспоненте, согласно эмпирическому «закону Мура», и программисты могли рассчитывать, что быстродействие их программ со временем будет расти независимо от их усилий. Но потом производители процессоров дошли до предела скорости одного ядра, и для дальнейшего ускорения начали делать процессоры с несколькими ядрами. Для программистов это означало, что если они всё ещё хотят писать быстрые современные программы, то им придётся усложнять логическую структуру своего кода, разделяя его на несколько потоков. Многопоточная программа — это что-то вроде нескольких самостоятельных программ, работающих одновременно в составе одного процесса. У каждого потока есть свой стек, но куча у них одна на всех. Хорошо, когда потоки друг от друга не зависят и не требуют доступа к одним и тем же данным. В противном случае, управление потоками превращается в дирижирование оркестром.

Если два потока одновременно обращаются к одной и той же ячейке памяти, и хотя бы один из них делает это с целью записи, а не чтения, то результат будет совершенно непредсказуемым. Это называется data race — состояние гонки за данными. Скорость выполнения разных потоков не детерми��ирована, поэтому появление гонки зависит от случая. Вы можете 1000 раз запустить один и тот же код и не поймать состояние гонки, но на 1001 запуск у вас появятся непонятные глюки, и нужно будет потратить усилия, чтобы разобраться, что это именно гонка.
Иногда одному потоку для продолжения работы нужны будут данные, которые готовит другой поток. Если они крутятся на разных ядрах, то одно ядро будет простаивать без дела в ожидании этих данных, и вам нужно будет придумать, чем его занять на это время. Вы должны следить за тем, чтобы ядра процессора постоянно выполняли полезную работу, иначе толку от многопоточности будет немного.
Если два потока работают с данными, близко расположенными в памяти, то такая многопоточность будет замедлять программу, вместо того чтобы ускорять её. Дело в том, что данные между кэшами, как я уже писал, передаются кэш-линиями по 64 байта. Если потоки работают с разными данными, но эти данные входят в одну кэш линию, то потоки постоянно стоят в очереди за этой линией и ожидают синхронизации кэшей. Такое состояние называется false sharing.
Процессор и компилятор могут переупорядочивать инструкции ради производительности, но они гарантируют, что эти перестановки не нарушают корректность однопоточной программы. При доступе к атомикам нужно помнить, что порядок изменения переменных в разных потоках может различаться, и иногда это может ломать логику приложения. Чтобы обеспечить необходимый порядок операций, нужно использовать разные виды барьеров памяти.
Если вы не хотите держать в голове все эти нюансы (а вы не хотите), то лучшее, что вы можете сделать — это вообще избежать многопоточного программирования. Запустите несколько инстансов вашего приложения на одной машине, и пускай они обрабатывают разные потоки данных — это будет ничем не хуже одного многопоточного процесса. Обрабатывая данные в несколько потоков, разделите их так, чтобы потоки друг с другом не контактировали и обращались к разным областям памяти, которые не cближаются на расстояние меньше одной кэш-линии — во избежание false sharing.
В приложениях с графическим интерфейсом (GUI) вам придётся выполнять задачи в разных потоках, чтобы обеспечить непрерывный отклик на действия пользователя. Вы не хотите, чтобы окно приложения зависало при загрузке файла или при долгих вычислениях. Вместо этого, вы хотите запустить эти задачи в фоновых потоках, а пользователю показать полосу прогресса и кнопку отмены, которая позволяет прервать операцию. Связь между потоками в этом случае довольно простая: фоновый поток сообщает потоку GUI процент выполненной работы, а поток GUI передаёт рабочему потоку флаг остановки, когда пользователь нажимает на отмену.
Если у вашего процессора 4 ядра, значит, вы можете запустить в своей программе 4 потока и в пределе увеличить её производительность в 4 раза. Но вообще в такой системе можно создать тысячи потоков, и более того — программисты могли создавать потоки ещё до появления многоядерных процессоров. Как такое возможно? Дело в том, что с помощью механизма контекстных переключений несколько потоков могут работать на одном ядре. ОС выделяет потоку квант времени, даёт ему немного поработать, потом сохраняет его состояние (регистры и стек), отбирает у него управление и загружает состояние другого потока. Затем для другого потока повторяется то же самое. Такая многопоточность не ускоряет, а замедляет работу компьютера, потому что переключения контекста занимают время. Но зато вы можете делать фоновые задачи без зависания интерфейса. И вообще, без контекстных переключений было бы невозможно одновременное выполнение нескольких программ на одном компьютере.
В современных процессорах реализована концепция SMT (Simultaneous multithreading). Когда в характеристиках процессора пишут что-то вроде «6 ядер, 12 потоков», это значит, что на одном ядре можно запускать сразу два аппаратных потока, и для операционной системы это представляется как наличие не одного, а двух логических ядер. У каждого логического ядра есть свой набор регистров и свой контроллер прерываний. Остальные элементы физического ядра для них остаются общими.
В работе каждого процессора случаются вынужденные простои — в ожидании разрешения кэш мисса, неверно предсказанного ветвления или в ожидании результата предыдущей инструкции. Основная идея логических ядер в том, что пока один поток простаивает, второй может работать, таким образом повышая утилизацию вычислительных ресурсов процессора. Пока одно логическое ядро считает целочисленную арифметику на арифметико-логическом устройстве (ALU), другое может делать вычисления с плавающей точкой на математическом сопроцессоре (FPU) (ALU и FPU — это компоненты одного физического ядра, предназначенные для разных видов инструкций). При правильном использовании этой технологии, ускорение по сравнению с однопоточным выполнением достигает десятков процентов.
Ещё один метод параллельных вычислений — это применение инструкций SIMD (Single Instruction, Multiple Data — Одна инструкция, Множество данных). SIMD-расширения добавляют в процессор векторные регистры размером 128, 256 или 512 бит, в любой из которых можно положить сразу несколько чисел — например, в 256-битный регистр поместится 8 32-битных или 4 64-битных числа. Все эти числа можно поэлементно просуммировать одной инструкцией — а также умножить, поделить, вычислить квадратный корень и так далее. Если мы складываем 4 пары чисел в векторных регистрах, то это даёт ускорение до 4 раз по сравнению с обычной арифметикой (у которой, очевидно, на это уходит 4 инструкции, а не одна).
Пользоваться векторными инструкциями можно напрямую, вставляя в код векторные интринсики — специальные функции, соответствующие машинным инструкциям конкретного процессора. Некоторым разработчикам иногда реально приходится это делать, но большинство либо используют библиотеки для векторных расчётов, либо просто пишут код и надеются, что компилятор сам добавит векторные инструкции туда, где это уместно. Но надо уметь писать код так, чтобы компилятор мог его векторизовать. Сложные циклы, содержащие условные переходы, для этого не подходят.
Если вы хотите выполнять тысячи потоков параллельно (а не последовательно, через контекстные переключения), то вам нужно проводить вычисления не на центральном процессоре, а на графическом ускорителе (или «видеокарте», хотя это не совсем синонимы). Видеокарты изначально созда��ались для рендеринга компьютерной графики — т. е. для быстрой отрисовки трёхмерной сцены на экране. Эта задача связана с обработкой большого количества геометрических данных, таких как трёхмерные координаты вершин полигонов и файлы текстур. Каждую вершину нужно перевести из пространства виртуальной сцены в систему координат экрана, и для этого в графическом конвейере нужно перемножить несколько матриц и один короткий вектор. В компьютерных играх это нужно делать для всех вершин по одному разу на каждый кадр, то есть примерно 60 раз в секунду. Вычисление каждой вершины происходит независимо и не требует доступа к данным других вершин, поэтому такие расчёты можно легко и неограниченно распараллеливать. Из-за потребности в массовом параллелизме, количество ядер в графических процессорах измеряется тысячами. После вычисления координат, необходимо рассчитать цвет каждого пикселя, с учётом текстур, геометрии и освещения. Существует множество техник расчёта цветов и геометрических преобразований для создания разных визуальных эффектов, поэтому производители графических чипов сделали их программируемыми, чтобы игровые разработчики могли менять логику рендеринга в меру своей фантазии. Программа, исполняемая в графическом конвейере видеокарты, называется шейдером. Для примера — вы можете написать фрагментный шейдер, который вычисляет цвет одного пикселя, а при запуске полноэкранной отрисовки на мониторе с разрешением 1920х1080, графический процессор будет выполнять этот шейдер 2 миллиона раз за каждый кадр рендеринга, потому что на экране 2 миллиона пикселей (1920х1080 = 2 073 600). При этом несколько тысяч ядер будут одновременно выполнять по несколько тысяч шейдеров.
Для компиляции и загрузки шейдеров, передачи данных на видеокарту и запуска операций рендеринга, можно использовать набор библиотек, реализующих один из распространённых графических API, таких как Direct3D (графическая часть DirectX), Metal, Vulkan и OpenGL.
Центральный процессор (CPU) состоит из нескольких мощных универсальных ядер, выполняющих инструкции быстро и с минимальной задержкой. Такие процессоры проектируются для оптимизации последовательных, а не параллельных вычислений. Графический процессор (GPU — т. е. центральная часть видеокарты) состоит из тысяч слабых ядер и показывает более продолжительную задержку при исполнении запросов, но в определённых задачах сильно выигрывает в производительности за счёт параллелизма. Он спроектирован для оптимизации не просто параллельных, а одинаковых параллельных вычислений, то есть для выполнения одной и той же программы на множестве ядер. Ядра сгруппированы в вычислительные модули, в каждом из которых используется один общий program counter на всех, поэтому потоки в пределах модуля работают синхронно и выполняют одни и те же инструкции на разных данных. Из-за этого в программах для GPU не очень выгодно использовать ветвление (if/else).
Как вы помните, вещественные числа бывают одинарной и двойной точности, и представляются, соответственно, 32-битными и 64-битными типами чисел с плавающей точкой. Для компьютерной графики обычно хватает одинарной точности, поэтому операции над 64-битными числами на GPU выполняются медленнее. На специализированных ускорителях для научных вычислений они выполняются в разы медленнее, а на обычных видеокартах — в десятки раз.
Некоторые видеокарты медленнее работают с целыми числами, чем с вещественными, поскольку имеют в своём составе меньше электронных компонентов для целочисленных вычислений. Это происходит из того, что целочисленные операции в шейдерах встречаются реже, чем операции с плавающей точкой.
Успех видеоигровой индустрии привёл к массовому распространению видеокарт, и в какой-то момент стало понятно, что их можно использовать и для других вычислительных задач — например, для фото- и видеомонтажа, научных расчётов, майнинга криптовалют и машинного обучения. Эти расчёты тоже можно программировать с помощью графических API, но чаще для этого используются универсальные вычислительные API вроде CUDA и OpenCL. Если ваша задача допускает массовое распараллеливание, то стоит задуматься о переносе вычислений на графический ускоритель.
Комментарии (5)

lws0954
09.11.2025 16:26На мой взгляд Вы в параллелизме разбираетесь столь же глубоко, как и в социализме (я прочитал Вашу статью по этой теме). Возможно, я ошибаюсь. Но ... возьмем экспериментально. Если бы было так, как Вы представляете, то капиталистическая Россия по определению должна быть эффективнее СССР, а Китай много неэффективнее США. Или Вы серьезно считаете, что описанный Вами "капиталистический параллелизм" - это верх эффективности?
Litemanager_remoteadmin
Статья полезна будет не только для начинающих, но и для опытного программиста, спасибо большое