И всё же C — низкоуровневый язык
За прошедшие с момента появления языка C десятилетия было создано множество интереснейших языков программирования. Какие-то из них используются до сих пор, другие — повлияли на следующие поколения языков, популярность третьих тихо сошла на нет. Между тем архаичный, противоречивый, примитивный, сделанный в худших традициях своего поколения языков C (и его наследники) живее всех живых.
Критика C — классический для нашей индустрии эпистолярный жанр. Она звучит то громче, то тише, но в последнее время буквально оглушает. Пример — перевод статьи Дэвида Чизнэлла «C — не низкоуровневый язык», опубликованный в нашем блоге некоторое время назад. Про C можно говорить разное, в дизайне языка действительно много неприятных ошибок, но отказывать C в «низкоуровневости» — это уже слишком!
Чтобы не терпеть такую несправедливость, я собрался с духом и постарался определиться с тем, что есть язык программирования низкого уровня и чего хотят от него практики, после чего перебрал аргументы критиков C. Так получилась эта статья.
Содержание
- Аргументы критиков C
- Язык программирования низкого уровня
- Дело не только в PDP-11
- Оптимизирующие компиляторы и язык низкого уровня
- Стандарт как безусловное благо
- Альтернативные архитектуры — удел специальных вычислений
- С будет жить
Аргументы критиков C
Вот некоторые аргументы критиков С, перечисленные в том числе и в статье Дэвида Чизнэлла:
- Абстрактная машина языка C слишком похожа на устаревшую архитектуру PDP-11, которая давно уже не соответствует устройству популярных современных процессоров.
- Несоответствие абстрактной машины C устройству реальных машин усложняет разработку оптимизирующих компиляторов языка.
- Неполнота и сложность стандарта языка ведут к разночтениям в реализациях стандарта.
- Доминирование C-подобных языков не позволяет исследовать альтернативные архитектуры процессоров.
Давайте для начала определимся с требованиями к низкоуровневому языку, после чего вернёмся к приведённым аргументам.
Язык программирования низкого уровня
Общепризнанного определения языка низкого уровня не существует. Но перед обсуждением спорных моментов желательно иметь хоть какие-то исходные требования к предмету спора.
Никто не станет спорить с тем, что язык ассемблера находится на самом низком уровне. Но на каждой платформе он уникален, поэтому код на таком языке не может быть переносимым. Даже на обратно совместимой платформе может потребоваться задействовать какие-то новые инструкции.
Отсюда следует первое требование к языку низкого уровня: он должен сохранять общие для популярных платформ черты. Проще говоря, компилятор должен быть портируемым. Портируемость компилятора упрощает разработку компиляторов языка для новых платформ, а многообразие поддерживаемых компиляторами платформ избавляет разработчиков от необходимости переписывать прикладные программы для каждой новой машины.
С первым требованием конфликтуют пожелания разработчиков специальных программ: языков программирования, драйверов, операционных систем и высокопроизводительных баз данных. Программисты, пишущие эти программы, хотят иметь возможности ручной оптимизации, прямой работы с памятью и так далее. Словом, низкоуровневый язык должен позволять работать с деталями реализации платформы.
Поиск баланса между этими двумя требованиями — выделение общих для платформ аспектов и доступ к по возможности большему числу деталей — фундаментальная причина сложности разработки языка низкого уровня.
Заметьте, что для такого языка не так важны высокоуровневые абстракции, — для него важнее служить контрактом между платформой, компилятором и разработчиком. А если есть контракт, то возникает необходимость в независимом от конкретной реализации стандарте языке.
Наше первое требование — общие для целевых платформ черты — выражается в абстрактной машине языка, поэтому с неё мы и начнём обсуждение C.
Дело не только в PDP-11
Платформа, на которой появился язык C, — PDP-11. В её основе лежит традиционная архитектура фон Неймана, в которой программы выполняются последовательно центральным процессором, а память представляет собой плоскую ленту, где хранятся и данные, и сами программы. Такая архитектура легко реализуется в железе, и со временем все компьютеры общего назначения стали использовать именно её.
Современные усовершенствования архитектуры фон Неймана направлены на устранение её главного узкого места — задержек при обмене данными между процессором и памятью (англ. von Neuman bottleneck). Разница в производительности памяти и центрального процессора привела к появлению кеширующих подсистем процессоров (одноуровневых и позже — многоуровневых).
Но даже кешей в наши дни уже недостаточно. Современные процессоры стали суперскалярными (англ. superscalar). Задержки при получении инструкциями данных из памяти частично компенсируются внеочередным выполнением (англ. instruction-level parallelism) инструкций вкупе с предсказателем ветвлений (англ. branch predictor).
Последовательная абстрактная машина C (и многих других языков) имитирует работу не столько конкретно PDP-11, сколько любых компьютеров, устроенных по принципу архитектуры фон Неймана. К нему относятся архитектуры, построенные вокруг процессоров с единственным ядром: настольные и серверные x86, мобильные ARM, сходящие со сцены Sun/Oracle SPARC и IBM POWER.
Со временем в один процессор стали интегрировать несколько вычислительных ядер, в результате чего появилась необходимость поддерживать когерентность кешей каждого ядра и потребовались протоколы межъядерного взаимодействия. Архитектура фон Неймана, таким образом, была масштабирована на несколько ядер.
Изначальный вариант абстрактной машины С был последовательным, никак не отражая наличие взаимодействующих через память потоков исполнения программ. Появление в стандарте модели памяти расширило возможности абстрактной машины до параллельной.
Таким образом, утверждение о том, что абстрактная машина C давно не соответствует устройству современных процессоров, касается не столько конкретного языка, сколько компьютеров, использующих архитектуру фон Неймана, в том числе и в параллельном исполнении.
Но как практик хочу отметить следующее: можно считать, что фоннеймановский подход устарел, можно считать, что он актуален, но это никак не отменяет того факта, что популярные сегодня архитектуры общего назначения используют производные от традиционного подходы.
Стандартизированное и переносимое воплощение архитектуры фон Неймана — абстрактная машина C — удобно реализуется на всех основных платформах и поэтому пользуется своей популярностью как портативного ассемблера вполне заслуженно.
Оптимизирующие компиляторы и язык низкого уровня
Наше второе требование к языку низкого уровня — доступ к низкоуровневым деталям реализации каждой из популярной платформ. В случае C это непосредственная работа с памятью и объектами в ней как с массивом байтов, возможность напрямую работать с адресами байтов и развитая арифметика указателей.
Критики C указывают на то, что в стандарте языка даётся слишком много гарантий касательно, например, расположения отдельных полей в структурах и объединениях. Вместе с указателями и примитивными механизмами циклов это усложняет работу оптимизатора.
Действительно, более декларативный подход позволил бы компилятору самостоятельно решать проблемы выравнивания данных в памяти или оптимального порядка полей в структурах; а высокоуровневые циклы дают свободу, необходимую при векторизации.
Позиция разработчиков C в данном случае такова: низкоуровневый язык должен позволять работать на уровне, достаточно низком для самостоятельного решения программистом задач оптимизации. В рамках C возможно поработать компилятором, выбрав, к примеру, инструкции SIMD и правильно разместив данные в памяти.
Другими словами, наше требование доступа к деталям реализации каждой из платформ вступает в конфликт с пожеланиями разработчиков оптимизирующих компиляторов именно в силу наличия низкоуровневых инструментов.
Интересно, что Чизнэлл в статье под названием «C — не низкоуровневый язык» парадоксально утверждает, что C — слишком низкоуровневый, указывая на отсутствие в нём высокоуровневых инструментов. Но практикам бывают нужны именно низкоуровневые инструменты, иначе язык не получится использовать для разработки операционных систем и других низкоуровневых программ, то есть он не будет удовлетворять второму из наших требований.
Отвлекаясь от описания проблем оптимизации именно C, хочу заметить, что в настоящий момент в оптимизирующие компиляторы высокоуровневых языков (тех же C# и Java) вложено не меньше усилий, чем в GCC или Clang. У функциональных языков тоже хватает эффективных компиляторов: MLTon, OCaml и другие. Но разработчики того же OCaml пока могут похвастаться производительностью в лучшем случае в половину скорости кода на C…
Стандарт как безусловное благо
Чизнэлл приводит в своей статье ссылку на результаты опроса, проведённого в 2015 году: многие программисты допускали ошибки в решении задач на понимание стандартов C.
Полагаю, что кто-то из читателей имел дело со стандартом C. У меня версия C99 есть в бумажном виде, страниц эдак на 900. Это не лаконичная спецификация Scheme объёмом меньше 100 страниц и не вылизанный Standard ML, состоящий из 300. Удовольствие от работы со стандартом C не получает никто: ни разработчики компиляторов, ни разработчики документа, ни программисты.
Но надо понимать, что стандарт C разрабатывался постфактум, уже после появления множества «почти-еле-только местами» совместимых диалектов. Авторы ANSI C проделали огромную работу, обобщив существующие реализации и прикрыв бесчисленными «костылями» неортогональности в дизайне языка.
Может показаться странным, что такой документ вообще кто-то взялся реализовывать. Но C был реализован множеством компиляторов. Я не буду пересказывать чужие байки о зоопарке мира UNIX конца 80-х, тем более что сам в то время считал не слишком уверенно и только до пяти. Но, очевидно, стандарт был всем в индустрии действительно нужен.
Прекрасно то, что он есть и реализован по меньшей мере тремя крупными компиляторами и множеством компиляторов поменьше, в совокупности поддерживающими сотни платформ. Ни один из языков — конкурентов C, претендующих на корону короля языков низкого уровня, не может похвастаться таким многообразием и универсальностью.
На самом деле современный стандарт C не так уж и плох. Более-менее опытный программист способен разработать неоптимизирующий компилятор C в разумные сроки, что подтверждается существованием множества полулюбительских реализаций (тех же TCC, LCC и 8cc).
Наличие общепринятого стандарта означает, что C удовлетворяет последнему из наших требований к языку низкого уровня: этот язык строится от спецификации, а не конкретной реализации.
Альтернативные архитектуры — удел специальных вычислений
Но Чизнэлл приводит ещё один аргумент, возвращаясь к устройству современных процессоров общего назначения, реализующих варианты архитектуры фон Неймана. Он утверждает, что имеет смысл изменить принципы работы центрального процессора. Повторюсь, что эта критика касается не конкретно C, а самой базовой модели императивного программирования.
Действительно, существует множество альтернатив традиционному подходу с последовательным исполнением программ: модели SIMD в стиле GPU, модели в стиле абстрактной машины Erlang и другие. Но каждый из этих подходов имеет ограниченную применимость при использовании в центральном процессоре.
GPU, например, замечательно перемножают матрицы в играх и машинном обучении, но их сложно использовать для трассировки лучей. Другими словами, эта модель подходит для специализированных ускорителей, но не работает для процессоров общего назначения.
Erlang прекрасно работает в кластере, но эффективную quick sort или быструю хеш-таблицу на нём сделать трудно. Модель независимых акторов лучше использовать на более высоком уровне, в большом кластере, где каждый узел — всё та же высокопроизводительная машина с традиционным процессором.
Между тем, современные x86-совместимые процессоры давно уже включают в себя наборы векторных инструкций, схожие с GPU по назначению и принципам работы, но сохраняющие общую схему процессора в стиле фон Неймана в целом. Не сомневаюсь, что любые достаточно общие подходы к вычислениям будут включены в популярные процессоры.
Есть такое авторитетное мнение: будущее за специализированными программируемыми ускорителями. Под такие неординарные железки действительно имеет смысл разрабатывать языки с особой семантикой. Но компьютер общего назначения был и остаётся похожим на ту самую PDP-11, для которой так хорошо подходят C-подобные императивные языки.
С будет жить
В статье Чизнэлла есть фундаментальное противоречие. Он пишет, что для обеспечения скорости программ на C процессоры имитируют абстрактную машину C (и давно забытую PDP-11), после чего указывает на ограниченность такой машины. Но я не понимаю, почему это означает, что «C — не низкоуровневый язык».
Вообще же речь идёт не о недостатках C как языка, а о критике распространённых архитектур в стиле фон Неймана и вытекающей из них модели программирования. Но пока не похоже, что индустрия готова отказаться от привычной архитектуры (по крайней мере, не в процессорах общего назначения).
Несмотря на доступность множества специализированных процессоров вроде GPU и TPU, в настоящий момент архитектура фон Неймана правит бал и индустрии нужен язык, позволяющий в рамках популярнейшей архитектуры работать на как можно более низком уровне. Достаточно простой, портированный на десятки платформ и стандартизированный язык программирования — это C (и его ближайшие родственники).
При всём при этом у C хватает недостатков: архаичная библиотека функций, запутанный и противоречивый стандарт, грубые ошибки в дизайне. Но, судя по всему, кое-что создатели языка всё же сделали правильно.
Так или иначе, нам по-прежнему нужен язык низкого уровня, причём построенный именно для популярных фоннеймановских компьютеров. И пускай C устарел, но, видимо, любому его преемнику всё равно придётся отталкиваться от тех же самых принципов.