КулЛиб - Классная библиотека! Скачать книги бесплатно 

Экстремальный Си. Параллелизм, ООП и продвинутые возможности [Камран Амини] (pdf) читать онлайн

Книга в формате pdf! Изображения и текст могут не отображаться!


 [Настройки текста]  [Cбросить фильтры]
Камран Амини

Экс тремальный



Параллелизм, ООП
и продвинутые возможности

2021

ББК 32.973.2-018.1
УДК 004.43
А62

Амини Камран
А62 Экстремальный Cи. Параллелизм, ООП и продвинутые возможности. — СПб.:
Питер, 2021. — 752 с.: ил. — (Серия «Для профессионалов»).
ISBN 978-5-4461-1694-2
Для того чтобы овладеть языком Cи, знания одного лишь синтаксиса недостаточно. Специалист
в области разработки должен обладать четким научным пониманием принципов и методик. Книга
«Экстремальный Cи» научит вас пользоваться продвинутыми низкоуровневыми возможностями языка
для создания эффективных систем, чтобы вы смогли стать экспертом в программировании на Cи.
Вы освоите директивы препроцессора, макрокоманды, условную компиляцию, указатели и многое
другое. Вы по-новому взглянете на алгоритмы, функции и структуры. Узнаете, как выжимать максимум
производительности из приложений с ограниченными ресурсами.
В XXI веке Си остается ключевым языком в машиностроении, авиации, космонавтикн и многих
других отраслях. Вы узнаете, как язык работает с Unix, как реализовывать принципы объектно-ориентированного программирования, и разберетесь с многопроцессной обработкой.
Камран Амини научит вас думать, сомневаться и экспериментировать. Эта книга просто необходима для всех, кто хочет поднять знания Cи на новый уровень.

16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.)

ББК 32.973.2-018.1
УДК 004.43

Права на издание получены по соглашению с Packt Publishing. Все права защищены. Никакая часть данной
книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев
авторских прав.
Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может
гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные
ошибки, связанные с использованием книги. Издательство не несет ответственности за доступность материалов,
ссылки на которые вы можете найти в этой книге. На момент подготовки книги к изданию все ссылки на интернетресурсы были действующими.

ISBN 978-1789343625 англ.
ISBN 978-5-4461-1694-2

© Packt Publishing 2019.
First published in the English language under the title ‘Extreme C –
(9781789343625)’
© Перевод на русский язык ООО Издательство «Питер», 2021
© Издание на русском языке, оформление ООО Издательство «Питер», 2021
© Серия «Для профессионалов», 2021
© Павлов А., перевод с английского языка, 2020

Краткое содержание
Об авторе................................................................................................................................................. 15
О научных редакторах....................................................................................................................... 16
Введение.................................................................................................................................................. 17
Глава 1. Основные возможности языка........................................................................................ 25
Глава 2. Компиляция и компоновка.............................................................................................. 74
Глава 3. Объектные файлы.............................................................................................................117
Глава 4. Структура памяти процесса...........................................................................................146
Глава 5. Стек и куча...........................................................................................................................171
Глава 6. ООП и инкапсуляция......................................................................................................208
Глава 7. Композиция и агрегация.................................................................................................244
Глава 8. Наследование и полиморфизм.....................................................................................260
Глава 9. Абстракция данных и ООП в C++..............................................................................289
Глава 10. История и архитектура Unix.......................................................................................307
Глава 11. Системные вызовы и ядра............................................................................................335
Глава 12. Последние нововведения в C......................................................................................365
Глава 13. Конкурентность...............................................................................................................381
Глава 14. Синхронизация................................................................................................................404
Глава 15. Многопоточное выполнение.......................................................................................446
Глава 16. Синхронизация потоков...............................................................................................469
Глава 17. Процессы............................................................................................................................501

6  Краткое содержание

Глава 18. Синхронизация процессов...........................................................................................532
Глава 19. Локальные сокеты и IPC..............................................................................................573
Глава 20. Программирование сокетов.........................................................................................613
Глава 21. Интеграция с другими языками.................................................................................655
Глава 22. Модульное тестирование и отладка..........................................................................693
Глава 23. Системы сборки...............................................................................................................730
Послесловие.........................................................................................................................................751

Оглавление
Об авторе................................................................................................................................................. 15
О научных редакторах....................................................................................................................... 16
Введение.................................................................................................................................................. 17
Для кого эта книга.......................................................................................................................... 18
Структура издания......................................................................................................................... 19
Условия, при соблюдении которых книга будет максимально полезной.................. 21
Скачивание файлов с примерами кода................................................................................... 22
Условные обозначения................................................................................................................. 22
От издательства............................................................................................................................... 24
Глава 1. Основные возможности языка........................................................................................ 25
Директивы препроцессора.......................................................................................................... 27
Макросы...................................................................................................................................... 28
Условная компиляция........................................................................................................... 41
Указатели на переменные............................................................................................................ 44
Синтаксис................................................................................................................................... 45
Арифметические операции с указателями на переменные....................................... 47
Обобщенные указатели......................................................................................................... 50
Размер указателей................................................................................................................... 53
Висячие указатели................................................................................................................... 53
Общая информация о функциях.............................................................................................. 56
Анатомия функции.................................................................................................................. 56
Роль функций в архитектуре приложений..................................................................... 57
Управление стеком.................................................................................................................. 57
Передача по значению и передача по ссылке................................................................. 58
Указатели на функции.................................................................................................................. 60
Структуры......................................................................................................................................... 63
Зачем нужны структуры........................................................................................................ 63
Зачем нужны пользовательские типы.............................................................................. 64
Принцип работы структур.................................................................................................... 65

8  Оглавление

Размещение структур в памяти.......................................................................................... 66
Вложенные структуры........................................................................................................... 70
Указатели на структуры........................................................................................................ 71
Резюме................................................................................................................................................ 72
Глава 2. Компиляция и компоновка.............................................................................................. 74
Процесс компиляции.................................................................................................................... 75
Сборка проекта на языке C................................................................................................... 77
Этап 1: предобработка............................................................................................................ 83
Этап 2: компиляция в ассемблерный код........................................................................ 85
Этап 3: компиляция в машинные инструкции.............................................................. 88
Этап 4: компоновка.................................................................................................................. 90
Препроцессор................................................................................................................................... 93
Компилятор...................................................................................................................................... 97
Дерево абстрактного синтаксиса . ..................................................................................... 98
Ассемблер........................................................................................................................................100
Компоновщик................................................................................................................................101
Принцип работы компоновщика......................................................................................102
Компоновщик можно обмануть!.......................................................................................110
Декорирование имен в C++...............................................................................................114
Резюме..............................................................................................................................................116
Глава 3. Объектные файлы.............................................................................................................117
Двоичный интерфейс приложений........................................................................................118
Форматы объектных файлов....................................................................................................119
Переносимые объектные файлы.............................................................................................121
Исполняемые объектные файлы.............................................................................................125
Статические библиотеки...........................................................................................................129
Динамические библиотеки.......................................................................................................138
Ручная загрузка разделяемых библиотек......................................................................142
Резюме..............................................................................................................................................145
Глава 4. Структура памяти процесса...........................................................................................146
Внутреннее устройство памяти процесса............................................................................147
Исследование структуры памяти...........................................................................................148
Исследование статической схемы размещения в памяти..............................................149
Сегмент BSS.............................................................................................................................151
Сегмент Data...........................................................................................................................153
Сегмент Text............................................................................................................................157
Исследование динамической схемы размещения в памяти..........................................159
Отражение памяти.................................................................................................................160
Стек.............................................................................................................................................164
Куча.............................................................................................................................................166
Резюме..............................................................................................................................................169

Оглавление  9

Глава 5. Стек и куча...........................................................................................................................171
Стек...................................................................................................................................................172
Исследование содержимого стека....................................................................................173
Рекомендации по использованию стековой памяти..................................................179
Куча...................................................................................................................................................183
Выделение и освобождение памяти в куче...................................................................185
Принцип работы кучи..........................................................................................................193
Управление памятью в средах с ограниченными ресурсами........................................197
Среды с ограниченной памятью.......................................................................................198
Высокопроизводительные среды.....................................................................................200
Резюме..............................................................................................................................................206
Глава 6. ООП и инкапсуляция......................................................................................................208
Объектно-ориентированное мышление...............................................................................210
Как мы мыслим.......................................................................................................................211
Диаграммы связей и объектные модели........................................................................212
В коде нет никаких объектов.............................................................................................214
Атрибуты объектов................................................................................................................216
Предметная область..............................................................................................................216
Отношения между объектами...........................................................................................217
Объектно-ориентированные операции..........................................................................218
Объекты имеют поведение.................................................................................................221
Почему язык C не является объектно-ориентированным.............................................221
Инкапсуляция...............................................................................................................................222
Инкапсуляция атрибутов....................................................................................................223
Инкапсуляция поведения...................................................................................................225
Принцип сокрытия информации.....................................................................................235
Резюме..............................................................................................................................................242
Глава 7. Композиция и агрегация.................................................................................................244
Отношения между классами....................................................................................................244
Объекты и классы.........................................................................................................................245
Композиция....................................................................................................................................247
Агрегация........................................................................................................................................253
Резюме..............................................................................................................................................259
Глава 8. Наследование и полиморфизм.....................................................................................260
Наследование.................................................................................................................................260
Природа наследования.........................................................................................................261
Полиморфизм................................................................................................................................277
Что такое полиморфизм......................................................................................................277
Зачем нужен полиморфизм................................................................................................280
Полиморфное поведение в языке C................................................................................280
Резюме..............................................................................................................................................288

10  Оглавление

Глава 9. Абстракция данных и ООП в C++..............................................................................289
Абстракция данных.....................................................................................................................289
Объектно-ориентированные концепции в C++................................................................293
Инкапсуляция.........................................................................................................................293
Наследование..........................................................................................................................296
Полиморфизм.........................................................................................................................302
Абстрактные классы.............................................................................................................305
Резюме..............................................................................................................................................306
Глава 10. История и архитектура Unix.......................................................................................307
История Unix.................................................................................................................................308
Multics OS и Unix..................................................................................................................308
BCPL и B...................................................................................................................................309
Путь к C.....................................................................................................................................310
Архитектура Unix.........................................................................................................................312
Философия...............................................................................................................................312
Многослойная структура Unix..........................................................................................314
Интерфейс командной оболочки для пользовательских приложений.....................317
Интерфейс ядра для кольца командной оболочки...........................................................322
Ядро...................................................................................................................................................327
Аппаратное обеспечение............................................................................................................332
Резюме..............................................................................................................................................334
Глава 11. Системные вызовы и ядра............................................................................................335
Системные вызовы.......................................................................................................................335
Тщательное исследование системных вызовов...........................................................336
Выполнение системного вызова напрямую, в обход стандартной
библиотеки C...........................................................................................................................337
Внутри функции syscall.......................................................................................................340
Добавление системного вызова в Linux.........................................................................342
Ядра Unix........................................................................................................................................355
Монолитные ядра и микроядра........................................................................................356
Linux...........................................................................................................................................357
Модули ядра............................................................................................................................358
Резюме..............................................................................................................................................364
Глава 12. Последние нововведения в C......................................................................................365
C11.....................................................................................................................................................366
Определение поддерживаемой версии стандарта C.........................................................366
Удаление функции gets..............................................................................................................368
Изменения в функции fopen.....................................................................................................368
Функции с проверкой диапазона............................................................................................370

Оглавление  11

Невозвращаемые функции.......................................................................................................371
Макрос для обобщенных типов...............................................................................................372
Unicode.............................................................................................................................................372
Анонимные структуры и анонимные объединения.........................................................378
Многопоточность.........................................................................................................................380
Немного о C18...............................................................................................................................380
Резюме..............................................................................................................................................380
Глава 13. Конкурентность...............................................................................................................381
Введение в конкурентность......................................................................................................381
Параллелизм..................................................................................................................................383
Конкурентность............................................................................................................................384
Планировщик заданий................................................................................................................385
Процессы и потоки......................................................................................................................387
Порядок выполнения инструкций.........................................................................................388
Когда следует использовать конкурентность.....................................................................390
Разделяемые состояния.............................................................................................................397
Резюме..............................................................................................................................................402
Глава 14. Синхронизация................................................................................................................404
Проблемы с конкурентностью.................................................................................................404
Естественные проблемы с конкурентностью.....................................................................406
Постсинхронизационные проблемы......................................................................................416
Методы синхронизации.............................................................................................................417
Холостые циклы и циклические блокировки..............................................................418
Механизм ожидания/уведомления.................................................................................421
Семафоры и мьютексы.........................................................................................................424
Системы с несколькими вычислительными блоками..............................................429
Циклические блокировки..........................................................................................................434
Условные переменные.........................................................................................................436
Конкурентность в POSIX..........................................................................................................438
Ядра с поддержкой конкурентности...............................................................................438
Многопроцессность.....................................................................................................................440
Многопоточность.........................................................................................................................443
Резюме..............................................................................................................................................444
Глава 15. Многопоточное выполнение.......................................................................................446
Потоки..............................................................................................................................................447
POSIX-потоки...............................................................................................................................450
Порождение POSIX-потоков...................................................................................................452
Пример состояния гонки...........................................................................................................457
Пример гонки данных.................................................................................................................465
Резюме..............................................................................................................................................468

12  Оглавление

Глава 16. Синхронизация потоков...............................................................................................469
Управление конкурентностью в POSIX..............................................................................470
POSIX-мьютексы...................................................................................................................470
Условные переменные POSIX..........................................................................................473
POSIX-барьеры......................................................................................................................477
POSIX-семафоры...................................................................................................................480
POSIX-потоки и память.............................................................................................................488
Сегмент стека..........................................................................................................................488
Сегмент кучи...........................................................................................................................493
Видимость памяти.................................................................................................................498
Резюме..............................................................................................................................................500
Глава 17. Процессы............................................................................................................................501
API для выполнения процессов..............................................................................................501
Создание процесса.................................................................................................................504
Выполнение процесса...........................................................................................................509
Разные методы создания и выполнения процессов...................................................512
Этапы выполнения процесса....................................................................................................512
Разделяемые состояния.............................................................................................................513
Методы разделения ресурсов............................................................................................514
Разделяемая память в POSIX............................................................................................516
Файловая система..................................................................................................................526
Сравнение многопоточности и многопроцессности........................................................528
Многопоточность...................................................................................................................528
Локальная многопроцессность.........................................................................................529
Распределенная многопроцессность...............................................................................530
Резюме..............................................................................................................................................531
Глава 18. Синхронизация процессов...........................................................................................532
Локальное управление конкурентностью............................................................................533
Именованные POSIX-семафоры............................................................................................534
Именованные мьютексы............................................................................................................538
Первый пример.......................................................................................................................538
Второй пример........................................................................................................................542
Именованные условные переменные....................................................................................552
Этап 1: класс разделяемой памяти...................................................................................553
Этап 2: класс разделяемого 32-битного целочисленного счетчика......................556
Этап 3: класс разделяемого мьютекса.............................................................................558
Этап 4: класс разделяемой условной переменной......................................................562
Этап 5: основная логика.......................................................................................................565
Распределенное управление конкурентностью.................................................................570
Резюме..............................................................................................................................................572

Оглавление  13

Глава 19. Локальные сокеты и IPC..............................................................................................573
Методы межпроцессного взаимодействия..........................................................................574
Коммуникационные протоколы..............................................................................................576
Характеристики протоколов..............................................................................................578
Взаимодействие в рамках одного компьютера...................................................................581
Файловые дескрипторы.......................................................................................................581
POSIX-сигналы......................................................................................................................582
POSIX-каналы........................................................................................................................586
Очереди сообщений POSIX...............................................................................................588
Сокеты домена Unix..............................................................................................................591
Введение в программирование сокетов................................................................................592
Компьютерные сети..............................................................................................................592
Что такое программирование сокетов............................................................................605
У сокетов есть собственные дескрипторы!...................................................................611
Резюме..............................................................................................................................................612
Глава 20. Программирование сокетов.........................................................................................613
Краткий обзор программирования сокетов........................................................................614
Проект «Калькулятор»...............................................................................................................616
Иерархия исходного кода...................................................................................................617
Сборка проекта.......................................................................................................................620
Запуск проекта........................................................................................................................621
Прикладной протокол..........................................................................................................622
Библиотека сериализации/десериализации................................................................625
Сервис калькулятора............................................................................................................630
Сокеты домена Unix....................................................................................................................632
Потоковый сервер на основе UDS...................................................................................632
Потоковый клиент на основе UDS..................................................................................640
Датаграммный сервер на основе UDS............................................................................643
Датаграммный клиент на основе UDS...........................................................................647
Сетевые сокеты.............................................................................................................................649
TCP-сервер...............................................................................................................................650
TCP-клиент..............................................................................................................................651
UDP-сервер..............................................................................................................................652
UDP-клиент.............................................................................................................................653
Резюме..............................................................................................................................................654
Глава 21. Интеграция с другими языками.................................................................................655
Что делает интеграцию возможной.......................................................................................656
Получение необходимых материалов...................................................................................657
Библиотека для работы со стеком..........................................................................................658
Интеграция с C++........................................................................................................................664

14  Оглавление

Декорирование имен в C++...............................................................................................665
Код на C++...............................................................................................................................667
Интеграция с Java.........................................................................................................................672
Написание кода на Java........................................................................................................672
Написание машинно-зависимой части..........................................................................677
Интеграция с Python...................................................................................................................685
Интеграция с Go...........................................................................................................................689
Резюме..............................................................................................................................................691
Глава 22. Модульное тестирование и отладка..........................................................................693
Тестирование программного обеспечения..........................................................................694
Уровни тестирования...........................................................................................................695
Модульное тестирование...........................................................................................................696
Тестовые дублеры..................................................................................................................704
Компонентное тестирование....................................................................................................706
Библиотеки тестирования для C............................................................................................707
CMocka......................................................................................................................................708
Google Test...............................................................................................................................717
Отладка............................................................................................................................................721
Категории программных ошибок.....................................................................................722
Отладчики................................................................................................................................723
Средства проверки памяти.................................................................................................725
Средства отладки потоков..................................................................................................726
Профилировщики производительности........................................................................727
Резюме..............................................................................................................................................728
Глава 23. Системы сборки...............................................................................................................730
Что такое система сборки..........................................................................................................731
Make..................................................................................................................................................732
CMake — не система сборки!....................................................................................................740
Ninja...................................................................................................................................................744
Bazel...................................................................................................................................................746
Сравнение систем сборки..........................................................................................................749
Резюме..............................................................................................................................................749
Послесловие.........................................................................................................................................751

Об авторе

Камран Амини специализируется на ядре и встроенных системах. Он работал
инженером, архитектором, консультантом и техническим директором во множестве известных иранских компаний. В 2017 году переехал в Европу, где трудился
старшим архитектором и инженером в таких солидных компаниях, как Jeppesen,
Adecco, TomTom и ActiveVideo Networks. Во время своего пребывания в Амстердаме Камран и написал эту книгу. Его больше всего интересуют следующие темы:
теория алгоритмов, распределенные системы, машинное обучение, теория информации и квантовые вычисления.
Хочу поблагодарить маму Эхтирам, которая посвятила жизнь воспитанию
меня и моего брата Ашкана. Она всегда нас поддерживает.
Хочу также поблагодарить мою прекрасную и любимую жену Афсанех, которая поддерживала меня на каждом этапе, особенно во время
работы над этой книгой. Без ее терпения и поддержки я бы никогда
не справился.

О научных редакторах

Алиакбар Аббаси — разработчик ПО с более чем восьмилетним опытом использования различных технологий и языков программирования. Специалист в ООП,
C/C++ и Python. Любит читать техническую литературу и расширять свой кругозор в области программирования. В настоящее время проживает в Амстердаме
и работает старшим программистом в компании TomTom.
Рохит Талвалкар — очень опытный специалист в программировании на языках C,
C++ и Java. На его счету разработка приложений, драйверов и сервисов для проприетарной версии RTOS (Real Time OS), Windows, устройств на основе Windows
Mobile и платформы Android.
Рохит получил диплом бакалавра технических наук в индийском Технологическом институте города Мумбаи, а также диплом магистра СS. В настоящее время
занимает должность ведущего разработчика приложений в сфере смешанной
реальности. Рохит успел поработать в Motorola и BlackBerry и сейчас является сотрудником компании Magic Leap, которая выпускает очки смешанной реальности
и специализируется на пространственных вычислениях. В свое время редактировал книгу C++ for the Impatient Брайана Оверленда.
Хочу поблагодарить доктора Кловиса Тондо, который научил меня C,
C++, Java и многому другому.

Введение

В современном мире мы регулярно имеем дело с умопомрачительными технологиями, которые невозможно было представить еще несколько десятилетий назад.
На улицах начинают появляться беспилотные автомобили. Достижения в физике
и других науках меняют наши представления о реальности как таковой. Мы читаем
новости о том, как исследователи делают первые шаги в квантовых вычислениях,
о блокчейне и криптовалютах, о планах колонизации других планет. Невероятно, но
в основе таких разнообразных по своей природе достижений лежат всего несколько
технологий, одной из которых посвящена эта книга. Речь идет о языке C.
Я начал программировать на C++ в девятом классе, присоединившись к команде
юных разработчиков, занимавшейся созданием 2D-симулятора игры в футбол.
Вскоре после C++ я начал изучать Linux и C. Стоит признать, что в те времена
важность C и Unix не была для меня столь очевидной, но, постепенно получая
опыт использования этих технологий в разных проектах и все больше узнавая
о них, я осознал, насколько важную роль они играют. Чем ближе я знакомился с C,
тем сильнее уважал этот язык программирования. В конце концов я решил стать
профессиональным программистом на C. Мне хотелось делиться своими знаниями с другими людьми, чтобы они тоже понимали всю важность этой технологии.
Данная книга стала результатом моих амбиций.
Бытует заблуждение, будто C — мертвый язык, и в целом многие технические
специалисты имеют о нем туманное представление. Чтобы убедиться в обратном,
достаточно взглянуть на рейтинг TIOBE по адресу www.tiobe.com/tiobe-index. На самом деле C наряду с Java — один из самых известных языков за прошедшие 15 лет,
и в последние годы он только набирал популярность.
Я подошел к написанию этой книги, имея многолетний опыт в разработке и проектировании с помощью C, C++, Golang, Java и Python на разных платформах, включая различные версии BSD Unix, Linux и Microsoft Windows. Моей основной целью
было вывести навыки читателей на новый уровень, поделиться с ними опытом, полученным тяжелым трудом. Легкой прогулки ждать не стоит, именно поэтому книга
называется «Экстремальный Cи. Параллелизм, ООП и продвинутые возможности».
Мы не станем отвлекаться и сравнивать C с другими языками программирования.

18  Введение

Я попытался сделать текст максимально практичным, однако он все равно содержит большое количество фундаментального теоретического материала, имеющего
реальное применение. Множество примеров, представленных здесь, помогут вам
подготовиться к тому, с чем вам предстоит столкнуться в реальных проектах.
Возможность взяться за столь весомую тему — большая честь для меня. Это сложно выразить словами, и потому скажу лишь, что мне было очень приятно писать
о том, что так близко моему сердцу. Это моя первая книга, и возможностью быть
ее автором я обязан Эндрю Валдрону.
Заодно хочу передать привет и поблагодарить Йена Хью, редактора-консультанта,
с которым мы бок о бок трудились над каждой главой, Алиакбара Аббаси за его замечания и предложения, а также Кишора Рита, Гаурава Гаваса, Веронику Пэйс и многих
других людей, внесших ценный вклад в подготовку и издание этой книги.
Я предлагаю вам стать моими спутниками в этом длинном путешествии. Надеюсь,
чтение книги изменит ваш кругозор, поможет вам увидеть C в новом свете и заодно
сделает вас отличным программистом.

Для кого эта книга
Книга предназначена для читателей, уже имеющих минимальный уровень знаний
в области разработки на C и C++. Основная аудитория — начинающие и middleпрограммисты на C/C++; именно они смогут извлечь максимальную пользу из
прочитанного материала, применяя полученные навыки и знания. Надеюсь, книга
поможет им ускорить карьерный рост и стать старшими разработчиками. Кроме
того, прочитав ее, они смогут претендовать на большое количество вакансий с высокими требованиями и, как правило, хорошей зарплатой. Те или иные темы могут
пригодиться и опытным программистам на C/C++, хотя я ожидаю, что эти люди
в целом знакомы с изложенным здесь материалом и могут почерпнуть для себя
лишь некоторые полезные детали.
Еще одна категория читателей, которым может пригодиться эта книга, — студенты
и научные сотрудники. Возможно, вы получаете высшее образование или учитесь
в аспирантуре в любой научной или технической области, такой как информатика,
разработка ПО, искусственный интеллект, Интернет вещей (Internet of Things,
IoT), астрономия, физика частиц и космология, либо занимаетесь исследованиями
в этих сферах. Книга позволит повысить уровень ваших знаний о C/C++ и Unixподобных операционных системах, а также поможет отточить соответствующие
навыки программирования. Представленный материал пригодится инженерам
и ученым, работающим над сложными, многопоточными или даже многопроцессными системами для удаленного управления устройствами, моделирования,
обработки больших объемов данных, машинного/глубокого обучения и т. д.

Структура издания  19

Структура издания
Книга состоит из семи условных частей, каждая из которых посвящена определенным
аспектам программирования на C. В первой части рассматривается создание проекта на C, во второй обсуждается память, в третьей — объектная ориентированность,
а в четвертой речь в основном идет о системах Unix и их связях с языком C. В пятой
части мы поговорим о конкурентности, в шестой — о межпроцессном взаимодействии, а в седьмой, заключительной части речь пойдет о тестировании и сопровождении кода. Ниже приводится краткое описание каждой из 23 глав книги.
Глава 1 «Основные возможности языка» посвящена основным возможностям C, которые определяют то, как мы используем данный язык. В число главных тем входят
определение макросов с помощью препроцессора и директив, указатели на переменные и функции, механизмы вызова функций, а также структуры.
Глава 2 «Компиляция и компоновка» содержит описание сборки проектов на C.
Во всех подробностях будут рассмотрены как процесс компиляции в целом, так
и отдельные его элементы.
Глава 3 «Объектные файлы» посвящена результатам компиляции проекта на C.
Вы познакомитесь с объектными файлами, заглянете внутрь этих файлов и посмотрите, какую информацию из них можно извлечь.
Глава 4 «Структура памяти процесса» исследует внутреннее устройство памяти
процесса. Вы узнаете, из каких сегментов состоит память и чем статическая память отличается от динамической.
Глава 5 «Стек и куча» содержит информацию о таких сегментах, как стек и куча.
Мы поговорим о переменных, которые в них хранятся, и об управлении их жизненным циклом в C. Вы научитесь передовым практикам работы с кучей.
Глава 6 «ООП и инкапсуляция» — первая из четырех глав, относящихся к объектной
ориентированности в C. Мы пройдемся по теории, стоящей за ООП, и будет дано
определение важным терминам, которые часто встречаются в технической литературе.
Глава 7 «Композиция и агрегация» посвящена композиции и ее специальной разновидности — агрегации. Мы обсудим их отличия, которые проиллюстрируем
с помощью примеров.
Глава 8 «Наследование и полиморфизм» исследует один из самых важных аспектов
объектно-ориентированного программирования (ООП) — наследование. Я покажу, как
происходит наследование между двумя классами и как это можно реализовать в C.
Вдобавок мы поговорим еще об одной обширной теме — полиморфизме.
Глава 9 «Абстракция данных и ООП в C++» является заключительной для третьей
части книги и отведена теме абстракции. Вы познакомитесь с абстрактными типами

20  Введение

данных и узнаете, как они реализованы в C. Мы обсудим C++ и рассмотрим объектно-ориентированные концепции на примере этого языка.
При обсуждении языка C нельзя не упомянуть о Unix. Глава 10 «История и архитектура Unix» объясняет, почему эти две технологии так тесно связаны между
собой и как данный симбиоз способствует их живучести. Мы также рассмотрим
архитектуру операционной системы Unix и узнаем, как программы используют
предоставляемые ею возможности.
Глава 11 «Системные вызовы и ядра» посвящена пространству ядра в архитектуре
Unix. Мы подробно обсудим системные вызовы и рассмотрим, как их можно создавать в Linux. Вдобавок поговорим о разных видах ядер и увидим принцип работы
модулей ядра Linux на примере простого модуля.
Глава 12 «Последние нововведения в C» представляет последнюю версию стандарта C под названием C18. Вы увидите, чем она отличается от предыдущей версии, C11. Я также продемонстрирую ряд новых возможностей, которые появились
с момента выхода C99.
Глава 13 «Конкурентность» открывает пятую часть и посвящена конкурентности.
Мы в основном обсудим среды конкурентного выполнения и их различные свойства, такие как чередование. Я объясню, почему эти системы недетерминистические
и каким образом данная особенность может вызывать проблемы конкурентности,
такие как состояние гонки.
Глава 14 «Синхронизация» побуждает продолжить наше обсуждение сред конкурентного выполнения и обращает внимание на разные проблемы, которые в них
встречаются, включая состояние гонки, конкуренцию за данные и взаимную блокировку. Вы познакомитесь с методиками, позволяющими преодолеть эти проблемы,
такими как семафоры, мьютексы и условные переменные.
Глава 15 «Многопоточное выполнение» демонстрирует одновременное выполнение
нескольких потоков и способы управления ими. Я также приведу реальные примеры
с проблемами конкурентности в C, перечисленные в предыдущей главе.
Глава 16«Синхронизация потоков» описывает методы синхронизации нескольких
потоков. Среди представленных здесь тем можно выделить семафоры, мьютексы
и условные переменные.
Глава 17 «Процессы» представляет способы создания или порождения новых процессов. Мы также обсудим пассивные и активные методики разделения состояния
между разными процессами и рассмотрим проблемы с конкурентностью, затронутые в главе 14, используя реальные примеры на языке C.
Глава 18 «Синхронизация процессов» в основном посвящена имеющимся механизмам для синхронизации разных процессов, находящихся на одном и том же компьютере, включая межпроцессные семафоры, мьютексы и условные переменные.

Условия, при соблюдении которых книга будет максимально полезной  21

Глава 19 «Локальные сокеты и IPC» основное внимание уделяет пассивным методам межпроцессного взаимодействия (interprocess communication, IPC). Основной
акцент делается на взаимодействии процессов, находящихся на одном компьютере.
Вы также познакомитесь с программированием сокетов и научитесь создавать каналы между процессами, принадлежащими разным сетевым узлам.
Глава 20 «Программирование сокетов» представляет сетевое программирование
и содержит примеры кода, которые проиллюстрируют разные типы сокетов, включая сокеты домена Unix, а также TCP- и UDP-сокеты, основанные на поточных
и датаграммных каналах.
Глава 21 «Интеграция с другими языками» демонстрирует, как библиотеку на C,
собранную в виде динамического объектного файла, можно загружать и использовать в программах, написанных на C++, Java, Python и Golang.
Глава 22 «Модульное тестирование и отладка» посвящена тестам разных видов, но
мы сосредоточимся на модульном тестировании в C. Вы познакомитесь с библиотеками CMocka и Google Test, предназначенными для написания наборов тестов в C.
Применительно к отладке мы пройдемся по различным инструментам, которые
позволяют отлаживать разного рода программные ошибки.
Глава 23 «Системы сборки», заключительная, представляет системы сборки, такие
как Make, Ninja и Bazel, и один генератор сборочных скриптов, CMake.

Условия, при соблюдении которых книга будет
максимально полезной
Как уже объяснялось, от читателя требуется определенный уровень знаний и навыков в области программирования.
zz Общее понимание архитектуры компьютера. Вы должны иметь представление

о памяти, центральном процессоре, периферийных устройствах и их характеристиках и понимать, как компьютерные программы взаимодействуют с этими
элементами.
zz Владение основами программирования. Вы должны знать, что такое алгоритм,

как проследить за его выполнением, что собой представляет исходный код и как
в двоичной системе выполняются арифметические операции.
zz Навыки работы с терминалом и основными утилитами командной строки

в Unix-подобных операционных системах, таких как Linux или macOS.
zz Понимание таких тем, как условные выражения, разные виды циклов, струк-

туры или классы минимум в одном языке программирования, указатели в C
или C++, функции и т. д.

22  Введение
zz Знание основ ООП. Требование необязательное, поскольку ООП подробно

рассматривается в книге, однако знание этой темы поможет вам лучше понять
материал, изложенный в главах, посвященных объектной ориентированности.
Кроме того, настоятельно рекомендуется скачать репозиторий с кодом
и самостоятельно выполнять команды, которые приводятся в листингах
командной оболочки. Пожалуйста, используйте компьютер под управлением Linux, macOS или другой операционной системы, совместимой с POSIX.

Скачивание файлов с примерами кода
Вы можете скачать файлы с кодом, выполнив следующие шаги.
1. Войдите или зарегистрируйтесь на сайте http://www.packt.com.
2. Выберите вкладку Support (Поддержка).
3. Щелкните на ссылке Code Downloads (Загрузка кода).
4. Введите английское название книги (Extreme C) в поле Search (Искать) и следуйте инструкциям, которые появятся на экране.
Скачав файл, не забудьте его распаковать, используя последнюю версию одного из
следующих инструментов:
zz WinRAR/7-Zip для Windows;
zz Zipeg/iZip/UnRarX для Mac;
zz 7-Zip/PeaZip для Linux.

Архив с кодом для этой книги также доступен на GitHub по адресу github.com/
PacktPublishing/Extreme-C. Все обновления кода вносятся в существующий GitHubрепозиторий.
У нас есть богатая библиотека книг и видеороликов. Примеры кода для них можно
найти на сайте github.com/PacktPublishing/.

Условные обозначения
В книге используются листинги кода и командной оболочки. Первые содержат
либо код на языке C, либо псевдокод. Пример листинга кода показан ниже (листинг 17.1).
Листинг 17.1. Создание дочернего процесса с помощью API fork
(ExtremeC_examples_chapter17_1.c)

#include
#include

Условные обозначения  23
int main(int argc, char** argv) {
printf("This is the parent process with process ID: %d\n",
getpid());
printf("Before calling fork() ...\n");
pid_t ret = fork();
if (ret) {
printf("The child process is spawned with PID: %d\n", ret);
} else {
printf("This is the child process with PID: %d\n", getpid());
}
printf("Type CTRL+C to exit ...\n");
while (1);
return 0;
}

Как видите, приведенный выше код можно найти в файле ExtremeC_examples_
chapter17_1.c, который находится в архиве с примерами для этой книги, в каталоге
главы 17. Архив с кодом доступен по ссылке github.com/PacktPublishing/Extreme-C.
Если листинг не связан ни с каким файлом, он содержит псевдокод или код на
языке C, который не вошел в архив. Вот пример.
Листинг 13.1. Простое задание с пятью инструкциями

Task P
1.
2.
3.
4.
5.
}

{
num = 5
num++
num = num – 2
x = 10
num = num + x

Иногда некоторые строки в листингах выделены жирным шрифтом. Они обычно
обсуждаются перед листингом или после него, а выделяются для того, чтобы вам
было легче их найти.
Листинги командной оболочки используются для иллюстрации вывода консольных команд, запускаемых в терминале. Сами команды, как правило, напечатаны
жирным шрифтом, а их вывод — обычным. Вот пример.
Терминал 17.6. Чтение из объекта разделяемой памяти, созданного в примере 17.4, и его удаление
$ ls /dev/shm
shm0
$ gcc ExtremeC_examples_chapter17_5.c -lrt -o ex17_5.out
$ ./ex17_5.out
Shared memory is opened with fd: 3
The contents of the shared memory object: ABC
$ ls /dev/shm
$

24  Введение

Команды начинаются либо с $, либо с #. В первом случае команду следует выполнять от имени обычного пользователя, а во втором — от имени администратора.
Консольные команды обычно выполняют в каталоге соответствующей главы, который находится в архиве с кодом. Если нужно перейти в определенный рабочий
каталог, то я вам об этом сообщу.
Курсивом выделены новые термины или слова, на которых нужно акцентировать
внимание. Шрифт без засечек используется для ссылок и названий каталогов, а также
для элементов интерфейса. Например: «Выберите раздел System info (Системная
информация) на панели Administration (Администрирование)». Названия файлов
оформляются моноширинным шрифтом.
Предупреждения и важные замечания оформлены так.

Советы и приемы оформлены таким образом.

От издательства
Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com
(­издательство «Питер», компьютерная редакция).
Мы будем рады узнать ваше мнение!
На сайте издательства www.piter.com вы найдете подробную информацию о наших
книгах.

1

Основные возможности
языка

Эта книга поможет вам получить как базовые, так и углубленные знания, необходимые для разработки и сопровождения реальных приложений на C. Как правило,
для написания успешных программ одного лишь синтаксиса языка программирования недостаточно — и это особенно актуально для C, в сравнении с большинством
других языков. И потому мы рассмотрим все концепции, с помощью которых вы
сможете создавать замечательное ПО, — от простых утилит до сложных многопроцессорных систем.
Глава 1 в основном посвящена конкретным возможностям C, которые, как вы
сами увидите, будут чрезвычайно полезными при написании программ. Вы будете
применять их в ситуациях, регулярно встречающихся в разработке ПО. О программировании на C написано множество замечательных книг и практических
руководств, подробно освещающих почти все аспекты синтаксиса этого языка,
но, прежде чем идти дальше, будет полезно обсудить некоторые из его ключевых
особенностей.
В число этих особенностей входят директивы препроцессора, указатели на переменные, функции и структуры. Конечно, все это можно встретить и в более современных языках программирования, а аналогичные концепции доступны в Java,
C#, Python и т. д. Например, ссылки в Java можно считать аналогом указателей
на переменные в C. Эти возможности и связанные с ними концепции настолько
фундаментальны, что ни один программный компонент не смог бы работать без
них, даже если бы его можно было запустить! Даже простейшая программа типа
Hello world нуждается в загрузке целого ряда динамических библиотек, что, в свою
очередь, требует использования указателей на функции!
Светофор, компьютерная система вашего автомобиля, микроволновая печь на
вашей кухне, операционная система вашего смартфона или, наверное, любого другого устройства, о котором вы обычно даже не задумываетесь, — все это содержит
программные компоненты, написанные на языке C.
Появление C оказало огромное влияние на нашу жизнь, и без него наш мир выглядел бы совсем иначе.

26  Глава 1



Основные возможности языка

Эта глава посвящена основным возможностям и механизмам, необходимым для
написания кода на C. Будут подробно рассмотрены следующие элементы языка.
zz Директивы препроцессора, макросы и условная компиляция. Наличие препроцес-

сора — одна из особенностей C, которая не характерна для большинства других
языков программирования. Препроцессор дает множество преимуществ, и мы
обсудим некоторые интересные сценарии его применения, включая макросы
и условные директивы.
zz Указатели на переменные рассматриваются в соответствующем разделе. Чтобы

лучше их понять, мы также обсудим примеры их неправильного использования.
zz Функции. В соответствующем разделе подробно описывается все, что с ними

связано. Мы не станем ограничиваться одним лишь синтаксисом. На самом деле
синтаксис — самое простое! Мы будем рассматривать функции в качестве составных элементов для написания процедурного кода. Мы также обсудим механизм
вызова функций и то, как они получают свои аргументы от вызывающего их кода.
zz Указатели на функции. Это, несомненно, один из важнейших аспектов C. Указа-

тели могут ссылаться не только на переменные, но и на функции. Возможность
сохранять указатели на существующую логику крайне важна для разработки
алгоритмов, и именно поэтому данной теме посвящен отдельный раздел. Указатели на функции имеют широкий спектр применения, от загрузки динамических
библиотек до полиморфизма. Они будут повсеместно встречаться в следующих
нескольких главах.
zz Структуры в C имеют простой синтаксис и выражают простую идею, но это

основные составляющие элементы для написания хорошо организованного и более объектно-ориентированного кода. Их важность в сочетании с указателями
на функции невозможно переоценить! В последнем разделе данной главы мы
еще раз пройдемся по всем аспектам структур в C, о которых вам нужно знать,
и рассмотрим присущие им нюансы.
Основные возможности C и сопутствующие концепции играют ключевую роль
в экосистеме Unix, благодаря чему этот язык, несмотря на почтенный возраст
и строгий синтаксис, является важной и влиятельной технологией. О том, как C
и Unix связаны между собой, мы поговорим в следующих главах. А пока сосредоточимся на директивах препроцессора.
Прежде чем продолжим, имейте в виду: вы уже должны быть знакомы
с C. В текущей главе в основном представлены обычные примеры, но
без знания синтаксиса языка вы не сможете двигаться дальше. Ниже
перечислены темы, в которых необходимо ориентироваться любому
читателю данной книги.
yy Общее понимание архитектуры компьютера. Вы должны иметь представление о памяти, центральном процессоре, периферийных устрой-

Директивы препроцессора   27
ствах и их характеристиках и понимать, как компьютерные программы
взаимодействуют с этими элементами.
yy Знание основ программирования. Вы должны знать, что такое алгоритм,
как проследить за его выполнением, что собой представляет исходный
код и как работают арифметические операции в двоичной системе.
yy Навыки работы с терминалом и основными утилитами командной
строки в Unix-подобных операционных системах, таких как Linux или
macOS.
yy Владение такими темами, как условные выражения, разные виды
циклов, структуры или классы минимум в одном языке программирования, указатели в C или C++, функции и т. д.
yy Понимание основ ООП. Требование необязательное, поскольку ООП
подробно рассматривается в этой книге, однако знание данной темы
поможет вам лучше понять материал, изложенный в главах третьей
части, посвященной объектной ориентированности.

Директивы препроцессора
Препроцессор — важная часть C. Мы подробно рассмотрим его в главе 2, но пока
будем считать, что это механизм, который позволяет вам генерировать и модифицировать свой исходный код до его передачи компилятору. Это значит, процесс
компиляции в C имеет по меньшей мере один дополнительный этап по сравнению
с другими языками программирования. В них исходный код сразу попадает в компилятор, но в C и C++ он должен сначала пройти через препроцессор.
Этот дополнительный этап делает C и (C++) уникальным языком программирования, поскольку программист может фактически изменять свой исходный код
перед передачей его компилятору. В большинстве высокоуровневых языков программирования такой возможности нет.
Задача препроцессора — заменить специальные директивы подходящим кодом на C
и подготовить итоговые исходники к компиляции.
Управлять препроцессором в C и влиять на его поведение можно с помощью набора
директив. Они представляют собой строчки кода, начинающиеся символом # как
в заголовочных, так и в исходных файлах. Эти строчки имеют смысл только для
препроцессора, но не для компилятора. C поддерживает различные директивы, но
часть из них играют ключевую роль, особенно те, которые используются в определении макросов и условной компиляции.
В следующем подразделе я объясню, что такое макросы, и приведу различные
примеры их использования. Кроме того, мы проанализируем их достоинства и недостатки.

28  Глава 1



Основные возможности языка

Макросы
Макросы в C окружены ореолом таинственности. Одни говорят, что они делают
исходный код слишком сложным и малопонятным, а другие уверены, что из-за них
возникают проблемы при отладке приложений. Возможно, вы и сами встречали
подобные слухи. Но правдивы ли они и если да, то в какой степени? Являются ли
макросы злом, которого следует избегать? Или же они имеют определенные преимущества, которые могли бы пригодиться в вашем проекте?
В действительности макросы можно найти в любом известном проекте на C.
Чтобы в этом убедиться, скачайте какое-нибудь популярное приложение наподобие HTTP-сервера Apache и поищите в его исходниках #define с помощью утилиты
grep. Вы найдете длинный список файлов, в которых определен данный макрос.
Макросы — неотъемлемая часть жизни разработчика на C. Даже если вы не используете их сами, они, скорее всего, попадутся вам в чужом коде. Поэтому вы должны
знать, что они собой представляют и как с ними работать.
Утилита grep — стандартная утилита командной строки в Unix-подобных
операционных системах, предназначенная для поиска шаблонных выражений в потоках символов. С ее помощью можно искать текст и шаблоны
в содержимом всех файлов в заданном каталоге.

Макросы можно применять различными способами. Ниже перечислено несколько
примеров:
zz определение константы;
zz использование вместо обычной функции на C;
zz разворачивание цикла;
zz предотвращение дублирования заголовков;
zz генерация кода;
zz условная компиляция.

Макросы применяются во множестве других ситуаций, однако ниже мы сосредоточимся на тех из них, которые перечислены выше.

Определение макроса
Для определения макросов используется директива #define . Каждый макрос
имеет имя и (иногда) список параметров. У него также есть значение, которое
подставляется вместо его имени на этапе работы препроцессора под названием

Директивы препроцессора  29

«развертывание макросов». С помощью директивы #undef макрос можно сделать
неопределенным. Начнем с простого примера (листинг 1.1).
Листинг 1.1. Определение макроса (ExtremeC_examples_chapter1_1.c)

#define ABC 5
int main(int argc, char** argv) {
int x = 2;
int y = ABC;
int z = x + y;
return 0;
}

В приведенном выше листинге ABC — не переменная с целочисленным значением
и не целочисленная константа. На самом деле это макрос с именем ABC, значение
которого равно 5. После его развертывания итоговый код, который передается
компилятору, выглядит примерно так (листинг 1.2).
Листинг 1.2. Код, сгенерированный в результате развертывания макроса из примера 1.1

int main(int argc, char** argv) {
int x = 2;
int y = 5;
int z = x + y;
return 0;
}

Код в листинге 1.2 имеет корректный с точки зрения C синтаксис, понятный компилятору. Препроцессор развернул макрос, подставив его значение туда, где было
указано его имя, и вдобавок убрал комментарий в начальной строчке.
Теперь рассмотрим еще один пример (листинг 1.3).
Листинг 1.3. Определение функционального макроса (ExtremeC_examples_chapter1_2.c)

#define ADD(a, b) a + b
int main(int argc, char** argv) {
int x = 2;
int y = 3;
int z = ADD(x, y);
return 0;
}

В приведенном выше коде, как и в примере 1.1, ADD не является функцией. Это всего
лишь функциональный макрос, который принимает аргументы. После обработки
препроцессором итоговый код будет выглядеть следующим образом (листинг 1.4).

30  Глава 1



Основные возможности языка

Листинг 1.4. Пример 1.2 после обработки препроцессором и развертывания макроса

int main(int argc, char** argv) {
int x = 2;
int y = 3
int z = x + y;
return 0;
}

Как видите, произошло следующее развертывание: аргумент x, который использовался в качестве параметра a, был заменен всеми экземплярами a в значении макроса. То же самое произошло с параметром b и соответствующим ему аргументом y.
Затем была произведена заключительная замена, и в обработанном препроцессором
коде мы получили x + y вместо ADD(a, b).
Поскольку функциональные макросы могут принимать аргументы, с их помощью
можно имитировать функции C. Иными словами, вы можете вынести часто используемую логику в функциональный макрос. Таким образом, препроцессор подставит
вместо макроса часто применяемую логику и вам не нужно будет создавать новую
функцию на языке C. Мы обсудим это более подробно и сравним оба подхода.
Макросы существуют только перед этапом компиляции. То есть компилятор теоретически ничего о них не знает. Это очень важный момент, о котором необходимо
помнить, если вы собираетесь использовать макросы вместо функций. О функциях
компилятору известно все, поскольку они являются частью грамматики языка C
и хранятся в синтаксическом дереве. А макрос — просто директива, которую понимает только препроцессор.
Макросы позволяют генерировать код перед компиляцией. В других языках программирования, таких как Java, для этого применяются специальные генераторы
кода. Я приведу примеры того, как это делается в контексте макросов.
Вопреки распространенному заблуждению, современные компиляторы C знают
о директивах и анализируют исходный код еще до его обработки препроцессором.
Взгляните на следующий пример (листинг 1.5).
Листинг 1.5. Определение макроса, которое вызывает ошибку «необъявленный идентификатор»
(example.c)

#include
#define CODE \
printf("%d\n", i);
int main(int argc, char** argv) {
CODE
return 0;
}

Директивы препроцессора  31

Если скомпилировать приведенный выше код с помощью clang в macOS, то получится следующий вывод (терминал 1.1).
Терминал 1.1. Вывод компилятора ссылается на определение макроса
$ clang example.c
code.c:7:3: error: use of undeclared identifier 'i'
CODE
^
code.c:4:16: note: expanded from macro 'CODE'
printf("%d\n", i);
^
1 error generated.
$

Как видите, компилятор сгенерировал сообщение об ошибке, в котором указана
строчка с объявлением макроса.
Стоит отметить: большинство современных компиляторов позволяют просмат­
ривать результаты работы препроцессора непосредственно перед компиляцией.
Например, задействуя gcc или clang, можно указать параметр -E, чтобы вывести
обработанный препроцессором код. Пример использования параметра -E продемонстрирован в терминале 1.2. Обратите внимание: это лишь часть вывода.
Терминал 1.2. Код example.c после обработки препроцессором
$ clang -E example.c
# 1 "sample.c"# 1 "" 1
# 1 "" 3
# 361 "" 3
...
# 412 "/Library/Developer/CommandLineTools/SDKs/MacOSX10.14.sdk/usr/include/
stdio.h" 2 3 4
# 2 "sample.c" 2
...
int main(int argc, char** argv) {
printf("%d\n", i);
return 0;
}
$

Мы подошли к важной концепции. Единица трансляции (или единица компиляции) — код на языке C, который прошел через препроцессор и готов к компиляции.
В единице трансляции все директивы заменены подключенными файлами или развернутыми макросами, благодаря чему получается один длинный блок кода на C.

32  Глава 1



Основные возможности языка

Итак, вы уже познакомились с макросами. Теперь рассмотрим более сложные
примеры, которые продемонстрируют всю эффективность и опасность данного механизма. Экстремальное программирование позволяет мастерски обращаться с опасными и тонкими концепциями, и это, как мне кажется, и есть суть
языка C.
Ниже показан интересный пример. Обратите внимание на последовательное применение макросов в цикле (листинг 1.6).
Листинг 1.6. Использование макросов для генерации цикла (ExtremeC_examples_chapter1_3.c)

#include
#define PRINT(a) printf("%d\n", a);
#define LOOP(v, s, e) for (int v = s; v b ? a : b;
}
int max_3(int a, int b, int c) {
int temp = max(a, b);
return c > temp ? c : temp;
}

Второй файл представлен в листинге 3.2.
Листинг 3.2. Функция main, использующая уже объявленные функции. Определения находятся
в отдельном исходном файле (ExtremeC_examples_chapter3_1.c)

int max(int, int);
int max_3(int, int, int);
int a = 5;
int b = 10;
int main(int argc, char** argv) {
int m1 = max(a, b);
int m2 = max_3(5, 8, -1);
return 0;
}

Теперь создадим из приведенных выше исходников переносимые объектные файлы. Так мы сможем исследовать их содержимое и продемонстрировать то, о чем мы
говорили ранее. Обратите внимание: компиляция выполняется в Linux, поэтому
конечный результат должен быть в формате ELF (терминал 3.1).

Переносимые объектные файлы  123
Терминал 3.1. Компиляция исходников в соответствующие переносимые объектные файлы
$ gcc -c ExtremeC_examples_chapter3_1_funcs.c -o funcs.o
$ gcc -c ExtremeC_examples_chapter3_1.c -o main.o
$

Мы получили два переносимых объектных файла в формате ELF: funcs.o и main.o.
Элементы, которые мы обсуждали ранее, разделены по разным секциям. Чтобы
узнать, какие секции присутствуют в переносимом объектном файле, можно воспользоваться утилитой readelf (терминал 3.2).
Терминал 3.2. Содержимое объектного файла funcs.o в формате ELF
$ readelf -hSl funcs.o
[7/7]
ELF Header:
Magic:
7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class:
ELF64
Data:
2's complement, little endian
Version:
1 (current)
OS/ABI:
UNIX - System V
ABI Version:
0
Type:
REL (Relocatable file)
Machine:
Advanced Micro Devices X86-64
...
Number of section headers:
12
Section header string table index: 11
Section Headers:
[Nr] Name
Size
[ 0]
0000000000000000
[ 1] .text
0000000000000045
...
[ 3] .data
0000000000000000
[ 4] .bss
0000000000000000
...
[ 9] .symtab
00000000000000f0
[10] .strtab
0000000000000030
[11] .shstrtab
0000000000000059
...
$

Type
Address
Offset
EntSize
Flags
Link Info Align
NULL
0000000000000000 00000000
0000000000000000
0
0
0
PROGBITS
0000000000000000 00000040
0000000000000000
AX
0
0
1
PROGBITS
0000000000000000
0000000000000000
WA
0
NOBITS
0000000000000000
0000000000000000
WA
0

00000085
0
1
00000085
0
1

SYMTAB
0000000000000000
0000000000000018
10
STRTAB
0000000000000000
0000000000000000
0
STRTAB
0000000000000000
0000000000000000
0

00000110
8
8
00000200
0
1
00000278
0
1

124  Глава 3



Объектные файлы

Как видите, данный переносимый объектный файл состоит из 11 секций. Принадлежащие к уже знакомым нам элементам секции выделены жирным шрифтом. Секция
.text содержит все машинные инструкции для единицы трансляции. В секциях
.data и .bss находятся соответственно значения для инициализированных глобальных переменных и количество байтов, необходимых для неинициализированных
глобальных переменных. Секция .symtab хранит таблицу символов.
Обратите внимание: оба наших объектных файла состоят из одних и тех же секций,
но имеют разное содержимое. Поэтому мы не станем показывать аналогичный вывод для main.o.
Как уже упоминалось ранее, в одной из секций ELF-файла находится таблица
символов. Мы уже подробно рассматривали эту таблицу и ее записи в предыдущей
главе. Сейчас же поговорим о том, как с ее помощью компоновщик генерирует исполняемые и разделяемые объектные файлы. Но сначала обращу ваше внимание
на один аспект, который еще не затрагивался. Он будет касаться того, почему эти
объектные файлы называются переносимыми.
Выведем таблицу символов для funcs.o. В предыдущей главе мы делали это с помощью objdump, но теперь воспользуемся утилитой readelf (терминал 3.3).
Терминал 3.3. Таблица символов объектного файла funcs.o
$ readelf -s funcs.o
Symbol
Num:
0:
...
6:
7:
8:
9:
$

table '.symtab' contains 10 entries:
Value
Size Type
Bind
0000000000000000
0 NOTYPE LOCAL

Vis
DEFAULT

0000000000000000
0000000000000000
0000000000000000
0000000000000016

DEFAULT
DEFAULT
DEFAULT
DEFAULT

0
0
22
47

SECTION
SECTION
FUNC
FUNC

LOCAL
LOCAL
GLOBAL
GLOBAL

Ndx Name
UND
7
5
1 max
1 max_3

Как можно видеть в столбце Value, функциям max и max_3 присвоены адреса соответственно 0 и 22 (16 в шестнадцатеричной системе). Это значит, что инструкции,
относящиеся к данным символам, являются смежными, а их адреса начинаются с 0.
Эти символы и соответствующие им машинные инструкции готовы к перемещению
на другие участки итогового исполняемого файла. Взглянем на таблицу символов
файла main.o (терминал 3.4).
Символы, относящиеся к глобальным переменным a и b, а также символ функции
main размещены по адресам, которые не похожи на итоговые. Это признак переносимого объектного файла. Как я уже говорил, символы в переносимых объектных
файлах не обладают итоговыми, абсолютными адресами; данная информация
определяется на этапе компоновки.

Исполняемые объектные файлы   125
Терминал 3.4. Таблица символов объектного файла main.o
$ readelf -s main.o
Symbol
Num:
0:
...
8:
9:
10:
11:
12:
13:
$

table '.symtab' contains 14 entries:
Value
Size Type
Bind
0000000000000000
0 NOTYPE LOCAL

Vis
DEFAULT

Ndx Name
UND

0000000000000000
0000000000000004
0000000000000000
0000000000000000
0000000000000000
0000000000000000

DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT

3
3
1
UND
UND
UND

4
4
69
0
0
0

OBJECT
OBJECT
FUNC
NOTYPE
NOTYPE
NOTYPE

GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL
GLOBAL

a
b
main
_GLOBAL_OFFSET_TABLE_
max
max_3

В следующем разделе мы создадим из данных файлов исполняемую программу.
Вы увидите, как изменится таблица символов.

Исполняемые объектные файлы
Теперь пришло время поговорить об исполняемых объектных файлах. Вы уже
должны знать, что это один из конечных продуктов компиляции проекта на языке C. Они содержат те же элементы, что и их переносимые аналоги: машинные инструкции, значения для инициализированных глобальных переменных и таблицу
символов. Отличается только расположение этих элементов. Наше обсуждение
будет вестись в контексте ELF-файлов, поскольку их легко сгенерировать и изучить
их внутреннюю структуру.
Чтобы получить исполняемый объектный файл в формате ELF, вернемся к примеру 3.1. В предыдущем разделе мы сгенерировали переносимые объектные файлы
из двух исходников. Теперь скомпонуем их в исполняемую программу.
Как уже объяснялось в предыдущей главе, для этого нужно выполнить команду,
показанную в терминале 3.5.
Терминал 3.5. Компоновка скомпилированных объектных файлов из примера 3.1
$ gcc funcs.o main.o -o ex3_1.out
$

В предыдущем разделе мы говорили о секциях, которые присутствуют в ELFфайлах. Следует сказать, что исполняемые объектные файлы этого формата состоят
из большего количества секций, но содержат и ряд сегментов. То же самое относится и к разделяемым объектным файлам, но об этом чуть позже. Каждый сегмент
состоит из определенного количества секций (от нуля и больше), сгруппированных
по своему содержимому.

126  Глава 3



Объектные файлы

Например, все секции с машинными инструкциями попадают в один сегмент.
В главе 4 вы увидите, что эти сегменты аккуратно ложатся на статические сегменты
памяти активного процесса.
Взглянем на содержимое исполняемого файла, чтобы понять, о чем идет речь.
Выводить секции и сегменты объектного файла в формате ELF, в том числе и исполняемого, можно с помощью уже знакомой нам команды (терминал 3.6).
Терминал 3.6. Содержимое исполняемого объектного файла ex3_1.out в формате ELF
$ readelf -hSl ex3_1.out
ELF Header:
Magic:
7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class:
ELF64
Data:
2's complement, little endian
Version:
1 (current)
OS/ABI:
UNIX - System V
ABI Version:
0
Type:
DYN (Shared object file)
Machine:
Advanced Micro Devices X86-64
Version:
0x1
Entry point address:
0x4f0
Start of program headers:
64 (bytes into file)
Start of section headers:
6576 (bytes into file)
Flags:
0x0
Size of this header:
64 (bytes)
Size of program headers:
56 (bytes)
Number of program headers:
9
Size of section headers:
64 (bytes)
Number of section headers:
28
Section header string table index: 27
Section Headers:
[Nr] Name
Type
Address
Offset
Size
EntSize
Flags Link Info Align
[ 0]
NULL
0000000000000000 00000000
0000000000000000
0000000000000000
0
0
0
[ 1] .interp
PROGBITS
0000000000000238 00000238
000000000000001c
0000000000000000
A
0
0
1
[ 2] .note.ABI-tag
NOTE
0000000000000254 00000254
0000000000000020
0000000000000000
A
0
0
4
[ 3] .note.gnu.build-i NOTE
0000000000000274 00000274
0000000000000024
0000000000000000
A
0
0
4
...
[26] .strtab
STRTAB
0000000000000000 00001678
0000000000000239
0000000000000000
0
0
1
[27] .shstrtab
STRTAB
0000000000000000 000018b1
00000000000000f9
0000000000000000
0
0
1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),

Исполняемые объектные файлы   127
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
Program Headers:
Type
Offset
VirtAddr
PhysAddr
FileSiz
MemSiz
Flags
Align
PHDR
0x0000000000000040
0x0000000000000040 0x0000000000000040
0x00000000000001f8
0x00000000000001f8 R
0x8
INTERP
0x0000000000000238
0x0000000000000238 0x0000000000000238
0x000000000000001c
0x000000000000001c R
0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
...
GNU_EH_FRAME 0x0000000000000714
0x0000000000000714 0x0000000000000714
0x000000000000004c
0x000000000000004c R
0x4
GNU_STACK
0x0000000000000000
0x0000000000000000 0x0000000000000000
0x0000000000000000
0x0000000000000000 RW
0x10
GNU_RELRO
0x0000000000000df0
0x0000000000200df0 0x0000000000200df0
0x0000000000000210
0x0000000000000210 R
0x1
Section to Segment mapping:
Segment Sections...
00
01
.interp
02
.interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr
.gnu.version .gnu.version_r .rela.dyn .init .plt .plt.got .text .fi ni .rodata
.eh_frame_hdr .eh_frame
03
.init_array .fi ni_array .dynamic .got .data .bss
04
.dynamic
05
.note.ABI-tag .note.gnu.build-id
06
.eh_frame_hdr
07
08
.init_array .fini_array .dynamic .got
$

Этот вывод имеет несколько нюансов.
zz С точки зрения формата ELF этот объектный файл является разделяемым.

Иными словами, в ELF исполняемый объектный файл отличается от разделяемого только наличием определенных сегментов наподобие INTERP. Этот
сегмент (который на самом деле ссылается на секцию .interp) используется
загрузчиком для запуска и выполнения исполняемого файла.
zz Четыре сегмента выделены жирным шрифтом. Первый, INTERP, уже рассмотрен
в предыдущем пункте. Второй, TEXT, содержит все секции с машинными инструкциями. В третьем, DATA, находятся все значения, которые будут использо-

ваться для инициализации глобальных переменных и других ранних структур.
Четвертый сегмент ссылается на секцию с информацией, которая относится
к динамической компоновке, такой как местоположение разделяемых объектных
файлов, загружаемых во время выполнения.

128  Глава 3



Объектные файлы

zz Если сравнивать с переносимым разделяемым объектным файлом, здесь есть

больше секций, которые, вероятно, содержат данные, необходимые для загрузки
и выполнения программы.
Как уже объяснялось в предыдущем разделе, в таблице символов переносимого
объектного файла нет никаких абсолютных итоговых адресов. Это связано с тем,
что секции, содержащие машинные инструкции, еще не скомпонованы.
Если копнуть чуть глубже, то процесс компоновки заключается в извлечении всех
однотипных секций из заданных переносимых объектных файлов и объединении
их в более крупные секции с последующим сохранением в конечный исполняемый
или разделяемый объектный файл. Таким образом, только после данного этапа
символам можно придать итоговый вид и присвоить адреса, которые больше не будут меняться. В исполняемом объектном файле абсолютными являются обычные
адреса, а в разделяемом — относительные. Более подробно поговорим об этом
в разделе, посвященном динамическим библиотекам.
Рассмотрим таблицу символов исполняемого файла ex3_1.out. Таблица содержит
много записей, поэтому в терминале 3.7 она сокращена.
Терминал 3.7. Таблица символов исполняемого объектного файла ex3_1.out
$ readelf -s ex3_1.out
Symbol table '.dynsym' contains 6 entries:
Num:
Value
Size Type
Bind
0: 0000000000000000
0 NOTYPE LOCAL
...
5: 0000000000000000
0 FUNC
WEAK
__cxa_finalize@GLIBC_2.2.5 (2)
Symbol table '.symtab' contains 66 entries:
Num:
Value
Size Type
Bind
0: 0000000000000000
0 NOTYPE LOCAL
...
45: 0000000000201000
0 NOTYPE WEAK
46: 0000000000000610
47 FUNC
GLOBAL
47: 0000000000201014
4 OBJECT GLOBAL
48: 0000000000201018
0 NOTYPE GLOBAL
49: 0000000000000704
0 FUNC
GLOBAL
50: 00000000000005fa
22 FUNC
GLOBAL
51: 0000000000000000
0 FUNC
GLOBAL
__libc_start_main@@GLIBC_
...
64: 0000000000000000
0 FUNC
WEAK
__cxa_finalize@@GLIBC_2.2
65: 00000000000004b8
0 FUNC
GLOBAL
$

Vis
DEFAULT

Ndx Name
UND

DEFAULT

UND

Vis
DEFAULT

Ndx Name
UND

DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT
DEFAULT

22
13
22
22
14
13
UND

DEFAULT

UND

DEFAULT

data_start
max_3
b
_edata
_fini
max

10 _init

Статические библиотеки  129

Как видите, исполняемый объектный файл содержит две разные таблицы символов.
Первая, .dynsym, включает символы, которые должны быть разрешены во время загрузки программы, а во второй, .symtab, находится все то же, что и в динамической
таблице, плюс все разрешенные символы.
Разрешенные символы имеют абсолютные адреса, полученные на этапе компоновки. Адреса функций max и max_3 выделены жирным шрифтом.
На этом я завершаю краткий обзор исполняемых объектных файлов. В следующем
разделе речь пойдет о статических библиотеках.

Статические библиотеки
Как уже объяснялось, статические библиотеки — один из возможных продуктов
компиляции проекта на языке C. В этом разделе мы поговорим о том, что они собой
представляют, как их создают и используют. Затем плавно перейдем к обсуждению
динамических библиотек.
Статическая библиотека в Unix — обычный архив с переносимыми объектными
файлами. Как правило, она компонуется с другими объектными файлами в целях
получения итоговой исполняемой программы.
Обратите внимание: сами по себе статические библиотеки не являются объектными файлами. Они скорее служат их контейнерами. Иными словами, это не ELFфайлы в Linux и не Mach-O-файлы в macOS. Это просто архивы, созданные Unixутилитой ar.
Перед компоновкой из статической библиотеки извлекаются переносимые объектные файлы. Затем компоновщик ищет в них неопределенные символы и пытается
их разрешить.
Создадим статическую библиотеку для проекта на C/C++ с несколькими исходными файлами. Вначале нужно создать переносимые объектные файлы. Скомпилировав все исходники, вы можете воспользоваться архиватором ar, чтобы создать
архив со статической библиотекой.
В системах семейства Unix применительно к статическим библиотекам действует
общепринятое соглашение об именовании. Имя файла должно начинаться с lib
и иметь расширение .a . В разных операционных системах могут действовать
разные правила; например, в Microsoft Windows статические библиотеки имеют
расширение .lib.
Представьте гипотетический проект на языке C с исходными файлами вида aa.c,
bb.c и вплоть до zz.c. Чтобы сгенерировать из них переносимые объектные файлы,

130  Глава 3



Объектные файлы

их нужно скомпилировать примерно так, как показано в терминале 3.8. Напомню,
что процесс компиляции был рассмотрен во всех подробностях в предыдущей главе.
Терминал 3.8. Компиляция множества исходников в соответствующие переносимые
объектные файлы
$ gcc -c aa.c -o aa.o
$ gcc -c bb.c -o bb.o
.
.
.
$ gcc -c zz.c -o zz.o
$

В результате выполнения этих команд мы получим нужные нам переносимые объектные файлы. Обратите внимание: в большом проекте с тысячами исходников это
может занять много времени. Конечно, сборку можно существенно ускорить за счет
мощного компьютера и параллельной компиляции.
Для создания статической библиотеки достаточно выполнить команду, показанную
в терминале 3.9.
Терминал 3.9. Примерно так выглядит создание статической библиотеки из множества
переносимых объектных файлов
$ ar crs libexample.a aa.o bb.o ... zz.o
$

В итоге получится архив libexample.a со всеми переносимыми объектными файлами, созданными ранее. Я не стану объяснять назначение параметра crs, который
передается утилите ar; вы можете почитать о нем на странице https://stackoverflow.com/
questions/29714300/what-does-the-rcs-option-in-ar-do.
Архив, который создает команда ar, может быть и несжатым. Это всего
лишь способ объединения множества файлов в один. Данная команда — инструмент общего назначения, и с ее помощью можно создавать
архивы из файлов любого рода.

Итак, мы знаем, как создается статическая библиотека. Попробуем сделать это
сами.
В примере 3.2 представлен проект на языке C с определениями геометрических
функций, которые нужно объединить в библиотеку и сделать доступными для

Статические библиотеки  131

других приложений. Здесь мы имеем дело с тремя исходниками и одним заголовком.
Из этих трех исходных файлов нужно создать статическую библиотеку под названием libgeometry.a. В сочетании с заголовочным файлом ее можно будет использовать для написания другой программы, которая будет применять определенные
в библиотеке геометрические функции.
Содержимое исходников и заголовка показано в листингах ниже. В первом файле,
ExtremeC_examples_chapter3_2_geometry.h, находятся все объявления, которые
нужно экспортировать из нашей геометрической библиотеки. Эти объявления
будут использоваться нашим будущим приложением.
Все команды для создания объектных файлов, которые здесь приводятся, проверялись в Linux. Если вы хотите выполнить их в другой
операционной системе, то, возможно, придется их немного откорректировать.

Следует отметить, что будущее приложение должно зависеть только от объявлений, но не от определений. Поэтому сначала рассмотрим объявления нашей геометрической библиотеки (листинг 3.3).
Листинг 3.3. Заголовочный файл в примере 3.2 (ExtremeC_examples_chapter3_2_geometry.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_3_2_H
#define EXTREME_C_EXAMPLES_CHAPTER_3_2_H
#define PI 3.14159265359
typedef struct {
double x;
double y;
} cartesian_pos_2d_t;
typedef struct {
double length;
// в градусах
double theta;
} polar_pos_2d_t;
typedef struct {
double x;
double y;
double z;
} cartesian_pos_3d_t;

132  Глава 3



Объектные файлы

typedef struct {
double length;
// в градусах
double theta;
// в градусах
double phi;
} polar_pos_3d_t;
double to_radian(double deg);
double to_degree(double rad);
double cos_deg(double deg);
double acos_deg(double deg);
double sin_deg(double deg);
double asin_deg(double deg);
cartesian_pos_2d_t convert_to_2d_cartesian_pos(
const polar_pos_2d_t* polar_pos);
polar_pos_2d_t convert_to_2d_polar_pos(
const cartesian_pos_2d_t* cartesian_pos);
cartesian_pos_3d_t convert_to_3d_cartesian_pos(
const polar_pos_3d_t* polar_pos);
polar_pos_3d_t convert_to_3d_polar_pos(
const cartesian_pos_3d_t* cartesian_pos);
#endif

Второй файл является исходным и содержит определения тригонометрических
функций, которые соответствуют первым шести объявлениям в представленном
выше заголовке (листинг 3.4).
Листинг 3.4. Исходный файл с определениями тригонометрических функций
(ExtremeC_examples_chapter3_2_trigon.c)

#include
// нам нужно подключить заголовочный файл,
// поскольку мы хотим использовать макрос PI
#include "ExtremeC_examples_chapter3_2_geometry.h"
double to_radian(double deg) {
return (PI * deg) / 180;
}
double to_degree(double rad) {
return (180 * rad) / PI;
}

Статические библиотеки  133
double cos_deg(double deg) {
return cos(to_radian(deg));
}
double acos_deg(double deg) {
return acos(to_radian(deg));
}
double sin_deg(double deg) {
return sin(to_radian(deg));
}
double asin_deg(double deg) {
return asin(to_radian(deg));
}

Обратите внимание: заголовочный файл можно было бы и не включать, не содержи он
такие объявления, как PI или to_degree, которые используются в наших исходниках.
Еще один исходный файл содержит определения всех двумерных геометрических
функций (листинг 3.5).
Листинг 3.5. Исходный файл с определениями двумерных функций
(ExtremeC_examples_chapter3_2_2d.c)

#include
// нам нужно подключить заголовочный файл,
// поскольку мы хотим использовать типы polar_pos_2d_t,
// cartesian_pos_2d_t и т. д., а также тригонометрические
// функции, реализованные в другом исходнике
#include "ExtremeC_examples_chapter3_2_geometry.h"
cartesian_pos_2d_t convert_to_2d_cartesian_pos(
const polar_pos_2d_t* polar_pos) {
cartesian_pos_2d_t result;
result.x = polar_pos->length * cos_deg(polar_pos->theta);
result.y = polar_pos->length * sin_deg(polar_pos->theta);
return result;
}
polar_pos_2d_t convert_to_2d_polar_pos(
const cartesian_pos_2d_t* cartesian_pos) {
polar_pos_2d_t result;
result.length = sqrt(cartesian_pos->x * cartesian_pos->x +
cartesian_pos->y * cartesian_pos->y);
result.theta =
to_degree(atan(cartesian_pos->y / cartesian_pos->x));
return result;
}

134  Глава 3



Объектные файлы

Последний, четвертый файл содержит определения трехмерных геометрических
функций (листинг 3.6).
Листинг 3.6. Исходный файл с определениями трехмерных функций
(ExtremeC_examples_chapter3_2_3d.c)

#include
// нам нужно подключить заголовочный файл,
// поскольку мы хотим использовать типы polar_pos_3d_t,
// cartesian_pos_3d_t и т. д., а также тригонометрические
// функции, реализованные в другом исходнике
#include "ExtremeC_examples_chapter3_2_geometry.h"
cartesian_pos_3d_t convert_to_3d_cartesian_pos(
const polar_pos_3d_t* polar_pos) {
cartesian_pos_3d_t result;
result.x = polar_pos->length *
sin_deg(polar_pos->theta) * cos_deg(polar_pos->phi);
result.y = polar_pos->length *
sin_deg(polar_pos->theta) * sin_deg(polar_pos->phi);
result.z = polar_pos->length * cos_deg(polar_pos->theta);
return result;
}
polar_pos_3d_t convert_to_3d_polar_pos(
const cartesian_pos_3d_t* cartesian_pos) {
polar_pos_3d_t result;
result.length = sqrt(cartesian_pos->x * cartesian_pos->x +
cartesian_pos->y * cartesian_pos->y +
cartesian_pos->z * cartesian_pos->z);
result.theta =
to_degree(acos(cartesian_pos->z / result.length));
result.phi =
to_degree(atan(cartesian_pos->y / cartesian_pos->x));
return result;
}

Теперь создадим статическую библиотеку. Для этого прежде всего нужно скомпилировать приведенные выше исходники в соответствующие переносимые объектные файлы. Следует отметить, что мы не можем скомпоновать результаты
компиляции для получения исполняемой программы, поскольку в данном проекте
нет функции main. Поэтому переносимые объектные файлы можно либо оставить
как есть, либо объединить их в статическую библиотеку. Есть и третий вариант:
создать из них разделяемый объектный файл, но об этом мы поговорим в следу­
ющем разделе.
Здесь же мы попробуем их архивировать, чтобы получить статическую библиотеку. Для выполнения компиляции в Linux используются команды, показанные
в терминале 3.10.

Статические библиотеки   135
Терминал 3.10. Компиляция исходников в соответствующие переносимые объектные файлы
$ gcc -c ExtremeC_examples_chapter3_2_trigon.c -o trigon.o
$ gcc -c ExtremeC_examples_chapter3_2_2d.c -o 2d.o
$ gcc -c ExtremeC_examples_chapter3_2_3d.c -o 3d.o
$

Для архивации этих файлов в статическую библиотеку нужно выполнить команды,
приведенные в терминале 3.11.
Терминал 3.11. Создание статической библиотеки из переносимых объектных файлов
$ ar crs libgeometry.a trigon.o 2d.o3d.o
$ mkdir -p /opt/geometry
$ mv libgeometry.a /opt/geometry
$

Как видите, у нас получился файл libgeometry.a. Мы переместили его в каталог /opt/
geometry, чтобы любая другая программа могли его легко найти. Опять же, если передать команде ar параметр t, можно просмотреть содержимое архива (терминал 3.12).
Терминал 3.12. Вывод содержимого статической библиотеки
$ ar t /opt/geometry/libgeometry.a
trigon.o
2d.o
3d.o
$

В этом терминале видно, что статическая библиотека, как и ожидалось, состоит из
трех переносимых объектных файлов.
Итак, мы создали статическую библиотеку для примера 3.2 с геометрическими
функциями. Теперь попробуем воспользоваться ею в новом приложении. При работе со статическими библиотеками в языке C необходим доступ к предоставляемым
ими объявлениям. Они известны как публичный интерфейс библиотеки (чаще
можно встретить название API).
Объявления нужны на этапе компиляции, ведь компилятору нужно знать о существовании типов, сигнатур функций и т. д. Для этого используются заголовочные
файлы. Другая информация, например размеры типов и адреса функций, понадобится на более поздних этапах, таких как компоновка и загрузка.
Как уже отмечалось ранее, API в языке C (интерфейсы, предоставляемые библио­
текой) обычно представлены в виде набора заголовков. Поэтому для написания новой
программы, которая использует наши геометрические функции, достаточно иметь
заголовочный файл из примера 3.2 и статическую библиотеку libgeometry.a.

136  Глава 3



Объектные файлы

Для использования статической библиотеки нужно написать еще один исходный
файл, который будет подключать ее API и вызывать ее функции. Создадим новый
пример, 3.3, код которого показан в листинге 3.7.
Листинг 3.7. Главная функция, использующая некоторые геометрические операции
(ExtremeC_examples_chapter3_3.c)

#include
#include "ExtremeC_examples_chapter3_2_geometry.h"
int main(int argc, char** argv) {
cartesian_pos_2d_t cartesian_pos;
cartesian_pos.x = 100;
cartesian_pos.y = 200;
polar_pos_2d_t polar_pos =
convert_to_2d_polar_pos(&cartesian_pos);
printf("Polar Position: Length: %f, Theta: %f (deg)\n",
polar_pos.length, polar_pos.theta);
return 0;
}

Этот код подключает заголовочный файл из примера 3.2, поскольку ему нужны
объявления функций, которые он будет использовать.
Теперь данный исходник нужно скомпилировать, чтобы получить соответству­
ющий переносимый объектный файл для системы Linux (терминал 3.13).
Терминал 3.13. Компиляция примера 3.3
$ gcc -c ExtremeC_examples_chapter3_3.c -o main.o
$

Теперь нам необходимо скомпоновать результат со статической библиотекой,
которую мы создали в примере 3.2. В данном случае предполагается, что файл
libgeometry.a находится в каталоге /opt/geometry, как было показано в терминале 3.11. Следующая команда завершит сборку, выполнив компоновку и создав
исполняемый объектный файл ex3_3.out (терминал 3.14).
Терминал 3.14. Компоновка со статической библиотекой, созданной в примере 3.2
$ gcc main.o -L/opt/geometry -lgeometry -lm -o ex3_3.out
$

Чтобы понять, как работает эта команда, рассмотрим каждый отдельный параметр,
который в ней указан.
zz Параметр -L/opt/geometry сообщает компилятору gcc , что каталог /opt/
geometry входит в число тех мест, в которых можно найти статические и разде-

Статические библиотеки   137

ляемые библиотеки. По умолчанию компоновщик ищет библиотечные файлы
в традиционных каталогах, таких как /usr/lib или /usr/local/lib. Если не указать
параметр -L, то компоновщик выполнит поиск только по этим стандартным
путям.
zz Параметр -lgeometry говорит компилятору gcc, что ему нужно искать файл
libgeometry.a или libgeometry.so. Расширение принадлежит разделяемым би-

блиотекам, о которых мы поговорим в следующем разделе. Обратите внимание
на то, по какому принципу выбирается имя. Если, к примеру, передать параметр
-lxyz, то компоновщик будет искать в заданных и стандартных каталогах файл
libxyz.a или libxyz.so. В случае неудавшегося поиска компоновщик остановится и сгенерирует ошибку.
zz Параметр -lm сообщает компилятору gcc, что нужно искать еще одну библиотеку с именем libm.a или libm.so. Она хранит определения математических функций, доступных в glibc, — в частности, cos, sin и acos. Обратите внимание: мы

собираем пример 3.3 на компьютере под управлением Linux, поэтому в качестве
реализации стандартной библиотеки C используется glibc. В macOS и, возможно,
в других операционных системах данный параметр можно опустить.
zz Параметр -o ex3_3.out говорит компилятору gcc, что итоговый исполняемый
файл должен называться ex3_3.out.

Если все пройдет гладко, то после выполнения представленной выше команды
у вас получится исполняемый двоичный файл, который содержит все переносимые
объектные файлы, найденные в libgeometry.a, плюс main.o.
Стоит отметить, что после компоновки программа не будет зависеть от наличия
статических библиотек, поскольку все их содержимое встраивается в ее исполняемый файл. Иными словами, полученная программа является самостоятельной
и для ее запуска не требуется присутствие статической библиотеки.
Однако исполняемые файлы, полученные путем компоновки большого количества
статических библиотек, обычно отличаются огромными размерами. Чем больше
статических библиотек и переносимых объектных файлов у них внутри, тем крупнее программа. Иногда итоговый размер может достигать сотен мегабайт или даже
нескольких гигабайтов.
Разработчику приходится выбирать между размером двоичного файла и количеством его зависимостей. Ваша программа может быть компактной, но при этом
использовать разделяемые библиотеки. Это значит, что конечный исполняемый
файл не является самодостаточным и не сможет работать, если внешние разделяемые библиотеки отсутствуют или их не удается найти. Более подробно об этом
поговорим в следующих разделах.
Итак, было показано, что собой представляют статические библиотеки и как их
следует создавать и использовать. Кроме того, вы увидели, как сторонняя программа может взаимодействовать с доступным API, и узнали, как ее скомпоновать

138  Глава 3



Объектные файлы

с существующей статической библиотекой. В следующем разделе речь пойдет
о динамических библиотеках. Будет продемонстрировано, как из исходников примера 3.2 создать разделяемый объектный файл (динамическую библиотеку) вместо
статической библиотеки.

Динамические библиотеки
Динамические (они же разделяемые) библиотеки — еще один продукт компиляции
с возможностью повторного использования. Как можно догадаться по названию, от
статических библиотек они отличаются тем, что не входят в состав итогового исполняемого файла. Вместе этого их необходимо загружать и подключать во время
запуска процесса.
Поскольку статические библиотеки — часть исполняемой программы, компоновщик помещает в итоговый исполняемый файл все, что удается найти в их
переносимых файлах. Иными словами, компоновщик распознает неопределенные
символы и необходимые определения и пытается найти их в заданных переносимых
объектных файлах, после чего помещает их все в готовую программу.
Конечный продукт создается только после того, как будут найдены все неопределенные символы. То есть мы обнаруживаем все зависимости и находим соответствующий код на этапе компоновки. Если же говорить о динамических библиотеках, неопределенные символы могут оставаться и после работы компоновщика; их
поиск начинается в момент, когда программа готовится к загрузке и выполнению.
Таким образом, если у нас есть неопределенные динамические символы, то этап
компоновки необходимо видоизменить. Во время загрузки исполняемого файла
и его подготовки к выполнению в виде процесса используется динамический компоновщик, или просто загрузчик.
Поскольку неопределенные динамические символы отсутствуют в самом исполняемом файле, их нужно искать в другом месте. Они должны загружаться из разделяемых объектных файлов — близких родственников статических библиотек.
В большинстве Unix-подобных систем разделяемые объектные файлы имеют
расширение .so, а в macOS — .dylib.
Перед самим запуском процесса разделяемый объектный файл загружается в участок
памяти, доступный этому процессу. Данная процедура выполняется динамическим
компоновщиком (или загрузчиком), который загружает и выполняет программу.
В разделе, посвященном исполняемым объектным файлам, уже говорилось о том,
что исполняемые и разделяемые объектные файлы формата ELF состоят из сегментов, каждый из которых может содержать произвольное количество секций (от
нуля и больше). Эти разновидности ELF-файлов имеют два основных различия.

Динамические библиотеки  139

Во-первых, символы имеют относительные и абсолютные адреса, что позволяет
загружать их в рамках сразу нескольких процессов.
Это значит, в каждом процессе инструкция имеет свой адрес, но расстояние между
двумя инструкциями остается неизменным. Иными словами, адреса фиксируются
относительно смещения. Причиной тому факт, что переносимые объектные файлы
позиционно независимы. Мы поговорим об этом несколько позже.
Например, если две инструкции процесса находятся по адресам 100 и 200, то
в другом процессе у них могут быть адреса 140 и 240, а еще в одном — 323 и 423.
Расстояние между адресами является абсолютным, но сами адреса меняются.
Эти две инструкции всегда остаются на расстоянии 100 адресов друг от друга.
Во-вторых, в разделяемых объектных файлах, в отличие от исполняемых, нет
сегментов для загрузки программы. Это фактически означает, что разделяемые
объектные файлы нельзя выполнять.
Прежде чем углубляться в подробности обращения разных процессов к разделя­
емым объектным файлам, следует показать, как эти файлы создаются и используются. Создадим динамические библиотеки из примера 3.2, с которым мы работали
в предыдущем разделе.
Как вы помните, мы создали статическую библиотеку с геометрическими функциями. В этом разделе мы скомпилируем тот же исходный код, чтобы получить
из него разделяемый объектный файл. В терминале 3.15 показаны команды для
компиляции трех исходников в соответствующие переносимые объектные файлы. Единственное отличие от примера 3.2 — параметр -fPIC, который передается
компилятору gcc.
Терминал 3.15. Компиляция исходников из примера 3.2 в соответствующие позиционно
независимые переносимые объектные файлы
$ gcc -c ExtremeC_examples_chapter3_2_2d.c -fPIC -o 2d.o
$ gcc -c ExtremeC_examples_chapter3_2_3d.c -fPIC -o 3d.o
$ gcc -c ExtremeC_examples_chapter3_2_trigon.c -fPIC -o trigon.o
$

Глядя на эти команды, можно заметить дополнительный параметр, -fPIC, который
мы указали при компиляции исходников. Он обязателен, если вам нужно создать
динамическую библиотеку из набора переносимых объектных файлов. PIC расшифровывается как position independent code (позиционно независимый код). Как я уже
объяснял, позиционная независимость означает, что инструкции внутри переносимого объектного файла имеют не фиксированные, а относительные адреса; таким
образом, в разных процессах они могут находиться на разных участках памяти.
Причиной тому — наш способ использования динамических библиотек.

140  Глава 3



Объектные файлы

Нет никакой гарантии, что динамический компоновщик будет загружать разделя­
емый объектный файл по одному и тому же адресу для разных процессов. На самом
деле загрузчик отображает разделяемый объектный файл в память, и диапазоны
адресов у этих отображений могут различаться. Если бы адреса инструкций были
абсолютными, то мы не смогли бы загружать одну и ту же динамическую библиотеку сразу в несколько участков памяти, принадлежащих разным процессам.
Более подробную информацию о том, как работает динамическая загрузка программ и разделяемых объектных файлов, можно найти по
следующим ссылкам:
yy https://software.intel.com/sites/default/files/m/a/1/e/dsohowto.pdf;
yy https://www.technovelty.org/linux/plt-and-got-the-key-to-code-sharingand-dynamic-libraries.html.

Чтобы создать динамическую библиотеку, нам снова нужно будет воспользоваться компилятором (в нашем случае это gcc). В отличие от статической библиотеки,
которая является обычным архивом, разделяемый объектный файл остается объектным. Поэтому его нужно создать с помощью того же компоновщика (такого как ld),
который мы использовали для генерации переносимых объектных файлов.
Вы уже знаете, что в большинстве Unix-подобных систем для этого предусмотрена
утилита ld. Но по причинам, которые были изложены в предыдущей главе, я настоятельно рекомендую не использовать ее напрямую.
В терминале 3.16 показана команда, которая позволяет создать динамическую
библиотеку из набора переносимых объектных файлов, скомпилированных с параметром -fPIC.
Терминал 3.16. Создание динамической библиотеки из переносимых объектных файлов
$ gcc -shared 2d.o 3d.o trigon.o -o libgeometry.so
$ mkdir -p /opt/geometry
$ mv libgeometry.so /opt/geometry
$

В первой команде мы передали параметр -shared, чтобы компилятор создал разделяемый объектный файл из переносимых. В результате получилась библиотека
под названием libgeometry.so. Мы переместили ее в каталог /opt/geometry, чтобы
другие программы, которые хотят ее использовать, могли легко к ней обращаться.
Дальше нужно снова скомпилировать и скомпоновать пример 3.3.
Ранее мы компоновали пример 3.3 с созданной нами статической библиотекой
libgeometry.a. Здесь мы повторим этот процесс, однако на сей раз выполним компоновку с разделяемым объектным файлом libgeometry.so.

Динамические библиотеки  141

На первый взгляд, всё (особенно команды) выглядит без изменений, но это не так.
Мы скомпонуем пример 3.3 с libgeometry.so вместо libgeometry.a; более того,
динамическая библиотека не встраивается в итоговый исполняемый файл, а загружается во время его запуска (терминал 3.17). Прежде чем приступать к повторной компоновке примера 3.3, не забудьте убрать файл статической библиотеки,
libgeometry.a, из каталога /opt/geometry.
Терминал 3.17. Компоновка примера 3.3 с собранным нами разделяемым объектным файлом
$ rm -fv /opt/geometry/libgeometry.a
$ gcc -c ExtremeC_examples_chapter3_3.c -o main.o
$ gcc main.o -L/opt/geometry-lgeometry -lm -o ex3_3.out
$

Как уже объяснялось ранее, параметр -lgeometry заставляет компилятор найти
библиотеку, статическую или разделяемую, и скомпоновать ее с остальными объектными файлами. Поскольку статическую библиотеку мы удалили, компилятор
остановит свой выбор на динамической. Но даже если заданная библиотека существует в двух вариантах, при компоновке программы gcc отдает предпочтение
разделяемому объектному файлу.
При попытке запустить файл ex3_3.out вы, скорее всего, получите ошибку, показанную в терминале 3.18.
Терминал 3.18. Попытка запустить пример 3.3
$ ./ex3_3.out
./ex3_3.out: error while loading shared libraries: libgeometry.so:
cannot open shared object file: No such file or directory
$

Эта ошибка нам еще не попадалась, поскольку до сих пор мы использовали статическую компоновку со статической библиотекой. Но теперь мы пытаемся запустить
программу, у которой есть динамические зависимости, поэтому нам необходимо
предоставить ей соответствующие библиотечные файлы. Но сначала разберемся,
что на самом деле произошло и почему мы получили данное сообщение.
Исполняемый файл ex3_3.out зависит от библиотеки libgeometry.so, внутри которой находятся некоторые из его зависимостей. Следует отметить, что с файлом
libgeometry.a все иначе. После компоновки со статической библиотекой исполняемый файл получается самодостаточным, поскольку в него копируется все ее
содержимое; таким образом, он больше не зависит от ее существования.
Описанное не относится к разделяемым объектным файлам. Мы получили ошибку, так как загрузчику программы (динамическому компоновщику) не удалось

142  Глава 3



Объектные файлы

найти libgeometry.so по своим стандартным поисковым путям. Поэтому нам нужно
добавить к ним каталог /opt/geometry, в котором находится файл libgeometry.so.
Для этого нужно обновить переменную среды LD_LIBRARY_PATH так, чтобы она
указывала на текущий каталог.
Загрузчик проверит значение этой переменной среды и выполнит поиск необходимых разделяемых библиотек по заданному пути. Следует отметить, что в одной
переменной можно указать несколько путей (используя : в качестве разделителя)
(терминал 3.19).
Терминал 3.19. Запуск примера 3.3 с указанием LD_LIBRARY_PATH
$ export LD_LIBRARY_PATH=/opt/geometry
$ ./ex3_3.out
Polar Position: Length: 223.606798, Theta: 63.434949 (deg)
$

На сей раз программа успешно запустилась! Это значит, ее загрузчик нашел разделяемый объектный файл, а динамический компоновщик загрузил из данного
файла все необходимые символы.
Обратите внимание: в терминале 3.19 для изменения LD_LIBRARY_PATH использовалась команда export. Но переменные среды часто указывают вместе с запуском
программы. Это показано в терминале 3.20. В обоих случаях результат будет
идентичный.
Терминал 3.20. Запуск примера 3.3 с указанием LD_LIBRARY_PATH в рамках самой команды
$ LD_LIBRARY_PATH=/opt/geometry ./ex3_3.out
Polar Position: Length: 223.606798, Theta: 63.434949 (deg)
$

Компонуя программу с несколькими разделяемыми объектными файлами, как
в данном примере, мы сообщаем системе о том, что в ходе ее запуска нужно найти
и загрузить ряд динамических библиотек. Поэтому при запуске программы загрузчик в первую очередь автоматически ищет эти разделяемые объектные файлы
и отображает нужные символы в подходящие адреса памяти, доступные процессу.
Только после этого процессор начинает выполнять код.

Ручная загрузка разделяемых библиотек
Разделяемые объектные файлы можно загружать и использовать иначе, не задействуя загрузчик (динамический компоновщик), который делает это автоматически. Идея в том, что программист загружает динамическую библиотеку вручную
непосредственно перед использованием ее символов (функций). У этого подхода

Динамические библиотеки  143

есть свои варианты применения, о которых мы поговорим после рассмотрения
приведенных здесь примеров.
В примере 3.4 показана отложенная (или ручная) загрузка разделяемого объектного файла уже после этапа компоновки. Исходники позаимствованы из
примера 3.3, только библиотека libgeometry.so загружается вручную внутри
самой программы.
Прежде чем продолжать, необходимо заново сгенерировать libgeometry.so, используя немного другой метод, иначе пример 3.4 не будет работать. В Linux для
этого нужно выполнить команду, показанную в терминале 3.21.
Терминал 3.21. Компоновка libgeometry.so со стандартной математической библиотекой
$ gcc -shared 2d.o 3d.o trigon.o -lm -o libgeometry.so
$

Обратите внимание на новый параметр, -lm, который позволит выполнить компоновку разделяемого объектного файла со стандартной математической библиотекой libm.so. Мы используем его в связи с тем, что в ходе ручной загрузки файла
libgeometry.so его зависимости должны загружаться автоматически. В противном
случае мы получим сообщения об отсутствии таких символов, как cos или sqrt, которые нужны самой библиотеке libgeometry.so. Обратите внимание: стандартная
математическая библиотека не компонуется с итоговым исполняемым файлом; она
будет разрешена автоматически во время загрузки libgeometry.so.
Скомпоновав разделяемый объектный файл, мы можем приступить к примеру 3.4
(листинг 3.8).
Листинг 3.8. Ручная загрузка геометрической библиотеки в примере 3.4
(ExtremeC_examples_chapter3_4.c)

#include
#include
#include
#include "ExtremeC_examples_chapter3_2_geometry.h"
polar_pos_2d_t (*func_ptr)(cartesian_pos_2d_t*);
int main(int argc, char** argv) {
void* handle = dlopen ("/opt/geometry/libgeometry.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "%s\n", dlerror());
exit(1);
}

144  Глава 3



Объектные файлы

func_ptr = dlsym(handle, "convert_to_2d_polar_pos");
if (!func_ptr) {
fprintf(stderr, "%s\n", dlerror());
exit(1);
}
cartesian_pos_2d_t cartesian_pos;
cartesian_pos.x = 100;
cartesian_pos.y = 200;
polar_pos_2d_t polar_pos = func_ptr(&cartesian_pos);
printf("Polar Position: Length: %f, Theta: %f (deg)\n",
polar_pos.length, polar_pos.theta);
return 0;
}

Глядя на приведенный выше код, можно заметить, каким образом мы использовали
функции dlopen и dlsym, чтобы загрузить разделяемый объектный файл и выполнить в нем поиск символа convert_to_2d_polar_pos. Функция dlsym возвращает
указатель, с помощью которого можно вызвать нужную функцию.
Стоит отметить, что поиск разделяемого объектного файла выполняется в каталоге
/opt/geometry и в случае неудачи выводится сообщение об ошибке. Обратите внимание: в macOS динамические библиотеки имеют расширение .dylib, поэтому если
вы работаете в данной ОС, то приведенный выше код нужно подправить.
Показанные в терминале 3.22 команды компилируют листинг 3.8 и запускают исполняемый файл.
Терминал 3.22. Запуск примера 3.4
$ gcc ExtremeC_examples_chapter3_4.c -ldl -o ex3_4.out
$ ./ex3_4.out
Polar Position: Length: 223.606798, Theta: 63.434949 (deg)
$

Как видите, мы не скомпоновали программу с libgeometry.so, поскольку нам хотелось загружать эту библиотеку вручную, возникни такая необходимость. Данный
метод часто называют отложенной загрузкой разделяемых объектных файлов.
Несмотря на название, он может оказаться крайне полезным.
Один из примеров тому — ситуация, когда у вас есть разные реализации или версии
одной и той же библиотеки. Гибкость отложенной загрузки позволяет вам выбрать
нужные разделяемые объектные файлы, исходя из логики программы, и сделать
это в подходящий момент. Для сравнения: традиционный способ подразумевает
автоматическую разгрузку во время запуска программы, что дает вам меньше контроля за происходящим.

Резюме   145

Резюме
Эта глава в основном посвящена различным типам объектных файлов, которые
являются продуктами компиляции проектов на C/C++. Мы рассмотрели следующие темы:
zz API и ABI и их отличия;
zz разные форматы объектных файлов и краткую историю их появления. Все они

произошли от общего предка, но со временем их пути разделились, что в итоге
привело к появлению имеющихся на сегодня форматов;
zz переносимые объектные файлы и их внутреннюю структуру в контексте фор-

мата ELF;
zz исполняемые объектные файлы и их отличия от переносимых объектных фай-

лов. Мы также провели краткий обзор исполняемой программы в формате ELF;
zz статические и динамические таблицы символов, чтение их содержимого с по-

мощью инструментов командной строки;
zz статическую и динамическую компоновку, поиск по разным таблицам символов

при создании итогового двоичного файла или запуске программы;
zz статические библиотечные файлы. Мы обсудили тот факт, что они фактически

являются архивами с рядом переносимых объектных файлов;
zz разделяемые объектные файлы (динамические библиотеки). Мы рассмотрели,

как их можно собрать из нескольких переносимых объектных файлов;
zz позиционно независимый код. Вы узнали, почему переносимые объектные

файлы, участвующие в создании разделяемой библиотеки, должны быть позиционно независимыми.
В следующей главе мы рассмотрим еще один ключевой аспект программирования
на C/C++: структуру памяти процесса. Вы познакомитесь с различными сегментами памяти и узнаете, как писать код, который корректно работает с ними.

4

Структура
памяти процесса

В этой главе мы поговорим о памяти и ее структуре внутри процесса. Управление
памятью — крайне важная тема для любого программиста на языке C, и, чтобы
применять рекомендуемые методики, необходимо иметь общее представление о ее
структуре. На самом деле это касается не только C. Использование многих языков
программирования, таких как C++ или Java, возможно при наличии базового понимания устройства и принципа работы памяти; в противном случае вы столкнетесь
с серьезными проблемами, которые будет непросто выявить и исправить.
Вероятно, вы знаете, что в C управление памятью полностью ручное. Более того,
вся ответственность за выделение областей памяти и их освобождение после того,
как они больше не нужны, ложится на программиста.
В высокоуровневых языках программирования, таких как Java или C#, управление памятью происходит иначе и частично выполняется внутренней платформой
языка — например, Java Virtual Machine ( JVM) в случае с Java. В таких языках программист занимается лишь выделением памяти, но не ее освобождением. Ресурсы
освобождаются автоматически с помощью компонента под названием «сборщик
мусора».
Поскольку сборщиков мусора в C и C++ нет, о концепциях и проблемах, относящихся к управлению памятью, необходимо поговорить отдельно. Вот почему упомянутым темам посвящены эта и следующая главы, в которых мы будем обсуждать
общие вопросы работы с памятью в C/C++.
В этой главе мы займемся следующим:
zz рассмотрим структуру памяти типичного процесса. Это поможет нам исследо-

вать анатомию процесса и то, как он взаимодействует с памятью;
zz обсудим статическую и динамическую схемы размещения в памяти;
zz познакомимся с сегментами, которые можно встретить в вышеупомянутых

схемах. Вы увидите, что некоторые из них находятся в исполняемом объектном
файле, а остальные создаются во время загрузки процесса;

Внутреннее устройство памяти процесса   147
zz рассмотрим инструменты и команды, которые помогут нам обнаруживать сег-

менты и просматривать их содержимое — как в объектном файле, так и глубоко
внутри активного процесса.
В рамках этой главы мы познакомимся с двумя сегментами: стеком и кучей. Они
являются частью динамической схемы размещения в памяти процесса, и в них происходят все операции по выделению и освобождению ресурсов. Более подробно об
этих сегментах мы поговорим в главе 5, поскольку именно с ними программисты
взаимодействуют чаще всего.
Для начала обсудим внутреннее устройство памяти процесса. Так вы сможете
получить общее представление о том, на какие сегменты делится память активного
процесса и для чего предназначен каждый сегмент.

Внутреннее устройство памяти процесса
При каждом запуске исполняемого файла операционная система создает новый
процесс. Процесс — активная запущенная программа, которая загружена в память
и имеет уникальный идентификатор (process identifier, PID). ОС полностью контролирует создание и загрузку новых процессов.
Процесс перестает быть активным либо в результате нормального завершения,
либо при получении сигнала наподобие SIGTERM, SIGINT или SIGKILL, который
в итоге заставляет его прекратить работу. Сигналы SIGTERM и SIGINT можно игнорировать, но SIGKILL останавливает процесс немедленно и принудительно.
Краткое описание упомянутых выше сигналов:
yy SIGTERM — сигнал, запрашивающий завершение; дает возможность
процессу подготовиться к выходу;
yy SIGINT — сигнал прерывания, который обычно отправляется активным процессам путем нажатия Ctrl+C;
yy SIGKILL — сигнал немедленного завершения; принудительно закрывает процесс, не давая ему возможности подготовиться.

Когда операционная система создает процесс, одно из первых действий, которые
она совершает, — выделяет участок памяти с заранее определенной внутренней
структурой. Та выглядит более или менее похоже в разных ОС, особенно в тех,
которые принадлежат к семейству Unix.
В этой главе мы исследуем данную структуру и познакомимся с рядом важных
и полезных терминов, которые с ней связаны.

148  Глава 4



Структура памяти процесса

Память типичного процесса делится на несколько частей, которые называются
сегментами. Каждый из них представляет собой область памяти с определенной
задачей, предназначенную для хранения данных конкретного типа. Ниже приведен
список сегментов, из которых состоит память активного процесса:
zz сегмент неинициализированных данных или BSS (block started by symbol —

блок, начинающийся с символа);
zz сегмент данных;
zz текстовый сегмент или сегмент кода;
zz сегмент стека;
zz сегмент кучи.

В следующих разделах мы исследуем каждый из этих сегментов по отдельности
и узнаем, какую роль они играют в выполнении программы. А стек и кучу во всех
подробностях обсудим в главе 5. Но прежде, чем углубляться в подробности перечисленных выше сегментов, сначала познакомимся с инструментами, которые
помогут нам исследовать память.

Исследование структуры памяти
Unix-подобные операционные системы предоставляют набор инструментов для
исследования сегментов памяти процесса. В этом разделе вы узнаете, что одни из
этих сегментов находятся в исполняемом файле, а другие создаются динамически
на этапе выполнения, при создании процесса.
Как вы уже должны знать из предыдущих глав, исполняемый объектный файл
и процесс — две разные вещи, поэтому неудивительно, что для их исследования
применяются разные инструменты.
Благодаря содержанию главы 3 вы уже знаете, что исполняемый объектный файл
содержит машинные инструкции и за его создание отвечает компилятор. Но процесс — активная программа, созданная путем запуска исполняемого объектного
файла; она занимает участок основной памяти, а центральный процессор постоянно
извлекает и выполняет ее инструкции.
Процесс — динамическая сущность, выполняемая внутри операционной системы,
в то время как исполняемый объектный файл — просто набор данных с подготовленной начальной структурой, и на ее основе создается будущий процесс. Действительно, некоторые сегменты в структуре памяти активного процесса берутся
непосредственно из исполняемого файла, а остальные создаются динамически

Исследование статической схемы размещения в памяти  149

во время его загрузки. Первые называют статической схемой размещения в памяти,
а вторые — динамической.
Статическая и динамическая схемы включают определенный набор сегментов.
Содержимое статической заранее записывается в исполняемый объектный файл во
время компиляции исходного кода. А динамическая схема формируется инструкциями процесса, которые выделяют память для переменных и массивов и изменяют
ее в соответствии с логикой программы.
Учитывая все вышесказанное, содержимое статической памяти можно предсказать
по исходному коду или скомпилированному объектному файлу. Но с динамической схемой все сложнее, поскольку ее можно узнать, только запустив программу.
Кроме того, при каждом выполнении одной и той же программы содержимое динамической памяти может меняться. Иными словами, динамическое содержимое
любого процесса уникально, и исследовать его нужно, пока он работает.
Начнем с рассмотрения статической схемы размещения в памяти.

Исследование статической схемы
размещения в памяти
Инструменты, которые используются для исследования статической памяти, обычно
рассчитаны на объектные файлы. Для начала рассмотрим пример 4.1; это минимальная программа на языке C без какой-либо логики и без переменных (листинг 4.1).
Листинг 4.1. Минимальная программа на C (ExtremeC_examples_chapter4_1.c)

int main(int argc, char** argv) {
return 0;
}

Сначала нам нужно скомпилировать эту программу. В Linux мы используем gcc
(терминал 4.1).
Терминал 4.1. Компиляция примера 4.1 в Linux с помощью gcc
$ gcc ExtremeC_examples_chapter4_1.c -o ex4_1-linux.out
$

После успешной компиляции и компоновки мы получили итоговый исполняемый
объектный файл с именем ex4_1-linux.out. Он содержит предустановленную статическую схему размещения в памяти, принятую в операционной системе Linux;
эта схема будет частью всех процессов, основанных на данном исполняемом файле.

150   Глава 4



Структура памяти процесса

Первым инструментом, с которым вы познакомитесь, будет команда size. С ее
помощью можно вывести статическую схему размещения в памяти исполняемого
объектного файла.
В терминале 4.2 показано, как команда size позволяет просмотреть различные
сегменты, из которых состоит статическая память.
Терминал 4.2. Использование команды size для просмотра статических сегментов
файла ex4_1-linux.out
$ size ex4_1-linux.out
text
data
bss
1099
544
8
$

dec
1651

hex
673

filename
ex4_1-linux.out

Как видите, в состав статической схемы размещения входят сегменты Text, Data
и BSS. Их размеры приводятся в байтах.
Теперь скомпилируем тот же код из примера 4.1, но в другой операционной системе. Мы выбрали macOS и компилятор clang (терминал 4.3).
Терминал 4.3. Компиляция примера 4.1 с помощью clang в macOS
$ clang ExtremeC_examples_chapter4_1.c -o ex4_1-macos.out
$

Операционная система macOS, как и Linux, совместима с POSIX, поэтому тоже
должна содержать утилиту size (поскольку та входит в состав POSIX). Таким
образом, чтобы просмотреть статические сегменты файла ex4_1-macos.out, можно
задействовать ту же команду (терминал 4.4).
Терминал 4.4. Использование команды size для просмотра статических сегментов
файла ex4_1-macos.out
$ size ex4_1-macos.out
__TEXT __DATA __OBJC others
4096
0
0
4294971392
$ size -m ex4_1-macos.out
Segment __PAGEZERO: 4294967296
Segment __TEXT: 4096
Section __text: 22
Section __unwind_info: 72
total 94
Segment __LINKEDIT: 4096
total 4294975488
$

dec
4294975488

hex
100002000

Исследование статической схемы размещения в памяти   151

В этом терминале мы дважды воспользовались утилитой size; второй ее запуск
дал нам больше информации о найденных сегментах памяти. Вы могли заметить,
что в macOS, как и в Linux, присутствуют сегменты Text и Data, однако нет BSS.
Дело в том, что в macOS он тоже существует, просто команда size его не показывает. Сегмент BSS содержит неинициализированные глобальные переменные,
поэтому нет никакой необходимости выделять для него место в объектном файле —
достаточно лишь знать, сколько байтов он занимает.
В приведенных выше терминалах есть интересная деталь. В Linux размер сегмента
Text равен 1099 байтам, а в macOS — 4 Кбайт. Вдобавок можно заметить, что сегмент Data для минимальной программы на C в Linux занимает некое место, хотя
в macOS он пустой. Очевидно, что на разных платформах низкоуровневые аспекты
работы с памятью реализованы по-разному.
Несмотря на эту небольшую разницу между Linux и macOS, мы видим, что сегменты
Text, Data и BSS содержат в статической схеме размещения. Далее я последовательно
объясню, для чего используется каждый из этих сегментов. В следующих разделах мы
обсудим их по отдельности и увидим, как на них влияют малейшие изменения в коде.

Сегмент BSS
Начнем с сегмента BSS. Его название расшифровывается как block started by
symbol (блок, начинающийся с символа). С давних пор так обозначают области
памяти, зарезервированные для неинициализированных машинных слов. Сегмент
BSS фактически предназначен для хранения либо неинициализированных, либо
обнуленных глобальных переменных.
Расширим пример 4.1, добавив в него несколько неинициализированных глобальных переменных, и посмотрим, как это повлияет на сегмент BSS. В листинге 4.2
показан пример 4.2.
Листинг 4.2. Минимальная программа на C с несколькими неинициализированными
и обнуленными глобальными переменными (ExtremeC_examples_chapter4_2.c)

int global_var1;
int global_var2;
int global_var3 = 0;
int main(int argc, char** argv) {
return 0;
}

Переменные global_var1 и global_var2 — целочисленные глобальные переменные,
не прошедшие инициализацию. Снова воспользуемся командой size, чтобы узнать,
как изменился итоговый исполняемый объектный файл в Linux по сравнению
с примером 4.1 (терминал 4.5).

152   Глава 4



Структура памяти процесса

Терминал 4.5. Использование команды size для просмотра статических сегментов
файла ex4_2-linux.out
$ gcc ExtremeC_examples_chapter4_2.c -o ex4_2-linux.out
$ size ex4_2-linux.out
text
data
bss
dec
hex
filename
1099
544
16
1659
67b
ex4_2-linux.out
$

Если сравнить этот результат с аналогичным выводом в примере 4.1, то можно
заметить изменение размера сегмента BSS. То есть объявление неинициализированных или обнуленных глобальных переменных увеличивает сегмент BSS.
Эти особые глобальные переменные — часть статической схемы размещения, и место для них выделяется во время загрузки процесса; кроме того, они не покидают
память, пока процесс не завершит работу. Иными словами, имеют статический
жизненный цикл.
С архитектурной точки зрения в алгоритмах обычно лучше использовать локальные переменные. Слишком большое количество глобальных
переменных может увеличить размер двоичного файла. Кроме того,
хранение деликатных данных в глобальной области видимости может
отрицательно сказаться на безопасности. Проблемы с конкурентностью,
особенно такие, как состояние гонки, а также загрязнение пространства
имен и отсутствие информации о владельце, — это лишь некоторые осложнения, которые может вызвать применение глобальных переменных.

Скомпилируем пример 4.2 в macOS и посмотрим на вывод команды size (терминал 4.6).
Терминал 4.6. Использование команды size для просмотра статических сегментов
файла ex4_2-macos.out
$ clang ExtremeC_examples_chapter4_2.c -o ex4_2-macos.out
$ size ex4_2-macos.out
__TEXT __DATA __OBJC others
dec
hex
4096
4096
0
4294971392
4294979584
100003000
$ size -m ex4_2-macos.out
Segment __PAGEZERO: 4294967296
Segment __TEXT: 4096
Section __text: 22
Section __unwind_info: 72
total 94
Segment __DATA: 4096
Section __common: 12
total 12
Segment __LINKEDIT: 4096
total 4294979584
$

Исследование статической схемы размещения в памяти   153

И снова результат отличается от того, который мы видели в Linux. В этой ОС при
отсутствии глобальных переменных в сегменте BSS было выделено 8 байт. В примере 4.2 мы добавили три неинициализированные глобальные переменные общим
размером 12 байт, и после этого компилятор C в Linux расширил сегмент BSS еще
на 8 байт. В macOS сегмент BSS отсутствует в выводе команды size, но размер
сегмента data был увеличен с 0 до 4 байт (что в macOS является стандартным
размером страницы памяти). Это значит, что компилятор clang выделил для data
в схеме размещения новую страницу памяти. Опять-таки это просто свидетельствует
о том, насколько разнятся низкоуровневые аспекты схемы размещения в памяти
на разных платформах.
Неважно, сколько байтов памяти нужно выделить программе. Аллокатор
считает память в страницах; программа получает то количество страниц, которое покрывает ее нужды. Подробности об аллокаторе памяти
в Linux можно найти по ссылке https://www.kernel.org/doc/gorman/html/
understand/understand009.html.

В терминале 4.6 внутри сегмента _DATA есть секция __common размером 12 байт; на
самом деле так обозначается сегмент BSS, которого нет в выводе size. Эта секция
содержит три неинициализированные глобальные целочисленные переменные
общим размером 12 байт (по 4 байта каждая). Стоит также отметить, что неинициализированные глобальные переменные по умолчанию обнуляются. Лучшего
значения, чем 0, для них и не придумаешь.
Теперь поговорим о следующем сегменте в статической схеме размещения — Data.

Сегмент Data
Чтобы продемонстрировать, какого рода содержимое находится в этом сегменте,
объявим больше глобальных переменных, однако на сей раз инициализируем их
с помощью ненулевых значений. В листинге 4.3, основанном на примере 4.2, добавлены две новые инициализированные глобальные переменные.
Листинг 4.3. Минимальная программа на C с инициализированными и неинициализированными
глобальными переменными (ExtremeC_examples_chapter4_3.c)

int global_var1;
int global_var2;
int global_var3 = 0;
double global_var4 = 4.5;
char global_var5 = 'A';
int main(int argc, char** argv) {
return 0;
}

154   Глава 4



Структура памяти процесса

В терминале 4.7 показан вывод утилиты size для примера 4.3, полученный в Linux.
Терминал 4.7. Использование команды size для просмотра статических сегментов
файла ex4_3-linux.out
$ gcc ExtremeC_examples_chapter4_3.c -o ex4_3-linux.out
$ size ex4_3-linux.out
text
data
bss
dec
hex
filename
1099
553
20
1672
688
ex4_3-linux.out
$

Мы уже знаем, что сегмент Data предназначен для хранения инициализированных
глобальных переменных с ненулевыми значениями. Если сравнить вывод команды
size в примерах 4.2 и 4.3, то в глаза сразу бросается то, что сегмент Data увеличился
на 9 байт; это общий размер двух добавленных нами переменных (восьмибайтной
типа double и однобайтной типа char).
Взглянем на изменения в macOS (терминал 4.8).
Терминал 4.8. Использование команды size для просмотра статических сегментов
файла ex4_3-macos.out
$ clang ExtremeC_examples_chapter4_3.c -o ex4_3-macos.out
$ size ex4_3-macos.out
__TEXT __DATA __OBJC others
dec
hex
4096
4096
0
4294971392
4294979584
100003000
$ size -m ex4_3-macos.out
Segment __PAGEZERO: 4294967296
Segment __TEXT: 4096
Section __text: 22
Section __unwind_info: 72
total 94
Segment __DATA: 4096
Section __data: 9
Section __common: 12
total 21
Segment __LINKEDIT: 4096
total 4294979584
$

При первом запуске не видно никаких изменений, поскольку суммарный размер
всех глобальных переменных все еще намного меньше 4 Кбайт. Но вслед за этим
в сегменте _DATA появилась новая секция, __data. В памяти для нее было выделено
9 байт, что соответствует размеру инициализированных глобальных переменных,
которые мы добавили. А размер неинициализированных глобальных переменных,
как в примере 4.2 и в macOS, по-прежнему равен 12 байтам.
Следует обратить внимание и на то, что команда size показывает только размер
сегментов, но не их содержимое. Чтобы просмотреть то, что хранится внутри
сегментов объектного файла, каждая операционная система предоставляет свои

Исследование статической схемы размещения в памяти   155

инструменты. Например, в Linux содержимое ELF-файла можно проанализировать с помощью команд readelf и objdump, которые также позволяют исследовать
статическую схему размещения внутри объектных файлов. С кое-какими из этих
инструментов мы познакомились в предыдущих двух главах.
Помимо глобальных, есть также статические переменные, объявленные внутри
функций. При многократном вызове одной и той же функции эти переменные
не меняют свои значения. В зависимости от платформы и наличия значения они
могут храниться как в сегменте Data, так и в BSS. В листинге 4.4 показано, как
объявить статические переменные внутри функции.
Листинг 4.4. Объявление двух статических переменных: инициализированной
и неинициализированной

void func() {
static int i;
static int j = 1;
...
}

Как видите, переменные i и j — статические. Первая не инициализирована, а вторая имеет значение 1. Неважно, сколько раз будет выполнена функция func, — эти
переменные всегда будут хранить последние присвоенные им значения.
Поговорим о том, как все работает. На этапе выполнения эти переменные находятся либо в сегменте Data, либо в BSS (которые обладают статическим жизненным
циклом), и функция func имеет к ним доступ. Поэтому их, в сущности, и называют
статическими. Мы знаем, что переменная j хранится в сегменте Data, просто потому, что имеет начальное значение; в то же время переменная i не инициализирована, поэтому должна находиться в сегменте BSS.
Теперь познакомимся со второй командой для исследования содержимого сегмента
BSS в Linux, objdump. С ее помощью можно выводить сегменты памяти объектных
файлов. В macOS аналогичная команда называется gobjdump, но там ее нужно самостоятельно установить.
Попробуем проанализировать итоговый исполняемый файл, чтобы найти глобальные переменные, которые записываются в сегмент Data. Код примера 4.4 показан
в листинге 4.5.
Листинг 4.5. Инициализированные глобальные переменные, которые должны быть записаны
в сегмент Data (ExtremeC_examples_chapter4_4.c)

int
x = 33;
int
y = 0x12153467;
char z[6] = "ABCDE";

// 0x00000021

int main(int argc, char**argv) {
return 0;
}

156   Глава 4



Структура памяти процесса

В приведенном выше коде нет ничего сложного. Он просто объявляет три глобальные переменные с некими начальными значениями. После компиляции нам
нужно вывести содержимое сегмента Data, чтобы найти значения, которые в него
записываются.
В терминале 4.9 показано, как скомпилировать исходный код и просмотреть сегмент Data с помощью команды objdump.
Терминал 4.9. Использование команды objdump для просмотра содержимого сегмента Data
$ gccExtremeC_examples_chapter4_4.c -o ex4_4.out
$ objdump -s -j .data ex4_4.out
a.out:

file format elf64-x86-64

Contents of section .data:
601020 00000000 00000000 00000000 00000000
601030 21000000 67341512 41424344 4500
$

...............
!....4..ABCDE.

Объясню, как следует читать этот вывод, особенно находящееся в секции .data.
В первом столбце слева — адреса. В следующих четырех столбцах хранятся сами
данные; как видите, каждый столбец занимает 4 байта. Поэтому размер всей
строки равен 16 байтам. Последний столбец, расположенный справа, — представление байтов, содержащихся в предыдущих четырех столбцах, в кодировке
ASCII. Точка означает, что символ нельзя показать в алфавитно-цифровом виде.
Обратите внимание на параметры -s и -j .data: первый сообщает команде objdump
о том, что нужно вывести все содержимое заданной секции, а второй указывает
секцию .data.
Первая строчка занимает 16 байт и состоит из нулей. В ней нет никакой переменной, поэтому она нам неинтересна. Во второй строчке показано содержимое
сегмента Data, начиная с адреса 0x601030. Первые 4 байта — это значение переменной x в примере 4.4. Следующие 4 байта содержат значение переменной y .
Последние 6 байт выделены для символов внутри массива z. Его содержимое отчетливо видно в последнем столбце.
Если внимательно посмотреть на терминал 4.9, то можно заметить, что шестнадцатеричное представление десятеричного числа 33, 0x00000021, хранится в сегменте
как 0x21000000. То же самое относится к содержимому переменной y: мы записывали ее как 0x12153467, но после сохранения она имеет вид 0x67341512. Все выглядит
так, будто порядок следования байтов поменялся на противоположный.
Это явление можно объяснить тем фактом, что в целом порядок следования байтов
бывает двух типов: от старшего к младшему и от младшего к старшему. Если взять
число 0x12153467, то в первом случае оно не изменится, поскольку старший байт,

Исследование статической схемы размещения в памяти   157
0x12, идет первым. Во втором случае это число будет выглядеть как 0x67341512,
поскольку первым идет младший байт, 0x67.

Независимо от представления, в языке C мы всегда читаем корректное значение.
Порядок следования байтов — характеристика центрального процессора, поэтому
в разных архитектурах байты в итоговых объектных файлах могут размещаться
в разном порядке. Это одна из причин, почему исполняемый файл нельзя запустить
на платформе с другим порядком следования байтов.
Интересно, как этот вывод будет выглядеть в macOS? В терминале 4.10 показано,
как просмотреть содержимое сегмента Data с помощью команды gobjdump.
Терминал 4.10. Использование команды gobjdump в macOS для просмотра содержимого
сегмента Data
$ gcc ExtremeC_examples_chapter4_4.c -o ex4_4.out
$ gobjdump -s -j .data ex4_4.out
a.out:

file format mach-o-x86-64

Contents of section .data:
100001000 21000000 67341512 41424344 4500
$

!...g4..ABCDE.

Здесь все следует читать точно так же, как и в Linux (см. вывод в терминале 4.9).
Как видите, в macOS сегмент Data не содержит 16-байтных нулевых заголовков.
Двоичный файл, очевидно, был скомпилирован для процессора с порядком следования байтов от младшего к старшему.
Напоследок нужно сказать, что для исследования содержимого объектных файлов можно использовать и другие инструменты — например, readelf в Linux
и dwarfdump в macOS. Кроме того, двоичное содержимое объектного файла можно
читать с помощью таких утилит, как hexdump.
В следующем подразделе мы обсудим сегмент Text и узнаем, как его просматривать
с использованием objdump.

Сегмент Text
Как мы уже знаем из главы 2, в итоговый исполняемый файл записываются инструкции машинного уровня. Поскольку все машинные инструкции программы
находятся в сегменте Text (или Code), он должен находиться в исполняемом
объектном файле — а именно, в его статической схеме размещения. Процессор извлекает эти инструкции и выполняет их во время работы процесса.
Заглянем в сегмент Text реального исполняемого файла. В листинге 4.6 показан
пример 4.5, который представляет собой всего лишь пустую функцию main.

158   Глава 4



Структура памяти процесса

Листинг 4.6. Минимальная программа на C (ExtremeC_examples_chapter4_5.c)

int main(int argc, char** argv) {
return 0;
}

Здесь мы видим различные части итоговой исполняемой программы. Стоит отметить, что команда objdump доступна только в Linux; в других операционных
системах для этого предусмотрены другие инструменты.
В терминале 4.11 демонстрируется использование команды objdump для извлечения
содержимого разных секций, присутствующих в исполняемом объектном файле из
примера 4.5. Обратите внимание: в следующем выводе показаны только те ассемб­
лерные инструкции, которые относятся к функции main.
Терминал 4.11. Использование objdump для вывода содержимого секции, относящейся
к функции main
$ gcc ExtremeC_examples_chapter4_5.c -o ex4_5.out
$ objdump -S ex4_5.out
ex4_5.out:
file format elf64-x86-64
Disassembly of section .init:
0000000000400390 :
... truncated.
.
.
Disassembly of section .plt:
00000000004003b0 :
... truncated
00000000004004d6 :
4004d6:
55
4004d7:
48 89 e5
4004da:
b8 00 00 00 00
4004df:
5d
4004e0:
c3
4004e1:
66 2e 0f 1f 84 00 00
4004e8:
00 00 00
4004eb:
0f 1f 44 00 00
00000000004004f0 :
... truncated
.
.
.
0000000000400564 :
... truncated
$

push
mov
mov
pop
retq
nopw

%rbp
%rsp,%rbp
$0x0,%eax
%rbp

nopl

0x0(%rax,%rax,1)

%cs:0x0(%rax,%rax,1)

Исследование динамической схемы размещения в памяти   159

Здесь показано несколько секций с машинными инструкциями, включая .text,
.init и .plt. Все вместе они делают возможными загрузку и выполнение программы. Каждая из этих секций — часть сегмента Text, который входит в статическую
схему размещения в исполняемом объектном файле.
Наша программа в примере 4.5 содержит всего одну функцию, main, но в итоговом
исполняемом файле мы видим десяток других функций.
В выводе, представленном в терминале 4.11, видно, что перед вызовом функции
main выполняется другая логика. Как уже объяснялось в главе 2, в Linux эти функции обычно заимствуются из библиотеки glibc и используются для формирования
готовой программы.
В следующем разделе мы начнем исследовать динамическую схему размещения
процесса в памяти.

Исследование динамической схемы
размещения в памяти
Динамическая схема размещения находится в памяти процесса и существует до
тех пор, пока он не завершится. Процедурой запуска исполняемого объектного
файла занимается программа под названием «загрузчик». Он создает новый процесс
и его начальную схему размещения в памяти, которая должна быть динамической.
Для этого копируются сегменты, найденные в статической схеме размещения
исполняемого объектного файла. Затем к ним добавляется два новых сегмента.
И только после этого процесс может приступить к выполнению.
Если коротко, то в памяти активного процесса должно быть пять сегментов, три из
которых копируются непосредственно из статической схемы размещения исполняемого объектного файла, а остальные два создаются с нуля и называются стеком
и кучей. Последние являются динамическими сегментами и существуют только
в период работы процесса. Это значит, вы не найдете никакого упоминания о них
в исполняемом объектном файле.
В этом разделе наша главная задача — исследовать стек и кучу, а также познакомиться с инструментами и участками операционной системы, которые могут быть
здесь полезными. Время от времени мы можем называть эти сегменты динамической схемой размещения в памяти процесса, игнорируя три других сегмента,
которые копируются из объектного файла; в таком случае следует помнить, что
динамическая память процесса состоит из всех пяти сегментов.
Стек — область памяти, в которой по умолчанию выделяется место для переменных. Она имеет ограниченный размер, поэтому большие объекты в ней хранить

160  Глава 4



Структура памяти процесса

нельзя. Для сравнения, куча — более крупная и гибкая область памяти, в которой
могут поместиться большие объекты и массивы. Для работы с кучей нужен отдельный API, с которым мы познакомимся чуть позже.
Как вы помните, динамическая схема размещения — не то же самое, что динамическое выделение памяти. Не следует путать эти два понятия, поскольку они имеют
разный смысл! Позже мы более подробно обсудим разные методы выделения памяти и отдельно остановимся на динамическом.
Пять сегментов, из которых состоит динамическая память, ссылаются на разные
участки основной памяти, уже выделенные и доступные только соответству­
ющему процессу. Все эти сегменты — динамические в том смысле, что во время
выполнения их содержимое постоянно меняется; исключение составляет только
сегмент Text, который является статическим и неизменяемым в буквальном
смысле слова.
Исследование динамической памяти процесса требует отдельной процедуры.
Прежде чем приступать, нужно запустить процесс: написать пример, который
будет работать достаточно долго для того, чтобы мы могли получить доступ к его
динамической памяти, используя специальные инструменты.
В следующем подразделе мы рассмотрим пример того, как исследовать структуру
динамической памяти.

Отражение памяти
Начнем с простого примера. Код, представленный ниже, выполняется бесконечно
долго. Таким образом, мы получим процесс, который никогда не завершается, что
позволит нам изучить структуру его памяти. Конечно, закончив исследование,
мы сможем от него избавиться с помощью команды kill. В листинге 4.7 показан
код примера 4.6.
Листинг 4.7. Пример 4.6, который мы используем для исследования схемы динамической памяти
(ExtremeC_examples_chapter4_6.c)

#include // Needed for sleep function
int main(int argc, char** argv) {
// Бесконечный цикл
while (1) {
sleep(1); // Засыпаем на 1 секунду
};
return 0;
}

Исследование динамической схемы размещения в памяти  161

Как видите, это обычный бесконечный цикл, благодаря которому процесс сможет
работать сколь угодно долго. Таким образом, у нас будет время изучить его память.
Сначала соберем данный код.
Заголовок unistd.h доступен только в Unix-подобных (или, если быть
точным, в POSIX-совместимых) операционных системах. Это значит,
в системе Microsoft Windows, которая несовместима с POSIX, вместо
этого можно подключить заголовок windows.h.

В терминале 4.12 показано, как скомпилировать этот пример в Linux.
Терминал 4.12. Компиляция примера 4.6 в Linux
$ gcc ExtremeC_examples_chapter4_6.c -o ex4_6.out
$

Теперь запустим его. Чтобы командную строку можно было использовать в дальнейшем, запуск процесса выполняется в фоновом режиме (терминал 4.13).
Терминал 4.13. Запуск примера 4.6 в фоновом режиме
$ ./ ex4_6.out &
[1] 402
$

Итак, процесс выполняется в фоне. Если верить полученному выводу, его PID
равен 402. В будущем мы воспользуемся этим значением в целях принудительного
завершения программы. PID меняется при каждом запуске, поэтому у себя на компьютере вы, скорее всего, увидите другой номер. Обратите внимание: командная
строка снова становится доступной сразу после фонового запуска процесса, и в ней
можно будет выполнять дальнейшие команды.
Имея PID (process ID — идентификатор процесса), вы можете легко завершить процесс, используя команду kill. Например, если значение PID
равно 402, то следующая команда будет работать в любой операционной
системе семейства Unix: kill -9 402.

PID — идентификатор, который мы используем для исследования памяти процесса. Обычно система предоставляет собственные механизмы получения различных
свойств активных процессов на основе их PID. Но здесь нас интересуют только
подробности о динамической памяти, поэтому мы применим соответствующие
инструменты, доступные в Linux.

162  Глава 4



Структура памяти процесса

На компьютере под управлением Linux сведения о процессе можно найти в файлах
внутри каталога /proc. Он находится в специальной файловой системе под названием procfs, которая отличается от обычных ФС тем, что не предназначена собственно
для хранения данных; это скорее иерархический интерфейс для получения информации о разных свойствах отдельных процессов или системы в целом.
Файловая система procfs есть не только в Linux. Она обычно является
частью Unix-подобных операционных систем, хотя используют ее не все
они. Например, в FreeBSD она применяется, а в macOS — нет.

Теперь с помощью procfs просмотрим структуру памяти активного процесса.
Память процесса состоит из ряда отражений. Каждое из них представляет отдельную область памяти, отраженную в специальный файл или сегмент, являющийся частью процесса. Вскоре вы увидите, что у стека и кучи процесса есть
собственные отражения.
Один из способов применения procfs — наблюдение за текущими отражениями
процесса. Посмотрим, как это делается.
Мы знаем, что наш процесс имеет PID 402. С помощью команды ls можно вывести
содержимое каталога /proc/402, как показано в терминале 4.14.
Терминал 4.14. Вывод содержимого /proc/402
$ ls -l /proc/402
total of 0
dr-xr-xr-x 2 root
-rw-r--r-- 1 root
-r-------- 1 root
-r--r--r-- 1 root
--w------- 1 root
-r--r--r-- 1 root
-rw-r--r-- 1 root
-rw-r--r-- 1 root
-r--r--r-- 1 root
lrwxrwxrwx 1 root
-r-------- 1 root
lrwxrwxrwx 1 root
dr-x------ 2 root
dr-x------ 2 root
-rw-r--r-- 1 root
-r-------- 1 root
-r--r--r-- 1 root
...
$

root
root
root
root
root
root
root
root
root
root
root
root
root
root
root
root
root

0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0

Jul
Jul
Jul
Jul
Jul
Jul
Jul
Jul
Jul
Jul
Jul
Jul
Jul
Jul
Jul
Jul
Jul

15
15
15
15
15
15
15
15
15
15
15
15
15
15
15
15
15

22:28
22:28
22:28
22:28
22:28
22:28
22:28
22:28
22:28
22:28
22:28
22:28
22:28
22:28
22:28
22:28
22:28

attr
autogroup
auxv
cgroup
clear_refs
cmdline
comm
coredump_filter
cpuset
cwd -> /root/codes
environ
exe -> /root/codes/a.out
fd
fdinfo
gid_map
io
limits

Как видите, внутри /proc/402 есть много файлов и каталогов, и все они соответствуют определенному свойству процесса. Чтобы получить список отражений, нужно

Исследование динамической схемы размещения в памяти  163

просмотреть содержимое файла maps в каталоге /proc/402. Выведем файл /proc/402/
maps с помощью команды cat (терминал 4.15).
Терминал 4.15. Вывод содержимого /proc/402/maps
$ cat /proc/402/maps
00400000-00401000 r-xp 00000000 08:01 790655
extreme_c/4.6/ex4_6.out
00600000-00601000 r--p 00000000 08:01 790655
extreme_c/4.6/ex4_6.out
00601000-00602000 rw-p 00001000 08:01 790655
extreme_c/4.6/ex4_6.out
7f4ee16cb000-7f4ee188a000 r-xp 00000000 08:01 787362
x86_64-linux-gnu/libc-2.23.so
7f4ee188a000-7f4ee1a8a000 ---p 001bf000 08:01 787362
x86_64-linux-gnu/libc-2.23.so
7f4ee1a8a000-7f4ee1a8e000 r--p 001bf000 08:01 787362
x86_64-linux-gnu/libc-2.23.so
7f4ee1a8e000-7f4ee1a90000 rw-p 001c3000 08:01 787362
x86_64-linux-gnu/libc-2.23.so
7f4ee1a90000-7f4ee1a94000 rw-p 00000000 00:00 0
7f4ee1a94000-7f4ee1aba000 r-xp 00000000 08:01 787342
x86_64-linux-gnu/ld-2.23.so
7f4ee1cab000-7f4ee1cae000 rw-p 00000000 00:00 0
7f4ee1cb7000-7f4ee1cb9000 rw-p 00000000 00:00 0
7f4ee1cb9000-7f4ee1cba000 r--p 00025000 08:01 787342
x86_64-linux-gnu/ld-2.23.so
7f4ee1cba000-7f4ee1cbb000 rw-p 00026000 08:01 787342
x86_64-linux-gnu/ld-2.23.so
7f4ee1cbb000-7f4ee1cbc000 rw-p 00000000 00:00 0
7ffe94296000-7ffe942b7000 rw-p 00000000 00:00 0
7ffe943a0000-7ffe943a2000 r--p 00000000 00:00 0
7ffe943a2000-7ffe943a4000 r-xp 00000000 00:00 0
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0
[vsyscall]
$

.../
.../
.../
/lib/
/lib/
/lib/
/lib/

/lib/

/lib/
/lib/

[stack]
[vvar]
[vdso]

Как видите, результат состоит из множества строк. Каждая из них представляет
отражение памяти с диапазоном выделенных адресов (областью); она привязана
к определенному файлу или сегменту в динамической схеме размещения процесса.
У каждого отражения есть поля, разделенные одним или несколькими пробелами.
Их описание приводится ниже (слева направо).
zz Диапазон адресов — начальный и конечный адреса отраженного диапазона. Если

данная область отражена в файл, то путь к нему указан рядом. Это элегантный
способ отражения одного и того же загруженного разделяемого объектного
файла в разных процессах. Я уже упоминал об этом в главе 3.
zz Разрешения — этот столбец определяет, можно ли выполнять (x), читать (r)
или изменять (w ) содержимое. Область памяти также можно разделять (s )

164  Глава 4



Структура памяти процесса

с другими процессами или изолировать (p) ее в рамках процесса, которому она
принадлежит.
zz Сдвиг — если область отражена в файл, то это сдвиг относительно начала данно-

го файла. Если не отражена, то данный столбец обычно содержит 0.
zz Устройство — если область отражена в файл, то здесь указан номер устройства

(в формате m:n), которое хранит данный файл. Это, к примеру, может быть
номер жесткого диска, на котором находится разделяемый объектный файл.
zz inode — файл, в который отражена область памяти, должен находиться в фай-

ловой системе и иметь в ней свой номер inode. Последний указывается в данном столбце. Файл inode — абстракция в файловых системах наподобие ext4,
которые в основном используются в ОС семейства Unix. Номер inode может
относиться как к файлам, так и к каталогам, и его используют для доступа к их
содержимому.
zz Путь или описание — если область отражена в файл, то здесь указывается путь

к нему. В противном случае данный столбец может быть пустым или описывать
назначение области. Например, метка [stack] говорит о том, что это стек.
Файл maps содержит еще больше полезной информации о динамической схеме
размещения памяти процесса. Чтобы продемонстрировать это должным образом,
обратимся к новому примеру.

Стек
Сначала поговорим о таком сегменте, как стек. Это ключевая часть динамической
памяти любого процесса, предусмотренная почти во всех существующих архитектурах. В отражениях памяти она помечена как [stack].
Стек и куча хранят динамические данные, которые постоянно меняются во время
выполнения программы. Просмотреть динамическое содержимое этих сегментов
не слишком просто; в большинстве случаев для этого необходим отладчик наподобие gdb, который читает байты памяти активного процесса.
Как уже отмечалось ранее, стек обычно имеет ограниченный размер и плохо подходит для хранения крупных объектов. Если в этом сегменте закончится свободное
место, то процесс больше не сможет вызывать функции, поскольку стек активно
используется механизмом вызова. В таких случаях процесс принудительно завершается операционной системой. Переполнение стека — широко известная ошибка,
которая возникает при полном заполнении стека. Механизм вызова функций будет
рассмотрен чуть ниже.
Как уже объяснялось, стек — область памяти, в которой по умолчанию выделяется
место для переменных. Представьте, что вы объявили переменную внутри функции, как показано в листинге 4.8.

Исследование динамической схемы размещения в памяти   165
Листинг 4.8. Объявление локальной переменной, память для которой выделяется в стеке

void func() {
// память, необходимая для следующей переменной, выделяется в стеке
int a;
...
}

В данной функции при объявлении переменной не указано ничего, что позволило бы компилятору понять, в каком сегменте следует выделять память. В связи
с этим компилятор использует стек по умолчанию. Это место, с которого начинается выделение.
Термин «стек» происходит от английского слова stack (стопка, штабель). Когда вы
объявляете локальную переменную, она создается на вершине стека. При выходе
из функции компилятор снимает со стека ее локальные переменные, в результате
чего на вершину поднимаются значения внешней области видимости.
В абстрактном виде стек представляет собой структуру данных FILO (first
in, last out — «первым пришел, последним вышел») или LIFO (last in, first
out — «последним пришел, первым вышел»). Независимо от деталей
реализации, каждая запись сохраняется на вершине стека, и постепенно
на нее кладутся последующие записи. Запись нельзя достать, не убрав
все остальные, которые находятся над ней.

В стеке хранятся не только переменные. При каждом вызове функции на стек
кладется новая запись под названием «стековый фрейм». Иначе вы бы не смогли
вернуться в предыдущую функцию или вернуть результат вызывающему коду.
Наличие надежного механизма стека — неотъемлемая часть рабочей программы.
Поскольку стек ограничен в размере, в нем рекомендуется объявлять небольшие
переменные. Кроме того, не следует создавать слишком много стековых фреймов
в результате бесконечной рекурсии или множества вызовов функций.
С другой стороны, стек — область памяти, в которой программист хранит данные
и объявляет локальные переменные для своих алгоритмов. Операционная система, отвечающая за запуск программ, использует стек в целях размещения данных,
необходимых ее внутренним механизмам, что является залогом успешного выполнения программы.
В этом смысле со стеком необходимо обращаться осторожно, поскольку его некорректное использование или повреждение его содержимого чревато прерыванием
работы активного процесса или даже выводом его из строя. Для сравнения, с кучей
имеет дело только программист. О ней мы поговорим в следующем подразделе.
Содержимое стека не так просто просматривать извне, используя только те инструменты, с которыми мы познакомились при исследовании статической схемы

166  Глава 4



Структура памяти процесса

размещения. Эта область памяти содержит приватные данные, которые могут требовать деликатного обращения. Кроме того, она доступна лишь одному процессу
и другие программы не могут читать или изменять ее.
Таким образом, чтобы пройтись по стеку, нужно неким образом подключиться
к процессу и взглянуть на память «его глазами». Для этого можно воспользоваться
отладчиком. Он подключается к процессу, позволяя управлять им и исследовать
содержимое его памяти. Мы будем применять данный подход для изучения стека
в следующей главе. А пока закончим с обсуждением этого сегмента и перейдем
к куче.

Куча
В примере 4.7 показано, как с помощью отражений памяти можно найти области,
выделенные для сегмента кучи. Это довольно похоже на пример 4.6, но здесь, прежде чем входить в бесконечный цикл, мы выделяем в куче определенное количество байтов. Таким образом, мы снова пройдемся по отражениям памяти активного
процесса и посмотрим, какие из них относятся к куче.
Код примера 4.7 показан в листинге 4.9.
Листинг 4.9. Пример 4.7 для исследования кучи (ExtremeC_examples_chapter4_7.c)

#include // для функции sleep
#include // для функции malloc
#include // для printf
int main(int argc, char** argv) {
void* ptr = malloc(1024); // выделяем для кучи 1 Кбайт
printf("Address: %p\n", ptr);
fflush(stdout); // для принудительного вывода
// бесконечный цикл
while (1) {
sleep(1); // засыпаем на 1 секунду
};
return 0;
}

В данном коде мы использовали функцию malloc. Это основной механизм выделения дополнительной памяти в сегменте кучи. Она принимает количество байтов,
которые нужно выделить, и возвращает обобщенный указатель.
Напомню, что обобщенный (пустой) указатель содержит адрес памяти, но его
нельзя разыменовать или использовать напрямую. Вначале его следует привести
к определенному типу.

Исследование динамической схемы размещения в памяти   167

В примере 4.7 мы выделяем 1024 байта (или 1 Кбайт), выводим адрес указателя,
полученный от malloc, и затем входим в цикл. Скомпилируем и запустим этот код
(терминал 4.16).
Терминал 4.16. Компиляция и запуск примера 4.7
$ g++ ExtremeC_examples_chapter4_7.c -o ex4_7.out
$ ./ex4_7.out &
[1] 3451
Address: 0x19790010
$

Итак, запущенный в фоне процесс получил PID 3451. Теперь откроем его файл maps
и посмотрим, какие области памяти у него отражаются (терминал 4.17).
Терминал 4.17. Вывод содержимого /proc/3451/maps
$ cat /proc/3451/maps
00400000-00401000 r-xp 00000000 00:2f 176521
extreme_c/4.7/ex4_7.out
00600000-00601000 r--p 00000000 00:2f 176521
extreme_c/4.7/ex4_7.out
00601000-00602000 rw-p 00001000 00:2f 176521
extreme_c/4.7/ex4_7.out
01979000-0199a000 rw-p 00000000 00:00 0
7f7b32f12000-7f7b330d1000 r-xp 00000000 00:2f 30
x86_64-linux-gnu/libc-2.23.so
7f7b330d1000-7f7b332d1000 ---p 001bf000 00:2f 30
x86_64-linux-gnu/libc-2.23.so
7f7b332d1000-7f7b332d5000 r--p 001bf000 00:2f 30
x86_64-linux-gnu/libc-2.23.so
7f7b332d5000-7f7b332d7000 rw-p 001c3000 00:2f 30
x86_64-linux-gnu/libc-2.23.so
7f7b332d7000-7f7b332db000 rw-p 00000000 00:00 0
7f7b332db000-7f7b33301000 r-xp 00000000 00:2f 27
x86_64-linux-gnu/ld-2.23.so
7f7b334f2000-7f7b334f5000 rw-p 00000000 00:00 0
7f7b334fe000-7f7b33500000 rw-p 00000000 00:00 0
7f7b33500000-7f7b33501000 r--p 00025000 00:2f 27
x86_64-linux-gnu/ld-2.23.so
7f7b33501000-7f7b33502000 rw-p 00026000 00:2f 27
x86_64-linux-gnu/ld-2.23.so
7f7b33502000-7f7b33503000 rw-p 00000000 00:00 0
7ffdd63c2000-7ffdd63e3000 rw-p 00000000 00:00 0
7ffdd63e7000-7ffdd63ea000 r--p 00000000 00:00 0
7ffdd63ea000-7ffdd63ec000 r-xp 00000000 00:00 0
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0
[vsyscall]
$

.../
.../
.../
[heap]
/lib/
/lib/
/lib/
/lib/

/lib/

/lib/
/lib/

[stack]
[vvar]
[vdso]

168  Глава 4



Структура памяти процесса

Если внимательно присмотреться к терминалу 4.17, то можно заметить новое отражение с меткой [heap], которое мы выделили. Эта область была добавлена ввиду
использования функции malloc. Ее размер равен 0x21000 байт, или 132 Кбайт. Это
значит, при выделении в коде 1 Кбайт процесс выделяет область размером 132 Кбайт.
Обычно это делается с целью предотвратить выделение памяти при дальнейшем
использовании malloc. Дело в том, что выделение места в куче — затратная операция, которая расходует как память, так и время.
Возвращаясь к листингу 4.9, отмечу, что адрес, на который ссылается указатель
ptr, тоже заслуживает внимания. Отражение кучи, показанное в терминале 4.17,
занимает диапазон адресов с 0x01979000 по 0x0199a000, в который, очевидно, входит
адрес 0x19790010, хранящийся в ptr и имеющий сдвиг размером 16 байт.
Размер 132 Кбайт — далеко не предел. Куча может достигать десятков гигабайтов.
В ней обычно размещают постоянные, глобальные и очень большие объекты, такие
как массивы и битовые потоки.
Как уже отмечалось, выделение и освобождение места в куче требует вызова специальных функций, которые предоставляются стандартом языка C. С локальными
переменными, созданными на вершине стека, можно взаимодействовать напрямую,
тогда как для доступа к содержимому кучи необходимо использовать указатели.
Это одна из причин, почему понимание принципа работы указателей и умение ими
пользоваться — ключевое требование для любого программиста на C. Рассмотрим
пример 4.8, в котором показано, как с помощью указателей можно обратиться
к сегменту кучи (листинг 4.10).
Листинг 4.10. Использование указателей для взаимодействия с кучей
(ExtremeC_examples_chapter4_8.c)

#include
#include

// для функции printf
// для функций malloc и free

void fill(char* ptr) {
ptr[0] = 'H';
ptr[1] = 'e';
ptr[2] = 'l';
ptr[3] = 'l';
ptr[5] = 0;
}
int main(int argc, char** argv) {
void* gptr = malloc(10 * sizeof(char));
char* ptr = (char*)gptr;
fill(ptr);
printf("%s!\n", ptr);
free(ptr);
return 0;
}

Резюме  169

Эта программа выделяет в куче 10 байт, используя функцию malloc. Та принимает
количество байтов, которое нужно выделить, и возвращает обобщенный указатель
на первый байт выделенного блока памяти.
Чтобы использовать возвращенный указатель, его нужно привести к подходящему
типу. Поскольку мы выделяем память для хранения символов, указатель будет
иметь тип char. Приведение типов выполняется с помощью функции fill.
Обратите внимание: переменные локальных указателей, gptr и ptr, выделяются
в стеке. Им нужно место для хранения своих значений, и оно находится в стековой
памяти. Но адреса, на которые они указывают, хранятся в куче. В этом нет ничего
необычного. Локальные указатели выделяются в стеке, но области памяти, на которые они ссылаются, находятся внутри кучи. Более подробно об этом поговорим
в главе 5.
Нужно сказать, что указатель ptr внутри функции fill тоже выделен в стеке, но
находится в другой области видимости и отличается от одноименного указателя
в функции main.
Когда речь идет о куче, за выделение памяти отвечает программа или, скорее, программист. Вдобавок программа должна сама освобождать память, которая ей больше не нужна. Наличие участка кучи, к которому нельзя обратиться, называется
утечкой памяти. Это значит, у нас нет указателя для работы с данной областью.
Утечки памяти могут быть губительными для программы; если они постепенно
накапливаются, то в конечном счете израсходуется вся доступная память, в результате чего процесс будет принудительно завершен. Именно поэтому наша
программа вызывает функцию free, прежде чем вернуться в main. Данный вызов
освободит выделенный блок кучи, после чего программа больше не должна использовать его адреса.
Более подробно о стеке и куче мы поговорим в следующей главе.

Резюме
В данной главе я в первую очередь пытался сделать краткий обзор структуры
памяти процессов в Unix-подобных операционных системах. Было представлено
много материала, поэтому напомню, какие темы мы обсудили:
zz рассмотрели структуру динамической памяти активного процесса и статической

памяти исполняемого объектного файла;
zz увидели, что статическая схема размещения в памяти находится в исполня-

емом объектном файле и разбита на части, которые называются сегментами.
Вы узнали, что в состав статической схемы размещения входят такие сегменты,
как Text, Data и BSS;

170   Глава 4



Структура памяти процесса

zz увидели, что сегмент Text (Code) используется для хранения инструкций ма-

шинного уровня, предназначенных для выполнения, когда на основе текущего
исполняемого файла создается новый процесс;
zz узнали, что сегмент BSS используется для хранения глобальных переменных,

которые либо равны нолю, либо не инициализированы;
zz выяснили, что сегмент Data используется для хранения инициализированных

глобальных переменных;
zz задействовали команды size и objdump в целях исследования внутренностей

объектных файлов. Для поиска внутри объектного файла вышеупомянутых
сегментов можно использовать инструменты наподобие readelf;
zz исследовали динамическую схему размещения памяти процесса. Вы увидели,

что в нее копируются все статические сегменты. Вместе с тем в динамической
памяти появляется два новых сегмента: стек и куча;
zz выяснили, что стек — область памяти, в которой по умолчанию выделяется

место для переменных;
zz узнали, что локальные переменные всегда создаются на вершине стека;
zz выяснили, что механизм вызова функций основан на стеке и его принципе

работы;
zz увидели, что для выделения и освобождения места в сегментах кучи необходи-

мо использовать специальный API (набор функций), входящий в стандартную
библиотеку языка C;
zz обсудили утечки памяти и показали, как они могут происходить в контексте

сегмента кучи.
Следующая глава полностью посвящена стеку и куче. В ней будет дополнен базовый материал, который мы рассмотрели в этой главе. Вы увидите больше примеров
и познакомитесь с новыми инструментами для исследования процессов; на этом
мы завершим наше обсуждение работы с памятью в C.

5

Стек
и куча

В предыдущей главе мы провели исследование структуры памяти активного
процесса. Системное программирование без понимания устройства памяти и ее
различных сегментов подобно проведению хирургической операции без знания
анатомии человеческого тела. Мы познакомились с основными сведениями о сегментах памяти процесса, но в этой главе речь пойдет только о двух из них, которые
используются чаще всего: о стеке и куче.
Куча и стек — основные сегменты, с которыми работает программист. Data, Text
и BSS используются реже, и доступ к ним ограничен. Причиной тому факт, что
данные сегменты генерируются компилятором и зачастую занимают небольшую
долю в общем объеме памяти запущенного процесса. Это не значит, что они неважны; на самом деле они имеют прямое отношение к некоторым потенциальным
проблемам. Но поскольку большую часть времени вы будете работать со стеком
и кучей, именно в них будет возникать большинство неполадок.
В этой главе вы изучите:
zz способы исследования стека и нужные для этого инструменты;
zz устройство автоматического управления памятью в стеке;
zz различные характеристики стека;
zz рекомендации по использованию стека;
zz приемы исследования кучи;
zz способы выделения и освобождения блоков памяти в куче;
zz рекомендации по использованию кучи;
zz среды с ограниченными ресурсами и тонкую настройку памяти в высокопро-

изводительных средах.
Начнем наше исследование с подробного обсуждения стека.

172   Глава 5



Стек и куча

Стек
Процесс может продолжать выполнение и без кучи, но без стека работать не будет.
Это о многом говорит. Стек — главный аспект метаболизма процесса. Это продиктовано тем, как происходит вызов функций. В предыдущей главе я уже упоминал, что функцию можно вызвать, только используя стек. Без этого сегмента
нельзя выполнить ни одну функцию, что делает невозможной работу программы
в целом.
Учитывая все вышесказанное, стек и его содержимое тщательно спроектированы
для обеспечения бесперебойной работы процесса. Поэтому беспорядок в стеке
может нарушить и прервать выполнение программы. Выделение памяти в стеке
происходит быстро и не требует применения никаких специальных функций.
Более того, освобождение ресурсов и все действия по управлению памятью происходят автоматически. Все описанное звучит заманчиво и может подстегнуть вас
к излишнему использованию стека.
К этому следует относиться серьезно. Применение стека имеет свои нюансы.
Данный сегмент не очень большой, поэтому в нем нельзя хранить крупные объекты. К тому же некорректное использование его содержимого может нарушить
выполнение и привести к сбою. Это продемонстрировано в следующем фрагменте
кода (листинг 5.1).
Листинг 5.1. Переполнение буфера. Функция strcpy перезаписывает содержимое стека

#include
int main(int argc, char** argv) {
char str[10];
strcpy(str, "akjsdhkhqiueryo34928739r27yeiwuyfiusdciuti7twe79ye");
return 0;
}

Если запустить этот код, то программа, скорее всего, аварийно завершится. Дело
в том, что функция strcpy переполняет содержимое стека. Как видно в листинге 5.1,
массив str содержит десять символов, но strcpy записывает в него намного больше
элементов. Вскоре вы сами увидите, что это фактически приводит к перезаписи
ранее сохраненных переменных и стековых фреймов, в результате чего после выхода из функции main программа переходит не к той инструкции. В итоге дальнейшее
выполнение становится невозможным.
Надеюсь, данный пример помог продемонстрировать хрупкость стека. Углубленному рассмотрению данного сегмента посвящен первый раздел текущей главы.
Начнем с исследования его содержимого.

Стек   173

Исследование содержимого стека
Прежде чем продолжать изучение стека, нам нужно сначала научиться его читать
и, возможно, даже изменять. Как утверждалось в предыдущей главе, стек — изолированная область памяти, читать и модифицировать которую имеет право
только ее владелец. Чтобы работать со стеком, необходимо быть частью процесса,
которому он принадлежит.
Здесь нам понадобится целый новый класс инструментов: отладчики. Это программа, которая подключается к стороннему процессу и позволяет его отлаживать. При этом программисты обычно занимаются отслеживанием и изменением
различных сегментов памяти. Читать и модифицировать приватные блоки памяти
можно только в ходе отладки. Отладчик также позволяет управлять порядком
выполнения программных инструкций. Примеры того, как это делается, будут
рассмотрены чуть позже.
Попробуем скомпилировать программу и подготовить ее к отладке. Я покажу, как
использовать gdb (GNU debugger) для запуска программы и чтения ее стека. В примере 5.1 объявлен массив символов, выделенный на вершине стека и содержащий
элементы, показанные в листинге 5.2.
Листинг 5.2. Объявление массива, выделенного на вершине стека (ExtremeC_examples_chapter5_1.c)

#include
int main(int argc, char** argv) {
char arr[4];
arr[0] = 'A';
arr[1] = 'B';
arr[2] = 'C';
arr[3] = 'D';
return 0;
}

Это простая программа, в которой легко разобраться, но в ее памяти происходит
нечто интересное. Прежде всего, память, необходимая для массива arr, выделяется в стеке, а не в куче просто потому, что мы не использовали функцию malloc.
Помните: в стеке по умолчанию выделяются все переменные и массивы.
Чтобы выделить место в куче, необходимо воспользоваться функцией malloc или
ее аналогом, таким как calloc. В противном случае память будет выделена в стеке,
а точнее, на его вершине.
Чтобы программу можно было отлаживать, ее двоичный файл должен быть
собран соответствующим образом. То есть нам нужно сообщить компилятору
о том, что в исполняемом файле должны быть отладочные символы. Они будут

174   Глава 5



Стек и куча

использоваться для поиска строчек кода, которые выполняются или приводят
к сбою. Скомпилируем пример 5.1 и создадим исполняемый объектный файл с отладочными символами.
Для начала соберем наш код в среде Linux (терминал 5.1).
Терминал 5.1. Компиляция примера 5.1 с отладочным параметром -g
$ gcc -g ExtremeC_examples_chapter5_1.c -o ex5_1_dbg.out
$

Параметр -g говорит компилятору о том, что итоговый исполняемый объектный
файл должен содержать отладочную информацию. Обратите внимание: он влияет
на размер программы. В терминале 5.2 вы можете видеть разницу в размере между
двумя исполняемыми файлами, второй из которых скомпилирован с параметром -g.
Терминал 5.2. Размер выходного исполняемого файла с параметром -g и без него
$ gcc ExtremeC_examples_chapter2_10.c -o ex5_1.out
$ ls -al ex5_1.out
-rwxrwxr-x 1 kamranamini kamranamini 8640 jul 24 13:55 ex5_1.out
$ gcc -g ExtremeC_examples_chapter2_10.c -o ex5_1_dbg.out
$ ls -al ex5_1.out
-rwxrwxr-x 1 kamranamini kamranamini 9864 jul 24 13:56 ex5_1_dbg.
out
$

Итак, у нас есть исполняемый файл с отладочными символами. Теперь мы можем
запустить его с помощью отладчика. В нашем примере для отладки используется
gdb. В терминале 5.3 показана команда для запуска отладчика.
Терминал 5.3. Запуск отладчика для примера 5.1
$ gdb ex5_1_dbg.out

В системах Linux gdb обычно является частью пакета build-essentials.
В macOS его можно установить с помощью диспетчера пакетов brew,
используя команду brew install gdb.

В результате запуска отладчика мы получим примерно такой вывод (терминал 5.4).
Терминал 5.4. Вывод отладчика после запуска
$ gdb ex5_1_dbg.out
GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1
Copyright (C) 2016 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later http://gnu.org/

Стек   175
licenses/gpl.html
...
Reading symbols from ex5_1_dbg.out...done.
(gdb)

Как вы, наверное, заметили, я выполнил эту команду на компьютере под управлением Linux. Отладчик gdb предоставляет интерфейс командной строки, который
позволяет выполнять отладочные команды. Введите команду r (или run), чтобы
запустить исполняемый объектный файл, поданный на вход отладчику. В терминале 5.5 показано, как команда run выполняет программу.
Терминал 5.5. Вывод отладчика после выполнения команды run
...
Reading symbols from ex5_1_dbg.out...done.
(gdb) run
Starting program: .../extreme_c/5.1/ex5_1_dbg.out
[Inferior 1 (process 9742) exited normally]
(gdb)

В этом терминале после ввода команды run отладчик запускает процесс, подключается к нему и дает возможность программе выполнить свои инструкции вплоть до
завершения. Он не прерывает выполнение, поскольку мы не указали точку останова. Она говорит gdb о том, что процесс нужно приостановить и ждать дальнейших
инструкций. Таких точек может быть сколько угодно.
Создадим точку останова в функции main, используя команду b (или break). В результате gdb остановит выполнение, когда программа войдет в main. Это показано
в терминале 5.6.
Терминал 5.6. Создание точки останова для функции main в gdb
(gdb) break main
Breakpoint 1 at 0x400555: file ExtremeC_examples_chapter5_1.c, line 4.
(gdb)

Запустим нашу программу еще раз. Это приведет к созданию нового процесса,
к которому подключится отладчик. Результат показан в терминале 5.7.
Терминал 5.7. Повторное выполнение программы после создания точки останова
(gdb) r
Starting program: .../extreme_c/5.1/ex5_1_dbg.out
Breakpoint 1, main (argc=1, argv=0x7fffffffcbd8) at ExtremeC_
examples_chapter5_1.c:3
3
int main(int argc, char** argv) {
(gdb)

176   Глава 5



Стек и куча

Как видите, выполнение остановилось на строчке 3, с которой начинается функция
main. После этого отладчик ждет ввода следующей команды. Мы можем попросить gdb выполнить следующую строчку и снова остановиться. Иными словами,
программу можно выполнить пошагово, строчка за строчкой. Таким образом,
у вас будет достаточно времени для того, чтобы оглядеться и проверить значения
переменных в памяти. На самом деле с помощью именно этого метода мы и будем
исследовать стек и кучу.
В терминале 5.8 показано, как выполнить следующую строчку кода с помощью
команды n (или next).
Терминал 5.8. Использование команды n (или next) для выполнения следующих строчек кода
(gdb)
5
(gdb)
6
(gdb)
7
(gdb)
8
(gdb)
9
(gdb)

n
arr[0] = 'A';
n
arr[1]
next
arr[2]
next
arr[3]
next
return

= 'B';
= 'C';
= 'D';
0;

Теперь, если ввести команду print arr, отладчик покажет содержимое массива
в виде строки (терминал 5.9).
Терминал 5.9. Вывод содержимого массива с помощью отладчика gdb
(gdb) print arr
$1 = "ABCD"
(gdb)

Но вернемся к исходной теме. Отладчик gdb нам нужен для того, чтобы заглянуть
внутрь стековой памяти. И теперь мы готовы это сделать. У нас есть приостановленный процесс со стеком, и с помощью командной строки gdb мы можем исследовать его память. Для начала выведем участок памяти, выделенный для массива
arr (терминал 5.10).
Терминал 5.10. Вывод байтов памяти, начиная с массива arr
(gdb) x/4b arr
0x7fffffffcae0: 0x41
(gdb) x/8b arr
0x7fffffffcae0: 0x41
0x00
0x00
(gdb)

0x42

0x43

0x44

0x42

0x43

0x44

0xff

0x7f

Стек   177

Первая команда, x/4b, выводит 4 байта, хранящиеся на том участке, на который
указывает arr. Напомню, что arr — просто указатель на первый элемент массива,
поэтому с его помощью можно перемещаться по памяти.
Вторая команда, x/8b, выводит 8 байт, идущих после arr. Согласно примеру 5.1
(см. листинг 5.2), в массиве arr находятся значения A, B, C и D. Вы должны знать, что
массив хранит не сами символы, а их ASCII-коды. Например, ASCII-код A равен 65
в десятеричной системе или 0x41 в шестнадцатеричной. В случае с B это 66 или 0x42.
Таким образом, gdb выводит те значения, которые мы сохранили в массив arr.
Но что насчет остальных 4 байт во второй команде? Они находятся в стеке и, вероятно, содержат данные последнего стекового фрейма, созданного во время вызова
функции main.
Обратите внимание: по сравнению с другими сегментами стек заполняется в обратном порядке.
Другие области памяти заполняются, начиная с младшего адреса, но со стеком все
не так.
Заполнение стека происходит от старших адресов к младшим. Отчасти это связано
с историей развития современных компьютеров, а отчасти — с некоторыми возможностями сегмента стека (который ведет себя как одноименная структура данных).
Учитывая все вышесказанное, если прочитать стек от младших адресов к старшим,
как в терминале 5.10, то мы получим уже сохраненные данные; попытка их изменить приведет к модификации стека, что чревато проблемами. Чуть позже я объясню, в чем заключается опасность и как это делать правильно.
Так почему же мы можем читать данные за пределами массива arr? Дело в том,
что отладчик gdb выводит то количество байтов памяти, которое мы запросили.
Команду x не заботят границы массива. Чтобы вывести диапазон, ей нужны лишь
начальный адрес и количество байтов.
Если вы хотите изменить значения внутри стека, то вам нужно использовать команду
set. Это позволит модифицировать существующие ячейки памяти. В данном случае
ячейка представляет собой отдельный байт внутри массива arr (терминал 5.11).
Терминал 5.11. Изменение отдельного байта в массиве с помощью команды set
(gdb) x/4b arr
0x7fffffffcae0: 0x41
(gdb) set arr[1] = 'F'
(gdb) x/4b arr
0x7fffffffcae0: 0x41
(gdb) print arr
$2 = "AFCD"
(gdb)

0x42

0x43

0x44

0x46

0x43

0x44

178   Глава 5



Стек и куча

Как видите, с помощью команды set нам удалось поменять второй элемент массива
arr на F. Отладчик gdb также позволяет перезаписыватьадреса, которые выходят
за пределы вашего массива.
Внимательно рассмотрим следующий пример (терминал 5.12). Мы хотим изменить
байт, адрес которого намного больше, чем у arr. То есть, как уже объяснялось, мы
собираемся модифицировать данные, помещенные в стек ранее. Напомню, что стековая память заполняется в противоположном порядке по сравнению с другими
сегментами.
Терминал 5.12. Изменение отдельного байта за пределами массива
(gdb) x/20x arr
0x7fffffffcae0: 0x41
0x42
0x43
0x00
0x00
0x7fffffffcae8: 0x00
0x96
0xea
0xea
0x73
0x7fffffffcaf0: 0x90
0x05
0x40
(gdb) set *(0x7fffffffcaed) = 0xff
(gdb) x/20x arr
0x7fffffffcae0: 0x41
0x42
0x43
0x00
0x00
0x7fffffffcae8: 0x00
0x96
0xea
0x00
0x00
0x7fffffffcaf0: 0x00
0x05
0x40м
(gdb)

0x44

0xff

0x7f

0x5d

0xf0

0x31

0x44

0xff

0x7f

0x5d

0xf0

0xff

0x00

0x00

Вот и все. Мы только что записали значение 0xff по адресу 0x7fffffffcaed, который находится вне массива arr. Вполне вероятно, что этот байт принадлежит
стековому фрейму, сохраненному еще до входа в функцию main.
Что произойдет, если мы продолжим выполнение? Если была модифицирована
важная часть стека, то можно ожидать сбоя; в крайнем случае это изменение будет
обнаружено неким механизмом, который прервет работу программы. Команда c
(или continue) возобновит выполнение процесса в gdb, и мы увидим следующее
(терминал 5.13).
Терминал 5.13. Модификация важного байта в стеке приводит к принудительному
завершению процесса
(gdb) c
Continuing.
*** stack smashing detected ***: .../extreme_c/5.1/ex5_1_dbg.out
terminated
Program received signal SIGABRT, Aborted.
0x00007ffff7a42428 in __GI_raise (sig=sig@entry=6) at ../sysdeps/Unix/sysv/
linux/raise.c:54

Стек   179
54
../sysdeps/Unix/sysv/linux/raise.c: No such file or
directory.
(gdb)

Мы только что сломали стек! Изменение его адреса, который выделяли не вы, даже
если речь идет об 1 байте, может быть очень опасным и обычно приводит к сбою
или внезапному завершению.
Как уже говорилось ранее, в стеке выполняются самые важные процедуры, относящиеся к выполнению программы. Поэтому при записи в стековые переменные
следует быть крайне осторожными. Значения нельзя записывать за рамками переменных и массивов, поскольку в стеке адреса растут в обратном направлении, что
повышает вероятность модификации уже записанных байтов.
Если вы закончили отладку и хотите покинуть gdb, можете использовать команду q
(или quit). Она должна позволить вам выйти из отладчика и вернуться в терминал.
Вдобавок следует отметить, что запись непроверенных значений в буфер (байтовый или символьный массив), выделенный на вершине стека (а не в куче), считается уязвимостью. Злоумышленник может тщательно подготовить массив байтов
и «скормить» его программе, чтобы получить контроль за ее выполнением. Обычно
это называют эксплойтом в результате переполнения буфера.
Эта уязвимость продемонстрирована в листинге 5.3.
Листинг 5.3. Программа, демонстрирующая переполнение буфера

int main(int argc, char** argv) {
char str[10];
strcpy(str, argv[1]);
printf("Hello %s!\n", str);
}

Этот код не проверяет содержимое и размер ввода argv[1], копируя его прямо
в массив arr, выделенный на вершине стека.
Если вам повезет, то вы отделаетесь лишь сбоем в работе. Но иногда это может
привести к вредоносной атаке.

Рекомендации по использованию стековой памяти
Теперь вы должны лучше себе представлять, что такое сегмент стека и как он
работает. Обсудим рекомендуемые методики и нюансы, к которым необходимо
относиться с осторожностью. Вы уже должны быть знакомы с понятием «область
видимости». Каждая переменная имеет свою область видимости, которая определяет ее время жизни. Это значит, что вместе с данной областью исчезают и все
переменные, которые были в ней созданы.

180  Глава 5



Стек и куча

Кроме того, только переменные, находящиеся в стеке, выделяются и освобождаются автоматически. Автоматическое управление памятью обусловлено природой
стекового сегмента.
Любая переменная, которую вы объявляете в стеке, автоматически создается на
его вершине. Выделение памяти происходит автоматически и может считаться
началом жизни переменной. После этого поверх нее будет записано множество
других переменных и стековых фреймов. Но пока она находится в стеке и над ней
есть другие переменные, ее существование продолжается.
Однако рано или поздно программа должна завершиться, и перед ее выходом
стек должен быть пустым. Поэтому в какой-то момент наша переменная будет
извлечена из стека. Таким образом, освобождение ее памяти происходит автоматически и знаменует конец ее жизненного цикла. Вот почему мы говорим,
что управление памятью стековых переменных происходит автоматически, без
участия программиста.
Представьте, что вы объявили в функции main переменную, как показано в листинге 5.4.
Листинг 5.4. Объявление переменной на вершине стека

int main(int argc, char** argv) {
int a;
...
return 0;
}

Эта переменная будет оставаться в стеке, пока не завершится функция main .
Иными словами, переменная существует, пока остается действительной ее область
видимости (функция main). И, поскольку в этой функции происходит выполнение
всей программы, время жизни данной переменной почти такое же, как у глобального значения, доступного на протяжении всей работы процесса.
Тем не менее глобальное значение всегда остается в памяти, даже когда завершаются главная функция и сама программа, в то время как наша переменная будет
извлечена из стека. Обратите внимание на два участка кода, которые выполняются
перед функцией main и после нее; они нужны для того, чтобы подготовить программы к выполнению и соответственно завершить работу. Кроме того, стоит отметить,
что глобальные переменные выделяются в другом сегменте памяти, Data или BSS,
который ведет себя не так, как стек.
Рассмотрим пример очень распространенной ошибки. Ее часто допускают программисты-любители при написании своих первых программ на языке C. Речь идет
о возвращении адреса локальной переменной внутри функции.

Стек  181

В листинге 5.5 показан пример 5.2.
Листинг 5.5. Объявление переменной на вершине стека (ExtremeC_examples_chapter5_2.c)

int* get_integer() {
int var = 10;
return &var;
}
int main(int argc, char** argv) {
int* ptr = get_integer();
*ptr = 5;
return 0;
}

Функция get_integer возвращает адрес локальной переменной var, объявленной
в ее области видимости. Затем функция main пытается разыменовать указатель
и обратиться к соответствующей области памяти. В терминале 5.14 показан вывод
компилятора gcc в ходе сборки этого кода в системе Linux.
Терминал 5.14. Компиляция примера 5.2 в Linux
$ gcc ExtremeC_examples_chapter5_2.c -o ex5_2.out
ExtremeC_examples_chapter5_2.c: In function 'get_integer':
ExtremeC_examples_chapter5_2.c:3:11: warning: function returns
address of local variable [-Wreturn-local-addr]
return &var;
^~~~
$

Как видите, мы получили предупреждение. Поскольку возвращение адреса локальной переменной — распространенная ошибка, компиляторы о ней уже знают
и выводят понятное сообщение такого содержания: функция возвращает адрес
локальной переменной.
А вот что произойдет при запуске программы (терминал 5.15).
Терминал 5.15. Выполнение примера 5.2 в Linux
$ ./ex5_2.out
Segmentation fault (core dumped)
$

Здесь видна ошибка сегментации, что можно интерпретировать как отказ программы. Обычно это происходит при доступе к некорректной области памяти, которая
уже освободилась.

182  Глава 5



Стек и куча

Некоторые предупреждения следует воспринимать как ошибки. Это, к примеру, касается предыдущего сообщения, поскольку подобный код обычно
приводит к сбою программы. Если вы хотите, чтобы компилятор gcc
считал ошибками любые предупреждения, то передайте ему параметр
-Werror. То же самое можно сделать и для отдельных предупреждений; например, в предыдущем случае достаточно указать параметр
-Werror=return-local-addr.

Если запустить программу с помощью отладчика gdb, то можно узнать больше
подробностей о ее сбое. Но помните: ее нужно скомпилировать с параметром -g,
иначе от gdb будет мало пользы.
Компиляция исходников с помощью параметра -g обязательна, если вы собираетесь отлаживать свою программу с помощью gdb или других инструментов, таких
как valgrind. В терминале 5.16 показано, как скомпилировать пример 5.2 и запустить его в отладчике.
Терминал 5.16. Выполнение примера 5.2 в отладчике
$ gcc -g ExtremeC_examples_chapter5_2.c -o ex5_2_dbg.out
ExtremeC_examples_chapter5_2.c: In function 'get_integer':
ExtremeC_examples_chapter5_2.c:3:11: warning: function returns
address of local variable [-Wreturn-local-addr]
return &var;
^~~~
$ gdb ex5_2_dbg.out
GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git
...
Reading symbols from ex5_2_dbg.out...done.
(gdb) run
Starting program: .../extreme_c/5.2/ex5_2_dbg.out
Program received signal SIGSEGV, Segmentation fault.
0x00005555555546c4 in main (argc=1, argv=0x7fffffffdf88) at
ExtremeC_examples_chapter5_2.c:8
8
*ptr = 5;
(gdb) quit
$

Вывод отладчика gdb говорит о том, что источник сбоя находится в строчке 8 внутри функции main — именно там, где программа пытается выполнить запись по
возвращенному адресу путем разыменования полученного указателя. Переменная
var была локальной для функции get_integer и перестала существовать просто
потому, что в строчке 8 мы уже вышли из get_integer и соответствующей области
видимости. Таким образом, возвращенный указатель получился висячим.
Обычно указатели на переменные в текущей области видимости рекомендуется
передавать другом функциям, но не наоборот, поскольку они существуют, пока

Куча  183

область видимости остается действительной. Дальнейшие вызовы кладут на стек
новые значения, и текущая область видимости не исчезнет раньше них.
Следует отметить, что эта рекомендация не относится к программам с параллельным выполнением, поскольку в будущем одно из параллельных заданий
может воспользоваться полученным указателем, содержащим адрес переменной
в текущей области видимости, но к тому моменту данная область уже может
не существовать.
В завершение этого раздела перечислю ключевые характеристики стека, с которыми вы познакомились:
zz стековая память имеет ограниченный размер, поэтому не подходит для хранения

крупных объектов;
zz адреса в стеке увеличиваются в обратном порядке, поэтому, перемещаясь впе-

ред по стеку, мы читаем уже сохраненные байты;
zz управление памятью в стеке происходит автоматически. Это касается как вы-

деления, так и освобождения места;
zz каждая переменная в стеке обладает областью видимости, которая определяет

ее время жизни. Это следует учитывать при проектировании логики программы.
Вы не можете контролировать этот механизм;
zz указатели должны ссылаться только на те стековые переменные, которые все

еще находятся в области видимости;
zz освобождение памяти стековых переменных происходит автоматически, прямо

перед исчезновением области видимости, и вы не можете на это повлиять;
zz указатели на переменные, существующие в текущей области видимости, можно

передавать в другие функции в качестве аргументов, но только если вы уверены
в том, что в момент использования этих указателей область видимости будет
оставаться на месте. Данное правило может не распространяться на программы
с параллельной логикой.
В следующем разделе мы поговорим о куче и ее различных возможностях.

Куча
Почти любой код, написанный на любом языке программирования, так или иначе
использует кучу. Это вызвано тем, что она имеет уникальные преимущества, недоступные при работе со стеком.
Но у этого сегмента памяти есть и недостатки. Например, выделение места в нем
происходит медленней, чем в стеке.

184  Глава 5



Стек и куча

В этом разделе мы более подробно поговорим о самой куче и принципах, которыми
необходимо руководствоваться при ее использовании.
Благодаря уникальным свойствам куча играет важную роль. Однако не все из
них положительные. На самом деле некоторые из них следует отнести к рискам,
смягчению которых нужно уделять отдельное внимание. Любой важный инструмент обладает хорошими и плохими качествами, и если вы хотите правильно его
использовать, то вам нужно как следует изучить обе стороны.
Эти качества перечислены ниже. Попробуйте определить, какие из них полезные,
а какие — рискованные.
1. В куче никакие блоки памяти не выделяются автоматически. Вместо этого для
выделения каждого участка памяти программист должен использовать malloc
или аналогичную функцию. На самом деле это можно считать преимуществом
кучи по сравнению со стеком. Фреймы, содержащиеся в стеке, выделяются
не самим программистом за счет вызова специальных функций, а автоматически.
2. Куча большая. В то время как стек имеет ограниченный размер и не подходит
для размещения крупных объектов, куча позволяет хранить огромные объемы данных, достигающие десятков гигабайтов. По мере увеличения кучи
аллокатору нужно запрашивать у операционной системы все больше страниц
памяти, по которым распределяются блоки этого сегмента. Стоит отметить,
что, в отличие от стека, куча выделяет адреса по направлению от младшего
к старшему.
3. Выделением и освобождением памяти в куче занимается программист. Это
значит, на программиста ложится вся ответственность за выделение памяти
и последующее ее освобождение, когда она больше не нужна. Во многих современных языках освобождение блоков кучи выполняется автоматически
параллельным компонентом под названием «сборщик мусора». Но в C и C++
такого механизма нет, и потому освобождать блоки кучи нужно вручную.
Это, несомненно, создает определенные риски, и программисты на C/C++
должны быть очень осторожными при работе с кучей. Если не освободить выделенный блок, то может произойти утечка памяти, которая в большинстве
случаев является роковой.
4. Переменные, выделенные в куче, не имеют никакой области видимости, в отличие
от стековых переменных. Такое свойство можно считать отрицательным, поскольку оно существенно усложняет управление памятью. Вы не знаете, когда
нужно освободить переменную, поэтому для эффективного использования кучи
необходимо выработать собственные понятия области видимости и владельца.
Некоторые из способов, позволяющих это реализовать, рассматриваются в следующих разделах.

Куча   185

5. К блокам кучи можно обращаться только с помощью указателей. Иными словами, нет такого понятия, как «переменная кучи». Для навигации по куче используются указатели.
6. Поскольку куча доступна только владеющему ей процессу, для ее исследования
нужен отладчик. К счастью, в языке C указатели работают одинаково как со
стеком, так и с блоками кучи. Данная абстракция работает очень хорошо, и благодаря ей мы можем использовать одни и те же указатели для обращения к этим
двум сегментам памяти. Таким образом, для исследования кучи и стека можно
применять одни те же методы.
В следующем подразделе будет показано, как выделять и освобождать блоки памяти в куче.

Выделение и освобождение памяти в куче
Как уже отмечалось в предыдущем подразделе, место в куче необходимо выделять
и освобождать вручную. Для этого программист должен использовать набор функций или API (функции для работы с памятью из стандартной библиотеки C).
Эти функции существуют, и их определения находятся в заголовке stdlib.h .
Для получения блока памяти в куче используются функции malloc , calloc
и realloc, а для освобождения памяти предусмотрена только одна функция: free.
В примере 5.3 показано, как их применять.
Иногда в технической литературе кучу называют динамической памятью.
Выделение динамической памяти — то же самое, что выделение кучи.

В листинге 5.6 показан исходный код примера 5.3. Он выделяет два блока кучи
и затем выводит собственные отображения памяти.
Листинг 5.6. Пример 5.3, который выводит отражения памяти после выделения двух блоков
в куче (ExtremeC_examples_chapter5_3.c)

#include // для функции printf
#include // для функций по работе с кучей из библиотеки C
void print_mem_maps() {
#ifdef __linux__
FILE* fd = fopen("/proc/self/maps", "r");
if (!fd) {
printf("Could not open maps file.\n");
exit(1);
}

186  Глава 5



Стек и куча

char line[1024];
while (!feof(fd)) {
fgets(line, 1024, fd);
printf("> %s", line);
}
fclose(fd);
#endif
}
int main(int argc, char** argv) {
// выделяем 10 байт без инициализации
char* ptr1 = (char*)malloc(10 * sizeof(char));
printf("Address of ptr1: %p\n", (void*)&ptr1);
printf("Memory allocated by malloc at %p: ", (void*)ptr1);
for (int i = 0; i < 10; i++) {
printf("0x%02x ", (unsigned char)ptr1[i]);
}
printf("\n");
// выделяем 10 байт, каждый из которых обнулен
char* ptr2 = (char*)calloc(10, sizeof(char));
printf("Address of ptr2: %p\n", (void*)&ptr2);
printf("Memory allocated by calloc at %p: ", (void*)ptr2);
for (int i = 0; i < 10; i++) {
printf("0x%02x ", (unsigned char)ptr2[i]);
}
printf("\n");
print_mem_maps();
free(ptr1);
free(ptr2);
return 0;
}

Приведенный выше код является кросс-платформенным и может быть скомпилирован в большинстве операционных систем семейства Unix. Но функция
print_mem_maps работает только в Linux, поскольку макрос __linux__ определен
лишь в этой ОС. Таким образом, вы можете скомпилировать данный код в macOS,
но вызов print_mem_maps не будет ничего делать.
Результаты выполнения этого примера в среде Linux показаны в терминале 5.17.
Терминал 5.17. Вывод примера 5.3 в Linux
$ gcc ExtremeC_examples_chapter5_3.c -o ex5_3.out
$ ./ex5_3.out
Address of ptr1: 0x7ffe0ad75c38
Memory allocated by malloc at 0x564c03977260: 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00

Куча   187
Address of ptr2: 0x7ffe0ad75c40
Memory allocated by calloc at 0x564c03977690: 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00
> 564c01978000-564c01979000 r-xp 00000000 08:01 5898436
/home/kamranamini/extreme_c/5.3/ex5_3.out
> 564c01b79000-564c01b7a000 r--p 00001000 08:01 5898436
/home/kamranamini/extreme_c/5.3/ex5_3.out
> 564c01b7a000-564c01b7b000 rw-p 00002000 08:01 5898436
/home/kamranamini/extreme_c/5.3/ex5_3.out
> 564c03977000-564c03998000 rw-p 00000000 00:00 0
[heap]
> 7f31978ec000-7f3197ad3000 r-xp 00000000 08:01 5247803
/lib/
x86_64-linux-gnu/libc-2.27.so
...
> 7f3197eef000-7f3197ef1000 rw-p 00000000 00:00 0
> 7f3197f04000-7f3197f05000 r--p 00027000 08:01 5247775
/lib/
x86_64-linux-gnu/ld-2.27.so
> 7f3197f05000-7f3197f06000 rw-p 00028000 08:01 5247775
/lib/
x86_64-linux-gnu/ld-2.27.so
> 7f3197f06000-7f3197f07000 rw-p 00000000 00:00 0
> 7ffe0ad57000-7ffe0ad78000 rw-p 00000000 00:00 0
[stack]
> 7ffe0adc2000-7ffe0adc5000 r--p 00000000 00:00 0
[vvar]
> 7ffe0adc5000-7ffe0adc7000 r-xp 00000000 00:00 0
[vdso]
> ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0
[vsyscall]
$

Здесь есть на что посмотреть. Программа выводит адреса указателей ptr1 и ptr2.
Если среди показанных выше отражений памяти найти сегмент стека, то можно
заметить, что она начинается с 0x7ffe0ad57000 и заканчивается на 0x7ffe0ad78000.
В данном диапазоне находятся наши указатели.
Это значит, указатели выделены в стеке, но ссылаются на какой-то другой сегмент
памяти — в данном случае на кучу. Использование стекового указателя для работы
с блоком кучи — распространенная практика.
Имейте в виду: указатели ptr1 и ptr2 находятся в одной области видимости и осво­
бождаются при завершении функции main , однако блоки памяти, полученные
в куче, не входят ни в какую область видимости. Они будут существовать, пока их
не освободят вручную. Вы можете видеть, что перед выходом из функции main оба
блока освобождаются с помощью указателей, которые на них ссылаются, и функции free.
Относительно данного примера следует сделать еще одно замечание: адреса, возвращенные функциями malloc и calloc, находятся внутри сегмента кучи. В этом
можно убедиться, если сравнить их с отражением памяти, помеченным как [heap].
Эта область находится в диапазоне от 0x564c03977000 до 0x564c03998000, к которому принадлежат адреса указателей ptr1 и ptr2: 0x564c03977260 и 0x564c03977260
соответственно.

188  Глава 5



Стек и куча

Если вернуться к функциям выделения памяти в куче, то calloc расшифровывается как clear and allocate («очистить и выделить»), а malloc — memory allocation
(«выделение памяти»). То есть calloc очищает выделенный блок памяти, а malloc
оставляет его инициализацию самой программе.
В C++ ключевые слова new и delete делают то же самое, что malloc
и free соответственно. Кроме того, оператор new определяет размер
выделяемого блока памяти по типу операнда и автоматически приводит
к этому типу возвращаемый указатель.

Но, взглянув на содержимое этих блоков, можно заметить: оба состоят из нулевых
байтов. Похоже, функция malloc тоже инициализировала блок памяти после его
выделения. Но если верить еe описанию в спецификации языка C, то она не должна
этого делать. Как же это объяснить? Чтобы разобраться, запустим тот же пример
в среде macOS (терминал 5.18).
Терминал 5.18. Вывод примера 5.3 в macOS
$ clang ExtremeC_examples_chapter5_3.c -o ex5_3.out
$ ./ ex5_3.out
Address of ptr1: 0x7ffee66b2888
Memory allocated by malloc at 0x7fc628c00370: 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x80 0x00 0x00
Address of ptr2: 0x7ffee66b2878
Memory allocated by calloc at 0x7fc628c02740: 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00
$

Если присмотреться, то можно заметить, что в блоке памяти, который выделила
функция malloc, находятся ненулевые байты, а блок, выделенный функцией calloc,
состоит из одних нулей. Что же делать? Можно ли исходить из того, что malloc
в Linux всегда выделяет обнуленные блоки?
Если вы собираетесь писать кросс-платформенную программу, то вам следует
руководствоваться спецификацией языка C. В спецификации сказано: функция
malloc не инициализирует выделяемый блок памяти.
Но даже если вы пишете свою программу только для Linux и вас не интересуют
другие операционные системы, то имейте в виду: в будущем компиляторы могут
изменить свое поведение. Поэтому, согласно спецификации C, мы должны всегда
предполагать, что блок памяти, выделенный функцией malloc, является неинициа­
лизированным и в случае необходимости его нужно инициализировать вручную.
Обратите внимание: благодаря этому malloc обычно работает быстрее, чем calloc.
На самом деле некоторые реализации malloc откладывают выделение блока памяти

Куча  189

до тех пор, пока к нему кто-то не обратится (для чтения или записи). Таким образом, ускоряется выделение памяти.
Если вы хотите инициализировать память, выделенную с помощью malloc , то
можете использовать функцию memset. Как это делается, показано в листинге 5.7.
Листинг 5.7. Использование функции memset для инициализации блока памяти

#include // для malloc
#include // для memset
int main(int argc, char** argv) {
char* ptr = (char*)malloc(16 * sizeof(char));
memset(ptr, 0, 16 * sizeof(char));
// заполняем нулями
memset(ptr, 0xff, 16 * sizeof(char)); // заполняем байтами 0xff
...
free(ptr);
return 0;
}

Еще одно средство выделения памяти в куче — функция realloc. Мы не использовали ее в примере 5.3. Она перераспределяет память, изменяя размер уже выделенного блока. В листинге 5.8 показано, как это может происходить.
Листинг 5.8. Использование функции realloc для изменения размера уже выделенного блока

int main(int argc, char** argv) {
char* ptr = (char*)malloc(16 * sizeof(char));
...
ptr = (char*)realloc(32 * sizeof(char));
...
free(ptr);
return 0;
}

Функция realloc не изменяет содержимое выделенной памяти, а просто расширяет старый блок. Если это не удается сделать из-за фрагментации, то она находит
другой блок подходящего размера и копирует в него данные из старого блока.
В этом случае последний освобождается. Как видите, такое перераспределение
может оказаться недешевой операцией, состоящей из множества этапов, и потому
ее следует использовать с осторожностью.
Заканчивая рассматривать пример 5.3, остановимся на функции free. Она освобождает уже выделенный блок кучи, принимая его адрес в виде указателя. Как уже
отмечалось ранее, когда блок кучи больше не нужен, его необходимо освободить.
Если этого не сделать, то возникнет утечка памяти. В примере 5.4 я продемонстрирую, как искать утечки памяти с помощью утилиты valgrind.

190  Глава 5



Стек и куча

Сначала создадим утечку (листинг 5.9).
Листинг 5.9. Создание утечки памяти из-за невыполнения освобождения выделенного блока
при возвращении из главной функции

#include // для функций по работе с кучей
int main(int argc, char** argv) {
char* ptr = (char*)malloc(16 * sizeof(char));
return 0;
}

У данной программы есть утечка памяти, поскольку после ее завершения в куче
остается неосвобожденный блок размером 16 байт. Это очень простой пример, но
по мере увеличения объема исходного кода и появления новых компонентов утечки
будет очень сложно (если вообще возможно) обнаружить невооруженным глазом.
Для поиска проблем с памятью в активном процессе применяются специальные
профилировщики, наиболее известен из которых valgrind.
Чтобы проанализировать пример 5.4 с помощью valgrind, нам сначала нужно собрать его с параметром -g. Затем профилировщик можно использовать для запуска
полученной программы. Во время работы исполняемого объектного файла valgrind
записывает все операции выделения и освобождения памяти. После завершения
или отказа программы он выводит записанную информацию и показывает, какой
объем памяти не был освобожден. Таким образом, мы сможем узнать, сколько
памяти утекло в ходе выполнения нашей программы.
В терминале 5.19 показано, как скомпилировать пример 5.4 и запустить его с помощью профилировщика valgrind.
Терминал 5.19. В выводе valgrind видно, что в результате выполнения примера 5.4
утекло 16 байт памяти
$ gcc -g ExtremeC_examples_chapter5_4.c -o ex5_4.out
$ valgrind ./ex5_4.out
==12022== Memcheck, a memory error detector
==12022== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==12022== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==12022== Command: ./ex5_4.out
==12022==
==12022==
==12022== HEAP SUMMARY:
==12022==
in use at exit: 16 bytes in 1 blocks
==12022==
total heap usage: 1 allocs, 0 frees, 16 bytes allocated
==12022==
==12022== LEAK SUMMARY:
==12022==
definitely lost: 16 bytes in 1 blocks
==12022==
indirectly lost: 0 bytes in 0 blocks

Куча  191
==12022==
possibly lost: 0 bytes in 0 blocks
==12022==
still reachable: 0 bytes in 0 blocks
==12022==
suppressed: 0 bytes in 0 blocks
==12022== Rerun with --leak-chck=full to see details of leaked memory
==12022==
==12022== For counts of detected and suppressed errors, rerun with: -v
==12022== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$

В разделе HEAP SUMMARY указано, что наша программа выделила один блок, а освободила 0; в результате в момент выхода выделенными оставались 16 байт. Если
опуститься чуть ниже к разделу LEAK SUMMARY, то можно увидеть, что эти 16 байт
безвозвратно потеряны, то есть произошла утечка памяти!
На случай, если вы хотите узнать, в какой именно строчке был выделен этот потерянный блок, у valgrind предусмотрен специальный параметр. В терминале 5.20
показано, как с помощью профилировщика найти строчки кода, ответственные за
выделение памяти.
Терминал 5.20. valgrind выводит строчки, ответственные за выделение памяти
$ gcc -g ExtremeC_examples_chapter5_4.c -o ex5_4.out
$ valgrind --leak-check=full ./ex5_4.out
==12144== Memcheck, a memory error detector
==12144== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==12144== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==12144== Command: ./ex5_4.out
==12144==
==12144==
==12144== HEAP SUMMARY:
==12144==
in use at exit: 16 bytes in 1 blocks
==12144==
total heap usage: 1 allocs, 0 frees, 16 bytes allocated
==12144==
==12144== 16 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12144==
at 0x4C2FB0F: malloc (in /usr/lib/valgrind/
vgpreload_memcheck-amd64-linux.so)
==12144==
by 0x108662: main (ExtremeC_examples_chapter5_4.c:4)
==12144==
==12144== LEAK SUMMARY:
==12144==
definitely lost: 16 bytes in 1 blocks
==12144==
indirectly lost: 0 bytes in 0 blocks
==12144==
possibly lost: 0 bytes in 0 blocks
==12144== still reachable: 0 bytes in 0 blocks
==12144==
suppressed: 0 bytes in 0 blocks
==12144==
==12144== For counts of detected and suppressed errors, rerun with : -v
==12144== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
$

192  Глава 5



Стек и куча

Как видите, мы передали утилите valgrind параметр --leak-check=full, и теперь
она показывает строчку кода, в которой был выделен потерянный блок кучи.
Это строчка 4 — то есть вызов malloc. Таким образом, вы можете проследить за
этим блоком и найти место, где его следует освободить.
Хорошо, отредактируем данный пример так, чтобы он освобождал выделенную
память. Для этого перед выражением return достаточно добавить инструкцию
free(ptr), как показано в листинге 5.10.
Листинг 5.10. Освобождение выделенного блока памяти в примере 5.4

#include // для функций по работе с кучей
int main(int argc, char** argv) {
char* ptr = (char*)malloc(16 * sizeof(char));
free(ptr);
return 0;
}

После внесения этого изменения единственный выделенный блок кучи будет
освобожден. Снова соберем данный код и запустим его с помощью все того же
профилировщика (терминал 5.21).
Терминал 5.21. Вывод valgrind после освобождения выделенного блока памяти
$ gcc -g ExtremeC_examples_chapter5_4.c -o ex5_4.out
$ valgrind --leak-check=full ./ex5_4.out
==12175== Memcheck, a memory error detector
==12175== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==12175== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==12175== Command: ./ex5_4.out
==12175==
==12175==
==12175== HEAP SUMMARY:
==12175==
in use at exit: 0 bytes in 0 blocks
==12175==
total heap usage: 1 allocs, 1 frees, 16 bytes allocated
==12175==
==12175== All heap blocks were freed -- no leaks are possible
==12175==
==12175== For counts of detected and suppressed errors, rerun with -v
==12175== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$

Обратите внимание на сообщение All Heap blocks were freed (Все блоки кучи были
освобождены). Оно фактически означает, что в программе не осталось утечек памяти. Программа, запущенная с помощью упомянутого профилировщика, может
существенно замедлить свою работу (в 10–50 раз), но благодаря этому вы сможете
легко выявить проблемы с памятью. Запускать код внутри valgrind, чтобы обнаружить утечки памяти на максимально раннем этапе, — рекомендованный подход.

Куча  193

Утечки памяти можно считать техническим долгом, если они вызваны плохой архитектурой, или рисками, если нам о них известно, но мы не знаем, что произойдет
в случае их роста. Но, по моему мнению, к ним следует относиться как к программным ошибкам; в противном случае их поиск задним числом может отнять много
времени. Именно так поступают команды разработчиков, пытаясь исправить их
как можно быстрее.
Помимо valgrind, существуют другие профилировщики памяти. Среди самых
известных можно выделить LLVM ASAN (Address Sanitizer) и MemProf. Профилировщики могут анализировать использование памяти и операции выделения
с помощью различных методов. Часть из них перечислены ниже.
zz Некоторые профилировщики могут служить изолированной средой для за-

пуска программ и мониторинга их операций с памятью. Мы применяли этот
метод для запуска примера 5.4 в изолированной среде valgrind. Он не требует
перекомпиляции кода.
zz Еще один метод заключается в использовании библиотек, которые предостав-

ляют некоторые профилировщики в качестве оберток для системных вызовов,
относящихся к управлению памятью. Таким образом, итоговый двоичный
файл будет содержать всю логику, необходимую для профилирования. Профилировщик valgrind и ASAN можно скомпоновать с итоговым исполняемым
объектным файлом в виде библиотек для профилирования памяти. Этот
метод требует повторной компиляции и даже некоторой модификации исходного кода.
zz Программы также могут предварительно загружать разные библиотеки с функ-

циями, которые меняют поведение операций по выделению памяти из стандартной библиотеки C. Таким образом, вам не обязательно компилировать
свой исходный код. Вы можете просто указать библиотеки таких профайлеров
в переменной среды LD_PRELOAD, и они будут загружены вместо стандартных
библиотек libc. Этот метод применяется в MemProf.
Подстановочная функция — это обертка вокруг стандартной функции.
Она находится в динамической библиотеке, загружается перед оригиналом и перенаправляет ему поступающие вызовы. Предварительную
загрузку динамических библиотек позволяет выполнять переменная
среды LD_PRELOAD.

Принцип работы кучи
Как уже отмечалось, куча имеет несколько отличий от стека. Поэтому при работе
с ней нужно руководствоваться отдельными правилами. Здесь мы поговорим об
этих отличиях и узнаем, как следует обращаться с данным пространством памяти
и чего не стоит делать.

194  Глава 5



Стек и куча

Каждый блок памяти (или переменная) в стеке имеет область видимости, поэтому
определить его время жизни не составляет труда. Каждый раз, когда мы покидаем
область видимости, все ее переменные исчезают. Но с кучей все намного сложнее.
У блока кучи нет никакой области видимости, и потому его время жизни является
неочевидным и должно быть определено вручную. Именно поэтому в современных
языках программирования, таких как Java, применяется автоматическое освобо­
ждение или сборка мусора с поддержкой поколений объектов. Сама программа
и библиотеки, которые она использует, не в состоянии определить время жизни
кучи, вследствие чего вся ответственность за это ложится на программиста.
Когда дело доходит до принятия конкретных решений, особенно в этом случае,
сложно предложить какой-то универсальный ответ. Любое мнение является предметом дискуссии и может вести к компромиссу.
Одна из лучших стратегий по преодолению сложностей, связанных с временем
жизни кучи, состоит в определении владельца блока памяти, а не области видимости, которая вмещает в себя данный блок. Конечно, это нельзя назвать полноценным решением.
Владелец полностью ответственен за управление жизненным циклом блока кучи;
он его изначально выделяет и затем, когда нужда в нем отпадает, освобождает.
Существует множество классических примеров применения этой стратегии. Она
используется в большинстве известных библиотек для работы с кучей в языке C.
В примере 5.5 показана очень простая реализация данного метода, позволяющая
обеспечивать управление жизненным циклом объекта очереди. Итак, в листинге 5.11 продемонстрирована стратегия владения.
Листинг 5.11. Пример 5.5 демонстрирует стратегию владения в целях управления временем
жизни кучи (ExtremeC_examples_chapter5_5.c)

#include // для функции printf
#include // для функций по работе с кучей
#define QUEUE_MAX_SIZE 100
typedef struct {
int front;
int rear;
double* arr;
} queue_t;
void init(queue_t* q) {
q->front = q->rear = 0;
// выделенными здесь блоками кучи владеет объект очереди
q->arr = (double*)malloc(QUEUE_MAX_SIZE * sizeof(double));
}

Куча   195
void destroy(queue_t* q) {
free(q->arr);
}
int size(queue_t* q) {
return q->rear - q->front;
}
void enqueue(queue_t* q, double item) {
q->arr[q->rear] = item;
q->rear++;
}
double dequeue(queue_t* q) {
double item = q->arr[q->front];
q->front++;
return item;
}
int main(int argc, char** argv) {
// выделенными здесь блоками кучи владеет функция main
queue_t* q = (queue_t*)malloc(sizeof(queue_t));
// выделяем необходимую память для объекта очереди
init(q);
enqueue(q, 6.5);
enqueue(q, 1.3);
enqueue(q, 2.4);
printf("%f\n", dequeue(q));
printf("%f\n", dequeue(q));
printf("%f\n", dequeue(q));
// освобождаем ресурсы, полученные объектом очереди
destroy(q);
// освобождаем память, выделенную для объекта кучи, принадлежащего функции main
free(q);
return 0;
}

В этом примере используется два экземпляра владения для двух разных объектов.
Первый относится к блоку кучи, на который ссылается указатель arr в структуре
queue_t, принадлежащей объекту очереди. Пока этот объект существует, данный
блок будет оставаться в памяти.
Второй экземпляр владения относится к блоку кучи, который функция main
выделила в качестве заглушки для объекта очереди, q (и владельцем которого
является). Очень важно отличать блоки кучи, принадлежащие объекту очереди

196  Глава 5



Стек и куча

и функции main, поскольку освобождение одного не приводит к освобождению
другого.
Чтобы продемонстрировать, как в приведенном выше коде может возникнуть утечка памяти, предположим, будто вы забыли вызвать функцию destroy для объекта
очереди. Это непременно приведет к утечке, поскольку блок кучи, полученный
внутри функции init, останется в памяти и будет освобожден.
Обратите внимание: факт владения блоком кучи со стороны объекта или функции следует отметить в комментариях. Код, которому этот блок не принадлежит,
не должен его освобождать.
Кроме того, нужно сказать, что повторное освобождение одного и того же блока
кучи приводит к повреждению памяти, и потому эту и любую другую проблему
подобного рода следует исправлять сразу после обнаружения. В противном случае
она может повлечь внезапные сбои программы.
Помимо стратегии владения, можно также использовать сборщик мусора — автоматический механизм, встроенный в программу, пытающийся освобождать блоки
памяти, на которые не ссылается ни один указатель. В языке C одним из традиционных и широко известных инструментов этого типа является консервативный
сборщик мусора Бема — Демерса — Вайзера; он предоставляет набор функций для
выделения памяти, которые нужно вызывать вместо malloc и других стандартных
операций языка C.
Больше информации о данном сборщике мусора можно найти здесь:
http://www.hboehm.info/gc/.

Еще один подход к управлению временем жизни блоков кучи — использование объекта RAII (resource acquisition is initialization — «получение ресурса есть
инициа­лизация»). Это идиома объектно-ориентированного программирования,
согласно которой время жизни ресурса (такого как выделенный блок кучи) можно
привязать к времени жизни объекта. Иными словами, мы задействуем объект,
который во время своего создания инициализирует ресурс, а во время своего уничтожения — освобождает его. К сожалению, данный метод нельзя реализовать в C,
поскольку в этом языке программиста не уведомляют об уничтожении объектов.
Но можно эффективно применять в C++ с помощью деструкторов. Объект RAII
инициализирует ресурс в своем конструкторе, а тот содержит код, отвечающий за
деинициа­лизацию. Следует отметить, что в C++ деструктор вызывается автоматически, когда объект удаляется или выходит из области видимости.
Подводя итоги, перечислю факты и рекомендации, которые нужно учитывать при
работе с кучей.

Управление памятью в средах с ограниченными ресурсами   197
zz Выделение памяти в куче отнимает определенные ресурсы. Разные функции

выделения памяти имеют разные накладные расходы. Самой «дешевой» является функция malloc.
zz Все блоки памяти, выделенные в пространстве кучи, должны быть освобождены

либо сразу после того, как в них пропадает необходимость, либо перед завершением программы.
zz Поскольку у блоков кучи нет области видимости, во избежание потенциальных

утечек программа должна уметь управлять памятью.
zz Как показывает практика, при выделении каждого блока кучи следует придер-

живаться выбранной стратегии управления памятью.
zz Выбранную стратегию и предположения, на которых она основана, следует до-

кументировать на любом участке кода, где происходит доступ к блоку, чтобы
в будущем программисты об этом знали.
zz В определенных языках программирования, таких как C++, управлять ресур-

сами (такими как, к примеру, блоки кучи) можно с помощью объектов RAII.
До сих пор мы исходили из того, что имеем достаточно памяти для хранения
крупных объектов и выполнения любого рода программ. Но в следующем разделе
мы установим определенные ограничения на доступную память и обсудим среды
с небольшим объемом памяти или с невозможностью ее расширения (ввиду таких
факторов, как стоимость, время, производительность и т. д.). В подобных условиях
доступную память нужно использовать максимально эффективно.

Управление памятью в средах
с ограниченными ресурсами
Существуют среды, в которых память является ценным и зачастую ограниченным
ресурсом. В ряде систем ключевым фактором выступает производительность,
и программы, которые в них выполняются, должны работать быстро, вне зависимости от количества доступной памяти. Каждая среда требует применения
определенных методик, позволяющих избежать нехватки памяти и снижения
производительности. Прежде всего нужно определиться с тем, что такое среда
с ограниченными ресурсами.
Ограниченность ресурсов еще не говорит о малом объеме памяти. Ограничения
обычно касаются того, как программа может использовать данную память. Это
могут быть жесткие лимиты, установленные вашим клиентом, определенная аппаратная конфигурация или отсутствие поддержки больших объемов памяти в вашей
операционной системе (как, например, в MS-DOS).

198  Глава 5



Стек и куча

Но даже при отсутствии подобных ограничений программист должен пытаться
использовать как можно меньше памяти и делать это наиболее оптимальным образом. Потребление памяти — одно из ключевых нефункциональных требований
к проекту, и потому его нужно тщательно отслеживать и оптимизировать.
В этом разделе вы сначала познакомитесь с методиками, которые используются
в низкоуровневых средах в целях преодоления проблем с нехваткой памяти, а затем
мы поговорим о приемах, распространенных в производительных средах и призванных увеличить скорость работы программ.

Среды с ограниченной памятью
В таких средах ограничением является память, и ваши алгоритмы должны уметь
справляться с ее нехваткой. К данной категории обычно относятся встраиваемые
системы с памятью размером десятки или сотни мегабайт. Существует несколько приемов по управлению памятью в подобных средах, однако ни один из них
не сравнится по своей эффективности с хорошо оптимизированным алгоритмом.
В этом случае обычно используются алгоритмы с низким потреблением памяти,
которое обычно выливается в увеличение времени работы.
Поговорим об этом более подробно. Каждый алгоритм обладает временно'й сложностью и сложностью памяти. Первая описывает отношение между размером
ввода и временем, необходимым для завершения алгоритма. Вторая описывает
отношение между размером ввода и объемом памяти, который нужен алгоритму
для выполнения работы. В математике эти отношения обычно обозначаются как
O большое, но мы не станем на них останавливаться. Нас интересуют качественные
характеристики, поэтому для обсуждения сред с ограниченной памятью можно
обойтись и без математики.
В идеале алгоритм должен иметь низкую временну' ю сложность и низкую сложность памяти. Иными словами, быстрая работа и умеренное потребление памяти
крайне желательны, но в реальности подобное сочетание встречается редко. Точно
так же мы не ожидаем низкой производительности от алгоритма, которому нужно
много памяти.
В большинстве случаев мы имеем дело с неким балансом между памятью и скоростью (то есть временем работы). Например, если один алгоритм сортировки
работает быстрее другого, то ему обычно нужно больше памяти, хотя оба делают
одно и то же.
При написании кода можно исходить из того, что он будет выполняться в системе
с ограниченной памятью, даже если нам известно, что в конечной промышленной
среде ресурсов будет более чем достаточно, — это хороший, но консервативный

Управление памятью в средах с ограниченными ресурсами  199

подход. Такое предположение делается в целях снижения риска чрезмерного потребления памяти.
Обратите внимание: основой для этого предположения должен быть достаточно
точный прогноз о среднем объеме доступной памяти в конечной системе. Алгоритмы, рассчитанные на среды с ограниченной памятью, более медленные по
своей природе, и, чтобы не попасть в ловушку, их следует использовать с осторожностью.
Ниже мы рассмотрим кое-какие методики, которые помогут нам собирать расходованную память и уменьшить ее потребление в средах с ограниченными ресурсами.

Упакованные структуры
Один из самых простых способов уменьшить потребление памяти — использовать
упакованные структуры. Они игнорируют выравнивание в памяти и применяют
более компактную схему размещения своих полей.
На самом деле это компромиссное решение. Ввиду отказа от выравнивания чтение
структуры занимает больше времени, что замедляет программу.
Это простой метод, но подходящий не для всех программ. Более подробно о нем
можно почитать в разделе «Структуры» главы 1.

Сжатие
Это эффективная методика, особенно если программа работает с большим количеством текстовых данных, которые нужно хранить в памяти. Текстовые данные,
по сравнению с двоичными, имеют высокую степень сжатия. Таким образом, программа может хранить текст в сжатом виде иэкономить много памяти.
Но за все приходится платить: алгоритмы сжатия требуют интенсивных вычислений и сильно нагружают процессор, поэтому конечная производительность ухудшается. Данный метод идеально подходит для программ, которые хранят много
текстовых данных, но нечасто их используют; в противном случае понадобится
много операций сжатия/разжатия, что в конечном счете затруднит или сделает
невозможным применение программы.

Внешнее хранилище данных
Использование внешнего хранилища данных в виде сетевого сервиса, облачной инфраструктуры или обычного жесткого диска — очень распространенный
и действенный способ борьбы с нехваткой памяти. Обычно предполагается, что
программа может выполняться в среде с ограниченными ресурсами, поэтому

200  Глава 5



Стек и куча

существует много программ, которые используют данный подход, даже когда
памяти в достатке.
Использование этой методики обычно предполагает, что оперативная память
играет роль кэша. Еще одно предположение — все данные в память не поместятся
и загружать их нужно по частям или постранично.
Эти алгоритмы не решают проблемы с нехваткой памяти как таковые, но пытаются решить проблемы с медленным внешним хранилищем данных. Внешние хранилища всегда очень медленные по сравнению с оперативной памятью.
Поэтому алгоритмы должны балансировать между чтением из внутренней памяти
и из внешнего хранилища. Данный подход используют все базы данных, включая
PostgreSQL и Oracle.
В большинстве проектов подобные алгоритмы не имеет смысла изобретать и реализовывать с нуля, поскольку они довольно нетривиальные. Разработчики, стоящие
за такими библиотеками, как SQLite, годами шлифуют свой код.
Если вам необходимо обращаться к внешнему хранилищу данных, такому как
файл, БД или сетевой узел, сохраняя при этом умеренное использование памяти,
то вы всегда можете подобрать подходящий инструмент.

Высокопроизводительные среды
Как уже объяснялось выше, быстрым алгоритмам свойственно повышенное потребление памяти. Поговорим об этом более подробно.
Наглядной иллюстрацией этого утверждения может быть использование кэша для
увеличения производительности. Кэширование данных означает повышенный расход памяти, но взамен мы получаем повышенную скорость, если кэш используется
должным образом
Но расширение памяти — не всегда оптимальный путь увеличения производительности. Существуют другие методы, которые имеют прямое или опосредованное
отношение к памяти и могут существенно повлиять на скорость работы алгоритма.
Но прежде, чем переходить к ним, сначала поговорим о кэшировании.

Кэширование
Термин «кэширование» объединяет в себе все похожие методики, применяемые
во многих частях компьютерной системы и подразумевающие использование
двух хранилищ данных с разными скоростями чтения/записи. Например, у центрального процессора есть ряд внутренних регистров, которые работают очень
быстро на чтение и запись. Кроме того, процессор должен копировать данные из
оперативной памяти, которая во много раз медленнее его регистров. Здесь нужен

Управление памятью в средах с ограниченными ресурсами  201

механизм кэширования, иначе низкая скорость оперативной памяти станет доминирующей и нивелирует высокую производительность вычислений, свойственную
процессору.
В качестве еще одного примера можно привести работу с базой данных. Файлы
БД обычно хранятся на внешнем жестком диске, который на несколько порядков
медленнее оперативной памяти. Очевидно, что здесь нужен механизм кэширования, иначе самая низкая скорость станет общим знаменателем и будет определять
производительность всей системы.
Кэширование и связанные с ним нюансы заслуживают отдельной главы, поскольку
перед их рассмотрением необходимо изучить абстрактные модели и соответствующие термины.
С помощью этих моделей можно предсказать, насколько хорошо проявит себя кэш
и какого повышения производительности можно ожидать от его использования.
Здесь мы попытаемся объяснить принцип работы кэширования простым и наглядным образом.
Представьте: у вас есть медленное хранилище, которое может содержать много
элементов. И быстрое — с ограниченной вместимостью. Очевидно, здесь нужно
искать компромисс. Хранилище с высокой скоростью, но малым размером можно
назвать кэшем. Перед обработкой элементов их было бы разумно копировать из
медленного хранилища в быстрое — просто потому, что оно быстрее.
Время от времени вам нужно будет обращаться к медленному хранилищу за новыми элементами. Очевидно, что элементы следует доставать не по одному, поскольку
это было бы неэффективно. Вместо этого в быстрое хранилище лучше копировать
целую группу элементов (назовем ее бакетом). Принято говорить, что элементы
кэшируются в быстрое хранилище.
Теперь представьте: при обработке одного элемента вам нужно загрузить другой,
и для этого приходится обращаться к медленному хранилищу. Первое, что приходит на ум, — поискать нужный элемент в недавно скопированном нами бакете,
который уже находится в кэше.
Если поиск увенчался успехом, то нет нужды использовать медленное хранилище.
Это так называемое попадание. При отсутствии элемента в кэше вам придется сходить в медленное хранилище и скопировать в кэш еще один бакет. Это называют
промахом. Естественно, чем больше попаданий, тем выше производительность.
Этот принцип можно применить к кэшу центрального процессора и оперативной
памяти. В кэше ЦПУ находятся последние инструкции и данные, прочитанные из
более медленной оперативной памяти.
Далее обсудим дружественный к кэшированию код и понаблюдаем за тем, насколько быстрее его выполняет процессор.

202  Глава 5



Стек и куча

Дружественный к кэшированию код
При выполнении инструкции процессору сначала нужно получить все необходимые данные. Они находятся в оперативной памяти по определенному адресу,
который указан в инструкции.
Перед вычислением данные должны быть скопированы в регистры процессора. Одна­
ко он обычно копирует больше блоков, чем ожидалось, и помещает их в свой кэш.
В следующий раз, если ему понадобится значение, находящееся недалеко от предыдущего адреса, он сможет найти его в кэше и избежать обращения к оперативной
памяти, что намного повысит скорость чтения. Как объяснялось в предыдущем
пункте текста, это попадание кэша. Если значение не удается найти, то это промах
кэша, в результате которого процессору придется считывать и копировать нужный
адрес из оперативной памяти, что довольно медленно. В целом, чем выше частота
попаданий, тем быстрее работает код.
Зачем процессор копирует соседние значения, находящиеся поблизости от искомого адреса? Это связано с принципом локальности. В компьютерных системах доступ
к данным, находящимся в одном районе, обычно происходит чаще. Процессор следует данному принципу и загружает дополнительные данные из той же местности.
Если алгоритм умеет извлекать пользу из такого поведения, то будет выполняться
быстрее. Поэтому такие алгоритмы называют дружественными к кэшированию.
В примере 5.6 показана разница в производительности между двумя блоками кода,
один из которых дружественен к кэшированию, а другой — нет (листинг 5.12).
Листинг 5.12. Пример 5.6 демонстрирует производительность обычного и дружественного
к кэшу кода (ExtremeC_examples_chapter5_6.c)

#include // для функции printf
#include // для функций по работе с кучей
#include // для функции strcmp
void fill(int* matrix, int rows, int columns) {
int counter = 1;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < columns; j++) {
*(matrix + i * columns + j) = counter;
}
counter++;
}
}
void print_matrix(int* matrix, int rows, int columns) {
int counter = 1;
printf("Matrix:\n");
for (int i = 0; i < rows; i++) {

Управление памятью в средах с ограниченными ресурсами  203
for (int j = 0; j < columns; j++) {
printf("%d ", *(matrix + i * columns + j));
}
printf("\n");
}
}
void print_flat(int* matrix, int rows, int columns) {
printf("Flat matrix: ");
for (int i = 0; i < (rows * columns); i++) {
printf("%d ", *(matrix + i));
}
printf("\n");
}
int friendly_sum(int*
int sum = 0;
for (int i = 0; i <
for (int j = 0; j
sum += *(matrix
}
}
return sum;
}

matrix, int rows, int columns) {
rows; i++) {
< columns; j++) {
+ i * columns + j);

int not_friendly_sum(int* matrix, int rows, int columns) {
int sum = 0;
for (int j = 0; j < columns; j++) {
for (int i = 0; i < rows; i++) {
sum += *(matrix + i * columns + j);
}
}
return sum;
}
int main(int argc, char** argv) {
if (argc < 4) {
printf("Usage: %s [print|friendly-sum|not-friendly-sum] ");
printf("[number-of-rows] [number-of-columns]\n", argv[0]);
exit(1);
}
char* operation = argv[1];
int rows = atol(argv[2]);
int columns = atol(argv[3]);
int* matrix = (int*)malloc(rows * columns * sizeof(int));
fill(matrix, rows, columns);
if (strcmp(operation, "print") == 0) {
print_matrix(matrix, rows, columns);

204  Глава 5



Стек и куча

print_flat(matrix, rows, columns);
}
else if (strcmp(operation, "friendly-sum") == 0) {
int sum = friendly_sum(matrix, rows, columns);
printf("Friendly sum: %d\n", sum);
}
else if (strcmp(operation, "not-friendly-sum") == 0) {
int sum = not_friendly_sum(matrix, rows, columns);
printf("Not friendly sum: %d\n", sum);
}
else {
printf("FATAL: Not supported operation!\n");
exit(1);
}
free(matrix);
return 0;
}

Эта программа вычисляет и выводит сумму всех элементов матрицы, однако имеет
еще одну особенность.
Пользователь может изменять ее поведение путем передачи параметров. Допустим,
мы хотим вывести матрицу размером 2 × 3, инициализированную алгоритмом из
функции fill. Для этого пользователь должен указать параметр print и нужное
количество строк и столбцов. В терминале 5.22 показано, как эти параметры передаются итоговому исполняемому файлу.
Терминал 5.22. Вывод примера 5.6 с матрицей 2 × 3
$ gcc ExtremeC_examples_chapter5_6.c -o ex5_6.out
$ ./ex5_6.out print 2 3
Matrix:
1 1 1
2 2 2
Flat matrix: 1 1 1 2 2 2
$

Данный вывод состоит из двух разных представлений матрицы: двумерного
и плоского. Как видите, элементы матрицы развертываются в памяти по строкам.
Это значит, они сохраняются строка за строкой. Поэтому если процессор копирует
из строки один элемент, то все остальные элементы в данной строке тоже, скорее
всего, копируются в кэш. И, как следствие, сложение лучше выполнять по строкам,
а не по столбцам.
Если еще раз взглянуть на код, то можно заметить, что в функции friendly_sum
сложение происходит построчно, а в friendly_sum — по столбцам. Посмотрим,
сколько времени у них уйдет на сложение элементов матрицы с 20 000 строк
и 20 000 столбцов. Разница налицо (терминал 5.23).

Управление памятью в средах с ограниченными ресурсами   205
Терминал 5.23. Разница во времени выполнения алгоритмов сложения элементов матрицы
с развертыванием по строкам и по столбцам
$ time ./ex5_6.out friendly-sum 20000 20000
Friendly sum: 1585447424
real
user
sys

0m5.192s
0m3.142s
0m1.765s

$ time ./ex5_6.out not-friendly-sum 20000 20000
Not friendly sum: 1585447424
real
user
sys
$

0m15.372s
0m14.031s
0m0.791s

Разница во времени составила около 10 секунд! Программа была скомпилирована
в macOS с помощью clang. Такое отличие говорит о том, что одна и та же логика
с одним и тем же объемом памяти может выполняться намного дольше, если в ней
выбран не тот способ доступа к элементам матрицы! Этот пример наглядно демонстрирует преимущество кода, дружественного к кэшированию.
Утилита time доступна во всех операционных системах семейства Unix.
С ее помощью можно измерить время между запуском и завершением
программы.

Прежде чем переходить к следующему методу, следует сказать несколько слов
о накладных расходах на выделение и освобождение памяти.

Накладные расходы на выделение и освобождение памяти
Здесь мы отдельно поговорим о цене, которую приходится платить за выделение
и освобождение памяти в куче. Вас, наверное, удивит тот факт, что эти операции
могут занимать довольно много времени и требовать существенных ресурсов,
особенно если речь идет о выделении и освобождении множества блоков каждую
секунду.
В отличие от стека, в котором выделение памяти происходит относительно быстро
и не требует получения дополнительного диапазона адресов, куча должна сначала
найти свободный блок памяти достаточного размера, и это может быть затратной
операцией.
Для выделения и освобождения существует множество алгоритмов, и между этими
операциями всегда приходится находить разумный баланс. Если вы хотите быстро
выделять память, то для этого необходимо потреблять больше блоков. И наоборот,

206  Глава 5



Стек и куча

если вы хотите использовать меньше памяти, то ваш алгоритм выделения будет
работать медленней.
Помимо функций malloc и free, которые входят в стандартную библиотеку, в языке C можно использовать другие аллокаторы. В их число входят ptmalloc, tcmalloc,
Haord и dlmalloc.
Как же решить эту скрытую проблему? Очень просто: реже выделять и освобождать
память. В ряде программ, которым необходимо часто выделять место в куче, такое
решение может показаться недостижимым. В подобных случаях в куче обычно
выделяют один большой блок и пытаются работать с ним самостоятельно. В результате получается еще один слой логики для выделения/освобождения памяти
(может быть, не такой сложный, как функции malloc и free), управляющий этим
большим блоком.
Но есть другой метод, основанный на пулах памяти. Прежде чем завершать эту
главу, мы кратко объясним, как он работает.

Пулы памяти
Как вы уже знаете, выделение и освобождение памяти — затратные операции.
Чтобы сократить их количество и повысить производительность, можно использовать пул заранее выделенных блоков кучи фиксированного размера. Обычно
каждый блок имеет идентификатор, который можно получить с помощью API,
предназначенного для управления пулом. Позже, когда блок больше не нужен, его
можно освободить. Поскольку объем выделенной памяти не меняется, это отличное решение для алгоритмов, стремящихся к предсказуемому поведению в средах
с ограниченными ресурсами.
Подробное описание пулов памяти выходит за рамки данной книги; если вы хотите
узнать больше, то в Интернете есть много материалов на эту тему.

Резюме
В этой главе мы в основном сосредоточились на стеке и куче и попытались выяснить, как их использовать. Мы также уделили некоторое внимание средам с ограниченной памятью и рассмотрели методики повышения производительности, такие
как кэширование и пулы памяти.
В данной главе мы:
zz обсудили инструменты и методики, которые используются для исследования

сегментов стека и кучи;
zz познакомились с отладчиками и использовали gdb в качестве основного инстру-

мента для поиска проблем, связанных с памятью;

Резюме   207
zz поговорили о профилировщиках памяти и использовали valgrind для поиска

утечек и висячих указателей на этапе выполнения программы;
zz сравнили время жизни стековой переменной и блока кучи и выяснили, как их

оценивать;
zz убедились в том, что в стеке управление памятью происходит автоматически,

а в куче — полностью вручную;
zz прошлись по распространенным ошибкам, возникающим при работе с пере-

менными стека;
zz обсудили среды с ограниченными ресурсами и увидели, как в них можно опти-

мизировать память;
zz поговорили о высокопроизводительных средах и о том, какие методики можно

использовать для повышения производительности.
Следующие четыре главы посвящены объектной ориентированности. На первый
взгляд может показаться, что это не имеет отношения к C, однако на самом деле
на данном языке можно писать настоящий объектно-ориентированный код.
Вы узнаете, как правильно проектировать и решать проблемы в стиле ООП, и получите рекомендации по написанию понятного и корректного кода на C.
В главе 6 рассматриваются инкапсуляция и основы объектно-ориентированного
программирования. В ходе обсуждения этой темы будет предоставлен как теоретический материал, так и практические примеры.

6

ООП
и инкапсуляция

Объектно-ориентированному программированию (ООП) посвящено множество
отличных книг и статей. Но мне кажется, немногие из них рассматривают эту
тему в контексте такого процедурного языка, как C! Действительно, разве это
возможно? Можно ли писать объектно-ориентированные программы на языке,
который на это не рассчитан? В частности, возможно ли написать на C программу
в стиле ООП?
Если коротко, то да. Но сначала следует объяснить почему. Поставленные выше вопросы нужно разбить на части и подумать над тем, что на самом деле представляет
собой ООП. Почему мы можем писать объектно-ориентированные программы на
языке, который не поддерживает данный стиль? Это похоже на парадокс, но здесь
нет ничего парадоксального. В текущей главе я попытаюсь объяснить, почему это
возможно и как это следует делать.
Вы также можете задаться вопросом, какой смысл обсуждать и изучать ООП, если
вы собираетесь задействовать C в качестве основного языка программирования?
Почти все зрелые проекты на C, такие как открытые ядра, реализации сервисов
наподобие HTTPD, Postfix, nfsd и ftpd, а также многочисленные библиотеки, такие как OpenSSL и OpenCV, написаны в объектно-ориентированном стиле. Это
не означает, что C — объектно-ориентированный язык; просто подход, который использовался при организации внутренней структуры указанных проектов, основан
на объектно-ориентированном образе мышления.
Я настоятельно рекомендую почитать данную главу и три следующие и узнать
больше об ООП, поскольку это, во-первых, позволит вам мыслить и проектировать
подобно авторам упомянутых выше библиотек, а во-вторых, это очень поможет вам
читать исходный код таких проектов.
В синтаксисе C нет таких объектно-ориентированных концепций, как классы, наследование и виртуальные функции. Но их поддержку можно реализовать самостоятельно. На самом деле почти все компьютерные языки, которые когда-либо
существовали, имели все необходимое для поддержки ООП — задолго до Smalltalk,
C++ и Java. Дело в том, что любой язык программирования общего назначения
должен предоставлять возможность расширения своих типов данных, а это уже
первый шаг в направлении ООП.

Глава 6



ООП и инкапсуляция   209

Синтаксис C не может и не должен поддерживать объектно-ориентированные
возможности; и не потому, что старый, а по нескольким очень весомым причинам,
которые мы обсудим в данной главе. Проще говоря, на C можно писать в стиле
ООП, просто это немного сложнее и требует чуть больше усилий.
ООП в C посвящено не так много книг и статей, и обычно их авторы пытаются
создать систему типов для написания классов, реализации наследования, полиморфизма и т. д. В этих книгах поддержка ООП рассматривается как совокупность
функций, макросов и препроцессора, с помощью которых на C можно писать объектно-ориентированные программы. В данной главе мы пойдем по другому пути.
Вместо того чтобы создавать из C новую разновидность C++, мы поразмышляем
о потенциале этого языка с точки зрения ООП.
Часто говорят, что ООП — еще одна парадигма программирования наряду с процедурной и функциональной. Но это верно лишь отчасти. ООП — скорее образ
мышления и способ анализа проблем. Это взгляд на вселенную и иерархию объектов в ней. Это часть древнего, свойственного человеку подхода к постижению
и исследованию физических и абстрактных вещей, которые нас окружают. Данный
подход лежит в основе нашего понимания природы.
Мы всегда обдумывали любой вопрос с объектно-ориентированной точки зрения.
ООП лишь берет этот взгляд на вещи, который свойственен нам всем, и применяет
его к решению вычислительных задач. Таким образом, становится понятно, почему ООП — наиболее распространенная парадигма программирования, которая
используется в разработке ПО.
В данной главе и трех последующих будет показано, что на языке C реализуемы любые концепции ООП — хотя это может быть довольно сложной задачей. Мы знаем:
ООП имеет место в C, поскольку это уже было доказано на практике другими людьми (особенно что касается создания C++ поверх C), которые написали множество
сложных и успешных программ на этом языке в объектно-ориентированном стиле.
Однако в этих главах вы не найдете упоминания конкретных библиотек или наборов макросов, с помощью которых можно объявлять классы, организовывать
наследование и работать с другими концепциями ООП. Я также не стану навязывать никакие методологии или правила наподобие определенных соглашений об
именовании. Мы просто будем использовать обычный язык C в целях реализации
объектной ориентированности.
Причина, по которой я отвожу целых четыре главы теме ООП в C, такова: объектная ориентированность имеет обширную теоретическую основу и, чтобы продемонстрировать ее в полном объеме, нужно рассмотреть множество разных примеров.
В этой главе будет представлен основной теоретический материал, стоящий за
ООП, а в более практические аспекты мы погрузимся в следующих главах. Учитывая все вышесказанное, нам следует начать с теории, поскольку большинство
умелых программистов на C, даже те, у кого за плечами многолетний опыт, незнакомы с концепциями ООП.

210  Глава 6



ООП и инкапсуляция

Эти четыре главы в совокупности охватят почти все, с чем можно столкнуться в ООП.
zz Прежде всего будет дано определение самым фундаментальным терминам,

которые используются в литературе в отношении ООП. Я расскажу, что такое
классы, объекты, атрибуты, поведение, методы, предметные области и т. д. Они
будут активно применяться в данных четырех главах. Эти концепции явля­ются
неотъемлемой частью общепринятой терминологии и ключом к пониманию
других аспектов объектной ориентированности.
zz Первый раздел этой главы посвящен терминологии, но не целиком; мы также

подробно обсудим истоки идеи ООП, философию, лежащую в ее основе, и природу объектно-ориентированного образа мышления.
zz Второй раздел посвящен языку C и тому, почему он не является и не может

быть объектно-ориентированным. Это важный вопрос, который заслуживает
хорошего ответа. К данной теме мы вернемся во время исследования системы
Unix и ее тесных отношений с C в главе 10.
zz В третьем разделе мы поговорим об инкапсуляции — одной из основополага­

ющих концепций ООП. Если коротко, то это механизм, который позволяет создавать и использовать объекты. Тот факт, что вы можете размещать переменные
и методы внутри объектов, — прямое следствие инкапсуляции. Мы тщательно
рассмотрим данное явление и разберем несколько примеров.
zz Затем мы перейдем к принципу сокрытия, который в определенном смысле

является побочным (хотя и очень важным) эффектом поддержки инкапсуляции.
Без него мы бы не могли изолировать и разделять программные модули, что
фактически сделало бы невозможным предоставление клиентам API, не зависящих от реализации. Это последнее, о чем пойдет речь в данной главе.
Как уже упоминалось ранее, эта тема будет изложена в четырех главах. Например,
в главе 7 мы поговорим о композиции, а в главах 8 и 9 — об агрегации, наследовании,
полиморфизме и абстрагировании.
Начнем же мы с теории, стоящей за ООП. Посмотрим, как вычленить объектную
модель из нашего процесса мышления и воплотить ее в программном компоненте.

Объектно-ориентированное мышление
Как уже говорилось во вступительной части данной главы, объектно-ориентированное мышление — способ анализа и разбиения на части окружающего нас мира.
Когда вы смотрите на вазу, стоящую на столе, вы понимаете, что это два отдельных
объекта, и для этого вам не нужно долго думать.
Вы интуитивно осознаете, что между этими предметами есть граница. Вы знаете,
что изменение цвета вазы не повлияет на цвет стола.

Объектно-ориентированное мышление  211

Эти наблюдения говорят о том, что мы воспринимаем окружающий мир в объектноориентированном ключе. Иными словами, мы просто создаем у себя в голове отражение объектно-ориентированной реальности. Это часто можно видеть в компьютерных играх, программах для 3D-моделирования и инженерном ПО, где мы
имеем дело со множеством взаимодействующих объектов.
Суть ООП состоит в применении объектно-ориентированного мышления к проектированию и разработке программного обеспечения. Мы воспринимаем окружающий мир объектно-ориентированным образом, поэтому парадигма ООП стала
наиболее востребованной при написании программ.
Конечно, существуют проблемы, которые сложно решить с помощью этого подхода и для анализа и устранения которых было бы проще использовать другую
парадигму. Но они считаются относительно редкими.
В следующем подразделе вы узнаете больше о переходе от объектно-ориентированного мышления к написанию объектно-ориентированного кода.

Как мы мыслим
Сложно найти программу, в которой нет ни следа объектно-ориентированного
мышления, даже если она написана на C или на другом языке без поддержки ООП.
Написание объектно-ориентированного кода естественно для нас. На это указывают
даже имена переменных. Взгляните на следующий пример (листинг 6.1). В нем объявлены переменные, необходимые для хранения информации о десяти студентах.
Листинг 6.1. Четыре массива, названные по одному и тому же принципу с использованием
префикса student_ и предназначенные для хранения информации о десяти студентах

char*
char*
int
double

student_first_names[10];
student_surnames[10];
student_ages[10];
student_marks[10];

Объявления в данном листинге показывают, как переменные можно объединять
по какому-то общему признаку (в данном случае это «студент») с помощью их
имен. Если этого не сделать, то наш объектно-ориентированный разум запутается
в бессвязных именах. Представьте, что наш код выглядит так (листинг 6.2).
Листинг 6.2. Четыре массива с произвольными именами, предназначенные для хранения
информации о десяти студентах

char*
char*
int
double

aaa[10];
bbb[10];
ccc[10];
ddd[10];

212  Глава 6



ООП и инкапсуляция

Назначение переменным таких имен, как в листинге 6.2, независимо от вашего
опыта программирования, приведет к большим проблемам при написании алгоритма. Именование переменных всегда было важным аспектом, поскольку имена
напоминают нам о концепциях и их отношениях с данными. Эта информация теряется, если мы используем в своем коде произвольные имена. Это может не быть
проблемой для компьютера, но затруднит анализ и диагностику со стороны программистов, а также повысит вероятность допущения ошибок.
Объясню, что имеется в виду под концепцией в этом контексте. Концепция — мысленный или абстрактный образ, который существует в нашем воображении в виде
мысли или идеи. Он может быть сформирован нашим восприятием материального
объекта, но может быть и полностью воображаемым. Когда вы смотрите на дерево
или думаете об автомобиле, вам на ум приходят соответствующие образы, которые
представляют собой две разные концепции.
Обратите внимание: иногда мы используем термин «концепция» в другом смысле — например, очевидно, что понятие «объектно-ориентированные концепции»
не соответствует тому определению, которое я привел только что. Слово «концепция», будучи примененным в техническом контексте, означает принципы, которые
следует понимать при изучении той или иной темы. Пока мы будем придерживаться именно такого определения.
Концепции играют важную роль в объектно-ориентированном мышлении, ведь
если вы не можете держать в уме понимание объектов, то не сможете извлечь
информацию о том, что они представляют и к чему относятся, как и понять их
взаимоотношения.
Таким образом, объектно-ориентированное мышление основано на концепциях
и связях между ними. Из этого следует, что для написания настоящей объектноориентированной программы необходимо иметь хорошее понимание всех соответствующих объектов, их концепций и того, как они связаны друг с другом.
Объектно-ориентированную картину, состоящую из множества концепций и их
взаимоотношений, сложно передать другим людям — например, во время обсу­
ждения задачи с коллегами. Более того, такие воображаемые концепции изменчивы и неуловимы и о них можно легко забыть. Это подчеркивает тот факт, что превращение ваших мысленных образов в идеи, которые можно объяснить, потребует
их представления с помощью моделей и других инструментов.

Диаграммы связей и объектные модели
Чтобы лучше понять все то, о чем мы говорили выше, рассмотрим практический
пример. Допустим, вы создали описание сцены. Цель подобного рода описания —
донести до аудитории соответствующие концепции. Попробуйте взглянуть на это
с такой стороны: тот, кто создает описание, имеет в своем воображении диаграмму

Объектно-ориентированное мышление  213

связей, в которой разложены по полочкам различные концепции и то, как они
соединены вместе; цель данного человека — передать эту диаграмму аудитории.
Вы можете сказать, что примерно так происходит любое творческое выражение
мыслей; действительно, описанный процесс повторяется каждый раз, когда вы
смотрите на картину, слушаете музыку или читаете роман.
Теперь взглянем на письменное описание классной комнаты. Расслабьтесь и попытайтесь представить себе то, о чем читаете. Все, что вы увидите в своем воображении, — концепция, переданная следующим описанием.
Наша классная комната старая, с двумя большими окнами. При входе в нее
на противоположной стене можно видеть окно. Посреди нее находится
несколько коричневых деревянных стульев. На этих стульях сидят пять
учеников, и два из них — мальчики. На стене справа от нас висит зеленая
деревянная доска, и учитель что-то говорит ученикам. Он пожилой человек
в синей рубашке.

Посмотрим, какие концепции сформировались в нашем воображении. Но имейте
в виду: вы можете слишком глубоко погрузиться в свои мысли и даже не заметить
этого. Поэтому постараемся придерживаться описания. Например, я могу представить, что у девочек в классе светлые волосы. Но в описании об этом не упоминается,
и потому данную концепцию следует отбросить. В следующем абзаце я объясню,
какая картина возникла в моем воображении, и, прежде чем продолжать, вам стоит
попытаться сделать то же самое.
Я представил пять концепций (или мысленных образов, или объектов), по одной
для каждого ученика в классе. Еще пять концепций — для стульев. По одной — для
дерева и стекла. Еще я знаю, что стулья сделаны из дерева. Это отношение между
концепциями «дерево» и «стулья». Я также знаю, что каждый ученик сидит на стуле. Из этого следует еще пять отношений — между стульями и учениками. Данное
упражнение можно было бы продолжать и в итоге получить огромную и сложную
диаграмму, описывающую отношения между сотнями концепций.
Теперь остановитесь на минуту и подумайте о том, чем отличается ваш подход к извлечению концепций и их отношений. Каждый делает это по-своему. То же самое
происходит при решении конкретной проблемы: вначале вам необходимо создать
в уме диаграмму связей. Мы будем называть данный этап пониманием.
Решение проблемы основано на ее концепциях и отношениях между ними, которые
вам удается обнаружить. Вы объясняете его с помощью терминов этих концепций,
и если кто-то захочет в нем разобраться, то это потребует понимания выведенных
вами концепций и того, как они связаны между собой.
Вы, наверное, удивитесь, если я скажу, что точно так же решаются проблемы с помощью компьютера, но это именно тот случай. Проблема разбивается на объекты

214  Глава 6



ООП и инкапсуляция

(то же самое, что концепции в контексте мышления) и связи между ними, и затем
делается попытка написать на основе этих объектов программу, которая в итоге
решает данную проблему.
Написанная вами программа будет имитировать концепции и их отношения так,
как вы это себе представляете. Компьютер выполнит ваше решение, и вы сможете
проверить, работает оно или нет. Проблему решаете вы, а компьютер становится
вашим коллегой, поскольку может выполнить то, что вы придумали, в виде последовательности машинных инструкций, сгенерированных из вашей диаграммы
связей. К тому же он может сделать это намного быстрее и точнее.
В объектно-ориентированной программе концепции представлены объектами,
а вместо диаграммы связей, которую мы держим в уме, она использует объектную
модель, хранящуюся в памяти. Иными словами, если сравнивать человека с объектноориентированной программой, то термины «концепция», «ум» и «диаграмма связей»
эквивалентны таким понятиям, как «объект», «память» и «объектная модель» соответственно. Это самое важное соответствие, которое вы найдете в данной главе; оно
позволяет перевести то, как мы мыслим, в объектно-ориентированный код.
Но зачем нам компьютеры для имитации наших диаграмм связей? Все просто: они
незаменимы с точки зрения скорости и точности. Это классический ответ на подобные вопросы, и в нашем случае он подходит. Создание и хранение большой диаграммы связей и соответствующей объектной модели — сложная задача, с которой
компьютеры справляются очень хорошо. К тому же объектную модель, созданную
программой, можно сохранить на диск и использовать в будущем.
Диаграмму связей, которую мы держим в уме, можно забыть, и на нее могут повлиять эмоции. Но компьютеры бесстрастны, а объектные модели куда более надежны,
чем человеческие мысли. Вот почему мы должны писать объектно-ориентированный код — чтобы переводить концепции, которые представляем, в эффективные
программы.
Мы пока не умеем загружать и сохранять схемы связей из человеческого
ума — но все еще впереди!

В коде нет никаких объектов
Взглянув на память запущенной объектно-ориентированной программы, можно
увидеть множество объектов, связанных между собой. То же самое с человеческим
разумом. Если допустить, что люди — компьютеры, то можно сказать, что они все­
гда включены и работают, пока не умирают. Это важная аналогия. Объекты могут
существовать только в запущенной программе — точно так же, как концепции могут
существовать лишь в разуме живого человека. Это значит, что за пределами активной программы никаких объектов нет.

Объектно-ориентированное мышление   215

Это может прозвучать парадоксально, поскольку во время написания программы
(в объектно-ориентированном стиле) она еще не существует и, следовательно,
не может работать! Как же мы можем писать объектно-ориентированный код, если
у нас нет ни запущенной программы, ни объектов?
В вашем объектно-ориентированном коде нет никаких объектов. Они создаются, когда вы собираете и запускаете свою программу.

На самом деле ООП не сводится к созданию объектов. Данная парадигма заключается в генерации набора инструкций, которые при запуске программы превращаются в полностью динамическую объектную модель. Поэтому объектно-ориентированный код, скомпилированный и запущенный, должен уметь создавать,
изменять, связывать и даже удалять разные объекты.
Таким образом, написание объектно-ориентированного кода — непростая задача.
Вам необходимо представлять себе объекты и отношения между ними в то время,
когда они еще не существуют. Именно поэтому ООП может вызывать трудности
и вот почему нам нужен язык программирования, который поддерживает данную
парадигму. Умение вообразить то, чего еще нет, и описать различные его аспекты
обычно называют проектированием (или объектно-ориентированным проектированием в контексте ООП).
В самом коде мы лишь планируем создание объектов. Где и как они будут создаваться — определяют инструкции, которые мы генерируем. Конечно, создание —
еще не все. Всевозможные операции с объектами можно описывать с помощью
языка программирования. Объектно-ориентированный язык предоставляет набор
инструкций (и грамматических правил), позволяющих писать и планировать различные операции, связанные с объектами.
Мы уже видели, что между концепциями в нашем воображении и объектами в памяти программы существует четкая связь. Точно так же должны быть связаны
между собой операции, которые можно проводить с концепциями и объектами.
Каждый объект имеет отдельный жизненный цикл. То же самое относится и к нашим мысленным концепциям. В какой-то момент нам на ум приходит идея, которая
порождает мысленный образ (концепцию), но рано или поздно эта идея улетучивается. Точно так же в один момент времени объекты создаются, а в какой-то
другой — уничтожаются.
В завершение следует сказать, что одни мысленные концепции могут быть прочными и долговечными, а другие — изменчивыми и мимолетными. Похоже, первые
существуют вне зависимости от человеческого разума и не требуют того, чтобы
их кто-то осознал. В основном это относится к области математики. Возьмем,
к примеру, число 2. Оно уникально, и другое такое не сыскать во всей вселенной!

216  Глава 6



ООП и инкапсуляция

Поразительно. Это значит, мы имеем в наших мыслях одну и ту же концепцию
числа 2; если попытаться ее изменить, то это будет уже нечто другое. Вот где заканчивается мир объектной ориентированности и начинается другой мир, полный
неизменяемых объектов и известный под названием «парадигма функционального
программирования».

Атрибуты объектов
Любая концепция, которую мы себе представляем, имеет некие атрибуты. Если
вернуться к нашему описанию классной комнаты, то у нас был коричневый стул
с именем chair1. То есть каждый стул имел такой атрибут, как цвет, и в случае
с chair1 этот цвет был коричневым. Мы знаем, что в классе было еще четыре стула,
и каждый из них тоже имел некий цвет (возможно, уникальный). В нашем описании все они были коричневыми, но теоретически один или два из них могли быть
желтыми.
Объект может иметь целый набор атрибутов. Совокупность значений, которые
присвоены этим атрибутам, называется состоянием объекта. Состояние можно
представить в виде списка значений, каждое из которых принадлежит определенному атрибуту, относящемуся к объекту. Объект можно изменять на протяжении
его времени жизни. В таком случае его называют изменяемым (mutable). Это просто означает, что его состояние может меняться со временем. Объекты также могут
не иметь состояния (или каких-либо атрибутов).
Объект может быть и неизменяемым — точно так же, как концепция числа 2, которую нельзя изменить. Неизменяемость означает, что состояние определяется
в момент создания и после этого его нельзя модифицировать.
Объект без состояния можно считать неизменяемым объектом, поскольку
его состояние не меняется на протяжении его существования. Состояние
нельзя изменить, если его нет.

В заключение следует отметить: неизменяемые объекты имеют особое значение.
Тот факт, что их состояние нельзя изменить, является преимуществом, особенно
если они разделяются в многопоточной среде.

Предметная область
У любой программы, написанной для решения конкретной задачи, даже самой незначительной, есть четко определенная предметная область (или домен). Это еще
один важный термин, который широко используется в литературе, посвященной
разработке ПО. Предметная область определяет рамки, в которых проявляются

Объектно-ориентированное мышление   217

возможности программы, а также требования, которым эта программа должна
удовлетворять.
Для достижения этой цели предметная область использует определенную и заранее
подготовленную терминологию (словарь терминов), что помогает разработчикам
оставаться в ее рамках. Все участники проекта должны иметь представление о предметной области, в которой он находится.
Например, банковское программное обеспечение обычно создается для строго
определенной предметной области. Оно содержит набор общеизвестных терминов,
таких как «счет», «кредитные средства», «баланс», «перевод», «заем», «процентная
ставка» и т. д.
Определение предметной области можно прояснить с помощью терминов, находящихся в ее словаре; например, в банковской предметной области вы не найдете
упоминания таких понятий, как «пациент», «лекарства» и «дозировка».
Если язык программирования не предоставляет средств для работы с концепциями
предметной области (такими как «пациент» или «лекарства» в случае со здравоохранением), написать для нее ПО будет хоть и выполнимой, но, несомненно,
сложной задачей. Более того, по мере увеличения количества кода его будет все
сложнее развивать и сопровождать.

Отношения между объектами
Объекты могут быть взаимосвязанными; для обозначения этих связей они могут
ссылаться друг на друга. Например, если вернуться к нашему примеру с классной
комнатой, то объект student4 (четвертый студент) может иметь отношение к объекту chair3 (третий стул) в том смысле, что сидит на нем. Иными словами, student4
сидит на chair3. Таким образом, все объекты в системе ссылаются друг на друга
и формируют сеть, известную как объектная модель. Ранее уже говорилось, что
она соответствует диаграмме связей, которую мы держим в уме.
Если между двумя объектами есть связь, то изменение состояния одного из них
может сказаться на состоянии другого. Объясню это на примере. Представьте, что
у нас есть два объекта, p1 и p2, которые описывают пикселы и не имеют никакого
отношения друг к другу.
Объект p1 содержит такие атрибуты: {x: 53, y: 345, red: 120, green: 45, blue: 178}.
Атрибуты объекта p2 следующие: {x: 53, y: 346, red: 79, green: 162, blue: 23}.
Синтаксис, который мы здесь использовали, очень похож (хоть и не до
конца) на формат JSON. Атрибуты каждого объекта заключены в фигурные скобки и разделены запятыми. Каждый атрибут имеет соответствующее значение, записанное через двоеточие.

218  Глава 6



ООП и инкапсуляция

Чтобы их связать, нужно использовать дополнительный атрибут, который будет
обозначать отношение между ними. Таким образом, состояние объекта p1 поменяется
на {x: 53, y: 345, red: 120, green: 45, blue: 178, adjacent_down_ pixel: p2}, а объекта p2 — на {x: 53, y: 346, red: 79, green: 162, blue: 23, adjacent_up_pixel: p1}.
Атрибуты adjacent_down_pixel и adjacent_up_pixel говорят о том, что эти пикселы
смежные; их координаты y отличаются всего на единицу. Благодаря этим дополнительным атрибутам объекты могут понять, что связаны между собой. Например,
p1 знает, что его атрибут adjacent_down_pixel равен p2, а p2 знает, что его атрибут
adjacent_up_pixel равен p1.
Итак, мы видим, что для установления отношений между объектами их состояние
(списки значений, соответствующие их атрибутам) нужно поменять. Для этого
объекты расширяются за счет новых атрибутов, и их отношения становятся частью
их состояния. Конечно, это имеет последствия для такой характеристики объектов,
как изменяемость и неизменяемость.
Обратите внимание: подмножество атрибутов, которые определяют состояние
и неизменяемость объекта, может зависеть от предметной области и не обязательно
включает в себя все атрибуты. В одной предметной области в качествесостояния
могут использоваться только нессылочные атрибуты (в предыдущем примере это x,
y, red, green и blue), а в другой они могут быть объединены вместе со ссылочными
(в предыдущем примере это adjacent_up_pixel и adjacent_down_pixel).

Объектно-ориентированные операции
Объектно-ориентированный язык программирования позволяет планировать
создание и уничтожение объекта, а также изменение его состояния еще до запуска
программы. Для начала посмотрим, как создается объект.
Более точный термин — «построение», но в технической литературе
эту операцию принято называть созданием.

Запланировать создание объекта можно двумя способами.
zz Первый заключается в создании либо полностью пустого объекта (без каких-либо

атрибутов в его состоянии), либо объекта с минимальным набором атрибутов.
Остальные атрибуты будут определены и добавлены во время выполнения кода.
Таким образом, у одного и того же объекта могут быть разные атрибуты в двух
разных сценариях использования программы, в зависимости от изменений,
обнаруженных в окружающей среде.
Мы обращаемся с каждым объектом как с отдельной сущностью, поэтому, даже
если два объекта принадлежат к одной и той же группе (или классу) и имеют

Объектно-ориентированное мышление  219

общий список атрибутов, по мере выполнения программы атрибуты в их
состоянии могут отличаться.
Например, уже упомянутые объекты p1 и p2 являются пикселами (то есть
принадлежат к одному классу под названием pixel ), поскольку содержат
одинаковые атрибуты (x, y, red, green и blue). Но после установления отношения
в их состоянии появятся разные атрибуты: adjacent_down_pixel у p1 и adjacent_up_pixel у p2.
Данный подход применяется в таких языках программирования, как JavaScript,
Ruby, Python, Perl и PHP. Это в основном интерпретируемые языки, и атрибуты в них хранятся в виде ассоциативного массива (или хеш-таблицы) во
внутренних структурах данных, которые можно легко изменять во время выполнения. Эта методика называется прототипным ООП.
zz Второй подход состоит в создании объектов, атрибуты которых определены за-

ранее и не меняются по ходу выполнения. Новые атрибуты добавлять нельзя,
и объект сохранит свою изначальную структуру. Меняться могут только значения атрибутов и лишь в том случае, если объект изменяемый.
Чтобы использовать эту методику, программист должен заранее подготовить
шаблон или класс объекта с описанием всех атрибутов, которые данный объект
должен иметь на этапе выполнения. Затем этот шаблон нужно скомпилировать
и передать объектно-ориентированному языку во время работы программы.
Во многих языках программирования, таких как Java, C++ и Python, этот шаблон
называется классом, а сам подход известен как классовое ООП. Стоит отметить,
что, помимо классового ООП, Python поддерживает и прототипное.
Класс определяет лишь список атрибутов, присутствующих в объекте,
но не сами значения, которые присваиваются этим атрибутам во время
выполнения.

Обратите внимание: «объект» и «экземпляр» — взаимозаменяемые понятия.
Но иногда в технической литературе они могут иметь незначительные отличия.
Есть еще один термин, который заслуживает отдельного упоминания и объяснения, — «ссылка». Объект или экземпляр ссылается на определенное место, выделенное в памяти для его значений, в то время как ссылка похожа на указатель
с адресом объекта. Поэтому у нас может быть много ссылок на один и тот же объект.
У объекта обычно нет имени, а у ссылки есть.
В языке C в качестве синтаксиса для ссылок используются указатели.
У нас также есть стековые объекты и объекты кучи. У стекового объекта нет имени, и обращаться к нему можно с помощью указателей.
Для сравнения, объект кучи представляет собой настоящую переменную
и, следовательно, имеет имя.

220  Глава 6



ООП и инкапсуляция

Оба подхода допустимы, но в C и особенно в C++ предусмотрена официальная
поддержка классового ООП. Поэтому, когда программист хочет создать объект
в C или C++, ему сначала нужно подготовить класс. О классах и их роли в ООП
мы поговорим несколько позже.
То, о чем мы поговорим дальше, на первый взгляд имеет мало общего с данной
темой, однако на самом деле это не так. Есть две точки зрения на то, как люди
взрослеют, и обе они довольно точно совпадают с методами создания объектов,
которые обсуждались выше. Согласно одной из них, человек появляется на свет
пустым, без сущности (или состояния).
По мере того как он сталкивается с хорошими и плохими событиями в жизни, его сущность начинает расти, превращаясь в нечто имеющее независимый и зрелый характер.
Экзистенциализм — то философское течение, которое исповедует эту идею.
Одна из его знаменитых догм звучит так: «Существование предшествует сущности». Смысл его в том, что человек сначала появляется на свет, а затем накапливает
сущность по мере приобретения жизненного опыта. Эта идея крайне похожа на
прототипный подход к созданию объектов, согласно которому объект изначально
является пустым и эволюционирует во время выполнения программы.
Другая, более старая философия характерна в основном религиям. Согласно ей,
человек создан «по образу и подобию» (или на основе некой сущности), и данный
образ был определен еще до появления человека. Это больше всего напоминает то,
как мы планируем создание объекта на основе шаблона или класса. Вы как создатель подготавливаете класс, и затем программа начинает создавать из него объекты.
Существует тесная корреляция между подходами, которые персонажи
романов и повестей, как художественных, так и исторических, используют для преодоления определенных трудностей, и алгоритмами, с помощью которых в информатике решаются аналогичные задачи. Я глубоко
убежден: то, как мы живем и познаем реальность, гармонирует с нашим
пониманием алгоритмов и структур данных. То, о чем мы говорили
выше, — отличный пример такой гармонии между ООП и философией.

Уничтожение объектов, как и их создание, происходит на этапе выполнения;
в нашем коде мы можем его только запланировать. Все ресурсы, выделенные объектом на протяжении его существования, должны быть освобождены в момент его
уничтожения. При этом необходимо изменить все связанные с ним объекты, чтобы
они больше не ссылались на него. Ни один из атрибутов не должен указывать на
уничтоженный объект, иначе наша объектная модель утратит ссылочную целостность. Это может привести к ошибкам времени выполнения, таким как повре­
ждение памяти, ошибка сегментации, а также к логическим проблемам наподобие
неправильных вычислений.

Почему язык C не является объектно-ориентированным  221

Объект (или его состояние) может меняться двумя разными путями. Это может
быть просто изменение значения уже существующего атрибута или изменение самого списка атрибутов. Второй вариант возможен только при выборе прототипного
подхода к созданию объектов. Помните, что модификация состояния неизменя­
емого объекта запрещена и обычно не допускается в объектно-ориентированных
языках.

Объекты имеют поведение
Любой объект вместе со всеми своими атрибутами имеет определенный список
операций, которые может выполнять. Например, автомобиль может разгоняться,
замедляться, поворачивать и т. д. В ООП эти операции всегда соответствуют требованиям предметной области. Например, в банковской объектной модели клиент
может заказать открытие нового счета, но не может пообедать. Конечно, клиент,
как и любой другой живой человек, может есть, но если потребление еды не относится к банковской сфере, то для соответствующего объекта данная возможность
считается необязательной.
Любая операция может изменить состояние объекта, модифицируя значения его
атрибутов. Приведу простой пример. Автомобиль может разгоняться. Разгон — это
его возможность. В момент разгона меняется скорость автомобиля (один из его
атрибутов).
Таким образом, объект — просто набор атрибутов и операций. В следующих разделах мы более подробно поговорим о том, как объединить все это в один объект.
Итак, мы представили основополагающую терминологию, необходимую для
изучения и понимания ООП. Далее рассмотрим такую фундаментальную концепцию, как инкапсуляция. Но прежде прервемся на минуту и порассуждаем о том,
почему C не может быть объектно-ориентированным языком.

Почему язык C не является
объектно-ориентированным
Язык C не объектно-ориентированный, но это не связано с его возрастом, иначе
к текущему моменту мы бы уже нашли способ добавить в него поддержку ООП.
Но, как вы сами увидите в главе 12, новейший стандарт данного языка программирования, C18, не пытается сделать его объектно-ориентированным.
С другой стороны, у нас есть C++ — попытка создать на основе C язык с поддержкой ООП. Если бы C было суждено уступить место объектно-ориентированному языку, то на него не было бы спроса в наши дни, в основном из-за

222  Глава 6



ООП и инкапсуляция

существования C++. Но текущая востребованность программистов на C показывает, что это не так.
Человек мыслит объектно-ориентированным образом, но машинные инструкции,
которые выполняет центральный процессор, являются процедурными. Они выполняются одна за другой, хотя иногда процессору приходится переходить по другому
адресу и выполнять инструкции, которые там находятся. Это очень напоминает
вызовы функций в программе, написанной на процедурном языке, таком как C.
Язык C не может быть объектно-ориентированным, поскольку находится на границе между ООП и процедурным программированием. Объектно-ориентированность — человеческое понимание задачи, но процессор решает ее в процедурном
стиле. Поэтому у нас должно быть нечто на стыке этих двух миров. В противном
случае высокоуровневые программы, которые обычно написаны с применением
ООП, нельзя было бы напрямую транслировать в процедурные инструкции, потребляемые процессором.
Если взять языки программирования высокого уровня, такие как Java, JavaScript,
Python, Ruby и пр., то в их архитектуре есть компонент или слой, который связывает их среду выполнения с системной библиотекой C (стандартной библио­
текой C в системах семейства Unix и Win32 API в системах Windows). Например,
на платформе Java для этого предусмотрена виртуальная машина Java (Java Virtual
Machine, JVM). И хотя не все эти среды сугубо объектно-ориентированные (например, JavaScript и Python поддерживают как ООП, так и процедурный стиль), им
нужен данный слой для перевода их высокоуровневой логики в низкоуровневые
процедурные инструкции.

Инкапсуляция
В предыдущих разделах вы увидели, что каждый объект имеет набор атрибутов
и операций. Здесь же мы поговорим о том, как объединить эти атрибуты и операции
в сущность под названием «объект». Для этого воспользуемся процессом, известным под названием «инкапсуляция».
Инкапсуляция означает объединение отдельных вещей в некую капсулу, которая
представляет объект. Вначале мы это делаем в нашем воображении и затем воплощаем в коде. Каждый раз, когда вам кажется, что у объекта должны быть какие-то
атрибуты и операции, вы производите мысленную инкапсуляцию; данный процесс
необходимо реализовать на уровне программы.
Инкапсуляция — неотъемлемая часть языка программирования, без которой объединение связанных между собой переменных превратилось бы в непосильную
задачу (я уже упоминал о том, как для этого можно использовать соглашения об
именовании).

Инкапсуляция  223

Объект состоит из набора атрибутов и операций. Все они должны быть инкапсулированы. Сначала поговорим об инкапсуляции атрибутов.

Инкапсуляция атрибутов
Как мы уже видели ранее, для объединения и группирования разных переменных
в рамках единого объекта можно использовать их имена. Пример этого показан
в листинге 6.3.
Листинг 6.3. Переменные, сгруппированные по именам и представляющие два пиксела

int
int
int
int
int

pixel_p1_x
pixel_p1_y
pixel_p1_red
pixel_p1_green
pixel_p1_blue

=
=
=
=
=

56;
34;
123;
37;
127;

int
int
int
int
int

pixel_p2_x
pixel_p2_y
pixel_p2_red
pixel_p2_green
pixel_p2_blue

=
=
=
=
=

212;
994;
127;
127;
0;

Здесь наглядно показано, как переменные можно сгруппировать в неявные объекты p1 и p2, используя их имена. Неявными эти объекты делает тот факт, что об
их существовании знает только сам программист; языку программирования о них
ничего не известно.
С точки зрения языка программирования это просто десять переменных, не имеющих ничего общего. Это очень низкоуровневая инкапсуляция — вплоть до того,
что к ней формально нельзя применить данный термин. Инкапсуляция по именам
переменных существует во всех языках программирования (поскольку у переменных всегда есть имена), даже в ассемблере.
Что нам нужно, так это методики, предлагающие явную инкапсуляцию атрибутов.
То есть о ней и о капсулах (объектах) должен знать как программист, так и язык
программирования. Языки, не имеющие подобной возможности, использовать
очень сложно.
К счастью, C поддерживает явную инкапсуляцию, и это одна из причин, почему
на нем можно более или менее легко писать всевозможные объектно-ориентированные по своей сути программы. С другой стороны, как мы увидим в следующем
подразделе, явной инкапсуляции поведения в C нет и ее реализация требует выработки определенных правил и условностей.
Обратите внимание: встроенная поддержка таких возможностей, как инкапсуляция, всегда приветствуется. Это касается многих других аспектов ООП, включая

224  Глава 6



ООП и инкапсуляция

наследование и полиморфизм. Их реализация на уровне языка позволяет отлавливать соответствующие ошибки на этапе компиляции, еще до выполнения программы.
Решение проблем во время выполнения — настоящий кошмар, поэтому всегда
следует стремиться к тому, чтобы выявление ошибок происходило в ходе компиляции. Это главное преимущество объектно-ориентированных языков, которые
прекрасно «понимают», как мы мыслим. Язык с поддержкой ООП может сообщать
об ошибках и некорректных аспектах нашей архитектуры во время компиляции,
избавляя нас от необходимости решать многочисленные серьезные проблемы на
этапе выполнения. Именно поэтому мы наблюдаем появление все новых и более
сложных языков программирования, которые пытаются сделать все явным.
К сожалению, в C нет возможности явно реализовать все объектно-ориентированные
концепции. Вот почему на C так сложно писать код в стиле ООП. В языке C++ с этим
дела обстоят лучше, именно поэтому его называют объектно-ориентированным.
В C инкапсуляция происходит за счет структур. Отредактируем код из листинга 6.3
(листинг 6.4).
Листинг 6.4. Структура pixel_t и объявление на ее основе двух переменных

typedef struct {
int x, y;
int red, green, blue;
} pixel_t;
pixel_t p1, p2;
p1.x = 56;
p1.y = 34;
p1.red = 123;
p1.green = 37;
p1.blue = 127;
p2.x = 212;
p2.y = 994;
p2.red = 127;
p2.green = 127;
p2.blue = 0;

В этом коде есть несколько важных моментов.
zz Инкапсуляция атрибутов происходит, когда мы помещаем переменные x, y, red,
green и blue в новый тип, pixel_t.
zz Инкапсуляция всегда заключается в создании нового типа; в частности, в C

это относится к атрибутам. Данный факт очень важен. Это то, как мы делаем

Инкапсуляция   225

инкапсуляцию явной. Обратите внимание на суффикс _t в конце pixel_t .
В C это общепринятый (но необязательный) способ обозначения новых типов.
Мы будем использовать его на страницах этой книги.
zz Во время выполнения этого кода переменные p1 и p2 будут явными объектами.
Обе они имеют тип pixel_t, и все их атрибуты перечислены в соответствующей

структуре. В C и особенно в C++ типы определяют атрибуты объектов.
zz Новый тип pixel_t описывает лишь атрибуты класса (или шаблона объекта).

Но, как вы помните, слово «класс», помимо атрибутов, охватывает и операции.
Как следствие, структуры языка C не могут быть аналогами классов. К сожалению, с этим ничего нельзя сделать; атрибуты и операции существуют отдельно
и в коде связываются косвенно. В C любой класс является неявным и представляет собой совокупность структуры и списка функций. Мы подробно поговорим
об этом в будущих примерах в данной главе и в последующих.
zz Как видите, объекты создаются на основе шаблона (то есть структуры pixel_t)

с набором заранее определенных атрибутов, с которыми объект появляется
на свет. И, как уже говорилось выше, структура хранит только атрибуты, но
не операции.
zz Создание объекта очень похоже на объявление новой переменной. Сначала идет

тип, затем имя (в данном случае имя объекта). При объявлении объекта почти
одновременно происходит две вещи: сначала для него выделяется память, после чего его атрибуты инициализируются с помощью значений по умолчанию.
В предыдущем примере все атрибуты целочисленные, поэтому используется
стандартное для языка C значение по умолчанию типа int: 0.
zz В C, как и во многих других языках программирования, для доступа к атрибутам внутри объекта используется точка (.); если доступ к атрибуту структуры

осуществляется не напрямую, а через адрес внутри указателя, то применяется
стрелка (->). Выражение p1.x (или p1->x, если p1 является указателем) следует
читать как «атрибут x в объекте p1».
Вы уже знаете, что в объектах можно инкапсулировать не только атрибуты.
Посмотрим, как выглядит инкапсуляция поведения.

Инкапсуляция поведения
Объект — некая капсула с атрибутами и методами. Метод — еще один стандартный термин, который обычно используется для обозначения части логики
или функциональности, хранящейся в объекте. Его можно считать обычной
функцией языка C, у которой есть имя, список аргументов и возвращаемый тип.
Атрибуты представляют собой значения, а методы — поведение. Таким образом,
объект имеет список значений и может демонстрировать определенное поведение
в системе.

226  Глава 6



ООП и инкапсуляция

В таких классовых объектно-ориентированных языках, как C++, атрибуты и методы легко объединить в класс. В прототипных языках наподобие JavaScript объект,
как правило, создается пустым (лат. ex nihilo — «из ничего») или клонируется из
другого объекта. Чтобы объект имел поведение, в него нужно добавить методы.
Следующий пример, написанный на JavaScript, поможет лучше понять, как работают прототипные языки программирования (листинг 6.5).
Листинг 6.5. Создание объекта clientObj в JavaScript

// создаем пустой объект
var clientObj = {};
// устанавливаем атрибуты
clientObj.name = "John";
clientObj.surname = "Doe";
// добавляем метод для открытия банковского счета
clientObj.orderBankAccount = function () {
...
}
...
// вызываем метод
clientObj.orderBankAccount();

Во второй строчке данного примера мы создаем пустой объект. В следующих
двух строчках добавляем в него два новых атрибута, name и surname. Дальше добавляем новый метод, orderBankAccount , который указывает на определение
функции. На самом деле в этой строчке происходит присваивание. Справа находится анонимная функция, которая не имеет имени и присваивается атрибуту
orderBankAccount, указанному слева. Иными словами, мы сохраняем функцию
в атрибуте orderBankAccount. Это отличная демонстрация прототипных программных языков, в которых все объекты изначально пустые.
В классовом языке предыдущий пример выглядел бы иначе. Вначале мы бы написали класс, поскольку без него нельзя получить объект. В листинге 6.6 показано,
как это делается в C++.
Листинг 6.6. Создание объекта clientObj в C++

class Client {
public:
void orderBankAccount() {
...
}
std::string name;
std::string surname;
};

Инкапсуляция   227
...
Client clientObj;
clientObj.name = "John";
clientObj.surname = "Doe";
...
clientObj.orderBankAccount ();

Как видите, все началось с объявления нового класса, Client, который сразу же
стал новым типом в C++. Он напоминает капсулу и находится внутри фигурных
скобок. Дальше мы создали из типа Client объект clientObj.
В оставшихся строчках мы установили атрибуты и наконец вызвали из clientObj
метод orderBankAccount.
В C++ методы обычно называют функциями-членами, а атрибуты —
данными-членами (или просто данными класса).

Если взглянуть на способы инкапсуляции разных элементов, которые применяются
в открытых и широко известных проектах на языке C, то можно заметить нечто
общее. В оставшейся части этого раздела я предложу вам вариант инкапсуляции
поведения, основанный на методиках, встречающихся в таких проектах.
Поскольку мы еще не раз будем возвращаться к данной методике, ей следует дать
некое имя. Назовем ее «неявная инкапсуляция». Неявной ее делает то, что поведение в ней инкапсулируется так, что язык C не знает об этом. Исходя из того, что нам
доступно в стандарте ANSI C, нет никакой возможности сделать так, чтобы языку C
было известно о существовании классов. Поэтому любые попытки реализации объектной ориентированности в C неизбежно оказываются неявными.
Неявная инкапсуляция предполагает следующее.
zz Использование структур языка C для атрибутов объекта (явная инкапсуляция

атрибутов). Мы будем называть их структурами атрибутов.
zz Для инкапсуляции поведения в C применяются функции. Это так называемые

поведенческие функции. Вероятно, вы знаете, что язык C не позволяет размещать
функции в структурах. Поэтому подобные функции должны существовать за
пределами структуры атрибутов (неявная инкапсуляция поведения).
zz В качестве одного из аргументов (обычно первого или последнего) поведенче-

ские функции должны принимать указатель на структуру. Данный указатель
ссылается на структуру атрибутов объекта. Дело в том, что поведенческой
функции может понадобиться доступ (на чтение или запись) к атрибутам — это
довольно частое явление.
zz Поведенческие функции должны иметь подходящие имена, которые указывают

на их принадлежность к одному и тому же классу объектов. Вот почему при

228  Глава 6



ООП и инкапсуляция

использовании данного подхода крайне важно придерживаться одного соглашения об именовании. Это одно из двух соглашений, которые мы постараемся
соблюдать в данных главах, чтобы сделать инкапсуляцию максимально ясной.
Второе — использование суффикса _t в именах структур атрибутов. Но, конечно, вас никто не заставляет их задействовать; вы можете выработать собственные соглашения об именовании.
zz Объявления поведенческих функций обычно размещаются в одном заголовоч-

ном файле с объявлением структуры атрибутов. Этот файл называется заголовком объявления.
zz Определения поведенческих функций обычно находятся в одном или несколь-

ких отдельных исходных файлах, которые подключают заголовок объявления.
Обратите внимание: при использовании неявной инкапсуляции у нас нет никаких
классов, но их существование подразумевается самим программистом. В примере 6.1 (листинг 6.7) показано, как эта методика применяется в настоящей программе на C. Данная программа демонстрирует поведение автомобиля, который
разгоняется до тех пор, пока не закончится бензин, а затем останавливается.
Заголовочный файл, приведенный ниже, содержит объявление нового типа, car_t,
который представляет собой структуру атрибутов класса Car. Там же находятся
объявления поведенческих функций этого класса. Под классом Car мы понимаем
неявный класс, который на самом деле в коде отсутствует, но объединяет в себе
структуру атрибутов и поведенческие функции.
Листинг 6.7. Объявления структуры атрибутов и поведенческих функций класса Car
(ExtremeC_examples_chapter6_1.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_6_1_H
#define EXTREME_C_EXAMPLES_CHAPTER_6_1_H
// эта структура содержит все атрибуты, относящиеся к объекту car
typedef struct {
char name[32];
double speed;
double fuel;
} car_t;
// это объявления функций, которые составляют поведение объекта car
void car_construct(car_t*, const char*);
void car_destruct(car_t*);
void car_accelerate(car_t*);
void car_brake(car_t*);
void car_refuel(car_t*, double);
#endif

Как видите, структура атрибутов car_t имеет три поля: name, speed и fuel, которые являются атрибутами автомобиля. Обратите внимание: car_t — новый тип

Инкапсуляция  229

в языке C, и на его основе теперь можно объявлять переменные. Поведенческие
функции обычно объявляются в том же заголовочном файле, и это показано в листинге выше. Их имена начинаются с префикса car_, сигнализируя о том, что все
они принадлежат к одному классу.
У неявной инкапсуляции есть очень важная особенность: каждая структура атрибутов относится к отдельному объекту, но все объекты разделяют одни и те же
поведенческие функции. Иными словами, для каждого объекта создается своя переменная на основе структуры атрибутов, но поведенческие функции существуют
в единственном экземпляре и вызываются из разных объектов.
Обратите внимание: сама по себе структура атрибутов car_t не является классом
Car. Она просто содержит его атрибуты. Класс Car — совокупность всех объявлений. Больше примеров этого будет показано ниже.
Многие знаменитые проекты с открытым исходным кодом используют этот подход
для написания частично объектно-ориентированного кода. Один из них — библиотека libcurl. Если взглянуть на ее исходный код, то можно увидеть много структур
и функций, имена которых начинаются с curl_. Список таких функций можно
найти по адресу https://curl.haxx.se/libcurl/c/allfuncs.html.
Исходный файл, показанный в листинге 6.8, содержит определение поведенческих
функций, объявленных в примере 6.1.
Листинг 6.8. Определения поведенческих функций, входящих в класс Car
(ExtremeC_examples_chapter6_1.c)

#include
#include "ExtremeC_examples_chapter6_1.h"
// определения подключенных выше функций
void car_construct(car_t* car, const char* name) {
strcpy(car->name, name);
car->speed = 0.0;
car->fuel = 0.0;
}
void car_destruct(car_t* car) {
// Здесь ничего не происходит!
}
void car_accelerate(car_t* car) {
car->speed += 0.05;
car->fuel -= 1.0;
if (car->fuel < 0.0) {
car->fuel = 0.0;
}
}

230  Глава 6



ООП и инкапсуляция

void car_brake(car_t* car) {
car->speed -= 0.07;
if (car->speed < 0.0) {
car->speed = 0.0;
}
car->fuel -= 2.0;
if (car->fuel < 0.0) {
car->fuel = 0.0;
}
}
void car_refuel(car_t* car, double amount) {
car->fuel = amount;
}

В данном листинге определены поведенческие функции класса Car. И вы можете
видеть, что все они принимают в качестве первого аргумента указатель car_t .
Это позволяет им считывать и модифицировать атрибуты объекта. Если функция
не принимает указатель на структуру атрибутов, то ее можно считать обычной
функцией языка C, которая не представляет поведение объекта.
Обратите внимание: объявления поведенческих функций и соответствующей
структуры атрибутов обычно находятся рядом. Причина в том, что за соответствие
этих двух сущностей полностью отвечает программист, и поддержка данного кода
должна быть достаточно легкой. Вот почему совместное хранение этих объявлений,
обычно в одном заголовочном файле, помогает поддерживать в порядке общую
структуру класса и облегчает его обслуживание в будущем.
В листинге 6.9 показан исходный файл с функцией main, в которой содержится
основная логика. Все поведенческие функции используются здесь.
Листинг 6.9. Главная функция в примере 6.1 (ExtremeC_examples_chapter6_1_main.c)

#include
#include "ExtremeC_examples_chapter6_1.h"
// главная функция
int main(int argc, char** argv) {
// создаем переменную объекта
car_t car;
// создаем объект
car_construct(&car, "Renault");
// основной алгоритм
car_refuel(&car, 100.0);
printf("Car is refueled, the correct fuel level is %f\n", car.fuel);
while (car.fuel > 0) {

Инкапсуляция  231
printf("Car fuel level: %f\n", car.fuel);
if (car.speed < 80) {
car_accelerate(&car);
printf("Car has been accelerated to the speed: %f\n", car.speed);
} else {
car_brake(&car);
printf("Car has been slowed down to the speed: %f\n", car.speed);
}
}
printf("Car ran out of the fuel! Slowing down ...\n");
while (car.speed > 0) {
car_brake(&car);
printf("Car has been slowed down to the speed: %f\n", car.speed);
}
// уничтожаем объект
car_destruct(&car);
return 0;
}

В качестве первой инструкции в функции main мы объявили переменную car
типа car_t. Это наш первый объект car. В той же строке мы выделили память для
атрибутов данного объекта. Сам он создается в следующей строчке; здесь же происходит инициализация его атрибутов. Объект можно инициализировать только
после выделения памяти для его атрибутов. Конструктор принимает название автомобиля в качестве второго аргумента. Вы, вероятно, заметили, что адрес объекта
car передается всем поведенческим функциям вида car_*.
Вслед за этим начинается цикл while, в котором функция main читает атрибут fuel
и проверяет, больше ли нуля его значение. Тот факт, что функция main, не относящаяся к поведенческим, может обращаться к атрибутам car (для чтения и записи),
играет важную роль. Атрибут fuel и speed — пример публичных атрибутов, доступ
к которым имеют не только поведенческие функции, но и внешний код. Мы еще
вернемся к этому замечанию в следующем подразделе.
Прежде чем покинуть функцию main и завершить программу, мы уничтожили
объект car. Это просто означает, что на данном этапе были освобождены ресурсы,
выделенные объектом. У нас не было нужды вмешиваться в процесс, но в ходе
уничтожения объекта необходимо выполнить определенные шаги. Более подробно
об этом мы поговорим в примерах ниже. Этап уничтожения обязателен и предотвращает утечки памяти в случае использования кучи.
Было бы неплохо посмотреть на то, как данный пример можно переписать на языке C++. Это может помочь вам разобраться в том, как языки с поддержкой ООП
понимают классы и объекты и как это помогает сделать объектно-ориентированный
код более компактным.

232  Глава 6



ООП и инкапсуляция

В листинге 6.10, который является частью примера 6.2, показан заголовочный файл
с классом Car, написанным на C++.
Листинг 6.10. Объявление класса Car в C++ (ExtremeC_examples_chapter6_2.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_6_2_H
#define EXTREME_C_EXAMPLES_CHAPTER_6_2_H
class Car {
public:
// конструктор
Car(const char*);
// деструктор
~Car();
void Accelerate();
void Brake();
void Refuel(double);
// данные класса (то, что в C зовется атрибутами)
char name[32];
double speed;
double fuel;
};
#endif

Главная особенность данного кода — тот факт, что C++ знает о существовании классов. Поэтому здесь мы видим явную инкапсуляцию как атрибутов, так и поведения.
Более того, C++ поддерживает и другие объектно-ориентированные концепции,
включая конструкторы и деструкторы.
В коде на C++ объявление атрибутов и поведения инкапсулировано в определении
класса. Это явная инкапсуляция. Взгляните на первые две функции, объявленные
в качестве конструктора и деструктора. Язык C не поддерживает такие абстракции, но в C++ для них предусмотрены специальные обозначения. Например, имя
деструктора начинается с ~ и совпадает с именем класса.
Кроме того, поведенческие функции не принимают в качестве первого аргумента
указатель на объект, поскольку все они имеют доступ к атрибутам внутри класса.
В листинге 6.11 показано содержимое исходного файла с определением объявленных поведенческих функций.
Листинг 6.11. Определение класса Car в C++ (ExtremeC_examples_chapter6_2.cpp)

#include
#include "ExtremeC_examples_chapter6_2.h"

Инкапсуляция  233
Car::Car(const char* name) {
strcpy(this->name, name);
this->speed = 0.0;
this->fuel = 0.0;
}
Car::~Car() {
// здесь ничего не происходит
}
void Car::Accelerate() {
this->speed += 0.05;
this->fuel -= 1.0;
if (this->fuel < 0.0) {
this->fuel = 0.0;
}
}
void Car::Brake() {
this->speed -= 0.07;
if (this->speed < 0.0) {
this->speed = 0.0;
}
this->fuel -= 2.0;
if (this->fuel < 0.0) {
this->fuel = 0.0;
}
}
void Car::Refuel(double amount) {
this->fuel = amount;
}

Если присмотреться, то можно заметить, что указатель car, который мы использовали в коде на C, был заменен указателем this. Это ключевое слово языка C++,
которое просто указывает на текущий объект. Я не стану углубляться в детали, но
это элегантное решение, которое позволяет избавиться от аргумента-указателя
и упрощает поведенческие функции.
И наконец, в листинге 6.12 показана функция main, которая использует описанный
выше класс.
Листинг 6.12. Главная функция в примере 6.2 (ExtremeC_examples_chapter6_2_main.cpp)

// имя файла: ExtremeC_examples_chapter6_2_main.cpp
// описание: главная функция
#include
#include "ExtremeC_examples_chapter6_2.h"

234  Глава 6



ООП и инкапсуляция

// главная функция
int main(int argc, char** argv) {
// создаем переменную объекта и вызываем конструктор
Car car("Renault");
// основной алгоритм
car.Refuel(100.0);
std::cout engine);
}
void car_start(car_t* car) {
engine_turn_on(car->engine);
}
void car_stop(car_t* car) {
engine_turn_off(car->engine);
}
double car_get_engine_temperature(car_t* car) {
return engine_get_temperature(car->engine);
}

В данном листинге видно, как объект car содержит engine. В структуре атрибутов
car_t есть новое поле типа struct engine_t*. Благодаря ему устанавливается отношение композиции.
Внутри этого исходного файла тип struct engine_t* по-прежнему остается неполным, но во время выполнения сможет сослаться на объект полного типа engine_t.
Данный атрибут будет указывать на объект, который станет создаваться в конструкторе класса Car, а освобождаться — внутри деструктора. В обоих случаях

250   Глава 7



Композиция и агрегация

объект car все еще существует; это значит, его время жизни включает в себя время
жизни объекта engine.
Указатель engine является приватным, доступным только внутри реализации.
Это важный факт. Когда вы реализуете композицию, ни один указатель не должен
быть виден снаружи, иначе внешний код сможет изменять состояние внутреннего
объекта. Как и в случае с инкапсуляцией, это касается любых указателей, которые
дают прямой доступ к приватным частям объекта. Обращение к этим приватным
частям всегда должно происходить косвенно, через поведенческие функции.
Функция car_get_engine_temperature в приведенном выше листинге дает доступ
к атрибуту temperature объекта engine. Но она имеет одну важную особенность:
использует публичный интерфейс engine. Если присмотреться, то можно заметить,
что приватная реализация car потребляет публичный интерфейс engine.
Это значит, самому объекту car ничего не известно о деталях реализации engine.
Так и должно быть.
В большинстве случаев два объекта разных типов не должны знать о деталях
реализации друг друга. Это продиктовано принципом сокрытия. Напомню, что поведение car считается внешним относительно engine.
Благодаря этому мы можем использовать альтернативную реализацию engine,
и она будет работать, если имеет определения для всех публичных функций, объявленных в заголовочном файле engine.
Теперь взглянем на реализацию класса Engine (см. листинг 7.6).
Листинг 7.6. Определение класса Engine (ExtremeC_examples_chapter7_1_engine.c)

#include
typedef enum {
ON,
OFF
} state_t;
typedef struct {
state_t state;
double temperature;
} engine_t;
// аллокатор памяти
engine_t* engine_new() {
return (engine_t*)malloc(sizeof(engine_t));
}
// конструктор
void engine_ctor(engine_t* engine) {
engine->state = OFF;

Композиция   251
engine->temperature = 15;
}
// деструктор
void engine_dtor(engine_t* engine) {
// Здесь ничего не происходит
}
// поведенческие функции
void engine_turn_on(engine_t* engine) {
if (engine->state == ON) {
return;
}
engine->state = ON;
engine->temperature = 75;
}
void engine_turn_off(engine_t* engine) {
if (engine->state == OFF) {
return;
}
engine->state = OFF;
engine->temperature = 15;
}
double engine_get_temperature(engine_t* engine) {
return engine->temperature;
}

Для приватной реализации в этом коде применяется подход с неявной инкапсуляцией, очень похожий на тот, который использовался в предыдущих примерах.
Но здесь следует отметить один важный момент. Как видите, объект engine не знает, что будет содержаться во внешнем объекте в рамках композиции. Все как в жизни: когда компания производит двигатели, она не знает, в какие именно автомобили
те будут устанавливаться. Конечно, мы могли бы оставить указатель на контейнер
car, но в данном примере этого делать не пришлось.
В листинге 7.7 показан сценарий, в котором мы создаем объект car и обращаемся
к его публичному API, чтобы получить информацию о двигателе автомобиля.
Листинг 7.7. Главная функция в примере 7.1 (ExtremeC_examples_chapter7_1_main.c)

#include
#include
#include "ExtremeC_examples_chapter7_1_car.h"
int main(int argc, char** argv) {
// выделяем память для объекта car
struct car_t *car = car_new();

252   Глава 7



Композиция и агрегация

// создаем объект car
car_ctor(car);
printf("Engine temperature before starting the car: %f\n",
car_get_engine_temperature(car));
car_start(car);
printf("Engine temperature after starting the car: %f\n",
car_get_engine_temperature(car));
car_stop(car);
printf("Engine temperature after stopping the car: %f\n",
car_get_engine_temperature(car));
// уничтожаем объект car
car_dtor(car);
// освобождаем память, выделенную для объекта car
free(car);
}

return 0;

Чтобы собрать этот пример, нужно сначала скомпилировать три предыдущих исходных файла. Затем их следует скомпоновать, чтобы сгенерировать итоговый
исполняемый объектный файл. Обратите внимание: главный исходный файл (тот,
который содержит функцию main) зависит только от публичного интерфейса car.
Поэтому при компоновке ему нужна лишь его приватная реализация. Однако приватная реализация объекта car зависит от публичного интерфейса engine, поэтому
приватную реализацию последнего нужно предоставить во время компоновки.
Таким образом, чтобы получить итоговую исполняемую программу, нам нужно
скомпоновать все три объектных файла.
В терминале 7.1 показаны команды, которые собирают этот пример и запускают
итоговый исполняемый файл.
Терминал 7.1. Компиляция, компоновка и запуск примера 7.1
$ gcc -c ExtremeC_examples_chapter7_1_engine.c -o engine.o
$ gcc -c ExtremeC_examples_chapter7_1_car.c -o car.o
$ gcc -c ExtremeC_examples_chapter7_1_main.c -o main.o
$ gcc engine.o car.o main.o -o ex7_1.out
$ ./ex7_1.out
Engine temperature before starting the car: 15.000000
Engine temperature after starting the car: 75.000000
Engine temperature after stopping the car: 15.000000
$

В этом разделе мы рассмотрели один из типов отношений, которые могут существовать между двумя объектами. Далее поговорим еще об одном отношении.
По своему принципу оно похоже на композицию, но при этом обладает некоторыми
значительными особенностями.

Агрегация   253

Агрегация
В агрегации тоже применяется контейнер, который содержит другой объект.
Основное отличие в том, что время жизни контейнера никак не связано с временем
жизни содержащегося в нем объекта.
При использовании агрегации внутренний объект можно создать даже раньше контейнера. Для сравнения, композиция подразумевает, что время жизни контейнера
должно быть не короче времени жизни внутреннего объекта.
Отношение типа «агрегация» продемонстрировано в примере 7.2. Здесь рассматривается очень простой сценарий, в котором игрок подбирает ружье, делает из него
несколько выстрелов и роняет его на землю.
Объект player будет какое-то время играть роль контейнера, а gun будет выступать
внутренним объектом, пока игрок держит его в руках. Эти два объекта имеют независимое время жизни.
В листинге 7.8 показан заголовочный файл класса Gun.
Листинг 7.8. Публичный интерфейс класса Gun (ExtremeC_examples_chapter7_2_gun.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_7_2_GUN_H
#define EXTREME_C_EXAMPLES_CHAPTER_7_2_GUN_H
typedef int bool_t;
// предварительное объявление типа
struct gun_t;
// аллокатор памяти
struct gun_t* gun_new();
// конструктор
void gun_ctor(struct gun_t*, int);
// деструктор
void gun_dtor(struct gun_t*);
// поведенческие функции
bool_t gun_has_bullets(struct gun_t*);
void gun_trigger(struct gun_t*);
void gun_refill(struct gun_t*);
#endif

Здесь находится только объявление структуры атрибутов gun_t, поскольку мы
еще не определили ее поля. Как уже объяснялось ранее, это называется предварительным объявлением, и в итоге получается неполный тип, из которого нельзя
создать объект.

254   Глава 7



Композиция и агрегация

В листинге 7.9 показан заголовочный файл класса Player.
Листинг 7.9. Публичный интерфейс класса Player (ExtremeC_examples_chapter7_2_player.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_7_2_PLAYER_H
#define EXTREME_C_EXAMPLES_CHAPTER_7_2_PLAYER_H
// предварительное объявление типа
struct player_t;
struct gun_t;
// аллокатор памяти
struct player_t* player_new();
// конструктор
void player_ctor(struct player_t*, const char*);
// деструктор
void player_dtor(struct player_t*);
// поведенческие функции
void player_pickup_gun(struct player_t*, struct gun_t*);
void player_shoot(struct player_t*);
void player_drop_gun(struct player_t*);
#endif

Опять же мы видим предварительное объявление структур gun_t и player_t. Тип
gun_t нужно объявить в связи с тем, что некоторые поведенческие функции класса
Player принимают аргументы этого типа.
Реализация класса Player выглядит следующим образом (листинг 7.10).
Листинг 7.10. Определение класса Player (ExtremeC_examples_chapter7_2_player.c)

#include
#include
#include
#include "ExtremeC_example s_chapter7_2_gun.h"
// структура атрибутов
typedef struct {
char* name;
struct gun_t* gun;
} player_t;
// аллокатор памяти
player_t* player_new() {
return (player_t*)malloc(sizeof(player_t));
}

Агрегация   255
// конструктор
void player_ctor(player_t* player, const char* name) {
player->name =
(char*)malloc((strlen(name) + 1) * sizeof(char));
strcpy(player->name, name);
// Это важно. Указатели агрегации, которые не должны быть заданы
// в конструкторе, необходимо обнулить
player->gun = NULL;
}
// деструктор
void player_dtor(player_t* player) {
free(player->name);
}
// поведенческие функции
void player_pickup_gun(player_t* player, struct gun_t* gun) {
// после следующей строчки начинается отношение типа «агрегация»
player->gun = gun;
}
void
//
//
if

player_shoot(player_t* player) {
нам нужно проверить, подобрал ли игрок ружье,
иначе стрельба не имеет смысла
(player->gun) {
gun_trigger(player->gun);
} else {
printf("Player wants to shoot but he doesn't have a gun!");
exit(1);
}

}
void player_drop_gun(player_t* player) {
// После следующей строчки завершается агрегация двух объектов.
// Заметьте, что объект gun не нужно освобождать, поскольку
// данный объект не является его владельцем (как в композиции).
player->gun = NULL;
}

Внутри структуры player_t объявлен атрибут-указатель gun, который скоро будет
ссылаться на одноименный объект. В конструкторе его нужно обнулить, а не присваивать ему значение, как в случае с композицией.
Если указатель агрегации необходимо установить в процессе создания, то адрес
соответствующего объекта следует передать конструктору в качестве аргумента.
Такую агрегацию называют принудительной.
Если во время создания объекта указатель можно оставить обнуленным, как в приведенном выше листинге, то это называется необязательной агрегацией. В таких
случаях обнуление необходимо выполнять в конструкторе.

256   Глава 7



Композиция и агрегация

Агрегация начинается в функции player_pickup_gun, а заканчивается в player_
drop_gun, когда игрок роняет ружье.
Следует отметить, что после разрыва отношения типа «агрегация» указатель gun
следует обнулить. В отличие от композиции, контейнер здесь не является владельцем внутреннего объекта, поэтому не может контролировать его жизненный цикл.
Таким образом, нам не обязательно освобождать объект gun на каком-либо участке
внутренней реализации player.
При использовании необязательной агрегации контейнерный объект может оставаться без определенного значения. Поэтому с указателем агрегации нужно обращаться осторожно, поскольку любое обращение к неустановленному или нулевому
указателю приводит к ошибке сегментации. Вот почему мы проверяем указатель
gun в функции player_shoot. Если он обнулен, то код, который работает с объектом
player, не должен к нему обращаться. Если же обращение все же происходит, то
мы прерываем выполнение программы и возвращаем 1 в качестве кода выхода из
процесса.
В листинге 7.11 показана реализация класса Gun.
Листинг 7.11. Определение класса Gun (ExtremeC_examples_chapter7_2_gun.c)

#include
typedef int bool_t;
// структура атрибутов
typedef struct {
int bullets;
} gun_t;
// аллокатор памяти
gun_t* gun_new() {
return (gun_t*)malloc(sizeof(gun_t));
}
//конструктор
void gun_ctor(gun_t* gun, int initial_bullets) {
gun->bullets = 0;
if (initial_bullets > 0) {
gun->bullets = initial_bullets;
}
}
// деструктор
void gun_dtor(gun_t* gun) {
// здесь ничего не происходит
}

Агрегация   257
// поведенческие функции
bool_t gun_has_bullets(gun_t* gun) {
return (gun->bullets > 0);
}
void gun_trigger(gun_t* gun) {
gun->bullets--;
}
void gun_refill(gun_t* gun) {
gun->bullets = 7;
}

Здесь все должно быть понятно. Этот код написан таким образом, что объекту gun
неизвестно о том, что он содержится внутри другого объекта.
Наконец, в листинге 7.12 показан короткий сценарий, в котором создаются объекты player и gun. Игрок подбирает ружье и начинает стрелять, пока не закончатся
патроны. После перезарядки все повторяется. В конце он роняет ружье.
Листинг 7.12. Главная функция в примере 7.2 (ExtremeC_examples_chapter7_2_main.c)

#include
#include
#include "ExtremeC_examples_chapter7_2_player.h"
#include "ExtremeC_examples_chapter7_2_gun.h"
int main(int argc, char** argv) {
// создаем и инициализируем объект gun
struct gun_t* gun = gun_new();
gun_ctor(gun, 3);
// создаем и инициализируем объект player
struct player_t* player = player_new();
player_ctor(player, "Billy");
// начинаем агрегацию
player_pickup_gun(player, gun);
// стреляем, пока не закончатся патроны
while (gun_has_bullets(gun)) {
player_shoot(player);
}
// перезаряжаем ружье
gun_refill(gun);
// стреляем, пока не закончатся патроны
while (gun_has_bullets(gun)) {

258   Глава 7



Композиция и агрегация

player_shoot(player);
}
// завершаем агрегацию
player_drop_gun(player);
// уничтожаем и освобождаем объект player
player_dtor(player);
free(player);
// уничтожаем и освобождаем объект gun
gun_dtor(gun);
free(gun);
return 0;
}

Как видите, объекты gun и player не зависят друг от друга. Логика, которая отвечает
за их создание и уничтожение, находится в функции main. На каком-то этапе выполнения они формируют отношение типа «агрегация», выполняют то, что им предписано, и снова разъединяются. Важной особенностью агрегации является то, что
контейнер не должен влиять на время жизни внутреннего объекта; если следовать
данному правилу, то никаких проблем с памятью возникнуть не должно.
В терминале 7.2 показано, как собрать этот пример и запустить полученный исполняемый файл. Как видите, функция main ничего не выводит.
Терминал 7.2. Компиляция, компоновка и запуск примера 7.2
$
$
$
$
$
$

gcc -c ExtremeC_examples_chapter7_2_gun.c -o gun.o
gcc -c ExtremeC_examples_chapter7_2_player.c -o player.o
gcc -c ExtremeC_examples_chapter7_2_main.c -o main.o
gcc gun.o player.o main.o -o ex7_2.out
./ex7_2.out

В объектной модели реального проекта агрегация обычно применяется чаще, чем
композиция. Кроме того, агрегация лучше видна снаружи, поскольку ее работа
требует наличия отдельных поведенческих функций (по крайней мере в публичном
интерфейсе контейнера) для установки и сброса внутреннего объекта.
Как можно видеть в приведенном выше примере, объекты gun и player изначально
разделены. Связь между ними устанавливается лишь на короткое время, после
чего они опять становятся независимыми. Это значит, что агрегация, в отличие от
композиции, является временным отношением между двумя объектами. Таким
образом, композицию можно считать усиленной разновидностью владения (отношения типа «иметь»), а агрегацию — ослабленной.

Резюме   259

Теперь встает вопрос: если агрегация носит временный характер для двух объектов, то является ли она временной для их соответствующих классов? Ответ отрицательный. Отношение агрегации между двумя типами остается навсегда. Если
существует малейшая вероятность того, что в будущем два объекта сформируют
связь на основе агрегации, то их типы должны поддерживать это отношение на
постоянной основе. То же самое относится и к композиции.
Даже небольшая вероятность возникновения отношения типа «агрегация» — повод
для объявления некоторых указателей в структуре атрибутов контейнера, что означает внесение постоянного изменения. Конечно, это относится только к классовым
языкам программирования.
Композиция и агрегация описывают владение некими объектами. Иными словами,
они представляют отношение типа «иметь»; игрок имеет пистолет, автомобиль
имеет двигатель. Если вам кажется, что один объект владеет другим, то между ними
(и их соответствующими классами) следует установить отношение композиции
или агрегации.
В следующей главе мы продолжим обсуждение разных типов отношений и уделим
внимание наследованию и расширению.

Резюме
В этой главе были рассмотрены следующие темы:
zz возможные типы отношений между классами и объектами;
zz различия и сходства между классом, объектом, экземпляром и ссылкой;
zz композиция подразумевает, что внутренний объект полностью зависит от своего

контейнера;
zz агрегация — внутренний объект может свободно существовать без зависимости

от своего контейнера;
zz агрегация может быть временной между объектами, но между их типами (или

классами) должна быть постоянной.
В следующей главе мы продолжим исследовать ООП и сосредоточимся еще на двух
концепциях, на которых основана эта парадигма: на наследовании и полиморфизме.

8

Наследование
и полиморфизм

Эта глава продолжает две предыдущие, в которых вы увидели, как реализовать
ООП в языке C, и познакомились с такими концепциями, как композиция и агрегация. Здесь же мы в основном продолжим рассматривать отношения между объектами и их соответствующими классами. На сей раз речь пойдет о наследовании
и полиморфизме. Это заключительная часть данной темы, а в следующей главе мы
поговорим об абстракции данных.
То, что мы будем здесь обсуждать, основано на теоретическом материале, пройденном в предыдущих двух главах, который касался возможных отношений
между классами. Мы уже познакомились с композицией и агрегацией, и теперь
пришло время поговорить о расширении (наследовании) наряду с несколькими
другими темами.
Ниже перечислены аспекты ООП, на которых мы сконцентрируемся.
zz Как уже упоминалось выше, в первую очередь мы рассмотрим наследова-

ние. Будут представлены методы реализации наследования в языке C и их
сравнение.
zz Следующей большой темой станет полиморфизм. Он позволяет наделять

классы разными версиями одних и тех же операций на случай, если один из
них наследует другой. Мы обсудим, как реализовать полиморфные функции
в C; это будет первым шагом на пути к пониманию того, как полиморфизм
устроен в C++.
Начнем с наследования.

Наследование
В предыдущей главе мы обсудили отношения типа «иметь», что в итоге привело
нас к композиции и агрегации. В этом разделе речь пойдет об отношениях типа
«быть», примером которых является наследование.

Наследование  261

Наследование также называют расширением, поскольку оно только добавляет новые атрибуты и операции в существующий объект или класс. Далее мы объясним
принцип работы этого отношения и то, как его можно реализовать в C.
Бывают случаи, когда один объект должен иметь те же атрибуты, которые есть
в другом. Иными словами, новый объект — расширение существующего.
Например, студент обладает всеми атрибутами человека, однако не ограничен ими
(листинг 8.1).
Листинг 8.1. Структуры атрибутов для классов Person и Student

typedef struct {
char first_name[32];
char last_name[32];
unsigned int birth_year;
} person_t;
typedef struct {
char first_name[32];
char last_name[32];
unsigned int birth_year;
char student_number[16]; // дополнительный атрибут
unsigned int passed_credits; // дополнительный атрибут
} student_t;

Этот пример наглядно показывает, как student_t расширяет person_t за счет новых
атрибутов student_number и passed_credits, которые характерны студентам.
Как уже отмечалось ранее, наследование (или расширение) — это отношение типа
«быть». Напомню, что композиция и агрегация относятся к типу «иметь». Поэтому
мы можем сказать: «Студент является человеком», что звучит логично в контексте
образовательного программного обеспечения. Если в предметной области существует отношение типа «быть», то это, скорее всего, наследование. В показанном выше
примере person_t обычно называется супертипом или просто базовым/родительским
типом, а student_t — дочерним типом или унаследованным подтипом.

Природа наследования
Если копнуть глубже и попытаться понять, что же на самом деле представляет собой наследование, то можно прийти к выводу: по своей природе это композиция.
Например, мы можем сказать, что студент имеет человеческую сущность. Иными
словами, мы можем предположить, что внутри структуры атрибутов класса Student
есть приватный объект person. Таким образом, наследование может быть эквивалентом композиции вида «один к одному».

262  Глава 8



Наследование и полиморфизм

Поэтому структуру из листинга 8.1 можно записать следующим образом (листинг 8.2).
Листинг 8.2. Структуры атрибутов для классов Person и Student, но на этот раз вложенные

typedef struct {
char first_name[32];
char last_name[32];
unsigned int birth_year;
} person_t;
typedef struct {
person_t person;
char student_number[16]; // Дополнительный атрибут
unsigned int passed_credits; // Дополнительный атрибут
} student_t;

Данный синтаксис полностью корректен с точки зрения C. На самом деле вложение
структур с помощью структурных переменных (а не указателей) имеет большой
потенциал. Это позволяет поместить в новую структуру переменную сложного типа
так, чтобы данная структура являлась его расширением.
В показанном выше листинге наличие первого поля типа person_t позволяет привести указатель student_t к этому типу и сделать так, чтобы они указывали на один
и тот же адрес в памяти.
Это так называемое восходящее приведение (или преобразование, upcasting) —
то есть приведение структуры атрибутов потомка к типу структуры атрибутов
родителя. Имейте в виду, что со структурными переменными этого делать нельзя.
Данный подход показан в примере 8.1 (листинг 8.3).
Листинг 8.3. Пример 8.1, демонстрирующий восходящее преобразование указателей на объекты
Student и Person (ExtremeC_examples_chapter8_1.c)

#include
typedef struct {
char first_name[32];
char last_name[32];
unsigned int birth_year;
} person_t;
typedef struct {
person_t person;
char student_number[16]; // дополнительный атрибут
unsigned int passed_credits; // дополнительный атрибут
} student_t;

Наследование  263
int main(int argc, char** argv) {
student_t s;
student_t* s_ptr = &s;
person_t* p_ptr = (person_t*)&s;
printf("Student pointer points to %p\n", (void*)s_ptr);
printf("Person pointer points to %p\n", (void*)p_ptr);
return 0;
}

Мы ожидаем, что s_ptr и p_ptr указывают на один и тот же адрес в памяти. В терминале 8.1 показан вывод примера 8.1 после его сборки и запуска.
Терминал 8.1. Вывод примера 8.1
$ gcc ExtremeC_examples_chapter8_1.c -o ex8_1.out
$ ./ex8_1.out
Student pointer points to 0x7ffeecd41810
Person pointer points to 0x7ffeecd41810
$

Действительно, они указывают на один и тот же адрес. Обратите внимание: данный
вывод может меняться с каждым запуском, но суть в том, что указатели всегда
ссылаются на один участок памяти. Это значит, что структурная переменная типа
student_t в действительности наследует структуру person_t в своей схеме размещения. Отсюда следует, что поведенческие функции класса Person можно вызывать с помощью указателя на объект student. Иными словами, поведенческие
функции класса Person могут использоваться объектами student . Это важное
достижение.
Обратите внимание на то, что код, приведенный в листинге 8.4, некорректен и его
нельзя скомпилировать.
Листинг 8.4. Отношение наследования, которое не компилируется!

struct person_t;
typedef struct {
struct person_t person; // генерирует ошибку!
char student_number[16]; // дополнительный атрибут
unsigned int passed_credits; // дополнительный атрибут
} student_t;

Строчка, в которой объявляется поле person, генерирует ошибку, поскольку мы
не можем создать переменную из неполного типа. Не забывайте, что неполный
тип — результат предварительного объявления структуры (подобного первой
строчке в листинге 8.4). Его могут иметь только указатели, но не переменные.
Как вы уже сами видели, память для неполного типа нельзя выделить даже в куче.

264  Глава 8



Наследование и полиморфизм

Так что же это означает? Получается, в случае реализации наследования с помощью вложенных структурных переменных структура student_t должна иметь
доступ к определению person_t, которое согласно тому, что мы знаем об инкапсуляции, должно быть приватным и недоступным для любых других классов.
В связи с этим наследование можно реализовать двумя путями:
zz сделать так, чтобы дочерний класс имел доступ к приватной реализации (опре-

делению) базового класса;
zz сделать так, чтобы дочерний класс мог обращаться только к публичному интер-

фейсу базового класса.

Первый подход к наследованию в C
Мы продемонстрируем первый подход в примере 8.2, а второй — в примере 8.3,
который находится в пункте «Второй подход к наследованию в C» далее, на с. 270.
В обоих случаях представлены одни и те же классы, Student и Person, содержащие
ряд поведенческих функций. Объекты на их основе будут взаимодействовать в некоем простом сценарии в функции main.
Начнем с примера 8.2, в котором классу Student нужен доступ к приватному определению структуры атрибутов класса Person. В листингах ниже показаны заголовки
и исходники классов Student и Person, а также функция main. В листинге 8.5 можно
видеть заголовочный файл, в котором объявляется класс Person.
Листинг 8.5. Публичный интерфейс класса Person в примере 8.2
(ExtremeC_examples_chapter8_2_person.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_H
// предварительное объявление
struct person_t;
// аллокатор памяти
struct person_t* person_new();
// конструктор
void person_ctor(struct person_t*,
const char* /* имя */,
const char* /* фамилия */,
unsigned int /* год рождения */);
// деструктор
void person_dtor(struct person_t*);
// поведенческие функции
void person_get_first_name(struct person_t*, char*);

Наследование   265
void person_get_last_name(struct person_t*, char*);
unsigned int person_get_birth_year(struct person_t*);
#endif

Взгляните на конструктор. Он принимает все значения, необходимые для создания объекта person: first_name, second_name и birth_year. Структура атрибутов
person_t является неполной, поэтому класс Student не может использовать этот
заголовочный файл для наследования (см. предыдущий раздел).
С другой стороны, этот заголовок не должен содержать определение структуры
атрибутов person_t, поскольку будет использоваться на других участках кода,
которые не должны ничего знать о внутренностях класса Person. Что же делать?
Мы хотим, чтобы одна часть кода знала об определении структуры, а другая — нет.
Здесь нам пригодятся приватные заголовочные файлы.
Приватным называют заголовочный файл, который должен подключаться и использоваться только на определенных участках кода или в классах, которым он
действительно нужен. Если вернуться к примеру 8.2, то в приватном заголовке
должно находиться определение person_t. Образец этого показан в листинге 8.6.
Листинг 8.6. Приватный заголовочный файл с определением person_t
(ExtremeC_examples_chapter8_2_person_p.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_P_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_2_PERSON_P_H
// приватное определение
typedef struct {
char first_name[32];
char last_name[32];
unsigned int birth_year;
} person_t;
#endif

Как видите, здесь содержится только определение структуры person_t и ничего
больше. Это часть класса Person, которая должна оставаться приватной, но притом
быть доступной классу Student. Данный заголовок нам понадобится при определении структуры атрибутов student_t. В листинге 8.7 показана приватная реализация
класса Person.
Листинг 8.7. Определение класса Person (ExtremeC_examples_chapter8_2_person.c)

#include
#include
// определение person_t находится в следующем заголовке
#include "ExtremeC_examples_chapter8_2_person_p.h"

266  Глава 8



Наследование и полиморфизм

// аллокатор памяти
person_t* person_new() {
return (person_t*)malloc(sizeof(person_t));
}
// конструктор
void person_ctor(person_t* person,
const char* first_name,
const char* last_name,
unsigned int birth_year) {
strcpy(person->first_name, first_name);
strcpy(person->last_name, last_name);
person->birth_year = birth_year;
}
// деструктор
void person_dtor(person_t* person) {
// здесь ничего не происходит
}
// поведенческие функции
void person_get_first_name(person_t* person, char* buffer) {
strcpy(buffer, person->first_name);
}
void person_get_last_name(person_t* person, char* buffer) {
strcpy(buffer, person->last_name);
}
unsigned int person_get_birth_year(person_t* person) {
return person->birth_year;
}

В определении класса Person нет ничего особенного; все это мы уже видели в предыдущих примерах. В листинге 8.8 представлен публичный интерфейс класса Student.
Листинг 8.8. Публичный интерфейс класса Student (ExtremeC_examples_chapter8_2_student.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_2_STUDENT_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_2_STUDENT_H
// предварительное объявление
struct student_t;
// аллокатор памяти
struct student_t* student_new();
// конструктор
void student_ctor(struct student_t*,
const char* /* имя */,
const char* /* фамилия */,

Наследование   267
unsigned int /* год рождения */,
const char* /* номер студенческого билета */,
unsigned int /* засчитанные кредиты */);
// деструктор
void student_dtor(struct student_t*);
// поведенческие функции
void student_get_student_number(struct student_t*, char*);
unsigned int student_get_passed_credits(struct student_t*);
#endif

Аргументы, которые принимает конструктор, похожи на аргументы конструктора
из класса Person. Это вызвано тем, что объект student фактически содержит объект
person и должен передать ему эти значения.
Отсюда следует, что конструктор student должен установить атрибуты person,
которые являются его частью.
Обратите внимание: класс Student содержит всего две дополнительные поведенческие функции, так как, помимо них, объекты student доступны функции класса
Person.
В листинге 8.9 показана приватная реализация класса Student.
Листинг 8.9. Приватное определение класса Student
(ExtremeC_examples_chapter8_2_student.c)

#include
#include
#include "ExtremeC_examples_chapter8_2_person.h"
// определение person_t находится в следующем заголовке, и оно нам здесь нужно
#include "ExtremeC_examples_chapter8_2_person_p.h"
// предварительное объявление
typedef struct {
// благодаря этой вложенности мы наследуем все атрибуты класса Person
// и получаем доступ ко всем его поведенческим функциям
person_t person;
char* student_number;
unsigned int passed_credits;
} student_t;
// аллокатор памяти
student_t* student_new() {
return (student_t*)malloc(sizeof(student_t));
}

268  Глава 8



Наследование и полиморфизм

// конструктор
void student_ctor(student_t* student,
const char* first_name,
const char* last_name,
unsigned int birth_year,
const char* student_number,
unsigned int passed_credits) {
// вызываем конструктор родительского класса
person_ctor((struct person_t*)student,
first_name, last_name, birth_year);
student->student_number = (char*)malloc(16 * sizeof(char));
strcpy(student->student_number, student_number);
student->passed_credits = passed_credits;
}
// деструктор
void student_dtor(student_t* student) {
// сначала нужно уничтожить дочерний объект
free(student->student_number);
// затем следует вызвать деструктор родительского класса
person_dtor((struct person_t*)student);
}
// поведенческие функции
void student_get_student_number(student_t* student,
char* buffer) {
strcpy(buffer, student->student_number);
}
unsigned int student_get_passed_credits(student_t* student) {
return student->passed_credits;
}

Этот листинг содержит самый важный код с точки зрения наследования. Прежде
всего, нам нужно было подключить приватный заголовок класса Person, так как
мы хотели, чтобы первое поле в определении student_t имело тип person_t. И, поскольку это поле является переменной, а не указателем, структура person_t уже
должна быть определена. Имейте в виду: данная переменная должна быть первым
полем структуры, иначе мы теряем возможность использования поведенческих
функций класса Person.
В данном листинге мы снова вызываем конструктор родителя в конструкторе
класса Student, чтобы инициализировать родительский объект. Обратите внимание: мы привели указатель student_t к типу person_t при передаче его в функцию
person_ctor. Это возможно лишь благодаря тому, что поле person — первый атрибут структуры student_t.
Аналогичным образом мы вызвали родительский деструктор в деструкторе класса
Student. Уничтожить нужно сначала дочерний объект, а затем родительский —

Наследование  269

в обратном порядке по сравнению с созданием. В листинге 8.10 находится главная
функция примера 8.2, в которой на основе класса Student создается объект соответствующего типа.
Листинг 8.10. Главная функция примера 8.2 (ExtremeC_examples_chapter8_2_main.c)

#include
#include
#include "ExtremeC_examples_chapter8_2_person.h"
#include "ExtremeC_examples_chapter8_2_student.h"
int main(int argc, char** argv) {
// создаем объект student
struct student_t* student = student_new();
student_ctor(student, "John", "Doe",
1987, "TA5667", 134);
// теперь мы используем поведенческие функции объекта person
// для чтения атрибутов объекта student
char buffer[32];
// восходящее приведение указателя к родительскому типу
struct person_t* person_ptr = (struct person_t*)student;
person_get_first_name(person_ptr, buffer);
printf("First name: %s\n", buffer);
person_get_last_name(person_ptr, buffer);
printf("Last name: %s\n", buffer);
printf("Birth year: %d\n", person_get_birth_year(person_ptr));
// теперь мы читаем атрибуты, принадлежащие объекту student
student_get_student_number(student, buffer);
printf("Student number: %s\n", buffer);
printf("Passed credits: %d\n",
student_get_passed_credits(student));
// уничтожаем и освобождаем объект student
student_dtor(student);
free(student);
return 0;
}

В главной функции подключаются публичные интерфейсы обоих классов, Person
и Student (но не приватный заголовочный файл), но создается только объект
student. Как видите, этот объект унаследовал все атрибуты своего внутреннего

270   Глава 8



Наследование и полиморфизм

объекта, person , и для их чтения может использовать поведенческие функции
класса Person.
В терминале 8.2 показано, как скомпилировать пример 8.2.
Терминал 8.2. Сборка и запуск примера 8.2
$ gcc -c ExtremeC_examples_chapter8_2_person.c -o person.o
$ gcc -c ExtremeC_examples_chapter8_2_student.c -o student.o
$ gcc -c ExtremeC_examples_chapter8_2_main.c -o main.o
$ gcc person.o student.o main.o -o ex8_2.out
$ ./ex8_2.out
First name: John
Last name: Doe
Birth year: 1987
Student number: TA5667
Passed credits: 134
$

В следующем примере, под номером 8.3, мы обсудим второй подход к реализации
наследования в языке C. Его вывод должен быть очень похож на то, что мы только
видели.

Второй подход к наследованию в C
При использовании первого подхода мы хранили структурную переменную в качестве первого поля структуры атрибутов потомка. Здесь же будем использовать
указатель на структурную переменную родителя. Это позволит сделать дочерний
класс независимым от реализации родительского, что является положительным
фактором с точки зрения принципа сокрытия информации.
Второй подход имеет свои преимущества и недостатки. После демонстрации примера 8.3 мы сравним эти два метода, и вы сможете увидеть их сильные и слабые стороны.
Пример 8.3, представленный ниже, удивительно похож на пример 8.2, особенно
с точки зрения вывода и итоговых результатов. Его основное отличие в том, что
класс Student зависит только от публичного интерфейса класса Person и не требует
доступа к его приватному определению. Это важно, поскольку так мы можем изолировать классы друг от друга и легко изменять реализацию родителя, не меняя
реализацию потомка.
В предыдущем примере класс Student, строго говоря, не нарушал принцип сокрытия, но имел такую возможность, поскольку имел доступ к определению структуры
person_t и к ее полям. Как результат, он мог читать или изменять ее поля в обход
поведенческих функций класса Person.

Наследование   271

Как уже отмечалось, примеры 8.3 и 8.2 мало чем отличаются, но у 8.3 есть некоторые фундаментальные особенности. Класс Person имеет тот же публичный интерфейс, который мы видели ранее, чего нельзя сказать о Student. Новый публичный
интерфейс класса Student показан в листинге 8.11.
Листинг 8.11. Новый публичный интерфейс класса Student
(ExtremeC_examples_chapter8_3_student.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_3_STUDENT_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_3_STUDENT_H
// предварительное объявление
struct student_t;
// аллокатор памяти
struct student_t* student_new();
// конструктор
void student_ctor(struct student_t*,
const char* /* имя */,
const char* /* фамилия */,
unsigned int /* год рождения */,
const char* /* номер студенческого билета */,
unsigned int /* засчитанные кредиты */);
// деструктор
void student_dtor(struct student_t*);
// поведенческие функции
void student_get_first_name(struct student_t*, char*);
void student_get_last_name(struct student_t*, char*);
unsigned int student_get_birth_year(struct student_t*);
void student_get_student_number(struct student_t*, char*);
unsigned int student_get_passed_credits(struct student_t*);
#endif

По причинам, которые станут очевидными чуть позже, класс Student должен
повторять все поведенческие функции, объявленные внутри Person. Дело в том,
что мы больше не можем привести указатель student_t к типу person_t. Иными
словами, для указателей Student и Person больше не работает восходящее преобразование.
Если публичный интерфейс класса Person остался прежним по сравнению с примером 8.2, то его реализация претерпела изменения. Вы можете видеть ее в листинге 8.12.

272   Глава 8



Наследование и полиморфизм

Листинг 8.12. Новая реализация класса Person (ExtremeC_examples_chapter8_3_person.c)

#include
#include
// приватное определение
typedef struct {
char first_name[32];
char last_name[32];
unsigned int birth_year;
} person_t;
// аллокатор памяти
person_t* person_new() {
return (person_t*)malloc(sizeof(person_t));
}
// конструктор
void person_ctor(person_t* person,
const char* first_name,
const char* last_name,
unsigned int birth_year) {
strcpy(person->first_name, first_name);
strcpy(person->last_name, last_name);
person->birth_year = birth_year;
}
// деструктор
void person_dtor(person_t* person) {
// Здесь ничего не происходит
}
// поведенческие функции
void person_get_first_name(person_t* person, char* buffer) {
strcpy(buffer, person->first_name);
}
void person_get_last_name(person_t* person, char* buffer) {
strcpy(buffer, person->last_name);
}
unsigned int person_get_birth_year(person_t* person) {
return person->birth_year;
}

Приватное определение person_t теперь находится в исходном файле, и мы больше
не используем приватный заголовок. Это значит, мы не станем делать его доступным ни для каких других классов, включая Student. Мы хотим добиться полноценной инкапсуляции класса Person и скрыть все детали его реализации.

Наследование   273

В листинге 8.13 показана приватная реализация класса Student.
Листинг 8.13. Новая реализация класса Student (ExtremeC_examples_chapter8_3_student.c)

#include
#include
// публичный интерфейс класса Person
#include "ExtremeC_examples_chapter8_3_person.h"
// предварительное объявление
typedef struct {
char* student_number;
unsigned int passed_credits;
// здесь нам нужен указатель, так как тип person_t является неполным
struct person_t* person;
} student_t;
// аллокатор памяти
student_t* student_new() {
return (student_t*)malloc(sizeof(student_t));
}
// конструктор
void student_ctor(student_t* student,
const char* first_name,
const char* last_name,
unsigned int birth_year,
const char* student_number,
unsigned int passed_credits) {
// выделяем память для родительского объекта
student->person = person_new();
person_ctor(student->person, first_name,
last_name, birth_year);
student->student_number = (char*)malloc(16 * sizeof(char));
strcpy(student->student_number, student_number);
student->passed_credits = passed_credits;
}
// деструктор
void student_dtor(student_t* student) {
// сначала нужно уничтожить дочерний объект
free(student->student_number);
// затем следует вызвать деструктор родительского класса
person_dtor(student->person);
// необходимо также освободить память, выделенную для родительского объекта
free(student->person);
}
// поведенческие функции
void student_get_first_name(student_t* student, char* buffer) {

274   Глава 8



Наследование и полиморфизм

// мы должны использовать поведенческие функции класса Person
person_get_first_name(student->person, buffer);
}
void student_get_last_name(student_t* student, char* buffer) {
// мы должны использовать поведенческие функции класса Person
person_get_last_name(student->person, buffer);
}
unsigned int student_get_birth_year(student_t* student) {
// мы должны использовать поведенческие функции класса Person
return person_get_birth_year(student->person);
}
void student_get_student_number(student_t* student, char* buffer) {
strcpy(buffer, student->student_number);
}
unsigned int student_get_passed_credits(student_t* student) {
return student->passed_credits;
}

В этом листинге мы использовали публичный интерфейс класса Person, подключив
его заголовочный файл. Кроме того, добавили в определение student_t поле-указатель, ссылающийся на родительский объект Person. Это должно напомнить вам об
отношении «композиция», которое мы реализовали в предыдущей главе.
Следует отметить, что данное поле не обязательно делать первым в структуре
атрибутов. Это отличается от того, что мы видели при реализации первого подхода.
Указатели типов student_t и person_t больше не являются взаимозаменяемыми,
так как ссылаются на разные адреса в памяти, которые могут не быть смежными.
Это еще одно отличие от примера 8.2.
Обратите внимание: в конструкторе класса Student создается родительский объект.
Затем он инициализируется путем вызова конструктора Person и передачи ему необходимых параметров. То же самое касается деструкторов: родительский объект
в деструкторе класса Student уничтожается в последнюю очередь.
Мы не можем использовать поведение класса Person для чтения унаследованных
приватных атрибутов, класс Student должен предоставлять свой набор поведенческих функций для доступа к этим атрибутам.
Иными словами, класс Student должен содержать функции-обертки для работы
с приватными атрибутами родительского объекта person. Обратите внимание: самому объекту student ничего не известно о приватных атрибутах объекта person.
Это отличается от того, что мы видели в предыдущем подходе.

Наследование   275

Главная функция тоже очень похожа на ту, которая использовалась в примере 8.2.
Это показано в листинге 8.14.
Листинг 8.14. Главная функция примера 8.3 (ExtremeC_examples_chapter8_3_main.c)

#include
#include
#include "ExtremeC_examples_chapter8_3_student.h"
int main(int argc, char** argv) {
// создаем объект student
struct student_t* student = student_new();
student_ctor(student, "John", "Doe", 1987, "TA5667", 134);
// Мы должны использовать поведенческие функции объекта student,
// так как его указатель нельзя привести к типу person и мы
// не имеем доступа к приватному родительскому указателю
// в объекте student.
char buffer[32];
student_get_first_name(student, buffer);
printf("First name: %s\n", buffer);
student_get_last_name(student, buffer);
printf("Last name: %s\n", buffer);
printf("Birth year: %d\n", student_get_birth_year(student));
student_get_student_number(student, buffer);
printf("Student number: %s\n", buffer);
printf("Passed credits: %d\n", student_get_passed_credits(student));
// уничтожаем и освобождаем объект student
student_dtor(student);
free(student);
return 0;
}

Если сравнивать с главной функцией в примере 8.2, то мы не подключили публичный интерфейс класса Person. Нам также пришлось использовать поведенческие функции класса Student, поскольку указатели student_t и person_t больше
не взаимо­заменяемы.
В терминале 8.3 показано, как скомпилировать и запустить пример 8.3. Как вы
уже, наверное, догадались, вывод ничем не отличается от предыдущего примера.

276   Глава 8



Наследование и полиморфизм

Терминал 8.3. Сборка и запуск примера 8.3
$ gcc -c ExtremeC_examples_chapter8_3_person.c -o person.o
$ gcc -c ExtremeC_examples_chapter8_3_student.c -o student.o
$ gcc -c ExtremeC_examples_chapter8_3_main.c -o main.o
$ gcc person.o student.o main.o -o ex8_3.out
$ ./ex8_3.out
First name: John
Last name: Doe
Birth year: 1987
Student number: TA5667
Passed credits: 134
$

Далее мы сравним рассмотренные выше подходы к реализации наследования в C.

Сравнение двух подходов
Итак, вы познакомились с двумя подходами к реализации наследования в языке C.
Теперь сравним их. Ниже перечислены их основные сходства и различия.
zz Оба подхода, по сути, демонстрируют отношение типа «композиция».
zz В первом подходе структурная переменная находится в структуре атрибутов

потомка. Здесь требуется доступ к приватной реализации родительского класса.
Но во втором подходе используется структурный указатель неполного типа,
принадлежащего структуре атрибутов родителя, поэтому теряется зависимость
от приватной реализации родительского класса.
zz В первом подходе родительский и дочерний типы тесно связаны. Во втором

классы независимы друг от друга и все, что находится в родительской реализации, скрыто от потомка.
zz В первом подходе может быть только один родитель. То есть это реализация

одиночного наследования в C. Во втором же подходе родителей может быть
сколько угодно; так выглядит концепция множественного наследования.
zz В первом подходе структурная переменная родителя должна быть первым полем

структуры атрибутов дочернего класса. Во втором указатели на родительские
объекты могут находиться в любом месте структуры.
zz В первом подходе у нас не было двух отдельных объектов. Родительский объект

был частью дочернего, и указатель на дочерний объект являлся также указателем на родительский.
zz В первом подходе мы могли использовать поведенческие функции родительского

класса. Во втором нам пришлось делать для них обертки в дочернем классе.
До сих пор мы обсуждали только саму концепцию наследования и не видели, как
она используется. Один из важнейших способов применения наследования — реа­
лизация полиморфизма в объектной модели. В следующем разделе мы поговорим
о полиморфизме и о том, как он реализован в языке C.

Полиморфизм   277

Полиморфизм
На самом деле полиморфизм — не отношение между двумя классами. Прежде всего
это метод использования одного и того же кода для реализации разного поведения.
С его помощью можно расширять код и добавлять новые возможности, не прибегая
к перекомпиляции всей кодовой базы.
В данном разделе мы поговорим о том, что такое полиморфизм и как его можно
добавить в язык C. Это поможет нам лучше понять, как эта концепция реализована
в современных языках программирования, таких как C++. Начнем с определения.

Что такое полиморфизм
Полиморфизм — просто предоставление разного поведения с помощью одного
и того же публичного интерфейса (или набора поведенческих функций).
Представьте, что у вас есть два класса, Cat и Duck, каждый из которых имеет поведенческую функцию sound для вывода издаваемых ими звуков. Объяснение
концепции полиморфизма — непростая задача; я начну с верхнего уровня и буду
двигаться по нисходящей. Для начала попробую показать, как выглядит и ведет
себя полиморфный код, а затем мы перейдем к его реализации в C. Когда вы разберетесь с общей идеей, вам будет легче понять ее практические аспекты. В следующих листингах мы сначала создадим некоторые объекты, а затем посмотрим,
какого поведения следовало бы ожидать от их функций, если бы они были полиморфными. Итак, нам понадобится три объекта. Мы изначально исходим из того,
что Cat и Duck — потомки класса Animal (листинг 8.15).
Листинг 8.15. Создание трех объектов с типами Animal, Cat и Duck

struct animal_t* animal = animal_malloc();
animal_ctor(animal);
struct cat_t* cat = cat_malloc();
cat_ctor(cat);
struct duck_t* duck = duck_malloc();
duck_ctor(duck);

Без полиморфизма операцию sound пришлось бы вызывать из каждого объекта
следующим образом (листинг 8.16).
Листинг 8.16. Вызов операции sound из созданных ранее объектов

// в отсутствие полиморфизма
animal_sound(animal);
cat_sound(cat);
duck_sound(duck);

278   Глава 8



Наследование и полиморфизм

Мы бы получили такой вывод (терминал 8.4).
Терминал 8.4. Вывод в результате вызова функций
Animal: Beeeep
Cat: Meow
Duck: Quack

На отсутствие полиморфизма в приведенных выше листингах указывает тот факт,
что для вызова определенных операций из объектов Cat и Duck использовались
разные функции: cat_sound и duck_sound. В листинге 8.17 показано, как должны выглядеть вызовы полиморфных функций. Это безупречный пример полиморфизма.
Листинг 8.17. Вызов одной и той же операции sound из всех трех объектов

// это полиморфизм
animal_sound(animal);
animal_sound((struct animal_t*)cat);
animal_sound((struct animal_t*)duck);

Несмотря на то что во всех трех случаях вызывается одна и та же функция animal_
sound, ее поведение должно меняться. Похоже, это обусловлено передачей указателей на разные объекты. В терминале 8.5 показан вывод кода из листинга 8.17
в случае, если функция animal_sound полиморфна.
Терминал 8.5. Вывод в результате вызова функции
Animal: Beeeep
Cat: Meow
Duck: Quake

Как видите, мы использовали одну и ту же функцию, animal_sound, но с разными
указателями, поэтому внутри выполнялись разные операции.
Если вам не удается разобраться в приведенном выше коде, то, пожалуйста, не листайте дальше. Попробуйте перечитать предыдущий раздел.

В этом полиморфном коде подразумевается наличие отношения наследования между
классом Animal и двумя другими классами, Cat и Duck, поскольку мы должны иметь
возможность приводить указатели duck_t и cat_t к типу animal_t. Еще одна особенность данного кода состоит в том, что для использования преимуществ этой разновидности полиморфизма нам следует применять первый подход к наследованию в C.
Вы, наверное, помните, что в первом подходе у дочернего класса был доступ к приватной реализации родительского класса, поэтому здесь структурная переменная
типа animal_t должна быть первым полем в структурах атрибутов duck_t и cat_t.
В листинге 8.18 показаны отношения между этими тремя классами.

Полиморфизм   279
Листинг 8.18. Определение структур атрибутов для классов Animal, Cat и Duck

typedef struct {
...
} animal_t;
typedef struct {
animal_t animal;
...
} cat_t;
typedef struct {
animal_t animal;
...
} duck_t;

Благодаря такой конфигурации мы можем приводить указатели duck_t и cat_t
к типу animal_t и затем использовать одни и те же поведенческие функции для
обоих дочерних классов.
Итак, было показано, как должны себя вести полиморфные функции и как следует
определить отношение наследования между классами. Теперь осталось выяснить,
как реализовать это полиморфное поведение. Иными словами, мы должны обсудить сам механизм, который стоит за полиморфизмом.
Представим, что функция animal_sound определена так, как показано в листинге 8.19. Независимо от того, какой указатель она получает в качестве аргумента, ее
поведение остается неизменным, поэтому без отдельного внутреннего механизма
ее вызовы не будут полиморфными. Данный механизм будет показан в примере 8.4,
который мы рассмотрим чуть позже.
Листинг 8.19. Функция animal_sound еще не полиморфная!

void animal_sound(animal_t* ptr) {
printf("Animal: Beeeep");
}
// Эти вызовы могли бы быть полиморфными, но таковыми НЕ являются!
animal_sound(animal);
animal_sound((struct animal_t*)cat);
animal_sound((struct animal_t*)duck);

Как вы вскоре увидите, вызов поведенческой функции animal_sound с разными
параметрами не влияет на ее логику; то есть она не полиморфная (терминал 8.6).
В примере 8.4 мы это исправим.
Терминал 8.6. Результат вызовов функции из листинга 8.19
Animal: Beeeep
Animal: Beeeep
Animal: Beeeep

280  Глава 8



Наследование и полиморфизм

Какой же внутренний механизм позволит нам сделать поведение функций полиморфным? На этот вопрос я отвечу ниже, но прежде объясню, зачем нам вообще
нужен полиморфизм.

Зачем нужен полиморфизм
Прежде чем продолжать разговор о том, как мы будем реализовывать полиморфизм
в языке C, следует уделить внимание причинам, по которым нам нужна эта концепция. Полиморфизм прежде всего нужен потому, что мы хотим использовать один
и тот же код при работе с разными подтипами базового типа. Соответствующие
примеры будут показаны чуть позже.
Нам не хочется модифицировать нашу логику при добавлении в систему новых
подтипов или когда один из подтипов меняет свое поведение. Конечно, при появлении новой возможности нельзя совсем обойтись без обновления кода — какието изменения обязательно потребуются. Но благодаря полиморфизму мы можем
существенно уменьшить количество необходимых изменений.
Еще один повод для использования полиморфизма связан с понятием абстракции.
Абстрактные типы (или классы) обычно содержат какие-то неопределенные или
нереализованные поведенческие функции, которые необходимо переопределять
в дочерних классах, и полиморфизм играет в этом ключевую роль.
Поскольку мы хотим писать свою логику с помощью абстрактных типов, нам нужно как-то вызывать подходящую реализацию при работе с ними. Здесь тоже помогает полиморфизм. Полиморфное поведение необходимо в любом языке, иначе
стоимость сопровождения крупных проектов будет быстро расти — например, при
добавлении в код нового подтипа.
Итак, мы убедились, насколько важен полиморфизм. Теперь пришло время узнать,
как реализовать его в языке C.

Полиморфное поведение в языке C
Чтобы получить полиморфизм в C, необходимо использовать первый подход к реализации наследования, который мы исследовали. Вдобавок можно применять указатели на функции. Однако на сей раз их следует оформить в виде полей структуры
атрибутов. Рассмотрим это на нашем примере со звуками, которые издают животные.
У нас есть три класса: Animal, Cat и Duck. Последние два — подклассы первого.
У каждого из них есть по одному заголовку и исходнику. У класса Animal есть также
дополнительный приватный заголовочный файл с определением его структуры
атрибутов; это нужно в связи с тем, что мы выбрали первый подход к реализации
наследования. Приватный заголовок будет использоваться классами Cat и Duck.

Полиморфизм  281

В листинге 8.20 показан публичный интерфейс класса Animal.
Листинг 8.20. Публичный интерфейс класса Animal (ExtremeC_examples_chapter8_4_animal.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_H
// предварительное объявление
struct animal_t;
// аллокатор памяти
struct animal_t* animal_new();
// конструктор
void animal_ctor(struct animal_t*);
// деструктор
void animal_dtor(struct animal_t*);
// поведенческие функции
void animal_get_name(struct animal_t*, char*);
void animal_sound(struct animal_t*);
#endif

У класса Animal есть две поведенческие функции. Первая, animal_sound, должна
быть полиморфной и доступной для переопределения со стороны дочерних классов; вторая, animal_get_name, будет обычной функцией, которую дочерние классы
не смогут переопределять.
В листинге 8.21 представлено приватное определение структуры атрибутов animal_t.
Листинг 8.21. Приватный заголовок класса Animal (ExtremeC_examples_chapter8_4_animal_p.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_P_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_4_ANIMAL_P_H
// тип указателя, необходимого для обращения к разным версиям animal_sound
typedef void (*sound_func_t)(void*);
// предварительное объявление
typedef struct {
char* name;
// этот член класса является указателем на функцию,
// которая отвечает за вывод звуков
sound_func_t sound_func;
} animal_t;
#endif

282  Глава 8



Наследование и полиморфизм

При использовании полиморфизма каждый дочерний класс может предоставить
собственную версию функции animal_sound. Иными словами, каждый подкласс
может переопределить функцию, унаследованную от родительского. Поэтому любой
потомок, который хочет выполнить переопределение, должен иметь свою разновидность функции. В случае с функцией animal_sound это означает, что мы будем
вызывать ту ее версию, которую переопределил потомок.
Вот почему мы используем указатели на функции. В каждом экземпляре animal_t
будет указатель, предназначенный специально для операции animal_sound; он станет
ссылаться на определение соответствующей полиморфной функции внутри класса.
Для каждой полиморфной поведенческой функции следует предусмотреть отдельный указатель. Здесь я покажу, как с помощью такого указателя вызывать
подходящую функцию в каждом подклассе. Иными словами, вы увидите то, как
на самом деле работает полиморфизм.
В листинге 8.22 показано определение класса Animal.
Листинг 8.22. Определение класса Animal (ExtremeC_examples_chapter8_4_animal.c)

#include
#include
#include
#include "ExtremeC_examples_chapter8_4_animal_p.h"
// родительское определение animal_sound, которое используется по умолчанию
void __animal_sound(void* this_ptr) {
animal_t* animal = (animal_t*)this_ptr;
printf("%s: Beeeep\n", animal->name);
}
// аллокатор памяти
animal_t* animal_new() {
return (animal_t*)malloc(sizeof(animal_t));
}
// конструктор
void animal_ctor(animal_t* animal) {
animal->name = (char*)malloc(10 * sizeof(char));
strcpy(animal->name, "Animal");
// присваиваем указателю на функцию адрес определения по умолчанию
animal->sound_func = __animal_sound;
}
// деструктор
void animal_dtor(animal_t* animal) {
free(animal->name);
}

Полиморфизм  283
// поведенческие функции
void animal_get_name(animal_t* animal, char* buffer) {
strcpy(buffer, animal->name);
}
void animal_sound(animal_t* animal) {
// вызываем функцию, на которую ссылается указатель
animal->sound_func(animal);
}

Само полиморфное поведение находится в функции animal_sound. На случай,
если дочерний класс решит ее не переопределять, предусмотрено поведение по
умолчанию — приватная функция __animal_sound. В следующей главе вы увидите, что у полиморфных поведенческих функцийесть определение по умолчанию,
которое наследуется и используется, если подкласс не предоставляет переопределенную версию.
Идем дальше. Внутри конструктора animal_ctor мы сохраняем в поле sound_func
объекта animal адрес __animal_sound. В такой конфигурации этот указатель на
определение по умолчанию, __animal_sound, наследуется каждым потомком.
И в завершение внутри поведенческой функции animal_sound вызывается операция, на которую указывает поле sound_func. Это поле — указатель на фактическое
определение операции, роль которого в данном случае играет __animal_sound.
Обратите внимание: animal_sound, в сущности, перенаправляет вызов к настоящей
поведенческой функции.
Таким образом, если поле sound_func указывает на другую функцию, именно она
будет выполняться при вызове animal_sound. Мы будем использовать этот прием,
чтобы переопределить стандартную операцию sound в классах Cat и Duck.
Итак, посмотрим, как выглядят эти классы. В следующих листингах показан публичный интерфейс класса Cat и его приватное определение. Начнем с заголовочного файла (листинг 8.23).
Листинг 8.23. Публичный интерфейс класса Cat (ExtremeC_examples_chapter8_4_cat.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_4_CAT_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_4_CAT_H
// предварительное объявление
struct cat_t;
// аллокатор памяти
struct cat_t* cat_new();
// конструктор
void cat_ctor(struct cat_t*);

284  Глава 8



Наследование и полиморфизм

// деструктор
void cat_dtor(struct cat_t*);
// все поведенческие функции наследуются от класса Animal
#endif

Как вы вскоре увидите, этот код унаследует операцию sound от своего родительского класса, Animal.
В листинге 8.24 показано определение класса Cat.
Листинг 8.24. Приватная реализация класса Cat (ExtremeC_examples_chapter8_4_cat.c)

#include
#include
#include
#include "ExtremeC_examples_chapter8_4_animal.h"
#include "ExtremeC_examples_chapter8_4_animal_p.h"
typedef struct {
animal_t animal;
} cat_t;
// определяем новое поведение для операции sound
void __cat_sound(void* ptr) {
animal_t* animal = (animal_t*)ptr;
printf("%s: Meow\n", animal->name);
}
// аллокатор памяти
cat_t* cat_new() {
return (cat_t*)malloc(sizeof(cat_t));
}
// конструктор
void cat_ctor(cat_t* cat) {
animal_ctor((struct animal_t*)cat);
strcpy(cat->animal.name, "Cat");
// указываем на новую поведенческую функцию
// переопределение происходит именно здесь
cat->animal.sound_func = __cat_sound;
}
// деструктор
void cat_dtor(cat_t* cat) {
animal_dtor((struct animal_t*)cat);
}

Полиморфизм   285

Как видите, мы определили новую функцию, __cat_sound, которая будет издавать
кошачьи звуки. Затем внутри конструктора сделали так, чтобы указатель sound_
func ссылался на эту функцию.
Теперь происходит переопределение, и с этого момента все объекты cat будут
вызывать __cat_sound вместо __animal_sound. Тот же прием используется и для
класса Duck.
Публичный интерфейс класса Duck показан в листинге 8.25.
Листинг 8.25. Публичный интерфейс класса Duck (ExtremeC_examples_chapter8_4_duck.h)

#ifndef EXTREME_C_EXAMPLES_CHAPTER_8_4_DUCK_H
#define EXTREME_C_EXAMPLES_CHAPTER_8_4_DUCK_H
// предварительное объявление
struct duck_t;
// аллокатор памяти
struct duck_t* duck_new();
// конструктор
void duck_ctor(struct duck_t*);
// деструктор
void duck_dtor(struct duck_t*);
// все поведенческие функции наследуются от класса Animal
#endif

Как видите, это очень похоже на класс Cat. Посмотрим на приватное определение
класса Duck (листинг 8.26).
Листинг 8.26. Приватная реализация класса Duck (ExtremeC_examples_chapter8_4_duck.c)

#include
#include
#include
#include "ExtremeC_examples_chapter8_4_animal.h"
#include "ExtremeC_examples_chapter8_4_animal_p.h"
typedef struct {
animal_t animal;
} duck_t;
// определяем новое поведение для операции sound
void __duck_sound(void* ptr) {

286  Глава 8



Наследование и полиморфизм

animal_t* animal = (animal_t*)ptr;
printf("%s: Quacks\n", animal->name);
}
// аллокатор памяти
duck_t* duck_new() {
return (duck_t*)malloc(sizeof(duck_t));
}
// конструктор
void duck_ctor(duck_t* duck) {
animal_ctor((struct animal_t*)duck);
strcpy(duck->animal.name, "Duck");
// указываем на новую поведенческую функцию
// переопределение происходит именно здесь
duck->animal.sound_func = __duck_sound;
}
// деструктор
void duck_dtor(duck_t* duck) {
animal_dtor((struct animal_t*)duck);
}

Данная методика используется для переопределения стандартной операции
sound. Мы определили новую приватную поведенческую функцию, duck_sound,
которая издает утиные звуки, и на нее теперь ссылается указатель sound_func.
В целом это то, как полиморфизм реализован в C++. Мы вернемся к этому в следующей главе.
Наконец, в листинге 8.27 представлена главная функция примера 8.4.
Листинг 8.27. Главная функция примера 8.4 (ExtremeC_examples_chapter8_4_main.c)

#include
#include
#include
// только публичные интерфейсы
#include "ExtremeC_examples_chapter8_4_animal.h"
#include "ExtremeC_examples_chapter8_4_cat.h"
#include "ExtremeC_examples_chapter8_4_duck.h"
int main(int argc, char** argv) {
struct animal_t* animal = animal_new();
struct cat_t* cat = cat_new();
struct duck_t* duck = duck_new();
animal_ctor(animal);
cat_ctor(cat);
duck_ctor(duck);

Полиморфизм   287
animal_sound(animal);
animal_sound((struct animal_t*)cat);
animal_sound((struct animal_t*)duck);
animal_dtor(animal);
cat_dtor(cat);
duck_dtor(duck);
free(duck);
free(cat);
free(animal);
return 0;
}

Здесь используются только публичные интерфейсы классов Animal, Cat и Duck.
Поэтому функция main ничего не знает об их внутренней реализации. Чтобы продемонстрировать полиморфизм в действии, мы вызываем функцию animal_sound, передавая ей разные указатели. Посмотрим на вывод, который генерирует этот пример.
В терминале 8.7 показано, как скомпилировать и запустить пример 8.4.
Терминал 8.7. Компиляция, запуск и вывод примера 8.4
$ gcc -c ExtremeC_examples_chapter8_4_animal.c -o animal.o
$ gcc -c ExtremeC_examples_chapter8_4_cat.c -o cat.o
$ gcc -c ExtremeC_examples_chapter8_4_duck.c -o duck.o
$ gcc -c ExtremeC_examples_chapter8_4_main.c -o main.o
$ gcc animal.o cat.o duck.o main.o -o ex8_4.out
$ ./ex8_4.out
Animal: Beeeep
Cat: Meow
Duck: Quake
$

Как видите, в классовом языке программирования поведенческие функции, которые должны быть полиморфными, требуют особого внимания и обращения.
В противном случае поведенческая функция, не имеющая представленного выше
внутреннего механизма, не может быть полиморфной. Вот почему эти функции
называются особым образом и в языках вроде C++ обозначаются специальными
ключевыми словами. Это виртуальные функции — операции, которые могут быть
переопределены дочерними классами. Они должны отслеживаться компилятором,
и при их переопределении в соответствующих объектах должны использоваться
указатели на сами операции. Во время выполнения с помощью этих указателей
вызывается подходящая версия функции.
В следующей главе мы покажем, как объектно-ориентированные отношения между
классами организованы в C++. Кроме того, вы узнаете, как в этом языке реализован полиморфизм. Еще мы обсудим абстракцию данных — прямое следствие полиморфизма.

288  Глава 8



Наследование и полиморфизм

Резюме
Здесь мы продолжили наше исследование концепций ООП, начатое в предыдущей
главе. Были рассмотрены следующие темы:
zz мы выяснили, как работает наследование, и обсудили два подхода к его реали-

зации в языке C;
zz первый подход дает прямой доступ ко всем приватным атрибутам родительского

класса; второй более консервативен и прячет эти атрибуты;
zz мы сравнили оба подхода и увидели, что каждый из них подходит для опреде-

ленных сценариев использования;
zz мы исследовали полиморфизм. Говоря простым языком, эта концепция позво-

ляет создавать разные версии одного и того же поведения и затем вызывать те из
них, которые нам нужны, с помощью публичного API абстрактного супертипа;
zz мы обсудили, как писать полиморфный код на C, и увидели, какую роль играют

функции на указатели при выборе подходящей версии конкретной операции на
этапе выполнения.
Мы подходим к последней главе, посвященной объектной ориентированности.
В ней мы исследуем то, как в C++ работают инкапсуляция, наследование и полиморфизм. Кроме того, обсудим принцип абстракции данных и покажем, как
он привел к появлению причудливых абстрактных классов, из которых нельзя
создавать объекты.

9

Абстракция данных
и ООП в C++

Это последняя глава, посвященная ООП в C. В ней мы рассмотрим оставшиеся
темы и познакомимся с новой парадигмой программирования. Кроме того, исследуем язык C++ и посмотрим, как у него внутри реализованы объектно-ориентированные концепции.
Данная глава охватывает следующие темы.
zz Вначале мы обсудим абстракцию данных. Это будет продолжение нашего

разговора о наследовании и полиморфизме, и так мы завершим обсуждение
ООП в языке C. Вы увидите, как абстракция данных помогает проектировать
максимально расширяемые объектные модели с минимальным количеством
зависимостей между их разными компонентами.
zz Мы поговорим о том, как объектно-ориентированные концепции были реализованы в популярном компиляторе для C++, g++. В ходе разговора вы узнаете,

насколько близка эта реализация к тем подходам, которые мы обсуждали ранее.
Начнем с абстракции данных.

Абстракция данных
Понятие абстракции данных может иметь разные значения в разных сферах науки
и техники. Но в программировании, и особенно в ООП, оно, в сущности, относится
к абстрактным типам данных. В классовой объектной ориентированности это
то же самое, что и абстрактные классы. Абстрактными называют специальные
классы, из которых нельзя создавать объекты; они не завершены и не готовы для
использования в создании объектов. Так зачем же они нужны? Дело в том, что,
работая с абстрактными и обычными типами данных, мы можем избежать возникновения жестких зависимостей между различными частями кода.
Например, между классами Человек и Яблоко могут быть следующие отношения:
Объект класса Человек ест объект класса Яблоко.
Объект класса Человек ест объект класса Апельсин.

290  Глава 9



Абстракция данных и ООП в C++

Если к Яблоку и Апельсину нужно добавить другие классы, которые может есть объект класса Человек, то у последнего появятся дополнительные связи. Вместо этого
мы можем создать абстрактный класс Фрукт, который будет родителем Яблока
и Апельсина. Это позволит ограничиться лишь одним отношением — между Человеком и Фруктом. Таким образом, мы можем свести два предыдущих утверждения
к одному:
Объект класса Человек ест объект одного из подтипов класса Фрукт.
Класс Фрукт — абстрактный, поскольку не несет в себе информацию о форме,
вкусе, запахе, цвете и многих других атрибутах, свойственных фруктам. Значения
этих атрибутов становятся известными, только когда мы получаем яблоко или
апельсин. Классы Яблоко и Апельсин называют конкретными типами.
Мы можем повысить уровень абстракции. Класс Человек может также есть Салат
и Шоколад. Поэтому допустимо сказать следующее:
Объект типа Человек может есть объект одного из подтипов класса Пища.
Как видите, Пища находится на еще более высоком уровне абстракции, чем Фрукт.
Это отличный подход к проектированию объектных моделей с минимальной
зависимостью от конкретных типов и возможностью легкого расширения, если
в будущем в систему понадобится добавить другие конкретные типы.
Возвращаясь к предыдущему примеру, мы могли бы абстрагироваться еще сильнее,
используя тот факт, что Человеку свойственно потреблять пищу. Таким образом,
можно получить еще более абстрактное утверждение:
Объект одного из подтипов класса Едок ест объект одного из подтипов класса
Пища.
Мы можем продолжить абстрагирование объектной модели и подобрать типы
данных, уровень абстракции которых выше того, который необходим для решения
нашей задачи. Это так называемое чрезмерное абстрагирование. Оно случается,
когда вы пытаетесь создать абстрактные типы, не имеющие реального применения
в контексте ваших текущих или будущих потребностей. Этого следует избегать
любой ценой, поскольку абстракция, несмотря на все свои преимущества, может
создать проблемы.
При определении того, насколько сильно нужно абстрагировать объектную модель,
можно руководствоваться принципом абстракции. На посвященной ему странице
в «Википедии», https://en.wikipedia.org/wiki/Abstraction_principle_(computer_programming),
утверждается следующее.
Каждая существенная часть функциональности программы должна быть
реализована только на одном участке кода. В целом, если разные участки
кода имеют похожие функции, то их имеет смысл объединить путем абстрагирования того, чем они отличаются.

Абстракция данных  291

На первый взгляд в этом утверждении нет никаких признаков объектной ориентированности или наследования. Но, немного подумав, можно заметить, что данный
принцип лежит в основе рассмотренных нами отношений между объектами. Поэтому на практике, если вы не ожидаете, что конкретная логика будет варьироваться
в будущем, на данном этапе не нужно вводить абстракцию.
Для создания абстракций в языках программирования используются две возможности: наследование и полиморфизм. Абстрактный класс, такой как Пища, —
супертип по отношению к своим конкретным классам, таким как Яблоко. И это
достигается за счет наследования.
Полиморфизм тоже играет важную роль. Некое поведение абстрактного типа
не может иметь реализации по умолчанию на этом абстрактном уровне. Например, у атрибута вкус, реализованного в классе Пища в виде поведенческой функции
eatable_get_taste, не может быть определенного значения. Иными словами, мы
не можем создать объект напрямую из класса Пища, не зная, как определить поведенческую функцию eatable_get_taste.
Эта функция может иметь определение, только когда дочерний класс является достаточно конкретным. Например, мы знаем, что у Яблок атрибут вкус должен быть
сладким (предположим, что кислых яблок не существует). Вот где на помощь приходит полиморфизм. Он позволяет дочернему классу переопределить поведение
родителя и, к примеру, вернуть подходящий вкус.
Как вы помните по предыдущей главе, поведенческие функции, которые могут
переопределяться дочерними классами, называются виртуальными. Имейте в виду,
что у виртуальной функции может вообще не быть никакого определения. И это,
конечно, делает абстрактным класс, к которому она принадлежит.
При достижении определенного уровня абстракции у нас получаются классы без
каких-либо атрибутов или определений по умолчанию, а только с виртуальными функциями. Такие классы называются интерфейсами. Иными словами, они
предоставляют возможности, но не предлагают никакой реализации; обычно с их
помощью в программных проектах создаются зависимости между различными
компонентами. В наших предыдущих примерах роль интерфейсов играли классы
Едок и Пища. Обратите внимание: как и в случае с абстрактными классами, из
интерфейсов нельзя создавать объекты. В представленном ниже коде показано,
почему эту концепцию нельзя воплотить в языке C.
Листинг 9.1 реализует упомянутый выше интерфейс Пища (Eatable) на C, используя приемы для организации наследования и полиморфизма, с которыми вы
познакомились в предыдущей главе.
Листинг 9.1. Интерфейс Eatable в C

typedef enum {SWEET, SOUR} taste_t;
// тип указателя на функцию
typedef taste_t (*get_taste_func_t)(void*);

292  Глава 9



Абстракция данных и ООП в C++

typedef struct {
// указатель на определение виртуальной функции
get_taste_func_t get_taste_func;
} eatable_t;
eatable_t* eatable_new() { ... }
void eatable_ctor(eatable_t* eatable) {
// у этой виртуальной функции нет определения по умолчанию
eatable->get_taste_func = NULL;
}
// виртуальная поведенческая функция
taste_t eatable_get_taste(eatable_t* eatable) {
return eatable->get_taste_func(eatable);
}

Внутри конструктора мы присвоили функции get_taste_func значение NULL .
Поэтому вызов виртуальной функции eatable_get_taste повлечет ошибку сегментации. Это практический аспект того, почему из интерфейса Eatable нельзя
создавать объекты; другие причины обусловлены самим понятием «интерфейс»
и правилами проектирования.
В листинге 9.2 показано, как создание объекта из интерфейса Eatable может привести к сбою и почему так не следует делать, хотя это никоим образом не противоречит синтаксису языка C как таковому.
Листинг 9.2. Ошибка сегментации при создании объекта из интерфейса Eatable
и вызове из него чисто сугубо виртуальной функции

eatable_t *eatable = eatable_new();
eatable_ctor(eatable);
taste_t taste = eatable_get_taste(eatable); // Ошибка сегментации!
free(eatable);

Чтобы избежать создания объекта абстрактного типа, из публичного интерфейса
класса можно убрать функцию-аллокатор. Если вы помните подходы к реализации
наследования в языке C, которые мы рассматривали в предыдущей главе, то в результате удаления аллокатора создание объектов из структуры атрибутов родителя
становится доступным только дочерним классам.
Внешний код больше не может этого делать. Представьте, что в предыдущем примере мы не хотели, чтобы внешний код мог создавать какие-либо объекты из структуры eatable_t. Для достижения этого структуру атрибутов нужно предварительно
объявить и тем самым сделать ее неполным типом. Затем из класса следует удалить
публичный аллокатор eatable_new.
Если подытожить, то для получения абстрактного класса в C необходимо обнулить указатели на виртуальные функции, у которых не должно быть определения

Объектно-ориентированные концепции в C++  293

по умолчанию на абстрактном уровне. На высочайшем уровне абстракции у нас
получится интерфейс, у которого обнулены все указатели на функции. Чтобы
внешний код не мог создавать объекты абстрактных типов, следует удалить
функцию-аллокатор из публичного интерфейса.
В следующем разделе мы сравним аналогичные объектно-ориентированные возможности в C и C++. Это поможет вам понять, как C++ получился из чистого
языка C.

Объектно-ориентированные концепции в C++
В этом разделе мы сравним то, что нам удалось реализовать на языке C, с помощью
внутренних механизмов известного компилятора g++, предназначенных для поддержки инкапсуляции, наследования, полиморфизма и абстракции данных в C++.
Я хочу показать, что для реализации объектно-ориентированных концепций
в C и C++ используются похожие методы. Обратите внимание: с этого момента
под C++ мы будем понимать реализацию данного языка в компиляторе g++ ,
а не его стандарт. У нас нет оснований полагать, что в других компиляторах все
реализовано совершенно иначе, но некоторые различия точно присутствуют.
Вдобавок отмечу, что g++ будет использоваться в 64-битной системе под управлением Linux.
Сначала мы применим методики для написания объектно-ориентированного кода
на C, рассмотренные ранее, а затем напишем ту же программу на C++. В конце
подведем итоги.

Инкапсуляция
Углубиться во внутренности компилятора C++ и проанализировать, как он использует рассмотренные нами методики для создания итогового исполняемого
файла, не так-то просто, но существует один элегантный прием, который позволит
нам это сделать. Мы сравним инструкции ассемблера, сгенерированные для двух
похожих программ на C и C++.
Так мы сможем увидеть: компилятор C++ в конечном счете генерирует тот же
ассемблерный код, что и программа на языке C, которая использует уже знакомые
нам подходы к реализации ООП.
В примере 9.1 рассматриваются два простых проекта на C и C++, имеющих похожую объектно-ориентированную логику. У нас есть класс Rectangle с поведенческой функцией для вычисления площади прямоугольника. Сравним ассемблерный
код, который генерируется для этой функции в обеих программах. Версия на
языке C показана в листинге 9.3.

294  Глава 9



Абстракция данных и ООП в C++

Листинг 9.3. Пример инкапсуляции в C (ExtremeC_examples_chapter9_1.c)

#include
typedef struct {
int width;
int length;
} rect_t;
int rect_area(rect_t* rect) {
return rect->width * rect->length;
}
int main(int argc, char** argv) {
rect_t r;
r.width = 10;
r.length = 25;
int area = rect_area(&r);
printf("Area: %d\n", area);
return 0;
}

А в листинге 9.4 показана та же программа, только на C++.
Листинг 9.4. Пример инкапсуляции в C++ (ExtremeC_examples_chapter9_1.cpp)

#include
class Rect {
public:
int Area() {
return width * length;
}
int width;
int length;
};
int main(int argc, char** argv) {
Rect r;
r.width = 10;
r.length = 25;
int area = r.Area();
std::cout func_n = __default_func_n;
}
// публичные и невиртуальные поведенческие функции
void* parent_non_virt_func_1(parent_t* parent, ...) { // Код }
void* parent_non_virt_func_2(parent_t* parent, ...) { // Код }
...
void* parent_non_virt_func_m(parent_t* parent, ...) { // Код }
// сами публичные виртуальные поведенческие функции
void* parent_func_1(parent_t* parent, ...) {
return parent->func_1(parent, ...);
}
void* parent_func_2(parent_t* parent, ...) {
return parent->func_2(parent, ...);
}
...
void* parent_func_n(parent_t* parent, ...) {
return parent->func_n(parent, ...);
}

Как видите, родительский класс должен хранить в своей структуре атрибутов список указателей на функции. Эти указатели (в родительском классе) либо ссылаются
на определения виртуальных функций по умолчанию, либо равны нулю. У псевдокласса, представленного в данном листинге, есть m невиртуальных поведенческих
функций и n виртуальных.
Обратите внимание на то, что все поведенческие функции полиморфны.
Их называют виртуальными. В некоторых языках, таких как Java, они
называются виртуальными методами.

Невиртуальные функции не полиморфны, и при их вызове поведение никогда
не будет меняться. Иными словами, это обычная функция, которая просто выполняет логику внутри определения и не перенаправляет вызов другой функции.
Для сравнения, виртуальные функции должны перенаправлять вызовы подходящим определениям, установленным в конструкторе родителя или потомка. Если
дочерний класс хочет переопределить какие-либо унаследованные виртуальные
функции, то должен обновить их указатели.

304  Глава 9



Абстракция данных и ООП в C++

Вместо типа void* исходящим переменным можно назначить любой
другой тип указателя. Я использовал обобщенный указатель с целью
показать: функции в псевдокоде могут возвращать что угодно.

В следующем псевдокоде показано, как дочерний класс переопределяет несколько
виртуальных функций (листинг 9.11).
Листинг 9.11. Псевдокод, демонстрирующий, как дочерний класс в C может переопределить
виртуальные функции, унаследованные от родительского класса

Include everything related to parent class ...
typedef struct {
parent_t parent;
// дочерние атрибуты
...
} child_t;
void* __child_func_4(void* parent, ...) { // переопределение функции
}
void* __child_func_7(void* parent, ...) { // переопределение функции
}
void child_ctor(child_t* child) {
parent_ctor((parent_t*)child);
// инициализируем дочерние атрибуты
...
// обновляем указатели на функции
child->parent.func_4 = __child_func_4;
child->parent.func_7 = __child_func_7;
}
// поведенческие функции потомка
...

Как видите, дочернему классу достаточно обновить лишь несколько указателей
в структуре атрибутов родителя. В C++ применяется похожий подход. Когда вы
объявляете поведенческую функцию как виртуальную (с помощью ключевого слова virtual), C++ создает массив указателей на функции, похожий на тот, который
мы только что видели.
Мы также добавили по одному атрибуту с указателем для каждой виртуальной
функции, но в C++ это делается более элегантно. Для этого используется массив,
который называется виртуальной таблицей (virtual table, vtable). Она создается
непосредственно перед созданием самого объекта и заполняется во время вызова
сначала конструктора базового класса, а затем конструктора потомка — точно так
же, как было показано в листингах 9.10 и 9.11.

Объектно-ориентированные концепции в C++   305

Поскольку виртуальная таблица заполняется только внутри конструкторов, вызова полиморфных методов из конструктора родительского или дочернего класса
следует избегать, поскольку их указатели могут еще не подвергнуться обновлению
и ссылаться не на то определение.
В завершение нашего разговора о внутренних механизмах для реализации различных
объектно-ориентированных концепций в C и C++ рассмотрим абстракцию данных.

Абстрактные классы
Абстракция в C++ возможна благодаря чистым виртуальным функциям. Если
сделать метод класса виртуальным и присвоить ему ноль, то получится чистая
виртуальная функция. Взгляните на следующий пример (листинг 9.12).
Листинг 9.12. Интерфейс Eatable в C++

enum class Taste { Sweet, Sour };
// это интерфейс
class Eatable {
public:
virtual Taste GetTaste() = 0;
};

Внутри класса Eatable находится виртуальная функция GetTaste с нулевым значением. Это чистая виртуальная функция, которая делает весь класс абстрактным.
Вы больше не можете создавать объекты типа Eatable — язык C++ этого не позволяет. Кроме того, Eatable является интерфейсом, поскольку все его функции-члены
чисто виртуальные. GetTaste можно переопределить в дочернем классе.
В листинге 9.13 показан класс, который переопределяет функцию GetTaste.
Листинг 9.13. Два дочерних класса, реализующих интерфейс Eatable

enum class Taste { Sweet, Sour };
// это интерфейс
class Eatable {
public:
virtual Taste GetTaste() = 0;
};
class Apple : public Eatable {
public:
Taste GetTaste() override {
return Taste::Sweet;
}
};

306  Глава 9



Абстракция данных и ООП в C++

Чистые виртуальные функции очень похожи на обычные виртуальные. Адреса их
определений точно так же хранятся в виртуальной таблице, но с одним отличием.
Указатель на чистую виртуальную функцию изначально равен NULL; для сравнения,
во время выполнения конструктора указатель на обычную виртуальную функцию
должен ссылаться на определение по умолчанию.
В отличие от компилятора языка C, которому ничего не известно об абстрактных
типах, компилятор C++ о них знает и при попытке создать из них объект генерирует ошибку компиляции.
В этом разделе мы взяли разные объектно-ориентированные концепции и сравнили
то, как они реализуются в C (с помощью методик, с которыми познакомились в предыдущих трех главах) и C++ (задействуя компилятор g++). Вы могли убедиться
в том, что в большинстве случаев применяемые нами подходы соответствовали
методам, которые используются в g++.

Резюме
Данную главу мы начали с такого понятия, как абстракция, и затем продемонстрировали сходство между C и C++ в отношении объектно-ориентированных
концепций. На этом наше обсуждение ООП завершается.
В этой главе мы:
zz вначале поговорили об абстрактных классах и интерфейсах. Мы можем создать

интерфейс или частично абстрактный класс, который позволяет создавать
конкретные дочерние классы с полиморфным и варьирующимся поведением;
zz сравнили ассемблерный код, генерирующий методики, которые мы задействовали для реализации некоторых аспектов ООП в C, и компилятор g++. Результаты

оказались очень похожими. Отсюда мы сделали вывод о том, что использованные нами подходы могут иметь очень похожие продукты компиляции;
zz подробно обсудили виртуальные таблицы;
zz увидели, как с помощью чистых виртуальных функций (концепции языка C++,

у которой нет аналога в C) можно объявлять виртуальное поведение, не име­
ющее определения по умолчанию.
Следующая глава посвящена системе Unix, истории ее развития, ее многоуровневой архитектуре и тому, как она связана с C. Мы также рассмотрим историю
создания языка C.

10

История
и архитектура Unix

Вы, наверное, уже задавались вопросом, что делает глава о Unix посреди книги,
посвященной углубленному изучению C. Если нет, то я предлагаю вам спросить
себя, каким образом могут переплетаться обе темы, C и Unix, что на это пришлось
выделить целых две главы (текущую и следующую)?
Ответ прост: если вам кажется, что они никак не связаны между собой, то вы допускаете большую ошибку. Отношение между ними довольно очевидное; Unix — первая операционная система, реализованная на достаточно высокоуровневом языке
программирования, C, который, в свою очередь, был разработан специально для
этой цели и получил известность и влияние благодаря Unix. Хотя, конечно, стоит
отметить, что C больше не считается языком программирования высокого уровня.
Если бы в 1970-е и 1980-е годы инженеры в Bell Labs решили перейти с C на другой язык для разработки новой версии Unix, то мы бы сегодня говорили именно
об этом языке и наша книга называлась бы по-другому. Остановимся на минуту
и прочтем высказывание Денниса М. Ритчи, одного из пионеров C, о роли Unix
в успехе данного языка.
Успех самой системы Unix, несомненно, сыграл самую важную роль; благодаря ему этот язык стал доступен сотням тысяч людей. С другой стороны,
конечно, использование C в Unix обеспечило переносимость этой системы
на широкий спектр компьютеров, что было важно для ее успеха.
Деннис М. Ритчи,
The Development of the C Language

Сам документ доступен по адресу https://www.bell-labs.com/usr/dmr/www/chist.html.
В процессе чтения этой главы мы:
zz кратко пройдемся по истории Unix, включая создание языка C;
zz увидим, почему разработка языка C была основана на B и BCPL;
zz обсудим многослойную архитектуру Unix и то, как она была спроектирована

в соответствии с философией этой системы;

308  Глава 10



История и архитектура Unix

zz рассмотрим прикладной пользовательский уровень вместе с кольцом команд-

ной оболочки и увидим, как программы задействуют API этого кольца. В рамках
данного раздела будут рассмотрены стандарты SUS и POSIX;
zz обсудим слой ядра и посмотрим, какие возможности и функции должны при-

сутствовать в ядре Unix;
zz поговорим о Unix-устройствах и о том, как их можно использовать в системе

Unix.
Начнем главу с обзора истории Unix.

История Unix
В этом разделе мы совершим небольшой экскурс в историю Unix. Мы постараемся
сделать его коротким, не углубляясь в подробности. Наша цель — закрепить в вашем воображении неразрывную связь между Unix и C.

Multics OS и Unix
Еще до Unix существовала система Multics OS — совместный проект MIT, General
Electric и Bell Labs, запущенный в 1964 году. Это был первый в мире пример
рабочей и безопасной операционной системы, что обеспечило ее успех. Multics
устанавливали везде, от университетов до правительственных учреждений. Если
перенестись обратно в наше время, то все современные ОС заимствуют те или иные
идеи из Multics (хоть и косвенно, через Unix).
В 1969 году по разным причинам, о которых мы вскоре поговорим, часть сотрудников Bell Labs, особенно такие пионеры, как Кен Томпсон и Деннис Ритчи, разочаровались в Multics, в результате чего проект был заброшен. Но на этом история
не закончилась. Компания Bell Labs разработала собственную, более простую
и эффективную операционную систему под названием Unix.
Более подробно о проекте Multics и его истории можно почитать на сайте https://
multicians.org/history.html. По ссылке https://www.quora.com/Why-did-Unix-succeed-and-notMultics также можно найти хорошее объяснение того, почему система Unix выжила,
а Multics — нет.
Сравним эти операционные системы. Ниже перечислены их сходства и различия.
zz Внутренняя структура обеих ОС имеет многослойную архитектуру. Это зна-

чит, их архитектура состоит примерно из одних и тех же колец. Особенно это касается ядра и командной оболочки. Следовательно, программисты могут писать
свои программы поверх оболочки. Кроме того, Unix и Multics предоставляют
ряд собственных утилит, таких как ls и pwd. В следующих разделах мы рассмотрим различные кольца, которые составляют архитектуру Unix.

История Unix  309
zz Системе Multics для работы требовались дорогие ресурсы и оборудование.

Ее нельзя было установить на обычный потребительский компьютер, и это был
один из основных недостатков, который открыл Unix дорогу и сделал Multics
устаревшей системой.
zz Архитектура Multics была сложной. В этом состояла основная причина разо-

чарования работников Bell Labs, и именно она, как уже упоминалось, иници­
ировала их уход из данного проекта. Систему Unix изначально пытались сделать
простой. В ее первой версии не было даже многозадачности и многопользовательского режима!
Больше о Unix и Multics, а также о событиях, происходивших в ту эпоху, можно
почитать в Интернете. Оба проекта были успешными, но Unix удалось преуспеть
и дожить до наших дней.
Вдобавок стоит отметить, что компания Bell Labs работала над новой распределенной операционной системой под названием Plan 9, которая была основана
на проекте Unix (рис. 10.1). Более подробную
информацию о ней можно получить в «Википедии»: https://ru.wikipedia.org/wiki/Plan_9.
Полагаю, достаточно сказать, что система Unix
являлась упрощенным выражением идей и инноваций, представленных в Multics; она не была
чем-то новым. И на этом я могу завершить обзор истории обеих систем.
До сих пор я ни разу не упомянул о языке C,
поскольку на данном этапе его еще не существовало. Первая версия Unix была полностью написана на ассемблере. Язык C начали
применять только в версии 4, которая вышла
в 1973 году.
Мы уже подходим к главной теме нашего обсуждения, но сначала затронем BCPL и B, которые дали дорогу языку C.

Рис. 10.1. Plan 9 от Bell Labs
(из «Википедии»)

BCPL и B
Язык программирования BCPL был создан Мартином Ричардсом для написания
компиляторов. Сотрудники Bell Labs познакомились с этим языком во время работы над Multics. После закрытия проекта компания Bell Labs начала писать Unix
на ассемблере. В те времена это был единственный приемлемый вариант!
Например, тот факт, что участники проекта Multics использовали в разработке
PL/1, считался чем-то необычным, но в то же время являлся доказательством того,

310  Глава 10



История и архитектура Unix

что операционную систему можно успешно написать на языке программирования
высокого уровня. И это стало основной причиной, почему разработка Unix была
переведена на другой язык.
Кен Томпсон и Деннис Ритчи продолжили попытки написания модулей операционной системы на языке, отличном от ассемблера. Они попробовали использовать
BCPL, но оказалось, что для применения этого языка на компактных компьютерах
наподобие DEC PDP-7 его необходимо модифицировать. Эти модификации привели к появлению языка программирования B.
Мы не станем слишком углубляться в особенности языка B. Больше о нем и о том,
как он разрабатывался, можно почитать на следующих страницах:
zz язык программирования B, https://ru.wikipedia.org/wiki/Би_(язык_программирования);
zz The Development of the C Language, https://www.bell-labs.com/usr/dmr/www/chist.html.

Вторую статью написал сам Деннис Ритчи. Помимо объяснения процесса создания
языка C, в ней приводится ценная информация о проекте B и его характеристиках.
Язык B, будучи системным языком программирования, имел недостатки. В нем
не было системы типов, из-за чего в каждой операции он позволял работать только
с машинными словами (а не с байтами). Это затрудняло его применение на компьютерах с разной длиной машинных слов.
Вот почему язык B со временем претерпел ряд изменений, которые привели
к появлению языка NB (new B — «новый B»), унаследовавшего структуры из B.
В языке B эти структуры не имели типа, но в C стали типизированными. Наконец,
в 1973 году вышла четвертая версия Unix, написанная с использованием C, хотя
в ней по-прежнему было много ассемблерного кода.
В следующем подразделе я расскажу об отличиях B и C и покажу, почему C остается первоклассным современным языком программирования для написания
операционных систем.

Путь к C
Не думаю, что кто-то мог бы объяснить причины создания C после возникновения
трудностей с использованием B лучше, чем сам Деннис Ритчи. В этом подразделе
мы увидим, почему он, Кен Томпсон и другие инженеры решили создать новый
язык программирования, вместо того чтобы писать Unix на B.
Ниже перечислены недостатки B, которые привели к появлению C.
zz Язык B позволял работать только с машинными словами в памяти. Каждую

операцию без исключения нужно было выполнять с машинными словами.
В те времена о языке программирования, который позволял бы работать с байта-

История Unix  311

ми, можно было только мечтать. Это обуславливалось доступным на тот момент
обрудованием, в котором адресация памяти основывалась на словах.
zz Язык B не поддерживал типы. Если более точно, то B был однотипным язы-

ком. Все переменные имели один и тот же тип: машинное слово. Поэтому при
наличии строки из 20 символов (плюс нулевой символ в конце) нужно было
разделить ее на слова и сохранить в несколько переменных. Например, если
слово занимало 4 байта, то для хранения 21-символьной строки требовалось
шесть переменных.
zz Отсутствие типов привело к тому, что многие операции, ориентированные на

байты (такие как алгоритмы для работы со строками), были неэффективно написаны на B. Причина — язык B использовал для работы с памятью слова, а не
байты, что не позволяло эффективно управлять многобайтными типами данных,
такими как целые числа и строки.
zz Язык B не поддерживал операции с плавающей запятой. В то время подобные

операции становились все более доступными в новом оборудовании, но их поддержка в языке B отсутствовала.
zz Несмотря на существование таких компьютеров, как PDP-1, которые могли

работать с памятью побайтно, язык B демонстрировал низкую эффективность
при адресации байтов памяти. Особенно это касалось указателей, которые могли
ссылаться только на машинные слова, но не на байты. То есть если программе
нужно было обратиться к определенному байту или диапазону байтов в памяти, то ей приходилось выполнять дополнительные действия, чтобы вычислить
индексы подходящих слов.
Недостатки языка B, особенно его медленное развитие и низкая производительность на современных по тем временам компьютерах, вынудили Денниса Ритчи
создать новый язык, NB (new B), который в итоге превратился в C.
Этот новый язык пытался исправить изъяны и трудности, присущие B, и стал
фактически стандартом в мире системного программирования, заменив ассемблер.
Менее чем через десять лет новые версии Unix уже были полностью написаны
на C, и с тех пор все ОС, основанные на Unix, полагаются на данный язык и его
важнейшую роль в системе.
Как видите, C имеет немного необычную историю появления по сравнению с другими языками программирования; он разрабатывался с учетом полного списка
требований, и на сегодня у него нет конкурентов. Такие языки, как Java, Python
и Ruby, — более высокоуровневые, но их нельзя использовать для тех же задач. Например, на Java или Python невозможно написать драйвер устройства или модуль ядра;
эти языки сами по себе содержат в своей основе слой, написанный на C.
В отличие от многих других языков программирования C является стандартом
ISO, и при добавлении в него новых возможностей этот стандарт следует соответствующим образом отредактировать.

312  Глава 10



История и архитектура Unix

В следующем разделе мы обсудим архитектуру Unix. Это одна из фундаментальных концепций, необходимая для понимания жизненного цикла программы
в среде Unix.

Архитектура Unix
В этом разделе мы исследуем философию, которой руководствовались создатели Unix,
и то, какой результат они ожидали получить, проектируя архитектуру данной системы.
Как уже объяснялось в предыдущем разделе, сотрудники Bell Labs, имевшие отношение к Unix, изначально работали над Multics. Это был более крупный проект
со сложной архитектурой, рассчитанный на дорогое оборудование. Но следует
помнить, что, несмотря на все трудности, перед Multics стояли масштабные цели.
Идея, стоявшая за этим проектом, полностью изменила наше представление об
операционных системах.
Несмотря на проблемы и трудности, рассмотренные ранее, идеи, легшие в основу
Multics, оказались удачными, поскольку этой системе удалось прожить около
40 лет, вплоть до 2000 года. Более того, проект стал огромным источником доходов
для компании, которая им владела.
Такие люди, как Кен Томпсон и его коллеги, привнесли эти идеи в Unix, хотя изначально система должна была быть простой. Multics и Unix пытались реализовать
похожие архитектуры, но их судьбы оказались совершенно разными. На рубеже
столетий о Multics начали забывать, а вот проект Unix и семейство основанных на
нем операционных систем, таких как BSD, продолжают развиваться.
Теперь поговорим о философии Unix. Это просто набор общих требований, на
которых основана данная система. Затем мы обсудим ее многокольцевую, многослойную архитектуру и роль каждого кольца в ее поведении.

Философия
Создатели системы Unix уже несколько раз изложили ее философию. Поэтому ее
подробное рассмотрение выходит за рамки данной книги. Я лишь проведу общий
обзор всех основных аспектов.
Но сначала ознакомьтесь со списком отличных дополнительных материалов, которые могут помочь вам лучше понять эту тему:
zz «Википедия», «Философия Unix»: https://ru.wikipedia.org/wiki/Философия_Unix;
zz The Unix Philosophy: A Brief Introduction: http://www.linfo.org/unix_philosophy.html;

Архитектура Unix  313
zz Эрик Стивен Реймонд, «Искусство программирования для Unix»1: https://home­
page.cs.uri.edu/~thenry/resources/unix_art/ch01s06.html.

А по ссылке, приведенной далее, вы найдете кардинально противоположный взгляд
на философию Unix. В этом мире нет ничего идеального, и потому всегда лучше
знать обе стороны: The Collapse of UNIX Philosophy: https://kukuruku.co/post/the-collapseof-the-unix-philosophy/.
Чтобы подытожить перечисленные выше точки зрения, приведу ключевые аспекты
философии Unix.
zz Система Unix проектировалась и разрабатывалась в основном для программи-

стов, а не для обычных пользователей. В связи с этим многие требования, относящиеся к пользовательскому интерфейсу и взаимодействию с пользователями,
не являются частью архитектуры Unix.
zz Unix состоит из множества мелких и простых программ. Каждая из них предна-

значена для выполнения небольшой и простой задачи. Существует множество
примеров таких программ, включая ls, mkdir, ifconfig, grep и sed.
zz Сложную задачу можно решить путем последовательного выполнения таких

мелких и простых программ. Это значит, в решении крупной и сложной задачи
принимает участие несколько программ, каждая из которых может выполняться
многократно. Хороший пример — использование скриптов командной оболочки вместо написания программ с нуля. Обратите внимание: такие скрипты
зачастую можно переносить между разными системами Unix, что поощряет
программистов разбивать свои большие и сложные проекты на мелкие и простые программы.
zz Каждая мелкая и простая программа должна уметь подавать свой вывод на

вход другой программе, продолжая цепочку выполнения. Таким образом, мелкие программы можно объединять в цепочку для выполнения сложных задач.
В ней каждая программа играет роль преобразователя, который получает вывод предыдущей программы, изменяет его в соответствии со своей логикой
и передает его следующей программе. Отличным примером тому служит объединение команд Unix в конвейер с помощью вертикальной черты; например,
ls -l | grep a.out.
zz Система Unix строго ориентирована на работу с текстом. Вся конфигурация

хранится в текстовых файлах, и командная строка тоже текстовая. Скрипты
командной оболочки тоже представляют собой текстовые файлы с простым
синтаксисом для написания алгоритмов, которые выполняют другие консольные команды Unix.
1

Реймонд, Э. С. Искусство программирования для Unix. — М.: Вильямс, 2005.

314  Глава 10



История и архитектура Unix

zz Unix поощряет выбор простоты перед совершенством. Например, если простое

решение работает в большинстве случаев, то не стоит проектировать сложный
механизм, который будет лишь немногим лучше.
zz Программы, написанные для определенной операционной системы, совмести-

мой с Unix, должны легко переноситься на другие системы данного семейства.
Это в основном достигается благодаря наличию единой кодовой базы, которую
можно собирать и выполнять на различных Unix-подобных ОС.
Эти требованиябыли выработаны и интерпретированы разными людьми, но
в целом их можно считать основными принципами, лежащими в основе философии
Unix и, как следствие, сформировавшими архитектуру данной системы.
Если вы уже имели дело с Unix-подобной ОС, такой как Linux, то ваш опыт должен
соответствовать изложенным выше принципам. Как уже объяснялось в предыдущем подразделе, посвященном истории Unix, это должна была быть упрощенная
версия Multics; именно впечатления от работы с Multics позволили создателям
Unix сформировать данную философию.
Но вернемся к основной теме нашей книги. Вы можете спросить, какую роль в этом
играет язык C? Дело в том, что почти все ключевые элементы (то есть мелкие и простые программы, лежащие в основе Unix), о которых шла речь выше, написаны на
данном языке.
Но лучше один раз показать, чем сто раз рассказать. Поэтому рассмотрим пример. Исходный код программы ls из NetBSD можно найти на странице http://
cvsweb.netbsd.org/bsdweb.cgi/~checkout~/src/bin/ls/ls.c?rev=1.67. Как вы уже должны знать,
программа ls занимается лишь тем, что выводит содержимое каталога; пройдя
по ссылке, вы увидите: ее простая логика написана на C. Но этим роль C в Unix
не ограничивается. Более подробно об этом мы поговорим ниже, где речь пойдет
о стандартной библиотеке C.

Многослойная структура Unix
Пришло время исследовать архитектуру Unix. В целом, как уже упоминалось, она
имеет многослойную структуру, похожую на луковицу. Структура состоит из колец,
каждое из которых оборачивает внутренние кольца.
Эта знаменитая модель показана на рис. 10.2.
На первый взгляд, эта модель выглядит довольно просто. Но, чтобы как следует
в ней разобраться, необходимо написать несколько программ для Unix. Только после этого вы поймете, для чего в действительности предназначено каждое кольцо.
Я попробую описать данную архитектуру максимально просто и тем самым заложу
фундамент для написания настоящих примеров.

Архитектура Unix   315

Рис. 10.2. Многослойная модель архитектуры Unix

Разберем многослойную модель, начиная с внутреннего кольца.
В самом центре модели находится аппаратное обеспечение. Как мы знаем, основная
задача операционной системы — дать пользователю возможность взаимодействовать с оборудованием. Вот почему аппаратное обеспечение занимает центральное
место на рис. 10.2. Таким образом нам показывают одну из важнейших задач, стоящих перед Unix: сделать оборудование доступным для программ, которые хотят
к нему обращаться. Все, что вы прочитали выше о философии Unix, направлено на
предоставление этой возможности максимально оптимальным образом.
В роли кольца, в которое заключено аппаратное обеспечение, выступает ядро.
Это самая важная часть операционной системы. Оно ближе всего находится к оборудованию и служит оберткой для предоставления возможностей, которыми обладает данное оборудование. Благодаря прямому доступу к аппаратному уровню
ядро имеет наивысшие привилегии и может использовать все ресурсы, доступные
в системе. Этот неограниченный доступ ко всем компонентам — лучшее обоснование наличия в данной архитектуре остальных колец, которые имеют менее
широкие привилегии. На самом деле это послужило причиной разделения между

316  Глава 10



История и архитектура Unix

пространством ядра и пространством пользователя. Данную тему мы подробно
рассмотрим в текущей и следующей главах.
Обратите внимание: на написание ядра уходит львиная доля усилий, необходимых для создания новой Unix-подобной операционной системы, и, как вы можете
видеть, его кольцо изображено более толстым по сравнению с другими. Ядро Unix
состоит из множества модулей, каждый из которых — неотъемлемая часть экосистемы. Позже в этой главе мы поближе познакомимся с внутренней структурой
ядра Unix.
Следующее кольцо называется командной оболочкой. Это обертка, которая позволяет пользовательским приложениям взаимодействовать с ядром и применять его многочисленные функции. Стоит отметить, что данное кольцо само по
себе лежит в основе большинства требований, которые пытается удовлетворить
философия Unix (см. предыдущий подраздел). Подробнее об этом — в следу­
ющих абзацах.
Кольцо командной оболочки состоит из множества мелких программ, в совокупности составляющих набор инструментов, предоставляющий приложениям и пользователям доступ к возможностям ядра. Оно также содержит набор библиотек,
полностью написанных на C, с помощью которых программист может разрабатывать новые приложения для Unix.
Используя библиотеки, описанные в спецификации SUS (Simple Unix Speci­
fication — простая спецификация Unix), кольцо командной оболочки должно
предоставлять программистам стандартный и строго определенный интерфейс.
Такая стандартизация делает Unix-программы переносимыми или как минимум
совместимыми с разными реализациями Unix. Чуть позже я открою вам шокирующие тайны об этом кольце!
Наконец, внешнее кольцо, пользовательские приложения, состоит из прикладных
программ, предназначенных для выполнения в системах Unix. Речь идет о базах
данных, веб-сервисах, почтовых серверах, браузерах, электронных таблицах и текстовых процессорах.
Эти приложения должны использовать API и инструменты, предоставляемые
командной оболочкой, не обращаясь к ядру напрямую (через системные вызовы,
которые мы вскоре обсудим). Этого требует принцип переносимости, являющийся частью философии Unix. Обратите внимание: в нашем текущем контексте под
пользователем обычно имеется в виду пользовательское приложение, а не живой
человек, который работает с приложением.
Использование исключительно кольца командной оболочки помогает сделать
эти приложения совместимыми с различными Unix-подобными операционными
системами, которые не до конца соответствуют SUS. Нам хочется, чтобы одна
кодовая база работала как на Unix-совместимых, так и на Unix-подобных ОС.

Интерфейс командной оболочки для пользовательских приложений   317

По ходу чтения данной главы вы постепенно познакомитесь с отличиями этих
систем.
Характерная черта архитектуры Unix — тот факт, что внешние кольца должны
взаимодействовать с внутренними через определенный интерфейс. На самом деле
эти интерфейсы даже важнее колец. Например, нас больше интересует, как использовать возможности ядра, а не его внутреннее устройство, которое зависит от
конкретной реализации Unix.
То же касается кольца командной оболочки и интерфейса, которое предоставляется
пользовательским приложениям. На самом деле эти интерфейсы будут основной
темой обсуждения в двух главах, посвященных Unix. В следующем разделе мы рассмотрим каждое кольцо в отдельности и подробно поговорим о предоставляемых
им интерфейсах.

Интерфейс командной оболочки
для пользовательских приложений
Чтобы применить возможности, доступные в системе Unix, живой пользователь
работает либо с терминалом, либо с отдельной графической программой, такой
как браузер. И то и другое принадлежит к категории пользовательских приложений (или просто приложений/программ), которые позволяют обращаться к оборудованию через кольцо командной оболочки. Оперативная память, центральный
процессор, сетевой адаптер и жесткие диски — все это типичные примеры аппаратного обеспечения, которое большинство Unix-программ задействуют через
API кольца командной оболочки. Этот интерфейс будет одной из тем, которые
мы обсудим ниже.
С точки зрения разработчика, приложение мало чем отличается от
программы. Но в понимании пользователя приложение — программа,
с которой можно взаимодействовать через графический или консольный интерфейс (GUI и CLI соответственно), а программа — просто
программный компонент, который выполняется на компьютере без
какого-либо пользовательского интерфейса (как в случае с сервисом).
В книге я не провожу такое разграничение; для нас эти термины взаимозаменяемы.

Для Unix был написан широкий спектр программ на языке C. Базы данных, вебсерверы, почтовые серверы, игры, офисные приложения — это лишь несколько
примеров ПО, существующего в среде Unix. Всем им присуща общая черта: их код
можно переносить между разными Unix-подобными ОС с минимальными изменениями. Но как это возможно? Как написать такую программу на C, которую можно
собрать в разных версиях Unix и на разных аппаратных платформах?

318  Глава 10



История и архитектура Unix

Ответ прост: все системы Unix предоставляют для своей командной оболочки
один и тот же интерфейс прикладного программирования (Application Programming
Interface, API). Фрагмент кода на языке C, который использует только этот стандартный интерфейс, можно собрать и запустить на любой Unix-системе.
Но что именно имеется в виду под предоставлением API? Как уже объяснялось
ранее, API — куча заголовочных файлов с объявлениями. Эти заголовки и объявленные в них функции остаются неизменными для всех Unix-систем, но их реализации (то есть статические и динамические библиотеки, написанные для каждой
Unix-совместимой ОС) могут отличаться.
Обратите внимание: мы рассматриваем Unix в качестве стандарта, а не операционной системы. Существуют ОС, полностью совместимые с этим стандартом, такие
как BSD Unix; мы их называем Unix-совместимыми. Есть и частично совместимые
системы наподобие Linux, которые мы называем Unix-подобными.
Все системы Unix предоставляют примерно одинаковый API для кольца командной оболочки. Например, согласно стандарту Unix функция printf всегда должна
быть объявлена в заголовочном файле stdio.h. Если вам нужно послать что-то
в стандартный вывод Unix-совместимой системы, то вы должны использовать
printf или fprintf из заголовка stdio.h.
На самом деле заголовок stdio.h не является частью C, несмотря на то что
он и объявленные в нем функции фигурируют во всех книгах о данном языке.
Это часть стандартной библиотеки C, описанной в стандарте SUS. Программе, написанной на C для Unix, ничего не известно о реализации тех или иных функций,
таких как printf или fopen. Иными словами, программы во внешнем кольце воспринимают командную оболочку как некий черный ящик.
В стандарте SUS собраны различные API, предоставляемые кольцом командной
оболочки. Этот стандарт развивается консорциумом The Open Group и имеет несколько версий, вышедших с момента создания Unix. Самая последняя версия
SUS под номером 4 была выпущена в 2008 году, хотя за это время у нее появилось
несколько ревизий (в 2013, 2016 и, наконец, 2018 годах).
По адресу http://www.unix.org/version4/GS5_APIs.pdf находится документ, в котором
описаны интерфейсы, доступные в SUS версии 4. Как видите, кольцо командной
оболочки предоставляет доступ к разного рода API. Одни из них обязательны,
а другие — нет. Все они перечислены ниже.
zz Системные интерфейсы — сюда входят все функции, которыми могут поль-

зоваться любые программы на языке C. В стандарте SUS v4 предусмотрена
1191 функция, которая должна быть реализована в Unix-системе. В таблице,
расположенной по приведенной выше ссылке, указано, какие из этих функций являются обязательными или дополнительными в конкретной версии C.
Заметьте, что нас интересует версия C99.

Интерфейс командной оболочки для пользовательских приложений  319
zz Заголовочные интерфейсы — список заголовочных файлов, которые могут быть

доступны в Unix-системе, совместимой с SUS v4. В этой версии SUS перечислены
82 заголовочных файла, к которым может обращаться любая программа на языке C.
Если пройтись по этому списку, то можно найти много известных заголовков,
таких как stdio.h, stdlib.h, math.h и string.h. В зависимости от версий Unix и языка C одни из них являются обязательными, а другие — нет. Последние могут отсутствовать, тогда как обязательные непременно хранятся где-то в файловой системе.
zz Вспомогательные интерфейсы — список консольных утилит или программ ко-

мандной строки, которые должны быть доступны в Unix-системе, совместимой
с SUS v4. Если пройтись по таблицам, то можно встретить много знакомых нам
команд, таких как mkdir, ls, cp, df, bc; всего их насчитывается 160. Обратите
внимание: эти программы должны быть написаны поставщиком Unix-системы
и входить в итоговый установочный пакет.
Эти утилиты в основном применяются в терминале или скриптах командной
оболочки и нечасто вызываются другими программами на C. Они, как правило,
задействуют те же системные интерфейсы, которые доступны обычным программам, написанным для кольца пользовательских приложений.
В качестве примера ниже приводится ссылка на исходный код утилиты mkdir,
написанной для системы macOS High Sierra 10.13.6, которая является разновидностью дистрибутива Berkeley Software Distribution (BSD), основанного на
Unix. Исходный код опубликован на сайте Apple Open Source в разделе macOS
High Sierra (10.13.6) и доступен по адресу https://opensource.apple.com/source/
file_cmds/file_cmds-272/mkdir/mkdir.c.
Если пройти по этой ссылке и просмотреть исходник, то можно заметить, что
в нем используются функции mkdir и umask, объявленные в рамках системных
интерфейсов.
zz Сценарный интерфейс — язык, который используется для написания скриптов

командной оболочки. С его помощью в основном автоматизируют задачи, в которых применяются консольные утилиты. Этот интерфейс обычно называют
языком командной оболочки (или командной строки).
zz Интерфейсы XCURSES — набор интерфейсов, которые позволяют программе,

написанной на C, взаимодействовать с пользователем с помощью минималистичного текстового GUI.
На рис. 10.3 можно видеть пример GUI, написанного на реализации XCURSES
под названием ncurses.
В SUS v4 интерфейсу XCURSES отводится 379 функций, размещенных в трех
заголовках, а также четыре утилиты.
Многие программы до сих пор применяют XCURSES для более удобного взаи­
модействия с пользователем. Следует отметить, что интерфейсы на основе
XCURSES не нуждаются в графической подсистеме. Благодаря этому с ними
можно работать удаленно, по SSH (Secure Shell).

320  Глава 10



История и архитектура Unix

Рис. 10.3. Конфигурационное меню на основе ncurses («Википедия»)

Как видите, в SUS не описывается иерархия файловой системы и то, где можно
найти те или иные заголовочные файлы. Этот стандарт лишь указывает на то,
какие заголовки должны присутствовать и быть доступны в системе. Согласно
широко распространенному соглашению о заголовочных файлах они должны находиться либо в /usr/include, либо /usr/local/include, но окончательное решение
по-прежнему остается за операционной системой и пользователем. Это пути по
умолчанию, и в конфигурации системы их можно изменить.
Если объединим системные и заголовочные интерфейсы, а также реализацию доступных функций, которая варьируется в зависимости от разновидности Unix, то
получится стандартная библиотека C, или libc. Иными словами, libc — это набор
функций, размещенных в определенных заголовочных файлах в соответствии
с SUS, плюс статические и динамические библиотеки с реализациями доступных
функций.
Определение libc тесно связано с процессом стандартизации систем Unix. Любая
программа на языке C, разработанная в системе Unix, использует libc для взаимодействия с более низкими уровнями ядра и аппаратного обеспечения.
Следует помнить, что не все операционные системы полностью совместимы с Unix.
Это касается, например, Microsoft Windows и ОС с ядром Linux, таких как Android.
Эти операционные системы Unix-подобны, но не Unix-совместимы. Я использовал
оба термина в предыдущих главах, не объясняя их настоящего значения. Теперь
пришло время определить их должным образом.

Интерфейс командной оболочки для пользовательских приложений  321

Unix-совместимая система полностью соответствует стандартам SUS, чего нельзя
сказать о Unix-подобной системе, которая имеет лишь частичную совместимость.
Это значит, Unix-подобные системы соответствуют только определенному подмножеству стандартов SUS. Следовательно, программы, разработанные для
одной Unix-совместимой системы, теоретически можно перенести на другую,
но перенос на Unix-подобную ОС не гарантирован. Это особенно касается программ, которые переносятся с Linux на другие Unix-совместимые системы или
наоборот.
Появление множества Unix-подобных операционных систем, особенно после
рождения Linux, создало необходимость в классификации этого конкретного
подмножества SUS. Данный стандарт был назван POSIX (Portable Operating
System Interface — переносимый интерфейс операционных систем). Можно сказать, что POSIX — подмножество SUS, с которым совместимы Unix-подобные
системы.
Пройдя по следующей ссылке, можно найти все интерфейсы, которые должны быть
доступны в соответствии с POSIX: http://pubs.opengroup.org/onlinepubs/9699919799/.
Как видите, в POSIX и SUS есть похожие интерфейсы. Эти спецификации имеют
довольно много общего, однако появление POSIX позволило применить стандарты
Unix к более широкому спектру операционных систем.
Unix-подобные ОС, включая большинство дистрибутивов Linux, изначально
POSIX-совместимы. Вот почему с Ubuntu можно работать так же, как с FreeBSD.
Но этого нельзя сказать о некоторых других ОС. Например, система Microsoft
Windows не POSIX-совместима, но это можно исправить, установив дополнительные инструменты наподобие Cygwin¸ POSIX-совместимую среду, которая работает
прямо в Windows.
Вышесказанное еще раз подтверждает, что совместимость с POSIX достигается за
счет наличия стандартного кольца командной оболочки, а не ядра.
К слову, в 1990-х годах система Microsoft Windows стала совместимой с POSIX,
чем наделала много шума. Но со временем эта поддержка устарела.
И SUS, и POSIX описывают необходимые интерфейсы. Оба стандарта определяют,
что должно быть доступно, однако не уточняют, как это нужно реализовать. У каждой системы Unix есть своя реализация POSIX или SUS, воплощенная в библиотеках libc, которые являются частью кольца командной оболочки. Иными словами,
кольцо командной оболочки Unix-системы содержит реализацию libc, которая
предоставляется стандартным образом. Получив запрос, кольцо направляет его
ядру для дальнейшей обработки.

322  Глава 10



История и архитектура Unix

Интерфейс ядра для кольца командной оболочки
В предыдущем разделе мы объяснили, что кольцо командной оболочки в Unix
предоставляет доступ к интерфейсам, описанным в стандарте SUS или POSIX. Выполнить программную логику в этом кольце можно двумя основными способами:
либо через libc, либо с помощью консольных программ. Пользовательское приложение должно быть скомпоновано с библиотеками libc для выполнения процедур
командной оболочки или вызывать существующие утилиты, доступные в системе.
Обратите внимание: имеющиеся утилиты сами используют библиотеки libc.
Следовательно, мы можем обобщить вышесказанное и утверждать, что все процедуры командной оболочки находятся в библиотеках libc. Это делает стандартную
библио­теку C еще более значимой. Если вы хотите создать новую Unix-систему
с нуля, то после ядра вам придется написать собственную версию libc.
Если вы читали эту книгу последовательно и уже знакомы с предыдущими главами, то у вас начнет вырисовываться общая картина. Нам нужен был процесс
компиляции и механизм компоновки, чтобы спроектировать операционную систему, которая предоставляет интерфейс и реализует набор библиотечных файлов.
Вы уже должны заметить, что каждая возможность языка C играет на руку Unix.
Чем лучше вы будете понимать отношения между C и Unix, тем более очевидной
будет для вас их тесная связь.
Итак, мы разобрались с отношениями между пользовательскими приложениями
и кольцом командной оболочки. Теперь покажем, как это кольцо (libc) взаимодействует с кольцом ядра. Но прежде, чем продолжать, следует отметить, что в этом
разделе мы не станем объяснять, что такое ядро. Мы будем считать его своеобразным черным ящиком, который предоставляет доступ к определенным функциям.
Для работы с ядром libc (или функции в кольце командной оболочки) в основном используют системные вызовы. Чтобы объяснить этот механизм и показать,
в каком месте многослойной модели применяются системные вызовы, нам нужен
реальный пример.
Нам также следует выбрать конкретную реализацию libc, чтобы иметь возможность проанализировать исходники и найти соответствующие системные вызовы.
Я остановился на FreeBSD. Это Unix-подобная операционная система, которая
является ответвлением от BSD Unix.
Git-репозиторий FreeBSD находится по адресу https://github.com/freebsd/
freebsd. В нем содержатся исходные коды колец ядра и командной оболочки. Исходники libc этой системы можно найти в директории lib/libc.

Начнем с примера 10.1. В нем показана программа, которая просто ждет одну
секунду и завершается. Она находится в прикладном кольце, что, несмотря на ее
необычную простоту, делает ее пользовательским приложением.

Интерфейс ядра для кольца командной оболочки  323

Для начала рассмотрим исходный код примера 10.1 (листинг 10.1).
Листинг 10.1. Пример 10.1, в котором подключается функция sleep из кольца ядра
(ExtremeC_examples_chapter10_1.c)

#include
int main(int argc, char** argv) {
sleep(1);
return 0;
}

Как видите, код подключает заголовочный файл unistd.h и вызывает функцию
sleep; и то и другое — часть интерфейсов, доступных в SUS. Но что происходит
дальше, особенно в функции sleep? Вы, как программист на C, могли никогда не задаваться этим вопросом, но знание ответа улучшит ваше понимание системы Unix.
Мы всегда используем такие функции sleep, printf и malloc, не зная, как они работают внутри, но сделаем кое-что необычное и попробуем исследовать механизм,
с помощью которого libc общается с ядром.
Мы уже знаем, что системные вызовы инициируются кодом, написанным в реа­
лизации libc. На самом деле именно так вызываются процедуры ядра. В SUS
и впоследствии в POSIX-совместимых системах была предусмотрена программа,
которая позволяла отслеживать системные вызовы во время выполнения кода.
С большой долей уверенности можно утверждать, что программа, не делающая
никаких системных вызовов, фактически бесполезна. Из этого следует: любой
код, который мы пишем, должен использовать системные вызовы, обращаясь
к функциям libc.
Скомпилируем предыдущий пример и посмотрим, какие системные вызовы он
использует. Для начала выполним команды, показанные в терминале 10.1.
Терминал 10.1. Сборка и запуск примера 10.1 с помощью утилиты truss для отслеживания
выполняемых системных вызовов
$ cc ExtremeC_examples_chapter10_1.c -lc -o ex10_1.out
$ truss ./ex10_1.out
...
$

Как видите, мы воспользовались утилитой truss. Ниже приведен отрывок из тематического практического руководства, взятый из документации FreeBSD.
Утилита truss отслеживает системные вызовы, выполняемые заданным
процессом или программой. Результат по умолчанию записывается в заданный файл или stderr. Для этого отслеживаемый процесс останавливается
и перезапускается с помощью ptrace(2).

324  Глава 10



История и архитектура Unix

Как следует из описания, truss — программа для просмотра всех системных вызовов, которые код делает во время выполнения. Аналогичные утилиты доступны
в большинстве Unix-подобных систем. Например, в Linux для этого можно использовать strace.
В терминале 10.2 показан вывод truss в ходе мониторинга системных вызовов,
выполняемых кодом из предыдущего примера.
Терминал 10.2. Вывод утилиты truss с системными вызовами,
инициированными примером 10.1
$ truss ./ex10_1.out
mmap(0x0,32768,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANON,-1,0x0) = 34366160896
(0x800620000)
issetugid()
= 0 (0x0)
lstat("/etc",{ mode=drwxr-xr-x ,inode=3129984,size=2560,blksize=32768 }) =
0 (0x0)
lstat("/etc/libmap.conf",{ mode=-rw-r--r-- ,in
ode=3129991,size=109,blksize=32768 }) = 0 (0x0)
openat(AT_FDCWD,"/etc/libmap.conf",O_RDONLY|O_CLOEXEC,00) = 3 (0x3)
fstat(3,{ mode=-rw-r--r-- ,inode=3129991,size=109,blksize=32768 }) = 0 (0x0)
...
openat(AT_FDCWD,"/var/run/ld-elf.
so.hints",O_RDONLY|O_CLOEXEC,00) = 3 (0x3)
read(3,"Ehnt\^A\0\0\0\M^@\0\0\0Q\0\0\0\0"...,128) = 128 (0x80)
fstat(3,{ mode=-r--r--r-- ,inode=7705382,size=209,blksize=32768 }) = 0 (0x0)
lseek(3,0x80,SEEK_SET)
= 128 (0x80)
read(3,"/lib:/usr/lib:/usr/lib/compat:/u"...,81) = 81 (0x51)
close(3)
= 0 (0x0)
access("/lib/libc.so.7",F_OK)
= 0 (0x0)
openat(AT_FDCWD,"/lib/libc.so.7",O_RDONLY|O_CLOEXEC|O_VERIFY,00) = 3 (0x3)
...
sigprocmask(SIG_BLOCK,{ SIGHUP|SIGINT|SIGQUIT|SIGKILL|SIGPIPE|SIGALRM|
SIGTERM|SIGURG|SIGSTOP|SIGTSTP|SIGCONT|SIGCHLD|SIGTTIN|SIGTTOU|SIGIO|
SIGXCPU|SIGXFSZ|SIGVTALRM|SIGPROF|SIGWINCH|SIGINFO|SIGUSR1|
SIGUSR2 },{ }) = 0 (0x0)
sigprocmask(SIG_SETMASK,{ },0x0)
= 0 (0x0)
sigprocmask(SIG_BLOCK,{ SIGHUP|SIGINT|SIGQUIT|SIGKILL|SIGPIPE|SIGALRM|
SIGTERM|SIGURG|SIGSTOP|SIGTSTP|SIGCONT|SIGCHLD|SIGTTIN|SIGTTOU|SIGIO|
SIGXCPU|SIGXFSZ|SIGVTALRM|SIGPROF|SIGWINCH|SIGINFO|SIGUSR1|
SIGUSR2 },{ }) = 0 (0x0)
sigprocmask(SIG_SETMASK,{ },0x0)
= 0 (0x0)
nanosleep({ 1.000000000 })
= 0 (0x0)
sigprocmask(SIG_BLOCK,{ SIGHUP|SIGINT|SIGQUIT|SIGKILL|SIGPIPE|SIGALRM|
SIGTERM|SIGURG|SIGSTOP|SIGTSTP|SIGCONT|SIGCHLD|SIGTTIN|SIGTTOU|SIGIO|
SIGXCPU|SIGXFSZ|SIGVTALRM|SIGPROF|SIGWINCH|SIGINFO|SIGUSR1|
SIGUSR2 },{ }) = 0 (0x0)
...
sigprocmask(SIG_SETMASK,{ },0x0)
= 0 (0x0)

Интерфейс ядра для кольца командной оболочки   325
exit(0x0)
process exit, rval = 0
$

В этом простом примере сделано много системных вызовов. Некоторые из них относятся к загрузке разделяемых объектных библиотек, особенно на этапе инициализации процесса. Первый системный вызов, выделенный полужирным шрифтом,
открывает разделяемый объектный файл libc.so.7, содержащий непосредственную реализацию libc для FreeBSD.
В этом же терминале мы видим, что программа делает системный вызов nanosleep.
Значение, которое она ему передает, равно 1000000000 наносекунд, что эквивалентно 1 секунде.
Системные вызовы подобны вызовам функций. Обратите внимание: у каждого из
них есть предопределенный, фиксированный номер, а также имя и список аргументов. Каждый системный вызов выполняет определенное действие. В данном случае
nanosleep замораживает вызывающий поток выполнения на заданное количество
наносекунд.
Больше информации на эту можно получить в справочнике по системным вызовам
в FreeBSD. В терминале 10.3 показана страница данного справочника, посвященная
системному вызову nanosleep.
Терминал 10.3. Страница справочника, посвященная системному вызову nanosleep
$ man nanosleep
NANOSLEEP(2)
NANOSLEEP(2)

FreeBSD System Calls Manual

NAME
nanosleep - high resolution sleep
LIBRARY
Standard C Library (libc, -lc)
SYNOPSIS
#include
Int
clock_nanosleep(clockid_t clock_id, int flags,
const struct timespec *rqtp, struct timespec *rmtp);
int
nanosleep(const struct timespec *rqtp, struct timespec *rmtp);
DESCRIPTION
If the TIMER_ABSTIME flag is not set in the flags argument, then
clock_nanosleep() suspends execution of the calling thread until either

326  Глава 10



История и архитектура Unix

the time interval specified by the rqtp argument has elapsed, or a signal
is delivered to the calling process and its action is to invoke
a signalcatching function or to terminate the process. The clock
used to measure the time is specified by the clock_id argument
...
...
$

На приведенной выше странице справочника говорится следующее.
zz Вызов nanosleep является системным.
zz Системный вызов доступен в виде функций nanosleep и clock_nanosleep ,
определенных в файле time.h и принадлежащих кольцу командной оболочки.
Обратите внимание: мы воспользовались функцией sleep из unitsd.h. Но могли
также вызвать две вышеупомянутые функции из time.h. Вдобавок следует от-

метить, что оба заголовочных файла и все приведенные здесь функции, включая
те, которые использовались в примере, являются частью SUS и POSIX.
zz Если вы хотите вызывать эти функции, то должны скомпоновать ваш исполняемый файл с libc; для этого компоновщику необходимо передать параметр -lc.

Это может касаться только FreeBSD.
zz На данной справочной странице речь идет не о самом системном вызове,

а о стандартном API языка C, который доступен в кольце командной оболочки.
Эти справочники рассчитаны на разработчиков приложений, и потому в них мало
информации о системных вызовах и внутренностях ядра. Вместо этого основ­ной
акцент в них делается на API, доступные в кольце командной оболочки.
Теперь найдем то место в libc, где инициируется системный вызов. Мы будем использовать исходники FreeBSD на GitHub. В данном случае взята фиксация с хешем bf78455d496 в ветви master. Чтобы клонировать из репозитория и применить
подходящую фиксацию, выполните команды, показанные в терминале 10.4.
Терминал 10.4. Клонирование проекта FreeBSD и переход к определенной фиксации
$ git clone https://github.com/freebsd/freebsd
...
$ cd freebsd
$ git reset --hard bf78455d496
...
$

Мы также можем открыть проект FreeBSD на самом сайте GitHub с помощью
ссылки https://github.com/freebsd/freebsd/tree/bf78455d496. В любом случае у вас должна
быть возможность найти следующие строчки кода.
Если зайти в каталог lib/libc и выполнить grep для sys_nanosleep, то можно увидеть
следующие файлы (терминал 10.5).

Ядро   327
Терминал 10.5. Поиск файлов, относящихся к системному вызову nanosleep в libc для FreeBSD
$ cd lib/libc
$ grep sys_nanosleep . -R
./include/libc_private.h:int
__sys_nanosleep(const struct
timespec *, struct timespec *);
./sys/Symbol.map:
__sys_nanosleep;
./sys/nanosleep.c:__weak_reference(__sys_nanosleep, __nanosleep);
./sys/interposing_table.c:
SLOT(nanosleep, __sys_nanosleep),
$

Как можно видеть в файле lib/libc/sys/interposing_table.c, функция nanosleep
привязана к вызову __sys_nanosleep. Следовательно, при выполнении nanosleep
всегда вызывается __sys_nanosleep.
В FreeBSD названия всех функций, которые являются системными вызовами, принято начинать с __sys. Обратите внимание: это часть реализации libc, и потому соглашение об именовании и другие детали реализации относятся исключительно к FreeBSD.
В приведенном выше терминале есть еще один интересный участок. Файл lib/
libc/include/libc_private.h содержит объявления приватных и внутренних
функций-оберток вокруг системных вызовов.
Итак, мы увидели, каким образом кольцо командной оболочки направляет вызовы
функций, принадлежащих libc, во внутренние кольца с помощью системных вызовов. Но для чего это делается? Если взять обычную функцию в пользовательском
приложении или libc, то чем она отличается от системного вызова в кольце ядра?
В главе 11 мы обсудим это подробнее, познакомившись с более конкретным определением системного вызова.
Следующий раздел посвящен кольцу ядра и его внутренним компонентам, которые
являются общими для ядер в большинстве Unix-совместимых и Unix-подобных
систем.

Ядро
Основное назначение кольца ядра состоит в управлении оборудованием, подключенным к системе, и предоставлении доступа к своим возможностям с помощью
системных вызовов. На рис. 10.4 показано, каким образом пользовательское приложение обращается к определенной аппаратной функции.
Это общая схема того, о чем мы говорили ранее. В данном разделе мы сосредоточимся на самом ядре и поговорим о том, что оно собой представляет. Ядро, как
и любой другой известный нам процесс, выполняет последовательность инструкций. Однако процесс ядра фундаментально отличается от обычных пользовательских процессов.

328  Глава 10



История и архитектура Unix

Рис. 10.4. Вызовы функций и системные вызовы, которые выполняются между разными
кольцами Unix при обращении к оборудованию

В перечне ниже сравниваются процесс ядра и пользовательский процесс. Обратите
внимание: речь идет в основном о монолитных ядрах, таких как Linux. Разные виды
ядер будут рассмотрены в следующей главе.
zz Процесс ядра — первое, что загружается и выполняется в системе. Только вслед

за ним можно запускать пользовательские процессы.
zz Процесс ядра существует в единственном экземпляре, при этом мы можем за-

пустить сразу несколько пользовательских процессов.
zz Процесс ядра создается в результате того, что загрузчик копирует его образ

в основную память, а для создания пользовательского процесса применяются
системные вызовы exec оли fork, присутствующие в большинстве Unix-систем.
zz Процесс ядра обрабатывает и выполняет системные вызовы, в то время как

пользовательский их инициирует и ждет их выполнения. Это значит, что, когда
пользовательский процесс запрашивает системный вызов, поток выполнения
переходит к процессу ядра; то есть логику системного вызова от имени пользо-

Ядро  329

вателя выполняет само ядро. Мы подробно разберем то, как это происходит, во
второй части нашего знакомства с Unix, в главе 11.
zz Процесс ядра имеет привилегированный доступ к физической памяти и всему

подключенному оборудованию, в то время как пользовательский работает
с виртуальной памятью, которая является отражением части физической памяти, и ничего не знает о ее физической структуре. Пользовательский процесс
имеет управляемый и наблюдаемый доступ к ресурсам и оборудованию. Можно
сказать, что он выполняется в изолированной среде, которая симулируется
операционной системой. Это также означает, что пользовательские процессы
не могут видеть память друг друга.
Из этого сравнения следует, что среда выполнения операционной системы имеет
два разных режима. Один предназначен для процесса ядра, а другой — для пользовательских процессов.
Первый режим выполнения называется пространством ядра, а второй — пользовательским пространством. Оба пространства взаимодействуют с помощью
предусмотренных системных вызовов. В целом причиной появления системных
вызовов послужила необходимость в изоляции между пространствами ядра и пользователя. Пространство ядра обладает максимально привилегированным доступом
к системным ресурсам, а доступ пользовательского пространства максимально
ограниченный и контролируемый.
Внутреннюю структуру типичного Unix-ядра можно разделить по выполняемым
задачам. В действительности обязанности ядра не ограничиваются управлением
аппаратным обеспечением. Ниже перечислены задачи, которые возложены на
ядро Unix. Стоит отметить, что одним из пунктов идет управление оборудо­
ванием.
zz Управление процессами. Ядро создает пользовательские процессы с помощью

системного вызова. Среди прочих операций перед запуском нового процесса
необходимо сначала выделить память и загрузить его инструкции.
zz Межпроцессное взаимодействие (Inter-Process Communication, IPC). Пользова-

тельские процессы, запущенные на одном компьютере, могут задействовать
разные методы обмена данными, включая разделяемую память, каналы и доменные сокеты Unix. Эти методы должны обеспечиваться ядром, и некоторые
из них применяют ядро для обмена информацией. Мы обсудим их в главе 19,
посвященной методикам IPC.
zz Планирование. Система Unix всегда славилась своей многозадачностью. Ядро

управляет доступом к центральному процессору, пытаясь его балансировать.
Планирование — процедура разделения процессорного времени между разными
процессами в зависимости от их приоритета и значимости. В следующих главах
мы более подробно поговорим о многозадачности, многопоточности и многопроцессности.

330  Глава 10



История и архитектура Unix

zz Управление памятью. Это, несомненно, одна из ключевых обязанностей ядра.

Ядро — единственный процесс, который видит всю физическую память и имеет
к ней неограниченный доступ. Поэтому на него ложится разбиение ее на выделяемые страницы, назначение новых страниц процессам (при выделении кучи),
освобождение памяти и многие другие сопутствующие процедуры.
zz Начальная загрузка системы. После загрузки образа ядра в основную память

его процесс должен запуститься и инициализировать пользовательское пространство. Обычно для этого создается первый пользовательский процесс с PID
(Process Identifier — идентификатор процесса) 1. В некоторых разновидностях
Unix, таких как Linux, этот процесс называется init. Вслед за ним запускаются
другие сервисы и демоны.
zz Управление устройствами. Ядро должно иметь возможность управлять не толь-

ко процессором и памятью, но и аппаратным обеспечением. Для этого преду­
смотрен слой абстракции. В качестве устройства может выступать реальное или
виртуальное оборудование, подключенное к системе. В Unix устройства привязываются к файлам, которые обычно хранятся в каталоге /dev. Это касается
всех подключенных жестких дисков, сетевых адаптеров, USB-устройств и т. д.
Эти файлы могут применяться пользовательскими процессами для взаимодействия с аппаратным обеспечением.
На рис. 10.5 показана внутренняя структура, присущая большинству ядер Unix
и основанная на приведенном выше перечне.
Это подробная иллюстрация колец Unix. Здесь явно видно, что в кольце командной
оболочки пользовательским приложениям доступно три компонента. Вы также
можете видеть подробную внутреннюю структуру кольца ядра.
В верхней части кольца ядра мы имеем интерфейс системных вызовов. Как видите,
все предыдущие компоненты в пользовательском пространстве могут взаимодействовать с нижними компонентами только через данный интерфейс. Это словно
шлюз или барьер между пространствами ядра и пользователя.
В ядре размещены различные компоненты, такие как блок управления памятью
(Memory Management Unit, MMU), который отвечает за доступную физическую
память. Модуль управления процессами создает процессы в пользовательском
пространстве, выделяет для них ресурсы и позволяет им взаимодействовать.
На рис. 10.5 также показаны символьные и блочные устройства, которые открывают доступ к различным функциям ввода/вывода с помощью драйверов. Файловая
система является неотъемлемой частью ядра и служит абстракцией над блочными
и символьными устройствами, позволяя процессам и самому ядру задействовать
общую файловую иерархию.
В следующем разделе мы поговорим об аппаратном обеспечении.

Рис. 10.5. Внутренняя структура разных колец в архитектуре Unix

Ядро  331

332  Глава 10



История и архитектура Unix

Аппаратное обеспечение
Конечная цель любой операционной системы — дать пользователю и приложениям
возможность работать с аппаратным обеспечением. Unix тоже пытается предоставить доступ к подключенному оборудованию абстрактным и прозрачным путем,
используя один и тот же набор утилит и команд на всех существующих и будущих
платформах.
Используя эту абстрактность и прозрачность, Unix инкапсулирует всевозможное
оборудование в виде ряда устройств, подключенных к системе. Термин «устройство» — одно из ключевых в Unix, и любое имеющееся аппаратное обеспечение
считается устройством, подключенным к системе Unix.
Оборудование, подключенное к компьютеру, можно разделить на две категории:
основное и периферийное. К основным устройствам относятся центральный процессор и память. Все остальное, включая жесткие диски, сетевые адаптеры, мыши,
мониторы, графические карты и адаптеры Wi-Fi, — периферийное оборудование.
Компьютер под управлением Unix не может работать без основных устройств, но
существуют системы, которые обходятся без жесткого диска или сетевого адаптера.
Обратите внимание: файловая система, без которой не может работать ядро Unix,
может не требовать наличия жесткого диска!
Ядро Unix полностью инкапсулирует центральный процессор и физическую память, управляя ими напрямую и не давая обращаться к ним из пространства пользователя. За работу с физической памятью и процессором в ядре Unix отвечают
соответственно блок управления памятью и планировщик.
С периферийным оборудованием все иначе. Доступ к нему осуществляется через
файлы устройств. В системе Unix эти файлы находятся в каталоге /dev.
В терминале 10.6 показан список файлов, которые можно найти на обычном компьютере с Linux.
Терминал 10.6. Содержимое каталога /dev на компьютере с Linux
$ ls -l /dev
total 0
crw-r--r-- 1
drwxr-xr-x 2
drwxr-xr-x 2
crw-rw---- 1
drwxr-xr-x 3
lrwxrwxrwx 1
drwxr-xr-x 2
crw------- 1
lrwxrwxrwx 1
crw------- 1

root
root
root
root
root
root
root
root
root
root

root
root
root
disk
root
root
root
root
root
root

10, 235
280
80
10, 234
60
3
3500
5,
1
11
10, 59

Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct

14
14
14
14
14
14
14
14
14
14

16:55
16:55
16:55
16:55
17:02
16:55
16:55
16:55
16:55
16:55

autofs
block
bsg
btrfs-control
bus
cdrom -> sr0
char
console
core -> /proc/kcore
cpu_dma_latency

Аппаратное обеспечение  333
crw------drwxr-xr-x
drwxr-xr-x
lrwxrwxrwx
crw------crw-rw---lrwxrwxrwx
crw-rw-rwcrw-rw-rwcrw------crw------drwxr-xr-x
crw------crw------...
crw-rw-r-lrwxrwxrwx
crw------brw-rw---brw-rw---brw-rw---crw-rw----+
crw-rw---drwxrwxrwt
crw------drwxr-xr-x
brw-rw----+
lrwxrwxrwx
lrwxrwxrwx
lrwxrwxrwx
crw-rw-rwcrw--w---...
$

1
6
3
1
1
1
1
1
1
1
1
2
1
1

root
root
root
root
root
root
root
root
root
root
root
root
root
root

root
root
root
root
root
video
root
root
root
root
root
root
root
root

1
1
1
1
1
1
1
1
2
1
3
1
1
1
1
1
1

root
root
root
root
root
root
root
root
root
root
root
root
root
root
root
root
root

root
root
root
disk
disk
disk
cdrom
disk
root
root
root
cdrom
root
root
root
tty
tty

10, 203
120
80
3
10, 61
29,
0
13
1,
7
10, 229
245,
0
10, 228
0
10, 183
89,
0
10,
249,
8,
8,
8,
21,
21,
10,
11,

5,
4,

62
4
0
0
1
2
0
1
40
231
180
0
15
15
15
0
0

Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct

14
14
14
14
14
14
14
14
14
14
14
14
14
14

16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55

cuse
disk
dri
dvd -> sr0
ecryptfs
fb0
fd -> /proc/self/fd
full
fuse
hidraw0
hpet
hugepages
hwrng
i2c-0

Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct
Oct

14
14
14
14
14
14
14
14
14
14
14
14
14
14
14
14
14

16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55
16:55

rfkill
rtc -> rtc0
rtc0
sda
sda1
sda2
sg0
sg1
shm
snapshot
snd
sr0
stderr -> /proc/self/fd/2
stdin -> /proc/self/fd/0
stdout -> /proc/self/fd/1
tty
tty0

Как видите, к компьютеру подключено довольно много устройств. Конечно, не все
они физические. Благодаря слою абстракции поверх аппаратного обеспечения
система Unix поддерживает виртуальные устройства.
Например, у вас может быть виртуальный сетевой адаптер, который физически
не существует, но может производить дополнительные операции с сетевыми
данными. Это один из способов организации VPN в средах, основанных на Unix.
Физический сетевой адаптер предоставляет реальные сетевые функции, а виртуальный — позволяет пропускать данные через безопасный туннель.
Как видно в показанном выше выводе, у каждого устройства есть собственный
файл в каталоге /dev. Строчки, начинающиеся с a и b, представляют соответственно символьные и блочные устройства. Первые отправляют и принимают данные
побайтно (как это, к примеру, делают последовательный и параллельный порты),
а последние работают с блоками информации размером больше 1 байта (как,

334  Глава 10



История и архитектура Unix

например, жесткие диски, сетевые адаптеры, видеокамеры и т. д.). В приведенном
выше терминале 10.6 строчки, начинающиеся с 'l', обозначают символьные ссылки
на другие устройства, а строчки с d в начале — это каталоги, которые могут содержать другие файлы устройств.
Пользовательские процессы применяют эти файлы, чтобы обращаться к соответствующему оборудованию. Обмен данными с устройством осуществляется путем
чтения и записи этих файлов.
Мы не станем углубляться в дальнейшие детали, но если хотите узнать больше об
устройствах и их драйверах, то вам стоит почитать дополнительный материал на
эту тему. В следующей главе мы более подробно обсудим системные вызовы и добавим собственный вызов в имеющееся ядро Unix.

Резюме
В этой главе мы начали разговор о системе Unix и о том, какое отношение она имеет
к C. Следы архитектуры Unix можно обнаружить даже в операционных системах
других семейств.
В ходе главы мы обсудили события, произошедшие в начале 1970-х годов, и узнали,
как из Multics возникла система Unix и язык программирования B стал прародителем C. Вслед за этим мы поговорили о многослойной архитектуре Unix, состоящей изчетырех колец: пользовательских приложений, командной оболочки, ядра
и аппаратного обеспечения.
Мы кратко прошлись по различным кольцам в этой многослойной модели, отдельно остановившись на командной оболочке. Была представлена стандартная
библиотека C и описано, как она в сочетании со стандартами POSIX и SUS позволяет программистам писать программы, которые можно собирать в разных
Unix-системах.
Во второй части этого обсуждения, в главе 11, мы продолжим рассматривать систему Unix и ее архитектуру. Вы узнаете больше о том, как устроены ядро и окружающий его интерфейс системных вызовов.

11

Системные вызовы
и ядра

В предыдущей главе мы обсудили историю системы Unix и ее многослойную архитектуру. Вы также познакомились со стандартами POSIX и SUS, которые описывают кольцо командной оболочки Unix, и увидели, как стандартная библиотека C
предоставляет часто используемые функции, доступные в Unix-совместимых ОС.
В данной главе мы продолжим обсуждать интерфейс системных вызовов и ядро
Unix. Это даст нам полную картину того, как работает система Unix.
Прочитав данную главу, вы будете способны проанализировать системные вызовы,
инициируемые программой, и объяснить, как ее процесс работает и изменяется
в среде Unix; вы также сможете использовать системные вызовы напрямую или
через libc. Вдобавок мы поговорим о разработке ядра Unix и увидим, как добавлять
в него новые системные вызовы и как их можно инициировать из кольца командной
оболочки.
В последней части главы речь пойдет о монолитных ядрах, микроядрах и их отличиях. Мы познакомимся с монолитным ядром Linux и напишем для него модуль,
который можно динамически загружать и выгружать.
Начнем с разговора о системных вызовах.

Системные вызовы
В предыдущей главе я коротко объяснил, что такое системный вызов. В этом разделе мы остановимся на данной теме более подробно и рассмотрим механизм,
с помощью которого системные вызовы передают поток выполнения от пользовательского процесса процессу ядра.
Но сначала нам следует еще немного обсудить пространства ядра и пользователя,
поскольку это поможет нам лучше понять принцип работы системных вызовов.
Мы также напишем простой системный вызов, чтобы получить общее представление о разработке ядра.

336  Глава 11



Системные вызовы и ядра

Материал, представленный ниже, будет незаменимым для тех, кто хочет научиться
писать новые системные вызовы и расширять возможности ядра. Это также позволит вам лучше понять пространство ядра и то, чем оно отличается от пользовательского пространства, поскольку в реальности они очень разнятся.

Тщательное исследование системных вызовов
Как уже упоминалось в предыдущей главе, кольца командной оболочки и ядра
разделены. Все, что находится в первых двух кольцах (пользовательских приложениях и командной оболочке), принадлежит к пространству пользователя. Точно
так же все, что мы видим в кольцах ядра и аппаратного обеспечения, принадлежит
к пространству ядра.
Это разделение следует одному правилу: содержимое двух внутренних колец, ядра
и аппаратного обеспечения не должно быть доступно напрямую из пространства
пользователя. Иными словами, никакой пользовательский процесс не должен
иметь прямого доступа к оборудованию, а также к внутренним структурам данных
и алгоритмам ядра. Обращение к ним должно происходить через системные вызовы.
Вам может показаться, будто это слегка противоречит тому, что вы уже знаете
о Unix-подобных операционных системах, таких как Linux, и вашему опыту работы с ними. Но если вы не видите никакой проблемы, то позвольте мне объяснить,
о чем речь. Нелогичность возникает, к примеру, когда программа считывает байты
из сетевого сокета, поскольку в действительности эти байты читает ядро и только
потом они копируются в пользовательское пространство, где с ними уже может
работать программа.
Чтобы прояснить ситуацию, можно рассмотреть пример того, как данные проходят
от пространства пользователя к пространству ядра и обратно. Когда вам нужно
прочитать файл с жесткого диска, вы пишете программу для кольца пользовательских приложений. Она задействует функцию ввода/вывода из libc под названием
fread (или ее аналог) и в итоге выполняется в виде процесса в пространстве пользователя. Когда программа вызывает функцию fread, срабатывает ее реализация
внутри libc.
Пока все это происходит в пользовательском процессе. В конечном счете реализация fread инициирует системный вызов, принимая три аргумента: дескриптор
уже открытого файла, адрес буфера, выделенного в памяти процесса (который
находится в пространстве пользователя) и длину данного буфера.
Когда реализация libc инициирует системный вызов, поток выполнения пользовательского процесса переходит к ядру, которое получает аргументы из пользовательского пространства и размещает их в пространстве ядра. Затем ядро читает файл,

Системные вызовы   337

обращаясь к своему модулю файловой системы (как можно видеть на рис. 10.5
в предыдущей главе).
После завершения операции read в кольце ядра прочитанные данные копируются
в буфер в пространстве пользователя, указанный во втором аргументе функции
fread; дальше поток выполнения переходит от системного вызова к пользовательскому процессу. Пока системный вызов делает свою работу, пользовательский
процесс обычно находится в ожидании. В таком случае системный вызов называют
блокирующим.
Этот сценарий имеет несколько важных аспектов.
zz Выполнением всей логики системных вызовов занимается одно ядро.
zz Если системный вызов блокирующий, то вызывающая сторона должна дождать-

ся завершения его работы. Неблокирующие системные вызовы очень быстро
возвращаются, но пользовательскому процессу приходится выполнять дополнительные действия, чтобы проверить, доступны ли результаты.
zz Аргументы вместе с входными и выходными данными копируются в пользова-

тельское пространство и из него. Ввиду этого системные вызовы должны быть
рассчитаны на прием крошечных значений и указателей в качестве входных
аргументов.
zz Ядро обладает полным привилегированным доступом ко всем ресурсам систе-

мы. Следовательно, должен существовать такой механизм, который проверяет,
может ли пользовательский процесс выполнять тот или иной системный вызов.
В нашем случае, если пользователь не является владельцем файла, то операция
fread должна завершиться ошибкой, связанной с нехваткой прав доступа.
zz Похожее разделение существует между участками памяти, выделенными для

пространств пользователя и ядра. Пользовательский процесс может обращаться
только к памяти пространства пользователя. В ходе работы некоторых системных вызовов поток выполнения передается несколько раз.
Прежде чем идти дальше, я хочу спросить вас кое о чем. Как системный вызов
передает поток выполнения ядру? Задумайтесь об этом на минуту, поскольку
в следующем подразделе мы попытаемся найти ответ.

Выполнение системного вызова напрямую,
в обход стандартной библиотеки C
Прежде чем отвечать на поставленный выше вопрос, рассмотрим пример, в котором
системный вызов инициируется напрямую, в обход стандартной библиотеки C.
Иными словами, программа выполняет системный вызов, не обращаясь к кольцу
командной оболочки. Как уже отмечалось прежде, это считается плохим подходом,

338  Глава 11



Системные вызовы и ядра

однако некоторые системные вызовы недоступны в libc и их пользовательское приложение может инициировать напрямую.
Любая Unix-система предоставляет определенный метод для прямого выполнения
системных вызовов. Например, в Linux для этого предусмотрена функция под названием syscall, размещенная в заголовочном файле .
В примере 11.1 (листинг 11.1) показана еще одна разновидность программы Hello
World, в которой для передачи текста в стандартный вывод не используется libc.
То есть не применяется функция printf, которая является частью кольца командной оболочки и стандарта POSIX. Вместо этого программа инициирует системный
вызов напрямую, из-за чего данный код совместим только с Linux, но не с другими
Unix-системами. Иными словами, данный код нельзя перенести между разными
вариациями Unix.
Листинг 11.1. Еще одна версия примера Hello World, которая выполняет системный вызов write
напрямую (ExtremeC_examples_chapter11_1.c)

// Это нужно, чтобы использовать элементы не из состава POSIX
#define _GNU_SOURCE
#include
// Это не является частью POSIX!
#include
int main(int argc, char** argv) {
char message[20] = "Hello World!\n";
// Инициирует системный вызов 'write', который записывает
// некоторые байты в стандартный вывод.
syscall(__NR_write, 1, message, 13);
return 0;
}

В начале данного листинга мы определили _GNU_SOURCE, сигнализируя тем самым,
что в нем будут использоваться возможности GNU C Library (glibc), которые
не входят в стандарты POSIX и SUS. Это нарушает переносимость программы, что
не позволит вам скомпилировать свой код в других системах Unix. Дальше идет
инструкция include, которая подключает один из уникальных заголовочных файлов glibs; эти файлы не существуют в других POSIX-системах, которые в качестве
стандартной библиотеки C используют не glibc, а нечто иное.
Внутри main с помощью функции syscall выполняется системный вызов. Прежде
всего мы должны указать целочисленный номер вызова. В Linux каждый системный вызов имеет уникальный номер.
В нашем коде вместо номера системного вызова передается константа __R_write,
числовое значение которой нам неизвестно. Если заглянуть в заголовочный файл

Системные вызовы  339
unistd.h, то можно обнаружить, что системный вызов write, по всей видимости,

имеет номер 64. Вслед за этим номером мы должны передать аргументы, необходимые системному вызову.
Несмотря на простоту представленного выше кода, вы должны понимать, что
syscall — не обычная функция. Это ассемблерная процедура, которая заполняет
подходящие регистры процессора и передает поток выполнения от пользовательского пространства пространству ядра. Мы вернемся к этому чуть позже.
Вызову write нужно передать три аргумента: дескриптор файла (1 обозначает стандартный вывод), указатель на буфер, размещенный в пространстве пользователя,
и, наконец, количество байтов, которое нужно скопировать из этого буфера.
В терминале 11.1 показан вывод примера 11.1 после его компиляции с помощью
gcc и запуска в Ubuntu 18.04.1.
Терминал 11.1. Вывод примера 11.1
$ gcc ExtremeC_examples_chapter11_1.c -o ex11_1.out
$ ./ex11_1.out
Hello World!
$

Теперь пришло время воспользоваться утилитой strace, с которой мы познакомились в предыдущей главе, чтобы посмотреть, какие системные вызовы на самом
деле инициирует пример 11.1. Вывод strace, представленный в терминале 11.2,
показывает, что наша программа выполнила нужный нам системный вызов.
Терминал 11.2. Вывод strace в ходе выполнения примера 11.1
$ strace ./ex11_1.out
execve("./ex11_1.out", ["./ex11_1.out"], 0x7ffcb94306b0 /* 22 vars */) = 0
brk(NULL)
= 0x55ebc30fb000
access("/etc/ld.so.nohwcap", F_OK)
= -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK)
= -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
...
...
arch_prctl(ARCH_SET_FS, 0x7f24aa5624c0) = 0
mprotect(0x7f24aa339000, 16384, PROT_READ) = 0
mprotect(0x55ebc1e04000, 4096, PROT_READ) = 0
mprotect(0x7f24aa56a000, 4096, PROT_READ) = 0
munmap(0x7f24aa563000, 26144)
= 0
write(1, "Hello World!\n", 13Hello World!)
= 13
exit_group(0)
= ?
+++ exited with 0 +++
$

340  Глава 11



Системные вызовы и ядра

Здесь полужирным шрифтом выделено то место, где утилита strace записала
системный вызов. Взгляните на возвращаемое значение (13). Оно означает, что
системный вызов успешно записал 13 байт в заданный файл (в нашем случае это
стандартный вывод).
Пользовательское приложение никогда не должно пытаться инициировать системные вызовы напрямую. Обычно перед системным вызовом
и после него необходимо выполнять определенные действия, которые
реализованы в libc. Если пойти в обход стандартной библиотеки, то
указанные действия придется выполнять самостоятельно; при этом они
могут отличаться в зависимости от вашей Unix-системы.

Внутри функции syscall
Что же происходит внутри функции syscall? Обратите внимание: все, что будет
сказано ниже, относится только к glibc, но не к другим реализациям libc. Определение syscall находится по ссылке https://github.com/lattera/glibc/blob/master/sysdeps/
unix/sysv/linux/x86_64/syscall.S.
Если открыть эту ссылку в браузере, то можно увидеть, что данная функция написана на ассемблере.
Ассемблер можно использовать в исходных файлах языка C совместно
с инструкциями последнего. На самом деле это одна из превосходных
особенностей языка C, которая позволяет применять его для написания
операционной системы. Что касается функции syscall, то ее объявление
написано на C, а определение — на ассемблере.

В листинге 11.2 представлена часть исходного кода в файле syscall.S.
Листинг 11.2. Определение функции syscall в glibc

/* Copyright (C) 2001-2018 Free Software Foundation, Inc.
This file is part of the GNU C Library.
...
. */
#include
/* Please consult the file sysdeps/unix/sysv/linux/x86-64/sysdep.h
For more information about the value -4095 used below. */
/* Usage: long syscall (syscall_number, arg1, arg2, arg3, arg4, arg5, arg6)
We need to do some arg shifting, the syscall_number will be in rax. */
.text
ENTRY (syscall)

Системные вызовы  341
movq %rdi, %rax
movq %rsi, %rdi
movq %rdx, %rsi
movq %rcx, %rdx
movq %r8, %r10
movq %r9, %r8
movq 8(%rsp),%r9
syscall
cmpq $-4095, %rax
jae SYSCALL_ERROR_LABEL
ret

/* Syscall number -> rax. */
/* shift arg1 - arg5. */

/*
/*
/*
/*
/*

arg6 is on the stack. */
Do the system call. */
Check %rax for error. */
Jump to error handler if error.*/
Return to caller. */

PSEUDO_END (syscall)

Этот метод выполнения системного вызова более сложен, но сами инструкции выглядят лаконично и просто. В комментариях объясняется, что каждому системному
вызову в glibc можно предоставить до шести аргументов.
Это значит, glibc не позволяет использовать определенные возможности ядер,
которые поддерживают системные вызовы с более чем шестью аргументами и добавление такой поддержки потребует изменения данной библиотеки. К счастью,
шести аргументов достаточно в большинстве случаев; если системному вызову
этого мало, то мы можем передать ему указатели на структурные переменные, выделенные в памяти пользовательского пространства.
В листинге выше вслед за инструкцией movq ассемблерный код вызывает процедуру syscall. Он просто генерирует прерывание, которое активирует определенный
участок ядра, предназначенный для его обработки.
Как можно видеть в первой строчке процедуры syscall, номер системного вызова
заносится в регистр %rax. В следующих строчках мы копируем в регистры другие
аргументы. Когда генерируется прерывание, его обработчик в ядре подхватывает
системный вызов вместе с номером и аргументами. Затем он обращается к таблице
системных вызовов, чтобы найти подходящую функцию, которая должна быть выполнена на стороне ядра.
Интересно, что к моменту выполнения обработчика прерывания пользовательский
код, который инициировал системный вызов, уже покинул центральный процессор,
уступив место ядру. Это основной механизм, лежащий в основе системных вызовов. Когда такой вызов инициируется, процессор переключается в другой режим
и принимает инструкции от ядра, тогда как приложение в пользовательском пространстве перестает выполняться. Вот почему мы говорим, что ядро выполняет
логику системного вызова от имени пользовательского приложения.
В следующем подразделе я продемонстрирую сказанное выше на примере написания системного вызова, который выводит приветственное сообщение. Это можно
считать модифицированной версией примера 11.1, которая принимает и возвращает строку с приветствием.

342  Глава 11



Системные вызовы и ядра

Добавление системного вызова в Linux
Здесь мы добавим новую запись в таблицу системных вызовов имеющегося Unixподобного ядра. Для многих читателей это может стать первой возможностью
написания кода на C, предназначенного для выполнения в пространстве ядра.
Все примеры, которые мы разбирали в предыдущих главах, и почти весь код, приводимый на оставшихся страницах, работает в пользовательском пространстве.
На самом деле большинство написанных нами программ предназначены для
выполнения в пространстве пользователя. Это то, что мы называем программированием или разработкой на C. Для написания кода, рассчитанного на работу
в пространстве ядра, есть другое название: разработка ядра.
Прежде чем переходить к примеру 11.2, необходимо исследовать среду ядра и понять, чем оно отличается от пользовательского пространства.

Разработка ядра
Этот участок текста будет полезен тем из вас, кто планирует стать разработчиком
ядра или исследователем безопасности в области операционных систем. Прежде
чем переходить к самому системному вызову, мы объясним разницу между разработкой ядра и обычных программ на языке C.
Разработка ядер имеет ряд отличий от того, как разрабатываются обычные программы на C. И прежде, чем рассматривать эти отличия, следует отметить, что код
на C обычно пишется для пространства пользователя.
Ниже перечислены шесть ключевых отличий в процессе разработки для ядра и для
пользовательского пространства.
zz Процесс ядра находится во главе системы и существует в единственном эк-

земпляре. Это просто означает, что если ваш код приведет к сбою в ядре, то
вам, скорее всего, придется перезагрузить компьютер и позволить ядру заново
выполнить инициализацию. Следовательно, цена ошибки в таком коде очень
высока, и для проведения экспериментов в нем требуется перезагрузка компьютера; в программах пользовательского пространства без этого можно легко
обойтись. В случае ошибки генерируется дамп сбоя ядра, который помогает
выявить причину.
zz В кольце ядра нет никакой стандартной библиотеки C наподобие glibc! Иными

словами, стандарты SUS и POSIX здесь не действуют. Поэтому вы не можете
подключать заголовочные файлы libc, такие как stdio.h или string.h. Вы получаете в свое распоряжение отдельный набор функций, которые необходимо
использовать для выполнения различных операций. Эти функции обычно
находятся в заголовках ядра и могут отличаться в зависимости от разновид-

Системные вызовы  343

ности Unix, поскольку в данной области еще не проводилось никакой стандартизации.
Например, занимаясь разработкой ядра в Linux, вы можете выполнять запись
в буфер сообщений ядра с помощью функции printk. Но в FreeBSD для этого
предусмотрено семейство функций printf, которые отличаются от своих аналогов из libc; они находятся в заголовочном файле . Их эквивалент
в ядре XNU, которое используется в macOS, — функция os_log.
zz В ядре тоже можно читать и изменять файлы, но без использования функций

из libc. У каждой Unix-системы есть собственный способ доступа к файлам из
кольца ядра. То же самое относится ко всем другим возможностям, доступным
в libc.
zz В кольце ядра вы имеете полный доступ к физической памяти и многим другим

возможностям. Поэтому очень важно писать безопасный и надежный код.
zz В ядре нет механизма системных вызовов. Системные вызовы — основной метод

взаимодействия с кольцом ядра из пользовательского пространства. Поэтому
в самом ядре в нем нет необходимости.
zz Процесс ядра создается путем копирования образа ядра в физическую память.

Этим занимается загрузчик. Вы не можете добавить новые системные вызовы,
не прибегнув к созданию совершенно нового образа и инициализации его в ходе
перезагрузки системы. Кое-какие ядра поддерживают модули, которые можно
динамически добавлять и удалять, но с системными вызовами этого делать
нельзя.
Все это показывает, что процесс разработки ядра отличается от обычной разработки
на языке C. Тестирование написанной логики затруднено, и некачественный код
может вывести из строя всю систему.
Далее мы попробуем свои силы в разработке ядра на примере создания нового
системного вызова. Мы это делаем вовсе не потому, что добавление системных
вызовов — распространенный способ расширения возможностей ядра; это просто
попытка познакомиться с данным видом разработки.

Написание демонстрационного
системного вызова для Linux
Здесь мы напишем новый системный вызов для Linux. В Интернете есть много
материала на эту тему, но мы возьмем за основу статью Adding a Hello World System
Call to Linux Kernel, доступную по адресу https://medium.com/anubhav-shrimal/adding-ahello-world-system-call-to-linux-kernel-dad32875872.
Пример 11.2 — углубленная версия примера 11.1; в ней используется другой, нестандартный системный вызов, который мы напишем здесь. Наш новый системный

344  Глава 11



Системные вызовы и ядра

вызов принимает четыре аргумента: первые два служат для ввода имени, а два последних — для получения выходной строки. Имя состоит из двух аргументов, один
из которых является указателем на буфер, заранее выделенный в пользовательском
пространстве, а второй обозначает длину этого буфера. Строка с приветствием возвращается с помощью двух других аргументов: указателя на другой буфер, который
тоже находится в пространстве пользователя, и его длины в виде целого числа.

ОСТОРОЖНО
Пожалуйста, не проводите этот эксперимент в системе Linux, которую
вы используете на домашнем или рабочем компьютере. Выполняйте
эти команды на отдельном экспериментальном устройстве, в качестве
которого я настоятельно рекомендую использовать виртуальную машину. Вы можете легко создавать виртуальные машины с помощью таких
эмуляторов, как VirtualBox или VMware.
Следующие инструкции, будучи примененными неправильно или не в том
порядке, могут теоретически повредить вашу систему и привести к частичной или полной потере данных. Если вы собираетесь выполнять их
не на экспериментальном компьютере, то не забудьте воспользоваться
каким-нибудь средством резервного копирования, чтобы сохранить
свои данные.

Прежде всего мы должны загрузить самый свежий исходный код ядра Linux.
Мы клонируем его из репозитория на GitHub и укажем нужный нам выпуск.
Версия 5.3 была выпущена 15 сентября 2019 года, и в этом примере мы будем использовать именно ее.
Linux — это ядро. То есть оно может быть установлено только в кольце
ядра Unix-подобной операционной системы. А вот дистрибутив Linux —
другое дело. Он содержит определенные версии ядра Linux, библиотеки
GNU libc и командной оболочки Bash (или GNU Shell).
Дистрибутивы Linux обычно поставляются с набором пользовательских
приложений во внешних кольцах, поэтому их можно считать полноценными операционными системами. Обратите внимание: дистрибутив,
«дистр» и разновидность Linux — одно и то же.

В этом примере я буду использовать дистрибутив Linux Ubuntu 18.04.1 на 64-битном компьютере.
Прежде чем мы начнем, очень важно убедиться в том, что у нас установлены и готовы к работе необходимые пакеты. Выполните для этого команды, показанные
в терминале 11.3.

Системные вызовы   345
Терминал 11.3. Установка пакетов, необходимых для примера 11.2
$ sudo apt-get update
$ sudo apt-get install -y build-essential autoconf libncurses5-dev
libssl-dev bison flex libelf-dev git
...
...
$

Несколько замечаний по поводу этих инструкций: apt — основной диспетчер
пакетов в дистрибутивах Linux, основанных на Debian, а sudo — утилита, которая
используется для запуска команд от имени администратора. Они доступны почти
во всех Unix-подобных операционных системах.
Следующими шагами будут клонирование репозитория Linux на GitHub и переход
к версии 5.3. Как показано на примере следующих команд (терминал 11.4), версию
можно выбрать по тегу выпуска.
Терминал 11.4. Клонирование ядра Linux и переход к версии 5.3
$ git clone https://github.com/torvalds/linux
$ cd linux
$ git checkout v5.3
$

Теперь, взглянув на корневой каталог, можно увидеть множество файлов и каталогов, которые вместе составляют кодовую базу ядра Linux (терминал 11.5).
Терминал 11.5. Содержимое кодовой базы ядра Linux
$ ls
total 760K
drwxrwxr-x 33 kamran kamran 4.0K Jan 28 2018 arch
drwxrwxr-x
3 kamran kamran 4.0K Oct 16 22:11 block
drwxrwxr-x
2 kamran kamran 4.0K Oct 16 22:11 certs
...
drwxrwxr-x 125 kamran kamran 12K Oct 16 22:11 Documentation
drwxrwxr-x 132 kamran kamran 4.0K Oct 16 22:11 drivers
-rw-rw-r-1 kamran kamran 3.4K Oct 16 22:11 dropped.txt
drwxrwxr-x
2 kamran kamran 4.0K Jan 28 2018 firmare
drwxrwxr-x 75 kamraln kamran 4.0K Oct 16 22:11 fs
drwxrwxr-x 27 kamran kamran 4.0K Jan 28 2018 include
...
-rw-rw-r-1 kamran kamran 287 Jan 28 2018 Kconfig
drwxrwxr-x 17 kamran kamran 4.0K Oct 16 22:11 kernel
drwxrwxr-x 13 kamran Kamran 12K Oct 16 22:11 lib
-rw-rw-r-1 kamran kamran 429K Oct 16 22:11 MAINTAINERS
-rw-rw-r-1 kamran kamran 61K Oct 16 22:11 Makefile
drwxrwxr-x
3 kamran kamran 4.0K Oct 16 22:11 mm

346  Глава 11
drwxrwxr-x
-rw-rw-r-drwxrwxr-x
drwxrwxr-x
...
drwxrwxr-x
drwxrwxr-x
$

69
1
28
14



Системные вызовы и ядра

kamran
kamran
kamran
kamran

kamran 4.0K Jan 28 2018
kamran 722 Jan 28 2018
kamran 4.0K Jan 28 2018
kamran 4.0K Oct 16 22:11

net
README
samples
scripts

4 kamran kamran 4.0K Jan 28 2018 virt
5 kamran kamran 4.0K Oct 16 22:11 zfs

Некоторые из этих каталогов могут показаться знакомыми: fs, mm, net, arch и т. д.
Мы не станем подробно рассматривать каждый из них, поскольку они могут существенно отличаться в зависимости от ядра; смысл в том, что большинство ядер
имеют одну и ту же внутреннюю структуру.
Итак, получив исходники ядра, мы можем приступить к созданию нашего нового
системного вызова. Но сначала нужно выбрать для него уникальный числовой
идентификатор; в данном случае я назову его hello_world и назначу ему номер 999.
Первым делом необходимо добавить объявление функции системного вызова
в конец заголовочного файла include/linux/syscalls.h. После внесения этого
изменения файл должен выглядеть так (листинг 11.3).
Листинг 11.3. Объявление нового системного вызова Hello World (include/linux/syscalls.h)

/*
* syscalls.h - Linux syscall interfaces (non-arch-specific)
*
* Copyright (c) 2004 Randy Dunlap
* Copyright (c) 2004 Open Source Development Labs
*
* This file is released under the GPLv2.
* See the file COPYING for more details.
*/
#ifndef _LINUX_SYSCALLS_H
#define _LINUX_SYSCALLS_H
struct epoll_event;
struct iattr;
struct inode;
...
asmlinkage long sys_statx(int dfd, const char __user *path, unsigned flags,
unsigned mask, struct statx __user *buffer);
asmlinkage long sys_hello_world(const char __user *str,
const size_t str_len,
char __user *buf,
size_t buf_len);
#endif

Системные вызовы   347

Согласно описанию, которое приводится вверху, это заголовочный файл с интерфейсами syscall, не зависящими от архитектуры. Это значит, Linux предоставляет
один и тот же набор системных вызовов на всех архитектурах.
В конце файла мы объявили функцию нашего системного вызова, которая принимает четыре аргумента. Как уже объяснялось ранее, эти две пары аргументов
отводятся для содержимого и длины входной и выходной строк соответственно.
Обратите внимание: входные аргументы обозначены как const, а выходные — нет.
Кроме того, идентификатор __user означает, что указатели ссылаются на адреса
в пользовательском пространстве. Как видите, каждый системный вызов имеет
целочисленное значение, которое они возвращают в рамках сигнатуры функции
и которое будет служить результатом ее выполнения. Диапазон возвращаемых
значений и их смысл варьируются от одного системного вызова к другому. В нашем
системном вызове 0 означает успех, а любое другое число — провал.
Теперь нам нужно определить наш системный вызов. Для этого необходимо сначала создать папку с именем hello_world в корневом каталоге, используя команды,
показанные в терминале 11.6.
Терминал 11.6. Создание каталога hello_world
$ mkdir hello_world
$ cd hello_world
$

Затем внутри hello_world нужно создать файл с именем sys_hello_world.c .
Он должен иметь следующее содержимое (листинг 11.4).
Листинг 11.4. Определение системного вызова Hello World

#include
#include
#include
#include
#include







//
//
//
//
//

Для
Для
Для
Для
Для

printk
strcpy, strcat, strlen
kmalloc, kfree
copy_from_user, copy_to_user
SYSCALL_DEFINE4

// Определение системного вызова
SYSCALL_DEFINE4(hello_world,
const char __user *, str,
// Вводимое имя
const unsigned int, str_len, // Длина вводимого имени
char __user *, buf,
// Выходной буфер
unsigned int, buf_len) {
// Длина выходного буфера
// Переменная в стеке ядра должна хранить содержимое
// входного буфера
char name[64];
// Переменная в стеке ядра должна хранить итоговое
// выходное сообщение.

348  Глава 11



Системные вызовы и ядра

char message[96];
printk("System call fired!\n");
if (str_len >= 64) {
printk("Too long input string.\n");
return -1;
}
// Копируем данные из пространства ядра в пространство пользователя
if (copy_from_user(name, str, str_len)) {
printk("Copy from user space failed.\n");
return -2;
}
// Формируем итоговое сообщение
strcpy(message, "Hello ");
strcat(message, name);
strcat(message, "!");
// Проверяем, помещается ли итоговое сообщение в выходной двоичный буфер
if (strlen(message) >= (buf_len - 1)) {
printk("Too small output buffer.\n");
return -3;
}
// Копируем сообщение обратно из пространства ядра
// в пространство пользователя
if (copy_to_user(buf, message, strlen(message) + 1)) {
printk("Copy to user space failed.\n");
return -4;
}
// Записываем отправленное сообщение в журнал ядра
printk("Message: %s\n", message);
return 0;
}

В этом листинге мы использовали макрос SYSCALL_DEFINE4 для определения нашей
функции. Суффикс DEFINE4 означает, что она принимает четыре аргумента.
Тело функции начинается с объявления двух символьных массивов на вершине
стека ядра. У процесса ядра, как и у обычного процесса, есть адресное пространство со стеком. Дальше мы копируем данные из пользовательского пространства
в пространство ядра и создаем приветственное сообщение, объединяя несколько
строк. Полученная строка все еще находится в памяти ядра. В конце мы копируем
сообщение обратно в пространство пользователя и делаем его доступным вызывающему процессу.
В случае ошибки возвращается соответствующий номер, чтобы вызывающий процесс знал о результате выполнения системного вызова.

Системные вызовы  349

Следующим шагом будет обновление еще одной таблицы. В архитектурах x86 и x64
предусмотрена лишь одна таблица для системных вызовов; мы должны добавить
в нее наш новый системный вызов, чтобы сделать его доступным.
Только после этого системный вызов можно будет использовать на компьютерах
с архитектурами x86 и x64. Итак, мы должны добавить в таблицу системный вызов
hello_word и имя его функции, sys_hello_world.
Откройте для этого файл arch/x86/entry/syscalls/syscall_64.tbl и добавьте в его
конец следующую строчку (листинг 11.5).
Листинг 11.5. Добавление нового системного вызова Hello World в таблицу системных вызовов

999

64

hello_world

__x64_sys_hello_world

После внесения этого изменения файл должен выглядеть так (терминал 11.7).
Терминал 11.7. Системный вызов Hello World был добавлен в таблицу системных вызовов
$ cat arch/x86/entry/syscalls/syscall_64.tbl
...
...
546
x32
preadv2
__x32_compat_sys_preadv64v2
547
x32
pwritev2
__x32_compat_sys_pwritev64v2
999
64
hello_world
__x64_sys_hello_world
$

Обратите внимание на префикс __x64_ в имени системного вызова. Он говорит
о том, что системный вызов доступен только в системах с архитектурой x64.
Для компиляции всех исходных файлов и создания итогового образа ядра Linux используется система сборки Make. Вы должны создать файл Makefile в каталоге hello_
world. Этот файл должен содержать одну строчку следующего вида (листинг 11.6).
Листинг 11.6. Файл Makefile для системного вызова Hello World

obj-y := sys_hello_world.o

Затем вам нужно добавить каталог hello_world в главный файл Makefile, который
находится в корневом каталоге. Перейдите в корневой каталог ядра, откройте
Makefile и найдите следующую строчку (листинг 11.7).
Листинг 11.7. Строчка, которую необходимо изменить в корневом файле Makefile

core-y += kernel/certs/mm/fs/ipc/security/crypto/block/

Добавьте в данный список hello_world/. Это всего лишь перечень каталогов, которые должны собираться вместе с ядром.

350   Глава 11



Системные вызовы и ядра

Нам нужно добавить каталог системного вызова Hello World, чтобы он был включен в процесс сборки и стал частью итогового образа ядра. После редактирования
эта строчка должна выглядеть следующим образом (листинг 11.8).
Листинг 11.8. Строчка после редактирования

core-y += kernel/certs/mm/fs/hello_world/ipc/security/crypto/block/

Следующим шагом будет сборка ядра.

Сборка ядра
Чтобы собрать ядро, нам нужно сначала вернуться в его корневой каталог и предоставить конфигурацию со списком возможностей и модулей, которые будут скомпилированы в процессе сборки.
Команда, показанная в терминале 11.8, пытается сгенерировать нужную нам конфигурацию на основе включенной в текущее ядро Linux. Она берет имеющуюся конфигурацию и при наличии в нашем ядре, которое мы пытаемся собрать, новых значений
просит вас их подтвердить. Для подтверждения достаточно нажать клавишу Enter.
Терминал 11.8. Создание новой конфигурации на основе текущего активного ядра
$ make localmodconfig
...
...
#
# configuration written to .config
#
$

Теперь можно начать процесс сборки (терминал 11.9). Поскольку ядро Linux содержит много исходных файлов, сборка может занять несколько часов. Поэтому
компиляцию нужно выполнять параллельно.
Если вы используете виртуальную машину, то, пожалуйста, сделайте ее процессор
многоядерным. Это существенно ускорит процесс сборки.
Терминал 11.9. Вывод процесса сборки ядра. Обратите внимание на строчку, относящуюся
к компиляции системного вызова Hello World
$ make -j4
SYSHDR arch/x86/include/generated/asm/unistd_32_ia32.h
SYSTBL arch/x86/include/generated/asm/syscalls_32.h
HOSTCC scripts/basic/bin2c
SYSHDR arch/x86/include/generated/asm/unistd_64_x32.h
...

Системные вызовы   351
...
UPD
CC
CC
CC
...
...
LD [M]
LD [M]
LD [M]
LD [M]
LD [M]
LD [M]
LD [M]
LD [M]
LD [M]
LD [M]
$

include/generated/compile.h
init/main.o
hello_world/sys_hello_world.o
arch/x86/crypto/crc32c-intel_glue.o

net/netfilter/x_tables.ko
net/netfilter/xt_tcpudp.ko
net/sched/sch_fq_codel.ko
sound/ac97_bus.ko
sound/core/snd-pcm.ko
sound/core/snd.ko
sound/core/snd-timer.ko
sound/pci/ac97/snd-ac97-codec.ko
sound/pci/snd-intel8x0.ko
sound/soundcore.ko

Не забудьте установить все необходимые пакеты, представленные в первой части этого раздела, иначе получите ошибки компиляции.

Как видите, начался процесс сборки, состоящий из четырех заданий, которые пытаются скомпилировать файлы C в параллель. Дождитесь завершения этого процесса. После этого вы сможете легко установить свое новое ядро и перезагрузить
компьютер (терминал 11.10).
Терминал 11.10. Создание и установка нового образа ядра
$ sudo make modules_install install
INSTALL arch/x86/crypto/aes-x86_64.ko
INSTALL arch/x86/crypto/aesni-intel.ko
INSTALL arch/x86/crypto/crc32-pclmul.ko
INSTALL arch/x86/crypto/crct10dif-pclmul.ko
...
...
run-parts: executing /et/knel/postinst.d/initam-tools 5.3.0+ /
boot/vmlinuz-5.3.0+
update-iniras: Generating /boot/initrd.img-5.3.0+
run-parts: executing /etc/keneostinst.d/unattende-urades 5.3.0+ /
boot/vmlinuz-5.3.0+
...
...
Found initrd image: /boot/initrd.img-4.15.0-36-generic
Found linux image: /boot/vmlinuz-4.15.0-29-generic
Found initrd image: /boot/initrd.img-4.15.0-29-generic
done.
$

352   Глава 11



Системные вызовы и ядра

Новый образ ядра версии 5.3.0 был создан и установлен. Теперь мы можем перезагрузить компьютер. Но прежде не забудьте проверить версию текущего ядра, если
не знаете ее. В моем случае это 4.15.0-36-generic. Для проверки я использовал
команду, показанную в терминале 11.11.
Терминал 11.11. Проверка версии текущего ядра
$ uname -r
4.15.0-36-generic
$

Теперь перезагрузим систему (терминал 11.12).
Терминал 11.12. Перезагрузка системы
$ sudo reboot

В ходе загрузки системы будет выбрано и задействовано новое ядро. Стоит отметить, что загрузчик не подхватит старые ядра; следовательно, если у вас было
ядро версии 5.3 и выше, то вам придется загрузить собранный вами образ вручную.
В этом вам может помочь следующая ссылка: https://askubuntu.com/questions/82140/
how-can-i-boot-with-an-older-kernel-version.
Когда операционная система завершит загрузку, у вас должно быть новое ядро.
В этом можно убедиться следующим образом (терминал 11.13).
Терминал 11.13. Проверка версии ядра после перезагрузки
$ uname -r
5.3.0+
$

Если все прошло как следует, то у вас должно быть загружено новое ядро. Теперь
мы можем вернуться к написанию программы на C, которая использует наш только
что добавленный системный вызов Hello World. Она будет очень похожа на пример 11.1, в котором фигурировал системный вызов write. В листинге 11.9 показан
код примера 11.2.
Листинг 11.9. Пример 11.2, инициирующий только что добавленный системный вызов Hello World
(ExtremeC_examples_chapter11_2.c)

// Это нужно для использования элементов, не входящих в состав POSIX
#define _GNU_SOURCE
#include
#include

Системные вызовы   353
// Это не является частью POSIX!
#include
int main(int argc, char** argv) {
char str[20] = "Kam";
char message[64] = "";
// Инициируем системный вызов hello world
int ret_val = syscall(999, str, 4, message, 64);
if (ret_val < 0) {
printf("[ERR] Ret val: %d\n", ret_val);
return 1;
}
printf("Message: %s\n", message);
return 0;
}

Как видите, мы инициировали системный вызов под номер 999. Мы подали на вход
строку Kam и ожидаем получить в качестве приветствия Hello Kam!. Программа ожидает получения результата и выводит буфер с сообщением, записанный системным
вызовом в пространстве ядра.
В терминале 11.14 показано, как собрать и запустить наш пример.
Терминал 11.14. Компиляция и запуск примера 11.2
$ gcc ExtremeC_examples_chapter11_2.c -o ex11_2.out
$ ./ex11_2.out
Message: Hello Kam!
$

Если выполнить этот пример и заглянуть в журнал ядра с помощью команды
dmesg, то можно увидеть следующие записи, сгенерированные инструкцией printk
(терминал 11.15).
Терминал 11.15. Использование команды dmesg для просмотра журнальных записей,
сгенерированных системным вызовом Hello World
$ dmesg
...
...
[ 112.273783] System call fired!
[ 112.273786] Message: Hello Kam!
$

Если запустить пример 11.2 с помощью strace, то можно заметить, что он на самом деле использует вызов 999 (терминал 11.16). Это видно в строчке, которая

354   Глава 11



Системные вызовы и ядра

начинается с syscall_0x3e7(...). Стоит отметить, что 0x3e7 — это 999 в шестнадцатеричной системе.
Терминал 11.16. Мониторинг системных вызовов, инициированных примером 11.2
$ strace ./ex11_2.out
...
...
mprotect(0x557266020000, 4096, PROT_READ) = 0
mprotect(0x7f8dd6d2d000, 4096, PROT_READ) = 0
munmap(0x7f8dd6d26000, 27048)
= 0
syscall_0x3e7(0x7fffe7d2af30, 0x4, 0x7fffe7d2af50, 0x40,
0x7f8dd6b01d80, 0x7fffe7d2b088) = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
brk(NULL)
= 0x5572674f2000
brk(0x557267513000)
...
...
exit_group(0)
= ?
+++ exited with 0 +++
$

Как видите, был выполнен вызов syscall_0x3e7 , который вернул 0 . Если отредактировать пример 11.2 так, чтобы передаваемое имя состояло из более чем
64 байт, то получится ошибка. Изменим наш пример и запустим его еще раз (листинг 11.10).
Листинг 11.10. Передача длинного сообщения (больше 64 байт) нашему системному вызову
Hello World

int main(int argc, char** argv) {
char name[84] = "A very very long message! It is really hard to
produce a big string!";
char message[64] = "";
...
return 0;
}

Снова скомпилируем и запустим пример (терминал 11.17).
Терминал 11.17. Компиляция и запуск отредактированного примера 11.2
$ gcc ExtremeC_examples_chapter11_2.c -o ex11_2.out
$ ./ex11_2.out
[ERR] Ret val: -1
$

Руководствуясь написанной нами логикой, системный вызов возвращает -1. Это
также подтверждает выполнение примера с помощью strace (терминал 11.18).

Ядра Unix   355
Терминал 11.18. Мониторинг системных вызовов, инициированных отредактированным
примером 11.2
$ strace ./ex11_2.out
...
...
munmap(0x7f1a900a5000, 27048) = 0
syscall_0x3e7(0x7ffdf74e10f0, 0x54, 0x7ffdf74e1110, 0x40,
0x7f1a8fe80d80, 0x7ffdf74e1248) = -1 (errno 1)
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
brk(NULL)
= 0x5646802e2000
...
...
exit_group(1)
= ?
+++ exited with 1 +++
$

В следующем разделе мы поговорим о разных подходах к проектированию ядер.
В ходе этого обсуждения мы познакомимся с модулями ядра и увидим, как они
используются в процессе разработки.

Ядра Unix
В этом разделе речь пойдет об архитектурах, которые были разработаны в ядрах
Unix за последние 30 лет. Но прежде, чем обсуждать разные виды ядер, которых,
к слову, не так уж и много, следует отметить, что процесс проектирования ядра
никак не стандартизован.
Представленные здесь рекомендации опираются на наш многолетний опыт. На их
основе была сформирована общая схема внутренних компонентов ядра Unix, один
из вариантов которой был показан на рис. 10.5 в предыдущей главе. Таким образом,
каждое ядро по-своему уникально. Но все они имеют общую черту: предоставляют
доступ к своим возможностям через интерфейс системных вызовов. Однако каждое
ядро имеет собственное ви' дение того, как нужно обрабатывать эти вызовы.
Такое разнообразие и споры вокруг него стали одной из самых горячих тем в области компьютерных архитектур в 1990-х годах. В этих обсуждениях принимали
участие большие группы людей. Самым знаменитым примером считаются дебаты
между Таненбаумом и Торвальдсом.
Мы не станем углубляться в суть этих дискуссий, но затронем два основных направления в проектировании ядер Unix: монолит и микроядро. Существуют и другие архитектуры, такие как гибридные ядра, наноядра и экзоядра, и каждая из них
имеет определенное назначение.
Но мы сосредоточимся на монолитных ядрах и микроядрах. Проведем их сравнение, чтобы получше познакомиться с их характеристиками.

356   Глава 11



Системные вызовы и ядра

Монолитные ядра и микроядра
В предыдущей главе, посвященной архитектуре Unix, мы определили ядро как
единый процесс, состоящий из множества компонентов. На самом деле это определение относится к монолитному ядру.
Монолитное ядро представляет собой один процесс с единым адресным пространством, содержащий более мелкие компоненты. Микроядра используют противоположный подход. Микроядро — минимальный процесс, из которого в пользовательское пространство вынесены такие компоненты, как файловая система, драйверы
устройств и управление процессами; благодаря этому процесс ядра получается
более компактным.
Обе архитектуры имеют свои преимущества и недостатки, благодаря чему стали
темой одного из самых знаменитых обсуждений в истории операционных систем.
Все началось еще в 1992 году, сразу после выпуска первой версии Linux. Начало
дискуссии положил Эндрю С. Таненбаум публикацией в сети Usenet. Более по­
дробно об этом можно почитать на странице «Википедии» https://ru.wikipedia.org/
wiki/Спор_Таненбаума_—_Торвальдса.
Публикация породила непримиримое противостояние между Таненбаумом, создателем Linux, Линусом Торвальдсом, и многими другими энтузиастами, которые
позже стали одними из первых разработчиков Linux. Споры велись о природе монолитных ядер и микроядер. Они касались таких тем, как архитектура ядра и влияние
на нее аппаратной платформы.
Споры были продолжительными и сложными, поэтому мы не станем углубляться
в них. Сравним оба подхода, чтобы вы получили представление о преимуществах
и недостатках каждого из них.
Ниже перечислены различия между монолитными ядрами и микроядрами.
zz Монолитное ядро представляет собой единый процесс, содержащий все возмож-

ности, предоставляемые этим ядром. Так разрабатывалось большинство ранних
ядер Unix, и это считается старым подходом. У микроядер все иначе: каждую
возможность они предоставляют в виде отдельного процесса.
zz Процесс монолитного ядра находится в пространстве ядра, тогда как в микроядре

серверные процессы обычно вынесены в пространство пользователя. Сервер­
ными называют процессы, которые предоставляют доступ к компонентам ядра,
таким как диспетчер памяти, файловая система и т. д. Микроядра отличаются
тем, что позволяют размещать серверные процессы в пользовательском пространстве. Это значит, одни операционные системы больше похожи на микроядро, чем другие.

Ядра Unix   357
zz Монолитные ядра, как правило, быстрее. Это объясняется тем, что все их функ-

ции выполняются внутри одного процесса. Микроядрам же нужно организовывать обмен сообщениями между пространствами пользователя и ядра, что приводит к большему количеству системных вызовов и переключений контекста.
zz В монолитной архитектуре все драйверы устройств загружаются прямо в ядро.

То есть в рамках ядра может выполняться код, написанныйсторонними поставщиками. Любой изъян в драйвере устройства или любом другом компоненте
ядра может привести к системному сбою. С микроядрами все иначе, поскольку
все драйверы устройств и многие другие компоненты выполняются в пространстве пользователя. Можно предположить, что это одна из причин, почему
монолитные ядра не применяются в критически важных проектах.
zz Для взлома монолитного ядра и, следовательно, всей системы достаточно

внедрить в него небольшой фрагмент вредоносного кода. С микроядром так
сделать не получится, поскольку многие серверные процессы вынесены в пользовательское пространство, а в пространстве ядра остается минимальный набор
важнейших функций.
zz В монолитной архитектуре малейшее изменение исходного кода требует пере-

компиляции всего ядра и создания его нового образа. А для загрузки этого
образа необходимо перезагрузить компьютер. Изменения в микроядре могут
потребовать перекомпиляции лишь отдельного серверного процесса, без перезагрузки всей системы. В монолитных ядрах похожего эффекта можно достичь
с помощью модулей.
Один из самых известных примеров микроядра — MINIX. Этот проект, написанный Эндрю С. Таненбаумом, изначально создавался в образовательных целях.
В 1991 году Линус Торвальдс использовал MINIX в качестве среды для разработки
собственного ядра в микропроцессоре 80386.
На протяжении уже почти 30 лет Линус является самым известным и успешным
сторонником монолитных ядер, поэтому в следующем разделе речь пойдет о Linux.

Linux
Вы уже познакомились с ядром Linux в предыдущем разделе, в котором мы разрабатывали новый системный вызов. Здесь же мы сделаем акцент на монолитности
Linux и на том факте, что в процессе ядра находятся все его возможности.
Однако должен существовать способ добавить в ядро новую функциональность,
не прибегая к его перекомпиляции. Этого нельзя достичь за счет новых системных
вызовов, поскольку, как вы уже видели, для их добавления нужно отредактировать
много важных файлов, а это означает компиляцию ядра заново.

358   Глава 11



Системные вызовы и ядра

Но есть другой подход. Он предусматривает написание и динамическое подключение к ядру модулей. Именно о модулях пойдет речь в следующем подразделе,
а затем мы попробуем написать собственный модуль для Linux.

Модули ядра
Монолитные ядра обычно предусматривают механизм, который позволяет динамически подключать к активному ядру новые компоненты. Эти подключаемые
компоненты называются модулями ядра.
В отличие от серверных процессов в микроядрах, которые фактически являются
отдельными процессами, взаимодействующими между собой по IPC, модули представляют собой объектные файлы ядра, скомпилированные и готовые к загрузке
в его процесс. Эти файлы можно собрать статически и сделать их частью образа
ядра; но вы также можете загружать их динамически, когда ядро уже работает.
Обратите внимание: по своей сути объектные файлы ядра аналогичны объектным
файлам, которые создаются в процессе разработки на языке C.
Стоит еще раз подчеркнуть, что плохое поведение модуля может привести к сбою
всего ядра.
Взаимодействие с модулями ядра происходит без участия системных вызовов,
и с этими модулями нельзя работать путем вызова функций или использования
определенного API. В целом общение с модулями ядра в Linux и некоторых похожих операционных системах происходит тремя способами.
zz Файлы устройств в каталоге /dev. Модули ядра в основном разрабатываются

для использования в качестве драйверов устройств. Вот почему самый распространенный способ взаимодействия с ними — использование файлов внутри
каталога /dev, которые мы обсуждали в предыдущей главе. Читая и записывая
эти файлы, вы можете обмениваться информацией с модулями.
zz Содержимое procfs. Файлы в каталоге /proc можно использовать для чтения

метаинформации об отдельных модулях ядра. Эти файлы также позволяют отправлять модулям метаинформацию или управляющие команды. Применение
procfs будет показано чуть ниже, в примере 11.3.
zz Содержимое sysfs. Это еще одна файловая система в Linux, которая позволяет

управлять пользовательскими процессами и другими компонентами, относящимися к ядру (включая модули ядра), с помощью скриптов или вручную.
Ее можно считать новой версией procfs.
Чтобы поближе познакомиться с модулями ядра, лучше всего написать собственный модуль, чем мы и займемся далее. Мы напишем модуль Hello World для Linux.
Стоит отметить, что модули ядра существуют не только в Linux, но и в других монолитных ядрах. Например, FreeBSD использует похожий механизм.

Ядра Unix   359

Создание нового модуля ядра для Linux
Здесь мы напишем новый модуль ядра для Linux под названием Hello World.
Он будет создавать запись в procfs. Затем мы используем эту запись, чтобы прочитать строку с приветствием.
Вы научитесь писать, компилировать и загружать модули в ядро, выгружать их оттуда и читать данные из записи в procfs. Основное назначение этого примера — дать
вам возможность набраться опыта и стать более самостоятельным разработчиком.
Модули ядра компилируются в объектные файлы, которые можно загружать непосредственно в активное ядро. Для этого не нужно перезагружать систему. Главное, что7бы ваш модуль не натворил беды в ядре
и не вывел его из строя. То же самое можно сказать о выгрузке модуля.

Первым делом нужно создать каталог, в котором будут находиться все файлы, относящиеся к модулю ядра. Это третий пример в данной главе, и потому назовем
данный каталог ex11_3 (терминал 11.19).
Терминал 11.19. Создание корневого каталога для примера 11.3
$ mkdir ex11_3
$ cd ex11_3
$

Затем создадим файл под названием hwkm.c, сокращенно от Hello World Kernel
Module, со следующим содержимым (листинг 11.11).
Листинг 11.11. Модуль ядра Hello World (ex11_3/hwkm.c)

#include
#include
#include
#include






// Структура, ссылающаяся на файл proc
struct proc_dir_entry *proc_file;
// Функция обратного вызова для чтения файла
ssize_t proc_file_read(struct file *file, char __user *ubuf, size_t count,
loff_t *ppos) {
int copied = 0;
if (*ppos > 0) {
return 0;
}
copied = sprintf(ubuf, "Hello World From Kernel Module!\n");
*ppos = copied;
return copied;
}

360  Глава 11



Системные вызовы и ядра

static const struct file_operations proc_file_fops = {
.owner = THIS_MODULE,
.read = proc_file_read
};
// Обратный вызов для инициализации модуля
static int __init hwkm_init(void) {
proc_file = proc_create("hwkm", 0, NULL, &proc_file_fops);
if (!proc_file) {
return -ENOMEM;
}
printk("Hello World module is loaded.\n");
return 0;
}
// Обратный вызов для выхода из модуля
static void __exit hkwm_exit(void) {
proc_remove(proc_file);
printk("Goodbye World!\n");
}
// Определение обратных вызовов для работы с модулем
module_init(hwkm_init);
module_exit(hkwm_exit);

С помощью двух последних выражений мы зарегистрировали обратные вызовы нашего модуля, которые отвечают за инициализацию и завершение работы. Эти функции вызываются соответственно при загрузке и выгрузке модуля. Выполнение кода
начинается с обратного вызова для инициализации.
Заглянув внутрь функции hwkm_init, вы увидите, что она создает файл hwkm в каталоге /proc. Рядом находится обратный вызов для завершения работы, hwkm_exit; он
удаляет файл hwkm из этого каталога. Файл /proc/hwkm позволяет взаимодействовать с модулем ядра из пользовательского пространства.
Функция proc_file_read — это обратный вызов, который дает возможность процессу
из пользовательского пространства читать файл /proc/hwkm. Как вы вскоре увидите,
для чтения файла будет использоваться утилита cat. С ее помощью мы просто будем
копировать строку Hello World From Kernel Module! в пространство пользователя.
Обратите внимание: на данном этапе код нашего модуля имеет неограниченный
доступ к почти любым компонентам ядра и из него в пользовательское пространство может утечь какая угодно информация. Это серьезная дыра в безопасности,
вследствие чего вам следует поближе познакомиться с рекомендациями по написанию безопасных модулей ядра.
Чтобы скомпилировать приведенный выше код, нам нужен подходящий компилятор, с помощью которого мы потенциально можем выполнить компоновку с нужными нам библиотеками. Чтобы упростить себе жизнь, создадим файл с именем

Ядра Unix  361
Makefile , который вызовет все необходимые инструменты для сборки нашего

модуля ядра.
Содержимое этого файла показано в листинге 11.12.
Листинг 11.12. Содержимое файла Makefile для модуля ядра Hello World

obj-m += hwkm.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

Затем можно выполнить команду make (терминал 11.20).
Терминал 11.20. Сборка модуля ядра Hello World
$ make
make -C /lib/modules/54.318.0+/build M=/home/kamran/extreme_c/ch11/codes/ex11_3
modules
make[1]: Entering directory '/home/kamran/linux'
CC [M] /home/kamran/extreme_c/ch11/codes/ex11_3/hwkm.o
Building modules, stage 2.
MODPOST 1 modules
WARNING: modpost: missing MODULE_LICENSE() in /home/kamran/extreme_c/ch11/codes/
ex11_3/hwkm.o
see include/linux/module.h for more information
CC
/home/kamran/extreme_c/ch11/codes/ex11_3/hwkm.mod.o
LD [M] /home/kamran/extreme_c/ch11/codes/ex11_3/hwkm.ko
make[1]: Leaving directory '/home/kamran/linux'
$

Как видите, в результате компиляции кода получается объектный файл, который
затем компонуется с другими библиотеками в файл .ko. Среди сгенерированных
результатов должен находиться файл с именем hwkm.ko.
Обратите внимание на расширение .ko. Оно означает, что выходной файл является
объектным файлом ядра. Это нечто вроде разделяемой библиотеки, которую можно
динамически загружать в ядро, где она будет выполняться.
Пожалуйста, обратите внимание: процесс сборки, представленный в предыдущем
терминале, выдал предупреждение о том, что у модуля нет лицензии. Крайне желательно, чтобы в ходе разработки или развертывания в тестовых и промышленных
средах генерировались лицензированные модули ядра.
В терминале 11.21 показан список файлов, которые можно получить в результате
сборки модуля ядра.

362  Глава 11



Системные вызовы и ядра

Терминал 11.21. Список существующих файлов после сборки модуля ядра Hello World
$ ls -l
total 556
-rw-rw-r--rw-rw-r--rw-rw-r--rw-rw-r--rw-rw-r--rw-rw-r--rw-rw-r--rw-rw-r-$

1
1
1
1
1
1
1
1

kamran
kamran
kamran
kamran
kamran
kamran
kamran
kamran

kamran
154 Oct 19 00:36 Makefile
kamran
0 Oct 19 08:15 Module.symvers
kamran
1104 Oct 19 08:05 hwkm.c
kamran 272280 Oct 19 08:15 hwkm.ko
kamran
596 Oct 19 08:15 hwkm.mod.c
kamran 104488 Oct 19 08:15 hwkm.mod.o
kamran 169272 Oct 19 08:15 hwkm.o
Kamran
54 Oct 19 08:15 modules.order

Мы использовали инструменты сборки модулей из ядра Linux версии 5.3.0. Вы можете получить ошибку, если попытаетесь скомпилировать этот пример с помощью ядра, версия которого ниже 3.10.

Для загрузки и установки модуля ядра hwkm мы используем команду Linux insmod,
как показано в терминале 11.22.
Терминал 11.22. Загрузка и установка модуля ядра Hello World
$ sudo insmod hwkm.ko
$

Теперь, заглянув в журнал ядра, можно увидеть строчки, сгенерированные функцией инициализации. Для просмотра самых последних журнальных записей ядра
используйте команду dmesg, как показано в терминале 11.23.
Терминал 11.23. Проверка журнальных сообщений ядра после установки модуля
$ dmesg
...
...
[ 7411.519575] Hello World module is loaded.
$

Итак, модуль загружен, и уже должен быть создан файл /proc/hwkm. Мы можем
прочитать его с помощью команды cat (терминал 11.24).
Терминал 11.24. Чтение файла /proc/hwkm с использованием команды cat
$ cat
Hello
$ cat
Hello
$

/proc/hwkm
World From Kernel Module!
/proc/hwkm
World From Kernel Module!

Ядра Unix  363

Здесь видно, что обе операции чтения файла возвращают одну и ту же строку: Hello
World From Kernel Module!. Обратите внимание: эта строка копируется в пользовательское пространство модулем ядра и команда cat направляет ее в стандартный
вывод.
В Linux для выгрузки модуля предусмотрена команда rmmod (терминал 11.25).
Терминал 11.25. Выгрузка модуля ядра Hello World
$ sudo rmmod hwkm
$

После выгрузки модуля опять заглянем в журнал ядра, чтобы найти прощальное
сообщение (терминал 11.26).
Терминал 11.26. Проверка журнальных сообщений ядра после выгрузки модуля
$ dmesg
...
...
[ 7411.519575] Hello World module is loaded.
[ 7648.950639] Goodbye World!
$

Как вы сами можете убедиться, такие модули очень удобны для написания кода
ядра.
В завершение этой главы, мне кажется, будет полезно перечислить особенности
модулей ядра, с которыми мы успели познакомиться:
zz модули ядра можно загружать и выгружать, не прибегая к перезагрузке ком-

пьютера;
zz после загрузки модули становятся частью ядра и получают доступ ко всем его

компонентам и структурам. Это можно считать уязвимостью, но ядро Linux
имеет средства защиты от загрузки нежелательных модулей;
zz модули ядра можно компилировать отдельно. А вот системные вызовы требуют

перекомпиляции всего ядра, на что может легко уйти несколько часов.
Наконец, модули ядра могут пригодиться при разработке кода, который необходимо выполнять в самом ядре и инициировать с помощью системных вызовов.
Сначала вы можете реализовать свою логику в виде модуля, а затем, протестировав
ее должным образом, сделать доступной через настоящий системный вызов.
Процесс разработки системных вызовов с нуля может быть довольно трудоемким,
поскольку требует регулярной перезагрузки компьютера. Но если сначала написать
и протестировать логику в виде модуля, то это упростит разработку ядра. Стоит
отметить: если ваш код нестабильный, то неважно, где он находится — в модуле

364  Глава 11



Системные вызовы и ядра

или в системном вызове; он может привести к сбою в ядре, что потребует перезагрузки системы.
В этом разделе мы обсудили различные виды ядер. Мы также рассмотрели модуль,
который можно использовать внутри монолитного ядра, загружая и выгружая его
динамически.

Резюме
На этом завершается наше обсуждение Unix, начатое в предыдущей главе. Вы узнали:
zz что такое системный вызов и как он предоставляет доступ к определенным

функциям;
zz что происходит внутри ядра, когда вы инициируете системный вызов;
zz каким образом некоторые системные вызовы можно инициировать прямо из

кода на C;
zz как добавить новый системный вызов в существующее Unix-подобное ядро

(Linux) и как перекомпилировать это ядро;
zz что такое монолитное ядро и чем оно отличается от микроядра;
zz как работают модули монолитного ядра и как написать новый модуль для Linux.

В следующей главе мы поговорим о стандартах языка C и его последней версии,
C18. Вы познакомитесь с новыми возможностями, которые появились в ней.

12

Последние
нововведения в C

Прогресс не остановить. Язык программирования C является стандартом ISO,
постоянно пересматриваемым в попытках сделать его лучше и привнести в него
новые возможности. Но это не значит, что C становится проще; по мере развития
языка в нем появляются новые и сложные концепции.
В этой главе я проведу краткий обзор новшеств C11. Как вы, наверное, знаете,
стандарт C11 пришел на смену C99 и позже был заменен стандартом C18. Иными
словами, C18 — самая последняя версия языка C, а C11 — предыдущая.
Интересно, что в C18 не появилось никаких новых возможностей; эта версия содержит лишь исправления ошибок, найденных в C11. Таким образом, все, что мы
говорим о C11, фактически относится и к C18 — то есть к самому последнему стандарту C. Как видите, в C наблюдаются постоянные улучшения… вопреки мнению
о том, что этот язык давно умер!
В данной главе будет представлен краткий обзор следующих тем:
zz способы определения версии C и написания кода, совместимого с разными

версиями этого языка;
zz новые средства оптимизации и защиты исходного кода, такие как невозвраща-

емые функции и функции с проверкой диапазона;
zz новые типы данных и методы компоновки памяти;
zz функции с обобщенными типами;
zz поддержка Unicode в C11, которой не хватало в предыдущих стандартах этого

языка;
zz анонимные структуры и объединения;
zz встроенная поддержка многопоточности и методов синхронизации в C11.

Начнем эту главу с обсуждения стандарта C11 и его нововведений.

366  Глава 12



Последние нововведения в C

C11
Разработка нового стандарта для технологии, которая используется на протяжении
более 30 лет, — непростая задача. На C написаны миллионы (если не миллиарды!)
строчек кода, и если вы хотите добавить новые возможности, то это нужно делать так,
чтобы не затронуть существующий код. Новшества не должны создавать новые проблемы для имеющихся программ и не должны содержать ошибки. Такой взгляд на
вещи может показаться идеалистическим, но это то, к чему нам следует стремиться.
Приведенный ниже PDF-документ находится на сайте Open Standards и выражает
обеспокоенность и мысли участников сообщества C перед началом работы над
C11: http://www.open-std.org/JTC1/SC22/wg14/www/docs/n1250.pdf. Его полезно почитать,
поскольку в нем собран опыт разработки нового стандарта для языка, на котором
уже было написано несколько тысяч проектов.
Мы будем рассматривать выпуск C11 с учетом всего вышесказанного. Будучи
опубликованным впервые, стандарт C11 был далек от идеала и имел некоторые
серьезные дефекты, со списком которых можно ознакомиться по адресу http://
www.open-std.org/jtc1/sc22/wg14/www/docs/n2244.htm.
Через семь лет после выхода C11 был представлен стандарт C18, который должен
был исправить недостатки предшественника. Стоит отметить, что C18 также неофициально называют C17, но это один и тот же стандарт. На странице, приведенной
в ссылке выше, можно просмотреть перечень дефектов и их текущее состояние. Если
состояние дефекта помечено как C17, то это значит, он был исправлен в рамках C18.
Это показывает, насколько сложным и щепетильным может быть процесс формирования стандарта с таким большим количеством пользователей, как у языка C.
В следующих разделах речь пойдет о новых возможностях C11. Но прежде, чем
мы по ним пройдемся, необходимо убедиться в том, что у нас есть компилятор, совместимый с данным стандартом. Об этом мы позаботимся в следующем разделе.

Определение поддерживаемой версии
стандарта C
На момент написания этих строк с момента выхода стандарта C11 прошло почти
восемь лет. И потому было бы логично ожидать, что он уже поддерживается многими компиляторами. И это действительно так. Открытые компиляторы, такие
как gcc и clang, имеют полную поддержку C11, но при необходимости могут переключаться на C99 и даже более ранние версии C. В данном разделе я покажу, как
с помощью специального макроса определить версию C и в зависимости от нее
использовать поддерживаемые возможности языка.
Если ваш компилятор поддерживает разные версии стандарта C, то первым делом
нужно проверить, какая версия является текущей. Каждый стандарт C определяет

Определение поддерживаемой версии стандарта C   367

специальный макрос, позволяющий сделать это. До сих пор мы использовали gcc
в Linux и clang в macOS. В gcc 4.1 C11 предоставляется в качестве одного из поддерживаемых стандартов.
Рассмотрим следующий пример, чтобы понять, как на этапе выполнения узнать
текущую версию стандарта C, используя уже определенный макрос (листинг 12.1).
Листинг 12.1. Определение версии стандарта C (ExtremeC_examples_chapter12_1.c)

#include
int main(int argc, char** argv) {
#if __STDC_VERSION__ >= 201710L
printf("Hello World from C18!\n");
#elif __STDC_VERSION__ >= 201112L
printf("Hello World from C11!\n");
#elif __STDC_VERSION__ >= 199901L
printf("Hello World from C99!\n");
#else
printf("Hello World from C89/C90!\n");
#endif
return 0;
}

Как видите, данный код может различать разные версии стандарта C. Чтобы продемонстрировать, как разные версии приводят к разному выводу, скомпилируем
этот исходный код несколько раз с применением разных стандартов C, поддерживаемых компилятором.
Чтобы заставить компилятор использовать определенный стандарт C, ему нужно
передать параметр -std=CXX. Взгляните на следующую команду и на вывод, который она генерирует (терминал 12.1).
Терминал 12.1. Компиляция примера 12.1 с помощью разных версий стандарта C
$ gcc ExtremeC_examples_chapter12_1.c
$ ./ex12_1.out
Hello World from C11!
$ gcc ExtremeC_examples_chapter12_1.c
$ ./ex12_1.out
Hello World from C11!
$ gcc ExtremeC_examples_chapter12_1.c
$ ./ex12_1.out
Hello World from C99!
$ gcc ExtremeC_examples_chapter12_1.c
$ ./ex12_1.out
Hello World from C89/C90!
$ gcc ExtremeC_examples_chapter12_1.c
$ ./ex12_1.out
Hello World from C89/C90!
$

-o ex12_1.out

-o ex12_1.out -std=c11

-o ex12_1.out -std=c99

-o ex12_1.out -std=c90

-o ex12_1.out -std=c89

368  Глава 12



Последние нововведения в C

Как видите, в новых компиляторах по умолчанию используется C11. В более старых
версиях для включения C11 может понадобиться параметр -std. Обратите внимание
на комментарии в начале файла. Я использовал многострочный формат, /* ... */,
вместо однострочного, //. Дело в том, что однострочные комментарии не поддерживались в стандартах, предшествовавших C99. Поэтому пришлось сделать комментарии многострочными, чтобы код компилировался со всеми версиями C.

Удаление функции gets
Из C11 была убрана знаменитая функция gets . Она была подвержена атакам
с переполнением буфера, и в предыдущих версиях ее решили сделать нерекомендуемой. Позже она была удалена в рамках стандарта C11. Следовательно, старый
исходный код, в котором используется эта функция, нельзя скомпилировать
с помощью C11.
Вместо gets можно использовать функцию fgets. Вот отрывок из справочной
страницы gets в macOS.
Соображения безопасности
Функция gets() не подходит для безопасного использования. Ввиду отсутствия проверки диапазона и неспособности вызывающей программы
надежно определить длину следующей входной строчки, применение
этой функции позволяет недобросовестным пользователям вносить произвольные изменения в функциональность запущенной программы с помощью атаки переполнения буфера. В любых ситуациях настоятельно
рекомендуется задействовать функцию fgets() (см. FSA).

Изменения в функции fopen
Функция fopen обычно используется для открытия файла и возвращения его
дескриптора. Понятие файла в Unix очень абстрактно и может не иметь ничего
общего с файловой системой. Функция fopen имеет следующие сигнатуры (листинг 12.2).
Листинг 12.2. Различные сигнатуры функций семейства fopen

FILE* fopen(const char *pathname, const char *mode);
FILE* fdopen(int fd, const char *mode);
FILE* freopen(const char *pathname, const char *mode, FILE *stream);

Все сигнатуры, приведенные выше, принимают входной параметр mode. Это строка,
которая определяет режим открытия файла. В терминале 12.2 приведено описание,

Изменения в функции fopen  369

взятое из справочной страницы fopen в FreeBSD. В нем объясняется, как следует
использовать mode.
Терминал 12.2. Отрывок из справочной страницы fopen в FreeBSD
$ man 3 fopen
...
The argument mode points to a string beginning with one of the following letters:
"r"

Open for reading. The stream is positioned at the beginning
of the file. Fail if the file does not exist.

"w"

Open for writing. The stream is positioned at the beginning
of the file. Create the file if it does not exist.

"a"

Open for writing. The stream is positioned at the end of
the file. Subsequent writes to the file will always end up
at the then current end of file, irrespective of
any intervening fseek(3) or similar. Create the file
if it does not exist.

An optional "+" following "r", "w", or "a" opens the file
for both reading and writing. An optional "x" following "w" or
"w+" causes the fopen() call to fail if the file already exists.
An optional "e" following the above causes the fopen() call to set
the FD_CLOEXEC flag on the underlying file descriptor.
The mode string can also include the letter "b" after either
the "+" or the first letter.
...
$

Режим x, описанный в данном отрывке, был представлен вместе со стандартом C11.
Чтобы открыть файл для записи, функции fopen нужно передать режим w или w+.
Но проблема вот в чем: если файл уже существует, то режимы w и w+ сделают его
пустым.
Поэтому если программист хочет добавить что-то в файл, не стирая имеющееся
содержимое, то должен задействовать другой режим, a. Следовательно, перед вызовом fopen ему нужно проверить существование файла, используя API файловой
системы, такой как stat, и затем выбрать подходящий режим в зависимости от
результата. Но теперь программист может сначала попробовать режим wx или wx+,
и если файл уже существует, то fopen вернет ошибку. После этого можно продолжить, применяя режим a.
Таким образом, открытие файла требует меньше шаблонного кода, поскольку
нам больше не нужно проверять его существование с помощью API файловой
системы. Теперь файл можно открыть в любом режиме, используя одну лишь
функцию fopen.

370   Глава 12



Последние нововведения в C

В C11 также появился API fopen_s. Это безопасная версия fopen. Согласно документации, которая находится по ссылке https://en.cppreference.com/w/c/io/fopen, функция fopen_s выполняет дополнительную проверку предоставленных ей буферов
и их границ, что позволяет обнаружить любые несоответствия.

Функции с проверкой диапазона
Программам на языке C, которые работают с массивами строк и байтов, присуща
одна серьезная проблема: они могут легко выйти за пределы диапазона, определенного для буфера или байтового массива.
Напомню, буфер — область памяти, которая служит для хранения массива байтов или строковой переменной. Выход за ее границы приводит к переполнению
буфера, чем могут воспользоваться злоумышленники, чтобы организовать атаку
(которую обычно называют атакой переполнения буфера). Это приводит либо
к отказу в обслуживании (denial of service, DoS), либо к эксплуатации атакуемой
программы.
Большинство таких атак обычно начинаются с функции, которая работает с массивами символов или байтов. В число уязвимых попадают функции обработки строк наподобие strcpy и strcat, находящиеся в string.h. У них нет механизмов проверки границ, которые могли бы предотвратить атаки переполнения
буфера.
Однако в C11 появился новый набор функций с проверкой диапазона. Они имеют
те же имена, что и функции для работы со строками, но с суффиксом _s в конце.
Он означает, что они являются безопасной (secure) разновидностью традиционных
функций и проводят дополнительные проверки на этапе выполнения, защищаясь
от уязвимостей. Среди функций с проверкой диапазона, появившихся в C11, можно
выделить strcpy_s и strcat_s.
Эти функции принимают дополнительные аргументы для входных буферов, которые исключают выполнение опасных операций. Например, функция strcpy_s
имеет следующую сигнатуру (листинг 12.3).
Листинг 12.3. Сигнатура функции strcpy_s

errno_t strcpy_s(char *restrict dest, rsize_t destsz, const char *restrict src);

Как видите, второй аргумент — длина буфера dest. С его помощью функция проводит определенные проверки на этапе выполнения; например, она убеждается
в том, что строка src не длиннее буфера dest, предотвращая тем самым запись
в невыделенную память.

Невозвращаемые функции   371

Невозвращаемые функции
Вызов функции можно завершить либо с помощью ключевого слова return, либо
при достижении конца ее блока. Бывают также ситуации, в которых вызов функции
никогда не завершается, и обычно это делается намеренно. Взгляните на следующий
пример кода, представленный в листинге 12.4.
Листинг 12.4. Пример функции, которая никогда не возвращается

void main_loop() {
while (1) {
...
}
}
int main(int argc, char** argv) {
...
main_loop();
return 0;
}

Основную работу в этой программе выполняет функция main_loop. После возвращения из нее программу можно считать завершенной. В таких исключительных
случаях компилятор может провести дополнительную оптимизацию, но для этого
он должен знать, что функция main_loop никогда не возвращается.
В C11 вы можете указать, что функция является невозвращаемой и никогда не прекращает работу. Для этого можно использовать ключевое слово _Noreturn из заголовочного файла stdnoreturn.h. Таким образом, код из листинга 12.4 можно
изменить в соответствии с C11 (листинг 12.5).
Листинг 12.5. Использование ключевого слова _Noreturn, чтобы пометить функцию main_loop
как невозвращаемую

_Noreturn void main_loop() {
while (true) {
...
}
}

Существуют и другие функции, которые считаются невозвращаемыми: exit, quick_
exit (были добавлены недавно в рамках C11 для быстрого завершения программы)
и abort. Кроме того, зная о невозвращаемых функциях, компилятор может распознать
вызовы, которые не возвращаются по ошибке, и сгенерировать соответствующее
предупреждение, поскольку это может быть признаком некорректной логики. Обра­
тите внимание: ситуация, когда функция, помеченная как _Noreturn, возвращается,
является примером неопределенного поведения и ни в коем случае не приветствуется.

372   Глава 12



Последние нововведения в C

Макрос для обобщенных типов
В C11 появилось новое ключевое слово: _Generic. С его помощью можно писать
макросы, которые получают информацию о типе на этапе выполнения. Иными
словами, вы можете написать макрос, который меняет свое значение в зависимости
от типов своих аргументов. Это обычно называют обобщающим селектором (generic
selection). Взгляните на следующий пример кода в листинге 12.6.
Листинг 12.6. Пример макроса _Generic

#include
#define abs(x) _Generic((x), \
int: absi, \
double: absd)(x)
int absi(int a) {
return a > 0 ? a : -a;
}
double absd(double a) {
return a > 0 ? a : -a;
}
int main(int argc, char** argv) {
printf("abs(-2): %d\n", abs(-2));
printf("abs(2.5): %f\n", abs(2.5));;
return 0;
}

В определении макроса есть два разных выражения, которые выбираются в зависимости от типа аргумента x. Для значений int используется absi, а для значений
double — absd. Эта возможность появилась еще до C11, и ее можно встретить в более старых компиляторах, но раньше она не была частью стандарта. С выходом C11
данный синтаксис вошел в спецификацию языка C, и вы можете создавать с его
помощью макросы, которые учитывают тип.

Unicode
Одним из самых важных нововведений в стандарте C11 стала поддержка Unicode
(в виде кодировок UTF-8, UTF-16 и UTF-32). В языке C она долгое время отсутствовала, и программистам приходилось использовать сторонние библиотеки,
такие как IBM International Components for Unicode (ICU).
До выхода C11 нам были доступны только восьмибитные типы char и unsigned
char, предназначенные для хранения символов кодировок ASCII и Extended ASCII.
Строки имели вид массивов этих ASCII-символов.

Unicode   373
Стандарт ASCII состоит из 128 символов, которые занимают 7 бит. У него
есть расширенная версия, Extended ASCII, содержащая дополнительные
128 символов (в общей сложности получается 256). Для их хранения
достаточно восьмибитной или однобайтной переменной. В тексте, представленном ниже, ASCII может означать как обычную, так и расширенную версию кодировки.

Обратите внимание: поддержка символов и строк ASCII является фундаментальной и из языка C никогда не исчезнет. Поэтому можно быть уверенными в том,
что в C мы всегда сможем работать с ASCII. Но в C11 появилась поддержка новых
символов и, следовательно, новых строк, которые используют разное количество
байтов (а не один байт для каждого символа).
Если более подробно, то в ASCII каждому символу выделяется один байт. Следовательно, все байты и символы взаимозаменяемы, но в целом это довольно
нетипичный подход. В других кодировках применяются новые методы хранения
широкого диапазона символов размером больше одного байта.
В ASCII в общей сложности имеется 256 символов. Поэтому все они могут поместиться в одном однобайтном (восьмибитном) символе. Если нам нужны дополнительные символы, то их числовые значения после 255 будут занимать больше
одного байта. Символы, которые не влезают в один байт, обычно называют широкими. Символы ASCII не являются таковыми по определению.
Стандарт Unicode предусматривает различные методы использования более одного
байта для кодирования всех символов в ASCII и Extended ASCII, а также широких
символов. Эти методы называются кодировки. Среди этих кодировок в Unicode
самыми известными являются UTF-8, UTF-16 и UTF-32. В UTF-8 первый байт
используется для хранения первой половины таблицы ASCII, а остальные (обычно
не более четырех) — для второй половины символов ASCII со всеми другими широкими символами. Таким образом, UTF-8 считается кодировкой переменной длины.
Она использует биты в первом байте символа в целях обозначения количества байтов,
которые необходимо прочитать, чтобы извлечь этот символ полностью. Кодировка
UTF-8 считается надмножеством ASCII, поскольку ASCII-символы в ней представлены точно таким же образом (это не относится к символам из Extended ASCII).
UTF-16, как и UTF-8, задействует одно или два слова (каждое слово содержит
16 бит) для хранения всех символов; следовательно, это тоже кодировка переменной длины. В UTF-32 для значений всех символов используются ровно 4 байта;
следовательно, это кодировка фиксированной длины. Для приложений, в которых
для хранения символов, применяемых не так часто, используется меньшее количество байтов, подходит в первую очередь UTF-8 и затем UTF-16.
В UTF-32 всегда используется фиксированное число байтов, даже для символов
ASCII. Поэтому для хранения строк данная кодировка требует больше памяти

374   Глава 12



Последние нововведения в C

и места на диске по сравнение с аналогами. UTF-8 и UTF-16 можно считать сжатыми кодировками, но они требуют дополнительных вычислений, чтобы вернуть
значение символа.
Больше информации о строках UTF-8, UTF-16 и UTF-32, а также о том,
как их декодировать, можно найти в «Википедии» и других источниках,
включая следующие:
yy https://unicodebook.readthedocs.io/unicode_encodings.html;
yy https://javarevisited.blogspot.com/2015/02/difference-between-utf-8utf-16-and-utf.html.

В C11 имеется поддержка всех кодировок Unicode, перечисленных выше. Взгляните на следующий пример, под номером 12.3. Он определяет различные строки
в форматах ASCII, UTF-8, UTF-16 и UTF-32 и подсчитывает количество байтов,
которое используется для их хранения, а также количество символов в каждой из
них. Код будет представлен поэтапно, с предоставлением дополнительных комментариев. В листинге 12.7 показаны все необходимые операции подключения
и объявления.
Листинг 12.7. Подключения и объявления, необходимые для сборки примера 12.3
(ExtremeC_examples_chapter12_3.c)

#include
#include
#include
#ifdef __APPLE__
#include
typedef uint16_t char16_t;
typedef uint32_t char32_t;
#else
#include // Нужно для char16_t и char32_t
#endif

Этот код состоит из инструкций include для примера 12.3. Как видите, в macOS нет
заголовка uchar.h, и нам пришлось объявить новые типы для char16_t и char32_t.
Тем не менее мы имеем полную поддержку строк Unicode. В Linux нет вообще
никаких проблем с Unicode в C11.
Следующий участок кода (листинг 12.8) демонстрирует функции, которые используются для подсчета количества байтов и символов в разных видах строк Unicode.
Обратите внимание: в C11 для работы со строками Unicode не предусмотрено никаких вспомогательных функций, поэтому нам приходится самостоятельно писать

Unicode   375

для них strlen. На самом деле наша версия функций strlen возвращает не только
количество символов, но и число потребленных байтов. Я не стану углубляться
в детали реализации, но настоятельно рекомендую с ними ознакомиться.
Листинг 12.8. Определения функций, используемых в примере 12.3
(ExtremeC_examples_chapter12_3.c)

typedef struct {
long num_chars;
long num_bytes;
} unicode_len_t;
unicode_len_t strlen_ascii(char* str) {
unicode_len_t res;
res.num_chars = 0;
res.num_bytes = 0;
if (!str) {
return res;
}
res.num_chars = strlen(str) + 1;
res.num_bytes = strlen(str) + 1;
return res;
}
unicode_len_t strlen_u8(char* str) {
unicode_len_t res;
res.num_chars = 0;
res.num_bytes = 0;
if (!str) {
return res;
}
// Последний нулевой символ
res.num_chars = 1;
res.num_bytes = 1;
while (*str) {
if ((*str | 0x7f) == 0x7f) { // 0x7f =
res.num_chars++;
res.num_bytes++;
str++;
} else if ((*str & 0xf0) == 0xf0) { //
res.num_chars++;
res.num_bytes += 4;
str += 4;
} else if ((*str & 0xe0) == 0xe0) { //
res.num_chars++;
res.num_bytes += 3;
str += 3;
} else if ((*str & 0xc0) == 0xc0) { //
res.num_chars++;
res.num_bytes += 2;
str += 2;

0b01111111

0xf0 = 0b11110000

0xe0 = 0b11100000

0xc0 = 0b11000000

376   Глава 12



Последние нововведения в C

} else {
fprintf(stderr, "UTF-8 string is not valid!\n");
exit(1);
}

}

}
return res;

unicode_len_t strlen_u16(char16_t* str) {
unicode_len_t res;
res.num_chars = 0;
res.num_bytes = 0;
if (!str) {
return res;
}
// Последний нулевой символ
res.num_chars = 1;
res.num_bytes = 2;
while (*str) {
if (*str < 0xdc00 || *str > 0xdfff) {
res.num_chars++;
res.num_bytes += 2;
str++;
} else {
res.num_chars++;
res.num_bytes += 4;
str += 2;
}
}
return res;
}
unicode_len_t strlen_u32(char32_t* str) {
unicode_len_t res;
res.num_chars = 0;
res.num_bytes = 0;
if (!str) {
return res;
}
// Последний нулевой символ
res.num_chars = 1;
res.num_bytes = 4;
while (*str) {
res.num_chars++;
res.num_bytes += 4;
str++;
}
return res;
}

Остается только функция main . Чтобы проверить работу приведенных выше
функций, в ней объявляются разные строки с текстом на английском, персидском
и инопланетном языках (листинг 12.9).

Unicode   377
Листинг 12.9. Главная функция в примере 12.3 (ExtremeC_examples_chapter12_3.c)

int main(int argc, char** argv) {
char ascii_string[32] = "Hello World!";
char utf8_string[32] = u8"Hello World!";
char utf8_string_2[32] = u8"‫;"!ایند دورد‬
char16_t utf16_string[32] = u"Hello World!";
char16_t utf16_string_2[32] = u"‫;"!ایند دورد‬
char16_t utf16_string_3[32] = u"হহহ!";
char32_t utf32_string[32] = U"Hello World!";
char32_t utf32_string_2[32] = U"‫;"!ایند دورد‬
char32_t utf32_string_3[32] = U"হহহ!";
unicode_len_t len = strlen_ascii(ascii_string);
printf("Length of ASCII string:\t\t\t %ld chars, %ld bytes\n\n",
len.num_chars, len.num_bytes);
len = strlen_u8(utf8_string);
printf("Length of UTF-8 english string:\t\t %ld chars, %ld bytes\n",
len.num_chars, len.num_bytes);
len = strlen_u16(utf16_string);
printf("Length of UTF-16 english string:\t %ld chars, %ld bytes\n",
len.num_chars, len.num_bytes);
len = strlen_u32(utf32_string);
printf("Length of UTF-32 english string:\t %ld chars, %ld bytes\n\n",
len.num_chars, len.num_bytes);
len = strlen_u8(utf8_string_2);
printf("Length of UTF-8 persian string:\t\t %ld chars, %ld bytes\n",
len.num_chars, len.num_bytes);
len = strlen_u16(utf16_string_2);
printf("Length of UTF-16 persian string:\t %ld chars, %ld bytes\n",
len.num_chars, len.num_bytes);
len = strlen_u32(utf32_string_2);
printf("Length of UTF-32 persian string:\t %ld chars, %ld bytes\n\n",
len.num_chars, len.num_bytes);
len = strlen_u16(utf16_string_3);
printf("Length of UTF-16 alien string:\t\t %ld chars, %ld bytes\n",
len.num_chars, len.num_bytes);
len = strlen_u32(utf32_string_3);
printf("Length of UTF-32 alien string:\t\t %ld chars, %ld bytes\n",
len.num_chars, len.num_bytes);
return 0;
}

Теперь мы должны скомпилировать данный пример. Заметьте, что это можно сделать только с помощью компилятора, совместимого с C11. Вы можете попробовать

378   Глава 12



Последние нововведения в C

более старые компиляторы и посмотреть на ошибки, которые получатся в результате. Команды, показанные в терминале 12.3, компилируют и запускают нашу
программу.
Терминал 12.3. Компиляция и запуск примера 12.3
$ gcc ExtremeC_examples_chapter12_3.c -std=c11 -o ex12_3.out
$ ./ex12_3.out
Length of ASCII string:
13 chars, 13 bytes
Length of UTF-8 english string:
Length of UTF-16 english string:
Length of UTF-32 english string:

13 chars, 13 bytes
13 chars, 26 bytes
13 chars, 52 bytes

Length of UTF-8 persian string:
Length of UTF-16 persian string:
Length of UTF-32 persian string:

11 chars, 19 bytes
11 chars, 22 bytes
11 chars, 44 bytes

Length of UTF-16 alien string:
Length of UTF-32 alien string:
$

5 chars, 14 bytes
5 chars, 20 bytes

Как видите, кодирование и хранение одной и той же строки с одним и тем же количеством символов требует разного количества байтов. Меньше всего места занимает
UTF-8, особенно если существенная часть текста состоит из символов ASCII, просто потому, что большинство из этих символов занимают всего один байт.
Но когда вы имеете дело с символами не из латинского алфавита, которые, например, используются в азиатских языках, UTF-16 показывает лучший баланс между
длиной строки и количеством примененных байтов, поскольку большинство символов будут двухбайтными.
Кодировка UTF-32 используется редко, но может пригодиться в системах, в которых предпочтителен код для вывода символов фиксированной длины; это касается
систем с низкой вычислительной мощью или механизмами, рассчитанными на
параллельную обработку. Следовательно, UTF-32 можно задействовать в качестве
ключей для привязки символов к любого рода данным. Иными словами, с помощью
этой кодировки можно строить индексы для быстрого поиска информации.

Анонимные структуры
и анонимные объединения
Анонимные структуры и анонимные объединения — определения типов без имени;
обычно они используются в качестве вложенных типов. Проще объяснить это на
примере. В листинге 12.10 вы можете видеть тип, который содержит как анонимную
структуру, так и анонимное объединение.

Анонимные структуры и анонимные объединения    379
Листинг 12.10. Пример анонимной структуры и анонимного объединения

typedef struct {
union {
struct {
int x;
int y;
};
int data[2];
};
} point_t;

Этот тип использует одну и ту же память для анонимной структуры и поля с массивом байтов data. Его применение демонстрируется в листинге 12.11.
Листинг 12.11. Главная функция с использованием анонимной структуры и анонимного
объединения (ExtremeC_examples_chapter12_4.c)

#include
typedef struct {
union {
struct {
int x;
int y;
};
int data[2];
};
} point_t;
int main(int argc, char** argv) {
point_t p;
p.x = 10;
p.data[1] = -5;
printf("Point (%d, %d) using anonymous structure.\n", p.x, p.y);
printf("Point (%d, %d) using anonymous byte array.\n",
p.data[0], p.data[1]);
return 0;
}

В этом примере мы создаем анонимное объединение с анонимной структурой
внутри. Таким образом, одна и та же область памяти используется для хранения
экземпляра анонимной структуры и двухэлементного целочисленного массива.
В терминале 12.4 показан вывод данной программы.
Терминал 12.4. Компиляция и запуск примера 12.4
$ gcc ExtremeC_examples_chapter12_4.c -std=c11 -o ex12_4.out
$ ./ex12_4.out
Point (10, -5) using anonymous structure.
Point (10, -5) using anonymous byte array.
$

380  Глава 12



Последние нововведения в C

Как видите, любые изменения в двухэлементном целочисленном массиве отражаются на структурной переменной и наоборот.

Многопоточность
Поддержка многопоточности в C существует уже давно в виде поточных функций
POSIX или библиотеки pthreads. Эта тема подробно рассматривается в главах 15 и 16.
Поточная библиотека POSIX, как можно понять по названию, доступна только
в POSIX-совместимых системах, таких как Linux и других Unix-подобных ОС. Поэтому если вы имеете дело с ОС, которые несовместимы с POSIX (например, с Microsoft
Windows), то вам следует использовать библиотеку, предоставляемую операционной
системой. В рамках C11 доступна стандартная библиотека для работы с потоками,
подходящая для всех систем, поддерживающих стандарт C, независимо от их совместимости с POSIX. Это самое большое изменение, которое можно наблюдать в C11.
К сожалению, данная возможность C11 не реализована в Linux и macOS. Поэтому
на момент написания книги мы не можем показать вам рабочие примеры.

Немного о C18
Как уже упоминалось в предыдущих разделах, стандарт C18 содержит все исправления, внесенные в C11, но в нем нет никаких нововведений. По ссылке, приведенной ниже, можно ознакомиться с заявками, созданными для C11, и их обсуждением:
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n2244.htm.

Резюме
В этой главе мы прошлись по C11, C18 и самым последним стандартам языка C,
исследовав различные нововведения в C11. Среди самых важных возможностей,
появившихся в современной версии C, можно выделить поддержку Unicode, анонимных структур и объединений, а также новую стандартную библиотеку для
работы с потоками (хотя последняя все еще недоступна в актуальных версиях компиляторов и платформ). Мы с нетерпением ждем выхода будущих стандартов C.
В следующей главе мы начнем обсуждение конкурентности и теории, лежащей
в основе конкурентных систем. Это первая из шести глав, в которых будут рассмотрены такие темы, как многопоточность и многопроцессность, понимание которых
поможет вам писать конкурентный код.

13

Конкурентность

В следующих двух главах мы обсудим конкурентность и теоретические основы,
необходимые для разработки конкурентных программ не только на C, но и на
других языках. Поэтому обе главы не будут содержать никакого кода на C. Вместо
этого для представления конкурентных систем и характерных им свойств будет
использоваться псевдокод.
Тема конкурентности ввиду ее обширности была разделена на две главы. Здесь мы
поговорим об основных ее концепциях, а в главе 14 речь пойдет о сопутствующих
проблемах и механизмах синхронизации, с помощью которых эти проблемы решаются в конкурентных программах. Общая цель обеих глав состоит в предоставлении теоретических знаний, достаточных для того, чтобыприступить к дальнейшему
обсуждению многопоточности и многопроцессности.
Базовый материал, содержащийся в данной главе, также будет полезен при работе
с поточной библиотекой POSIX.
Для начала мы попытаемся ответить на следующие вопросы:
zz чем параллельные системы отличаются от конкурентных;
zz когда нам нужна конкурентность;
zz что такое планировщик заданий и какие алгоритмы планирования нашли ши-

рокое применение;
zz как выполняется конкурентная программа и что такое чередование;
zz что такое разделяемое состояние и как к нему могут обращаться различные за-

дания.
Начнем наше обсуждение со знакомства с концепцией конкурентности и попробуем понять, какое значение она имеет для нас.

Введение в конкурентность
Конкурентность означает, что несколько участков логики программы выполняются одновременно. Многие современные программные системы конкурентны,
поскольку программам необходимо совершать сразу несколько разных действий.

382  Глава 13



Конкурентность

Таким образом, конкурентность в той или иной степени присутствует в любом
современном ПО.
Можно сказать, это мощный инструмент, позволяющий писать код, который выполняет несколько заданий одновременно, и его поддержка обычно находится
в сердце операционной системы — в ядре.
Существует множество примеров того, как обычной программе приходится выполнять сразу несколько действий. Например, вы можете перемещаться по вебстраницам, загружая при этом какие-то файлы. В данном случае задания выполняются конкурентно в контексте процесса браузера. Еще одна распространенная
ситуация — потоковая загрузка видео, когда вы смотрите что-то на YouTube. Пока
вы просматриваете уже загруженные куски видеоролика, проигрыватель занимается получением следующих.
Даже простой текстовый процессор выполняет в фоне несколько заданий. Когда
я пишу эту главу в Microsoft Word, мне помогают механизмы проверки правописания и форматирования, выполняющиеся в параллель. Если вы читаете данную
книгу в приложении Kindle на iPad, то какие, по вашему мнению, задания могут
выполняться в ходе его работы?
Одновременное выполнение нескольких программ звучит чудесно, но, как часто
бывает с технологиями, конкурентность несет с собой не только преимущества,
но и определенные проблемы. Действительно, мало какая технология в истории
компьютерных наук вызывала столько головной боли! Эти проблемы, о которых
мы поговорим позже, могут оставаться незамеченными на протяжении долгого
времени после выпуска (вплоть до нескольких месяцев), поскольку их обычно
сложно выявлять, воспроизводить и устранять.
В начале этого раздела мы определили конкурентность как одновременное (или
конкурентное) выполнение нескольких заданий. Подобное описание подразумевает, что задания работают параллельно, но все, строго говоря, не так. Это упрощение,
к тому же не совсем точное, поскольку конкурентная и параллельная работа — разные вещи, и мы еще не объяснили, чем они отличаются. Две конкурентные программы отличаются от двух параллельных, и одна из целей данной главы — пролить свет
на их отличия и предоставить определения, которые используются в официальной
литературе в этой области.
В следующем разделе я объясню некоторые базовые концепции, относящиеся
к конкурентности, такие как задания, планирование, чередование, состояние и разделяемое состояние; вы будете регулярно встречать данные термины на страницах
книги. Вдобавок стоит отметить: большинство из этих концепций являются абстрактными и применимы к любой конкурентной системе, а не только к C.
Чтобы понять разницу между конкурентностью и параллелизмом, проведу краткий
обзор параллельных систем.

Параллелизм  383

Нужно сказать, что в текущей главе я буду придерживаться простых определений.
Я всего лишь хочу дать вам общее представление о том, как работают конкурентные
системы, поскольку более глубокие темы выходят за рамки этой книги.

Параллелизм
Параллелизм означает, что два задания выполняются одновременно, или в параллель. Именно словосочетание «в параллель» — ключевое отличие между параллелизмом и конкурентностью. Почему? Потому что параллельность подразумевает
протекание двух событий в один и тот же момент. В случае с конкурентной системой это не так; прежде чем выполнять другое задание, она должна остановить
текущее. Обратите внимание: в контексте современных конкурентных систем это
определение может быть слишком простым и неполным, но чтобы понять общую
идею, его должно быть достаточно.
Параллелизм регулярно встречается в повседневной жизни. Когда вы со своим
другом одновременно выполняете разные задачи, это означает, что задачи выполняются в параллель. Для параллельного выполнения необходимо разделить
и изолировать вычислительные блоки, каждый из которых назначается определенному заданию. Например, в компьютерной системе таким блоком выступает ядро
процессора, которое может выполнять по одной инструкции за раз.
Остановитесь на минуту и подумайте о себе как об отдельно взятом читателе этой
книги. Вы не можете читать две книги одновременно; чтобы взяться за одну книгу,
вам пришлось бы отложить другую. Но скооперировавшись с другом, вы можете
читать две книги параллельно.
Но что, если вам нужно прочитать три книги? Поскольку вы не владеете мастерством параллельного чтения, одному из вас пришлось бы отложить текущую книгу
и взяться за новую. Проще говоря, вам или вашему другу нужно выделить достаточное количество времени для прочтения всех трех книг.
Для параллельного выполнения двух задач компьютерной системе нужно по меньшей мере два отдельных и независимых вычислительных блока. Современные процессоры содержат внутри несколько ядер, которые являются этими самыми блоками.
Например, четырехъядерный процессор имеет четыре вычислительных блока; следовательно, может выполнять сразу четыре параллельных задания. Для простоты предположим, что в этой главе мы имеем дело с воображаемым процессором, имеющим
всего одно ядро, вследствие чего лишен возможности параллельного выполнения.
Мы еще поговорим о многоядерных процессорах в соответствующих разделах.
Представьте, что у вас есть два ноутбука с нашим воображаемым процессором
внутри: один воспроизводит музыку, а другой ищет решение дифференциального
уравнения. Оба они работают параллельно, но если вы решите выполнить эти

384  Глава 13



Конкурентность

задачи на одном из них, используя один процессор с одним ядром, то это нельзя
будет сделать параллельно. На самом деле выполнение будет конкурентным.
Параллелизм возможен только в задачах, которые можно распараллелить. Это озна­
чает, что алгоритм можно разделить на части и запустить на разных вычислительных блоках. Однако на сегодня большинство алгоритмов, которые мы пишем,
по своей природе последовательны, а не параллельны. Даже в многопоточности
у каждого потока есть ряд последовательных инструкций, не подлежащих делению
на параллельные потоки выполнения.
Иными словами, операционной системе сложно автоматически разделить последовательный алгоритм на параллельные потоки выполнения; следовательно, этим
должен заниматься программист. Поэтому, даже имея многоядерный процессор,
вы все равно должны назначить каждый поток выполнения определенному ядру.
И если одно ядро получит несколько потоков, то их нельзя будет выполнить параллельно. В результате вы сможете наблюдать конкурентное поведение.
Если коротко, то наличие двух потоков, назначенных двум разным ядрам, несомненно, может привести к созданию двух параллельных потоков. Но если назначить их одному ядру, то получатся два конкурентных потока. В многоядерном
процессоре мы фактически наблюдаем смешанное поведение: как параллелизм
между ядрами, так и конкурентность в рамках одного ядра.
Несмотря на простое определение и бесчисленные примеры из повседневной
жизни, параллелизм — сложный аспект компьютерной архитектуры. На самом
деле это отдельная дисциплина, с собственными теориями, книгами и учебной
литературой. Проектирование операционной системы, которая может разделить
последовательный алгоритм на какие-то параллельные потоки выполнения, — активное направление исследований, и текущие ОС не умеют этого делать.
Как уже отмечалось, цель текущей главы заключается не в сколь-нибудь глубоком
обсуждении параллелизма, а лишь в том, чтобы дать вам базовое определение этой
концепции. Поскольку любые дальнейшие подробности выходят за рамки нашей
книги, мы можем переходить к понятию конкурентности.
Для начала поговорим о конкурентных системах и посмотрим, что они собой представляют в сравнении с параллельными.

Конкурентность
Вы, наверное, уже слышали о многозадачности — что ж, конкурентность зиждется
на той же идее. Если ваша система выполняет сразу несколько задач, то это вовсе не означает их параллельную работу. В данном процессе может участвовать
планировщик заданий; он просто очень быстро переключается между разными за-

Планировщик заданий   385

даниями, выполняя некую крошечную часть каждого из них за довольно короткий
промежуток времени.
Это, несомненно, происходит на компьютере с одним вычислительным блоком.
В оставшейся части данного раздела предполагается, что мы имеем дело именно
с таким компьютером.
Если планировщик достаточно быстрый и беспристрастный, то вы не заметите
переключения между заданиями; вам будет казаться, что они работают параллельно.
В этом вся магия конкурентности, и именно поэтому она применяется в большинстве широко известных операционных систем, включая Linux, macOS и Microsoft
Windows.
Конкурентность можно считать имитацией параллельного выполнения заданий
на одном вычислительном блоке. На самом деле это своего рода искусственный
параллелизм. Для старых систем с одним одноядерным процессором это было
большим шагом вперед, который позволил людям использовать одно ядро в многозадачной манере.
К слову, Multics была одной из первых операционных систем с поддержкой многозадачности и управления одновременными процессами. Как вы можете помнить
из главы 10, проект Unix был основан на идеях, почерпнутых из Multics.
Как уже объяснялось ранее, почти все ОС могут выполнять конкурентные задания, используя многозадачность. Особенно это относится к POSIX-совместимым
системам, поскольку эта возможность явно обозначена в стандарте POSIX.

Планировщик заданий
Как уже отмечалось, у всех многозадачных операционных систем в ядре должен
быть планировщик заданий, или просто планировщик. В этом разделе вы увидите,
как работает данный компонент и какую роль играет в беспрепятственном выполнении конкурентных заданий.
Ниже перечислены несколько фактов о планировщике.
zz У планировщика есть очередь заданий, ждущих, когда их выполнят. Задание —

просто наборы операций, которые должны быть выполнены в отдельных потоках.
zz Очередь обычно поддерживает приоритеты. Высокоприоритетные задания вы-

полняются в первую очередь.
zz Планировщик управляет вычислительными ресурсами и разделяет их между

всеми заданиями. Когда вычислительный блок свободен (его не использует
ни одно задание), планировщик должен выбрать из очереди следующее задание

386  Глава 13



Конкурентность

и назначить его этому блоку. Когда задание заканчивает работу, оно освобо­
ждает вычислительный блок и снова делает его доступным, после чего планировщик выбирает очередное задание. Описанное продолжается в непрерывном
цикле и называется планированием заданий, и это единственная обязанность
планировщика.
zz Существует много разных алгоритмов планирования, которые может использо-

вать планировщик, но все они должны соответствовать определенным требованиям. Например, им следует быть беспристрастными, и ни одно задание в очереди
не должно оставаться без внимания на протяжении долгого времени.
zz В зависимости от выбранной стратегии планировщик должен либо выделить

определенный временной интервал, на протяжении которого задание может
использовать вычислительный блок, либо подождать, пока блок не будет
освобожден.
zz Если стратегия планирования вытесняющая, то у планировщика должна быть

возможность принудительно освобождать ядро процессора, чтобы отдать его
следующему заданию. Это называется вытесняющим планированием. Есть еще
одна стратегия, в которой задание добровольно освобождает процессор, — так
называемое кооперативное планирование.
zz Алгоритмы вытесняющего планирования пытаются разделять временные ин-

тервалы равномерно и беспристрастно между разными заданиями. Задания
с повышенным приоритетом могут выбираться чаще других или даже получать
более длинные интервалы в зависимости от реализации планировщика.
Задание — абстрактная концепция общего характера, которая может означать любой набор инструкций, предназначенный для выполнения в конкурентной системе,
не обязательно компьютерной. О каких именно некомпьютерных системах идет
речь, мы поговорим чуть позже. Точно так же процессор не единственный ресурс,
который можно разделять между заданиями. Люди всегда занимались планированием и приоритизацией заданий, непригодных для одновременного выполнения.
В следующих нескольких абзацах мы рассмотрим такие ситуации в качестве наглядных примеров планирования.
Перенесемся в начало ХХ века и представим, что на улице стоит всего одна телефонная будка, к которой растянулась очередь из десяти человек. В этом случае
очередь должна соблюдать алгоритм планирования, чтобы время пользования
телефоном справедливо разделялось между всеми ее участниками.
Прежде всего мы должны сформировать очередь. Первое, что приходит на ум цивилизованному человеку в подобной ситуации, — подождать своей очереди. Но этого
недостаточно; организация такого подхода требует неких правил. Вы не можете
разговаривать по телефону сколько вздумается, когда за вами еще девять человек.
Через какое-то время вы должны покинуть будку и уступить место следующему
человеку в очереди.

Процессы и потоки   387

В редких случаях, когда время вышло, но человек не успел завершить разговор, он покидает будку и становится в конец очереди. Затем он должен подождать, чтобы иметь
возможность вернуться к тому, на чем его прервали. Таким образом, все десять человек
станут по очереди заходить в телефонную будку, пока не наговорятся вдосталь.
Это всего лишь иллюстрация. Мы ежедневно встречаем примеры разделения ресурсов между несколькими потребителями, и люди придумали много способов, как делать это по справедливости, — насколько позволяет человеческая природа! В следу­
ющем разделе мы поговорим о планировании в контексте компьютерных систем.

Процессы и потоки
В этой книге нас в основном интересует планирование в компьютерных системах.
В ОС роль заданий играют либо процессы, либо потоки. Мы рассмотрим их и разницу между ними в следующих главах, но пока вам следует знать, что большинство
операционных систем обращаются с ними практически одинаковым образом: как
с некими заданиями, которые необходимо выполнять конкурентно.
Операционная система должна использовать планировщик в целях разделения
ядер процессора между множеством заданий, будь то процессы или потоки, которым для выполнения нужно процессорное время. В момент создания новый
процесс или поток поступает в очередь планировщика в качестве нового задания
и, прежде чем начинать работу, ждет, когда ему выделят процессорное ядро.
Если у вас установлен вытесняющий планировщик (с разделением времени) и задание не успевает закончить работу в отведенное ему время, то он принудительно
освобождает ядро процессора, а задание опять попадает в очередь — точно так же,
как в примере с телефонной будкой.
В данном случае задание будет находиться в очереди, пока снова не получит доступ
к ядру процессора, после чего сможет продолжить работу. Если ему не удается завершить свою логику со второй попытки, то процедура повторяется, пока задание
не закончится.
Ситуация, когда вытесняющий планировщик останавливает процесс посреди выполнения и запускает вместо него другой, называется переключением контекста.
Чем быстрее переключается контекст, тем сильнее ощущение параллельной работы
заданий. Интересно, что большинство ОС на сегодня используют вытесняющий
планировщик, и именно на нем мы сосредоточим свое внимание в оставшейся
части этой главы.
С этого момента мы будем подразумевать, что все упоминаемые мной
планировщики являются вытесняющими. Иные ситуации будут оговорены отдельно.

388  Глава 13



Конкурентность

В ходе выполнения задание может испытывать сотни и даже тысячи переключений
контекста. Однако эти переключения имеют одно причудливое и уникальное свойство — они непредсказуемы. Иными словами, мы не знаем, когда или даже на какой
инструкции контекст будет переключен. Даже в двух максимально приближенных
прогонах программы на одной и той же платформе переключения контекста будут
отличаться.
Значимость данного факта и его последствий сложно переоценить; переключение
контекста невозможно предсказать! Чуть позже вы увидите примеры, которые показывают, что из этого следует.
Переключения контекста крайне непредсказуемы, поэтому лучше всего предполагать, что они с одинаковой вероятностью могут произойти на любой инструкции.
То есть вы должны быть готовы к тому, что при каждом выполнении программы
все инструкции подвержены переключению контекста. Это просто означает возможный разрыв между выполнением двух соседних инструкций.
Учитывая все вышесказанное, пойдем дальше и рассмотрим единственные аспекты
конкурентной среды, в которых можно быть уверенными.

Порядок выполнения инструкций
В предыдущем разделе мы определились с тем, что переключения контекста непредсказуемы; мы не знаем, в какие моменты они могут возникнуть в наших программах. Несмотря на это, об инструкциях, выполняющихся конкурентно, можно
сказать кое-что определенное.
Рассмотрим простой пример. Мы будем исходить из того, что у нас есть задание
с пятью инструкциями, наподобие показанного в листинге 13.1. Обратите внимание: эти инструкции абстрактны и никак не связаны с машинными инструкциями
или инструкциями языка C.
Листинг 13.1. Простое задание с пятью инструкциями

Task P
1.
2.
3.
4.
5.
}

{
num = 5
num++
num = num – 2
x = 10
num = num + x

Как видите, инструкции пронумерованы; это значит, они должны быть выполнены
в заданном порядке, чтобы соответствовать назначению задания. Мы в этом уверены. Говоря техническим языком, каждая следующая инструкция может начинать
работу только после завершения предыдущей. Инструкция num++ должна быть

Порядок выполнения инструкций  389

выполнена перед num = num – 2, и это ограничение должно соблюдаться независимо
от того, как переключается контекст.
Обратите внимание: мы по-прежнему не знаем, когда будут происходить переключения контекста; необходимо помнить, что это может случиться между любыми
двумя инструкциями.
В листинге 13.2 представлены два возможных варианта выполнения нашего задания с разными переключениями контекста.
Листинг 13.2. Один потенциальный прогон задания с переключениями контекста

Run 1:
1. num = 5
2. num++
>>>>> Context Switch > Context Switch Context Switch done = FALSE;
pthread_mutex_init(&shared_state->mtx, NULL);
pthread_cond_init(&shared_state->cv, NULL);
}
// Уничтожает члены объекта shared_state_t
void shared_state_destroy(shared_state_t *shared_state) {
pthread_mutex_destroy(&shared_state->mtx);
pthread_cond_destroy(&shared_state->cv);
}
void* thread_body_1(void* arg) {
shared_state_t* ss = (shared_state_t*)arg;
pthread_mutex_lock(&ss->mtx);
printf("A\n");
ss->done = TRUE;
// Шлем сигнал потокам, ожидающим условной переменной
pthread_cond_signal(&ss->cv);
pthread_mutex_unlock(&ss->mtx);
return NULL;
}
void* thread_body_2(void* arg) {
shared_state_t* ss = (shared_state_t*)arg;
pthread_mutex_lock(&ss->mtx);
// Ждем, пока флаг не станет равным TRUE
while (!ss->done) {
// Ждем условную переменную
pthread_cond_wait(&ss->cv, &ss->mtx);
}
printf("B\n");
pthread_mutex_unlock(&ss->mtx);
return NULL;
}
int main(int argc, char** argv) {
// Разделяемое состояние
shared_state_t shared_state;

Управление конкурентностью в POSIX   475
// Инициализируем разделяемое состояние
shared_state_init(&shared_state);
// Обработчики потоков
pthread_t thread1;
pthread_t thread2;
// Создаем новые потоки
int result1 =
pthread_create(&thread1, NULL, thread_body_1, &shared_state);
int result2 =
pthread_create(&thread2, NULL, thread_body_2, &shared_state);
if (result1 || result2) {
printf("The threads could not be created.\n");
exit(1);
}
// Ждем, пока потоки не завершат работу
result1 = pthread_join(thread1, NULL);
result2 = pthread_join(thread2, NULL);
if (result1 || result2) {
printf("The threads could not be joined.\n");
exit(2);
}
// Уничтожаем разделяемое состояние, освобождаем мьютекс
// и объекты условных переменных
shared_state_destroy(&shared_state);
return 0;
}

Здесь используется удобная структура, в которую инкапсулируются разделя­
емый мьютекс, разделяемая условная переменная и разделяемый флаг. Заметьте,
что в каждый поток можно передать только по одному указателю. Поэтому нам
пришлось поместить все наши разделяемые ресурсы в одну структурную переменную.
В качестве второго типа (после bool_t ) мы определили shared_state_t (листинг 16.3).
Листинг 16.3. Размещение всех разделяемых переменных из примера 16.1
в одной структуре

typedef struct {
bool_t
done;
pthread_mutex_t mtx;
pthread_cond_t cv;
} shared_state_t;

476   Глава 16



Синхронизация потоков

После типов мы определили две функции, предназначенные для инициализации
и удаления экземпляров shared_state_t. Их можно считать конструктором и деструктором типа shared_state_t соответственно. Больше информации о конструкторах и деструкторах можно найти в главе 6.
Вот как мы используем условную переменную. Поток, который ее ждет (пребывая
в состоянии «сна»), рано или поздно получает уведомление и «просыпается». Более
того, поток может уведомлять (или «пробуждать») любые другие потоки, ожидающие (в состоянии «сна») условную переменную. Все эти операции должны быть
защищены мьютексом, и именно поэтому мьютексы всегда необходимо применять
в сочетании с условными переменными.
Именно это мы и сделали в коде, приведенном выше. В нашем объекте разделяемого
состояния есть условная переменная и сопутствующий мьютекс, который должен ее
защищать. Следует еще раз подчеркнуть, что условную переменную нужно использовать только на критических участках, защищенных сопутствующим мьютексом.
Так что же происходит в этом коде? Поток, который должен вывести A, пытается заблокировать мьютекс mtx, используя указатель на объект разделяемого состояния.
Получив блокировки, данный поток выводит A, устанавливает флаг done и вызывает функцию pthread_cond_signal, чтобы уведомить другой поток, который в это
время может ждать условную переменную cv.
С другой стороны, если второй поток станет активным до того, как первый успеет
вывести A, то сам попытается получить блокировку мьютекса mtx и в случае успеха
проверит флаг done. Если флаг равен false, то это просто означает, что первый
поток еще не зашел на критический участок (в противном случае был бы равен
true). Следовательно, второй поток подождет условную переменную и немедленно
освободит процессор, вызвав функцию pthread_cond_wait.
Обязательно обратите внимание на то, что во время ожидания условной переменной связанный с ней мьютекс освобождается и другой поток может продолжить
работу. Вдобавок при активации и выходе из состояния ожидания необходимо снова получить мьютекс. Познакомиться с условными переменными поближе можно,
проанализировав другие потенциальные варианты чередований.
Функцию pthread_cond_signal можно использовать для уведомления
только единственного потока. Если вы хотите уведомить все потоки, которые ждут условную переменную, то для этого предусмотрена функция
pthread_cond_broadcast. Вскоре вы увидите соответствующий пример.

Но почему мы использовали для проверки флага done цикл while, а не обычное
выражение if? Дело в том, что второй поток могут уведомить другие источники,
а не только первый поток. В таких случаях, если потоку при выходе из состояния
ожидания и активации удается заблокировать мьютекс, то он может проверить

Управление конкурентностью в POSIX   477

условие цикла и в случае невыполнения снова подождать. Ожидание условной
переменной внутри цикла — приемлемый метод.
Приведенное выше решение соответствует и ограничению, связанному с видимостью памяти. Как уже объяснялось в предыдущих главах, все операции блокировки
и разблокировки приводят к согласованию памяти между разными ядрами процессора; поэтому значения разных закэшированных флагов done всегда совпадают
и являются самыми актуальными.
Проблему с состоянием гонки, которую мы наблюдали в примерах 15.2 и 16.1 (при
отсутствии управляющих механизмов), также можно решить с помощью POSIXбарьеров. В следующем подразделе мы поговорим о них и перепишем пример 16.1,
задействуя другой подход.

POSIX-барьеры
POSIX-барьеры используют другой метод синхронизации разных потоков. Представьте группу людей, которые планируют выполнять какие-то задачи параллельно;
на определенных этапах им нужно встречаться, пересматривать планы и продолжать работу. То же самое может происходить с потоками (и даже процессами).
Одни потоки выполняют задания быстрее, а другие — медленнее. Мы можем преду­
смотреть контрольную точку (или точку встречи), при достижении которой все
потоки должны остановиться и подождать, пока к ним не присоединятся остальные.
Эти контрольные точки можно имитировать с помощью POSIX-барьеров.
Код листинга 16.4 использует барьеры, чтобы решить проблемы, которые мы наблюдали в примере 16.1. Напомню, что там у нас было два потока. Один из них
должен был выводить A, а другой — B, и мы хотели, чтобы, независимо от чередований, буква A всегда выводилась первой.
Листинг 16.4. Решение для примера 16.1 с применением POSIX-барьеров (ExtremeC_examples_
chapter16_1_barrier.c)

#include
#include
#include
// Объект барьера
pthread_barrier_t barrier;
void* thread_body_1(void* arg) {
printf("A\n");
// Ждем присоединения другого потока
pthread_barrier_wait(&barrier);
return NULL;
}

478   Глава 16



Синхронизация потоков

void* thread_body_2(void* arg) {
// Ждем присоединения другого потока
pthread_barrier_wait(&barrier);
printf("B\n");
return NULL;
}
int main(int argc, char** argv) {
// Инициализируем объект барьера
pthread_barrier_init(&barrier, NULL, 2);
// Обработчики потоков
pthread_t thread1;
pthread_t thread2;
// Создаем новые потоки
int result1 = pthread_create(&thread1, NULL,
thread_body_1, NULL);
int result2 = pthread_create(&thread2, NULL,
thread_body_2, NULL);
if (result1 || result2) {
printf("The threads could not be created.\n");
exit(1);
}
// Ждем, пока потоки не завершат работу
result1 = pthread_join(thread1, NULL);
result2 = pthread_join(thread2, NULL);
if (result1 || result2) {
printf("The threads could not be joined.\n");
exit(2);
}
// Уничтожаем объект барьера
pthread_barrier_destroy(&barrier);
return 0;
}

Как видите, код значительно уменьшился по сравнению с тем, в котором был основан на условных переменных. POSIX-барьеры позволяют легко синхронизировать
разные потоки на определенных этапах выполнения.
Вначале мы объявили глобальный объект барьера с типом pthread_barrier_t.
Затем внутри функции main инициализировали этот объект с помощью функции
pthread_barrier_init.

Управление конкурентностью в POSIX   479

Первый аргумент — указатель на объект барьера. Второй аргумент — пользовательские атрибуты данного объекта. Мы передаем NULL, поэтому атрибуты в объекте барьера будут инициализированы с помощью значений по умолчанию. Важную роль
играет третий аргумент: это количество потоков, которые должны инициировать
ожидание барьера путем вызова функции pthread_barrier_wait, и их выполнение
продолжится только после их освобождения.
В приведенном выше примере данный аргумент равен 2. Поэтому, когда два потока
начнут ждать объект барьера, оба они будут разблокированы и смогут продолжить
работу. Оставшийся код мало чем отличается от примеров, которые уже обсуждались в предыдущей главе.
Объект барьера можно реализовать с помощью мьютекса и условной переменной,
подобно тому что было показано в предыдущем разделе. На самом деле POSIXсовместимые операционные системы не предоставляют такого механизма, как
барьер, в своих интерфейсах системных вызовов, и большинство реализаций
основаны на мьютексах и условных переменных.
В сущности, вот почему некоторые ОС, такие как macOS, не предоставляют
реализации POSIX-барьеров. Показанный выше код нельзя скомпилировать на
компьютере с macOS ввиду отсутствия соответствующих функций. Код проверялся в Linux и FreeBSD; он работает в обеих системах. Поэтому будьте осторожны
при использовании барьеров, поскольку их наличие делает ваш код менее переносимым.
Тот факт, что macOS не предоставляет функций для работы с POSIXбарьерами, означает: данная система лишь частично совместима
с POSIX, и программы, в которых используются барьеры (являющиеся
частью стандарта), нельзя скомпилировать на компьютерах с macOS.
Это противоречит одному из принципов философии языка C: напиши
один раз — компилируй где угодно.

В заключение отмечу, что POSIX-барьеры обеспечивают видимость памяти. Подоб­
но операциям блокировки и разблокировки, ожидание барьеров гарантирует, что
все закэшированные версии одной и той же переменной синхронизируются между
разными потоками непосредственно перед выходом из барьера.
В следующем подразделе мы рассмотрим пример семафоров. Этот механизм нечасто используется в конкурентном программировании, но имеет специфические
сферы применения.
Широкое распространение получили специальные двоичные семафоры (то же
самое, что мьютексы), и выше мы неоднократно сталкивались с ними.

480  Глава 16



Синхронизация потоков

POSIX-семафоры
В большинстве случаев мьютексов (или двоичных семафоров) достаточно для синхронизации разных потоков, обращающихся к разделяемому ресурсу. Дело в том,
что для последовательного выполнения операций чтения и записи на критическом
участке должен находиться только один поток. Это называется взаимным исключением (mutual exclusion — отсюда и название «мьютекс»).
Но иногда у вас может возникнуть необходимость в том, чтобы сразу несколько
потоков могло работать с разделяемым ресурсом на критическом участке. В таких
случаях следует использовать семафоры общего вида.
Прежде чем переходить к обычным семафорам, рассмотрим пример, в котором используется двоичный семафор (или мьютекс). Вместо pthread_mutex_* мы будем
применять функции sem_*, которые предоставляют возможности, относящиеся
к семафорам.

Двоичные семафоры
В листинге 16.5 показано решение для примера 15.3 на основе семафоров. Напомню: этот пример состоял из двух потоков, каждый из которых инкрементировал
разделяемое целочисленное значение на определенную величину. Наша задача
состоит в том, чтобы обеспечить целостность разделяемой переменной. Обратите
внимание: в коде мы не будем использовать POSIX-мьютексы.
Листинг 16.5. Решение для примера 15.3 с использованием POSIX-семафоров (ExtremeC_
examples_chapter15_3_sem.c)

#include
#include
// Стандартный заголовок POSIX для использования библиотеки pthread
#include
// Семафоры недоступны в заголовке pthread.h
#include
// Главный указатель на объект семафора, применяемый
// для синхронизации доступа к разделяемому состоянию.
sem_t *semaphore;
void* thread_body_1(void* arg) {
// Получаем указатель на разделяемую переменную
int* shared_var_ptr = (int*)arg;
// Ждем семафор
sem_wait(semaphore);
// Инкрементируем разделяемую переменную на 1,
// выполняя запись непосредственно по ее адресу в памяти

Управление конкурентностью в POSIX  481
(*shared_var_ptr)++;
printf("%d\n", *shared_var_ptr);
// Освобождаем семафор
sem_post(semaphore);
return NULL;
}
void* thread_body_2(void* arg) {
// Получаем указатель на разделяемую переменную
int* shared_var_ptr = (int*)arg;
// Ждем семафор
sem_wait(semaphore);
// Инкрементируем разделяемую переменную на 1,
// выполняя запись непосредственно по ее адресу в памяти
(*shared_var_ptr) += 2;
printf("%d\n", *shared_var_ptr);
// Освобождаем семафор
sem_post(semaphore);
return NULL;
}
int main(int argc, char** argv) {
// Разделяемая переменная
int shared_var = 0;
// Обработчики потоков
pthread_t thread1;
pthread_t thread2;
#ifdef __APPLE__
// Неименованные семафоры не поддерживаются в OS/X. Поэтому
// семафор нужно инициализировать как именованный, используя
// функцию sem_open
semaphore = sem_open("sem0", O_CREAT | O_EXCL, 0644, 1);
#else
sem_t local_semaphore;
semaphore = &local_semaphore;
// Инициализируем семафор как мьютекс (двоичный семафор)
sem_init(semaphore, 0, 1);
#endif
// Создаем новые потоки
int result1 = pthread_create(&thread1, NULL,
thread_body_1, &shared_var);
int result2 = pthread_create(&thread2, NULL,
thread_body_2, &shared_var);
if (result1 || result2) {
printf("The threads could not be created.\n");
exit(1);
}

482  Глава 16



Синхронизация потоков

// Ждем, пока потоки не завершат работу
result1 = pthread_join(thread1, NULL);
result2 = pthread_join(thread2, NULL);
if (result1 || result2) {
printf("The threads could not be joined.\n");
exit(2);
}
#ifdef __APPLE__
sem_close(semaphore);
#else
sem_destroy(semaphore);
#endif
return 0;
}

Первое, что бросается в глаза в приведенном выше коде, — использование других
функций для работы с семафорами в системах Apple. В этих ОС (macOS, OS X
и iOS) не поддерживаются неименованные семафоры. Как следствие, мы не могли
применить функции sem_init и sem_destroy. У неименованных семафоров нет имен
(что неудивительно), и разные потоки могут работать с ними только внутри процесса. Для сравнения, именованные семафоры доступны на уровне системы и их
могут видеть и использовать разные процессы.
В системах Apple функции, необходимые для создания неименованных семафоров,
помечены как устаревшие, и объект семафора нельзя инициализировать с помощью sem_init. Таким образом, мы используем вместо этого функции sem_open
и sem_close, чтобы определить именованные семафоры.
Именованные семафоры используются для синхронизации процессов, и мы обсудим их в главе 18. В других POSIX-совместимых операционных системах,
в частности в Linux, мы по-прежнему можем применять неименованные семафоры
и инициализировать/удалять их с помощью функций sem_init и sem_destroy соответственно.
В приведенном выше коде мы подключили дополнительный заголовочный файл,
semaphore.h . Как уже объяснялось прежде, поддержка семафоров появилась
в библиотеке потоков POSIX в виде расширения, поэтому они недоступны в заголовке pthread.h.
Подключив заголовки, мы объявили глобальный указатель на адрес, по которому
находится объект семафора. Нам приходится это делать, поскольку в системах Apple
необходимо использовать функцию sem_open, которая возвращает указатель.
Затем внутри функции main мы определили блок для систем Apple, в котором создается именованный семафор sem0. В POSIX-совместимых операционных системах

Управление конкурентностью в POSIX  483

мы инициализируем семафор с помощью sem_init. Стоит отметить, что в данном
случае указатель semaphore ссылается на переменную local_sempahore, которая
находится на вершине стека главного потока. semaphore не может превратиться
в висячий указатель, поскольку главный поток присоединяет два других потока
и ждет их завершения.
Обратите внимание: макрос __APPLE__ позволяет нам различать системы Apple
и другие ОС. Он определен по умолчанию в препроцессорах языка C в системах
Apple. Таким образом, с его помощью можно исключить код, который не должен
компилироваться в этих системах.
Заглянем внутрь потоков. В функции-компаньоне критические участки защищены
sem_wait и sem_post — аналогами функций pthread_mutex_lock и pthread_mutex_
unlock соответственно в API для работы с POSIX-мьютексами. Заметьте, что
sem_wait позволяет заходить на критический участок нескольким потокам.
Максимальное количество потоков, которым позволено находиться на критическом
участке, определяется во время инициализации объекта семафора с помощью последнего аргумента функций sem_open и sem_init. Мы передали значение 1, поэтому
наш семафор должен вести себя подобно мьютексу.
Чтобы лучше понять, как работают семафоры, немного углубимся в детали. У каждого объекта семафора есть целочисленное значение. Если оно больше нуля и поток,
который ожидает семафор, вызывает функцию sem_wait, то значение уменьшается
на 1, а потоку разрешается зайти на критический участок. Если оно равно нулю, то
поток должен подождать, пока оно снова не станет положительным. Каждый раз,
когда поток выходит с критического участка, вызывая функцию sem_post, значение
семафора увеличивается на 1. Следовательно, если с самого начала сделать его
равным 1, то мы в конечном счете получим двоичный семафор.
В конце нашего кода вызывается функция sem_destroy (или sem_close в системах
Apple), который фактически освобождает объект семафора со всеми его внутренними ресурсами. Что касается именованных семафоров, то они могут разделяться
между разными процессами, поэтому при их закрытии могут возникать более
сложные ситуации. Мы поговорим о них в главе 18.

Семафоры общего вида
Пришло время рассмотреть классический пример, в котором использованы семафоры общего вида. Синтаксис довольно похож на показанный в предыдущем
листинге, но ситуация, когда на критический участок позволено заходить сразу
нескольким потокам, может оказаться интересной.
В классическом примере речь идет о создании 50 молекул воды. Чтобы их получить,
нам понадобится 50 атомов кислорода и 100 атомов водорода. Если представить

484  Глава 16



Синхронизация потоков

каждый атом в виде отдельного потока, то, чтобы сгенерировать молекулу воды,
на свои критические участки должны зайти два потока для водорода и один для
кислорода.
В следующем коде мы сначала создаем 50 потоков для кислорода и 100 для водорода. В первых критические участки будут защищены мьютексом, а во вторых —
семафором общего вида, который позволяет находиться на критическом участке
сразу двум потокам.
Для уведомления потоков мы используем POSIX-барьеры, но, поскольку они
не поддерживаются в системах Apple, нам нужно реализовать их в виде мьютексов
и условных переменных. Соответствующий код показан в листинге 16.6.
Листинг 16.6. Использование семафоров общего вида для имитации процесса создания
50 молекул воды из 50 атомов кислорода и 100 атомов водорода (ExtremeC_examples_chapter16_2.c)

#include
#include
#include
#include
#include





// For errno and strerror function

// Стандартный заголовок POSIX для использования библиотеки pthread
#include
// Семафоры недоступны в заголовке pthread.h
#include
#ifdef __APPLE__
// В системах Apple нужно имитировать возможности для работы с барьерами
pthread_mutex_t barrier_mutex;
pthread_cond_t barrier_cv;
unsigned int
barrier_thread_count;
unsigned int
barrier_round;
unsigned int
barrier_thread_limit;
void barrier_wait() {
pthread_mutex_lock(&barrier_mutex);
barrier_thread_count++;
if (barrier_thread_count >= barrier_thread_limit) {
barrier_thread_count = 0;
barrier_round++;
pthread_cond_broadcast(&barrier_cv);
} else {
unsigned int my_round = barrier_round;
do {
pthread_cond_wait(&barrier_cv, &barrier_mutex);
} while (my_round == barrier_round);
}
pthread_mutex_unlock(&barrier_mutex);
}

Управление конкурентностью в POSIX   485
#else
// Барьер для синхронизации потоков водорода и кислорода
pthread_barrier_t water_barrier;
#endif
// Мьютекс для синхронизации потоков кислорода
pthread_mutex_t
oxygen_mutex;
// Общий семафор для синхронизации потоков водорода
sem_t*
hydrogen_sem;
// Разделяемое целое число для подсчета созданных молекул воды
unsigned int
num_of_water_molecules;
void* hydrogen_thread_body(void* arg) {
// На этот критический участок может зайти два потока водорода
sem_wait(hydrogen_sem);
// Ждем присоединения другого потока водорода
#ifdef __APPLE__
barrier_wait();
#else
pthread_barrier_wait(&water_barrier);
#endif
sem_post(hydrogen_sem);
return NULL;
}
void* oxygen_thread_body(void* arg) {
pthread_mutex_lock(&oxygen_mutex);
// Ждем присоединения потоков водорода
#ifdef __APPLE__
barrier_wait();
#else
pthread_barrier_wait(&water_barrier);
#endif
num_of_water_molecules++;
pthread_mutex_unlock(&oxygen_mutex);
return NULL;
}
int main(int argc, char** argv) {
num_of_water_molecules = 0;
// Инициализируем мьютекс кислорода
pthread_mutex_init(&oxygen_mutex, NULL);
// Инициализируем семафор водорода
#ifdef __APPLE__
hydrogen_sem = sem_open("hydrogen_sem",
O_CREAT | O_EXCL, 0644, 2);
#else
sem_t local_sem;

486  Глава 16



Синхронизация потоков

hydrogen_sem = &local_sem;
sem_init(hydrogen_sem, 0, 2);
#endif
// Инициализируем барьер воды
#ifdef __APPLE__
pthread_mutex_init(&barrier_mutex, NULL);
pthread_cond_init(&barrier_cv, NULL);
barrier_thread_count = 0;
barrier_thread_limit = 0;
barrier_round = 0;
#else
pthread_barrier_init(&water_barrier, NULL, 3);
#endif
// Чтобы создать 50 молекул воды, нам понадобится 50 атомов кислорода
// и 100 атомов водорода
pthread_t thread[150];
// Создаем потоки кислорода
for (int i = 0; i < 50; i++) {
if (pthread_create(thread + i, NULL,
oxygen_thread_body, NULL)) {
printf("Couldn't create an oxygen thread.\n");
exit(1);
}
}
// Создаем потоки водорода
for (int i = 50; i < 150; i++) {
if (pthread_create(thread + i, NULL,
hydrogen_thread_body, NULL)) {
printf("Couldn't create an hydrogen thread.\n");
exit(2);
}
}
printf("Waiting for hydrogen and oxygen atoms to react ...\n");
// Ждем завершения всех потоков
for (int i = 0; i < 150; i++) {
if (pthread_join(thread[i], NULL)) {
printf("The thread could not be joined.\n");
exit(3);
}
}
printf("Number of made water molecules: %d\n",
num_of_water_molecules);
#ifdef __APPLE__
sem_close(hydrogen_sem);

Управление конкурентностью в POSIX   487
#else
sem_destroy(hydrogen_sem);
#endif
return 0;
}

Несколько начальных строчек этого кода находятся между #ifdef __APPLE__
и #endif. Эти строчки компилируются только в системах Apple и в основном представляют собой реализацию и переменные, необходимые для имитации поведения
POSIX-барьеров. В других POSIX-совместимых системах используются POSIXбарьеры. Мы не станем подробно останавливаться на том, как реализованы барьеры
в системах Apple, но если хотите в этом разобраться, то можете почитать код.
Среди ряда глобальных переменных, определенных в этом коде, можно заметить
мьютекс oxygen_mutex, который должен защищать критические участки потоков
кислорода. В любой момент времени на критическом участке должен находиться
только один такой поток.
Затем на самом критическом участке поток кислорода ждет, пока к нему не присоединятся два потока водорода, и продолжает работу, инкрементируя счетчик
молекул воды. Операция инкремента происходит внутри критического участка
кислорода.
Вы лучше поймете происходящее на критическом участке, когда мы рассмотрим роль,
которую играет семафор общего вида. В нашем коде мы объявили такой семафор,
hydrogen_sem, чтобы он защищал критический участок потоков водорода. На свои
критические участки эти потоки могут заходить по двое, и они должны ждать перед
объектом барьера, разделяющимся между потоками кислорода и водорода.
Когда количество потоков, ожидающих перед объектом барьера, достигает двух, это
означает, что у нас имеется один атом кислорода и два атома водорода; вуаля —
мы получаем молекулу воды, и все ожидающие потоки могут продолжить работу.
Потоки водорода сразу же завершаются, а поток кислорода сначала инкрементирует счетчик молекул воды.
В заключение отмечу, что в примере 16.2 при реализации барьеров для систем Apple
использоваласьфункция pthread_cond_broadcast. Она уведомляет все потоки,
которые ждут условную переменную барьера и должны продолжить работу после
присоединения к ним других потоков.
В следующем разделе речь пойдет о модели памяти, лежащей в основе POSIXпотоков, и о том, как эти потоки взаимодействуют с памятью процесса, который
ими владеет. Кроме того, мы рассмотрим примеры использования сегментов стека
и кучи и увидим, как из-за них могут возникать серьезные проблемы, относящиеся
к памяти.

488  Глава 16



Синхронизация потоков

POSIX-потоки и память
Этот раздел посвящен взаимодействию потоков и памяти процесса. Вы уже знае­
те, что структура памяти процесса состоит из нескольких сегментов, таких как
сегменты кода, стека, данных и кучи. Мы обсуждали их в главе 4. С каждым из
этих сегментов потоки работают по-разному. В рамках данного раздела будут рассмотрены только стек и куча, поскольку при написании многопоточных программ
эти области используются чаще других и вызывают больше всего проблем.
Кроме того, вы увидите, как синхронизация потоков и хорошее понимание их модели памяти может помочь в разработке более качественных конкурентных программ.
Эти концепции особенно очевидны в контексте сегмента кучи, поскольку управление памятью в нем происходит вручную, а в конкурентных системах за выделение
и освобождение блокировок в куче отвечают сами потоки. Элементарное состояние
гонки может вызвать серьезные проблемы с памятью, поэтому во избежание подобных катастроф необходимо должным образом озаботиться о синхронизации.
В следующем подразделе я объясню, как разные потоки обращаются к сегменту
стека и какие меры предосторожности при этом следует принимать.

Сегмент стека
У каждого потока есть собственный сегмент стека, который должен быть доступен
только ему. Стек потока — часть общего стека процесса-владельца, в котором по
умолчанию должны выделяться сегменты стека для всех потоков. Но стек может
быть выделен и в куче. В будущих примерах я покажу, как это делается, но пока мы
будем исходить из того, что стек потока принадлежит стеку процесса.
Поскольку все потоки внутри одного процесса могут читать и изменять его сегмент стека, они фактически имеют доступ к стекам друг друга. Но не должны
пользоваться этим доступом. Стоит отметить: работа со стеками других потоков
считается опасным поведением, так как переменные, определенные на вершине
стека, могут быть уничтожены в любой момент, особенно при завершении потока
или возвращении функции.
Вот почему мы пытаемся исходить из того, что каждый сегмент стека доступен
только его потоку-владельцу, но не другим потокам. Таким образом, локальные
переменные (объявленные на вершине стека) считаются приватными ресурсами
потока и другие потоки не должны к ним обращаться.
В однопоточных приложениях есть всего один, главный поток. Поэтому мы используем его сегмент стека так, словно это стек процесса. Причина в том, что в однопоточной программе между главным потоком и самим процессом нет никакого
разделения. Но в многопоточных программах все иначе. У каждого потока есть
свой стек, который отличается от стеков других потоков.

POSIX-потоки и память  489

При создании нового потока в стеке процесса выделяется блок памяти. Если программист самостоятельно не указал его размер, то будет использовано значение по
умолчанию, которое зависит от платформы и варьируется от одной архитектуры
к другой. В POSIX-совместимой системе можно использовать команду ulimit -s,
чтобы узнать размер стека по умолчанию.
На моей текущей платформе (macOS на 64-битном компьютере с процессором
Intel) стандартный размер стека равен 8 Мбайт (терминал 16.1).
Терминал 16.1. Вывод размера стека по умолчанию
$ ulimit -s
8192
$

API для работы с POSIX-потоками позволяет назначить новому потоку область
стека. В примере 16.3 (листинг 16.7) у нас два потока. В одном из них мы используем параметры стека по умолчанию, а в другом выделяем буфер в сегменте кучи
и назначаем его в качестве стека. Стоит отметить, что для выделяемого буфера
следует указывать минимальный размер, иначе его нельзя будет использовать
в качестве стека.
Листинг 16.7. Назначение блока кучи в качестве стека потока (ExtremeC_examples_chapter16_3.c)

#include
#include
#include
#include
void* thread_body_1(void* arg) {
int local_var = 0;
printf("Thread1 > Stack Address: %p\n", (void*)&local_var);
return 0;
}
void* thread_body_2(void* arg) {
int local_var = 0;
printf("Thread2 > Stack Address: %p\n", (void*)&local_var);
return 0;
}
int main(int argc, char** argv) {
size_t buffer_len = PTHREAD_STACK_MIN + 100;
// Буфер, выделенный из кучи и используемый как
// стек потока
char *buffer = (char*)malloc(buffer_len * sizeof(char));

490  Глава 16



Синхронизация потоков

// Обработчики потоков
pthread_t thread1;
pthread_t thread2;
// Создаем новый поток с атрибутами по умолчанию
int result1 = pthread_create(&thread1, NULL,
thread_body_1, NULL);
// Создаем новый поток с нашим собственным стеком
pthread_attr_t attr;
pthread_attr_init(&attr);
// Задаем адрес и размер стека
if (pthread_attr_setstack(&attr, buffer, buffer_len)) {
printf("Failed while setting the stack attributes.\n");
exit(1);
}
int result2 = pthread_create(&thread2, &attr,
thread_body_2, NULL);
if (result1 || result2) {
printf("The threads could not be created.\n");
exit(2);
}
printf("Main Thread > Heap Address: %p\n", (void*)buffer);
printf("Main Thread > Stack Address: %p\n", (void*)&buffer_len);
// Ждем, пока потоки не завершат работу
result1 = pthread_join(thread1, NULL);
result2 = pthread_join(thread2, NULL);
if (result1 || result2) {
printf("The threads could not be joined.\n");
exit(3);
}
free(buffer);
return 0;
}

В начале программы мы создаем первый поток со стандартными параметрами стека. Таким образом, стек должен быть выделен в одноименном сегменте процесса.
После этого создается второй поток, для которого в качестве стека указывается
адрес буфера.
Обратите внимание: указанное значение, 100 байт, превышает минимальный
размер стека, представленный макросом PTHREAD_STACK_MIN. Эта константа имеет
разные значения на разных платформах и подключается вместе с заголовочным
файлом limits.h.

POSIX-потоки и память  491

Если собрать и запустить эту программу на устройстве с Linux, то вы увидите примерно следующий результат (терминал 16.2).
Терминал 16.2. Сборка и выполнение примера 16.3
$ gcc ExtremeC_examples_chapter16_3.c -o ex16_3.out -lpthread
$ ./ex16_3.out
Main Thread > Heap Address: 0x55a86a251260
Main Thread > Stack Address: 0x7ffcb5794d50
Thread2 > Stack Address: 0x55a86a2541a4
Thread1 > Stack Address: 0x7fa3e9216ee4
$

Как следует из этого вывода, адрес локальной переменной local_var, выделенной
на вершине стека второго потока, находится в другом диапазоне (диапазоне пространства кучи). Это значит, что область стека второго потока находится в куче,
чего нельзя сказать о первом потоке.
В полученном выводе видно, что адрес локальной переменной в первом потоке
находится в диапазоне сегмента стека, принадлежащего процессу. В результате
мы могли легко выделить для нашего нового потока область стека в сегменте кучи.
В ряде ситуаций возможность назначать потоку область стека может оказаться незаменимой. Например, в средах с большими стеками, у которых общий объем памяти
является низким, или в высокопроизводительных системах, где выделение стека для
каждого потока было бы слишком расточительным, использование заранее выделенных буферов может иметь смысл. В целях предварительного выделения буфера в качестве области стека нового потока можно применить описанную выше процедуру.
Следующий пример, под номером 16.4, демонстрирует, как разделение адреса
в стеке одного из потоков может привести к проблемам с памятью. Поток, которому
принадлежит разделяемый адрес, должен оставаться активным, иначе все указатели
с этим адресом станут висячими.
Код, представленный в листинге 16.8, не является потокобезопасным, поэтому
можно ожидать, что при многократных прогонах время от времени будут возникать
сбои. Кроме того, потоки имеют стандартные параметры стека; это значит, что их
стеки выделяются в одноименном сегменте.
Листинг 16.8. Попытка прочитать переменную, выделенную в области стека другого потока
(ExtremeC_examples_chapter16_4.c)

#include
#include
#include
#include

492  Глава 16



Синхронизация потоков

int* shared_int;
void* t1_body(void* arg) {
int local_var = 100;
shared_int = &local_var;
// Ждем, пока другой поток не выведет разделяемое целое значение
usleep(10);
return NULL;
}
void* t2_body(void* arg) {
printf("%d\n", *shared_int);
return NULL;
}
int main(int argc, char** argv) {
shared_int = NULL;
pthread_t t1;
pthread_t t2;
pthread_create(&t1, NULL, t1_body, NULL);
pthread_create(&t2, NULL, t2_body, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}

Вначале мы объявили глобальный разделяемый указатель. Он может принимать
любой адрес, независимо от того, в каком месте структуры памяти процесса тот
находится: в сегменте стека, кучи или даже данных.
В представленном выше коде внутри функции-компаньона t1_body мы сохраняем
адрес локальной переменной в разделяемый указатель. Эта переменная принадлежит первому потоку и выделена на вершине его стека.
С этого момента по завершении первого потока разделяемый указатель станет
висячим, и любое его разыменование, скорее всего, проведет к сбою, логической
ошибке или в лучшем случае к неочевидным проблемам с памятью. Это происходит
в некоторых вариантах чередований, и в итоге, запустив программу несколько раз,
время от времени можно наблюдать сбои.
Необходимо отметить: если один поток хочет использовать переменную, выделенную в стеке другого потока, то следует реализовать подходящие методы синхронизации. Поскольку время жизни переменной в стеке привязано к ее области
видимости, синхронизация должна быть направлена на то, чтобы сохранять эту
область видимости активной, пока поток не закончит работать с переменной.

POSIX-потоки и память  493

Обратите внимание: мы не проверяем результаты функций pthread, чтобы не услож­
нять наш код, хотя проверка возвращаемых значений всегда приветствуется. Не все
функции pthread ведут себя одинаково на всех платформах, и если что-то пойдет
не так, то возвращаемые значения помогут вам об этом узнать.
В этом подразделе было в целом продемонстрировано, почему не следует разделять
адреса, принадлежащие сегментам стека, и почему разделяемые состояния лучше
не выделять в стеке. Дальше речь пойдет о самом распространенном месте для хранения разделяемых состояний — о куче. Как вы можете представить, работа с ней
тоже имеет сложности и нужно остерегаться утечек памяти.

Сегмент кучи
Сегменты кучи и данных доступны всем потокам, однако первый является динамическим и разделяется во время выполнения, тогда как второй генерируется на этапе
компиляции. Потоки могут читать и изменять содержимое кучи; оно существует на
протяжении всего времени жизни процесса и не зависит от отдельных его потоков.
Кроме того, в кучу можно поместить большие объекты. Благодаря совокупности
всех этих факторов куча — отличное место для хранения состояний, которые должны разделяться между разными потоками.
Когда дело доходит до выделения кучи, управление памятью превращается в настоящий кошмар. Причиной тому факт, что выделяемые ресурсы в конечном
счете должен освободить один из активных потоков, иначе можно столкнуться
с утечками памяти.
Применительно к конкурентным средам чередования могут легко привести к появлению висячих указателей, в результате чего возникают сбои. Важнейшая цель
синхронизации состоит в упорядочении выполнения таким образом, чтобы висячие
указатели не могли появиться, и достичь этого непросто.
Взгляните на пример 16.5, представленный ниже. Он состоит из пяти потоков.
Первый выделяет массив в куче, а второй и третий заполняют его следующим
образом. Второй присваивает элементам с четными индексами прописные буквы, начиная с Z и двигаясь обратно к A; третий присваивает элементам с нечетными индексами строчные буквы, начиная с a и двигаясь вперед к z. Четвертый
поток выводит полученный массив, а пятый его удаляет и освобождает память
в куче.
В данном примере следует применить все методы управления конкурентностью
с помощью средств POSIX, описанных в предыдущих разделах, чтобы не позволить
этим потокам выйти из-под контроля при работе с кучей. Листинг 16.9 не содержит управляющего механизма и потому явно не является типобезопасным. Стоит
отметить, что это не окончательная версия кода. Чуть позже мы добавим в нее
механизмы управления конкурентностью.

494  Глава 16



Синхронизация потоков

Листинг 16.9. Пример 16.5 без каких-либо механизмов синхронизации
(ExtremeC_examples_chapter16_5_raw.c)

#include
#include
#include
#include
#define CHECK_RESULT(result) \
if (result) { \
printf("A pthread error happened.\n"); \
exit(1); \
}
int TRUE = 1;
int FALSE = 0;
// Указатель на разделяемый массив
char* shared_array;
// Размер разделяемого массива
unsigned int shared_array_len;
void* alloc_thread_body(void* arg) {
shared_array_len = 20;
shared_array = (char*)malloc(shared_array_len * sizeof(char*));
return NULL;
}
void* filler_thread_body(void* arg) {
int even = *((int*)arg);
char c = 'a';
size_t start_index = 1;
if (even) {
c = 'Z';
start_index = 0;
}
for (size_t i = start_index; i < shared_array_len; i += 2) {
shared_array[i] = even ? c-- : c++;
}
shared_array[shared_array_len - 1] = '\0';
return NULL;
}
void* printer_thread_body(void* arg) {
printf(">> %s\n", shared_array);
return NULL;
}
void* dealloc_thread_body(void* arg) {
free(shared_array);

POSIX-потоки и память   495
return NULL;
}
int main(int argc, char** argv) {
... Create threads ...
}

Можно легко заметить, что этому коду не хватает потоковой безопасности: когда
пятый поток пытается освободить память массива, возникают серьезные сбои.
Каждый раз, получая процессорное время, пятый поток немедленно освобо­ждает
буфер, находящийся в куче, после чего указатель shared_array становится висячим и другие потоки начинают выходить из строя. Чтобы поток, освобождающий память, выполнялся последним и обеспечивался корректный порядок
выполнения логики в других потоках, нужно использовать подходящие средства
синхронизации.
В листинге 16.10 мы заворачиваем приведенный выше код в POSIX-объект в целях
управления конкурентностью, чтобы сделать его потокобезопасным.
Листинг 16.10. Пример 16.5 с механизмами синхронизации (ExtremeC_examples_chapter16_5.c)

#include
#include
#include
#include
#define CHECK_RESULT(result) \
if (result) { \
printf("A pthread error happened.\n"); \
exit(1); \
}
int TRUE = 1;
int FALSE = 0;
// Указатель на разделяемый массив
char* shared_array;
// Размер разделяемого массива
size_t shared_array_len;
pthread_barrier_t alloc_barrier;
pthread_barrier_t fill_barrier;
pthread_barrier_t done_barrier;
void* alloc_thread_body(void* arg) {
shared_array_len = 20;
shared_array = (char*)malloc(shared_array_len * sizeof(char*));

496  Глава 16



Синхронизация потоков

pthread_barrier_wait(&alloc_barrier);
return NULL;
}
void* filler_thread_body(void* arg) {
pthread_barrier_wait(&alloc_barrier);
int even = *((int*)arg);
char c = 'a';
size_t start_index = 1;
if (even) {
c = 'Z';
start_index = 0;
}
for (size_t i = start_index; i < shared_array_len; i += 2) {
shared_array[i] = even ? c-- : c++;
}
shared_array[shared_array_len - 1] = '\0';
pthread_barrier_wait(&fill_barrier);
return NULL;
}
void* printer_thread_body(void* arg) {
pthread_barrier_wait(&fill_barrier);
printf(">> %s\n", shared_array);
pthread_barrier_wait(&done_barrier);
return NULL;
}
void* dealloc_thread_body(void* arg) {
pthread_barrier_wait(&done_barrier);
free(shared_array);
pthread_barrier_destroy(&alloc_barrier);
pthread_barrier_destroy(&fill_barrier);
pthread_barrier_destroy(&done_barrier);
return NULL;
}
int main(int argc, char** argv) {
shared_array = NULL;
pthread_barrier_init(&alloc_barrier, NULL, 3);
pthread_barrier_init(&fill_barrier, NULL, 3);
pthread_barrier_init(&done_barrier, NULL, 2);
pthread_t
pthread_t
pthread_t
pthread_t
pthread_t

alloc_thread;
even_filler_thread;
odd_filler_thread;
printer_thread;
dealloc_thread;

POSIX-потоки и память   497
pthread_attr_t attr;
pthread_attr_init(&attr);
int res = pthread_attr_setdetachstate(&attr,
PTHREAD_CREATE_DETACHED);
CHECK_RESULT(res);
res = pthread_create(&alloc_thread, &attr,
alloc_thread_body, NULL);
CHECK_RESULT(res);
res = pthread_create(&even_filler_thread,
&attr, filler_thread_body, &TRUE);
CHECK_RESULT(res);
res = pthread_create(&odd_filler_thread,
&attr, filler_thread_body, &FALSE);
CHECK_RESULT(res);
res = pthread_create(&printer_thread, &attr,
printer_thread_body, NULL);
CHECK_RESULT(res);
res = pthread_create(&dealloc_thread, &attr,
dealloc_thread_body, NULL);
CHECK_RESULT(res);
pthread_exit(NULL);
return 0;
}

Чтобы сделать код в данном листинге потокобезопасным, достаточно добавить
в него POSIX-барьеры. Это самый простой способ гарантировать последовательное
выполнения нескольких потоков.
Если сравнить листинги 16.9 и 16.10, то можно увидеть, как POSIX-барьеры используются для упорядочения разных потоков. Единственное исключение составляют два потока фильтрации. Они могут выполняться независимо, не блокируя друг
друга, и, так как изменяют четные и нечетные индексы по отдельности, проблем
с конкурентностью возникнуть не может. Обратите внимание: приведенный выше
код нельзя скомпилировать в системах Apple. В таких ОС вам нужно имитировать
поведение барьеров с помощью мьютексов и условных переменных (как мы делали
в примере 16.2).
В терминале 16.3 показан вывод предыдущего кода. Независимо от того, сколько
раз вы его запускаете, он никогда не испытывает сбоев. Иными словами, этот код
защищен от различных чередований и является потокобезопасным.

498  Глава 16



Синхронизация потоков

Терминал 16.3. Сборка и выполнение примера 16.5
$ gcc ExtremeC_examples_chapter16_5.c -o ex16_5 -lpthread
$ ./ex16_5
>> ZaYbXcWdVeUfTgShRiQ
$ ./ex16_5
>> ZaYbXcWdVeUfTgShRiQ
$

В этом подразделе мы рассмотрели пример использования пространства кучи в качестве хранилища разделяемых состояний. В отличие от стека, в котором память
выделяется автоматически, куча требует ручного выделения памяти. В противном
случае неизбежным побочным эффектом будут утечки памяти.
Самое простое и зачастую оптимальное место для хранения разделяемых состояний
(с точки зрения усилий, которые программист должен приложить, чтобы управлять
памятью) — сегмент данных, в котором операции выделения и освобождения выполняются автоматически. Переменные, находящиеся в этом сегменте, считаются
глобальными и имеют максимально продолжительное время жизни — с момента
создания процесса до самого его завершения. Но в ряде ситуаций такое время
жизни может быть нежелательным, особенно если вам нужно хранить в сегменте
данных большой объект.
В следующем подразделе мы поговорим о видимости памяти и о том, как она гарантируется функциями POSIX.

Видимость памяти
Видимость памяти и когерентность уже обсуждались в предыдущих главах, когда
мы рассматривали системы с несколькими процессорными ядрами. Здесь же будет
показано, как видимость памяти гарантируется библиотекой pthread.
Как вы уже знаете, протокол когерентности кэша ядер процессора следит за тем,
чтобы все закэшированные версии одного адреса памяти во всех ядрах оставались
синхронизированными и соответствовали последним изменениям, внесенным
в одно из ядер. Однако этот протокол должен как-то инициироваться.
Существуют системные вызовы, которые приводят к срабатыванию протокола
когерентности кэша и делают память видимой для всех ядер процессора. В библио­
теке pthread тоже есть ряд функций, перед выполнением которых гарантируется
видимость памяти.
Кое-какие из них вы уже могли встречать ранее:
zz pthread_barrier_wait;
zz pthread_cond_broadcast;

POSIX-потоки и память  499
zz pthread_cond_signal;
zz pthread_cond_timedwait;
zz pthread_cond_wait;
zz pthread_create;
zz pthread_join;
zz pthread_mutex_lock;
zz pthread_mutex_timedlock;
zz pthread_mutex_trylock;
zz pthread_mutex_unlock;
zz pthread_spin_lock;
zz pthread_spin_trylock;
zz pthread_spin_unlock;
zz pthread_rwlock_rdlock;
zz pthread_rwlock_timedrdlock;
zz pthread_rwlock_timedwrlock;
zz pthread_rwlock_tryrdlock;
zz pthread_rwlock_trywrlock;
zz pthread_rwlock_unlock;
zz pthread_rwlock_wrlock;
zz sem_post;
zz sem_timedwait;
zz sem_trywait;
zz sem_wait;
zz semctl;
zz semop.

Помимо локальных кэшей в процессорных ядрах, компиляторы могут применять
механизмы кэширования для часто используемых переменных. Для этого компилятор должен проанализировать код и оптимизировать его таким образом, чтобы
часто используемые переменные записывались и считывались из его кэша. Это программные кэши, которые компилятор размещает в итоговом исполняемом файле,
чтобы оптимизировать и ускорить работу программы.
При написании многопоточного кода такой подход может принести не только
пользу, но и лишнюю головную боль, связанную с видимостью памяти. И потому
иногда для некоторых переменных эти кэши отключаются.

500   Глава 16



Синхронизация потоков

Переменные, которые компилятор не должен оптимизировать с помощью кэширования, можно объявлять изменчивыми. Стоит отметить, что изменчивые переменные могут кэшироваться на уровне процессора, но компилятор не станет размещать
их в своих кэшах. Изменчивая переменная объявляется с помощью ключевого
слова volatile. В листинге 16.11 показан пример объявления целочисленной изменчивой переменной.
Листинг 16.11. Объявление целочисленной изменчивой переменной

volatile int number;

Важной особенностью изменчивых переменных является то, что они не избавляют
вас от проблем видимости в многопоточных системах. Обеспечить видимость памяти можно с помощью перечисленных выше POSIX-функций, использованных
на подходящих участках кода.

Резюме
В этой главе мы обсудили механизмы управления конкурентностью, которые
предоставляются API POSIX для работы с потоками. Были рассмотрены следующие темы:
zz POSIX-мьютексы и их применение;
zz условные переменные и барьеры POSIX, а также их использование;
zz POSIX-семафоры и то, чем двоичные семафоры отличаются от семафоров

общего вида;
zz взаимодействие потоков с областью стека;
zz назначение потоку новой области стека, выделенной в куче;
zz взаимодействие потоков с пространством кучи;
zz видимость памяти и функции POSIX, которые ее гарантируют;
zz изменчивые переменные и кэши компилятора.

В следующей главе мы продолжим обсуждение и рассмотрим второй подход к конкурентности в программных системах — многопроцессность. Мы поговорим о том,
как выполнить процесс и чем он отличается от потока.

17

Процессы

Пришло время поговорить о системах, архитектура которых состоит из нескольких
процессов. Такие системы обычно называются многопроцессными. В этой и следующей главах мы рассмотрим концепцию многопроцессности и провести анализ
ее достоинств и недостатков в сравнении с многопоточностью, о которой речь шла
в главах 15 и 16.
В данной главе мы сосредоточимся на API и методиках для запуска новых процессов, а также увидим, как на самом деле выполняется процесс. Следующая глава
посвящена конкурентным средам, состоящим из нескольких процессов. Я объясню
способы разделения различных состояний между разными процессами и то, как
обычно происходит обращение к этим состояниям в многопроцессных средах.
Часть главы посвящена сравнению многопроцессных и многопоточных сред.
Вдобавок мы затронем локальные и распределенные многопроцессные системы.

API для выполнения процессов
Любая программа выполняется в виде процесса. Но поначалу она представляет
собой исполняемый двоичный файл, содержащий некоторые сегменты памяти
и, скорее всего, множество инструкций машинного уровня. А вот процесс —
это отдельный экземпляр программы на этапе ее выполнения. Таким образом,
одну скомпилированную программу (или исполняемый двоичный файл) можно
выполнять по нескольку раз в разных процессах. На самом деле это основная
причина, почему в данной главе я уделяю внимание процессам, а не самим программам.
В двух предыдущих главах мы обсуждали потоки в однопроцессном программном
обеспечении. Здесь же речь пойдет о ПО с несколькими процессами. Но сначала нужно разобраться с тем, как и с помощью какого API можно создать новый
процесс.
Стоит отметить, что нас в основном интересует выполнение процессов в Unixподобных операционных системах, поскольку все они имеют многослойную

502   Глава 17



Процессы

архитектуру, свойственную Unix, и предоставляют широко известные и похожие
API. У других ОС могут быть собственные механизмы для выполнения процессов,
но большинство из них более или менее соответствуют многослойной архитектуре,
поэтому от них можно ожидать наличия аналогичных методов.
В Unix-подобной операционной системе есть не так уж много способов выполнить
процесс на уровне системных вызовов. Как вы, наверное, помните из главы 11,
кольцо ядра находится почти в самом центре, сразу после кольца аппаратного обеспечения, и предоставляет внешним кольцам (командной оболочке и прикладным
программам) различные возможности ядра в виде интерфейса системных вызовов.
Два таких системных вызова предназначены для создания и выполнения процессов;
речь идет о fork и exec (или execve в Linux) соответственно. Создание подразумевает порождение нового процесса, а при выполнении мы берем имеющийся процесс
и подставляем в него новую программу; таким образом, во втором случае новый
процесс не порождается.
В результате использования этих системных вызовов программа всегда выполняется в виде нового процесса, но данный процесс порождается не всегда! Вызов fork
порождает новый процесс, а вызов exec заменяет вызывающий процесс новым.
О различиях между fork и exec мы поговорим позже. А сначала посмотрим, как
к этим системным вызовам обращаться из внешних колец.
Как уже объяснялось в главе 10, Unix-подобные операционные системы имеют
два стандарта, которые среди прочего описывают интерфейс, предоставляемый
кольцу командной оболочки. Эти стандарты — единая спецификация Unix (Single
Unix Specification, SUS) и POSIX. Более подробно о них, а также об их сходствах
и различиях можно почитать в главе 10.
Интерфейс, предоставляемый кольцом ядра, должен быть подробно описан в стандарте POSIX, и, действительно, некоторые аспекты этого стандарта посвящены
выполнению и управлению процессами.
Поэтому, как следовало ожидать, в POSIX должны присутствовать заголовки
и функции для создания и выполнения процессов. И они там действительно есть;
соответствующие возможности обнаруживаются в разных заголовочных файлах.
POSIX-функции, предусмотренные для создания и выполнения процессов, перечислены ниже:
zz функция fork, которая находится в заголовочном файле unistd.h, отвечает за

создание процесса;
zz функции posix_spawn и posix_spawnp , находящиеся в заголовочном файле
spawn.h, отвечают за создание процесса;
zz функции семейства exec*, такие как execl и execlp, находятся в заголовочном
файле unistd.h и отвечают за выполнение процесса.

API для выполнения процессов   503

Обратите внимание: перечисленные выше функции не следует путать с системными вызовами fork и exec. Эти функции входят в состав интерфейса POSIX, доступного в кольце командной оболочки, тогда как системные вызовы предоставляются
кольцом ядра. Со стандартом POSIX совместимо большинство Unix-подобных
систем и некоторые другие ОС. Последние тоже могут содержать функции, представленные выше, но их внутренние механизмы для порождения процесса могут
отличаться на уровне системных вызовов.
В качестве реального примера можно привести использование Cygwin или MinGW
в Microsoft Windows в целях обеспечения совместимости с POSIX. Установив эти
программы, вы сможете писать и компилировать стандартный код на языке C, который использует интерфейс POSIX. Таким образом, система Microsoft Windows
становится частично POSIX-совместимой, хотя не содержит системных вызовов
fork или exec! Это очень важное обстоятельство может вызывать сильную путаницу, и потому вы должны знать: кольцо командной оболочки не всегда предоставляет
тот же интерфейс, что и кольцо ядра.
Детали реализации функции fork в Cygwin можно найти по адресу
https://github.com/openunix/cygwin/blob/master/winsup/cygwin/fork.cc.
Стоит отметить, что она не использует внутри системный вызов fork,
который обычно присутствует в Unix-подобных ядрах; вместо этого она
подключает заголовки из Win32 API и вызывает общеизвестные функции,
предназначенные для создания процессов и управления ими.

Согласно спецификации POSIX, кольцо командной оболочки в Unix-подобных
системах предоставляет не только стандартную библиотеку C. В терминале есть
предустановленные командные утилиты, которые позволяют использовать стандартные функции C в сложных сценариях. И каждый раз, когда пользователь
вводит команду в терминале, создается новый процесс.
Даже такие простые команды, как ls или sed, порождают новые процессы, которые
могут проработать меньше одной секунды. Вы должны понимать, что эти утилиты
в основном написаны на языке C и обращаются к тому же интерфейсу POSIX,
который вы можете использовать при написании собственных программ.
Скрипты командной оболочки тоже выполняются в отдельных процессах, но немного по-другому. Мы вернемся к ним в будущих разделах при обсуждении того,
как процессы выполняются в Unix-подобных системах.
Создание процесса происходит в ядре, особенно если оно монолитно. Каждый раз,
когда пользователь порождает новый процесс или даже поток, интерфейс системных вызовов получает запрос и передает его кольцу ядра. А там уже в ответ на этот
запрос создается новое задание, будь то процесс или поток.

504   Глава 17



Процессы

В монолитных системах, таких как Linux или FreeBSD, механизм отслеживания
заданий (процессов и потоков) находится внутри ядра, поэтому логично, что процессы создаются в самом ядре.
Обратите внимание: любое новое задание, которое создается в ядре, помещается
в очередь планировщика заданий и до того, как оно получит ресурсы процессора
и сможет начать выполнение, может пройти какое-то время.
Создание новых процессов требует родительского процесса. Вот почему у любого
процесса есть родитель. На самом деле он может быть только один. Цепочка родителей и прародителей растягивается до самого первого пользовательского процесса,
который обычно называется init. Родителем init служит процесс ядра.
Это предок всех остальных процессов внутри Unix-подобной системы, и существует он вплоть до ее выключения. Когда процесс завершает работу, но у него остаются
дочерние, осиротевшие процессы, его место в качестве родителя занимает init.
Такие отношения между родителями и потомками формируют большое дерево
процессов, которое можно просмотреть с помощью командной утилиты pstree.
О том, как ее использовать, вы узнаете в будущих примерах.
Итак, мы знаем, что для выполнения новых процессов предусмотрен специальный
API. Теперь пришло время рассмотреть настоящие примеры на языке C, чтобы увидеть, как это работает на самом деле. Начнем с функции fork, которая в конечном
счете использует одноименный системный вызов.

Создание процесса
Как уже упоминалось выше, функцию fork можно использовать для порождения
новых процессов. Мы также выяснили, что новый процесс всегда является потомком какого-то другого процесса. Здесь мы рассмотрим примеры того, как породить
новый дочерний процесс подобным образом.
Чтобы создать новый дочерний процесс, родительскому процессу необходимо
вызвать функцию fork. Ее объявление можно подключить вместе с заголовочным
файлом unistd.h, который входит в состав POSIX.
При вызове функции fork создается точная копия вызывающего (родительского)
процесса. В результате оба процесса продолжают работать конкурентно, начиная
с инструкции, которая идет за вызовом fork. Отмечу, что дочерний процесс наследует много атрибутов от своего родителя, включая все сегменты памяти и их содержимое. Следовательно, он имеет доступ ко всем тем же переменным в сегментах
данных и кучи, а также содержит те же программные инструкции в сегменте кода.

API для выполнения процессов   505

О других наследуемых атрибутах мы поговорим чуть ниже. Но сначала рассмотрим
пример.
Поскольку мы получаем два разных процесса, функция fork возвращается дважды:
в родительском процессе и в дочернем. Кроме того, в каждом процессе fork возвращает разные значения: ноль в дочернем и PID нового процесса в родительском.
В примере 17.1 показан один из простейших сценариев использования функции
fork (листинг 17.1).
Листинг 17.1. Создание дочернего процесса с помощью API fork
(ExtremeC_examples_chapter17_1.c)

#include
#include
int main(int argc, char** argv) {
printf("This is the parent process with process ID: %d\n",
getpid());
printf("Before calling fork() ...\n");
pid_t ret = fork();
if (ret) {
printf("The child process is spawned with PID: %d\n", ret);
} else {
printf("This is the child process with PID: %d\n", getpid());
}
printf("Type CTRL+C to exit ...\n");
while (1);
return 0;
}

Выше вы можете видеть, что использовалась инструкция printf для вывода некоторых журнальных записей с целью отслеживать работу процессов. Чтобы породить
новый процесс, была вызвана функция fork. Она не принимает никаких аргументов, поэтому применять ее очень легко и просто.
В момент вызова функции fork из вызывающего процесса, который впоследствии
становится родителем, создается (или клонируется) новый. После этого они продолжают работать конкурентно, как два разных процесса.
Естественно, функция fork, в свою очередь, инициирует дополнительные операции
на уровне системных вызовов, и только после этого соответствующая логика в ядре
может создать новую копию процесса.
Непосредственно перед инструкцией return находится бесконечный цикл, который
позволяет работать обоим процессам и не дает им завершиться. Обратите внимание: процессы рано или поздно должны дойти до этого цикла, поскольку имеют
совершенно одинаковые инструкции в своих сегментах кода.

506   Глава 17



Процессы

Мы специально предотвращаем завершение процессов, чтобы увидеть их в выводе
команд pstree и top. Но сначала нам нужно скомпилировать приведенный выше
код и понаблюдать за тем, как создается новый процесс (терминал 17.1).
Терминал 17.1. Сборка и запуск примера 17.1
$ gcc ExtremeC_examples_chapter17_1.c -o ex17_1.out
$ ./ex17_1.out
This is the parent process with process ID: 10852
Before calling fork() …
The child process is spawned with PID: 10853
This is the child process with PID: 10853
Type CTRL+C to exit ...
$

Как видите, родительский процесс выводит свой PID, который равен 10852. Заметьте:
PID меняется при каждом выполнении. После создания дочернего процесса родитель выводит PID, который вернула функция fork; он равен 10853.
В следующей строчке потомок выводит свой PID, тоже равный 10853. Это соответствует тому, что получил родитель из функции fork. Наконец, оба процесса
входят в бесконечный цикл, давая нам время понаблюдать за ними с помощью
специальных утилит.
Как вы могли заметить, дочерний процесс наследует от родителя те же файловый
дескриптор stdout и терминал. Следовательно, может возвращать данные в тот же
вывод, что и родитель. Потомок наследует файловые дескрипторы, открытые на
момент вызова функции fork в родительском процессе.
Кроме того, наследуются и другие атрибуты, о которых можно почитать на справочной странице функции fork. В случае с Linux она находятся по адресу http://
man7.org/linux/man-pages/man2/fork.2.html.
Если пройти по этой ссылке и просмотреть атрибуты, то можно заметить, что некоторые из них являются общими для родителя и его потомков, а другие — уникальными для каждого процесса. Например, PID, PID родителя, потоки и т. д.
Отношения между родительскими и дочерними процессами можно наглядно продемонстрировать с помощью таких утилит, как pstree. У каждого процесса есть
родитель, и все вместе они составляют большое дерево. Помните, что родитель
у любого отдельно взятого процесса может быть только один.
Пока процессы в нашем примере находятся в своих бесконечных циклах, мы можем
воспользоваться командной утилитой pstree, чтобы вывести древовидный список
всех процессов в системе. В терминале 17.2 показан вывод pstree на компьютере
с Linux. Обратите внимание: в системах Linux эта утилита установлена по умолчанию,
но в других Unix-подобных ОС ее, возможно, придется устанавливать отдельно.

API для выполнения процессов   507
Терминал 17.2. Использование утилиты pstree для поиска процессов,
порожденных в примере 17.1
$ pstree -p
systemd(1)─┬─accounts-daemon(877)─┬─{accounts-daemon}(960)

└─{accounts-daemon}(997)
...
...
...
├─systemd-logind(819)
├─systemd-network(673)
├─systemd-resolve(701)
├─systemd-timesyn(500)───{systemd-timesyn}(550)
├─systemd-udevd(446)
└─tmux: server(2083)─┬─bash(2084)───pstree(13559)
└─bash(2337)───ex17_1.
out(10852)───ex17_1.out(10853)
$

В последней строчке этого терминала указано два процесса с PID 10852 и 10852,
которые имеют отношения вида «родитель — потомок». Интересно, что родитель
процесса 10852 имеет PID 2337, который принадлежит процессу bash.
Обратите внимание на предпоследнюю строчку, в которой указан сам процесс
pstree; он является потомком процесса bash с PID 2084. Оба процесса bash принадлежат одному и тому же эмулятору терминала tmux с PID 2083.
В Linux самым первым процессом является планировщик, который входит в образ
ядра и имеет PID 0. Следующий процесс обычно называется init, и его PID равен 1;
это первый процесс, который создается планировщиком. Он существует с момента
запуска системы и до ее выключения. Все остальные пользовательские процессы —
прямые или непрямые потомки init. Процесс, потерявший родителя, становится
сиротой, и init делает его своим прямым потомком.
Однако в более новых версиях почти всех известных дистрибутивов Linux процесс init был заменен демоном systemd. Вот почему в первой строчке терминала 17.2 значится systemd(1). По приведенной ниже ссылке находится отличный
материал об отличиях init и systemd и о том, почему разработчики дистрибутивов Linux решили выполнить этот переход: https://www.tecmint.com/systemd-replacesinit-in-linux.
При использовании функции fork родительский и дочерний процессы выполняются конкурентно. Это значит, что они должны проявлять некоторые свойства
конкурентных систем.
Самое известное свойство, которое мы можем наблюдать, — разные варианты чередований. Если вы незнакомы с этим термином, то я настоятельно рекомендую
почитать главы 13 и 14.

508   Глава 17



Процессы

В примере 17.2, представленном ниже, видно, как родительский и дочерний процессы могут демонстрировать недетерминированные чередования. Мы выведем
несколько строк и посмотрим, как меняются чередования в двух последовательных
прогонах (листинг 17.2).
Листинг 17.2. Два процесса, которые записывают разные строки в стандартный вывод
(ExtremeC_examples_chapter17_2.c)

#include
#include
int main(int argc, char** argv) {
pid_t ret = fork();
if (ret) {
for (size_t i = 0; i < 5; i++) {
printf("AAA\n");
usleep(1);
}
} else {
for (size_t i = 0; i < 5; i++) {
printf("BBBBBB\n");
usleep(1);
}
}
return 0;
}

Здесь все очень похоже на код, написанный нами в примере 17.1. Мы создаем
копию процесса, которая затем вместе со своим родителем записывает текстовые
строки в стандартный вывод. Родительский и дочерний процессы выводят по пять
раз AAA и BBBBBB соответственно. В терминале 17.3 показан вывод двух последовательных прогонов одного и того же скомпилированного исполняемого файла.
Терминал 17.3. Вывод двух последовательных прогонов примера 17.2
$ gcc ExtremeC_examples_chapter17_2.c -o ex17_2.out
$ ./ex17_2.out
AAA
AAA
AAA
AAA
AAA
BBBBBB
BBBBBB
BBBBBB
BBBBBB
BBBBBB
$ ./ex17_2.out
AAA
AAA

API для выполнения процессов   509
BBBBBB
AAA
AAA
BBBBBB
BBBBBB
BBBBBB
AAA
BBBBBB
$

В этом выводе явно видно, что мы имеем разные чередования. Это означает следующее: если определить наше инвариантное ограничение в соответствии с тем, что
мы видим в стандартном выводе, то существует риск состояния гонки. Это рано
или поздно приведет к проблемам, с которыми мы сталкивались при написании
многопоточного кода, и для борьбы с ними следует использовать аналогичные
методы. В следующей главе мы подробно обсудим такие решения.
Следующий подраздел посвящен выполнению процесса и тому, как его инициировать с помощью функций exec*.

Выполнение процесса
Еще один способ выполнить новый процесс заключается в использовании функций семейства exec*. В них применяется другой подход по сравнению с API fork.
Философию, стоящую за функциями exec*, можно описать так: вначале создается
простой базовый процесс, и затем в какой-то момент в него загружается нужный
нам исполняемый файл, который становится новым образом процесса. Это загруженная версия исполняемого файла с выделенными сегментами памяти, готовая
к выполнению. В будущих разделах мы обсудим разные этапы загрузки исполня­
емого файла и подробно узнаем, что собой представляет образ процесса.
Таким образом, использование функций exec* вместо создания нового процесса
вызывает подмену уже существующего. Это самое важное отличие от функции
fork. Базовый процесс не копируется, а полностью заменяется новым набором
сегментов памяти и программных инструкций.
В листинге 17.3, содержащем пример 17.3, показано использование функции
execvp, входящей в семейство функций exec*, для запуска процесса echo. Функция execvp — одна из функций группы exec*, которая наследует от родительского
процесса переменную среды PATH и ищет исполняемые файлы так, как это делал
родитель.
Листинг 17.3. Демонстрация работы функции execvp (ExtremeC_examples_chapter17_3.c)

#include
#include

510   Глава 17



Процессы

#include
#include
int main(int argc, char** argv) {
char *args[] = {"echo", "Hello", "World!", 0};
execvp("echo", args);
printf("execvp() failed. Error: %s\n", strerror(errno));
return 0;
}

Как видите, была вызвана функция execvp. Чуть выше уже упоминалось о том, что
она наследует от базового процесса переменную среды PATH и точно так же ищет
существующие исполняемые файлы. Эта функция принимает два аргумента: имя
исполняемого файла или скрипта, который должен быть загружен и выполнен,
и список аргументов, которые ему нужно передать.
Обратите внимание: мы указали echo, а не абсолютный путь. Таким образом, функция execvp должна сначала найти соответствующий исполняемый файл. Он может
находиться в любом месте Unix-подобной операционной системы: в /usr/bin ,
/usr/local/bin или где-то еще. Реальное местонахождение echo можно определить
путем перебора всех каталогов, указанных в переменной среды PATH.
Функции exec* могут запускать разного рода исполняемые файлы. Ниже
перечислены все файловые форматы, которые поддерживаются этими
функциями:
yy исполняемые файлы ELF;
yy скриптовые файлы с символами #! в самом начале, которые обозначают интерпретатор скрипта;
yy традиционный формат двоичных файлов a.out;
yy исполняемые файлы ELF FDPIC.

Обнаружив исполняемый файл echo , функция execvp делает все остальное.
Она инициирует системный вызов exec (execve в Linux) с подготовленным набором аргументов, в результате чего ядро формирует из найденного исполняемого
файла образ процесса. Когда все готово, ядро заменяет текущий образ процесса
новым, и после этого базовый процесс теряется навсегда. Дальше управление
переходит к новому процессу, который начинает работу с функции main, как при
обычном выполнении.
В результате данной процедуры инструкция printf не может быть выполнена, если
вызов функции execvp, за которой она идет, был успешным, поскольку мы теперь
имеем совершенно новый процесс с новыми сегментами памяти и инструкциями.
Если инструкция printf выполняется, то это признак того, что вызов функции
execvp завершился неудачей.

API для выполнения процессов   511

Как уже говорилось ранее, execvp — это лишь одна из функций семейства exec*.
И несмотря на похожее поведение, они все же слегка отличаются. Ниже приводится их сравнение.
zz Функция execl(const char* path, const char* arg0, …, NULL) принимает абсо-

лютный путь к исполняемому файлу вместе с набором аргументов, которые
должны быть переданы новому процессу. Последним аргументом должна быть
нулевая строка, 0 или NULL. Пример 17.3, переписанный с использованием execl,
выглядел бы так: execl("/usr/bin/echo", "echo", "Hello","World", NULL).
zz Функция execlp(const char* file, const char* arg0, ..., NULL) принимает

относительный путь в качестве первого аргумента и может легко найти исполняемый файл благодаря доступу к переменной среды PATH. Эта функция также
принимает набор аргументов, которые должны быть переданы новому процессу.
Последним аргументом должна быть нулевая строка, 0 или NULL. Пример 17.3,
переписанный с использованием execlp , выглядел бы так: execlp("echo",
"echo," "Hello," "World," NULL).
zz Функция excele(const char* path, const char* arg0, ..., NULL, const char*
env0, ..., NULL) принимает абсолютный путь к исполняемому файлу вместе

с набором аргументов, которые должны быть переданы новому процессу. В конце этого набора должна быть нулевая строка. Затем должен идти список строк,
представляющих переменные среды. Они тоже должны заканчиваться нулевой
строкой. Пример 17.3, переписанный с использованием execle, выглядел бы
так: execle("/usr/bin/echo", "echo", "Hello", "World", NULL, "A=1", "B=2",
NULL). Заметьте, что в этом вызове мы передали новому процессу две новые
переменные среды: A и B.
zz Функция execv(const char* path, const char* args[]) принимает абсолютный

путь к исполняемому файлу вместе с массивом аргументов, которые должны
быть переданы новому процессу. Последним элементом массива должна быть
нулевая строка, 0 или NULL. Пример 17.3, переписанный с использованием execl,
выглядел бы так: execl("/usr/bin/echo", args), где переменная args была бы
объявлена как char* args[] = {"echo", "Hello", "World", NULL}.
zz Функция execvp(const char* file, const char* args[]) принимает относитель-

ный путь в качестве первого аргумента и может легко найти исполняемый файл
благодаря доступу к переменной среды PATH. Данная функция также принимает
массив аргументов, которые должны быть переданы новому процессу. Последним элементом массива должна быть нулевая строка, 0 или NULL. Именно эту
функцию мы использовали в примере 17.3.
В случае успешного выполнения функций exec* предыдущий процесс исчезает,
а его место занимает новый. Таким образом, создание второго процесса не происходит. По этой причине мы не можем продемонстрировать разные чередования, как
в случае с функцией fork. В следующем подразделе мы сравним fork и функции
exec* в контексте выполнения новой программы.

512   Глава 17



Процессы

Разные методы создания
и выполнения процессов
Учитывая все вышесказанное и примеры, приведенные выше, мы можем сравнить
два метода выполнения новой программы.
zz В результате успешного вызова fork получаются два отдельных процесса: родительский, который вызывал fork, и дочерний. Успешное выполнение exec*

заключается в замене вызывающего процесса новым образом, в результате чего
новый процесс не создается.
zz Вызов fork дублирует все содержимое памяти родительского процесса, поэтому
потомку доступны все те же адреса и переменные. Для сравнения, вызов exec*

уничтожает структуру памяти базового процесса и создает новую структуру,
основанную на загруженном исполняемом файле.
zz У скопированного процесса есть доступ к определенным атрибутам родителя,

таким как открытые файловые дескрипторы. А вот при использовании функций
exec* новый процесс не знает об атрибутах старого и ничего от него не наследует.
zz В обоих случаях новый процесс содержит всего один главный поток. Потоки
родительного процесса не копируются при использовании fork.
zz Функции exec* можно применять для выполнения скриптов и внешних исполняемых файлов, а fork подходит только для создания новых процессов, которые

являются копией той же программы.
В следующем разделе мы поговорим о тех этапах, через которые проходит большинство ядер при загрузке и выполнении нового процесса. Перечень этапов
и разные их аспекты могут отличаться от одного ядра к другому, но мы попробуем
описать общую процедуру выполнения процессов, которой следуют большинство
известных ядер.

Этапы выполнения процесса
Чтобы выполнить процесс из исполняемого файла, в большинстве операционных систем пространства ядра и пользователя проходят через ряд общих этапов.
Как уже отмечалось в предыдущем разделе, исполняемыми обычно являются объектные файлы, такие как ELF и Mach, или скрипты, которым для работы нужен
интерпретатор.
С точки зрения кольца прикладных приложений для этого следует инициировать
системный вызов, такой как exec. Стоит отметить, что мы не рассматриваем здесь
системный вызов fork, поскольку он в действительности занимается не выполнением, а, скорее, клонированием текущего активного процесса.

Разделяемые состояния   513

Когда пользовательское пространство инициирует системный вызов exec, ядро
получает новый запрос на запуск исполняемого файла. Оно пытается найти подходящий обработчик для файлов этого типа и затем использует загрузчик, чтобы
загрузить содержимое исполняемого файла.
Обратите внимание: в случае со скриптом загружается программа-интерпретатор,
которая обычно указывается в первой строчке скрипта вслед за символами #!.
Чтобы выполнить процесс, загрузчик обязан произвести следующие действия:
zz проверить контекст выполнения и права доступа пользователя, который запро-

сил выполнение;
zz выделить для нового процесса адресное пространство в основной памяти;
zz скопировать двоичное содержимое исполняемого файла в выделенное про-

странство. Это в основном касается сегментов данных и кода;
zz выделить область памяти для сегмента стека и подготовить начальные привязки

памяти;
zz создать главный поток выполнения и его сегмент стека;
zz скопировать аргументы командной строки в стековый фрейм и поместить его

на вершину стека главного потока;
zz инициализировать регистры, необходимые для выполнения;
zz выполнить первую инструкцию точки входа в программу.

В случае со скриптом путь к файлу копируется в качестве аргумента командной
строки для процесса интерпретатора. Описанные выше этапы присущи большинству ядер, но детали их реализации могут варьироваться.
Подробности о конкретных операционных системах можно найти в их документации или с помощью Google. Если вас интересует выполнение процессов в Linux,
то можете начать с отличных статей на сайте LWN: https://lwn.net/Articles/631631/
и https://lwn.net/Articles/630727/.
Далее мы перейдем к обсуждению тем, относящихся к конкурентности, и подготовимся к следующей главе, посвященной углубленному анализу методов синхронизации в многопроцессных системах. Начнем с рассмотрения разделяемых
состояний, которые можно использовать в многопроцессном коде.

Разделяемые состояния
Процессы, как и потоки, могут разделять между собой состояния. Единственное
отличие в том, что потоки имеют доступ к общему пространству памяти, которое
принадлежит их процесс-владельцу. Процессы лишены такой роскоши, поэтому для
разделения состояний между ними необходимо использовать другие механизмы.

514   Глава 17



Процессы

В данном разделе мы обсудим эти методики и уделим отдельное внимание тем из
них, которые играют роль хранилища. В первом подразделе мы попытаемся разделить их на группы в зависимости от того, какова их природа.

Методы разделения ресурсов
Если проанализировать методы разделения состояния (переменной или массива)
между двумя процессами, окажется, что их не так уж много. Теоретически существует
два общих подхода к разделению состояния между разными процессами, но в реальных компьютерных системах у каждого из них есть несколько подкатегорий.
Состояние либо размещается в каком-то «месте», доступном ряду процессов, либо
отправляется (или передается) другим процессам в виде сообщения, сигнала или
события. Точно так же вы должны либо извлечь имеющееся состояние из заданного
«места», либо принять его в виде сообщения, сигнала или события. Для первого
подхода требуется хранилище (или носитель), такое как буфер памяти или файловая система, а для второго — механизм обмена сообщениями, или канал, между
процессами.
В качестве примера первого подхода вполне уместен массив, который находится
в разделяемой области памяти и может быть прочитан и изменен разными процессами. Что касается второго подхода, то это может быть компьютерная сеть,
служащая каналом для передачи сообщений между процессами, размещенными
на разных компьютерах в данной сети.
На самом деле наше обсуждение методов разделения состояния не ограничено
одними лишь процессами; все сказанное применимо и к потокам. Вдобавок потоки
могут разделять и распространять состояние, обмениваясь сигналами.
В другой терминологии методики, относящиеся к первой группе и требующие
носитель или хранилище для разделения состояний, называются активными.
Это объясняется тем, что процесс, который хочет прочитать состояние, должен
извлечь его из хранилища.
Методики из второй группы, которым нужен канал для передачи состояния, называются пассивными, поскольку состояние доставляется принимающему процессу
по каналу и тот не должен извлекать его из носителя. В дальнейшем я буду использовать эти термины для описания вышеупомянутых подходов.
Изобилие пассивных методик привело к появлению в современной индустрии
разработки ПО различных распределенных архитектур. Активные методики считаются устаревшими по сравнению с пассивными, и вы можете встретить их во
многих корпоративных приложениях, в которых для разделения состояния всей
системы используется единая центральная база данных.

Разделяемые состояния   515

Но в настоящее время все более популярным становится пассивный подход.
Он привел к появлению таких шаблонов проектирования, как источник событий
(event sourcing) и других похожих методик, которые позволяют сохранять все
элементы программной системы в согласованном состоянии, не прибегая к централизованному хранению всех данных.
В этой главе нас главным образом интересует первый подход, а о втором речь
пойдет в главах 19 и 20. В них будут представлены различные каналы, доступные
для обмена сообщениями между разными процессами в рамках межпроцессного
взаимодействия (inter-process communication, IPC). Только после этого можно
будет перейти к активным методикам и рассмотреть реальные примеры проблем,
которые наблюдаются в конкурентных системах, и управляющих механизмов,
предназначенных для их решения.
Ниже перечислены активные методики, которые поддерживаются в стандарте
POSIX и могут свободно использоваться во всех POSIX-совместимых операционных системах.
zz Разделяемая память. Это просто область главной памяти, которая разделяется

между разными процессами и доступна каждому из них; они могут использовать ее как обычный блок памяти для хранения переменных и массивов.
Объект разделяемой памяти — настоящая оперативная память, а не файл на
диске. Он может существовать в виде отдельного объекта файловой системы,
даже если не используется никакими процессами. Объект разделяемой памяти
может быть удален либо самим процессом за ненадобностью, либо в результате
перезагрузки системы. Учитывая то, что этот объект не может пережить перезагрузку, его можно считать временным.
zz Файловая система. Процессы могут разделять состояние с помощью файловой

системы. Это одна из старейших методик разделения состояния в программной
системе между разными процессами. В какой-то момент трудности синхронизации доступа к общим файлам в сочетании со многими другими справедливыми
причинами привели к изобретению систем управления базами данных (СУБД),
но данный подход до сих пор используется в определенных ситуациях.
zz Сетевые сервисы. Процессы могут использовать сетевые хранилища или серви-

сы для хранения и извлечения разделяемого состояния. В этом случае процессы
не знают, что именно происходит внутри. Они просто обращаются к сетевым
сервисам через строго определенный API, который позволяет им выполнять
различные операции с разделяемым состоянием. В качестве примеров можно
привести NFS (network filesystems — сетевые файловые системы) и СУБД. Они
предоставляют сервисы для работы с состояниями с использованием четко
определенной модели и набора сопутствующих операций. В частности, можно
упомянуть о реляционных СУБД, которые позволяют сохранять состояния в реляционной модели с помощью SQL-команд.

516   Глава 17



Процессы

В следующих подразделах мы проведем обзор каждого из перечисленных выше
методов в контексте интерфейса POSIX. Начнем с разделяемой памяти и узнаем,
как ее использование может вызывать уже знакомую нам гонку данных, с которой
мы сталкивались в главе 16.

Разделяемая память в POSIX
Разделяемая память поддерживается стандартом POSIX и является одним из
самых распространенных методов разделения информации между разными процессами. Процессы, в отличие от потоков, не имеют доступа к общему пространству
памяти; он запрещен на уровне операционной системы. Следовательно, нам нужен
какой-то механизм, который позволит разделять участки памяти между двумя процессами, и разделяемая память — именно такой механизм.
В следующих примерах мы обсудим подробности создания и использования
объекта разделяемой памяти. Для начала создадим в памяти общую область.
В листинге 17.4 показано, как создать и заполнить объект разделяемой памяти
в POSIX-совместимой системе.
Листинг 17.4. Создание и инициализация объекта разделяемой памяти средствами POSIX
(ExtremeC_examples_chapter17_4.c)

#include
#include
#include
#include
#include
#include








#define SH_SIZE 16
int main(int argc, char** argv) {
int shm_fd = shm_open("/shm0", O_CREAT | O_RDWR, 0600);
if (shm_fd < 0) {
fprintf(stderr, "ERROR: Failed to create shared memory: %s\n",
strerror(errno));
return 1;
}
fprintf(stdout, "Shared memory is created with fd: %d\n",
shm_fd);
if (ftruncate(shm_fd, SH_SIZE * sizeof(char)) < 0) {
fprintf(stderr, "ERROR: Truncation failed: %s\n",
strerror(errno));
return 1;
}
fprintf(stdout, "The memory region is truncated.\n");
void* map = mmap(0, SH_SIZE, PROT_WRITE, MAP_SHARED, shm_fd, 0);

Разделяемые состояния   517
if (map == MAP_FAILED) {
fprintf(stderr, "ERROR: Mapping failed: %s\n",
strerror(errno));
return 1;
}
char* ptr = (char*)map;
ptr[0] = 'A';
ptr[1] = 'B';
ptr[2] = 'C';
ptr[3] = '\n';
ptr[4] = '\0';
while(1);
fprintf(stdout, "Data is written to the shared memory.\n");
if (munmap(ptr, SH_SIZE) < 0) {
fprintf(stderr, "ERROR: Unmapping failed: %s\n",
strerror(errno));
return 1;
}
if (close(shm_fd) < 0) {
fprintf(stderr, "ERROR: Closing shared memory failed: %s\n",
strerror(errno));
return 1;
}
return 0;
}

Данный код создает объект разделяемой памяти с именем /shm0 и 16 байтами
внутри. Затем он записывает туда литерал ABC\n и выходит, отвязываясь от разделяемой области памяти. Обратите внимание: эта область остается на месте даже
после завершения программы. Будущие процессы могут снова его открыть и прочитать. Объект разделяемой памяти уничтожается либо во время перезагрузки
системы, либо самим процессом.
В FreeBSD имена объектов разделяемой памяти должны начинаться с /.
В Linux и macOS это не обязательно, но мы все равно указали косую
черту, чтобы сделать код совместимым с FreeBSD.

В начале приведенного выше листинга мы используем функцию shm_open для открытия объекта разделяемой памяти. Она принимает имя объекта и его режимы.
Элементы O_CREAT и O_RDWR означают, что разделяемая память должна быть создана
и доступна как для чтения, так и для записи.
Обратите внимание: операция создания объекта завершится успешно, даже если
он уже существует. Последний аргумент определяет права доступа к разделяемой
памяти. Элемент 0600 означает, что она доступна для чтения и записи со стороны
процессов, инициированных владельцем соответствующего объекта.

518   Глава 17



Процессы

В следующих строчках мы определяем размер разделяемой области, усекая его
с помощью функции ftruncate. Этот шаг необходим, если вы хотите создать новый
объект разделяемой памяти. В нашем примере мы указали, что нужно выделить
и затем усечь 16 байт.
Далее мы привязали наш объект к области, доступной процессу, используя функцию mmap. В результате получился указатель на привязанную память, который
можно применять для обращения к соответствующей области. Это тоже обязательный шаг, который делает разделяемую память доступной для нашей программы
на языке C.
Функция mmap обычно служит для отображения файла или области разделяемой
памяти (изначально выделенной в адресном пространстве ядра) на память, доступную вызывающему процессу. Благодаря этому к такой области можно обращаться
как к любой другой, используя обычные указатели.
Элемент PROT_WRITE указывает на то, что область привязывается с возможностью
записи, а MAP_SHARED означает ее разделение между процессами. Наличие MAP_SHARED
всего лишь говорит о том, что любые изменения, внесенные в привязанную область,
будут доступны всем остальным процессам, которые к ней привязаны.
Вместо MAP_SHARED можно было бы указать MAP_PRIVATE; в этом случае изменения,
вносимые в привязанную область, не распространялись бы на другие процессы,
оставаясь локальными. Такой подход встречается нечасто и подходит в ситуациях,
когда разделяемую память нужно использовать только в рамках одного процесса.
После привязки области разделяемой памяти приведенный выше код записывает
в нее строку ABC\n; обратите внимание на символ перевода строки в конце. Затем
процесс отвязывает область разделяемой памяти с помощью функции munmap и закрывает файловый дескриптор, назначенный объекту этой области.
Каждая операционная система предлагает собственный способ создания
неименованных или анонимных объектов разделяемой памяти. В FreeBSD
для этого достаточно передать функции shm_open в качестве пути к объекту разделяемой памяти параметр SHM_ANON. В Linux вместо объекта
можно создать анонимный файл, используя функцию memfd_create, и затем передать его дескриптор для создания привязанной области. Анонимная разделяемая память принадлежит владеющему ей процессу и не может
применяться для разделения состояний между несколькими процессами.

Наш код можно скомпилировать в системах macOS, FreeBSD и Linux. В Linux
объекты разделяемой памяти доступны в каталоге /dev/shm. Обратите внимание: он
находится не в обычной файловой системе, поэтому то, что вы в нем видите, не является файлами на диске. Для /dev/shm используется файловая система shmfs. Она
доступна только в Linux и предназначена для предоставления доступа к временным
объектам внутри памяти через подключенный каталог.

Разделяемые состояния   519

Скомпилируем пример 17.4 в Linux и исследуем содержимое каталога /dev/shm.
Чтобы итоговому исполняемому файлу в Linux были доступны механизмы работы
с разделяемой памятью, его необходимо скомпоновать с библиотекой rt. Вот почему вы можете видеть параметр -lrt в терминале 17.4.
Терминал 17.4. Сборка и выполнение примера 17.4 с последующей проверкой того,
был ли создан объект разделяемой памяти
$ ls /dev/shm
$ gcc ExtremeC_examples_chapter17_4.c -lrt -o ex17_4.out
$ ./ex17_4.out
Shared memory is created with fd: 3
The memory region is truncated.
Data is written to the shared memory.
$ ls /dev/shm
shm0
$

В первой строчке видно, что в каталоге /dev/shm нет никаких объектов разделяемой
памяти. Во второй строчке мы собираем пример 17.4, а в третьей — запускаем полученный исполняемый файл. Затем еще раз проверяем каталог /dev/shm и видим, что
там появился новый объект разделяемой памяти, shm0.
Создание объекта разделяемой памяти подтверждается и выводом программы.
Еще один аспект этого терминала, заслуживающий внимания, — дескриптор 3,
который назначается объекту разделяемой памяти.
Когда вы открываете какой-либо файл, в каждом процессе для него создается новый дескриптор. Данный файл может находиться не на диске; это может быть объект разделяемой памяти, стандартный вывод и т. д. В каждом процессе файловые
дескрипторы начинаются с 0 и инкрементируются, пока не достигнут максимально
разрешенного числа.
Следует отметить, что в каждом процессе номера файловых дескрипторов 0, 1 и 2
заранее назначены потокам данных stdout, stdin и stderr соответственно. Эти дескрипторы открываются для каждого нового процесса перед началом выполнения
функции main. Вот почему в нашем примере объект разделяемой памяти получает
файловый дескриптор 3.
В macOS для просмотра активных IPC-объектов, существующих в системе, можно использовать утилиту pics. Она умеет выводить очереди
активных сообщений и объекты разделяемой памяти. С ее помощью
также можно проверить активные семафоры.

У каталога /dev/shm есть еще одно любопытное свойство. Вы можете применить
утилиту cat, чтобы просматривать содержимое объектов разделяемой памяти, хотя
эта возможность доступна только в Linux. Воспользуемся ею для просмотра нашего

520   Глава 17



Процессы

нового объекта shm0. Его содержимое показано в терминале 17.5. Это строка ABC
и символ перевода строки.
Терминал 17.5. Использование программы cat для просмотра содержимого объекта
разделяемой памяти, созданного в рамках примера 17.4
$ cat /dev/shm/shm0
ABC
$

Как уже объяснялось ранее, объект разделяемой памяти существует, пока его использует хотя бы один процесс. Даже попросив операционную систему его удалить,
процесс продолжит существовать, пока кто-то им пользуется. Однако удаление
произойдет при перезагрузке системы даже без запросов со стороны процессов.
Объект разделяемой памяти не может пережить перезагрузку, и процессам, которые
хотят взаимодействовать, приходится создавать его заново.
В следующем примере показано, как процесс может открыть и прочитать уже существующий объект разделяемой памяти и как этот объект можно удалить. Пример 17.5
выполняет чтение из объекта, созданного в примере 17.4 (листинг 17.5). Это можно
считать дополнением к коду, который был представлен ранее.
Листинг 17.5. Чтение из объекта разделяемой памяти, созданного в примере 17.4
(ExtremeC_examples_chapter17_5.c)

#include
#include
#include
#include
#include
#include








#define SH_SIZE 16
int main(int argc, char** argv) {
int shm_fd = shm_open("/shm0", O_RDONLY, 0600);
if (shm_fd < 0) {
fprintf(stderr, "ERROR: Failed to open shared memory: %s\n",
strerror(errno));
return 1;
}
fprintf(stdout, "Shared memory is opened with fd: %d\n", shm_fd);
void* map = mmap(0, SH_SIZE, PROT_READ, MAP_SHARED, shm_fd, 0);
if (map == MAP_FAILED) {
fprintf(stderr, "ERROR: Mapping failed: %s\n",
strerror(errno));
return 1;
}
char* ptr = (char*)map;
fprintf(stdout, "The contents of shared memory object: %s\n",
ptr);

Разделяемые состояния   521

}

if (munmap(ptr, SH_SIZE) < 0) {
fprintf(stderr, "ERROR: Unmapping failed: %s\n",
strerror(errno));
return 1;
}
if (close(shm_fd) < 0) {
fprintf(stderr, "ERROR: Closing shared memory fd filed: %s\n",
strerror(errno));
return 1;
}
if (shm_unlink("/shm0") < 0) {
fprintf(stderr, "ERROR: Unlinking shared memory failed: %s\n",
strerror(errno));
return 1;
}
return 0;

Первое выражение в функции main открывает существующий объект разделяемой
памяти с именем /shm0. Если такого объекта не существует, то мы генерируем
ошибку. Как видите, объект открывается только для чтения; это значит, что мы
не будем ничего записывать в разделяемую память.
В следующих строчках мы привязываем область разделяемой памяти. Опять
же, передавая аргумент PROT_READ, мы указываем, что эта область предназначена
только для чтения. После этого мы наконец получаем указатель на разделяемую
память и используем его для вывода ее содержимого. Закончив, мы отвязываем
область. Дальше закрывается назначенный файловый дескриптор, и в конце
объект разделяемой памяти регистрируется для удаления с помощью функции shm_unlink.
Когда все процессы, которые задействуют одну и ту же разделяемую память, заканчивают с ней работать, ее объект удаляется из системы. Еще раз подчеркну:
объект разделяемой памяти существует, пока им пользуется хотя бы один процесс.
В терминале 17.6 показан результат выполнения приведенного выше кода. Обратите внимание на содержимое /dev/shm до и после запуска примера 17.5.
Терминал 17.6. Чтение из объекта разделяемой памяти, созданного в примере 17.4,
и его удаление
$ ls /dev/shm
shm0
$ gcc ExtremeC_examples_chapter17_5.c -lrt -o ex17_5.out
$ ./ex17_5.out
Shared memory is opened with fd: 3
The contents of the shared memory object: ABC
$ ls /dev/shm
$

522   Глава 17



Процессы

Пример гонки данных с использованием
разделяемой памяти
Теперь пришло время продемонстрировать гонку данных при использовании
функции fork в сочетании с разделяемой памятью. Это будет аналог примера
из главы 15, в котором гонка данных была показана в контексте нескольких потоков.
В примере 17.6 у нас есть переменная-счетчик, размещенная внутри области разделяемой памяти. Код создает копию главного текущего процесса, и затем каждый
процесс пытается инкрементировать разделяемый счетчик. В итоговом выводе явно
видно, что это приводит к гонке данных (листинг 17.6).
Листинг 17.6. Демонстрация гонки данных при использовании разделяемой памяти POSIX
и функции fork (ExtremeC_examples_chapter17_6.c)

#include
#include
#include
#include
#include
#include
#include
#include
#include











#define SH_SIZE 4
// Разделяемый файловый дескриптор, с помощью которого
// мы ссылаемся на объект в разделяемой памяти
int shared_fd = -1;
// Указатель на разделяемый счетчик
int32_t* counter = NULL;
void init_shared_resource() {
// Открываем объект в разделяемой памяти
shared_fd = shm_open("/shm0", O_CREAT | O_RDWR, 0600);
if (shared_fd < 0) {
fprintf(stderr, "ERROR: Failed to create shared memory: %s\n",
strerror(errno));
exit(1);
}
fprintf(stdout, "Shared memory is created with fd: %d\n",
shared_fd);
}
void shutdown_shared_resource() {
if (shm_unlink("/shm0") < 0) {

Разделяемые состояния   523
fprintf(stderr, "ERROR: Unlinking shared memory failed: %s\n",
strerror(errno));
exit(1);
}
}
void inc_counter() {
usleep(1);
int32_t temp = *counter;
usleep(1);
temp++;
usleep(1);
*counter = temp;
usleep(1);
}
int main(int argc, char** argv) {
// Родительский процесс должен инициализировать разделяемый ресурс
init_shared_resource();
// Выделяем и усекаем разделяемую область памяти
if (ftruncate(shared_fd, SH_SIZE * sizeof(char)) < 0) {
fprintf(stderr, "ERROR: Truncation failed: %s\n",
strerror(errno));
return 1;
}
fprintf(stdout, "The memory region is truncated.\n");
// Отражаем разделяемую память и инициализируем счетчик
void* map = mmap(0, SH_SIZE, PROT_WRITE,
MAP_SHARED, shared_fd, 0);
if (map == MAP_FAILED) {
fprintf(stderr, "ERROR: Mapping failed: %s\n",
strerror(errno));
return 1;
}
counter = (int32_t*)map;
*counter = 0;
// Создаем новый процесс из текущего
pid_t pid = fork();
if (pid) { // Родительский процесс
// Инкрементируем счетчик
inc_counter();
fprintf(stdout, "The parent process sees the counter as %d.\n",
*counter);
// Ждем завершения дочернего процесса
int status = -1;

524   Глава 17



Процессы

wait(&status);
fprintf(stdout, "The child process finished with status %d.\n",
status);
} else { // Дочерний процесс
// Инкрементируем счетчик
inc_counter();
fprintf(stdout, "The child process sees the counter as %d.\n",
*counter);
}
// Оба процесса должны уничтожить отражение в области памяти
// и закрыть свои файловые дескрипторы
if (munmap(counter, SH_SIZE) < 0) {
fprintf(stderr, "ERROR: Unmapping failed: %s\n",
strerror(errno));
return 1;
}
if (close(shared_fd) < 0) {
fprintf(stderr, "ERROR: Closing shared memory fd filed: %s\n",
strerror(errno));
return 1;
}
// Только родительскому процессу нужно закрывать разделяемый ресурс
if (pid) {
shutdown_shared_resource();
}
return 0;
}

В этом коде, помимо main, есть три функции. Функция init_shared_resource создает объект разделяемой памяти. Причина, по которой я назвал ее именно так, а не
init_shared_memory, обусловлена тем, что в будущих примерах такое общее имя позволит нам использовать в ней другие активные методики, не меняя функцию main.
Функция shutdown_shared_resource уничтожает разделяемую память и удаляет ее
из процесса. Функция inc_counter увеличивает разделяемый счетчик на 1.
Функция main усекает и привязывает область разделяемой памяти точно так же,
как это делалось в примере 17.4. После этого начинается логика копирования процесса. Вызов функции fork порождает новый процесс, и затем оба процесса (родительский и дочерний) пытаются инкрементировать счетчик, вызывая функцию
inc_counter.
Когда родительский процесс изменяет разделяемый счетчик, он ждет завершения
своего потомка и только затем пытается отвязать, закрыть и удалить объект разделяемой памяти. Следует отметить, что отвязка и закрытие файлового дескриптора
происходит в обоих процессах, но удаление выполняет только родитель.

Разделяемые состояния   525

В листинге 17.6 внутри функции inc_counter используются необычные вызовы
usleep. Это делается для того, чтобы заставить планировщик заданий передать ядро
процессора от одного процесса к другому. Без вызова usleep процессорное ядро
обычно не передается между процессами, и потому результаты разных чередований
проявлялись бы не так часто.
Одна из причин такого эффекта связана с небольшим количеством инструкций
в каждом процессе. Будь их значительно больше, мы смогли бы увидеть недетерминированное поведение чередований даже без вызовов usleep. Например, наличие
в каждом процессе цикла, который считает до 10 000 и инкрементирует счетчик на
каждой итерации, с большой долей вероятности выявило бы гонку данных. Можете
сами попробовать такой способ.
Напоследок можно отметить, что родительский процесс создает и открывает объект разделяемой памяти и назначает ему файловый дескриптор до вызова функции
fork. Скопированный процесс не открывает объект разделяемой памяти, но может
использовать тот же файловый дескриптор. Тот факт, что все файловые дескрипторы наследуются от родительского процесса, помогает потомку продолжить работу,
ссылаясь на тот же объект разделяемой памяти.
В листинге 17.7 показан вывод примера 17.6 после нескольких запусков. Как видите, у нас получилась явная гонка данных, относящаяся к разделяемому счетчику.
В некоторых ситуациях родительский или дочерний процесс обновляет счетчик,
не зная последнего измененного значения, в результате чего оба процесса выводят 1.
Терминал 17.7. Демонстрация того, как разделяемый счетчик в примере 17.6 приводит
к гонке данных
$ gcc ExtremeC_examples_chapter17_6 -o ex17_6.out
$ ./ex17_6.out
Shared memory is created with fd: 3
The memory region is truncated.
The parent process sees the counter as 1.
The child process sees the counter as 2.
The child process finished with status 0.
$ ./ex17_6
...
...
...
$ ./ex17_6.out
Shared memory is created with fd: 3
The memory region is truncated.
The parent process sees the counter as 1.
The child process sees the counter as 1.
The child process finished with status 0.
$

526   Глава 17



Процессы

Я показал, как создавать и использовать разделяемую память. Кроме того, продемонстрировал пример гонки данных и то, как ведет себя конкурентная система при
доступе к области разделяемой памяти. В следующем подразделе мы поговорим
еще об одном распространенном активном механизме разделения состояния между
разными процессами — о файловой системе.

Файловая система
В стандарте POSIX есть аналогичный API для работы с объектами файловой системы. Он ничем не отличается от того, с помощью которого мы получали доступ
к разделяемой памяти. Он позволяет ссылаться на различные системные объекты — главное, чтобы для этого использовались файловые дескрипторы.
Файловые дескрипторы дают возможность ссылаться на файлы, хранящиеся в таких файловых системах, как ext4, а также в разделяемой памяти, каналах и т. д.
Более того, вам доступна та же семантика для открытия, чтения, записи и привязки
дескрипторов к локальной области памяти. Таким образом, обсуждение файловой
системы и, вероятно, код для работы с ней будут напоминать то, что мы уже видели
в контексте разделяемой памяти. Это будет продемонстрировано в примере 17.7.
Файловые дескрипторы обычно привязываются (отображаются). Однако
существуют определенные исключительные случаи, когда привязать
можно дескрипторы сокетов. Дескрипторы сокетов похожи на файловые,
но используются для сетевых или Unix-сокетов. По приведенной ниже
ссылке находится интересный пример привязки буфера ядра к TCPсокету, известный как механизм приема данных без копирования: https://
lwn.net/Articles/752188/.

Похожесть API для работы с файловой системой и разделяемой памяти вполне
закономерна, но это не значит, что они имеют похожую реализацию. На самом
деле объекты файловой системы, находящиеся на жестком диске, фундаментально отличаются от объектов разделяемой памяти. Проведем краткий обзор этих
различий.
zz Объект разделяемой памяти — это, в сущности, адресное пространство процесса

ядра, а объект файловой системы находится на диске. В крайнем случае файл
имеет выделенные буферы для операций чтения и записи.
zz Состояния, записанные в разделяемую память, стираются при перезагрузке

системы, а состояния, сохраненные в разделяемый файл, остаются на месте,
если этот файл находится на жестком диске или на другом постоянном накопителе.
zz Доступ к разделяемой памяти обычно быстрее доступа к файловой системе.

Разделяемые состояния   527

В листинге 17.7 показан такой же пример гонки данных, который мы рассматривали выше в контексте разделяемой памяти. Поскольку API для работы с файловой
системой и разделяемой памятью похожи, в примере 17.6 нужно отредактировать
всего две функции: init_shared_resource и shutdown_shared_resource. Больше
никаких изменений не будет. Это важное достижение, которое стало возможным
благодаря использованию общего API POSIX для работы с файловыми дескрипторами. Перейдем к коду.
Листинг 17.7. Демонстрация гонки данных на примере обычных файлов и функции fork
(ExtremeC_examples_chapter17_7.c)

#include
#include
#include
#include
#include
#include
#include
#include
#include











#define SH_SIZE 4
// Разделяемый файловый дескриптор, с помощью которого
// мы ссылаемся на разделяемый файл
int shared_fd = -1;
// Указатель на разделяемый счетчик
int32_t* counter = NULL;
void init_shared_resource() {
// Открываем файл
shared_fd = open("data.bin", O_CREAT | O_RDWR, 0600);
if (shared_fd < 0) {
fprintf(stderr, "ERROR: Failed to create the file: %s\n",
strerror(errno));
exit(1);
}
fprintf(stdout, "File is created and opened with fd: %d\n",
shared_fd);
}
void shutdown_shared_resource() {
if (remove("data.bin") < 0) {
fprintf(stderr, "ERROR: Removing the file failed: %s\n",
strerror(errno));
exit(1);
}
}

528   Глава 17



Процессы

void inc_counter() {
... как в примере 17.6 ...
}
int main(int argc, char** argv) {
... как в примере 17.6 ...
}

Как видите, большая часть кода взята из примера 17.6. Остальное — это код, в котором вместо shm_open и shm_unlink используются функции open и remove.
Обратите внимание: файл data.bin создается в текущем каталоге, поскольку мы
не передавали функции open абсолютный путь. Выполнение приведенного выше
кода приводит к такой же гонке данных, относящейся к разделяемому счетчику.
Результат можно проанализировать с помощью того же подхода, который применялся в примере 17.6.
До сих пор демонстрировалось, как сохранять состояние в разделяемой памяти
и разделяемых файлах и обращаться к нему из разных процессов в конкурентной
манере. Теперь пришло время провести более детальное сравнение многозадачности и многопроцессности.

Сравнение многопоточности
и многопроцессности
Благодаря обсуждению многопоточности и многопроцессности, проведенному
в главе 14, а также концепциям, рассмотренным в предыдущих главах, мы готовы
к тому, чтобы сравнить их и дать общее описание ситуаций, в которых следует использовать тот или иной подход. Представьте, что мы проектируем программное
обеспечение для конкурентной обработки ряда входящих запросов. Мы рассмотрим
три разных сценария. Начнем с первого.

Многопоточность
В первом сценарии мы можем написать приложение, состоящее из одного процесса, принимающего все запросы. Вся логика должна быть написана в рамках единого
процесса, который реализует все возможности нашей системы и в итоге становится
слишком раздутым. Данное ПО является однопроцессным, и если мы хотим конкурентно обрабатывать множество запросов, то это нужно делать многопоточным
образом, создавая для обработки каждого запроса отдельные потоки. Кроме того,
более удачным архитектурным решением здесь будет пул потоков с ограниченным
количеством элементов.

Сравнение многопоточности и многопроцессности   529

Ниже перечислены некоторые аспекты конкурентности и синхронизации, о которых необходимо позаботиться. Отмечу: хоть речь здесь и не идет о циклах событий
и асинхронном вводе/выводе, эти механизмы могут служить разумной альтернативой многозадачности.
Если количество запросов существенно возрастает, то для того, чтобы справиться
с нагрузкой, следует увеличить количество потоков в пуле. Это буквально требует
обновления аппаратного обеспечения и ресурсов компьютера, на котором выполняется главный процесс. Такой подход называется вертикальным масштабированием.
Он подразумевает увеличение производительности отдельно взятого компьютера,
чтобы тот мог обработать больше запросов. Помимо времени простоя, необходимого для установки нового оборудования (хотя его можно свести к нулю), такие
обновления требуют денежных расходов, и их приходится выполнять каждый раз,
когда запросов становится больше.
Если в ходе обработки запросов вносятся изменения в разделяемое состояние
или хранилище данных, то мы можем легко применить методы синхронизации,
зная о том, что потоки имеют доступ к общему пространству памяти. Конечно,
это требуется как в случае с разделяемой структурой данных, которую нужно
хранить, так и при доступе к удаленному хранилищу данных, которое не является
транзакционным.
Все потоки выполняются на одном компьютере и могут использовать все те методики разделения состояния, которые мы рассматривали ранее (и которые подходят не только для потоков, но и для процессов). Эта прекрасная особенность
существенно облегчает синхронизацию потоков.
Теперь обсудим второй сценарий, в котором мы имеем дело с несколькими процессами, размещенными на одном компьютере.

Локальная многопроцессность
В этом сценарии у нашего программного обеспечения есть несколько процессов, но
все они развернуты на одном компьютере. Процессы могут быть однопоточными
или содержать внутри себя пул потоков, который позволяет каждому из них обрабатывать сразу несколько запросов.
При увеличении количества запросов мы можем создавать новые процессы вместо
новых потоков. Это обычно называется горизонтальным масштабированием. Однако при наличии всего одного компьютера масштабирование должно происходить
вертикально, то есть за счет обновления аппаратного обеспечения. Это чревато
теми же проблемами, которые упоминались в предыдущем подразделе в контексте
вертикального масштабирования многопоточной программы.

530   Глава 17



Процессы

Процессы выполняются в конкурентной среде. В целях разделения состояния
и синхронизации они могут использовать только многопроцессные методики.
Это, безусловно, не настолько удобно, как писать многопоточный код. Кроме того,
для разделения состояний процессы могут применять как активные, так и пассивные механизмы.
Многопроцессность на одном компьютере не очень эффективна, и многопоточность, по всей видимости, более удобна с точки зрения усилий, необходимых для
написания кода.
В следующем подразделе речь пойдет о распределенной многопроцессной среде, которая считается лучшим архитектурным решением для создания современного ПО.

Распределенная многопроцессность
В нашем заключительном сценарии мы написали программу, которая состоит из
нескольких процессов, выполняемых на разных компьютерах. Все они соединены
с помощью сети, и на каждом из них может находиться несколько процессов. Такая
конфигурация имеет свои особенности.
При существенном увеличении количества запросов данная система может горизонтально масштабироваться без каких-либо ограничений. Это отличное свойство,
позволяющее справляться с пиковыми нагрузками за счет добавления нового потребительского оборудования. Объединение данного оборудования в кластеры,
вместо того чтобы использовать мощные серверы, было одной из тех идей, которые
позволили компании Google выполнять свои алгоритмы Page Rank и Map Reduce
на кластерах компьютеров.
Подходы, рассмотренные в текущей главе, мало чем помогают, поскольку у всех
у них есть одно важное требование: все процессы должны находиться на одном компьютере. Следовательно, чтобы синхронизировать процессы и предоставить всем
им доступ к разделяемым состояниям, нужен совершенно другой набор алгоритмов
и методик. В таких распределенных системах следует анализировать и оптимизировать латентность, отказоустойчивость, доступность, согласованность данных
и многие другие факторы.
Процессы, размещенные на разных компьютерах, взаимодействуют в пассивной манере с помощью сетевых сокетов, в то время как процессы, принадлежащие одному
компьютеру, могут использовать для передачи данных и разделения состояния локальные методы IPC, такие как очереди сообщений, разделяемая память, каналы и т. д.
В заключение отмечу: в современной компьютерной индустрии предпочтение отдается не вертикальному, а горизонтальному масштабированию. Благодаря этому
возникло множество новых идей и технологий хранения данных, синхронизации,
обмена сообщениями и т. д. Существуют даже аппаратные архитектуры, разработанные специально для поддержки горизонтального масштабирования.

Резюме   531

Резюме
В этой главе мы исследовали многопроцессные системы и различные методики,
с помощью которых можно разделять состояние между разными процессами:
zz познакомились с API POSIX для выполнения процессов и объяснили, как работают функции fork и exec*;
zz рассмотрели этапы, через которые проходит ядро при выполнении процесса;
zz обсудили разные способы разделения состояния между разными процессами;
zz познакомились с двумя общими категориями, к которым можно отнести все

методики синхронизации: активными и пассивными;
zz узнали, что разделяемая память и разделяемые объекты файловой системы —

одни из самых распространенных методов разделения состояния в активной
манере;
zz рассмотрели сходства и различия между многопоточными и многопроцессными

конфигурациями, а также такие понятия, как вертикальное и горизонтальное
масштабирование в распределенной программной системе.
В следующей главе речь пойдет о конкурентности в локальных многопроцессных
средах. Мы поговорим о проблемах конкурентности и способах синхронизации
разных процессов для защиты разделяемых ресурсов. Эти темы очень похожи на
те, с которыми мы уже сталкивались в главе 16, однако на сей раз внимание будет
уделяться не потокам, а процессам.

18

Синхронизация
процессов

Здесь мы продолжаем обсуждение, начатое в предыдущей главе, и теперь в центре
нашего внимания — синхронизация процессов. Управляющие механизмы в многопроцессных программах отличаются от тех методик, с которыми мы познакомились
при обсуждении многопоточности. Отличается не только память; отдельные факторы существуют лишь в многопроцессных средах, и вы не встретите их в многопоточной программе.
В отличие от потоков, привязанных к одному процессу, сами процессы могут
свободно выполняться на любых компьютерах с любыми операционными системами и в любого рода сети, даже в такой большой, как Интернет. Как можно себе
представить, это все усложняет. Синхронизировать разные процессы в такой распределенной системе будет нелегко.
Этаглава посвящена синхронизации процессов, происходящей на одном компьютере. Иными словами, основное внимание будет уделено локальной синхронизации
и сопутствующим методикам. Мы также затронем синхронизацию процессов в распределенных системах, однако не станем углубляться в подробности.
Мы рассмотрим следующие темы.
zz Вначале будет описано многопроцессное программное обеспечение, в котором

все процессы выполняются на одном компьютере. Мы познакомимся с методиками, доступными в таких средах, и задействуем знания, полученные в прошлой
главе, чтобы показать примеры использования этих методик.
zz В нашей первой попытке синхронизировать разные процессы будут использо-

ваться именованные POSIX-семафоры. Мы выясним, как их следует применять,
и затем рассмотрим примеры решения проблемы с состоянием гонки, с которой
сталкивались в предыдущих главах.
zz После этого речь пойдет об именованных POSIX-мьютексах; будет показано,

как подготовить их к работе с помощью областей разделяемой памяти. В качестве примера попробуем избавиться от того же состояния гонки, которое было
устранено благодаря семафорам, однако на сей раз применим именованные
мьютексы.

Локальное управление конкурентностью   533
zz Последней методикой синхронизации процессов, которую мы рассмотрим, бу-

дут именованные условные переменные POSIX. Как и именованные мьютексы,
они должны размещаться в области разделяемой памяти, чтобы к ним могли обращаться разные процессы. Я приведу подробный пример использования этого
метода, в котором будет показана синхронизация многопроцессной системы
с помощью условных переменных POSIX.
zz В конце главы будет затронута тема многопроцессных систем, процессы кото-

рых распределены по сети. Мы обсудим их свойства и дополнительные проблемы, присущие им в сравнении с локальными многопроцессными системами.
Начну эту главу с краткого обзора механизмов управления конкурентностью на
одном компьютере и методик, которые для них доступны.

Локальное управление конкурентностью
Довольно часто возникает ситуация, когда несколько процессов выполняются на
одном компьютере и пытаются одновременно обратиться к разделяемому ресурсу.
Поскольку все процессы работают в рамках одной операционной системы, они
имеют доступ ко всем механизмам, которые она предоставляет.
В данном разделе я покажу, как с помощью некоторых из этих механизмов управлять синхронизацией процессов. Ключевую роль в большинстве этих механизмов
играет разделяемая память, вследствие чего в дальнейшем мы будем в значительной мере опираться на материал, рассмотренный в предыдущей главе.
Ниже перечислены управляющие механизмы, предусмотренные в стандарте
POSIX, которые можно применять, когда все процессы выполняются на одном
POSIX-совместимом компьютере.
zz Именованные POSIX-семафоры. Те же POSIX-семафоры, которые были рассмо-

трены в главе 16, но с одним отличием: теперь у них есть имя, благодаря чему их
можно использовать глобально по всей системе. То есть это уже не анонимные
или приватные семафоры.
zz Именованные мьютексы. Те же мьютексы с теми же свойствами, о которых шла

речь в главе 16, но с именами и возможностью использования по всей системе.
Чтобы сделать эти мьютексы доступными разным процессам, их необходимо
поместить в разделяемую память.
zz Именованные условные переменные. Те же условные переменные POSIX, кото-

рые обсуждались в главе 16, но, как и в случае с мьютексами, их необходимо
размещать в объекте разделяемой памяти, чтобы они были доступны разным
процессам.

534   Глава 18



Синхронизация процессов

Далее мы поговорим обо всех этих методиках и рассмотрим их работу. Следующий
раздел посвящен именованным POSIX-семафорам.

Именованные POSIX-семафоры
Как вы уже знаете из главы 16, семафоры — основное средство синхронизации
разных конкурентных заданий. Они уже встречались нам в многопоточных программах, в которых позволяли преодолевать проблемы конкурентности.
В этом разделе я покажу, как задействовать их в контексте процессов. Пример 18.1
демонстрирует применение POSIX-семафора в целях устранения гонки данных,
с которыми мы сталкивались в примерах 17.6 и 17.7 в предыдущей главе. Представ­
ленный ниже код очень похож на пример 17.6; в нем тоже используется область разделяемой памяти для хранения переменной с разделяемым счетчиком. Однако на сей
раз для синхронизации доступа к счетчику применяются именованные семафоры.
В следующих листингах демонстрируется использование именованного семафора
для синхронизации двух процессов, обращающихся к разделяемой переменной.
Начнем с глобальных объявлений в примере 18.1 (листинг 18.1).
Листинг 18.1. Глобальные объявления в примере 18.1 (ExtremeC_examples_chapter18_1.c)

#include
...
#include

// Для использования семафоров

#define SHARED_MEM_SIZE 4
// Разделяемый файловый дескриптор, с помощью которого
// мы ссылаемся на объект в разделяемой памяти
int shared_fd = -1;
// Указатель на разделяемый счетчик
int32_t* counter = NULL;
// Указатель на разделяемый семафор
sem_t* semaphore = NULL;

Здесь объявляются глобальный счетчик и глобальный указатель на объект семафора, который будет инициализирован позже. Этот указатель позволит родительскому и дочернему процессам иметь синхронизированный доступ к разделяемому
счетчику через его указатель.
В коде листинга 18.2 показаны определения функций, которые будут заниматься
синхронизацией процессов. Часть этих определений вы уже видели в примере 17.6,
и потому они убраны из данного листинга.

Именованные POSIX-семафоры   535
Листинг 18.2. Определение функций синхронизации (ExtremeC_examples_chapter18_1.c)

void init_control_mechanism() {
semaphore = sem_open("/sem0", O_CREAT | O_EXCL, 0600, 1);
if (semaphore == SEM_FAILED) {
fprintf(stderr, "ERROR: Opening the semaphore failed: %s\n",
strerror(errno));
exit(1);
}
}
void shutdown_control_mechanism() {
if (sem_close(semaphore) < 0) {
fprintf(stderr, "ERROR: Closing the semaphore failed: %s\n",
strerror(errno));
exit(1);
}
if (sem_unlink("/sem0") < 0) {
fprintf(stderr, "ERROR: Unlinking failed: %s\n",
strerror(errno));
exit(1);
}
}
void init_shared_resource() {
... как в примере 17.6 ...
}
void shutdown_shared_resource() {
... как в примере 17.6 ...
}

Если сравнивать с примером 17.6, то у нас появились две новые функции: init_
control_mechanism и shutdown_control_mechanism. Мы также внесли некоторые
изменения в функцию inc_counter (показана в листинге 18.3, см. ниже), добавив
в нее семафор и сформировав внутри критический участок.
Внутри функций init_control_mechanism и shutdown_control_mechanism используется API для открытия, закрытия и удаления семафора, подобный интерфейсу
разделяемой памяти.
Функции sem_open, sem_close и sem_unlink можно считать аналогами shm_open,
shm_close и shm_unlink. Но есть одно отличие: функция sem_open возвращает указатель на семафор, а не файловый дескриптор.
Обратите внимание: в данном примере используется тот же API для работы с семафорами, что и прежде, поэтому остальной код остается без изменений и не отличается от примера 17.6. Мы инициализируем семафор с помощью значения 1,
превращая его тем самым в мьютекс. В листинге 18.3 показаны критический

536   Глава 18



Синхронизация процессов

участок и процедура синхронизации операций чтения и записи разделяемого
счетчика с помощью семафора.
Листинг 18.3. Критический участок, в котором инкрементируется разделяемый счетчик
(ExtremeC_examples_chapter18_1.c)

void inc_counter() {
usleep(1);
sem_wait(semaphore); // Возвращаемое значение должно проверяться
int32_t temp = *counter;
usleep(1);
temp++;
usleep(1);
*counter = temp;
sem_post(semaphore); // Возвращаемое значение должно проверяться
usleep(1);
}

Если сравнивать с функцией inc_counter из примера 17.6, то для входа на критический участок и выхода с него используются вызовы sem_wait и sem_post соответственно.
В листинге 18.4 представлена функция main. Она почти ничем не отличается от
той, которую вы видели в примере 17.6; изменения наблюдаются только в начале
и конце, где находится код двух новых функций, показанных в листинге 18.2.
Листинг 18.4. Главная функция примера 18.1 (ExtremeC_examples_chapter18_1.c)

int main(int argc, char** argv) {
// Родительский процесс должен инициализировать разделяемый ресурс
init_shared_resource();
// Родительский процесс должен инициализировать механизм управления
init_control_mechanism();
... как в примере 17.6 ...
// Только родительский процесс должен закрывать разделяемый ресурс
// и задействованный механизм управления
if (pid) {
shutdown_shared_resource();
shutdown_control_mechanism();
}
return 0;
}

В терминале 18.1 можно видеть вывод после двух успешных выполнений примера 18.1.

Именованные POSIX-семафоры   537
Терминал 18.1. Сборка и два последовательных запуска примера 18.1 в Linux
$ gcc ExtremeC_examples_chapter18_1.c -lrt -lpthread -o ex18_1.out
$ ./ex18_1.out
Shared memory is created with fd: 3
The memory region is truncated.
The child process sees the counter as 1.
The parent process sees the counter as 2.
The child process finished with status 0.
$ ./ex18_1.out
Shared memory is created with fd: 3
The memory region is truncated.
The parent process sees the counter as 1.
The child process sees the counter as 2.
The child process finished with status 0.
$

Обратите внимание: этот код нужно скомпоновать с библиотекой pthread, поскольку мы используем POSIX-семафоры. В Linux также необходимо провести
компоновку с библиотекой rt, чтобы иметь доступ к разделяемой памяти.
В показанном выше выводе все понятно. Иногда первым получает ресурсы процессора и инкрементирует счетчик дочерний процесс, а иногда — родительский.
Они не могут зайти на критический участок одновременно, поэтому наш код соблюдает целостность данных в отношении разделяемого счетчика.
Отмечу, что для работы с именованными семафорами не обязательно использовать
функцию fork. Один и тот же семафор могут открыть совершенно разные процессы,
которые не являются родителем и потомком, — они всего лишь должны выполняться на одном компьютере и в одной и той же операционной системе. Это будет
продемонстрировано в примере 18.3.
В заключение следует сказать, что в Unix-подобных операционных системах существует два вида именованных семафоров: семафоры System V и POSIX-семафоры.
В этом разделе использовались последние, так как благодаря своей производительности и удобному API их репутация куда лучше. Ниже приводится вопрос на сайте
Stack Overflow, в котором хорошо описаны различия между семафорами System V
и POSIX-семафорами: https://stackoverflow.com/questions/368322/differences-betweensystem-v-and-posix-semaphores.
Если говорить о работе с семафорами, то Microsoft Windows не является POSIX-совместимой ОС. У нее есть собственный API для создания
и управления семафорами.

В следующем разделе мы обсудим именованные мьютексы. Если коротко, то это
обычные мьютексы, размещенные в области разделяемой памяти.

538   Глава 18



Синхронизация процессов

Именованные мьютексы
В многопоточных программах POSIX-мьютексы работают довольно просто; это
было показано в главе 16. Однако в многопроцессных средах все немного иначе.
Чтобы мьютекс можно было использовать в разных процессах, его необходимо
определить там, где он будет им доступен.
Лучшее место подобного рода — область разделяемой памяти. Следовательно,
чтобы получить мьютекс, который работает в многопроцессной среде, его нужно
распределить именно в этой области.

Первый пример
Следующий пример, 18.2, — копия предыдущего, но для борьбы с потенциальным
состоянием гонки в нем применяются именованные мьютексы вместо именованных
семафоров. В нем также показано, как создать область разделяемой памяти и сохранить в ней разделяемый мьютекс.
Поскольку каждый объект разделяемой памяти имеет глобальное имя, мьютекс,
размещаемый в этой области, можно считать именованным, и обращаться к нему
могут другие процессы в системе.
В листинге 18.5 показаны объявления, необходимые для примера 18.2. Это то, что
требуется для создания разделяемого мьютекса.
Листинг 18.5. Глобальные объявления в примере 18.2 (ExtremeC_examples_chapter18_2.c)

#include
#include
#include
#include
#include
#include
#include
#include
#include
#include










// Для использования функций pthread_mutex_*

#define SHARED_MEM_SIZE 4
// Разделяемый файловый дескриптор для обращения к объекту
// в разделяемой памяти
int shared_fd = -1;
// Разделяемый файловый дескриптор для обращения
// к объекту в разделяемой памяти мьютекса
int mutex_shm_fd = -1;

Именованные мьютексы   539
// Указатель на разделяемый счетчик
int32_t* counter = NULL;
// Указатель на разделяемый мьютекс
pthread_mutex_t* mutex = NULL;

Здесь мы объявили:
zz глобальный файловый дескриптор для обращения к области разделяемой памя-

ти, в которой должна храниться переменная разделяемого счетчика;
zz глобальный файловый дескриптор для области разделяемой памяти, в которой

хранится разделяемый мьютекс;
zz указатель на разделяемый счетчик;
zz указатель на разделяемый мьютекс.

Ниже эти переменные будут инициализированы соответствующим образом.
В следующих листингах показаны все те функции, которые у нас были в примере 18.1, но, как видите, мы обновили их определения, чтобы они работали
с именованным мьютексом вместо именованного семафора. Начнем с функции
init_control_mechanism (листинг 18.6).
Листинг 18.6. Функция init_control_mechanism в примере 18.2 (ExtremeC_examples_chapter18_2.c)

void init_control_mechanism() {
// Открываем разделяемую память мьютекса
mutex_shm_fd = shm_open("/mutex0", O_CREAT | O_RDWR, 0600);
if (mutex_shm_fd < 0) {
fprintf(stderr, "ERROR: Failed to create shared memory: %s\n"
, strerror(errno));
exit(1);
}
// Выделяем и усекаем разделяемую область памяти мьютекса
if (ftruncate(mutex_shm_fd, sizeof(pthread_mutex_t)) < 0) {
fprintf(stderr, "ERROR: Truncation of mutex failed: %s\n",
strerror(errno));
exit(1);
}
// Отображаем разделяемую память мьютекса
void* map = mmap(0, sizeof(pthread_mutex_t),
PROT_READ | PROT_WRITE, MAP_SHARED, mutex_shm_fd, 0);
if (map == MAP_FAILED) {
fprintf(stderr, "ERROR: Mapping failed: %s\n",
strerror(errno));
exit(1);
}
mutex = (pthread_mutex_t*)map;
// Инициализируем объект мьютекса
int ret = -1;

540   Глава 18



Синхронизация процессов

pthread_mutexattr_t attr;
if ((ret = pthread_mutexattr_init(&attr))) {
fprintf(stderr, "ERROR: Failed to init mutex attrs: %s\n",
strerror(ret));
exit(1);
}
if ((ret = pthread_mutexattr_setpshared(&attr,
PTHREAD_PROCESS_SHARED))) {
fprintf(stderr, "ERROR: Failed to set the mutex attr: %s\n",
strerror(ret));
exit(1);
}
if ((ret = pthread_mutex_init(mutex, &attr))) {
fprintf(stderr, "ERROR: Initializing the mutex failed: %s\n",
strerror(ret));
exit(1);
}
if ((ret = pthread_mutexattr_destroy(&attr))) {
fprintf(stderr, "ERROR: Failed to destroy mutex attrs : %s\n"
, strerror(ret));
exit(1);
}
}

Внутри функции init_control_mechanism мы создали новый объект разделяемой
памяти с именем /mutex0. В качестве размера области разделяемой памяти указано значение sizeof(pthread_mutex_t); это говорит о том, что мы хотим разделить
объект POSIX-мьютекса.
Далее мы получаем указатель на область разделяемой памяти. Теперь у нас есть
мьютекс, выделенный в разделяемой памяти, однако нам все еще нужно его инициа­
лизировать. Для этого мы вызываем функцию pthread_mutex_init и передаем ей
атрибуты, которые говорят о том, что объект мьютекса должен быть разделяемым
и доступным для других процессов. Это особенно важно, поскольку в противном
случае мьютекс не будет работать в многопроцессной среде, даже если поместить
его в область разделяемой памяти. Как было показано в предыдущем листинге
в функции init_control_mechanism, мы установили атрибут PTHREAD_PROCESS_
SHARED, чтобы сделать мьютекс разделяемым. Рассмотрим следующую функцию
(листинг 18.7)
Листинг 18.7. Функция destroy_control_mechanism в примере 18.2
(ExtremeC_examples_chapter18_2.c)

void shutdown_control_mechanism() {
int ret = -1;
if ((ret = pthread_mutex_destroy(mutex))) {
fprintf(stderr, "ERROR: Failed to destroy mutex: %s\n",
strerror(ret));
exit(1);
}

Именованные мьютексы   541

}

if (munmap(mutex, sizeof(pthread_mutex_t)) < 0) {
fprintf(stderr, "ERROR: Unmapping the mutex failed: %s\n",
strerror(errno));
exit(1);
}
if (close(mutex_shm_fd) < 0) {
fprintf(stderr, "ERROR: Closing the mutex failed: %s\n",
strerror(errno));
exit(1);
}
if (shm_unlink("/mutex0") < 0) {
fprintf(stderr, "ERROR: Unlinking the mutex failed: %s\n",
strerror(errno));
exit(1);
}

В функции destroy_control_mechanism мы уничтожаем объект мьютекса и затем
закрываем и удаляем соответствующую область разделяемой памяти. Это ничем
не отличается от уничтожения обычного объекта разделяемой памяти. Перейдем
к следующему фрагменту кода (листинг 18.8).
Листинг 18.8. Эти функции мы уже видели в примере 18.1 (ExtremeC_examples_chapter18_2.c)

void init_shared_resource() {
... как в примере 18.1 ...
}
void shutdown_shared_resource() {
... как в примере 18.1 ...
}

Показанные выше функции не претерпели никаких изменений по сравнению
с примером 18.1. Взглянем на критический участок в функции inc_counter, которая теперь использует именованный мьютекс вместо именованного семафора
(листинг 18.9).
Листинг 18.9. Для защиты разделяемого счетчика на критическом участке теперь используется
именованный мьютекс (ExtremeC_examples_chapter18_2.c)

void inc_counter() {
usleep(1);
pthread_mutex_lock(mutex); // Нужно проверить возвращаемое значение
int32_t temp = *counter;
usleep(1);
temp++;
usleep(1);
*counter = temp;
pthread_mutex_unlock(mutex); // Нужно проверить возвращаемое значение
usleep(1);
}

542   Глава 18



Синхронизация процессов

int main(int argc, char** argv) {
... как в примере 18.1 ...
}

В целом, как показывают эти листинги, мы изменили всего несколько участков по
сравнению с примером 18.1, и лишь три функции поменялись существенно. Например, функция main осталась точно в таком же виде, в котором была в примере 18.1.
Это объясняется тем, что мы заменили лишь управляющий механизм, не трогая
остальную логику.
Завершая рассматривать листинг 18.9, отметим: в функции inc_counter объект
мьютекса используется точно так же, как это делалось в многопоточной программе.
API абсолютно аналогичен; он спроектирован таким образом, чтобы его можно
было применять как в многопоточных, так и в многопроцессных средах. Эта замечательная особенность POSIX-мьютексов позволяет нам использовать один и тот же
код, независимо от того, что работает с этими объектами: потоки или процессы.
Хотя, конечно, инициализация и уничтожение могут отличаться.
Вывод представленного выше кода очень похож на тот, который мы наблюдали
в примере 18.1. В данном случае разделяемый счетчик защищен мьютексом, а не
семафором, как раньше. Семафор, который применялся в предыдущем примере,
был двоичным, и, согласно объяснениям в главе 16, двоичные семафоры могут
имитировать мьютексы. Таким образом, в примере 18.2 мало что изменилось.

Второй пример
Именованные объекты разделяемой памяти и мьютексы можно применять в любом
процессе, запущенном в системе. И этот процесс не обязательно должен быть клонированным. В примере 18.3 мы рассмотрим совместное использование разделяемого мьютекса и разделяемой памяти для одновременного завершения нескольких
процессов, которые работают параллельно. Мы хотим, чтобы при нажатии Ctrl+C
в одном из процессов все они немедленно прекращали работу.
Обратите внимание: код будет представлен поэтапно. Вслед за каждым этапом идут
комментарии. Начнем с глобальных определений.

Этап 1: глобальные определения
В этом примере мы будем работать с одним исходным файлом, который можно будет скомпилировать и многократно запустить для создания нескольких процессов.
В целях синхронизации своего выполнения эти процессы используют области разделяемой памяти. Один из них выбирает владелец этих областей; он будет отвечать
за их создание и уничтожение. Другие процессы просто применяют уже созданную
разделяемую память.

Именованные мьютексы   543

На первом этапе мы объявим глобальные объекты, которые понадобятся нам
на разных участках кода. Позже они будут инициализированы. Обратите внимание:
глобальные переменные, объявленные в листинге 18.10 (такие как mutex), не разделяются между процессами. Они существуют в адресном пространстве каждого
отдельного процесса, но привязываются к разным областям разделяемой памяти.
Листинг 18.10. Глобальные объявления в примере 18.3 (ExtremeC_examples_chapter18_3.c)

#include
...
#include // Для использования функций pthread_mutex_*
typedef uint16_t bool_t;
#define TRUE 1
#define FALSE 0
#define MUTEX_SHM_NAME "/mutex0"
#define SHM_NAME "/shm0"
// Разделяемый файловый дескриптор для обращения к объекту разделяемой
// памяти, содержащему флаг отмены
int cancel_flag_shm_fd = -1;
// Флаг, определяющий, владеет ли текущий процесс
// объектом разделяемой памяти
bool_t cancel_flag_shm_owner = FALSE;
// Разделяемый файловый дескриптор для обращения к объекту разделяемой
// памяти мьютекса
int mutex_shm_fd = -1;
// Разделяемый мьютекс
pthread_mutex_t* mutex = NULL;
// Флаг, определяющий, владеет ли текущий процесс
// объектом разделяемой памяти
bool_t mutex_owner = FALSE;
// Указатель на флаг отмены, хранящийся в разделяемой памяти
bool_t* cancel_flag = NULL;

В этом коде мы можем видеть глобальные объявления. Разделяемый флаг позволит уведомлять процессы о сигнале отмены. Отмечу, что в данном примере для
ожидания момента, когда флаг отмены станет равен true, будет использоваться
холостой цикл.
Здесь, как и в примере 18.2, у нас есть два отдельных объекта разделяемой памяти:
один для флага отмены, а другой — для мьютекса, который защищает данный флаг.

544   Глава 18



Синхронизация процессов

Я мог бы создать единую структуру и определить в ней оба объекта в виде полей,
что позволило бы хранить их в одной области разделяемой памяти. Но я решил
использовать для этого разные области.
Одним из важных нюансов при работе с объектами разделяемой памяти в этом
примере является то, что очистку должен производить процесс, который их изначально создал и инициализировал. Поскольку все процессы используют один
и тот же код, нам нужно каким-то образом определить владельца объекта. Таким
образом, когда мы дойдем до очистки, удалить объект сможет только его владелец. В связи с этим нам пришлось объявить две булевы переменные: mutex_owner
и cancel_flag_shm_owner.

Этап 2: разделяемая память флага отмены
В листинге 18.11 показана процедура инициализации области разделяемой памяти,
отведенной флагу отмены.
Листинг 18.11. Инициализация разделяемой памяти флага отмены
(ExtremeC_examples_chapter18_3.c)

void init_shared_resource() {
// Открываем объект разделяемой памяти
cancel_flag_shm_fd = shm_open(SHM_NAME, O_RDWR, 0600);
if (cancel_flag_shm_fd >= 0) {
cancel_flag_shm_owner = FALSE;
fprintf(stdout, "The shared memory object is opened.\n");
} else if (errno == ENOENT) {
fprintf(stderr,
"WARN: The shared memory object doesn't exist.\n");
fprintf(stdout, "Creating the shared memory object ...\n");
cancel_flag_shm_fd = shm_open(SHM_NAME,
O_CREAT | O_EXCL | O_RDWR, 0600);
if (cancel_flag_shm_fd >= 0) {
cancel_flag_shm_owner = TRUE;
fprintf(stdout, "The shared memory object is created.\n");
} else {
fprintf(stderr,
"ERROR: Failed to create shared memory: %s\n",
strerror(errno));
exit(1);
}
} else {
fprintf(stderr,
"ERROR: Failed to create shared memory: %s\n",
strerror(errno));
exit(1);
}
if (cancel_flag_shm_owner) {
// Выделяем и усекаем разделяемую область памяти

Именованные мьютексы   545
if (ftruncate(cancel_flag_shm_fd, sizeof(bool_t)) < 0) {
fprintf(stderr, "ERROR: Truncation failed: %s\n",
strerror(errno));
exit(1);
}
fprintf(stdout, "The memory region is truncated.\n");
}
// Отображаем разделяемую память и инициализируем флаг отмены
void* map = mmap(0, sizeof(bool_t), PROT_WRITE, MAP_SHARED,
cancel_flag_shm_fd, 0);
if (map == MAP_FAILED) {
fprintf(stderr, "ERROR: Mapping failed: %s\n",
strerror(errno));
exit(1);
}
cancel_flag = (bool_t*)map;
if (cancel_flag_shm_owner) {
*cancel_flag = FALSE;
}
}

Выбранный нами подход отличается от того, который использовался в примере 18.2. Дело в том, что каждый новый процесс должен проверять, был ли объект
разделяемой памяти создан другим процессом. Обратите внимание: в этом примере
создание новых процессов обходится без функции fork, и пользователь может самостоятельно создавать новые процессы в своей командной оболочке.
В связи с этим новый процесс сначала пытается открыть область разделяемой
памяти, предоставляя один лишь флаг O_RDWR. Успех будет признаком того, что
текущий процесс не владеет данной областью, поэтому на следующем шаге привязывает его. Неудача означает отсутствие области разделяемой памяти, и это говорит о том, что текущий процесс должен ее создать и стать ее владельцем. Таким
образом, он продолжает работу и пытается открыть область с помощью других
флагов: O_CREAT и O_EXCL. Эти флаги создают объект разделяемой памяти, если он
еще не существует.
Если создание пройдет успешно, то текущий процесс становится владельцем области разделяемой памяти и переходит к ее усечению и привязке.
В описанном выше сценарии существует небольшой шанс, что другой процесс
создаст ту же область разделяемой памяти прямо между двумя вызовами shm_open,
в результате чего второй из них завершится неудачей. Флаг O_EXCL не дает текущему процессу создать объект, если тот уже существует; в таком случае процесс завершает работу, показывая подходящее сообщение об ошибке. Если это произойдет
(что маловероятно), то мы всегда можем запустить процесс повторно, и во второй
раз подобной проблемы уже не будет.
Код, показанный в листинге 18.12, выполняет обратную процедуру: уничтожает
флаг отмены и его область разделяемой памяти.

546   Глава 18



Синхронизация процессов

Листинг 18.12. Освобождение ресурсов, выделенных для разделяемой памяти флага отмены
(ExtremeC_examples_chapter18_3.c)

void shutdown_shared_resource() {
if (munmap(cancel_flag, sizeof(bool_t)) < 0) {
fprintf(stderr, "ERROR: Unmapping failed: %s\n",
strerror(errno));
exit(1);
}
if (close(cancel_flag_shm_fd) < 0) {
fprintf(stderr,
"ERROR: Closing the shared memory fd filed: %s\n",
strerror(errno));
exit(1);
}
if (cancel_flag_shm_owner) {
sleep(1);
if (shm_unlink(SHM_NAME) < 0) {
fprintf(stderr,
"ERROR: Unlinking the shared memory failed: %s\n",
strerror(errno));
exit(1);
}
}
}

Логика освобождения объекта разделяемой памяти, представленная в данном листинге, очень похожа на ту, которую мы видели в предыдущих примерах. Но здесь
есть одно отличие: удалить объект разделяемой памяти может только владеющий
им процесс. Обратите внимание: прежде чем удалять объект, владелец ждет 1 секунду, чтобы другие процессы успели освободить свои ресурсы. Эта задержка
обычно не требуется, поскольку в большинстве POSIX-совместимых систем объект
разделяемой памяти остается на месте, пока не завершатся все процессы, которые
зависят от него.

Этап 3: разделяемая память именованного мьютекса
В листинге 18.13 показано, как инициализировать разделяемый мьютекс и связанный с ним объект разделяемой памяти.
Листинг 18.13. Инициализация разделяемого мьютекса и соответствующей области
разделяемой памяти (ExtremeC_examples_chapter18_3.c)

void init_control_mechanism() {
// Открываем разделяемую память мьютекса
mutex_shm_fd = shm_open(MUTEX_SHM_NAME, O_RDWR, 0600);
if (mutex_shm_fd >= 0) {
// Разделяемый объект мьютекса существует, и я теперь его владелец
mutex_owner = FALSE;

Именованные мьютексы   547
fprintf(stdout,
"The mutex's shared memory object is opened.\n");
} else if (errno == ENOENT) {
fprintf(stderr,
"WARN: Mutex's shared memory doesn't exist.\n");
fprintf(stdout,
"Creating the mutex's shared memory object ...\n");
mutex_shm_fd = shm_open(MUTEX_SHM_NAME,
O_CREAT | O_EXCL | O_RDWR, 0600);
if (mutex_shm_fd >= 0) {
mutex_owner = TRUE;
fprintf(stdout,
"The mutex's shared memory object is created.\n");
} else {
fprintf(stderr,
"ERROR: Failed to create mutex's shared memory: %s\n",
strerror(errno));
exit(1);
}
} else {
fprintf(stderr,
"ERROR: Failed to create mutex's shared memory: %s\n",
strerror(errno));
exit(1);
}
if (mutex_owner) {
// Выделяем и усекаем область разделяемой памяти мьютекса
if (ftruncate(mutex_shm_fd, sizeof(pthread_mutex_t)) < 0) {
fprintf(stderr,
"ERROR: Truncation of the mutex failed: %s\n",
strerror(errno));
exit(1);
}
}
// Отображаем разделяемую память мьютекса
void* map = mmap(0, sizeof(pthread_mutex_t),
PROT_READ | PROT_WRITE, MAP_SHARED, mutex_shm_fd, 0);
if (map == MAP_FAILED) {
fprintf(stderr, "ERROR: Mapping failed: %s\n",
strerror(errno));
exit(1);
}
mutex = (pthread_mutex_t*)map;
if (mutex_owner) {
int ret = -1;
pthread_mutexattr_t attr;
if ((ret = pthread_mutexattr_init(&attr))) {
fprintf(stderr,
"ERROR: Initializing mutex attributes failed: %s\n",
strerror(ret));

548   Глава 18



Синхронизация процессов

exit(1);
}
if ((ret = pthread_mutexattr_setpshared(&attr,
PTHREAD_PROCESS_SHARED))) {
fprintf(stderr,
"ERROR: Setting the mutex attribute failed: %s\n",
strerror(ret));
exit(1);
}
if ((ret = pthread_mutex_init(mutex, &attr))) {
fprintf(stderr,
"ERROR: Initializing the mutex failed: %s\n",
strerror(ret));
exit(1);
}
if ((ret = pthread_mutexattr_destroy(&attr))) {
fprintf(stderr,
"ERROR: Destruction of mutex attributes failed: %s\n",
strerror(ret));
exit(1);
}
}
}

Здесь мы создаем и инициализируем область разделяемой памяти, лежащей в основе разделяемого мьютекса. Это похоже на то, как мы пытались создать область
разделяемой памяти для флага отмены. Стоит отметить: как и в примере 18.2,
мьютекс имеет атрибут PTHREAD_PROCESS_SHARED, благодаря чему его могут использовать разные процессы.
Листинг 18.14 демонстрирует процедуру освобождения разделяемого мьютекса.
Листинг 18.14. Освобождение разделяемого мьютекса и связанной с ним области разделяемой
памяти (ExtremeC_examples_chapter18_3.c)

void shutdown_control_mechanism() {
sleep(1);
if (mutex_owner) {
int ret = -1;
if ((ret = pthread_mutex_destroy(mutex))) {
fprintf(stderr,
"WARN: Destruction of the mutex failed: %s\n",
strerror(ret));
}
}
if (munmap(mutex, sizeof(pthread_mutex_t)) < 0) {
fprintf(stderr, "ERROR: Unmapping the mutex failed: %s\n",
strerror(errno));
exit(1);
}

Именованные мьютексы   549
if (close(mutex_shm_fd) < 0) {
fprintf(stderr, "ERROR: Closing the mutex failed: %s\n",
strerror(errno));
exit(1);
}
if (mutex_owner) {
if (shm_unlink(MUTEX_SHM_NAME) < 0) {
fprintf(stderr, "ERROR: Unlinking the mutex failed: %s\n",
strerror(errno));
exit(1);
}
}
}

И снова отмечаем, что разделяемую память мьютекса может освободить только
процесс, который ею владеет.

Этап 4: установка флага отмены
В листинге 18.15 показаны функции, которые позволяют процессу считывать
и устанавливать флаг отмены.
Листинг 18.15. Синхронизированные функции, которые считывают и устанавливают флаг
отмены, защищенный разделяемым мьютексом (ExtremeC_examples_chapter18_3.c)

bool_t is_canceled() {
pthread_mutex_lock(mutex); // Нужно проверить возвращаемое значение
bool_t temp = *cancel_flag;
pthread_mutex_unlock(mutex); // Нужно проверить возвращаемое значение
return temp;
}
void cancel() {
pthread_mutex_lock(mutex); // Нужно проверить возвращаемое значение
*cancel_flag = TRUE;
pthread_mutex_unlock(mutex); // Нужно проверить возвращаемое значение
}

Эти две функции обеспечивают синхронизированный доступ к разделяемому флагу
отмены. Функция is_canceled используется для проверки значения флага, а функция cancel — для его установки. Как видите, обе они защищены одним и тем же
разделяемым мьютексом.

Этап 5: главная функция
И, наконец, в листинге 18.16 представлены функция main и обработчик сигналов,
о котором мы поговорим чуть ниже.

550   Глава 18



Синхронизация процессов

Листинг 18.16. Функция main и обработчик сигналов в примере 18.3
(ExtremeC_examples_chapter18_3.c)

void sigint_handler(int signo) {
fprintf(stdout, "\nHandling INT signal: %d ...\n", signo);
cancel();
}
int main(int argc, char** argv) {
signal(SIGINT, sigint_handler);
// Родительский процесс должен инициализировать разделяемый ресурс
init_shared_resource();
// Родительский процесс должен инициализировать механизм управления
init_control_mechanism();
while(!is_canceled()) {
fprintf(stdout, "Working ...\n");
sleep(1);
}
fprintf(stdout, "Cancel signal is received.\n");
shutdown_shared_resource();
shutdown_control_mechanism();
return 0;
}

Логика внутри функции main выглядит вполне понятно. Она инициализирует разделяемые флаг и мьютекс, после чего входит в холостой цикл и ждет, пока флаг
отмены будет равен true. В конце она освобождает все разделяемые ресурсы и завершает работу.
В этом коде есть кое-что новое: использование функции signal, которая назначает
обработчик для определенного набора сигналов. Сигналы — один из механизмов,
доступный во всех POSIX-совместимых операционных системах, с помощью которого процессы могут слать друг другу информацию в рамках одной ОС. Терминал —
обычный процесс, с которым взаимодействуют пользователи и который позволяет
отправлять сигналы другим процессам. Нажать Ctrl+C — удобный способ послать
сигнал SIGINT активному процессу, запущенному в терминале.
Сигнал SIGINT представляет собой сигнал прерывания, который может получить
процесс. В предыдущем листинге мы назначили функцию sigint_handler в качестве обработчика сигнала SIGINT . Иными словами, эта функция вызывается
каждый раз, когда процесс получает этот сигнал. Если SIGINT не обработать, то
будет выполнена процедура по умолчанию, которая завершит процесс, но ее можно
переопределить с помощью обработчиков сигналов, как показано выше.

Именованные мьютексы   551

Существует множество способов, как отправить процессу сигнал SIGINT, но один
из самых простых — нажать сочетание клавиш Ctrl+C на клавиатуре, в результате
которого сигнал будет сразу же получен. Внутри обработчика сигнала мы присваиваем разделяемому флагу отмены значение true, после чего все процессы начинают
выходить из своих холостых циклов.
В терминале 18.2 продемонстрированы компиляция и выполнение предыдущего
листинга. Соберем наш код и запустим первый процесс.
Терминал 18.2. Компиляция примера 18.3 и запуск первого процесса
$ gcc ExtremeC_examples_chapter18_3.c -lpthread -lrt -o ex18_3.out
$ ./ex18_3.out
WARN: The shared memory object doesn't exist.
Creating a shared memory object ...
The shared memory object is created.
The memory region is truncated.
WARN: Mutex's shared memory object doesn't exist.
Creating the mutex's shared memory object ...
The mutex's shared memory object is created.
Working ...
Working ...
Working ...

Как видите, данный процесс запускается первым, поэтому становится владельцем
мьютекса и флага отмены. В терминале 18.3 показан запуск второго процесса.
Терминал 18.3. Запуск второго процесса
$ ./ex18_3.out
The shared memory object is opened.
The mutex's shared memory object is opened.
Working ...
Working ...
Working ...

Второй процесс не является владельцем объектов разделяемой памяти, поэтому только открывает их. В следующем выводе демонстрируется нажатие Ctrl+C
в первом процессе (терминал 18.4).
Терминал 18.4. Вывод первого процесса при нажатии Ctrl+C
...
Working ...
Working ...
^C
Handling INT signal: 2 ...
Cancel signal is received.
$

552   Глава 18



Синхронизация процессов

Первый процесс сообщает о том, что обрабатывает сигнал с номером 2 (это стандартный номер SIGINT). Он устанавливает флаг отмены и сразу же завершается.
Вслед за этим прекращает работу и второй процесс. В терминале 18.5 показан вывод второго процесса.
Терминал 18.5. Вывод второго процесса в момент, когда устанавливается флаг отмены
...
Working ...
Working ...
Working ...
Cancel signal is received.
$

Тот же результат можно получить, если послать сигнал SIGINT второму процессу;
оба процесса получат сигнал и завершат работу. Кроме того, вы можете создать
несколько процессов, и все они синхронно завершатся, применяя одни и те же
разделяемую память и мьютекс.
В следующем разделе мы рассмотрим использование условных переменных. Если
поместить условную переменную в область разделяемой памяти, то к ней, как
и к именованному мьютексу, можно будет обращаться из разных процессов, задействуя имя соответствующей области.

Именованные условные переменные
Ранее я уже объяснял: чтобы использовать условные переменные POSIX в многопроцессной системе, их, как и именованные POSIX-мьютексы, необходимо выделять в области разделяемой памяти. В примере 18.4 показано, как это делается; мы
создадим ряд процессов, которые будут выполнять отсчет в определенном порядке.
В главе 16 мы обсудили, что каждая условная переменная должна использоваться
в связке с объектом мьютекса, защищающим ее. Поэтому в примере 18.4 будет три
области разделяемой памяти: для разделяемого счетчика, для разделяемой именованной условной переменной и для разделяемого именованного мьютекса, который
будет ее защищать.
Обратите внимание: вместо трех разных областей разделяемой памяти мы могли бы использовать одну общую. Для этого можно было бы определить структуру,
которая заключает в себе все необходимые объекты. Но в данном примере мы
не станем применять этот подход и определим для каждого объекта отдельную
область разделяемой памяти.
Пример 18.4 состоит из ряда процессов, которые должны выполнять отсчет в порядке возрастания. Каждому процессу назначается номер, начиная с 1 и заканчивая

Именованные условные переменные   553

количеством процессов. Данный номер обозначает приоритет. Первоочередное
право отсчета отдается процессу с наименьшим номером; только когда он закончит,
следующий процесс сможет выполнить свой отсчет и завершить работу. Это значит,
первым отсчет выполняет процесс с номером 1, даже если был создан последним.
Поскольку у нас будет три разные области разделяемой памяти, каждую из которых нужно отдельно инициализировать и освобождать, нам пришлось бы дуб­
лировать много кода, реши мы использовать тот же подход, что и в предыдущих
примерах. Сделаем код более лаконичным и организованным, а также оформим
повторяющиеся участки в виде функций с помощью объектно-ориентированного
стиля. Для этого задействуем концепции и процедуры, описанные в главах 6, 7 и 8.
Пример 18.4 будет написан в объектно-ориентированной манере с применением
наследования, чтобы уменьшить объем дублируемого кода.
Мы определим общий родительский класс для всех областей разделяемой памяти,
а также по одному дочернему классу для разделяемого счетчика, разделяемого именованного мьютекса и разделяемой именованной условной переменной. У каждого
из этих классов будет своя пара заголовочных и исходных файлов, и все они будут
использоваться в главной функции нашего примера.
В следующих разделах мы последовательно пройдемся по каждому из вышеупомянутых классов. Начнем с родительского класса: разделяемой памяти.

Этап 1: класс разделяемой памяти
В листинге 18.17 показаны определения класса разделяемой памяти.
Листинг 18.17. Публичный интерфейс класса разделяемой памяти
(ExtremeC_examples_chapter18_4_shared_mem.h)

struct shared_mem_t;
typedef int32_t bool_t;
struct shared_mem_t* shared_mem_new();
void shared_mem_delete(struct shared_mem_t* obj);
void shared_mem_ctor(struct shared_mem_t* obj,
const char* name,
size_t size);
void shared_mem_dtor(struct shared_mem_t* obj);
char* shared_mem_getptr(struct shared_mem_t* obj);
bool_t shared_mem_isowner(struct shared_mem_t* obj);
void shared_mem_setowner(struct shared_mem_t* obj,
bool_t is_owner);

554   Глава 18



Синхронизация процессов

В этом листинге содержатся объявления (публичный API), необходимые для
работы с объектом разделяемой памяти. Функции shared_mem_getptr, shared_mem_
isowner и shared_mem_setowner представляют собой поведение данного класса.
Если вам незнаком этот синтаксис, то, пожалуйста, прочитайте главы 6, 7 и 8.
В листинге 18.18 показаны определения функций, объявленных в рамках публичного интерфейса класса в листинге 18.17.
Листинг 18.18. Определения всех функций, принадлежащих классу разделяемой памяти
(ExtremeC_examples_chapter18_4_shared_mem.c)

#include
#include
#include
#include
#include
#include
#include









#define TRUE 1
#define FALSE 0
typedef int32_t bool_t;
bool_t owner_process_set = FALSE;
bool_t owner_process = FALSE;
typedef struct {
char* name;
int shm_fd;
void* map_ptr;
char* ptr;
size_t size;
} shared_mem_t;
shared_mem_t* shared_mem_new() {
return (shared_mem_t*)malloc(sizeof(shared_mem_t));
}
void shared_mem_delete(shared_mem_t* obj) {
free(obj->name);
free(obj);
}
void shared_mem_ctor(shared_mem_t* obj, const char* name,
size_t size) {
obj->size = size;
obj->name = (char*)malloc(strlen(name) + 1);
strcpy(obj->name, name);

Именованные условные переменные   555
obj->shm_fd = shm_open(obj->name, O_RDWR, 0600);
if (obj->shm_fd >= 0) {
if (!owner_process_set) {
owner_process = FALSE;
owner_process_set = TRUE;
}
printf("The shared memory %s is opened.\n", obj->name);
} else if (errno == ENOENT) {
printf("WARN: The shared memory %s does not exist.\n",
obj->name);
obj->shm_fd = shm_open(obj->name,
O_CREAT | O_RDWR, 0600);
if (obj->shm_fd >= 0) {
if (!owner_process_set) {
owner_process = TRUE;
owner_process_set = TRUE;
}
printf("The shared memory %s is created and opened.\n",
obj->name);
if (ftruncate(obj->shm_fd, obj->size) < 0) {
fprintf(stderr, "ERROR(%s): Truncation failed: %s\n",
obj->name, strerror(errno));
exit(1);
}
} else {
fprintf(stderr,
"ERROR(%s): Failed to create shared memory: %s\n",
obj->name, strerror(errno));
exit(1);
}
} else {
fprintf(stderr,
"ERROR(%s): Failed to create shared memory: %s\n",
obj->name, strerror(errno));
exit(1);
}
obj->map_ptr = mmap(0, obj->size, PROT_READ | PROT_WRITE,
MAP_SHARED, obj->shm_fd, 0);
if (obj->map_ptr == MAP_FAILED) {
fprintf(stderr, "ERROR(%s): Mapping failed: %s\n",
name, strerror(errno));
exit(1);
}
obj->ptr = (char*)obj->map_ptr;
}
void shared_mem_dtor(shared_mem_t* obj) {
if (munmap(obj->map_ptr, obj->size) < 0) {
fprintf(stderr, "ERROR(%s): Unmapping failed: %s\n",
obj->name, strerror(errno));
exit(1);

556   Глава 18



Синхронизация процессов

}
printf("The shared memory %s is unmapped.\n", obj->name);
if (close(obj->shm_fd) < 0) {
fprintf(stderr,
"ERROR(%s): Closing the shared memory fd failed: %s\n",
obj->name, strerror(errno));
exit(1);
}
printf("The shared memory %s is closed.\n", obj->name);
if (owner_process) {
if (shm_unlink(obj->name) < 0) {
fprintf(stderr,
"ERROR(%s): Unlinking the shared memory failed: %s\n",
obj->name, strerror(errno));
exit(1);
}
printf("The shared memory %s is deleted.\n", obj->name);
}
}
char* shared_mem_getptr(shared_mem_t* obj) {
return obj->ptr;
}
bool_t shared_mem_isowner(shared_mem_t* obj) {
return owner_process;
}
void shared_mem_setowner(shared_mem_t* obj, bool_t is_owner) {
owner_process = is_owner;
}

Как видите, мы просто скопировали код, написанный для работы с разделяемой
памятью в предыдущих примерах. Структура shared_mem_t инкапсулирует все,
что нам нужно для обращения к объекту разделяемой памяти POSIX. Обратите
внимание на глобальную булеву переменную process_owner. Она определяет,
является ли текущий процесс владельцем всех областей разделяемой памяти.
Она устанавливается только однажды.

Этап 2: класс разделяемого
32-битного целочисленного счетчика
В листинге 18.19 содержится определение класса разделяемого счетчика, который
представляет собой 32-битное целое число. Этот класс наследуется от класса разделяемой памяти. Как вы могли заметить, для реализации наследования мы используем только второй подход, описанный в главе 8.

Именованные условные переменные   557
Листинг 18.19. Публичный интерфейс класса разделяемого счетчика
(ExtremeC_examples_chapter18_4_shared_int32.h)

struct shared_int32_t;
struct shared_int32_t* shared_int32_new();
void shared_int32_delete(struct shared_int32_t* obj);
void shared_int32_ctor(struct shared_int32_t* obj,
const char* name);
void shared_int32_dtor(struct shared_int32_t* obj);
void shared_int32_setvalue(struct shared_int32_t* obj,
int32_t value);
void shared_int32_setvalue_ifowner(struct shared_int32_t* obj,
int32_t value);
int32_t shared_int32_getvalue(struct shared_int32_t* obj);

В листинге 18.20 представлены реализации функций, объявленных выше.
Листинг 18.20. Определения всех функций в классе разделяемого счетчика
(ExtremeC_examples_chapter18_4_shared_int32.c)

#include "ExtremeC_examples_chapter18_4_shared_mem.h"
typedef struct {
struct shared_mem_t* shm;
int32_t* ptr;
} shared_int32_t;
shared_int32_t* shared_int32_new(const char* name) {
shared_int32_t* obj =
(shared_int32_t*)malloc(sizeof(shared_int32_t));
obj->shm = shared_mem_new();
return obj;
}
void shared_int32_delete(shared_int32_t* obj) {
shared_mem_delete(obj->shm);
free(obj);
}
void shared_int32_ctor(shared_int32_t* obj, const char* name) {
shared_mem_ctor(obj->shm, name, sizeof(int32_t));
obj->ptr = (int32_t*)shared_mem_getptr(obj->shm);
}
void shared_int32_dtor(shared_int32_t* obj) {
shared_mem_dtor(obj->shm);
}

558   Глава 18



Синхронизация процессов

void shared_int32_setvalue(shared_int32_t* obj, int32_t value) {
*(obj->ptr) = value;
}
void shared_int32_setvalue_ifowner(shared_int32_t* obj,
int32_t value) {
if (shared_mem_isowner(obj->shm)) {
*(obj->ptr) = value;
}
}
int32_t shared_int32_getvalue(shared_int32_t* obj) {
return *(obj->ptr);
}

Как видите, благодаря наследованию наш код получился намного более компактным. Все операции по управлению соответствующим объектом разделяемой памяти
доступны в поле shm структуры shared_int32_t.

Этап 3: класс разделяемого мьютекса
Листинг 18.21 содержит объявления класса разделяемого мьютекса.
Листинг 18.21. Публичный интерфейс класса разделяемого мьютекса
(ExtremeC_examples_chapter18_4_shared_mutex.h)

#include
// Прямое объявление
struct shared_mutex_t;
struct shared_mutex_t* shared_mutex_new();
void shared_mutex_delete(struct shared_mutex_t* obj);
void shared_mutex_ctor(struct shared_mutex_t* obj,
const char* name);
void shared_mutex_dtor(struct shared_mutex_t* obj);
pthread_mutex_t* shared_mutex_getptr(struct shared_mutex_t* obj);
void shared_mutex_lock(struct shared_mutex_t* obj);
void shared_mutex_unlock(struct shared_mutex_t* obj);
#if !defined(__APPLE__)
void shared_mutex_make_consistent(struct shared_mutex_t* obj);
#endif

Представленный выше класс, как и ожидалось, содержит три операции: shared_
mutex_lock, shared_mutex_unlock и shared_mutex_make_consistent. Но есть один

Именованные условные переменные   559

нюанс: операция shared_mutex_make_consistent доступна только в системах POSIX,
которые не принадлежат к семейству macOS (Apple). Дело в том, что системы Apple
не поддерживают устойчивые мьютексы. Мы еще вернемся к этому в следующих
абзацах. Обратите внимание: здесь используется макрос __APPLE__, позволяющий
определить, компилируется ли код в системе Apple.
Реализация этого класса представлена в листинге 18.22.
Листинг 18.22. Определения всех функций, которые находятся в классе разделяемого
именованного мьютекса (ExtremeC_examples_chapter18_4_shared_mutex.c)

#include"ExtremeC_examples_chapter18_4_shared_mem.h"
typedef struct {
struct shared_mem_t* shm;
pthread_mutex_t* ptr;
} shared_mutex_t;
shared_mutex_t* shared_mutex_new() {
shared_mutex_t* obj =
(shared_mutex_t*)malloc(sizeof(shared_mutex_t));
obj->shm = shared_mem_new();
return obj;
}
void shared_mutex_delete(shared_mutex_t* obj) {
shared_mem_delete(obj->shm);
free(obj);
}
void shared_mutex_ctor(shared_mutex_t* obj, const char* name) {
shared_mem_ctor(obj->shm, name, sizeof(pthread_mutex_t));
obj->ptr = (pthread_mutex_t*)shared_mem_getptr(obj->shm);
if (shared_mem_isowner(obj->shm)) {
pthread_mutexattr_t mutex_attr;
int ret = -1;
if ((ret = pthread_mutexattr_init(&mutex_attr))) {
fprintf(stderr,
"ERROR(%s): Initializing mutex attrs failed: %s\n",
name, strerror(ret));
exit(1);
}
#if !defined(__APPLE__)
if ((ret = pthread_mutexattr_setrobust(&mutex_attr,
PTHREAD_MUTEX_ROBUST))) {
fprintf(stderr,
"ERROR(%s): Setting the mutex as robust failed: %s\n",
name, strerror(ret));
exit(1);
}
#endif

560   Глава 18



Синхронизация процессов

if ((ret = pthread_mutexattr_setpshared(&mutex_attr,
PTHREAD_PROCESS_SHARED))) {
fprintf(stderr,
"ERROR(%s): Failed to set ass process-shared: %s\n",
name, strerror(ret));
exit(1);
}
if ((ret = pthread_mutex_init(obj->ptr, &mutex_attr))) {
fprintf(stderr,
"ERROR(%s): Initializing the mutex failed: %s\n",
name, strerror(ret));
exit(1);
}
if ((ret = pthread_mutexattr_destroy(&mutex_attr))) {
fprintf(stderr,
"ERROR(%s): Destruction of mutex attrs failed: %s\n",
name, strerror(ret));
exit(1);
}
}
}
void shared_mutex_dtor(shared_mutex_t* obj) {
if (shared_mem_isowner(obj->shm)) {
int ret = -1;
if ((ret = pthread_mutex_destroy(obj->ptr))) {
fprintf(stderr,
"WARN: Destruction of the mutex failed: %s\n",
strerror(ret));
}
}
shared_mem_dtor(obj->shm);
}
pthread_mutex_t* shared_mutex_getptr(shared_mutex_t* obj) {
return obj->ptr;
}
#if !defined(__APPLE__)
void shared_mutex_make_consistent(shared_mutex_t* obj) {
int ret = -1;
if ((ret = pthread_mutex_consistent(obj->ptr))) {
fprintf(stderr,
"ERROR: Making the mutex consistent failed: %s\n",
strerror(ret));
exit(1);
}
}
#endif

Именованные условные переменные   561
void shared_mutex_lock(shared_mutex_t* obj) {
int ret = -1;
if ((ret = pthread_mutex_lock(obj->ptr))) {
#if !defined(__APPLE__)
if (ret == EOWNERDEAD) {
fprintf(stderr,
"WARN: The owner of the mutex is dead ...\n");
shared_mutex_make_consistent(obj);
fprintf(stdout, "INFO: I'm the new owner!\n");
shared_mem_setowner(obj->shm, TRUE);
return;
}
#endif
fprintf(stderr, "ERROR: Locking the mutex failed: %s\n",
strerror(ret));
exit(1);
}
}
void shared_mutex_unlock(shared_mutex_t* obj) {
int ret = -1;
if ((ret = pthread_mutex_unlock(obj->ptr))) {
fprintf(stderr, "ERROR: Unlocking the mutex failed: %s\n",
strerror(ret));
exit(1);
}
}

В данном коде выполняются инициализация и освобождение POSIX-мьютекса,
а также предоставляется доступ к таким тривиальным операциям, как блокировка и разблокировка. Все остальное, касающееся объекта разделяемой памяти,
находится в родительском классе. Это одно из преимуществ использования наследования.
Обратите внимание: в функции-конструкторе shared_mutex_ctor мы делаем мьютекс разделяемым (PTHREAD_PROCESS_SHARED), чтобы он был доступен для всех
процессов. В многопроцессном ПО это совершенно необходимо. Кроме того, в системах, не принадлежащих к семейству Apple, мы идем еще дальше и помечаем
мьютекс как устойчивый (PTHREAD_MUTEX_ROBUST).
Когда процесс завершается неожиданно, обычный мьютекс, заблокированный
им, переходит в несогласованное состояние. Однако устойчивый мьютекс в этой
ситуации можно опять сделать согласованным. Следующий процесс, который
обычно ждет разблокировка мьютекса, может заблокировать его только после его
согласования. В функции shared_mutex_lock показано, как это делается. Отмечу,
что в системах Apple данной возможности нет.

562   Глава 18



Синхронизация процессов

Этап 4: класс разделяемой условной переменной
В листинге 18.23 показано объявление класса разделяемой условной переменной.
Листинг 18.23. Публичный интерфейс класса разделяемой условной переменной
(ExtremeC_examples_chapter18_4_shared_cond.h)

struct shared_cond_t;
struct shared_mutex_t;
struct shared_cond_t* shared_cond_new();
void shared_cond_delete(struct shared_cond_t* obj);
void shared_cond_ctor(struct shared_cond_t* obj,
const char* name);
void shared_cond_dtor(struct shared_cond_t* obj);
void shared_cond_wait(struct shared_cond_t* obj,
struct shared_mutex_t* mutex);
void shared_cond_timedwait(struct shared_cond_t* obj,
struct shared_mutex_t* mutex,
long int time_nanosec);
void shared_cond_broadcast(struct shared_cond_t* obj);

Здесь предоставляется доступ к трем операциям: shared_cond_wait, shared_cond_
timedwait и shared_cond_broadcast. Как вы помните из главы 16, операция shared_
cond_wait ждет сигнала, относящегося к условной переменной.
Выше мы добавили новую версию операции ожидания, shared_cond_timedwait.
Она ждет получения сигнала на протяжении заданного времени, и если по его
прошествии сигнал так и не получен, то завершается. А вот shared_cond_wait заканчивает работу только при получении сигнала. В примере 18.4 будет использоваться версия с временем ожидания. Стоит отметить, что обе операции принимают
указатель на сопутствующий разделяемый мьютекс, как это происходило в многопоточных средах.
Листинг 18.24 содержит реализацию класса разделяемой условной переменной.
Листинг 18.24. Определения всех функций в классе разделяемой условной переменной
(ExtremeC_examples_chapter18_4_shared_cond.c)

#include "ExtremeC_examples_chapter18_4_shared_mem.h"
#include "ExtremeC_examples_chapter18_4_shared_mutex.h"
typedef struct {
struct shared_mem_t* shm;
pthread_cond_t* ptr;
} shared_cond_t;

Именованные условные переменные   563
shared_cond_t* shared_cond_new() {
shared_cond_t* obj =
(shared_cond_t*)malloc(sizeof(shared_cond_t));
obj->shm = shared_mem_new();
return obj;
}
void shared_cond_delete(shared_cond_t* obj) {
shared_mem_delete(obj->shm);
free(obj);
}
void shared_cond_ctor(shared_cond_t* obj, const char* name) {
shared_mem_ctor(obj->shm, name, sizeof(pthread_cond_t));
obj->ptr = (pthread_cond_t*)shared_mem_getptr(obj->shm);
if (shared_mem_isowner(obj->shm)) {
pthread_condattr_t cond_attr;
int ret = -1;
if ((ret = pthread_condattr_init(&cond_attr))) {
fprintf(stderr,
"ERROR(%s): Initializing cv attrs failed: %s\n",
name, strerror(ret));
exit(1);
}
if ((ret = pthread_condattr_setpshared(&cond_attr,
PTHREAD_PROCESS_SHARED))) {
fprintf(stderr,
"ERROR(%s): Setting as process shared failed: %s\n",
name, strerror(ret));
exit(1);
}
if ((ret = pthread_cond_init(obj->ptr, &cond_attr))) {
fprintf(stderr,
"ERROR(%s): Initializing the cv failed: %s\n",
name, strerror(ret));
exit(1);
}
if ((ret = pthread_condattr_destroy(&cond_attr))) {
fprintf(stderr,
"ERROR(%s): Destruction of cond attrs failed: %s\n",
name, strerror(ret));
exit(1);
}
}
}
void shared_cond_dtor(shared_cond_t* obj) {
if (shared_mem_isowner(obj->shm)) {
int ret = -1;
if ((ret = pthread_cond_destroy(obj->ptr))) {

564   Глава 18



Синхронизация процессов

fprintf(stderr, "WARN: Destruction of the cv failed: %s\n",
strerror(ret));
}
}
shared_mem_dtor(obj->shm);
}
void shared_cond_wait(shared_cond_t* obj,
struct shared_mutex_t* mutex) {
int ret = -1;
if ((ret = pthread_cond_wait(obj->ptr,
shared_mutex_getptr(mutex)))) {
fprintf(stderr, "ERROR: Waiting on the cv failed: %s\n",
strerror(ret));
exit(1);
}
}
void shared_cond_timedwait(shared_cond_t* obj,
struct shared_mutex_t* mutex,
long int time_nanosec) {
int ret = -1;
struct timespec ts;
ts.tv_sec = ts.tv_nsec = 0;
if ((ret = clock_gettime(CLOCK_REALTIME, &ts))) {
fprintf(stderr,
"ERROR: Failed at reading current time: %s\n",
strerror(errno));
exit(1);
}
ts.tv_sec += (int)(time_nanosec / (1000L * 1000 * 1000));
ts.tv_nsec += time_nanosec % (1000L * 1000 * 1000);
if ((ret = pthread_cond_timedwait(obj->ptr,
shared_mutex_getptr(mutex), &ts))) {
#if !defined(__APPLE__)
if (ret == EOWNERDEAD) {
fprintf(stderr,
"WARN: The owner of the cv's mutex is dead ...\n");
shared_mutex_make_consistent(mutex);
fprintf(stdout, "INFO: I'm the new owner!\n");
shared_mem_setowner(obj->shm, TRUE);
return;
} else if (ret == ETIMEDOUT) {
#else
if (ret == ETIMEDOUT) {
#endif
return;
}
fprintf(stderr, "ERROR: Waiting on the cv failed: %s\n",

Именованные условные переменные   565
strerror(ret));
exit(1);
}
}
void shared_cond_broadcast(shared_cond_t* obj) {
int ret = -1;
if ((ret = pthread_cond_broadcast(obj->ptr))) {
fprintf(stderr, "ERROR: Broadcasting on the cv failed: %s\n",
strerror(ret));
exit(1);
}
}

В нашем классе разделяемой условной переменной доступна только операция
shared_cond_broadcast . Мы бы также могли предоставить доступ к операции
отправки сигнала. Как вы, наверное, помните из главы 16, отправка сигнала, каса­
ющегося условной переменной, пробуждает только один из ожидающих процессов;
притом мы не можем указать или предсказать, какой именно. Для сравнения, операция shared_cond_broadcast пробуждает все ожидающие процессы. В примере 18.4
мы будем использовать лишь ее, поэтому предоставляем доступ исключительно
к данной функции.
Поскольку у каждой условной переменной есть сопутствующий мьютекс, ее класс
должен уметь использовать экземпляр класса разделяемого мьютекса. Вот почему
мы выполнили предварительное объявление shared_mutex_t.

Этап 5: основная логика
Листинг 18.25 содержит основную логику, реализованную в нашем примере.
Листинг 18.25. Главная функция в примере 18.4 (ExtremeC_examples_chapter18_4_main.c)

#include "ExtremeC_examples_chapter18_4_shared_int32.h"
#include "ExtremeC_examples_chapter18_4_shared_mutex.h"
#include "ExtremeC_examples_chapter18_4_shared_cond.h"
int int_received = 0;
struct shared_cond_t* cond = NULL;
struct shared_mutex_t* mutex = NULL;
void sigint_handler(int signo) {
fprintf(stdout, "\nHandling INT signal: %d ...\n", signo);
int_received = 1;
}
int main(int argc, char** argv) {

566   Глава 18



Синхронизация процессов

signal(SIGINT, sigint_handler);
if (argc < 2) {
fprintf(stderr,
"ERROR: You have to provide the process number.\n");
exit(1);
}
int my_number = atol(argv[1]);
printf("My number is %d!\n", my_number);
struct shared_int32_t* counter = shared_int32_new();
shared_int32_ctor(counter, "/counter0");
shared_int32_setvalue_ifowner(counter, 1);
mutex = shared_mutex_new();
shared_mutex_ctor(mutex, "/mutex0");
cond = shared_cond_new();
shared_cond_ctor(cond, "/cond0");
shared_mutex_lock(mutex);
while (shared_int32_getvalue(counter) < my_number) {
if (int_received) {
break;
}
printf("Waiting for the signal, just for 5 seconds ...\n");
shared_cond_timedwait(cond, mutex, 5L * 1000 * 1000 * 1000);
if (int_received) {
break;
}
printf("Checking condition ...\n");
}
if (int_received) {
printf("Exiting ...\n");
shared_mutex_unlock(mutex);
goto destroy;
}
shared_int32_setvalue(counter, my_number + 1);
printf("My turn! %d ...\n", my_number);
shared_mutex_unlock(mutex);
sleep(1);
// ПРИМЕЧАНИЕ: вещание может начаться после открытия мьютекса
shared_cond_broadcast(cond);
destroy:
shared_cond_dtor(cond);
shared_cond_delete(cond);
shared_mutex_dtor(mutex);
shared_mutex_delete(mutex);

Именованные условные переменные   567
shared_int32_dtor(counter);
shared_int32_delete(counter);
return 0;
}

Как видите, программа принимает аргумент, обозначающий ее номер. Узнав его,
процесс начинает инициализацию разделяемого счетчика, разделяемого мьютекса
и разделяемой условной переменной. Затем заходит на критический участок, защищенный разделяемым мьютексом.
Внутри цикла процесс ждет, когда счетчик станет равным его номеру. Поскольку
ожидание длится 5 секунд, время теоретически может истечь, в результате чего
функция shared_cond_timedwait завершится. Это фактически означает, что условная переменная не была уведомлена на протяжении этих 5 секунд. Затем процесс
проверяет условие и использует еще один пятисекундный период ожидания. Так
продолжается до тех пор, пока не придет его очередь.
Когда это случится, процесс выведет свой номер, инкрементирует разделяемый
счетчик и уведомит о данном изменении остальные ожидающие процессы, разослав
им сигналы об объекте условной переменной. И только после этого он приступит
к завершению работы.
Тем временем, если пользователь нажмет Ctrl+C, обработчик сигнала, определенный
в рамках основной логики, установит локальный флаг int_received и, как только
процесс, находящийся внутри главного цикла, покинет функцию shared_mutex_
timedwait, заметит сигнал прерывания, и цикл будет завершен.
В терминале 18.6 показано, как скомпилировать пример 18.4. Мы сделаем это в Linux.
Терминал 18.6. Компиляция исходников примера 18.4 и создание итогового исполняемого файла
$
$
$
$
$
$

gcc -c ExtremeC_examples_chapter18_4_shared_mem.c -o shared_mem.o
gcc -c ExtremeC_examples_chapter18_4_shared_int32.c -o shared_int32.o
gcc -c ExtremeC_examples_chapter18_4_shared_mutex.c -o shared_mutex.o
gcc -c ExtremeC_examples_chapter18_4_shared_cond.c -o shared_cond.o
gcc -c ExtremeC_examples_chapter18_4_main.c -o main.o
gcc shared_mem.o shared_int32.o shared_mutex.o shared_cond.o \
main.o -lpthread -lrt -o ex18_4.out

$

Итак, получив итоговый исполняемый файл, ex18_4.out, мы можем запустить три
процесса и посмотреть, как один за другим они выполняют отсчет, независимо от
того, каким образом им были назначены номера, и от порядка их запуска. Запустим
первый процесс и назначим ему номер 3, передав соответствующий аргумент исполняемому файлу (терминал 18.7).

568   Глава 18



Синхронизация процессов

Терминал 18.7. Запуск первого процесса, который принимает число 3
$ ./ex18_4.out 3
My number is 3!
WARN: The shared memory /counter0 does not exist.
The shared memory /counter0 is created and opened.
WARN: The shared memory /mutex0 does not exist.
The shared memory /mutex0 is created and opened.
WARN: The shared memory /cond0 does not exist.
The shared memory /cond0 is created and opened.
Waiting for the signal, just for 5 seconds ...
Checking condition ...
Waiting for the signal, just for 5 seconds ...
Checking condition ...
Waiting for the signal, just for 5 seconds ...

Как видите, первый процесс создает все необходимые разделяемые объекты и становится их владельцем. Теперь запустим в отдельном терминале второй процесс.
Он принимает число 2 (терминал 18.8).
Терминал 18.8. Запуск второго процесса, который принимает число 2
$ ./ex18_4.out 2
My number is 2!
The shared memory /counter0 is opened.
The shared memory /mutex0 is opened.
The shared memory /cond0 is opened.
Waiting for the signal, just for 5 seconds ...
Checking condition ...
Waiting for the signal, just for 5 seconds ...

Наконец, последний процесс принимает число 1. Поскольку ему был назначен наименьший номер, он немедленно его выводит, инкрементирует разделяемый счетчик
и уведомляет об этом остальные процессы (листинг 18.9).
Терминал 18.9. Запуск третьего процесса, который принимает 1. Этот процесс немедленно
завершается, поскольку ему назначен наименьший номер
$ ./ex18_4.out 1
My number is 1!
The shared memory
The shared memory
The shared memory
My turn! 1 ...
The shared memory
The shared memory
The shared memory
The shared memory
The shared memory
The shared memory
$

/counter0 is opened.
/mutex0 is opened.
/cond0 is opened.
/cond0 is unmapped.
/cond0 is closed.
/mutex0 is unmapped.
/mutex0 is closed.
/counter0 is unmapped.
/counter0 is closed.

Именованные условные переменные   569

Теперь, вернувшись ко второму процессу, можно заметить, что он тоже вывел свой
номер, инкрементировал разделяемый счетчик и уведомил об этом третий процесс
(терминал 18.10).
Терминал 18.10. Второй процесс выводит свой номер и завершается
...
Waiting for the signal, just for 5 seconds ...
Checking condition ...
My turn! 2 ...
The shared memory /cond0 is unmapped.
The shared memory /cond0 is closed.
The shared memory /mutex0 is unmapped.
The shared memory /mutex0 is closed.
The shared memory /counter0 is unmapped.
The shared memory /counter0 is closed.
$

Вернемся к первому процессу. Получив уведомление от второго процесса, он выводит свой номер и завершается.
Терминал 18.11. Первый процесс выводит свой номер и завершается. Он также удаляет все
объекты разделяемой памяти
...
Waiting for the signal, just for 5 seconds ...
Checking condition ...
My turn! 3 ...
The shared memory /cond0 is unmapped.
The shared memory /cond0 is closed.
The shared memory /cond0 is deleted.
The shared memory /mutex0 is unmapped.
The shared memory /mutex0 is closed.
The shared memory /mutex0 is deleted.
The shared memory /counter0 is unmapped.
The shared memory /counter0 is closed.
The shared memory /counter0 is deleted.
$

Поскольку первый процесс владеет всеми объектами разделяемой памяти, при
выходе он должен их удалить. Освобождение выделенных ресурсов в многопроцессной среде может вызвать определенные сложности, так как всего одной простой ошибки достаточно для того, чтобы вывести из строя все процессы. Когда
разделяемый ресурс должен быть удален из системы, требуется дополнительная
синхронизация.
Представьте, что в этом примере сначала запустился процесс под номером 2, а за
ним процесс под номером 3. Следовательно, первый процесс должен вывести свой
номер перед вторым. Но перед выходом первый процесс удаляет все разделяемые

570   Глава 18



Синхронизация процессов

объекты, поскольку является их владельцем; в результате, когда второй процесс
пытается к ним обратиться, происходит сбой.
Это лишь простая иллюстрация того, какие проблемы могут возникать при освобождении памяти в многопроцессных системах. Чтобы уменьшить вероятность
подобных сбоев, нужно обеспечить дополнительную синхронизацию процессов.
В предыдущих разделах мы обсудили механизмы, с помощью которых можно
синхронизировать разные процессы, выполняемые на одном компьютере. Далее
проведем краткий обзор распределенных механизмов управления конкурентностью
и их характеристик.

Распределенное управление конкурентностью
До сих пор в данной главе предполагалось, что все процессы существуют в рамках
одной операционной системы и, следовательно, одного компьютера. Иными словами, речь всегда шла о локальном ПО.
Однако настоящие программные продукты обычно этим не ограничиваются. Помимо локальных систем существуют еще и распределенные. В них процессы разбросаны по разным устройствам и взаимодействуют по сети.
Некоторые аспекты систем с распределенными процессами создают трудности,
отсутствующие (или не проявляющиеся в такой степени) в централизованных или
локальных системах. Кратко их обсудим.
zz В распределенной программной системе вы, скорее всего, имеете дело с паралле-

лизмом, а не с конкурентностью. Поскольку каждый процесс выполняется на
отдельном компьютере со своим собственным процессором, мы будем наблюдать не конкурентность, а параллелизм. Конкурентность обычно ограничена
рамками одного компьютера. Обратите внимание: чередования по-прежнему
существуют, равно как и недетерминированность, которую мы встречали в конкурентных системах.
zz Не все процессы внутри распределенной программной системы написаны на од-

ном и том же языке. В распределенных системах довольно часто встречаются
разные языки программирования. Такое же разнообразие нередко присутствует и в локальных системах. Здесь мы исходим из того, что все наши процессы
написаны на C, но в действительности они могут быть разработаны на любом
другом языке. Разные языки предоставляют различные способы организации
конкурентности и управляющие механизмы. Поэтому в некоторых языках,
к примеру, вам будет непросто задействовать именованные мьютексы. Использование разнообразных технологий и языков в программных системах, будь
они локальными или распределенными, вынуждает нас применять достаточно

Распределенное управление конкурентностью   571

абстрактные механизмы управления конкурентностью, доступные во всех
средах. Это может ограничить выбор методов синхронизации теми, которые
совместимы с определенной технологией или языком программирования.
zz В распределенной системе в качестве канала между процессами, находящимися

на разных компьютерах, всегда используется сеть. Это совершенно противоречит нашему предположению о локальной системе, где все процессы работают
в рамках одного компьютера и общаются между собой с помощью имеющейся
инфраструктуры для обмена сообщениями.
zz Наличие сети между процессами вызывает латентность. В локальных системах

тоже есть небольшая латентность, однако она предсказуема и с ней можно совладать. Она намного меньше той латентности, которую вы можете встретить в сети.
Латентность попросту означает, что по многим причинам, связанным с сетевой
инфраструктурой, процесс может не получить сообщение сразу. В таких системах
нужно быть готовыми к тому, что ничего не происходит мгновенно.
zz Наличие сети между процессами также приводит к проблемам с безопасностью.

Когда все процессы находятся в одной системе, они взаимодействуют в общих
границах, используя механизмы с чрезвычайно низкой латентностью, поэтому
проблемы с безопасностью у них совершенно другие. Для взлома процессов
необходимо сперва получить доступ к самой системе. В распределенной же
среде весь обмен сообщениями происходит по сети, в которую может кто-то
вклиниться и прочитать или, что еще хуже, изменить передаваемые данные.
То же самое касается сообщений, предназначенных для синхронизации процессов внутри распределенной системы.
zz Помимо проблем с латентностью и безопасностью, у вас могут возникнуть

трудности с доставкой, которые в локальных системах случаются намного реже.
Чтобы обработать сообщения, их нужно сначала доставить. Когда один процесс
передает сообщение другому в рамках одной системы, отправитель должен както убедиться в том, что оно было получено. Для этого можно реализовать механизмы гарантии доставки, но они приводят к накладным расходам, а иногда их
и вовсе нельзя использовать. В таких случаях при обмене сообщениями можно
наблюдать особого рода проблемы, которые обычно моделируются с помощью
задачи двух генералов.
Перечисленные выше отличия и потенциальные проблемы оказались достаточным
поводом для изобретения новых способов синхронизации процессов и различных
компонентов в огромных распределенных системах. В целом распределенную
систему можно сделать транзакционной и синхронизированной двумя путями.
zz Через централизованную синхронизацию процессов. Этот подход подразумевает

наличие центрального процесса (или узла), который управляет остальными
компонентами. Все другие процессы в системе должны постоянно с ним взаимодействовать и запрашивать его разрешения для захода на свои критические
участки.

572   Глава 18



Синхронизация процессов

zz Через распределенную (пиринговую) синхронизацию процессов. Построение

инфраструктуры синхронизации процессов, не имеющей центрального узла, —
непростая задача. На самом деле в этой области ведутся активные исследования.
Кроме того, существуют некоторые узкоспециализированные алгоритмы.
В данном разделе я пытался пролить свет на сложности, присущие управлению
конкурентностью в распределенных многопроцессных системах. Дальнейшее обсуждение этой темы выходит за рамки данной книги.

Резюме
В этой главе мы завершили наше обсуждение многопроцессных сред. На ее страницах мы:
zz рассмотрели, что такое именованный семафор, как его можно создать и исполь-

зовать в разных процессах;
zz познакомились с именованным мьютексом и тем, как его применять с помощью

области разделяемой памяти;
zz рассмотрели пример организованного завершения работы, в котором несколько

процессов ждали сигнала о завершении; этот сигнал принимался и обрабатывался одним процессом и затем распространялся между остальными. Мы реализовали этот пример, задействовав разделяемые мьютексы;
zz изучили, что такое условная переменная, как ее можно сделать разделяемой

и именованной, используя область разделяемой памяти;
zz рассмотрели еще один пример с процессами, которые выполняют отсчет. При его

реализации применялось наследование, чтобы уменьшить объем повторяющегося кода для работы с объектами мьютекса и условной переменной, привязанными
к области разделяемой памяти;
zz привели краткий обзор особенностей и сложностей, присущих распределенным

системам;
zz вдобавок кратко обсудили методы, с помощью которых в распределенном про-

граммном обеспечении можно организовать управление конкурентностью.
В следующей главе мы приступим к обсуждению методов межпроцессного взаимодействия (inter-process communication, IPC). Дискуссия займет две главы и будет
включать множество тем, таких как компьютерные сети, транспортные протоколы,
программирование сокетов и др.

19

Локальные сокеты
и IPC

В предыдущей главе мы обсудили методики, с помощью которых два процесса
могут конкурентно и синхронно работать с одним и тем же разделяемым ресурсом.
Здесь я представлю новую категорию методов, позволяющих двум процессам передавать данные. Новые методы и уже знакомые нам по предыдущей главе относятся
к межпроцессному взаимодействию (Inter-Process Communication, IPC).
В данной и следующей главах мы поговорим о методах IPC, которые предусматривают некий обмен сообщениями или сигналами между разными процессами. Передающиеся сообщения не хранятся ни в каком общем месте наподобие файла или
разделяемой памяти; вместо этого процессы их генерируют и принимают.
В этой главе мы рассмотрим две основные темы. Во-первых, обсудим методы IPC,
локальное межпроцессное взаимодействие и API POSIX. Во-вторых, начнем знакомство с программированием сокетов и сопутствующими темами. В ходе нашего
разговора мы затронем компьютерные сети, модель «слушатель — соединитель»
и способы установления соединения между двумя процессами.
В рамках данной главы будут рассмотрены:
zz различные методы IPC. Мы познакомимся с пассивными и активными мето-

дами IPC и заодно отметим, какие из методов, рассмотренных в предыдущей
главе, относятся к активным;
zz коммуникационные протоколы и присущие им характеристики. Вы узнаете,

что такое сериализация и десериализация и какую роль эти механизмы играют
в полноценном межпроцессном взаимодействии;
zz файловые дескрипторы и их ключевая роль в создании каналов IPC;
zz API для работы с POSIX-сигналами, POSIX-каналами и очередями сообще-

ний. Будут продемонстрированы общие примеры использования каждой из
этих методик;
zz компьютерные сети и то, как два процесса могут взаимодействовать по суще-

ствующей сети;

574   Глава 19



Локальные сокеты и IPC

zz модель «слушатель — соединитель» и то, как два процесса могут установить

транспортное соединение по разным сетям. Это послужит основой для будущих
обсуждений, касающихся программирования сокетов;
zz программирование сокетов и что собой представляет объект сокета;
zz последовательность действий, которые выполняет каждый процесс, устанав-

ливающий соединение вида «слушатель — соединитель», и API библиотеки
POSIX-сокетов, который используется при этом.
В первом разделе мы еще раз пройдемся по методам IPC.

Методы межпроцессного взаимодействия
К методам IPC обычно относят любые средства взаимодействия и передачи данных
между процессами. В предыдущей главе в качестве начального подхода к разделению данных между процессами были предложены файловая система и разделяемая
память. На тот момент мы не использовали термин IPC, но здесь он вполне уместен! В этой главе мы прибавим еще несколько методов IPC к уже знакомым нам,
но следует помнить: они имеют ряд отличий. Однако прежде, чем рассматривать
эти отличия и пытаться поделить их на категории, перечислим их:
zz разделяемая память;
zz файловая система (как на диске, так и в памяти);
zz POSIX-сигналы;
zz POSIX-каналы;
zz очереди сообщений POSIX;
zz сокеты домена Unix;
zz интернет-сокеты (или сетевые сокеты).

С точки зрения программирования, методики разделяемой памяти и файловой
системы определенно чем-то схожи, поэтому их можно отнести к одной категории,
известной как активные методы IPC. Остальные подходы выделяются на их фоне,
и мы будем называть их пассивными. Эта и следующая главы посвящены пассивному межпроцессному взаимодействию и описывают различные методики.
Обратите внимание: все методы IPC сводятся к передаче сообщений между двумя
процессами. Поскольку термин «сообщение» активно используется в следующих
абзацах, его сначала стоит определить.
Каждое сообщение содержит последовательность байтов, которые собираются вместе в соответствии с четко определенным интерфейсом, протоколом или стандар-

Методы межпроцессного взаимодействия   575

том. Структура сообщения должна быть известна обоим процессам, работающим
с ним, и обычно описывается в рамках коммуникационного протокола.
Ниже перечислены различия между активными и пассивными методиками.
zz Активные методики подразумевают наличие разделяемого ресурса, или носи-

теля, который доступен в пользовательском пространстве и является внешним
по отношению к обоим процессам. Роль разделяемого ресурса может играть
файл, разделяемая память или даже сетевой сервис, такой как NFS-сервер.
Эти носители служат основным средством хранения сообщений, создаваемых
и потребляемых процессами. В пассивных методах вместо таких разделяемых
ресурсов, или носителей, используется канал, через который процессы отправляют и принимают сообщения. Сами сообщения при этом нигде не хранятся.
zz При активном подходе все процессы должны сами извлекать из носителя до-

ступные сообщения. При пассивном — входящие сообщения передаются (доставляются) принимающей стороне.
zz Активные методики подразумевают наличие разделяемого ресурса или носи-

теля, конкурентный доступ к которому необходимо синхронизировать. Вот почему в предыдущей главе мы исследовали различные способы синхронизации
для этой категории IPC. Обратите внимание: в случае с пассивными методиками
синхронизация не требуется.
zz При использовании активных методик процессы могут работать независимо

друг от друга. Это объясняется тем, что сообщения могут храниться в разделяемом ресурсе, и их извлечение можно отложить. Иными словами, процессам доступно выполнение в асинхронной манере. Для сравнения, в случае применения
пассивных методов IPC оба процесса должны находиться в активном состоянии.
Кроме того, ввиду мгновенного поступления сообщений принимающий процесс
может потерять какую-то их часть, если не запущен в это время. То есть процессы выполняются в синхронной манере.
При использовании пассивных методик каждый процесс имеет временный буфер, который хранит поступающие сообщения. Он находится
в ядре и существует, пока процесс не завершится. Доступ к нему может
быть конкурентным, но в этом случае синхронизация должна гарантироваться самим ядром.

Сообщения, которые либо передаются по IPC-каналу (пассивный подход), либо
хранятся на IPC-накопителе (активный подход), должны иметь содержимое, понятное принимающему процессу. Это значит, что оба процесса: отправитель и получатель — должны знать, как их создавать и разбирать. Сообщения состоят из байтов;
отсюда следует, что обе стороны должны уметь превращать объект (текст или видео)
в байтовую последовательность и восстанавливать тот же объект из полученных

576   Глава 19



Локальные сокеты и IPC

байтов. Чуть позже вы увидите, что совместимость процессов обеспечивается общим коммуникационным протоколом, который они используют.
В следующем разделе мы подробно обсудим коммуникационные протоколы.

Коммуникационные протоколы
Одного лишь коммуникационного канала или носителя недостаточно. Две стороны,
желающие взаимодействовать по общему каналу, должны понимать друг друга!
Представьте, что два человека хотят пообщаться на одном языке — например,
на английском или японском. В этом случае язык общения можно считать коммуникационным протоколом, который используют две стороны взаимодействия.
То же самое в контексте IPC можно сказать и о процессах; им нужен общий язык,
на котором они могут общаться. Формально он называется протоколом. В рамках
этого раздела мы обсудим коммуникационные протоколы и их различные характеристики, такие как длина и содержимое сообщения. Но сначала коммуникационный
протокол необходимо описать в более глубоком смысле. Отмечу, что в данной
главе основное внимание уделяется методам межпроцессного взаимодействия,
поэтому речь будет идти только о протоколах, связывающих два процесса. Любые виды взаимодействия сторон, не являющиеся процессами, выходят за рамки
текущей главы.
Процессы могут передавать только байты. То есть речь фактически о том, что каждый фрагмент информации, передаваемый с помощью любого метода IPC, должен
быть предварительно переведен в байтовую последовательность. Это называется
сериализацией или маршалингом. Текстовый абзац, отрезок аудиозаписи, музыкальная композиция или любой другой объект перед попаданием в канал IPC должен
быть сериализован или сохранен на носителе IPC. В контексте межпроцессного
взаимодействия это означает, что сообщения, передаваемые между двумя процессами, представляют собой байты, упорядоченные строго определенным образом.
С другой стороны, когда процесс получает последовательность байтов по каналу
IPC, он должен уметь воссоздать из них исходный объект. Это называется десериа­
лизацией или демаршалингом.
Поговорим о том, как происходит сериализация и десериализация. Когда процесс хочет отправить объект по какому-либо имеющемуся каналу IPC, он сначала
сериализует его в байтовый массив и передает результат другой стороне. Принимающий процесс десериализует поступившие байты и воссоздает отправленный
объект. Как видите, эти операции являются обратными по отношению друг к другу
и используются обеими сторонами для обмена информацией по каналу IPC, ориентированному на передачу байтов. Без этого невозможно обойтись. Любая тех-

Коммуникационные протоколы   577

нология межпроцессного взаимодействия (RPC, RMI и т. д.) активно задействует
сериализацию и десериализацию различных объектов. Пока под сериализацией мы
будем понимать совокупность этих двух операций.
Обратите внимание: сериализация применяется не только в пассивных методах
IPC, которые рассматривались до сих пор. Активные методы IPC, такие как файловая система или разделяемая память, тоже нуждаются в сериализации. Дело в том,
что носители в этих методах могут хранить последовательности байтов, и если
процесс хочет записать некий объект, скажем, в разделяемый файл, то должен сначала сериализовать его. Таким образом, сериализация — неотъемлемая часть всех
методов IPC; независимо от выбранного вами метода, при работе с каналом или
носителем постоянно приходится иметь дело с сериализацией и десериализацией.
Выбор коммуникационного протокола сам по себе определяет способ сериализации, так как в рамках протокола детально описывается порядок, в котором размещаются байты. Это очень важно, поскольку сериализованный объект должен
быть десериализован обратно принимающей стороной. Следовательно, механизмы
сериализации и десериализации должны подчиняться правилам, которые диктует
протокол. Если эти механизмы несовместимы между собой, то стороны фактически
теряют всякую возможность взаимодействовать, просто ввиду того, что получатель
не может воссоздать переданный объект.
Иногда в качестве синонима десериализации используется термин «разбор» (parsing), однако на самом деле это совершенно разные понятия.

Чтобы перевести наш разговор в более практическую плоскость, рассмотрим реальные примеры. Веб-сервер и веб-клиент взаимодействуют по протоколу передачи
гипертекста (Hyper Text Transfer Protocol, HTTP). Следовательно, обе стороны,
желающие общаться друг с другом, должны применять совместимые средства сериализации и десериализации HTTP-сообщений. В качестве еще одного примера
можно привести протокол системы доменных имен (Domain Name Service, DNS).
DNS-клиент и DNS-сервер должны использовать для взаимодействия совместимые
средства сериализации и десериализации. Отмечу, что в отличие от протокола
HTTP, предназначенного для передачи текста, DNS является двоичным протоколом. Мы поговорим об этом в следующих разделах.
Сериализация может проводиться в разных компонентах программного проекта,
потому ее обычно реализуют в виде библиотек, которые можно подключать там,
где они нужны. Для таких распространенных протоколов, как HTTP, DNS и FTP
существуют широко известные и легко доступные сторонние библиотеки. Но если
у вас есть собственные протоколы, то библиотеки сериализации для них придется
писать самостоятельно.

578   Глава 19



Локальные сокеты и IPC

Такие распространенные протоколы, как HTTP, FTP и DNS, — это стандарты, описанные в публичных документах, которые называются рабочими предложениями (request for comments, RFC). Например, протокол
HTTP/1.1 описан в RFC-2616. В Google можно легко найти страницу
соответствующего документа.

Вдобавок следует отметить, что библиотеки сериализации могут быть доступны
в разных языках программирования. Обратите внимание: сам алгоритм сериализации не зависит ни от какого языка, поскольку всего лишь описывает порядок
размещения байтов и способ их интерпретации. Поэтому алгоритмы сериализации и десериализации можно разрабатывать на любых языках. Это чрезвычайно
важный аспект. В крупных программных проектах некоторые компоненты могут
быть написаны на разных языках, и бывают случаи, когда этим компонентам нужно
обмениваться информацией. В результате нам необходим один и тот же алгоритм
сериализации, реализованный с помощью разных технологий. Например, существуют HTTP-сериализаторы для C, C++, Java, Python и т. д.
Если подытожить, то главная идея данного раздела в том, что двум сторонам, которые хотят общаться друг с другом, необходим четко определенный протокол. Протокол IPC — это стандарт, который диктует, как в целом должно осуще­ствляться
взаимодействие и какие правила относительно порядка следования байтов в сообщениях должны соблюдаться. Для передачи объектов по байтовым каналам IPC
требуются алгоритмы сериализации.
В следующем подразделе будут перечислены характеристики протоколов межпроцессного взаимодействия.

Характеристики протоколов
Протоколы IPC имеют различные характеристики. Если коротко, то любой протокол должен определять разные виды содержимого для сообщений, передающихся по IPC-каналу. В одних протоколах сообщения могут иметь фиксированную
длину, а в других — переменную. Некоторые протоколы вынуждают использовать
предоставляемые ими операции в синхронной манере; есть и те, что поддерживают асинхронную работу. Далее мы рассмотрим эти отличительные особенности.
Существующие протоколы можно разделить на разные категории в зависимости
от характеристик.

Тип содержимого
Сообщения, отправляемые по IPC-каналам, могут иметь текстовое, двоичное или
гибридное содержимое. Двоичные данные состоят из байтов со значениями в диапазоне от 0 до 255. Текстовые представляют собой символы, используемые в тексте.

Коммуникационные протоколы   579

Иными словами, в текстовом содержимом допускаются только алфавитно-цифровые и некоторые дополнительные символы.
Текстовые данные можно считать частным случаем двоичных, но мы стараемся их
разделять и работать с ними по-разному. Например, текстовые сообщения имеет
смысл сжимать перед отправкой, а вот у двоичных сообщений плохой коэффициент
сжатия (реальный размер, поделенный на размер сжатых данных). Следует понимать: одни протоколы — сугубо текстовые (например, JSON), а другие — полностью
двоичные (такие как DNS). Но протоколы наподобие BSON и HTTP позволяют
хранить в сообщениях сочетание из текстовых и двоичных данных; то есть итоговое
сообщение может быть смесью обычных байтов и текста.
Отмечу, что двоичное содержимое может передаваться в текстовом виде. Существуют разные кодировки, которые позволяют представлять двоичные данные
с помощью текстовых символов. Один из самых известных алгоритмов кодирования
двоичных данных в текст, который выполняет такое преобразование, — Base64.
Подобные алгоритмы широко используются в сугубо текстовых протоколах, таких
как JSON, для отправки двоичной информации.

Длина сообщений
Сообщения, сгенерированные в соответствии с протоколом IPC, могут иметь либо
фиксированную, либо переменную длину. В первом случае длина всех сообщений
совпадает, а во втором может быть разной. Прием сообщений фиксированной или
переменной длины оказывает непосредственное влияние на то, как их содержимое будет десериализовано принимающей стороной. Использование протоколов,
которые всегда генерируют сообщения одинаковой длины, может упростить их
разбор, поскольку получатель заранее знает, сколько байтов ему нужно прочитать
из канала; к тому же сообщения одинаковой длины обычно (но не всегда) имеют
идентичную структуру. Чтение из IPC-канала сообщений фиксированной длины,
которые устроены внутри по одному и тому же принципу, дает нам отличную возможность с помощью структур языка C обратиться к их содержимому, используя
заранее подготовленные поля. Это похоже на то, как мы работали с объектами
разделяемой памяти в предыдущей главе.
Если протокол может генерировать сообщения разной длины, то определить, где
заканчивается отдельно взятое сообщение, может быть непросто. Принимающая
сторона должна неким образом (об этом чуть позже) знать, прочитано ли сообщение до конца, или в канале еще что-то осталось. Стоит отметить: для получения
всего сообщения иногда необходимо прочитать несколько блоков и один из них
может содержать данные, принадлежащие двум смежным сообщениям. Пример
этого будет показан в главе 20.
Большинство протоколов используют переменную длину, и работа с сообщениями
фиксированной длины обычно является роскошью, поэтому имеет смысл обсудить

580   Глава 19



Локальные сокеты и IPC

методы, с помощью которых разные протоколы позволяют различать или разделять
сообщения с разной длиной. Иными словами, эти протоколы задействуют механизмы, позволяющие им пометить конец сообщения, благодаря чему получатель может
свериться с этими метками и понять, прочитано ли сообщение целиком. Некоторые
из таких методов перечислены ниже.
zz Использование разделителя. Разделитель — это последовательность байтов

(в двоичных протоколах) или символов (в текстовых протоколах), которые обозначают конец сообщения. Разделитель следует выбирать с учетом содержимого
сообщений, чтобы его было легко отличить от самих данных.
zz Обрамление фиксированной длины. В таких протоколах у каждого сообщения

есть префикс фиксированной длины (обычно 4 байта или даже больше), в котором указано, сколько байтов необходимо прочитать, чтобы получить все сообщение целиком. Примеры использования этой методики — протоколы TLV
(Tag-Value-Length) и ASN (Abstract Syntax Notation).
zz Использование конечного автомата. Такие протоколы имеют формальную

грамматику, которую можно смоделировать с помощью конечного автомата.
Чтобы прочитать полное сообщение из IPC-канала, принимающая сторона
должна знать грамматику протокола и применить подходящий десериализатор,
основанный на конечном автомате.

Последовательность
В большинстве протоколов общение между процессами проходит по принципу
«запрос — ответ». Одна сторона шлет запрос, а другая на него отвечает. Такая
схема обычно используется в клиент-серверных системах. Процесс-слушатель
(которым зачастую выступает серверный процесс) ожидает сообщения и, когда оно
приходит, отвечает соответствующим образом.
Если протокол синхронный или последовательный, то отправитель (клиент) подождет, пока слушатель (сервер) не завершит обработку запроса и не вернет ответ.
То есть, пока слушатель не ответит, отправитель находится в заблокированном состоянии. В асинхронном протоколе процесс-отправитель не блокируется и может
заниматься другими делами, в то время как запрос обрабатывается слушателем.
Это значит, что во время подготовки ответа отправитель не будет заблокирован.
В асинхронном протоколе должен быть предусмотрен активный или пассивный механизм, позволяющий отправителю проверять наличие ответа. Активный подход
означает, что отправитель будет регулярно запрашивать результат у слушателя.
В случае с пассивным подходом слушатель сам передаст ответ отправителю по
тому же или другому каналу взаимодействия.
Последовательность протокола не ограничена сценариями вида «запрос — ответ».
Приложения для обмена сообщениями обычно используют эту методику для обеспечения максимальной отзывчивости как на серверной, так и на клиентской стороне.

Взаимодействие в рамках одного компьютера    581

Взаимодействие
в рамках одного компьютера
В этом разделе мы поговорим о локальном межпроцессном взаимодействии. IPC
между разными компьютерами будет темой следующей главы. Ниже перечислены
четыре формальных метода, с помощью которых могут общаться процессы, находящиеся в одной и той же системе:
zz POSIX-сигналы;
zz POSIX-каналы;
zz очереди сообщений POSIX;
zz сокеты домена Unix.

POSIX-сигналы, в отличие от методик, рассмотренных ранее, не создают канал
связи между процессами, но могут служить для уведомления процессов о событиях.
В определенных сценариях процессы могут использовать такие сигналы, чтобы
сообщать другдругу о тех или иных событиях, происходящих в системе.
Прежде чем переходить к первому методу IPC — POSIX-сигналам, — обсудим
файловые дескрипторы. Их так или иначе применяет любой механизм межпроцессного взаимодействия (если не считать POSIX-сигналов). Поэтому их подробному
рассмотрению посвящен весь следующий подраздел.

Файловые дескрипторы
Два взаимодействующих процесса могут выполняться как на одном, так и на двух
разных компьютерах, соединенных по сети. В данном подразделе и в большей части
этой главы основное внимание уделяется первому варианту, в котором процессы
находятся в одной системе. И здесь очень важную роль играют файловые дескрипторы. Отмечу, что они применяются и в распределенном IPC, но там называются
сокетами. Мы подробно расскажем о них в следующей главе.
Файловый дескриптор — абстрактная ссылка на локальный объект, который можно
использовать для чтения и записи данных. Несмотря на свое название, файловые
дескрипторы могут обозначать широкий спектр различных механизмов, предназначенных для чтения и изменения байтовых потоков.
Естественно, в число объектов, на которые могут ссылаться файловые дескрипторы, входят обычные файлы, размещенные в файловой системе (либо на жестком
диске, либо в памяти).
С помощью файловых дескрипторов можно обращаться и к устройствам. Как вы
уже видели в главе 10, у каждого устройства есть свой файл, который обычно находится в каталоге /dev.

582   Глава 19



Локальные сокеты и IPC

Что касается пассивных методов IPC, то файловый дескриптор может представлять
IPC-канал с возможностью чтения и записи. Вот почему первый этап подготовки
IPC-канала заключается в определении ряда файловых дескрипторов.
Теперь, когда вы лучше ориентируетесь в файловых дескрипторах и понимаете,
что они собой представляют, мы переходим к первому методу межпроцессного
взаимодействия, который можно использовать в системах, состоящих из одного
компьютера, — POSIX-сигналах. Однако стоит отметить: файловые дескрипторы
в этом подходе не применяются; мы еще вернемся к ним в разделах, посвященных
POSIX-каналам и очередям сообщений POSIX. Но сначала поговорим о POSIXсигналах.

POSIX-сигналы
В POSIX-системах процессы и потоки могут отправлять и принимать ряд заранее
определенных сигналов, каждый из которых может быть отправлен процессом,
потоком или даже самим ядром. На самом деле сигналы предназначены для уведомления процессов или потоков о событиях или ошибках. Например, когда система
должна перезагрузиться, она рассылает всем процессам сигнал SIGTERM, уведомляя
их о том, что им следует немедленно завершить работу. Процесс, получивший
сигнал, должен действовать соответствующим образом. В некоторых случаях ему
ничего не нужно делать, но иногда он должен сохранить текущее состояние.
В табл. 19.1 перечислены сигналы, доступные в системе Linux. Она взята со справочной страницы по сигналам, с которой можно ознакомиться, перейдя по ссылке
http://www.man7.org/linux/man-pages/man7/signal.7.html.
Таблица 19.1. Список всех сигналов, доступных в системе Linux
Сигнал

Стандарт

Действие

Комментарий

SIGABRT

P1990

Core

Сигнал о прекращении, посланный abort(3)

SIGALRM

P1990

Term

Сигнал таймера от alarm(2)

SIGBUS

P2001

Core

Ошибка шины (некорректный доступ к памяти)

SIGCHLD

P1990

Ign

Дочерний процесс остановлен или прерван

SIGCLD



Ign

Синоним SIGCHLD

SIGCONT

P1990

Cont

Продолжить в случае остановки

SIGEMT



Term

Ловушка эмулятора

SIGFPE

P1990

Core

Неправильная операция с плавающей запятой

SIGHUP

P1990

Term

Обнаружено закрытие управляющего терминала

SIGILL

P1990

Core

Некорректная инструкция от процессора

Взаимодействие в рамках одного компьютера    583
Сигнал

Стандарт

Действие

Комментарий

SIGINFO





Синоним SIGPWR

SIGINT

P1990

Term

Прерывание с клавиатуры

SIGIO



Term

Теперь возможен ввод/вывод (4.2 BSD)

SIGIOT



Core

Ловушка IOT. Синоним SIGABRT

SIGKILL

P1990

Term

Сигнал принудительного завершения

SIGLOST



Term

Не действует блокировка файла

SIGPIPE

P1990

Term

Запись в канале, не имеющем считывающих
процессов

SIGPOLL

P2001

Term

Событие, которое можно отложить (Sys V).
Синоним SIGIO

SIGPROF

P2001

Term

Закончилось время профилирующего таймера

SIGPWR



Term

Отказ системы питания (System V)

SIGQUIT

P1990

Core

Прекратить работу с клавиатурой

SIGSEGV

P1990

Core

Некорректное обращение к памяти

SIGSTKFLT



Term

Ошибка в стеке сопроцессора (не используется)

SIGSTOP

P1990

Stop

Процесс остановлен

SIGTSTP

P1990

Stop

Остановка с помощью клавиатуры

SIGSYS

P2001

Core

Некорректный системный вызов (SVr4);
см. также seccomp(2)

SIGTERM

P1990

Term

Сигнал снятия

SIGTRAP

P2001

Core

Ловушка отладки

SIGTTIN

P1990

Stop

Запрос на ввод с терминала для фонового процесса

SIGTTOU

P1990

Stop

Запрос на вывод с терминала для фонового
процесса

SIGUNUSED



Core

Синоним SIGSYS

SIGURG

P2001

Ign

Приоритетные данные в сокете (4.2 BSD)

SIGUSR1

P1990

Term

Определяемый пользователем сигнал № 1

SIGUSR2

P1990

Term

Определяемый пользователем сигнал № 2

SIGVTALRM

P2001

Term

Виртуальный таймер (4.2 BSD)

SIGXCPU

P2001

Core

Превышено время работы процессора (4.2 BSD);
см setrlimit(2)

SIGXFSZ

P2001

Core

Превышен размер файла (4.2 BSD); см. setrlimit(2)

SIGWINCH



Ign

Сигнал изменения размера окна (4.3 BSD, Sun)

584   Глава 19



Локальные сокеты и IPC

В этой таблице видно, что у Linux есть собственные сигналы, которые не входят
в стандарт POSIX. Большинство сигналов относятся к общеизвестным событиям,
но два из них может определить пользователь. Обычно это нужно в ситуациях,
когда во время выполнения программы должны быть совершены определенные
действия. В примере 19.1 показано, как применять и обрабатывать сигналы в программе на языке C (листинг 19.1).
Листинг 19.1. Обработка POSIX-сигналов (ExtremeC_examples_chapter19_1.c)

#include
#include
#include
void handle_user_signals(int signal) {
switch (signal) {
case SIGUSR1:
printf("SIGUSR1 received!\n");
break;
case SIGUSR2:
printf("SIGUSR2 received!\n");
break;
default:
printf("Unsupported signal is received!\n");
}
}
void handle_sigint(int signal) {
printf("Interrupt signal is received!\n");
}
void handle_sigkill(int signal) {
printf("Kill signal is received! Bye.\n");
exit(0);
}
int main(int argc, char** argv) {
signal(SIGUSR1, handle_user_signals);
signal(SIGUSR2, handle_user_signals);
signal(SIGINT, handle_sigint);
signal(SIGKILL, handle_sigkill);
while (1);
return 0;
}

В этом примере мы использовали функцию signal, чтобы назначить различные
обработчики для определенных сигналов. Один обработчик предназначен для
сигналов, определенных пользователем, второй — для сигнала SIGINT, третий — для
сигнала SIGKILL.

Взаимодействие в рамках одного компьютера    585

Программа представляет собой обычный бесконечный цикл, и наша единственная
цель — обработка некоторых сигналов. В терминале 19.1 показаны команды для
компиляции этого примера и его запуска в фоновом режиме.
Терминал 19.1. Компиляция и запуск примера 19.1
$ gcc ExtremeC_examples_chapter19_1.c -o ex19_1.out
$ ./ex19_1.out &
[1] 4598
$

Узнав PID программы, мы можем послать ей несколько сигналов. В нашем случае
программа имеет PID 4598 (у вас он будет отличаться) и выполняется в фоне.
Чтобы послать процессу сигнал, можно воспользоваться командой kill. Проверим
наш пример с помощью следующих команд (терминал 19.2).
Терминал 19.2. Передача разных сигналов фоновому процессу
$ kill -SIGUSR2 4598
SIGUSR2 received!
$ kill -SIGUSR1 4598
SIGUSR2 received!
$ kill -SIGINT 4598
Interrupt signal is received!
$ kill -SIGKILL 4598
$
[1]+ Stopped
./ex19_1.out
$

Как видите, программа обрабатывает все сигналы, кроме SIGKILL. Это объясняется
тем, что процессы не могут обрабатывать SIGKILL; уведомление о принудительном
завершении процесса обычно поступает его родителю.
Обратите внимание: SIGINT (сигнал прерывания) можно послать программе, находящейся на переднем плане, нажав Ctrl+C. Следовательно, нажимая сочетание этих
клавиш, вы отправляете активной программе сигнал прерывания. По умолчанию
сигнал SIGINT просто останавливает выполнение программы, но, как показано
в предыдущем примере, мы можем написать собственный обработчик, в котором
SIGINT будет игнорироваться.
Сигналы можно отправлять не только из командной оболочки, но и из самих процессов, при условии, что нам известен PID получателя. Мы можем использовать
функцию kill (объявленную в signal.h), которая делает то же самое, что и ее
консольный аналог. Она принимает два аргумента: PID адресата и номер сигнала.
Процессы и потоки также могут применять функции kill и raise для отправки сигналов самим себе. Обратите внимание: функция raise посылает сигнал текущему

586   Глава 19



Локальные сокеты и IPC

потоку. Это может быть довольно полезно в ситуациях, когда вам нужно уведомить
о событии другую часть своей программы.
В завершение следует сказать: сигналы поступают асинхронно, и потому факт занятости главного потока бесконечным циклом не играет никакой роли. Это можно
наблюдать в терминале 19.2. Таким образом, вы можете быть уверены в том, что
ваш процесс всегда сможет получить входящие сигналы.
Теперь пришло время поговорить еще об одном локальном методе межпроцессного
взаимодействия, который может пригодиться в определенных обстоятельствах, —
о POSIX-каналах.

POSIX-каналы
Unix поддерживает однонаправленные POSIX-каналы (pipes), которые можно
использовать для обмена сообщениями между двумя процессами. При создании
POSIX-канала вы получаете два файловых дескриптора: один для записи в канал,
а второй для чтения из него. Простой пример использования POSIX-каналов показан в листинге 19.2.
Листинг 19.2. Пример 19.2, демонстрирующий использование POSIX-каналов
(ExtremeC_examples_chapter19_2.c)

#include
#include
#include
#include
#include







int main(int argc, char** argv) {
int fds[2];
pipe(fds);
int childpid = fork();
if (childpid == -1) {
fprintf(stderr, "fork error!\n");
exit(1);
}
if (childpid == 0) {
// Потомок закрывает файловый дескриптор для чтения
close(fds[0]);
char str[] = "Hello Daddy!";
// Потомок записывает в файловый дескриптор, открытый для записи
fprintf(stdout, "CHILD: Waiting for 2 seconds ...\n");
sleep(2);
fprintf(stdout, "CHILD: Writing to daddy ...\n");
write(fds[1], str, strlen(str) + 1);
} else {

Взаимодействие в рамках одного компьютера    587
// Родитель закрывает файловый дескриптор для записи
close(fds[1]);
char buff[32];
// Родитель читает из файлового дескриптора, открытого для чтения
fprintf(stdout, "PARENT: Reading from child ...\n");
int num_of_read_bytes = read(fds[0], buff, 32);
fprintf(stdout, "PARENT: Received from child: %s\n", buff);
}
return 0;
}

Во второй строчке функции main используется вызов pipe. Как уже говорилось
ранее, он принимает массив из двух файловых дескрипторов (один для чтения,
а другой для записи) и открывает каждый из них. Первый дескриптор имеет индекс 0, а второй — 1. Первый должен использоваться для чтения из канала, а второй — для записи в канал.
Чтобы получить второй процесс, мы воспользовались вызовом fork . Как уже
объяснялось в главе 17, этот вызов создает дочерний процесс, который является
копией родительского. Следовательно, после выполнения функции fork дочернему
процессу будут доступны открытые файловые дескрипторы.
После вызова fork родительский процесс входит в блок else, а дочерний — в блок
if . Первым делом каждый процесс закрывает файловый дескриптор, который
не будет использоваться в дальнейшем. В этом примере родитель собирается
читать из канала, а потомок хочет туда записывать. Вот почему родительский
процесс закрывает второй файловый дескриптор (предназначенный для записи),
а дочерний — первый (предназначенный для чтения). Обратите внимание: каналы
являются однонаправленными и не поддерживают обратную связь.
В терминале 19.3 показан вывод примера 19.2.
Терминал 19.3. Результат выполнения примера 19.2
$ gcc ExtremeC_examples_chapter19_2.c -o ex19_2.out
$ ./ex19_2.out
PARENT: Reading from child ...
CHILD: Waiting for 2 seconds ...
CHILD: Writing to daddy ...
PARENT: Received from child: Hello Daddy!
$

Как можно видеть в листинге 19.2, для операций чтения и записи используются
функции read и write. Ранее мы уже упоминали о том, что в пассивном межпроцессном взаимодействии файловый дескриптор указывает на байтовый канал, что
позволяет использовать функции для работы с файловыми дескрипторами. Функции read и write принимают файловый дескриптор и производят с ним действия
независимо от того, какой IPC-канал он представляет.

588   Глава 19



Локальные сокеты и IPC

В данном примере новый процесс порождался с помощью функции fork. Но представьте ситуацию с двумя разными процессами, созданными независимо друг от
друга. Возникает вопрос: каким образом они могут взаимодействовать через разделяемый канал? Процесс, который хочет иметь доступ к объекту канала, должен
иметь соответствующий файловый дескриптор. Этого можно добиться двумя
путями:
zz один из процессов должен подготовить канал и передать соответствующие

файловые дескрипторы другому процессу;
zz процесс должен использовать именованный канал.

В первом случае процессы должны применять для обмена файловыми дескрипторами канал на основе сокета домена Unix. Но проблема в том, что если между процессами уже установлен такой канал, то они могут использовать его для дальнейшего
взаимодействия. В результате отпадает необходимость в POSIX-канале, у которого
к тому же менее дружественный API, по сравнению с сокетами домена Unix.
Второй вариант выглядит более разумным. Один из процессов может использовать
функцию mkfifo и создать файл очереди по определенному пути. Затем второй процесс может взять путь к уже созданному файлу и открыть его для последующего
общения. Стоит отметить: канал по-прежнему однонаправленный и в некоторых
ситуациях один из процессов должен открывать файл только для чтения, а другой — только для записи.
Предыдущий пример имеет еще одну особенность, на которую стоит обратить
внимание. Прежде чем записывать в канал, дочерний процесс ждет 2 секунды.
А родительский процесс тем временем заблокирован на функции read. Поэтому,
когда в канал ничего не записывается, читающий процесс блокируется.
В заключение напомню, что POSIX-каналы относятся к пассивным методам IPC.
Как уже объяснялось ранее, пассивные методы подразумевают наличие в ядре
буфера для хранения входящих сообщений, и POSIX-каналы не исключение.
Записанные сообщения хранятся в ядре, пока их не прочитают. Если же процессвладелец завершает работу, то объект канала и его буфер в ядре уничтожаются.
В следующем подразделе мы обсудим очереди сообщений POSIX.

Очереди сообщений POSIX
Очереди сообщений, размещенные в ядре, — часть стандарта POSIX. Они во
многом отличаются от POSIX-каналов. Ниже перечислены некоторые фундаментальные отличия.
zz В канале хранятся байты, а в очереди — сообщения. Каналу ничего не известно

о структуре записываемых данных, тогда как очередь хранит отдельные со-

Взаимодействие в рамках одного компьютера    589

общения, которые добавляются при каждом вызове функции write. Очередь
соблюдает границы между записанными сообщениями. Чтобы это проиллюстрировать, представьте: у нас есть три сообщения размером 10, 20 и 30 байт
и каждое из них записывается как в POSIX-канал, так и в очередь сообщений
POSIX. Канал знает лишь то, что внутри у него 60 байт, и позволяет программе
считывать 15-байтные фрагменты. А вот очередь сообщений знает только то,
что у нее есть три сообщения, и, поскольку ни одно из них не занимает 15 байт,
программа не может прочитать 15-байтный фрагмент.
zz Каналы и очереди сообщений имеют ограниченный размер, который исчисляет-

ся в байтах и сообщениях соответственно. Кроме того, каждое сообщение имеет
максимальный размер, измеряемый в байтах.
zz Каждая очередь сообщений, такая как именованная разделяемая память или

именованный семафор, открывает файл. Это не обычные файлы, но другие
процессы могут использовать их для доступа к одному и тому же экземпляру
очереди сообщений.
zz Сообщения в очереди могут иметь разный приоритет, тогда как в канале все

байты равны.
Эти два механизма имеют и некоторые сходства:
zz оба однонаправленные; для двунаправленного взаимодействия необходимо

создать два экземпляра очереди или канала;
zz оба имеют ограниченную емкость; вы можете записать только определенное

количество байтов или сообщений;
zz в большинстве POSIX-систем оба механизма представлены файловыми де-

скрипторами, поэтому для работы с ними можно использовать функции ввода/
вывода, такие как read и write;
zz обе методики работают без соединения. Иными словами, если два разных про-

цесса запишут два разных сообщения, то один из них может прочитать сообщение другого. У сообщений нет владельца, и их могут читать любые процессы.
Это чревато проблемами, особенно если с одним и тем же каналом или очередью
сообщений работают несколько конкурентных процессов.
Очереди сообщений POSIX, о которых идет речь в этой главе, не следует путать с брокерами сообщений, применяемыми в архитектуре MQM
(Message Queue Middleware — связующее ПО для работы с очередями
сообщений).

В Интернете есть разные ресурсы, в которых объясняется, как работают очереди
сообщений POSIX. По ссылке, представленной ниже, эта тема подается в контексте
операционной системы QNX, но большая часть материала применима и к другим
POSIX-системам: https://users.pja.edu.pl/~jms/qnx/help/watcom/clibref/mq_overview.html.

590   Глава 19



Локальные сокеты и IPC

Пришло время посмотреть, как это работает на практике. В примере 19.3 реализован тот же сценарий, что и в предыдущем разделе, но вместо POSIX-канала
используется очередь сообщений POSIX. Все функции, относящиеся к очередям
сообщений, объявлены в заголовочном файле mqueue.h. Некоторые из них мы
вскоре обсудим.
Обратите внимание: код, представленный в листинге 19.3, не скомпилируется
в macOS, поскольку эта система не поддерживает очереди сообщений POSIX.
Листинг 19.3. Пример 19.3 с использованием очереди сообщений POSIX
(ExtremeC_examples_chapter19_3.c)

#include
#include
#include
#include
#include







int main(int argc, char** argv) {
// Обработчик очереди сообщений
mqd_t mq;
struct mq_attr attr;
attr.mq_flags = 0;
attr.mq_maxmsg = 10;
attr.mq_msgsize = 32;
attr.mq_curmsgs = 0;
int childpid = fork();
if (childpid == -1) {
fprintf(stderr, "fork error!\n");
exit(1);
}
if (childpid == 0) {
// Потомок ждет, пока родитель создает очередь
sleep(1);
mqd_t mq = mq_open("/mq0", O_WRONLY);
char str[] = "Hello Daddy!";
// Потомок записывает в файловый дескриптор, открытый для записи
fprintf(stdout, "CHILD: Waiting for 2 seconds ...\n");
sleep(2);
fprintf(stdout, "CHILD: Writing to daddy ...\n");
mq_send(mq, str, strlen(str) + 1, 0);
mq_close(mq);
} else {
mqd_t mq = mq_open("/mq0", O_RDONLY | O_CREAT, 0644, &attr);
char buff[32];
fprintf(stdout, "PARENT: Reading from child ...\n");
int num_of_read_bytes = mq_receive(mq, buff, 32, NULL);
fprintf(stdout, "PARENT: Received from child: %s\n", buff);
mq_close(mq);

Взаимодействие в рамках одного компьютера    591
mq_unlink("/mq0");
}
return 0;
}

Чтобы скомпилировать этот код, выполните команды, приведенные в терминале 19.4. Отмечу, что в Linux код следует скомпоновать с библиотекой rt.
Терминал 19.4. Сборка примера 19.3 в Linux
$ gcc ExtremeC_examples_chapter19_3.c -lrt -o ex19_3.out
$

Вывод примера 19.3 показан в терминале 19.5. Как видите, он ничем не отличается
от того, который мы имели в примере 19.2, только теперь для выполнения той же
логики используются очереди сообщений POSIX.
Терминал 19.5. Выполнение примера 19.3. в Linux
$ ./ex19_3.out
PARENT: Reading from child ...
CHILD: Waiting for 2 seconds ...
CHILD: Writing to daddy ...
PARENT: Received from child: Hello Daddy!
$

Напомню, что POSIX-каналы и очереди сообщений имеют ограниченный размер буфера в ядре. Следовательно, запись в канал или очередь, из которой никто
не читает, может привести к блокировке любых операций записи. Иными словами,
любой вызов функции write будет оставаться заблокированным, пока потребитель
не прочитает сообщение из очереди или байты из канала.
В следующем подразделе я кратко опишу сокеты домена Unix. Этот механизм
является самым очевидным выбором в ситуации, когда необходимо соединить два
локальных процесса в одной системе.

Сокеты домена Unix
Еще один подход, с помощью которого разные процессы могут взаимодействовать
на одном компьютере, состоит в использовании сокетов домена Unix. Это особая
разновидность сокетов, работающих только в рамках одной системы. Этим они
отличаются от сетевых сокетов, позволяющих двум процессам, находящимся на
разных компьютерах, общаться друг с другом по сети. Сокеты домена Unix имеют
различные характеристики, которые делают их важными и нетривиальными в использовании по сравнению с POSIX-каналами и очередями сообщений POSIX.
Самая главная характеристика — тот факт, что они двунаправленные. Благодаря

592   Глава 19



Локальные сокеты и IPC

этому одного сокета достаточно для чтения и записи в связанный с ним канал.
То есть каналы, основанные на сокетах домена Unix, являются полнодуплексными.
Кроме того, такие сокеты могут работать как с сеансами, так и с отдельными сообщениями. Это делает их еще более гибкими. Мы вернемся к поддержке сеансов
и сообщений в следующих разделах.
Поскольку сокеты домена Unix нельзя обсуждать, не имея понимания основных
принципов программирования сокетов, данная тема в этой главе больше рассматриваться не будет. В следующих разделах вы познакомитесь с областью программирования сокетов и сопутствующими концепциями. А полноценное обсуждение
сокетов домена Unix будет продолжено в главе 20.

Введение в программирование сокетов
Прежде чем переходить к примерам реального кода на языке C, которые будут
представлены в следующей главе, мы сначала поговорим о программировании
сокетов. Дело вот в чем: чтобы понимать код, вам необходимо знать некоторые
фундаментальные концепции.
Программированием сокетов можно заниматься и локально, и на разных компьютерах. Как вы уже могли догадаться, в первом случае используются сокеты домена
Unix. А вот взаимодействие разных систем требует создания и применения сетевых
сокетов. Оба вида сокетов имеют примерно одинаковые API и принцип работы,
поэтому вполне логично, что в следующей главе они рассматриваются бок о бок.
Прежде чем начинать задействовать сетевые сокеты, необходимо знать, как работают компьютерные сети. Мы поговорим об этом в следующем разделе. Существует
множество понятий и концепций, с которыми нужно познакомиться, чтобы быть
готовыми к написанию первого примера с использованием сокетов.

Компьютерные сети
Подход к объяснению сетевых технологий, выбранный мной в данном подразделе,
отличается от того, который обычно можно встретить в других текстах. Моя задача — дать вам базовое понимание того, что происходит внутри компьютерной
сети, особенно между двумя процессами. Мы попытаемся взглянуть на это с точки
зрения программиста. И главными «действующими лицами» нашего обсуждения
будут процессы, а не компьютеры. Из-за этого порядок следования материалов
может сначала показаться необычным, но все вместе поможет вам получить представление о том, как происходит межпроцессное взаимодействие по сети.
Обратите внимание: данный подраздел не следует считать полноценным описанием компьютерных сетей. Очевидно, что его просто невозможно уместить в это
количество страниц и всего один подраздел.

Введение в программирование сокетов   593

Физический уровень
Для начала забудем о процессах и сосредоточимся на компьютерах. Прежде чем
продолжать, отмечу, что компьютер в сети может называться по-разному: устройство, хост, узел или даже система. Конечно, чтобы понять настоящее значение того
или иного термина, нужен контекст.
Первый шаг на пути к созданию распределенной системы — наличие нескольких
компьютеров, соединенных по сети, или, если быть более точным, компьютерной
сети. Пока ограничимся двумя компьютерами, которые нужно соединить, — двумя
физическими устройствами. Соединить их между собой, очевидно, следует с помощью некой физической промежуточной среды наподобие кабеля или беспроводного соединения.
Без этой среды (которая не обязательно должна быть видимой, как в случае с беспроводной сетью) соединение было бы невозможным. Такие физические соединения похожи на дороги между городами. Я буду придерживаться данной
аналогии, поскольку она поможет мне очень подробно объяснить происходящее
внутри компьютерной сети.
Любое аппаратное оборудование, необходимое для физического подключения двух
устройств, относится к физическому уровню. Это первый и самый низкий уровень,
который мы исследуем. Без него невозможно было бы передавать данные между
двумя компьютерами и считать их соединенными. Все остальные уровни, находящиеся выше, являются программными и представляют собой набор различных
стандартов, определяющих то, как должны передаваться данные.
Перейдем к следующему, канальному уровню.

Канальный уровень
Организация дорожного движения между городами требует не только дорог. То же
самое относится и к физическому соединению между компьютерами. Чтобы пользоваться дорогами, нам нужны законы и правила, регулирующие транспортные
средства, дорожные знаки, строительные материалы, бровки, скорость, разметку,
направление движения и т. д. Похожие правила требуются и для прямого физического соединения между двумя компьютерами.
Если все аппаратные компоненты и устройства, необходимые для соединения разных компьютеров, принадлежат к физическому уровню, то обязательные нормы
и протоколы, определяющие способ передачи данных, относятся к более высокому,
канальному уровню.
В рамках правил, соблюдение которых обеспечивается канальными протоколами,
сообщения разбиваются на фрагменты, называемые кадрами. Ситуация аналогична
тому, как в нормах, регулирующих дорожное движение, описывается максимальная

594   Глава 19



Локальные сокеты и IPC

длина автомобилей, которым позволено перемещаться по определенной дороге.
Вы не можете вести по трассе автомобиль с прицепом длиной 1 км (если предположить, что это в принципе возможно). Вам нужно разделить его на более короткие сегменты или автомобили меньшей длины. Точно так же длинный фрагмент
данных следует разбить на несколько кадров, которые будут перемещаться по сети
независимо друг от друга.
Стоит упомянуть, что сеть может быть установлена между любыми двумя вычислительными устройствами, не обязательно компьютерами. Подобных устройств
существует очень много. Промышленные сети имеют собственные стандарты касательно кабелей, разъемов, терминаторов и т. д., а также отдельный набор канальных
протоколов и спецификаций.
Существует множество стандартов, описывающих такие канальные соединения, —
например, как настольный компьютер можно подключить к промышленному
устройству. Один из самых известных канальных протоколов, предназначенный
для проводного соединения компьютеров, — Ethernet. Он описывает все правила
и нормы относительно передачи данных по компьютерным сетям. Еще один широко распространенный протокол, определяющий работу беспроводных сетей,
называется IEEE 802.11.
Сеть, состоящая из компьютеров (или любых других вычислительных машин/
устройств одного типа), соединенных физически с помощью определенного
канального протокола, называется локальной вычислительной сетью (local area
network, LAN). Обратите внимание: любое устройство, которое подключается
к LAN, должно обладать физическим компонентом под названием «сетевой адаптер» или «контроллер сетевого интерфейса» (Network Interface Controller, NIC).
Например, у компьютера, который мы подключаем к сети Ethernet, должен иметься Ethernet NIC.
У компьютера может быть несколько сетевых адаптеров, каждый из которых
подключен к отдельной локальной сети. Таким образом, компьютер с тремя NIC
способен работать с тремя локальными сетями одновременно.
Кроме того, возможно, что все три сетевых адаптера используются для подключения к одной сети. Способ конфигурации NIC и то, как вы соединяетесь с различными сетями, следует продумать заранее и разработать подробный план.
Каждый NIC имеет уникальный адрес, который задается управляющим канальным
протоколом. Этот адрес используется для передачи данных между узлами внутри
LAN. Протоколы Ethernet и IEEE 802.11 назначают каждому совместимому сетевому адаптеру MAC-адрес (media access control — управление доступом к сети).
Таким образом, адаптеру Ethernet или IEEE 802.11 Wi-Fi следует иметь уникальный MAC-адрес, иначе он не сможет подключиться к LAN. MAC-адреса не должны
повторяться внутри одной локальной сети. В идеале им надлежит быть совершенно

Введение в программирование сокетов   595

уникальными и неизменяемыми. Однако в реальности это не так; вы даже можете
установить MAC-адрес сетевого адаптера вручную.
Если подытожить все вышесказанное, то мы имеем стек из двух уровней: физического (снизу) и канального (сверху). Этого достаточно, чтобы соединить ряд
компьютеров в одной локальной сети. Но это еще не все. Нам нужен еще один
уровень поверх указанных двух, чтобы иметь возможность соединять компьютеры
из разных локальных сетей с промежуточными LAN или без них.

Сетевой уровень
Мы уже знаем, что для соединения разных узлов в локальных сетях Ethernet
используются MAC-адреса. Но что, если соединить нужно компьютеры из двух
разных LAN, которые, к слову, могут быть несовместимы между собой?
Например, одна из них может быть проводной сетью Ethernet, а другая — FDDIсетью (fiber distributed data interface — волоконно-оптический распределенный интерфейс передачи данных), которая на физическом уровне в основном использует
оптическое волокно. Еще одним примером могут быть промышленные устройства,
подключенные к сети IE (Industrial Ethernet — промышленный Ethernet) и взаимодействующие с компьютерами операторов, которые обычно находятся в локальной
сети Ethernet. Эти и многие другие примеры показывают: поверх упомянутых выше
протоколов нужен еще один уровень для соединения узлов из разных LAN. Стоит
отметить: данный уровень нужен, даже если локальные сети совместимы между
собой. Это особенно важно для передачи данных из одной сети (совместимой или
неоднородной) в другую через ряд промежуточных LAN. Чуть ниже будут даны
более подробные объяснения.
Сетевой уровень работает с пакетами точно так же, как канальный с кадрами.
Длинные сообщения разбиваются на более мелкие части, пакеты. Кадры и пакеты
представляют собой две разные концепции на двух разных уровнях, но для простоты мы будем считать, что это одно и то же, и до конца данной главы будем называть
и то и другое пакетами.
Ключевое различие между этими понятиями, о котором необходимо знать, состоит
в том, что пакеты инкапсулируются (содержатся) в кадрах. Мы не станем углуб­
ляться в данную тему, но в Интернете можно найти много материала, посвященного
разным аспектам обеих концепций.
Сетевой протокол заполняет пробел между разными локальными сетями, соединяя
их между собой. У каждой локальной сети могут быть свои отдельные стандарты
и протоколы физического и канального уровней, но все они имеют общий управляющий сетевой протокол. В противном случае неоднородные (несовместимые)
LAN не могли бы соединяться друг с другом. Самый известный сетевой протокол
на текущий момент — IP (Internet Protocol — интернет-протокол). Он активно

596   Глава 19



Локальные сокеты и IPC

используется в крупных компьютерных сетях, которые обычно состоят из более
мелких LAN на основе Ethernet или Wi-Fi. У IP есть две версии с разной длиной
адресов: IPv4 и IPv6.
Но как соединить два компьютера из двух разных LAN? Ответ кроется в механизме
маршрутизации. Получение данных из внешней локальной сети требует наличия
узла-маршрутизатора. Представьте, что мы хотим соединить две разные сети:
LAN1 и LAN2. Маршрутизатор — обычный узел, который находится в обеих сетях
благодаря наличию двух сетевых адаптеров. Один принадлежит LAN1, а другой —
LAN2. Затем специальный алгоритм маршрутизации определяет, какие пакеты
следует передавать между сетями и как это делать.
Благодаря механизмам маршрутизации через узел-маршрутизатор могут проходить
данные из разных сетей и в разных направлениях. Для этого в каждой локальной
сети должен иметься маршрутизатор. Таким образом, данные, отправленные компьютеру, который находится в другой географической зоне, на пути к адресату могут
пройти через десятки маршрутизаторов. Я не стану углубляться в данную тему, но
в Интернете можно найти огромное количество информации об этом механизме.
Существует утилита под названием traceroute, которая позволяет просматривать маршруты между вашим компьютером и адресатом.

Итак, два компьютера из двух разных LAN могут соединяться друг с другом напрямую или через промежуточные локальные сети. Любые более специализированные соединения должны устанавливаться поверх этого уровня. Следовательно,
в основе любого взаимодействия двух программ, находящихся на разных узлах,
лежит трехуровневый стек из трех протоколов: физического, канального и сетевого. Но что именно мы имеем в виду, когда говорим о соединении между двумя
компьютерами?
Утверждение о том, что два узла соединены между собой, звучит слегка расплывчато, по крайней мере для программистов. Если быть более точным, то соединяются
и обмениваются данными операционные системы, установленные на этих узлах.
Способность подключаться к LAN и взаимодействовать с другими узлами в той же
или другой локальной сети — неотъемлемая часть большинства современных ОС.
Все операционные системы на основе Unix, которым в данной книге уделяется
основное внимание, поддерживают работу с сетью и могут быть установлены на
узлы, участвующие в сетевом взаимодействии.
Сетевые возможности присутствуют в Linux, Microsoft Windows и почти любой
другой ОС. И в самом деле, вряд ли операционная система сможет выжить без доступа к сети. Обратите внимание: управлением сетевыми соединениями занимается
ядро (или, точнее, один из его компонентов), поэтому более правильно говорить,
что сетевые возможности предоставляются ядром.

Введение в программирование сокетов   597

Поскольку ядро обеспечивает работу с сетью, эта возможность доступна любому
процессу в пользовательском пространстве, который благодаря этому может
соединяться с другими процессами, размещенными на разных сетевых узлах.
Вам как программисту не нужно беспокоиться о различных уровнях (физическом, канальном и сетевом), с которыми имеет дело ядро; уровни, имеющие отношение к вашему коду, находятся выше, и вы можете сосредоточиться именно
на них.
У каждого узла в IP-сети есть IP-адрес. Как уже было сказано ранее, существует два вида IP-адресов: IP версии 4 (IPv4) и IP версии 6 (IPv6). В IPv4 адреса
состоят из четырех сегментов, каждый из которых может содержать числовое
значение от 0 до 255. То есть IPv4-адреса находятся в диапазоне от 0.0.0.0 до
255.255.255.255. Как видите, для хранения адресов этого формата достаточно
4 байт (или 32 бит). Если взять IPv6-адреса, то они могут достигать 16 байт
(128 бит). Кроме того, IP-адреса бывают приватными и публичными, но по­
дробности этого выходят далеко за рамки темы, обсуждаемой в данной главе. Нам
достаточно знать о том, что каждый узел в IP-сети имеет уникальный IP-адрес.
Если вернуться к предыдущему разделу, то в отдельно взятой локальной сети
каждый узел имеет как адрес канального уровня, так и IP-адрес, но для соединения
с узлами мы будем использовать только последний. Например, в сети Ethernet
у узла есть MAC-адрес, который протоколы канального уровня задействуют для
передачи данных внутри LAN, и IP-адрес, с помощью которого программы, размещенные на разных узлах, устанавливают сетевые соединения как в рамках одной
локальной сети, так и с рядом других сетей.
Главная обязанность сетевого уровня состоит в соединении двух или больше
локальных сетей. В итоге множество отдельных LAN можно объединить в одну
громадную сеть. На самом деле такая сеть уже существует; мы называем ее Интернетом.
Как и в любой другой сети, у узла, подключенного к Интернету, должен быть
IP-адрес. Но главное отличие между узлом с выходом в Интернет и без него состоит в том, что первый должен иметь публичный IP-адрес, а второй может обойтись
приватным.
Возьмем, к примеру, вашу домашнюю сеть, подключенную к Интернету. Внешний
узел, размещенный в Интернете, не может соединиться с вашим ноутбуком, поскольку у того есть только приватный IP-адрес. То есть ноутбук доступен в вашей
домашней сети, но не в Интернете. Следовательно, если вы хотите открыть доступ
к своему программному обеспечению снаружи, то его следует разместить на компьютере с публичным IP-адресом.
Сетевым IP-технологиям посвящено огромное количество информации, и мы
не станем рассматривать ее в полном объеме, но вы, как программист, должны
знать, чем приватные адреса отличаются от публичных.

598   Глава 19



Локальные сокеты и IPC

Программист не отвечает за управление сетевыми соединениями между узлами,
но должен быть способен обнаруживать неисправности в сети. Это очень важно,
поскольку благодаря данным навыкам вы будете знать, что является виной ошибки
или некорректного поведения — ваш код или инфраструктура (сеть). Вот почему
мы должны затронуть некоторые дополнительные концепции и инструменты.
Простейший инструмент, который позволяет убедиться в том, что два хоста (узла)
в одной или разных локальных сетях могут обмениваться данными или «видеть»
друг друга, — утилита ping. Вы уже, наверное, с ней знакомы. Она отправляет
ICMP-пакеты (Internet Control Message Protocol — протокол межсетевых управляющих сообщений); если они возвращаются обратно, то это значит, другой компьютер работает, подключен к сети и отвечает на запросы.
ICMP — еще один протокол сетевого уровня, который в основном используется для мониторинга и администрирования сетей на основе IP
в ситуациях, когда возникают проблемы с соединением или с качеством
обслуживания.

Представьте, что хотите узнать, видит ли ваш компьютер публичный IP-адрес
8.8.8.8 (если он подключен к Интернету, то должен видеть). Следующие команды
помогут вам проверить ваше соединение (терминал 19.6).
Терминал 19.6. Использование утилиты ping для проверки подключения к Интернету
$ ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=123 time=12.190 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=123 time=25.254 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=123 time=15.478 ms
64 bytes from 8.8.8.8: icmp_seq=3 ttl=123 time=22.287 ms
64 bytes from 8.8.8.8: icmp_seq=4 ttl=123 time=21.029 ms
64 bytes from 8.8.8.8: icmp_seq=5 ttl=123 time=28.806 ms
64 bytes from 8.8.8.8: icmp_seq=6 ttl=123 time=20.324 ms
^C
--- 8.8.8.8 ping statistics --7 packets transmitted, 7 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 12.190/20.767/28.806/5.194 ms
$

Вывод показывает: было отправлено семь ICMP-пакетов, и ни один из них не был
потерян во время передачи. Это значит, операционная система, находящаяся за
IP-адресом 8.8.8.8, работает и отвечает на запросы.
Публичный IP-адрес 8.8.8.8 принадлежит публичному DNS-сервису
Google. Более подробно об этом можно почитать на странице https://
ru.wikipedia.org/wiki/Google_Public_DNS.

Введение в программирование сокетов   599

Мы увидели, как два компьютера могут соединиться по сети. Теперь вплотную подошли к моменту, когда один процесс может подключиться к другому и передать
данные по ряду локальных сетей. Для этого поверх сетевого уровня нужен еще
один. Именно с этого начинается сетевое программирование.

Транспортный уровень
Итак, мы уже знаем, что два компьютера можно соединить с помощью стека из
трех уровней: физического, канального и сетевого. Межпроцессное взаимодействие
требует, чтобы соединение и общение происходило на уровне процессов. Однако на
каждом из соединенных компьютеров подобных процессов может быть много, и мы
должны иметь возможность установить соединение между любыми двумя процессами, размещенными на разных компьютерах. Поэтому соединение, действующее
лишь на сетевом уровне, будет слишком общим для поддержки взаимодействия
нескольких отдельных процессов.
Вот почему поверх сетевого уровня необходим еще один. Транспортный уровень
закрывает эту потребность. Если компьютеры общаются на сетевом уровне, то запущенные на них процессы могут соединяться на транспортном уровне, который
функционирует поверх сетевого. Транспортный уровень, как и любой другой, имеет
собственные уникальные идентификаторы, роль которых в данном случае играют
порты. Ниже мы обсудим это более подробно, но сначала необходимо объяснить
модель «слушатель — соединитель», которая позволяет двум сторонам взаимодействовать по каналу. Далее для описания этой модели будет использоваться
сравнение между компьютерными и телефонными сетями.

Модель «слушатель — соединитель»
в контексте телефонных сетей
Лучший пример, с которого только можно начать, — телефонные сети общего пользования (ТСОП). На первый взгляд они могут показаться не очень похожими на
компьютерные, но у них есть ряд сходств, которые позволят нам должным образом
объяснить принцип работы транспортного протокола.
В нашей аналогии люди, пользующиеся телефоном, играют ту же роль, что и процессы в компьютерной сети. Следовательно, телефонный звонок — эквивалент
транспортного соединения. Абоненты могут звонить, только если предусмотрена
вся необходимая инфраструктура. Это подобно сетевой инфраструктуре, без которой процессы не могли бы взаимодействовать.
Предположим, вся необходимая инфраструктура уже готова и работает без какихлибо проблем. Мы хотим, чтобы два субъекта, находящиеся в данных системах,
создали канал и передали данные. Это аналогично двум абонентам ТСОП или двум
процессам, которые размещены на разных узлах компьютерной сети.

600  Глава 19



Локальные сокеты и IPC

Использование ТСОП требует наличия телефонного аппарата. Точно так же компьютерный узел должен иметь сетевой адаптер. Помимо этого, существуют разные
уровни, состоящие из различных протоколов. Эти уровни, на которые опирается
инфраструктура, делают возможным создание транспортного канала.
Вернемся к ТСОП. Один из телефонных аппаратов ждет звонка. Пусть это будет
слушающая сторона. Отмечу, что телефон, подключенный к ТСОП, всегда ожидает
сигнала вызова и звонит при его получении.
Теперь поговорим о другой стороне, которая инициирует звонок. Это эквивалент
создания транспортного канала. Здесь тоже используется телефонный аппарат.
Слушатель доступен по телефонному номеру, который можно считать его адресом.
Чтобы сделать звонок, сторона соединителя должна знать данный номер. Таким
образом, соединитель набирает телефонный номер слушателя, и инфраструктура
информируетпоследнего о том, что ему поступил входящий звонок.
Когда сторона слушателя берет трубку, она принимает входящее соединение, в результате чего между слушателем и соединителем создается канал. С этого момента
люди, находящиеся по разные стороны соединения, могут общаться друг с другом
по каналу ТСОП. Обратите внимание: если стороны разговаривают на разных
языках, то общение не сможет продолжаться и один из абонентов повесит трубку.
В результате канал будет уничтожен.

Транспортное взаимодействие
с соединениями и без
С помощью аналогии, представленной выше, я попытался объяснить, как происходит взаимодействие в компьютерной сети. Однако на самом деле это лишь один
вид взаимодействия: с установлением соединения. Мы рассмотрим другой подход,
в котором соединения не используются. Но сначала более подробно поговорим
о первом способе.
В рамках взаимодействия, ориентированного на соединения, для соединителя
создается отдельный канал. Следовательно, если один слушатель общается с тремя
соединителями, то для этого требуется три разных канала. Неважно, насколько
велико передаваемое сообщение, оно дойдет до получателя в корректном виде
и без какой-либо потери данных внутри канала. Если одному адресату послать
несколько сообщений, то порядок их отправки будет сохранен и принимающий
процесс не заметит никаких неполадок в работе инфраструктуры.
Как уже объяснялось в предыдущих разделах, любое сообщение при передаче по
компьютерной сети всегда разбивается на мелкие фрагменты, которые называют
пакетами. В случае ориентации взаимодействия на соединения ни одна из сторон
(ни слушатель, ни соединитель) не будет иметь никакого представления о комму-

Введение в программирование сокетов  601

тации отдельных пакетов. Даже если пакеты будут получены в другом порядке,
принимающая операционная система их отсортирует и воссоздаст сообщение в его
исходном виде и процесс-получатель ничего не заметит.
Более того, если один из пакетов потеряется во время передачи, то операционная
система получателя запросит его снова, чтобы восстановить все сообщение целиком. Например, TCP (Transport Control Protocol — протокол управления передачей) — это протокол транспортного уровня, который ведет себя в точности так, как
описано выше. Поэтому TCP-каналы ориентированы на соединения.
Взаимодействие можно проводить и без соединений. Наличие соединения гарантирует два фактора: доставку отдельных пакетов и их упорядоченность. Такие
протоколы, как TCP, обеспечивают одновременное выполнение этих двух условий. А вот транспортные протоколы, которые не устанавливают соединение, их
не гарантируют.
Иными словами, вы не можете гарантировать, что будет доставлен каждый отдельный пакет в сообщении или что все пакеты дойдут в правильном порядке.
И то и другое возможно по отдельности и вместе! Например, UDP (User Datagram
Protocol — протокол пользовательских датаграмм) не гарантирует доставку пакетов и их упорядоченность. Стоит отметить: корректность содержимого отдельно
взятого пакета обеспечивается протоколом на сетевом и канальном уровнях.
Теперь пришло время обсудить термины, которые часто используются в сетевом
программировании. Поток данных1 — последовательность байтов, передающаяся
по каналу, ориентированному на соединения. Это значит, что взаимодействие,
в котором отсутствуют соединения, фактически не поддерживает потоки данных.
Для единицы данных, передающихся по каналу без соединения, предусмотрен
специальный термин — датаграмма. Это фрагмент данных, который можно доставить целиком в условиях отсутствия соединения. Если фрагмент данных превышает максимальный размер датаграммы, то нам не под силу гарантировать его
доставку и итоговая последовательность может оказаться неверной. Концепция
датаграммы существует на транспортном уровне и аналогична концепции пакета,
принадлежащей сетевому уровню.
Например, протокол UDP гарантирует корректную доставку отдельно взятой
датаграммы (пакета), но о соотношении двух смежных датаграмм (пакетов) ничего сказать нельзя. Вы должны принять тот факт, что целостность данных в UDP
существует только на уровне датаграммы. В TCP все не так. Данный протокол гарантирует доставку и упорядоченность отправленных пакетов, поэтому мы можем
рассматривать передачу потока байтов между двумя процессами.
1

Не путать с потоком выполнения. — Примеч. ред.

602  Глава 19



Локальные сокеты и IPC

Последовательность инициализации транспортного протокола
Здесь мы поговорим о шагах, которые предпринимает каждый процесс в целях установления транспортного взаимодействия. Последовательность этих шагов зависит
от наличия или отсутствия соединения, и потому оба варианта будут рассмотрены
в отдельных фрагментах текста. Отмечу, что разница видна только при инициализации канала и после этого обе стороны используют примерно одинаковые API для
чтения и записи в созданный канал.
Процесс-слушатель всегда привязывается к конечной точке (обычно это сочетание
IP-адреса и порта), а процесс-соединитель к ней подключается. Это происходит как
при наличии соединения, так и при его отсутствии.
Обратите внимание: в описанных ниже последовательностях предполагается,
что между компьютерами, на которых размещены процесс-слушатель и процесссоединитель, установлена IP-сеть.

Последовательности инициализации при отсутствии соединения
Чтобы создать канал связи без соединения, процесс-слушатель делает следующее.
1. Привязывается к порту на одном из имеющихся сетевых интерфейсов (или
даже на каждом из них). Это значит, что процесс-слушатель просит свою операционную систему перенаправлять ему входящий трафик через данный порт.
Порт представляет собой обычное число между 0 и 65 535 (2 байта); необходимо
выбрать номер порта, который еще не привязан к другому процессу-слушателю,
иначе получится ошибка. Если мы привязываемся к порту в определенном сетевом
интерфейсе, то операционная система будет перенаправлять процессу все пакеты,
которые проходят по данному интерфейсу и предназначены для этого порта.
2. Процесс ждет и считывает сообщения, которые становятся доступными в созданном канале, и отвечает на них, выполняя запись в тот же канал.
А процесс-соединитель выполняет следующие действия.
1. Он должен знать IP-адрес и номер порта, принадлежащие процессу-слушателю.
Поэтому для соединения с этим процессом он предоставляет своей системе соответствующую информацию. Если нужный процесс не прослушивает указанный
порт или если IP-адрес указывает не на тот компьютер, то соединение не удается
установить.
2. Если соединение установлено успешно, то процесс-соединитель может записывать в канал и читать из него почти таким же способом (то есть используя тот же
API), что и процесс-слушатель.
Помимо выполнения описанных выше шагов, процесс-слушатель и процесс-соединитель должны использовать один и тот же транспортный протокол, иначе их
операционные системы не смогут прочитать и понять передаваемые сообщения.

Введение в программирование сокетов  603

Последовательности инициализации при использовании соединения
При использовании подхода, ориентированного на соединения, процесс-слушатель
инициализируется, выполняя такую последовательность.
1. Он привязывается к порту, как и в предыдущем сценарии, в котором не было
соединений. Порт ничем не отличается от описанного в прошлом разделе и подвержен тем же ограничениям.
2. Далее процесс слушателя задает размер очереди отставания. Она содержит
ожидающие соединения, еще не принятые процессом. При взаимодействии,
ориентированном на соединения, сторона слушателя получает возможность отправлять данные только после приема входящих соединений. Настроив очередь
отставания, процесс-слушатель переходит в режим прослушивания.
3. Теперь процесс-слушатель начинает принимать входящие соединения. Это обязательный этап создания транспортного канала. Только приняв входящее соединение, слушатель может передать данные. Если процесс-соединитель инициирует соединение со слушателем, но тот не может его принять, то данное
соединение будет оставаться в очереди отставания, пока не будет принято или
пока не истечет время ожидания. Это может произойти, если процесс-слушатель слишком занят обработкой других соединений. В таком случае входящие
соединения будут накапливаться в очереди отставания, и когда та заполнится,
соединения начнут немедленно отклоняться операционной системой.
Процесс-соединитель проходит через последовательность шагов, очень похожую
на ту, которую мы видели выше, при рассмотрении взаимодействия без использования соединений. Соединитель подключается к определенной конечной точке,
предоставляя IP-адрес и порт, и, после того как его примет слушатель, может задействовать тот же API для чтения и записи в канал.
Поскольку созданный канал ориентирован на соединения, между слушателем
и соединителем устанавливается отдельное соединение; это позволяет им обмениваться потоком байтов неограниченной длины. Следовательно, оба процесса
могут передавать огромные объемы данных, корректность которых гарантируется
транспортным и сетевым протоколами.
В заключение напомню: процесс-слушатель (независимо от того, ориентирован
канал на соединения или нет) должен привязаться к конечной точке. Если говорить конкретно о UDP и TCP, то эта конечная точка состоит из IP-адреса и номера
порта.

Прикладной уровень
Транспортный канал соединяет два процесса, находящихся на разных его концах,
и позволяет им общаться друг с другом. Под общением мы понимаем передачу последовательности байтов, которую могут понять обе стороны. Как уже объяснялось

604  Глава 19



Локальные сокеты и IPC

в начальных разделах данной главы, для этого требуется коммуникационный протокол. Поскольку он находится на прикладном уровне и используется процессами
(или приложениями, которые выполняются в виде процессов), этот протокол называется прикладным.
На канальном, сетевом и транспортном уровнях существует не так уж много протоколов, и большинство из них хорошо известны. Протоколов прикладного уровня
намного больше. Это опять же аналогично ситуации в телекоммуникационных
сетях. Для телефонных сетей существует всего несколько стандартов, однако люди
общаются между собой на множестве языков, которые могут сильно разниться.
В компьютерных сетях каждому приложению, запущенному в виде процесса, нужен
прикладной протокол, позволяющий взаимодействовать с другими процессами.
Следовательно, программист использует либо общеизвестный прикладной протокол, такой как HTTP или FTP, либо спроектированный и реализованный внутри
своей команды.
Итак, мы обсудили пять уровней: физический, канальный, сетевой, транспортный
и прикладной. Теперь можно объединить их и использовать в качестве отправной
точки для разработки и развертывания компьютерных сетей. В следующем пункте
текста речь пойдет о наборе интернет-протоколов.

Набор интернет-протоколов
Ежедневно мы имеем дело с широко распространенной сетевой моделью IPS
(Internet Protocol Suite — набор интернет-протоколов). Она в основном используется в Интернете, и, поскольку доступ к нему поддерживают практически все
компьютеры, мы можем наблюдать ее повсеместное присутствие. Тем не менее IPS
не является официальным стандартом ISO. Стандартной моделью для компьютерных сетей считается OSI (Open System Interconnections — модель взаимодействия
открытых систем), которая носит скорее теоретический характер и почти никогда
не развертывается и не используется в публичных сетях. Ниже перечислены уровни, из которых состоит IPS, а также известные протоколы, которые применяются
на каждом из них:
zz физический уровень;
zz канальный уровень — Ethernet, IEEE 802.11 Wi-Fi;
zz сетевой уровень — IPv4, IPv6 и ICMP;
zz транспортный уровень — TCP, UDP;
zz прикладной уровень — многочисленные протоколы наподобие HTTP, FTP,

DNS, DHCP и многие другие.
Как видите, данная модель напрямую связана с теми уровнями, которые мы обсудили в текущей главе, только сетевой уровень иногда называют интернет-уровнем.

Введение в программирование сокетов   605

Причина в том, что в IPS распространенными сетевыми протоколами являются
только IPv4 и IPv6. В остальном к IPS применимо все представленное нами ранее.
Это основная модель, с которой мы будем иметь дело в данной книге и в реальных
рабочих условиях.
Мы уже знаем, как работают компьютерные сети, и готовы к тому, чтобы познакомиться с программированием сокетов. На оставшихся страницах этой главы
и в следующей вы увидите, что между уже знакомыми нам понятиями транспортного
уровня и концепциями программирования сокетов существует глубокая связь.

Что такое программирование сокетов
Итак, мы рассмотрели модель IPS и различные уровни сети. Теперь вам будет
намного легче понять, что такое программирование сокетов. Прежде чем объяснять технические аспекты, следует отметить: программирование сокетов — метод
межпроцессного взаимодействия, который позволяет соединить два процесса,
размещенных на одном или разных узлах с установленным между ними сетевым
соединением. Если мы говорим о сценарии с двумя узлами, то они должны быть
подключены к рабочей сети. Уже сам этот факт делает программирование сокетов
неотрывным от компьютерных сетей и всего того, что объяснялось ранее.
Строго говоря, программирование сокетов происходит в основном на транспортном
уровне. Как уже отмечалось, транспортный уровень отвечает за соединение двух
процессов поверх имеющегося сетевого уровня. Следовательно, транспортный
уровень — ключевой элемент установления контекста для программирования сокетов. Это, в сущности, причина, по которой программист должен иметь хорошее
представление о транспортном уровне и его различных протоколах. Некоторые
ошибки, связанные с программированием сокетов, возникают в транспортном
канале, лежащем в основе взаимодействия.
В этом виде взаимодействия сокеты выступают главным средством прокладывания транспортного канала. Несмотря на то, о чем мы говорили выше, программирование распространяется не только на транспортный (процесс — процесс), но
и на сетевой уровень (хост — хост). Это значит, сокеты могут оперировать как на
сетевом, так и на транспортном уровне. Учитывая сказанное, следует сказать, что
в этой и следующей главах мы в основном будем рассматривать и использовать
транспортные сокеты.

Что такое сокет
Как уже было сказано, программирование сокетов на самом деле происходит на
транспортном уровне. Все, что находится выше, всего лишь делает взаимодействие
более узкоспециализированным; однако сам канал, лежащий в основе программирования сокетов, создается на транспортном уровне.

606  Глава 19



Локальные сокеты и IPC

Я также упоминал, что интернет-соединение (сетевое соединение), в рамках которого создается транспортный канал, на самом деле соединяет операционные системы или, точнее, их ядра. Следовательно, в ядре должна существовать абстракция,
напоминающая соединение. Более того, одно ядро может инициировать и принимать много соединений, поскольку в его операционной системе может выполняться
несколько процессов, которым нужен доступ к сети.
Роль этой абстракции играет сокет. Для любого соединения в системе, уже существующего или устанавливаемого, выделяется сокет, который его идентифицирует.
Для отдельно взятого соединения между двумя процессами нужно по одному сокету на каждом конце. Ранее уже объяснялось, что один из этих сокетов принадлежит
стороне соединителя, а другой — стороне слушателя. API, который позволяет нам
определять сокеты и работать с ними, описывается библиотекой сокетов, предоставляемой операционной системой.
Поскольку нас в основном интересуют POSIX-системы, мы можем ожидать, что
в составе API POSIX такая библиотека есть, и это действительно так. В оставшейся
части этой главы мы будем обсуждать библиотеку сокетов POSIX. Вы увидите, как
с ее помощью можно устанавливать соединения между двумя процессами.

Библиотека сокетов POSIX
У каждого объекта сокета есть три атрибута: домен, тип и протокол. Они подробно
описаны в справочных страницах операционной системы, и потому здесь мы поговорим о значениях, обычно присваиваемых этим атрибутам. Начнем с домена, иначе называемого семейством адресов (address family, AF) или семейством протоколов
(protocol family, PF). Ниже перечислено несколько значений, которые можно часто
встретить. Отмечу, что эти семейства адресов поддерживаются транспортными
каналами независимо от наличия соединения.
zz Сокеты AF_LOCAL или AF_UNIX — локальные; работают, только если процессы

соединителя и слушателя находятся на одном компьютере.
zz Сокеты AF_INET позволяют двум процессам соединяться друг с другом по IPv4.
zz Сокеты AF_INET6 дают возможность двум процессам соединяться друг с другом

по IPv6.
В некоторых POSIX-системах константы, которые используются в качестве домена, могут иметь префикс PF_ вместо AF_. Обычно эти константы
имеют одни и те же значения, что делает их взаимозаменяемыми.

В следующей главе будет продемонстрировано использование доменов AF_UNIX
и AF_INET, но вы также можете легко найти примеры с доменом AF_INET6. Кроме

Введение в программирование сокетов   607

того, в ряде операционных систем есть уникальные семейства адресов, которые
не встречаются в других ОС.
Ниже перечислены самые распространенные значения, которые используются
в качестве типа сокета.
zz Тип сокетов SOCK_STREAM представляет транспортный канал, ориентированный

на соединения, с гарантией доставки, корректности и упорядоченности отправляемых данных. На это также указывает термин STREAM (поток), который
мы рассматривали выше. Обратите внимание: на данном этапе мы не можем
сказать, используется ли в качестве транспортного протокола TCP, поскольку
здесь также могут применяться локальные сокеты, принадлежащие к семейству
адресов AF_UNIX.
zz Тип сокетов SOCK_DGRAM представляет транспортный канал без поддержки

соединений. Как уже объяснялось выше, термин «датаграмма» обозначает последовательность байтов, которые нельзя считать потоком. Вместо этого их
следует рассматривать в качестве отдельных фрагментов данных, именуемых
датаграммами. На более техническом уровне датаграмма представляет собой
пакет данных, передаваемый по сети.
zz Сокет типа SOCK_RAW может представлять каналы с соединениями и без. Основное
отличие между SOCK_RAW и SOCK_DGRAM или SOCK_STREAM в том, что ядро на самом

деле знает о том, какой транспортный протокол используется внутри (UDP или
TCP). Это позволяет ему проанализировать пакет и извлечь из него заголовок
и содержимое. Однако с сокетами типа SOCK_RAW ядро этого не делает, и потому
программа, которая открыла сокет, сама должна его прочитать и извлечь из него
нужные элементы.
Иными словами, при использовании SOCK_RAW пакеты доставляются непосредственно программе, которая должна понимать их структуру и уметь извлекать
их содержимое. Обратите внимание: если канал является потоковым (ориентированным на соединения), то восстановлением потерянных пакетов и их
упорядочением должно заниматься не ядро, а сама программа. Из этого следует,
что при выборе протокола TCP ядро выполняет восстановление и упорядочение
пакетов за вас.
Третий атрибут определяет протокол, который должен использоваться для объекта сокета. Этот атрибут может быть выбран операционной системой в момент
создания сокета, поскольку в большинстве случаев его можно определить по сочетанию семейства адресов и типа. Если же существует несколько потенциальных
протоколов, то данный атрибут необходимо установить вручную.
Программирование сокетов предлагает решения для межпроцессного взаимодействия и внутри одной системы, и между разными компьютерами. То есть два
процесса, которые мы соединяем, могут вполне находиться как на разных хостах

608  Глава 19



Локальные сокеты и IPC

и даже в разных локальных сетях, так и в одной и той же системе; в первом случае
используются сетевые сокеты, а во втором — сокеты домена Unix.
В заключение стоит отметить, что соединения на основе сокетов являются двунаправленными и полнодуплексными. Это значит, обе стороны могут читать из
канала и записывать в него, не мешая друг другу. Это полезное свойство, которое
требуется в большинстве ситуаций, связанных с межпроцессным взаимодействием.
Итак, мы познакомились с концепцией сокетов. Теперь заново рассмотрим последовательности действий, которые выполняют процессы слушателя и соединителя.
Однако на сей раз я уделю больше внимания подробностям и покажу, как эти
действия можно выполнить с помощью сокетов.

Повторное рассмотрение последовательности инициализации
слушателя и соединителя
Как уже упоминалось ранее, в почти любом соединении в компьютерной сети одна
из сторон всегда ожидает входящих соединений, а другая пытается к ней подключиться. Мы также обсудили пример с телефонной сетью и увидели, как телефон
используется для ожидания входящих звонков и как с его помощью можно соединяться с другими ожидающими устройствами. Аналогичная ситуация существует
и в программировании сокетов. Здесь мы исследуем действия, которые процессы
должны выполнить на двух противоположных концах канала, чтобы успешно
установить транспортное соединение.
В следующих подразделах мы подробно поговорим о создании сокета и различных
операциях, которые должны выполнить оба процесса, желающие участвовать в соединении. Последовательность шагов, описанная ниже, не зависит от конкретной
инфраструктуры. Это возможно благодаря тому, что программирование сокетов
инкапсулирует в себе разного рода транспортные соединения.
Как вы помните, ранее мы обсуждали последовательность действий слушателя
и соединителя отдельно для взаимодействия с соединениями и без. Здесь будет
использоваться тот же подход. Начнем с потоковой последовательности (ориентированной на соединения) на стороне слушателя.
Потоковая последовательность на стороне слушателя. Процесс, который хочет
принимать новые потоковые соединения (слушатель), должен выполнить следующие шаги. Вы уже знакомы с этапами привязки, прослушивания и приема по
предыдущим разделам, здесь мы обсудим их с точки зрения программирования
сокетов. Обратите внимание: соответствующие возможности реализованы в ядре,
а процесс, который хочет перейти в режим прослушивания, должен всего лишь
вызвать подходящие функции из библиотеки сокетов.

Введение в программирование сокетов  609

1. Процесс должен создать объект сокета, используя функцию socket. Этот объект
обычно называют слушающим сокетом. Он представляет весь процесс слушателя и служит для приема входящих соединений. Аргументы, передаваемые функции socket, могут разниться в зависимости от вида канала. В качестве семейства
адресов можно указать либо AF_UNIX, либо AF_INET, но тип сокета может быть
только SOCK_STREAM, поскольку мы имеем дело с потоковым каналом. Протокол
сокета может определить операционная система. Например, если вы укажете
для своего объекта атрибуты AF_INET и SOCK_STREAM, то в качестве протокола по
умолчанию будет выбран TCP.
2. Теперь сокет нужно привязать к конечной точке, доступной процессу соединителя. Для этого предусмотрена функция bind. Свойства выбранной конечной
точки во многом зависят от заданного семейства адресов. Например, в случае
с интернет-каналом она представляет собой сочетание IP-адреса и порта. Если
мы имеем дело с сокетом домена Unix, то конечная точка будет иметь вид пути
к файлу сокета, размещенному в файловой системе.
3. Сокет должен быть сконфигурирован для прослушивания. Здесь используется
функция listen. Как уже объяснялось ранее, она просто создает очередь отставания для слушающего сокета. Эта очередь содержит список ожидающих соединений, которые еще не были приняты процессом слушателя. Ядро хранит новые
входящие соединения в соответствующей очереди отставания, пока слушатель
не освободится и не начнет их принимать. Как только очередь заполнится,
ядро будет отклонять любые последующие соединения. Если сделать очередь
слишком короткой, то в периоды загруженности слушателя многие входящие
соединения могут быть отклонены; если же очередь слишком длинная, то вы
получите кучу сообщений, время ожидания которых в конечном счете истечет,
что сделает их недействительными. Размер очереди отставания следует выбирать с учетом поведения слушающей программы.
4. После настройки очереди отставания можно приступать к приему входящих
соединений. Для каждого входящего соединения должна быть вызвана функция
accept. В связи с этим вызов данной функции зачастую помещают в бесконечный цикл. Каждый раз, когда процесс слушателя перестает принимать новые
соединения, запросы соединителей направляются в очередь отставания; как
только очередь заполняется, новые запросы начинают отклоняться. Отмечу, что
каждый вызов функции accept берет следующее соединение, ожидающее в очереди сокета. Если очередь пустая, а слушающий сокет сделан блокиру­ющим,
то любой вызов accept будет блокироваться до тех пор, пока не появится новое
соединение.
Обратите внимание: функция accept возвращает новый объект сокета. Это значит,
что ядро выделяет новый уникальный сокет для каждого принятого соединения.
Иными словами, процесс слушателя, принявший 100 клиентов, использует как
минимум 101 сокет: 1 для прослушивания и 100 для входящих соединения. Сокет,

610  Глава 19



Локальные сокеты и IPC

возвращаемый функцией accept, следует применять для дальнейшего взаимодействия с клиентом, находящимся на противоположном конце канала.
Как видите, данная последовательность вызовов характерна для всех типов потокового (ориентированного на соединения) межпроцессного взаимодействия на
основе сокетов. В следующей главе мы покажем реальные примеры того, как эти
шаги реализуются на языке C. Ниже описана потоковая последовательность на
стороне соединителя.
Потоковая последовательность на стороне соединителя. Когда процесс соединителя хочет подключиться к процессу слушателя, который уже находится в режиме прослушивания, он должен выполнить ту же последовательность действий.
Обратите внимание: если слушатель не находится в режиме прослушивания, то его
ядро отклонит соединение.
1. Процесс соединителя должен создать сокет, вызвав функцию socket. Этот сокет
будет использоваться для подключения к процессу на противоположной стороне. По своим характеристикам он должен быть похож на слушающий сокет или
по крайней мере быть с ним совместимым, иначе установить соединение не получится. Следовательно, обоим сокетам следует иметь одно и то же семейство
адресов, а тип, как и прежде, должен быть SOCK_STREAM.
2. Затем нужно вызвать функцию connect и передать ей аргументы, которые
однозначно идентифицируют конечную точку слушателя. Соединитель должен
иметь возможность обратиться к этой конечной точке, а слушателю следует
сделать ее доступной. Успешное выполнение connect говорит о том, что соединение было принято нужным нам процессом. Но перед этим соединение может
какое-то время находиться в очереди отставания слушателя. Если по какой-то
причине конечная точка недоступна, то соединение не будет установлено и процесс соединителя получит ошибку.
Функция connect , как и функция accept на стороне соединителя, возвращает
объект сокета. Этот сокет служит идентификатором соединения, и его нужно использовать для дальнейшего взаимодействия с процессом слушателя. В следующей
главе я покажу рассмотренные нами последовательности шагов на примере программы-калькулятора.
Датаграммная последовательность на стороне слушателя. При инициализации
датаграммного процесса слушателя выполняются следующие шаги.
1. Датаграммный слушатель, как и потоковый, создает объект сокета, вызывая
функцию socket. Однако на этот раз в качестве типа сокета следует указать
SOCK_DGRAM.
2. После создания слушающего сокета процесс слушателя должен привязать его
к конечной точке. Конечная точка и присущие ей ограничения очень похожи
на те, которые используются на стороне потокового соединителя. Отмечу, что

Введение в программирование сокетов  611

у датаграммного слушающего сокета нет режима прослушивания и этапа приема,
поскольку лежащий в его основе канал не поддерживает соединения, и потому
мы не можем выделить отдельный сеанс для каждого входящего запроса.
Как уже упоминалось ранее, у датаграммного серверного сокета нет режима прослушивания и этапа приема. Для чтения из процесса соединителя и записи в него
датаграммные слушатели должны использовать функции recvfrom и sendto. Чтение
по-прежнему можно выполнять с помощью функции read, однако для записи одного вызова write будет недостаточно. Причину этого вы увидите при рассмотрении
примера с датаграммным слушателем в следующей главе.
Датаграммная последовательность на стороне соединителя. Датаграммный соединитель выполняет почти ту же последовательность действий, что и потоковый.
Единственное отличие состоит в типе сокета: в случае с датаграммным соединителем это должен быть SOCK_DGRAM. Особый случай представляют датаграммные
соединители на основе сокетов домена Unix: в целях получения ответов от сервера
они должны быть привязаны к файлу соответствующего сокета. Более подробно
об этом поговорим в следующей главе, в которой будет представлен пример калькулятора с использованием датаграмм и сокетов домена Unix.
Итак, мы прошлись по всем возможным последовательностям. Теперь можно
поговорить о том, как связаны между собой сокеты и дескрипторы сокетов.
Это будет заключительный подраздел в данной главе, а уже в главе 20 мы рассмотрим реальные примеры на языке C, которые иллюстрируют все описанные
последовательности.

У сокетов есть собственные дескрипторы!
В отличие от других пассивных методов межпроцессного взаимодействия, которые
работают с файловыми дескрипторами, методики на основе сокетов имеют дело
с объектами сокетов. Каждый объект имеет целочисленный идентификатор, играющий роль дескриптора сокета внутри ядра. С помощью этого дескриптора можно
ссылаться на соответствующий канал.
Заметьте, что файловые дескрипторы и дескрипторы сокетов — это разные вещи.
Первые указывают на обычные файлы или файлы устройств, а вторые ссылаются
на объекты сокетов, созданные путем вызова функций socket, accept и connect.
Несмотря на разницу файловых дескрипторов и дескрипторов сокетов, для чтения
и записи в них используется один и тот же API (или набор функций). Поэтому
с сокетами, как и с файлами, можно работать с помощью функций read и write.
Эти дескрипторы имеют нечто общее: их API позволяет сделать их неблокиру­
ющими. Такие дескрипторы можно использовать для работы с файлом или сокетом
в неблокирующей манере.

612  Глава 19



Локальные сокеты и IPC

Резюме
В этой главе мы начали обсуждать методы межпроцессного взаимодействия, которые позволяют двум процессам общаться и обмениваться данными. Мы завершим
изучение этой темы уже в следующей главе, где поговорим именно о программировании сокетов и рассмотрим различные реальные примеры на языке C.
В ходе данной главы мы:
zz рассмотрели пассивные и активные методы межпроцессного взаимодействия,

их различия и сходства;
zz сравнили методы IPC на одном и нескольких компьютерах;
zz познакомились с коммуникационными протоколами и их различными харак-

теристиками;
zz рассмотрели концепции сериализации и десериализации, поговорив о том, как

они работают в рамках определенного коммуникационного протокола;
zz выяснили, каким образом такие характеристики протокола, как содержимое,

длина и упорядоченность, могут влиять на процесс-получатель;
zz познакомились с POSIX-каналами и увидели пример их использования;
zz выяснили, что такое очередь сообщений POSIX и как с ее помощью можно

организовать взаимодействие двух процессов;
zz кратко затронули тему сокетов домена Unix и их основные свойства;
zz разобрались, что такое компьютерные сети и каким образом совокупность раз-

личных уровней сети позволяет устанавливать транспортные соединения;
zz обсудили, что такое программирование сокетов;
zz рассмотрели последовательность инициализации процессов слушателя и со-

единителя, а также шаги, которые они выполняют при подготовке к взаимодействию;
zz сравнили файловые дескрипторы и дескрипторы сокетов.

В следующей главе мы продолжим обсуждать программирование сокетов и рассмотрим реальные примеры на языке C. Мы напишем клиентскую и серверную
программы, которые будут выполнять функции калькулятора. Затем воспользуемся
сокетами домена Unix и сетевыми сокетами, чтобы организовать полноценное клиент-серверное взаимодействие клиентского процесса калькулятора и его сервера.

20

Программирование
сокетов

В предыдущей главе мы обсудили локальное межпроцессное взаимодействие и познакомились с концепцией программирования сокетов. Здесь же завершим наше
введение и подробно рассмотрим тему программирования сокетов на примере
реального клиент-серверного приложения: проекта «Калькулятор».
Порядок, в котором представлены темы, может показаться немного необычным, но
это сделано для того, чтобы вы лучше ориентировались в различных типах сокетов
и понимали, как они себя ведут в реальном проекте. В рамках данной главы мы:
zz для начала подытожим материал, представленный в предыдущей главе. Это будет

всего лишь краткое повторение пройденного материала. Вы обязательно должны
прочитать вторую часть главы 19, посвященную программированию сокетов;
zz в процессе повторения обсудим разные типы сокетов, датаграммные и потоко-

вые последовательности, а также некоторые другие темы, которые понадобятся
нам при рассмотрении примера с калькулятором;
zz опишем и полностью проанализируем пример клиент-серверного проекта

«Калькулятор». Это позволит нам перейти к обсуждению различных его компонентов и представить код на языке C;
zz разработаем один из важнейших компонентов нашего примера — библиотеку

сериализации/десериализации. Она реализует главный протокол взаимодействия клиентской и серверной частей калькулятора;
zz необходимо понимать, что клиентская и серверная части калькулятора должны

уметь взаимодействовать с помощью сокетов любых типов. Поэтому в нашем
примере рассмотрим интеграцию с разного рода сокетами, начиная с сокетов
домена Unix (Unix domain sockets, UDS);
zz увидим в нашем примере, как с их помощью установить клиент-серверное со-

единение в рамках одного компьютера;
zz вслед за этим рассмотрим сетевые сокеты; узнаем, как TCP- и UDP-сокеты

можно интегрировать в проект «Калькулятор».
Для начала повторим все, что уже знаем о сокетах и программировании сокетов
в целом. Прежде чем продолжать, крайне желательно ознакомиться со второй
частью предыдущей главы. Я буду исходить из того, что вы уже обладаете необходимыми знаниями.

614  Глава 20



Программирование сокетов

Краткий обзор программирования сокетов
В данном разделе мы еще раз поговорим о том, что такое сокеты, каких типов они
бывают и что мы в целом понимаем под программированием сокетов. Это будет
краткий обзор, но он станет основой для дальнейшего углубленного обсуждения
в последующих разделах.
Если помните, в предыдущей главе мы говорили о наличии двух категорий межпроцессного взаимодействия, которые позволяют двум и более процессам общаться
и обмениваться данными. К первой категории относятся активные (pull-based)
методики, требующие наличия доступного носителя (такого как разделяемая память или обычный файл) для хранения и извлечения данных. Вторая категория
охватывает пассивные (push-based) методики и подразумевает создание канала,
доступного всем процессам, участвующим во взаимодействии. Главное различие
между этими категориями связано с тем, как именно данные извлекаются из носителя (при активном подходе) или канала (при пассивном подходе).
Говоря простым языком, при использовании активных методик данные должны
извлекаться или считываться с носителя, а в пассивном подходе данные доставляются читающему процессу автоматически. В первом случае процессы извлекают
данные из разделяемого носителя, и если несколько из них могут туда записывать,
то это чревато состояниями гонки.
Если быть более точным, то в активных методиках данные всегда доставляются
в буфер внутри ядра, который доступен принимающему процессу с помощью дескриптора (файла или сокета).
Затем принимающий процесс может либо заблокироваться, пока не станут доступными какие-то новые данные, либо запросить дескриптор и проверить, появилось ли в буфере ядра нечто новое, и в случае отрицательного ответа заняться
чем-то другим. Первый подход называется блокирующим вводом/выводом, а второй — неблокирующим, или асинхронным, вводом/выводом. В этой главе все активные
методики используют блокирующий подход.
Мы уже знаем, что программирование сокетов — особая разновидность межпроцессного взаимодействия, относящаяся ко второй категории. Поэтому все методы
IPC, основанные на сокетах, являются активными. Однако основная характеристика, которая отличает программирование сокетов от других активных методов IPC,
заключается в использовании сокетов. Сокеты — это специальные объекты в Unixподобных и других операционных системах, включая даже Microsoft Windows,
представляющие двунаправленные каналы.
Иными словами, один объект сокета можно использовать для чтения и записи
в один и тот же канал. Таким образом, два процесса, находящиеся на разных концах
одного канала, могут участвовать в двунаправленном взаимодействии.

Краткий обзор программирования сокетов   615

В предыдущей главе мы видели, что сокеты представлены дескрипторами по аналогии с тем, как файловые дескрипторы представляют файлы. Оба вида дескрипторов
имеют некоторые общие аспекты, такие как операции ввода/вывода и возможность
их запрашивать, однако на самом деле это разные вещи. Дескриптор сокета всегда
представляет канал, а вот файловый дескриптор может представлять такие носители, как обычный файл или POSIX-канал. В связи с этим дескрипторы сокетов
не поддерживают определенные операции с файлами, например seek; то же самое
можно сказать даже о файловых дескрипторах, которые представляют канал.
Взаимодействие, основанное на сокетах, может работать как с соединениями, так
и без. В первом случае канал представляет поток байтов, передающийся между
двумя определенными процессами, а во втором по каналу могут передаваться
датаграммы, и при этом никакого соединения между процессами нет. Несколько
процессов могут использовать один и тот же канал для разделения состояния или
обмена данными.
Таким образом, мы имеем два типа каналов: потоковые и датаграммные. Каждый
потоковый канал в программе представлен потоковым сокетом, а каждый датаграммный — датаграммным. При подготовке канала мы должны сделать его либо
потоковым, либо датаграммным. Чуть позже вы увидите, что наш пример с калькулятором поддерживает оба вида каналов.
Сокеты бывают разных типов. Каждый тип предназначен для определенных задач
и ситуаций. В целом сокеты можно разделить на две категории: UDS и сетевые.
Как вы уже, наверное, знаете из предыдущей главы, UDS можно использовать
в случаях, когда все процессы, желающие участвовать в межпроцессном взаимодействии, находятся на одном компьютере. Иными словами, UDS подходит только
для проектов, развернутых в рамках одной системы.
Для сравнения, сетевые сокеты можно применять в почти любой конфигурации,
независимо от того, как именно развернуты процессы и где они находятся. Они могут размещаться на одном компьютере или быть распределены по сети. В случае
с локальным развертыванием более предпочтительны сокеты UDS, поскольку они
более быстрые и имеют меньше накладных расходов по сравнению с сетевыми сокетами. В рамках нашего примера с калькулятором мы реализуем поддержку как
UDS, так и сетевых сокетов.
UDS и сетевые сокеты могут представлять как потоковые, так и датаграммные
каналы. Следовательно, мы имеем четыре разные комбинации: UDS поверх потокового канала, UDS поверх датаграммного канала, сетевой сокет поверх потокового
канала и, наконец, сетевой сокет поверх датаграммного канала. Все эти четыре
варианта будут реализованы в нашем примере.
Сетевой сокет, предоставляющий потоковый канал, обычно работает по TCP. Дело
в том, что TCP — самый распространенный транспортный протокол для этого вида
сокетов. С другой стороны, сетевой сокет, предоставляющий датаграммный канал,

616  Глава 20



Программирование сокетов

обычно работает по UDP. Это объясняется тем, что в большинстве случаев для такого
рода сокетов используется транспортный протокол UDP. Обратите внимание: сокеты
UDS, предоставляющие потоковые или датаграммные каналы, не имеют каких-то
специальных названий, поскольку не применяют никаких транспортных протоколов.
Все указанные разновидности сокетов и каналов лучше всего показать на реальном
примере. Вот, собственно, почему мы пошли таким необычным путем. Это позволит
вам обратить внимание на общие аспекты разных типов сокетов и каналов и оформить их в виде блоков кода, пригодных к повторному использованию. В следующем
разделе мы обсудим проект «Калькулятор» и его внутреннюю структуру.

Проект «Калькулятор»
Мы выделили целый раздел на то, чтобы объяснить назначение проекта «Калькулятор». Это масштабный пример, поэтому, прежде чем углубляться в него, будет
полезно получить четкое понимание того, что он собой представляет. Данный проект должен помочь вам достичь следующих целей:
zz рассмотреть полнофункциональный пример с рядом простых и четко опреде-

ленных возможностей;
zz извлечь общие аспекты различных типов сокетов и каналов и оформить их

в виде библиотек, пригодных к повторному использованию. Это существенно
уменьшит количество кода, который нужно будет написать, и продемонстрирует
вам границы, пролегающие между разными видами сокетов и каналов;
zz организоватьвзаимодействие с помощью четко определенного прикладного про-

токола. Обычным примерам программирования сокетов недостает этого очень
важного свойства. Они, как правило, демонстрируют очень простые и зачастую
одноразовые сценарии взаимодействия клиента и сервера;
zz поработать над примером, имеющим все характеристики полнофункциональной

клиент-серверной программы, такой как прикладной протокол с поддержкой
разных видов каналов, возможностью сериализации/десериализации и т. д.
Это позволит вам по-новому взглянуть на программирование сокетов.
С учетом всего вышесказанного этот проект станет нашим главным примером в данной главе. Мы будем разрабатывать его шаг за шагом, и я проведу вас через различные этапы, кульминацией которых станет завершенное и рабочее приложение.
Для начала следует разработать относительно простой и полноценный прикладной
протокол. Он будет использоваться для взаимодействия клиента и сервера. Как уже
объяснялось ранее, две стороны не могут общаться между собой без четко определенного протокола прикладного уровня. Они могут быть соединены и передавать
данные, поскольку эту возможность предоставляет программирование сокетов, но
не будут понимать друг друга.

Проект «Калькулятор»   617

Вот почему мы должны уделить немного времени обсуждению прикладного протокола, который будет использоваться в проекте «Калькулятор». Но прежде, чем
переходить к самому протоколу, рассмотрим иерархию исходного кода, которую
можно увидеть в кодовой базе проекта. Это существенно облегчит поиск прикладного протокола и библиотеки сериализации/десериализации.

Иерархия исходного кода
С точки зрения программиста, API для программирования POSIX-сокетов обращается со всеми потоковыми каналами одинаково, независимо от того, что лежит в их
основе: UDS или сетевой сокет. Как вы можете помнить из материалов предыдущей
главы, для потоковых каналов предусмотрены определенные последовательности
шагов, которые выполняются на стороне слушателя и соединителя, и эти последовательности не зависят от типа сокетов.
Таким образом, если вы собираетесь поддерживать разные типы сокетов в сочетании с разными типами каналов, то лучше определить их общие аспекты и реа­
лизовать их отдельно, чтобы не повторяться. Именно такой подход мы станем
применять в проекте «Калькулятор», и он отражен в исходном коде. Поэтому вам
будут встречаться различные библиотеки, и некоторые из них будут содержать
общий код, повторно использующийся разными компонентами.
Теперь пришло время взяться за кодовую базу. Прежде всего, исходный код
проекта находится по адресу https://github.com/PacktPublishing/Extreme-C/tree/master/
ch20-socket-programming. Если пройти по данной ссылке и взглянуть на код, то можно увидеть ряд каталогов с исходными файлами. Очевидно, что анализ каждого
файла занял бы слишком много времени, поэтому мы сосредоточимся на важных
участках кода. Вы можете сами пройтись по кодовой базе, собрать ее и запустить
полученную программу; благодаря этому вы получите общее представление о том,
как разрабатывался наш пример.
Отмечу, что весь код, относящийся к сокетам UDS, UDP и TCP, находится в одном
каталоге. Далее мы рассмотрим иерархию кодовой базы.
Если перейти в корень проекта и выполнить команду tree , то можно увидеть
дерево файлов и каталогов, представленное в терминале 20.1. В нем же показано,
как клонировать репозиторий этой книги на GitHub и перейти в корневой каталог
данного примера.
Терминал 20.1. Клонирование кодовой базы проекта «Калькулятор» и вывод его файлов
и каталогов
$ git clone https://github.com/PacktPublishing/Extreme-C
Cloning into 'Extreme-C'...
...

618  Глава 20



Программирование сокетов

Resolving deltas: 100% (458/458), done.
$ cd Extreme-C/ch20-socket-programming
$ tree
.
├── CMakeLists.txt
├── calcser
...
├── calcsvc
...
├── client

├── CMakeLists.txt

├── clicore
...

├── tcp


├── CMakeLists.txt


└── main.c

├── udp


├── CMakeLists.txt


└── main.c

└── Unix

├── CMakeLists.txt

├── datagram


├── CMakeLists.txt


└── main.c

└── stream

├── CMakeLists.txt

└── main.c
├── server

├── CMakeLists.txt

├── srvcore
...

├── tcp


├── CMakeLists.txt


└── main.c

├── udp


├── CMakeLists.txt


└── main.c

└── Unix

├── CMakeLists.txt

├── datagram


├── CMakeLists.txt


└── main.c

└── stream

├── CMakeLists.txt

└── main.c
└── types.h
18 directories, 49 files
$

Проект «Калькулятор»  619

Как можно видеть в этом списке файлов и каталогов, наш проект состоит из ряда
компонентов, в том числе и библиотек. Все компоненты находятся в отдельных
каталогах, каждый из которых описан ниже.
zz Библиотека сериализации/десериализации в каталоге /calcser содержит соот-

ветствующие исходные файлы. Она описывает прикладной протокол, по которому общаются клиентская и серверная часть калькулятора. В итоге собирается
в статическую библиотеку libcalcser.a.
zz Библиотека в каталоге /calcsvc содержит исходники вычислительного сервиса.

Это не то же самое, что серверный процесс. Данный сервис предоставляет
основные функции калькулятора и не привязан к серверному процессу, и потому его можно использовать отдельно в качестве самостоятельной библиотеки C. В результате сборки этого каталога получается статическая библиотека
libcalcsvc.a.
zz Библиотека в каталоге /server/srvcore содержит исходники, общие для потоко-

вых и датаграммных процессов, независимо от типа сокета. Поэтому ее могут
использовать все серверные процессы калькулятора, включая те, которые
основаны на UDS и сетевых сокетах и работают с потоковыми и датаграммными каналами. Итоговым результатом сборки этого каталога будет статическая
библиотека libsrvcore.a.
zz Каталог /server/unix/stream содержит исходники серверной программы, которая

использует потоковые каналы внутри сокетов UDS. Она будет собрана в исполняемый файл unix_stream_calc_server. Это одна из нескольких программ,
которые можно применять в качестве серверной части калькулятора. Этот
конкретный сервер прослушивает сокет UDS для установления потоковых
соединений.
zz Каталог /server/unix/datagram содержит исходники серверной программы, кото-

рая использует датаграммные каналы внутри сокетов UDS. Она будет собрана
в исполняемый файл unix_datagram_calc_server. Это одна из нескольких программ, которые можно задействовать в качестве серверной части калькулятора.
Этот конкретный сервер прослушивает сокет UDS для приема датаграммных
сообщений.
zz Каталог /server/tcp содержит исходники серверной программы, которая исполь-

зует потоковые каналы внутри сетевых сокетов TCP. Она будет собрана в исполняемый файл tcp_calc_server. Это одна из нескольких программ, которые
можно применять в качестве серверной части калькулятора. Этот конкретный
сервер прослушивает сокет TCP для установления потоковых соединений.
zz Каталог /server/udp содержит исходники серверной программы, которая ис-

пользует датаграммные каналы внутри сетевых сокетов UDP. Она будет собрана в исполняемый файл udp_calc_server. Это одна из нескольких программ,

620  Глава 20



Программирование сокетов

которые можно задействовать в качестве серверной части калькулятора. Этот
конкретный сервер прослушивает сокет UDP для приема датаграммных сообщений.
zz Библиотека в каталоге /client/clicore содержит исходники, общие для потоковых

и датаграммных клиентских процессов, независимо от типа сокета. Поэтому ее
могут использовать все клиентские процессы калькулятора, включая те, которые
основаны на UDS и сетевых сокетах и работают с потоковыми и датаграммными каналами. Итоговым результатом сборки этого каталога будет статическая
библиотека libclicore.a.
zz Каталог /client/unix/stream содержит исходники клиентской программы, которая

использует потоковые каналы внутри сокетов UDS. Она будет собрана в исполняемый файл unix_stream_calc_client. Это одна из нескольких программ,
которые можно применять для запуска клиентской части калькулятора. Этот
конкретный клиент подключается к конечной точке UDS и устанавливает потоковое соединение.
zz Каталог /client/unix/datagram содержит исходники клиентской программы, которая

использует датаграммные каналы внутри сокетов UDS. Она будет собрана в исполняемый файл unix_datagram_calc_client. Это одна из нескольких программ,
которые можно задействовать в целях запуска клиентской части калькулятора.
Этот конкретный клиент подключается к конечной точке UDS и отправляет
датаграммные сообщения.
zz Каталог /client/tcp содержит исходники клиентской программы, которая исполь-

зует потоковые каналы внутри сокетов TCP. Она будет собрана в исполняемый
файл tcp_calc_client. Это одна из нескольких программ, которые можно применять для запуска клиентской части калькулятора. Этот конкретный клиент подключается к конечной точке TCP-сокета и устанавливает потоковое соединение.
zz Каталог /client/udp содержит исходники клиентской программы, которая ис-

пользует датаграммные каналы внутри сокетов UDP. Она будет собрана
в исполняемый файл udp_calc_client. Это одна из нескольких программ, которые можно задействовать для запуска клиентской части калькулятора. Этот
конкретный клиент подключается к конечной точке UDP-сокета и отправляет
датаграммные сообщения.

Сборка проекта
Итак, мы прошлись по всем каталогам проекта. Теперь нам нужно показать, как он
собирается. Проект использует систему CMake, поэтому, прежде чем переходить
к сборке, убедитесь в том, что она у вас установлена.
Чтобы собрать проект, выполните следующие команды в корневом каталоге главы
(терминал 20.2).

Проект «Калькулятор»  621
Терминал 20.2. Программы для сборки проекта «Калькулятор»
$ mkdir -p build
$ cd build
$ cmake ..
...
$ make
...
$

Запуск проекта
Ничто так не помогает убедиться в работоспособности проекта, как его самостоя­
тельный запуск. Поэтому, прежде чем переходить к техническим подробностям,
я хочу, чтобы вы по очереди запустили серверную и клиентскую части калькулятора и понаблюдали за тем, как они общаются друг с другом.
Перед запуском процессов необходимо открыть два отдельных терминала (или командные оболочки), чтобы ввести два разных набора команд. В первом терминале
мы запустим потоковый сервер, прослушивающий сокет UDS. Соответствующая
команда приводится ниже (терминал 20.3).
Обратите внимание: перед вводом этой команды вы должны перейти в каталог build,
который был создан в рамках предыдущего раздела.
Терминал 20.3. Запуск потокового сервера, который прослушивает сокет UDS
$ ./server/unix/stream/unix_stream_calc_server

Убедитесь в том, что сервер работает. Запустите во втором терминале потоковый
клиент, собранный для использования UDS (терминал 20.4).
Терминал 20.4. Запуск клиентской части калькулятора и отправка нескольких запросов
$ ./client/unix/stream/unix_stream_calc_client
? (type quit to exit) 3++4
The req(0) is sent.
req(0) > status: OK, result: 7.000000
? (type quit to exit) mem
The req(1) is sent.
req(1) > status: OK, result: 7.000000
? (type quit to exit) 5++4
The req(2) is sent.
req(2) > status: OK, result: 16.000000
? (type quit to exit) quit
Bye.
$

622  Глава 20



Программирование сокетов

Как видите, у клиентского процесса есть собственная командная строка. Она принимает команды от пользователя, превращает их в запросы, соответствующие прикладному протоколу, и отправляет их серверу для дальнейшей обработки. Затем
клиент ждет ответа и сразу после того, как тот будет получен, выводит результат.
Отмечу, что данная командная строка является частью общего кода, написанного
для всех клиентов, поэтому вы всегда сможете работать с ней, независимо от типа
канала или сокета.
Теперь пришло время углубиться в детали прикладного протокола и посмотреть
на то, как выглядят запросы и ответы.

Прикладной протокол
Любые два процесса, которые хотят общаться друг с другом, должны соблюдать
общий прикладной протокол. Он может быть написан специально для проекта
«Калькулятор», но мы можем воспользоваться и общеизвестным стандартом, таким
как HTTP. Наш протокол будет называться протоколом калькулятора.
Сообщения в протоколе калькулятора имеют переменную длину. То есть длина
сообщений может отличаться, и между ними должны находиться разделители.
У нас будет один тип запросов и один тип ответов. Вдобавок следует сказать, что
протокол будет текстовым. То есть запросы и ответы могут состоять только из
алфавитно-цифровых и нескольких других символов. Это позволит сделать сообщения калькулятора понятными человеку.
Запрос состоит из четырех полей: идентификатора, метода, первого и второго
операнда. Каждый запрос обладает уникальным идентификатором, благодаря которому сервер знает, кому отправлять соответствующий ответ.
Метод — операция, выполняемая сервисом калькулятора. В листинге 20.1 показан
заголовочный файл calcser/calc_proto_req.h, который описывает структуру запроса в нашем протоколе.
Листинг 20.1. Определение объекта запроса (calcser/calc_proto_req.h)

#ifndef CALC_PROTO_REQ_H
#define CALC_PROTO_REQ_H
#include
typedef enum {
NONE,
GETMEM, RESMEM,
ADD, ADDM,
SUB, SUBM,

Проект «Калькулятор»  623
MUL, MULM,
DIV
} method_t;
struct calc_proto_req_t {
int32_t id;
method_t method;
double operand1;
double operand2;
};
method_t str_to_method(const char*);
const char* method_to_str(method_t);
#endif

Как видите, в рамках нашего протокола определено девять методов. Любой приличный калькулятор должен иметь внутреннюю память, и потому у нас есть операции
с памятью, относящиеся к сложению, вычитанию и умножению.
Например, метод ADD просто складывает два числа с плавающей запятой, а ADDM —
его разновидность, которая прибавляет к этим двум числам значение, хранящееся
во внутренней памяти, и заносит результат в ту же память для дальнейшего использования. Это аналогично применению кнопки памяти в настольном калькуляторе;
вы можете найти ее по надписи +M.
У нас также есть специальный метод для чтения и сброса внутренней памяти калькулятора. Операцию деления с внутренней памятью выполнять нельзя, поэтому
других вариаций не предусмотрено.
Представьте, что клиент хочет создать запрос с ID 1000, методом ADD и двумя операндами: 1.5 и 5.6. В языке C для этого нужно создать объект calc_proto_req_t
(данная структура объявлена в предыдущем заголовке в листинге 20.1) и заполнить
его нужными значениями. В листинге 20.2 показано, как это делается.
Листинг 20.2. Создание объекта запроса на C

struct calc_proto_req_t req;
req.id = 1000;
req.method = ADD;
req.operand1 = 1.5;
req.operand2 = 5.6;

Как уже объяснялось в предыдущей главе, объект req в этом листинге можно отправить серверу только после сериализации и превращения его в запрос. Иными
словами, нам нужно сериализовать данный объект запроса в соответствующее
сообщение запроса. В соответствии с нашим прикладным протоколом результат
сериализации объекта req будет выглядеть следующим образом (листинг 20.3).

624  Глава 20



Программирование сокетов

Листинг 20.3. Сериализованное сообщение, эквивалентное объекту req,
который был определен в листинге 20.2

1000#ADD#1.5#5.6$

Символ # служит для разделения полей, а символ $ играет роль разделителя сообщений. Кроме того, у каждого сообщения есть ровно четыре поля. Десериализатор на
другом конце канала использует эти факты для разбора входящих байтов и воссоздания оригинального объекта.
С другой стороны, серверный процесс, который отвечает на запрос, должен сериа­
лизовать объект ответа. Ответ состоит из трех полей: идентификатора запроса,
статуса и результата. Идентификатор уникален и указывает на запрос, на который хочет ответить сервер.
Заголовочный файл calcser/calc_proto_resp.h описывает то, как должен выглядеть ответ. Вы можете видеть его в листинге 20.4.
Листинг 20.4. Определение объекта ответа (calcser/calc_proto_resp.h)

#ifndef CALC_PROTO_RESP_H
#define CALC_PROTO_RESP_H
#include
#define
#define
#define
#define
#define
#define

STATUS_OK
STATUS_INVALID_REQUEST
STATUS_INVALID_METHOD
STATUS_INVALID_OPERAND
STATUS_DIV_BY_ZERO
STATUS_INTERNAL_ERROR

0
1
2
3
4
20

typedef int status_t;
struct calc_proto_resp_t {
int32_t req_id;
status_t status;
double result;
};
#endif

По аналогии с объектом запроса из листинга 20.2, req, серверный процесс создает
объект ответа, выполняя следующие инструкции (листинг 20.5).
Листинг 20.5. Создание объекта ответа для объекта req, который был определен в листинге 20.2

struct calc_proto_resp_t resp;
resp.req_id = 1000;
resp.status = STATUS_OK;
resp.result = 7.1;

Проект «Калькулятор»   625

Результат сериализации этого объекта выглядит так (листинг 20.6).
Листинг 20.6. Сериализованное ответное сообщение, эквивалентное объекту resp,
который был создан в листинге 20.5

1000#0#7.1$

И снова мы используем символы # для разделения полей и $ для разделения сообщений. Обратите внимание: поле status является числовым и сигнализирует
об успешном или неуспешном запросе. В случае неудачи оно имеет ненулевое
значение, описанное в заголовочном файле ответа (или, точнее, в протоколе калькулятора).
Теперь более подробно поговорим о библиотеке сериализации/десериализации
и посмотрим, как она выглядит внутри.

Библиотека сериализации/десериализации
В предыдущем подразделе было показано, как выглядят сообщения запроса и ответа. Здесь же поговорим об алгоритмах сериализации и десериализации, которые
используются в проекте «Калькулятор». Предоставление соответствующих операций будет выполняться с помощью класса serializer с calc_proto_ser_t в качестве
структуры атрибутов.
Я уже упоминал о том, что эти возможности предоставляются другим частям
проекта в виде статической библиотеки libcalcser.a. В листинге 20.7 вы можете
видеть публичный API класса serializer, который находится в файле calcser/
calc_proto_ser.h.
Листинг 20.7. Публичный интерфейс класса serializer (calcser/calc_proto_ser.h)

#ifndef CALC_PROTO_SER_H
#define CALC_PROTO_SER_H
#include
#include "calc_proto_req.h"
#include "calc_proto_resp.h"
#define
#define
#define
#define
#define

ERROR_INVALID_REQUEST
ERROR_INVALID_REQUEST_ID
ERROR_INVALID_REQUEST_METHOD
ERROR_INVALID_REQUEST_OPERAND1
ERROR_INVALID_REQUEST_OPERAND2

#define ERROR_INVALID_RESPONSE
#define ERROR_INVALID_RESPONSE_REQ_ID

101
102
103
104
105
201
202

626  Глава 20



Программирование сокетов

#define ERROR_INVALID_RESPONSE_STATUS
#define ERROR_INVALID_RESPONSE_RESULT

203
204

#define ERROR_UNKNOWN 220
struct buffer_t {
char* data;
int len;
};
struct calc_proto_ser_t;
typedef void (*req_cb_t)(
void* owner_obj,
struct calc_proto_req_t);
typedef void (*resp_cb_t)(
void* owner_obj,
struct calc_proto_resp_t);
typedef void (*error_cb_t)(
void* owner_obj,
const int req_id,
const int error_code);
struct calc_proto_ser_t* calc_proto_ser_new();
void calc_proto_ser_delete(
struct calc_proto_ser_t* ser);
void calc_proto_ser_ctor(
struct calc_proto_ser_t* ser,
void* owner_obj,
int ring_buffer_size);
void calc_proto_ser_dtor(
struct calc_proto_ser_t* ser);
void* calc_proto_ser_get_context(
struct calc_proto_ser_t* ser);
void calc_proto_ser_set_req_callback(
struct calc_proto_ser_t* ser,
req_cb_t cb);
void calc_proto_ser_set_resp_callback(
struct calc_proto_ser_t* ser,
resp_cb_t cb);
void calc_proto_ser_set_error_callback(
struct calc_proto_ser_t* ser,
error_cb_t cb);
void calc_proto_ser_server_deserialize(
struct calc_proto_ser_t* ser,

Проект «Калькулятор»   627
struct buffer_t buffer,
bool_t* req_found);
struct buffer_t calc_proto_ser_server_serialize(
struct calc_proto_ser_t* ser,
const struct calc_proto_resp_t* resp);
void calc_proto_ser_client_deserialize(
struct calc_proto_ser_t* ser,
struct buffer_t buffer,
bool_t* resp_found);
struct buffer_t calc_proto_ser_client_serialize(
struct calc_proto_ser_t* ser,
const struct calc_proto_req_t* req);
#endif

Помимо конструктора и деструктора, которые нужны для создания и уничтожения
объекта serializer, у нас есть две пары функций: первая пара для серверного процесса, а вторая — для клиентского.
На клиентской стороне мы сериализуем объект запроса и десериализуем сообщение
с ответом. А на серверной стороне десериализуем сообщение с запросом и сериализуем объект ответа.
Помимо операций сериализации и десериализации, у нас также есть три функции
обратного вызова:
zz обратный вызов для получения объекта запроса, десериализованного из соот-

ветствующего канала;
zz обратный вызов для получения объекта ответа, который был десериализован

из соответствующего канала;
zz обратный вызов для получения ошибки в случае провала сериализации или

десериализации.
Эти обратные вызовы используются клиентскими и серверными процессами для
получения входящих запросов и ответов, а также ошибок, которые могут возникнуть при сериализации или десериализации сообщения.
Теперь поближе рассмотрим функции сериализации/десериализации для серверной стороны.

Функции сериализации/десериализации для серверной стороны
Для серверного процесса предусмотрено две функции: одна сериализует объект
ответа, а другая десериализует сообщение с запросом. Начнем с функции сериа­
лизации.

628  Глава 20



Программирование сокетов

В листинге 20.8 представлен код функции calc_proto_ser_server_serialize, которая сериализует ответ.
Листинг 20.8. Функция сериализации ответа для серверной стороны (calcser/calc_proto_ser.c)

struct buffer_t calc_proto_ser_server_serialize(
struct calc_proto_ser_t* ser,
const struct calc_proto_resp_t* resp) {
struct buffer_t buff;
char resp_result_str[64];
_serialize_double(resp_result_str, resp->result);
buff.data = (char*)malloc(64 * sizeof(char));
sprintf(buff.data, "%d%c%d%c%s%c", resp->req_id,
FIELD_DELIMITER, (int)resp->status, FIELD_DELIMITER,
resp_result_str, MESSAGE_DELIMITER);
buff.len = strlen(buff.data);
return buff;
}

Элемент resp — это указатель на объект ответа, который нужно сериализовать.
Функция возвращает объект buffer_t, который объявлен в заголовочном файле
calc_proto_ser.h и выглядит так (листинг 20.9).
Листинг 20.9. Определение объекта buffer_t (calcser/calc_proto_ser.h)

struct buffer_t {
char* data;
int len;
};

Сериализатор имеет простой код, основная часть которого — инструкция sprintf,
предназначенная для создания строки с ответным сообщением. Теперь взглянем на
функцию десериализации запроса. Десериализатор обычно сложнее реализовать,
и если найти в кодовой базе вызовы следующих функций, то можно увидеть, насколько сложными они бывают.
В листинге 20.10 показана функция для десериализации запроса.
Листинг 20.10. Функция десериализации запроса для серверной стороны (calcser/calc_proto_ser.c)

void calc_proto_ser_server_deserialize(
struct calc_proto_ser_t* ser,
struct buffer_t buff,
bool_t* req_found) {
if (req_found) {
*req_found = FALSE;
}
_deserialize(ser, buff, _parse_req_and_notify,
ERROR_INVALID_REQUEST, req_found);
}

Проект «Калькулятор»  629

Приведенная выше функция выглядит просто, однако на самом деле использует
приватные методы _deserialize и _parse_req_and_notify, которые определены
в файле calc_proto_ser.c, где содержится сама реализация класса Serializer.
Мы не станем углубляться в код упомянутых приватных методов, поскольку это
уже выходит за рамки данной книги. Но чтобы вы имели общее представление,
особенно если вам хочется почитать исходный код, отмечу: десериализатор использует кольцевой буфер фиксированной длины и пытается найти символ $ ,
который служит разделителем сообщений.
При нахождении $ десериализатор применяет указатель, который в нашем случае
ссылается на функцию _parse_req_and_notify (третий аргумент, переданный
функции _deserialize). Она пытается извлечь поля и воссоздать объект запроса.
Затем использует функции обратного вызова, отправляя уведомления зарегистрированному наблюдателю, роль которого в данном случае исполняет объект сервера,
а тот уже приступает к обработке запроса.
Теперь рассмотрим функции, применяемые на стороне клиента.

Функции сериализации/десериализации
для клиентской стороны
Как и в случае с серверной стороной, для стороны клиента предусмотрено две
функции: одна для сериализации объекта запроса, а другая для сериализации
входящего ответа.
Начнем с сериализатора запроса. Его определение можно видеть в листинге 20.11.
Листинг 20.11. Функция сериализации запроса для клиентской стороны
(calcser/calc_proto_ser.c)

struct buffer_t calc_proto_ser_client_serialize(
struct calc_proto_ser_t* ser,
const struct calc_proto_req_t* req) {
struct buffer_t buff;
char req_op1_str[64];
char req_op2_str[64];
_serialize_double(req_op1_str, req->operand1);
_serialize_double(req_op2_str, req->operand2);
buff.data = (char*)malloc(64 * sizeof(char));
sprintf(buff.data, "%d%c%s%c%s%c%s%c", req->id, FIELD_DELIMITER,
method_to_str(req->method), FIELD_DELIMITER,
req_op1_str, FIELD_DELIMITER, req_op2_str,
MESSAGE_DELIMITER);
buff.len = strlen(buff.data);
return buff;
}

630  Глава 20



Программирование сокетов

Данная функция принимает объект запроса и возвращает объект buffer; это очень
похоже на сериализацию ответа на серверной стороне. Здесь даже применяется
тот же подход: использование инструкции sprintf для создания сообщения с запросом.
В листинге 20.12 показана функция десериализации ответа.
Листинг 20.12. Функция десериализации ответа для клиентской стороны
(calcser/calc_proto_ser.c)

void calc_proto_ser_client_deserialize(
struct calc_proto_ser_t* ser,
struct buffer_t buff, bool_t* resp_found) {
if (resp_found) {
*resp_found = FALSE;
}
_deserialize(ser, buff, _parse_resp_and_notify,
ERROR_INVALID_RESPONSE, resp_found);
}

Здесь применяется тот же механизм и задействуются похожие приватные методы.
Я настоятельно рекомендую должным образом прочесть эти исходники, чтобы
лучше понять, как разные части кода были собраны вместе и максимально хорошо
подготовлены для повторного использования существующих компонентов.
Мы не станет углубляться в класс Serializer; можете сами пройтись по коду и посмотреть, как он работает.
Итак, у нас есть библиотека сериализации. Теперь мы можем написать клиентскую
и серверную программы. Разработка библиотеки, которая сериализует и десериа­
лизует сообщения в соответствии с согласованным прикладным протоколом, —
обязательный шаг при написании многопроцессного программного обеспечения.
Заметьте, что это не зависит от того, как развернута ваша система: на одном или
нескольких компьютерах. Процессы должны понимать друг друга, и для этого
должны быть определены подходящие протоколы прикладного уровня.
Прежде чем переходить к коду, относящемуся к программированию сокетов, необходимо объяснить еще кое-что: сервис калькулятора. Он лежит в основе серверного
процесса и выполняет сами вычисления.

Сервис калькулятора
Сервис калькулятора — основная логика нашего примера. Стоит отметить, что он
должен работать независимо от того или иного механизма межпроцессного взаимодействия. В листинге 20.13 показано объявление его класса.

Проект «Калькулятор»  631

Как видите, этот сервис спроектирован таким образом, что его можно использовать
даже в простейшей программе, состоящей из одной лишь функции main и не имеющей никакого отношения к IPC.
Листинг 20.13. Публичный интерфейс класса, принадлежащего сервису калькулятора
(calcsvc/calc_service.h)

#ifndef CALC_SERVICE_H
#define CALC_SERVICE_H
#include
static const int CALC_SVC_OK = 0;
static const int CALC_SVC_ERROR_DIV_BY_ZERO = -1;
struct calc_service_t;
struct calc_service_t* calc_service_new();
void calc_service_delete(struct calc_service_t*);
void calc_service_ctor(struct calc_service_t*);
void calc_service_dtor(struct calc_service_t*);
void calc_service_reset_mem(struct calc_service_t*);
double calc_service_get_mem(struct calc_service_t*);
double calc_service_add(struct calc_service_t*, double, double b, bool_t mem);
double calc_service_sub(struct calc_service_t*, double, double b, bool_t mem);
double calc_service_mul(struct calc_service_t*, double, double b, bool_t mem);
int calc_service_div(struct calc_service_t*, double, double, double*);
#endif

Как видите, в этом классе даже предусмотрены отдельные типы ошибок. Входные
аргументы имеют стандартные типы языка C, которые никоим образом не зависят
от классов или структур, связанных с сериализацией. Поскольку это изолированная и самостоятельная логика, мы компилируем ее в виде отдельной статической
библиотеки libcalcsvc.a.
Каждый серверный процесс должен использовать объекты данного сервиса для
выполнения вычислений. Эти объекты обычно называются сервисными или служебными. И потому итоговая серверная программа должна быть скомпонована
с данной библиотекой.
Прежде чем двигаться дальше, необходимо сделать следующее замечание: если клиенту не требуется определенный контекст, то мы можем ограничиться одним служебным
объектом. То есть если сервис на клиентской стороне не требует от нас сохранения
какого-либо состояния из предыдущих запросов данного клиента, то служебный
объект можно сделать синглтоном. Это значит, что у объекта нет состояния.

632  Глава 20



Программирование сокетов

И наоборот, если для обработки текущего запроса нужна какая-либо информация
о предыдущих запросах, то в каждом клиенте необходимо использовать отдельный
служебный объект. Именно это происходит в нашем проекте. Как вы уже знаете,
у калькулятора есть внутренняя память, уникальная для каждого клиента. Поэтому
мы не можем задействовать один и тот же объект в разных клиентах. Это значит,
у объекта есть состояние.
Если подытожить вышесказанное, то для каждого клиента нам нужно создавать
новый объект сервиса. Благодаря этому у каждого клиента будет свой калькулятор
с отдельной внутренней памятью. Служебные объекты калькулятора имеют состоя­
ние (значение во внутренней памяти), которое им нужно загружать.
Теперь мы готовы перейти к обсуждению различных типов сокетов с примерами
в контексте проекта «Калькулятор».

Сокеты домена Unix
Мы уже знаем по предыдущей главе, при установлении соединения между двумя
процессами, размещенными на одном компьютере, одним из лучших решений
являются сокеты домена Unix (Unix domain sockets, UDS). В данной главе мы продолжили наше обсуждение и поговорили чуть более подробно о пассивных методах
межпроцессного взаимодействия, а также о потоковых и датаграммных каналах.
Теперь пришло время объединить эти знания и посмотреть на UDS в действии.
Текущий раздел состоит из четырех частей, посвященных разным видам процессов,
находящимся на стороне слушателя или соединителя и использующим потоковые
или датаграммные каналы. Все эти процессы работают с сокетами UDS. Мы рассмотрим все шаги, которые они должны выполнить в целях создания канала с учетом последовательностей, представленных нами в предыдущей главе. Для начала
поговорим о слушающем процессе, работающем с потоковым каналом. Это будет
потоковый сервер.

Потоковый сервер на основе UDS
Как вы помните, в предыдущей главе мы рассматривали разные последовательности действий, которые выполняют слушатель и соединитель, взаимодействуя
с помощью транспортного канала. Сервер играет роль слушателя, поэтому должен
выполнять его последовательность. В частности, поскольку в данном подразделе
речь идет о потоковом канале, ему следует использовать последовательность потокового слушателя.
В рамках этой последовательности сервер должен сначала создать объект сокета.
В нашем проекте потоковый сервер, желающий принимать соединения по UDS,
должен выполнять те же шаги.

Сокеты домена Unix  633

Следующий фрагмент кода находится в главной функции серверной программы.
Как видно в листинге 20.14, процесс сначала создает объект socket.
Листинг 20.14. Создание потокового объекта UDS (server/unix/stream/main.c)

int server_sd = socket(AF_UNIX, SOCK_STREAM, 0);
if (server_sd == -1) {
fprintf(stderr, "Could not create socket: %s\n",
strerror(errno));
exit(1);
}

Как видите, объект сокета создается с помощью функции socket. Она подключается
из заголовка , который входит в стандарт POSIX. Обратите внимание: мы пока не знаем, каким будет данный объект: клиентским или серверным.
Это смогут определить только последующие функции.
В предыдущей главе мы уже объясняли, что у каждого объекта сокета есть три атрибута, которые определяются тремя аргументами, переданными функции socket.
Эти аргументы указывают семейство адресов, тип и протокол, которые будет использовать данный объект.
Согласно последовательности инициализации потокового слушателя, особенно
той ее части, которая относится к UDS после создания объекта сокета, серверная
программа должна привязаться к файлу сокета. Это будет наш следующий шаг.
Листинг 20.15 используется в проекте «Калькулятор» в целях привязки объекта
сокета к файлу, расположенному по заранее известному пути. Этот путь указан
в виде массива символов sock_file.
Листинг 20.15. Привязка потокового объекта UDS к файлу сокета, заданному с помощью
массива символов sock_file (server/unix/stream/main.c)

struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, sock_file, sizeof(addr.sun_path) - 1);
int result = bind(server_sd, (struct sockaddr*)&addr,
sizeof(addr));
if (result == -1) {
close(server_sd);
fprintf(stderr, "Could not bind the address: %s\n",
strerror(errno));
exit(1);
}

Данный код состоит из двух этапов. На первом создается экземпляр типа struct
sockaddr_un с именем addr , который после инициализации указывает на файл

634  Глава 20



Программирование сокетов

сокета. На втором этапе объект addr передается функции bind, чтобы она знала,
какой файл следует привязать к объекту сокета. Вызов данной функции завершается успешно, только если к заданному файлу не привязан никакой другой объект.
Следовательно, в случае с UDS два объекта сокетов, которые, вероятно, принадлежат разным процессам, не могут быть привязаны к одному и тому же файлу.
В Linux UDS можно привязать к абстрактному адресу сокета. Это в основном полезно в ситуациях, когда для размещения файла сокета нельзя
задействовать файловую систему. В приведенном выше листинге в целях
инициализации структуры адреса, addr, можно применять строку, начинающуюся с нулевого символа, \0, в результате чего предоставленное
имя будет привязано к объекту сокета внутри ядра. Данное имя должно
быть уникальным в рамках всей системы, и к нему не должен быть привязан никакой другой объект.

Продолжая говорить о пути к файлу сокета, следует отметить, что в большинстве
систем Unix он не может превышать 104 байтов. Однако в системах Linux его длина
составляет 108 байт. Обратите внимание: строковая переменная, которая хранит
этот путь в виде массива типа char , всегда содержит в конце дополнительный
нулевой символ. Поэтому путь к файлу сокета фактически имеет длину 103 или
107 байт в зависимости от операционной системы.
Если функция bind возвращает 0, то это значит, что привязка прошла успешно
и вы можете приступать к следующему шагу в последовательности потокового
слушателя: настройке размера очереди отставания.
В коде, представленном в листинге 20.16, показана процедура настройки очереди
отставания для потокового сервера, прослушивающего сокет UDS.
Листинг 20.16. Настройка размера очереди отставания для привязанного потокового сокета
(server/unix/stream/main.c)

result = listen(server_sd, 10);
if (result == -1) {
close(server_sd);
fprintf(stderr, "Could not set the backlog: %s\n",
strerror(errno));
exit(1);
}

Функция listen задает размер очереди отставания для уже привязанного сокета.
Как объяснялось в предыдущей главе, когда занятый серверный процесс не в состоянии принимать от клиентов дальнейшие входящие запросы, некоторое количество этих запросов может подождать в очереди отставания, пока у программы
не появится возможность их обработать. Это важный этап подготовки потокового
сокета перед приемом клиентских запросов.

Сокеты домена Unix   635

Согласно последовательности действий потокового слушателя, вслед за привязкой
потокового сокета и настройки размера его очереди отставания мы можем приступить к приему новых клиентских запросов. В листинге 20.17 показано, как это
делается.
Листинг 20.17. Принятие новых клиентских запросов с помощью сокета потокового слушателя
(server/unix/stream/main.c)

while (1) {
int client_sd = accept(server_sd, NULL, NULL);
if (client_sd == -1) {
close(server_sd);
fprintf(stderr, "Could not accept the client: %s\n", strerror(errno));
exit(1);
}
...
}

Все волшебство происходит в функции accept, которая возвращает новый сокет
при получении нового запроса со стороны клиента. Возвращаемый объект сокета
указывает на соответствующий потоковый канал, созданный между сервером
и клиентом, запрос которого был принят. Обратите внимание: у клиента есть свой
потоковый канал и, следовательно, собственный дескриптор сокета.
Отмечу: если потоковый слушающий сокет является блокирующим (что происходит по умолчанию), то функция accept блокирует выполнение, пока не поступит
новый клиентский запрос. То есть при отсутствии новых запросов поток выполнения, вызывающий функцию accept, блокируется на ней.
Теперь пришло время собрать все перечисленные выше шаги в единое целое.
В листинге 20.18 показан потоковый сервер из проекта «Калькулятор», который
прослушивает сокет UDS.
Листинг 20.18. Главная функция потокового сервиса калькулятора, прослушивающая конечную
точку UDS (server/unix/stream/main.c)

#include
#include
#include
#include
#include
#include








#include
#include
#include
int main(int argc, char** argv) {
char sock_file[] = "/tmp/calc_svc.sock";

636  Глава 20



Программирование сокетов

// ----------- 1. Создаем объект сокета -----------------int server_sd = socket(AF_UNIX, SOCK_STREAM, 0);
if (server_sd == -1) {
fprintf(stderr, "Could not create socket: %s\n", strerror(errno));
exit(1);
}
// ----------- 2. Привязываем файл сокета -----------------// Удаляем ранее созданный файл сокета, если таковой имеется
unlink(sock_file);
// Подготавливаем адрес
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, sock_file, sizeof(addr.sun_path) - 1);
int result = bind(server_sd,
(struct sockaddr*)&addr, sizeof(addr));
if (result == -1) {
close(server_sd);
fprintf(stderr, "Could not bind the address: %s\n",
strerror(errno));
exit(1);
}
// ----------- 3. Подготавливаем резерв -----------------result = listen(server_sd, 10);
if (result == -1) {
close(server_sd);
fprintf(stderr, "Could not set the backlog: %s\n",
strerror(errno));
exit(1);
}
// ----------- 4. Начинаем принимать клиентов --------accept_forever(server_sd);
return 0;
}

Найти блоки кода, ответственные за выполнение вышеупомянутых действий по
инициализации серверного сокета, должно быть довольно легко. Здесь не хватает
лишь кода, который принимает запросы от клиентов. Он вынесен в отдельную
функцию с именем accept_forever. Обратите внимание: данная функция является
блокирующей, поэтому главный поток будет заблокирован до завершения работы
сервера.
В листинге 20.19 показано определение функции accept_forever. Она является частью общей серверной библиотеки, которая находится в каталоге srvcore.

Сокеты домена Unix   637

Подобное размещение объясняется тем, что ее определение используется другими
потоковыми сокетами, включая работающие по TCP. Таким образом, мы можем
повторно задействовать имеющуюся логику, вместо того чтобы создавать ее заново.
Листинг 20.19. Функция, принимающая новые клиентские запросы на потоковом сокете,
который прослушивает конечную точку UDS (server/srvcore/stream_server_core.c)

void accept_forever(int server_sd) {
while (1) {
int client_sd = accept(server_sd, NULL, NULL);
if (client_sd == -1) {
close(server_sd);
fprintf(stderr, "Could not accept the client: %s\n",
strerror(errno));
exit(1);
}
pthread_t client_handler_thread;
int* arg = (int *)malloc(sizeof(int));
*arg = client_sd;
int result = pthread_create(&client_handler_thread, NULL,
&client_handler, arg);
if (result) {
close(client_sd);
close(server_sd);
free(arg);
fprintf(stderr, "Could not start the client handler thread.\n");
exit(1);
}
}
}

В данном листинге видно, что в момент приема нового клиентского запроса мы
создаем новый поток выполнения, который отвечает за работу с соединением.
Это фактически сводится к чтению байтов из клиентского канала, передаче прочитанных байт десериализатору и генерации подходящих ответов при обнаружении запроса.
Создание нового потока выполнения для каждого клиента — стандартный подход,
который обычно используется серверными процессами, работающими с блокирующим потоковым каналом, независимо от типа сокета. Поэтому в таких ситуациях
важнейшую роль играет многопоточность и все связанные с ней темы.
Что касается неблокирующих потоковых каналов, то для них обычно
используется другой подход, известный как цикл событий.

Полученный объект сокета можно использовать для чтения и записи на клиентской стороне. Если следовать принципу, по которому разрабатывалась библиотека

638  Глава 20



Программирование сокетов

srvcore, то следующим шагом будет анализ функции-компаньона в клиентском
потоке, client_handler. В исходных текстах эту функцию можно найти сразу за
accept_forever. В листинге 20.20 показано ее определение.
Листинг 20.20. Функция-компаньон потока выполнения, который работает с клиентом
(server/srvcore/stream_server_core.c)

void* client_handler(void *arg) {
struct client_context_t context;
context.addr = (struct client_addr_t*)
malloc(sizeof(struct client_addr_t));
context.addr->sd = *((int*)arg);
free((int*)arg);
context.ser = calc_proto_ser_new();
calc_proto_ser_ctor(context.ser, &context, 256);
calc_proto_ser_set_req_callback(context.ser, request_callback);
calc_proto_ser_set_error_callback(context.ser, error_callback);
context.svc = calc_service_new();
calc_service_ctor(context.svc);
context.write_resp = &stream_write_resp;
int ret;
char buffer[128];
while (1) {
int ret = read(context.addr->sd, buffer, 128);
if (ret == 0 || ret == -1) {
break;
}
struct buffer_t buf;
buf.data = buffer; buf.len = ret;
calc_proto_ser_server_deserialize(context.ser, buf, NULL);
}
calc_service_dtor(context.svc);
calc_service_delete(context.svc);
calc_proto_ser_dtor(context.ser);
calc_proto_ser_delete(context.ser);
free(context.addr);
return NULL;
}

У этого кода есть много разных аспектов, но среди них я хочу выделить несколько
особенно важных. Как вы сами можете видеть, для чтения блоков информации, передаваемой клиентом, используется функция read. Если помните, данная функция

Сокеты домена Unix  639

принимает файловый дескриптор, однако здесь ей передается дескриптор сокета.
Это демонстрация того, что, несмотря на разницу этих двух видов дескрипторов,
для работы с ними можно использовать одни и те же функции ввода/вывода.
В этом коде мы читаем блоки байтов из ввода и передаем их десериализатору, вызывая функцию calc_proto_ser_server_deserialize. Прежде чем ответ полностью
десериализуется, данную функцию, возможно, придется вызвать три или четыре
раза. Это во многом зависит от размера блоков, которые вы читаете из ввода, и длины сообщений, передаваемых по каналу.
Вдобавок следует отметить: у каждого клиента есть свой объект-сериализатор.
То же самое касается объекта сервиса. Эти объекты создаются и уничтожаются
вместе со своим потоком выполнения.
Заключительным аспектом, на который стоит обратить внимание, является то,
что ответы клиенту возвращаются с помощью функции stream_write_response,
предназначенной для работы с потоковым сокетом. Эту функцию можно найти
в том же файле, что и код из предыдущих листингов. В листинге 20.21 представлено
ее определение.
Листинг 20.21. Функция, которая используется для возвращения ответов клиенту
(server/srvcore/stream_server_core.c)

void stream_write_resp(
struct client_context_t* context,
struct calc_proto_resp_t* resp) {
struct buffer_t buf =
calc_proto_ser_server_serialize(context->ser, resp);
if (buf.len == 0) {
close(context->addr->sd);
fprintf(stderr, "Internal error while serializing response\n");
exit(1);
}
int ret = write(context->addr->sd, buf.data, buf.len);
free(buf.data);
if (ret == -1) {
fprintf(stderr, "Could not write to client: %s\n",
strerror(errno));
close(context->addr->sd);
exit(1);
} else if (ret < buf.len) {
fprintf(stderr, "WARN: Less bytes were written!\n");
exit(1);
}
}

В данном листинге видно, что для записи сообщения, которое возвращается клиенту, мы используем функцию write. Как мы уже знаем, она может принимать
файловые дескрипторы, но, по всей видимости, ей можно передавать и дескрипторы

640  Глава 20



Программирование сокетов

сокетов. Это наглядная демонстрация того, что API ввода/вывода POSIX совместим с обоими видами дескрипторов.
То же самое относится и к функции close. Мы использовали ее для разрыва соединения. Как известно, она умеет работать с файловыми дескрипторами, поэтому
ей смело можно передавать дескрипторы сокетов.
Итак, мы прошлись по некоторым важнейшим аспектам потокового сервера UDS
и получили общее представление о том, как он работает. Теперь пришло время
перейти к обсуждению потокового клиента UDS. Конечно, многие участки кода
остались без внимания, но вы можете проанализировать их самостоятельно.

Потоковый клиент на основе UDS
Как и серверная программа, описанная в предыдущем подразделе, клиент должен
первым делом создать объект сокета. Вы помните, что мы должны выполнить последовательность шагов потокового соединителя. Здесь используетсятакой же
фрагмент кода, что и на серверной стороне, включая те же аргументы, которые
сигнализируют о работе с UDS. Дальше нам нужно подключиться к процессу сервера, указав конечную точку UDS, аналогично тому, как это сделал сервер. После
создания потокового канала клиентский процесс может читать и записывать в него
с помощью открытого дескриптора сокета.
В листинге 20.22 показана функция main потокового клиента, который соединяется
с конечной точкой UDS.
Листинг 20.22. Главная функция потокового клиента, соединяющегося с конечной точкой UDS
(client/unix/stream/main.c)

int main(int argc, char** argv) {
char sock_file[] = "/tmp/calc_svc.sock";
// ----------- 1. Создаем объект сокета -----------------int conn_sd = socket(AF_UNIX, SOCK_STREAM, 0);
if (conn_sd == -1) {
fprintf(stderr, "Could not create socket: %s\n",
strerror(errno));
exit(1);
}
// ----------- 2. Подключаемся к серверу --------------------// Подготавливаем адрес
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, sock_file, sizeof(addr.sun_path) - 1);

Сокеты домена Unix  641
int result = connect(conn_sd,
(struct sockaddr*)&addr, sizeof(addr));
if (result == -1) {
close(conn_sd);
fprintf(stderr, "Could no connect: %s\n", strerror(errno));
exit(1);
}
stream_client_loop(conn_sd);
return 0;
}

Первая часть этого листинга очень похожа на код сервера, но дальше вместо bind
клиент вызывает connect. Обратите внимание: код подготовки адреса ничем не отличается от того, который используется сервером.
Успешное возвращение вызова connect означает, что дескриптор сокета conn_sd
был привязан к открытому каналу. С этого момента conn_sd можно использовать для взаимодействия с сервером. Мы передаем даный дескриптор функции
stream_client_loop, которая выводит командную строку клиента и выполняет все
остальные действия, которые будут инициированы клиентской стороной. Это блокирующая функция, выполняющаяся, пока клиент не завершит работу.
Клиент также использует функции read и write для передачи и получения сообщений от сервера. Листинг 20.23 содержит определение функции stream_client_loop,
которая входит в состав общей клиентской библиотеки и используется всеми потоковыми клиентами, независимо от типа сокета — UDS или TCP. Как видите, она
вызывает функцию write для отправки серверу сообщения с сериализованным
запросом.
Листинг 20.23. Функция, выполняющая потоковый клиент (client/clicore/stream_client_core.c)

void stream_client_loop(int conn_sd) {
struct context_t context;
context.sd = conn_sd;
context.ser = calc_proto_ser_new();
calc_proto_ser_ctor(context.ser, &context, 128);
calc_proto_ser_set_resp_callback(context.ser, on_response);
calc_proto_ser_set_error_callback(context.ser, on_error);
pthread_t reader_thread;
pthread_create(&reader_thread, NULL,
stream_response_reader, &context);
char buf[128];
printf("? (type quit to exit) ");
while (1) {

642  Глава 20



Программирование сокетов

scanf("%s", buf);
int brk = 0, cnt = 0;
struct calc_proto_req_t req;
parse_client_input(buf, &req, &brk, &cnt);
if (brk) {
break;
}
if (cnt) {
continue;
}
struct buffer_t ser_req =
calc_proto_ser_client_serialize(context.ser, &req);
int ret = write(context.sd, ser_req.data, ser_req.len);
if (ret == -1) {
fprintf(stderr, "Error while writing! %s\n",
strerror(errno));
break;
}
if (ret < ser_req.len) {
fprintf(stderr, "Wrote less than anticipated!\n");
break;
}
printf("The req(%d) is sent.\n", req.id);
}
shutdown(conn_sd, SHUT_RD);
calc_proto_ser_dtor(context.ser);
calc_proto_ser_delete(context.ser);
pthread_join(reader_thread, NULL);
printf("Bye.\n");
}

Данный код демонстрирует, что все клиентские процессы имеют один общий объект-сериализатор, и это логично. Для сравнения, на серверной стороне у каждого
клиента был свой сериализатор.
Более того, клиентский процесс создает новый поток выполнения для чтения ответов,
которые присылает сервер. Это вызвано тем, что чтение из серверного процесса —
блокирующая операция, поэтому ее следует выполнять в отдельном потоке.
В рамках главного потока мы выводим командную строку клиента, которая принимает пользовательский ввод через терминал. Во время завершения работы главный
поток присоединяет поток чтения и ждет, когда тот завершится.
В данном листинге также следует обратить внимание на то, что клиентский процесс использует тот же API ввода/вывода для чтения и записи в потоковый канал.
Как  уже говорилось ранее, для этого предусмотрены функции read и write, и пример их использования показан в листинге 20.23.
В следующем подразделе мы поговорим о датаграммных каналах, но с применением
все тех же сокетов UDS. Начнем с датаграммного сервера.

Сокеты домена Unix  643

Датаграммный сервер на основе UDS
Возможно, из предыдущей главы вы помните, что датаграммные процессы, слушатель и соединитель, имеют собственные последовательности действий по передаче
данных. Пришло время показать, как может выглядеть датаграммный сервер на
основе UDS.
Согласно последовательности датаграммного слушателя, процесс должен сначала
создать объект сокета. Это показано в листинге 20.24.
Листинг 20.24. Создание объекта UDS, предназначенного для работы с датаграммным каналом
(server/unix/datagram/main.c)

int server_sd = socket(AF_UNIX, SOCK_DGRAM, 0);
if (server_sd == -1) {
fprintf(stderr, "Could not create socket: %s\n",
strerror(errno));
exit(1);
}

Вместо SOCK_STREAM мы используем SOCK_DGRAM. Это значит, объект сокета будет
работать с датаграммным каналом. Остальные два аргумента остаются без изменений.
Второй шаг в последовательности датаграммного слушателя состоит в привязке сокета к конечной точке UDS. Как уже говорилось ранее, это файл сокета.
Данный шаг ничем не отличается от того, который мы выполнили в потоковом
сервере, и потому я не стану его здесь приводить. Можете взглянуть на него
в листинге 20.15.
Это все действия, которые выполняет слушающий датаграммный процесс; датаграммному сокету не назначается очередь отставания. Более того, здесь нет этапа
приема клиентских запросов, поскольку у нас не может быть клиентских соединений с выделенным каналом между двумя процессами.
В листинге 20.25 показана функция main датаграммного сервера, который прослушивает конечную точку UDS в рамках проекта «Калькулятор».
Листинг 20.25. Главная функция датаграммного сервера, который прослушивает конечную
точку UDS (server/unix/datagram/main.c)

int main(int argc, char** argv) {
char sock_file[] = "/tmp/calc_svc.sock";
// ----------- 1. Создаем объект сокета -----------------int server_sd = socket(AF_UNIX, SOCK_DGRAM, 0);
if (server_sd == -1) {
fprintf(stderr, "Could not create socket: %s\n",

644  Глава 20



Программирование сокетов

strerror(errno));
exit(1);
}
// ----------- 2. Привязываем файл сокета -----------------// Удаляем ранее созданный файл сокета, если таковой существует
unlink(sock_file);
// Подготавливаем адрес
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, sock_file, sizeof(addr.sun_path) - 1);
int result = bind(server_sd,
(struct sockaddr*)&addr, sizeof(addr));
if (result == -1) {
close(server_sd);
fprintf(stderr, "Could not bind the address: %s\n",
strerror(errno));
exit(1);
}
// ----------- 3. Начинаем обслуживать запросы --------serve_forever(server_sd);
return 0;
}

Вы уже знаете, что датаграммные каналы не поддерживают соединения и работают
не так, как потоковые каналы. Иными словами, мы не можем установить выделенное соединение между двумя процессами. Поэтому процессы передают по каналу
только отдельные фрагменты данных. Клиент отправляет отдельные и независимые друг от друга датаграммы, а сервер их принимает и в свою очередь возвращает
другие датаграммы в качестве ответа.
Таким образом, важнейшим аспектом датаграммного канала является то, что сообщение с запросом или ответом должно влезать в одну датаграмму. В противном
случае его нельзя разделить между двумя датаграммами, и ни сервер, ни клиент
не смогут его обработать. К счастью, сообщения в нашем проекте в основном достаточно короткие.
Размер датаграммы во многом зависит от канала, по которому она проходит.
Например, датаграммы UDS являются довольно гибкими, поскольку проходят
через ядро. А вот при использовании UDP-сокетов все зависит от конфигурации
сети. Что касается UDS, то информация, доступная по следующей ссылке, более
подробно объясняет, как выбрать корректный размер: https://stackoverflow.com/
questions/21856517/whats-the-practical-limit-on-the-size-of-single-packet-transmitted-over-domain.

Сокеты домена Unix   645

Еще одно различие между датаграммными и потоковыми сокетами, заслуживающее
внимания, состоит в том, что для передачи данных по ним используются разные
API ввода/вывода. Для работы с датаграммным сокетом, как и с потоковым, можно
применять операции read и write, однако для чтения и отправки данных в датаграммный канал мы обычно используем другие функции: recvfrom и sendto.
Это вызвано тем, что потоковые сокеты имеют выделенный канал и при записи
в него мы знаем, что находится на обоих его концах. Касательно датаграммных сокетов, один и тот же канал используется множеством разных сторон. Упомянутые
выше функции способны запомнить нужный процесс и отправить ему датаграмму.
Ниже вы можете видеть определение функции serve_forever, которая использовалась в конце функции main в листинге 20.25. Она входит в состав общей серверной
библиотеки и предназначена для датаграммных серверов, независимо от типа сокета. В листинге 20.26 наглядно показано, как работает операция recvfrom.
Листинг 20.26. Функция для обработки датаграмм, принадлежащая общей
серверной библиотеке и предназначенная для датаграммных серверов
(server/srvcore/datagram_server_core.c)

void serve_forever(int server_sd) {
char buffer[64];
while (1) {
struct sockaddr* sockaddr = sockaddr_new();
socklen_t socklen = sockaddr_sizeof();
int read_nr_bytes = recvfrom(server_sd, buffer,
sizeof(buffer), 0, sockaddr, &socklen);
if (read_nr_bytes == -1) {
close(server_sd);
fprintf(stderr, "Could not read from datagram socket: %s\n",
strerror(errno));
exit(1);
}
struct client_context_t context;
context.addr = (struct client_addr_t*)
malloc(sizeof(struct client_addr_t));
context.addr->server_sd = server_sd;
context.addr->sockaddr = sockaddr;
context.addr->socklen = socklen;
context.ser = calc_proto_ser_new();
calc_proto_ser_ctor(context.ser, &context, 256);
calc_proto_ser_set_req_callback(context.ser, request_callback);
calc_proto_ser_set_error_callback(context.ser, error_callback);
context.svc = calc_service_new();
calc_service_ctor(context.svc);
context.write_resp = &datagram_write_resp;

646  Глава 20



Программирование сокетов

bool_t req_found = FALSE;
struct buffer_t buf;
buf.data = buffer;
buf.len = read_nr_bytes;
calc_proto_ser_server_deserialize(context.ser, buf, &req_found);
if (!req_found) {
struct calc_proto_resp_t resp;
resp.req_id = -1;
resp.status = ERROR_INVALID_RESPONSE;
resp.result = 0.0;
context.write_resp(&context, &resp);
}
calc_service_dtor(context.svc);
calc_service_delete(context.svc);
calc_proto_ser_dtor(context.ser);
calc_proto_ser_delete(context.ser);
free(context.addr->sockaddr);
free(context.addr);
}
}

Как показано в этом листинге, датаграммный сервер представляет собой программу с одним потоком выполнения и без какой-либо многопоточности. Более
того, данная программа работает с каждой датаграммой отдельно и независимо.
Она получает датаграмму, десериализует ее содержимое, создает объект запроса,
обрабатывает запрос с помощью объекта сервиса, сериализует объект ответа, помещает его в новую датаграмму и отправляет процессу, который послал исходный
запрос. Эта процедура повторяется по кругу снова и снова для каждой входящей
датаграммы.
Отмечу: у каждой датаграммы есть собственные объект-сериализатор и объект
сервиса. Мы могли бы спроектировать приложение так, чтобы для всех датаграмм
использовались одни и те же сериализатор и сервис. Подумайте, благодаря чему
это возможно и почему такой подход может оказаться несовместимым с проектом
«Калькулятор». Это спорное решение, и у разных людей могут быть разные мнения
на сей счет.
Обратите внимание: в листинге 20.26 при получении датаграммы мы сохраняем ее
клиентский адрес. Позже данный адрес можно использовать для записи напрямую
в клиентский процесс. Вам стоит ознакомиться с тем, как датаграмма возвращается
исходному клиенту. Как и в случае с потоковым сервером, мы используем для этого функцию. В листинге 20.27 показано определение функции datagram_write_resp,
которая находится в общей библиотеке датаграммного сервера сразу за функцией
serve_forever.

Сокеты домена Unix   647
Листинг 20.27. Функция, возвращающая датаграммы клиентам
(server/srvcore/datagram_server_core.c)

void datagram_write_resp(struct client_context_t* context,
struct calc_proto_resp_t* resp) {
struct buffer_t buf =
calc_proto_ser_server_serialize(context->ser, resp);
if (buf.len == 0) {
close(context->addr->server_sd);
fprintf(stderr, "Internal error while serializing object.\n");
exit(1);
}
int ret = sendto(context->addr->server_sd, buf.data, buf.len,
0, context->addr->sockaddr, context->addr->socklen);
free(buf.data);
if (ret == -1) {
fprintf(stderr, "Could not write to client: %s\n",
strerror(errno));
close(context->addr->server_sd);
exit(1);
} else if (ret < buf.len) {
fprintf(stderr, "WARN: Less bytes were written!\n");
close(context->addr->server_sd);
exit(1);
}
}

Как вы можете видеть, мы берем адрес клиента и передаем его функции sendto
вместе с сериализованным ответом. Все остальное делает за нас операционная система, в результате чего датаграмма возвращается клиенту, который послал запрос.
Мы уже имеем достаточно хорошее представление о датаграммном сервере и о том,
как следует использовать сокеты. Теперь обратим внимание на датаграммный
клиент, который основан на сокетах того же типа.

Датаграммный клиент на основе UDS
С технической точки зрения потоковые и датаграммные клиенты очень похожи.
Это значит, что их общая структура должна быть почти идентичной, а различия
будут связаны с тем, что мы передаем датаграммы, вместо того чтобы работать
с потоковым каналом.
Однако существует одна важная особенность, довольно уникальная и присущая
датаграммным клиентам, подключающимся к конечным точкам UDS.
Дело в том, что датаграммный клиент, как и серверная программа, обязан привязаться к файлу сокета, чтобы получать направляемые ему датаграммы. Но, как вы
вскоре увидите, это не относится к датаграммным клиентам, которые используют

648  Глава 20



Программирование сокетов

сетевые сокеты. Отмечу, что клиент и сервер должны привязываться к разным
файлам сокетов.
Главная причина этого различия состоит в том, что серверной программе нужен
адрес, по которому можно вернуть ответ, и если датаграммный клиент не привязан
к файлу сокета, то данный файл не будет иметь никакого отношения к конечной
точке. Если же говорить о сетевых сокетах, то у клиента всегда есть соответству­
ющий дескриптор, привязанный к IP-адресу и порту, и потому подобной проблемы
не возникает.
Если не считать этих различий, то код в целом выглядит довольно похоже. В листинге 20.28 вы можете видеть функцию main датаграммного клиента.
Листинг 20.28. Функция, возвращающая датаграммы клиентам
(server/srvcore/datagram_server_core.)

int main(int argc, char** argv) {
char server_sock_file[] = "/tmp/calc_svc.sock";
char client_sock_file[] = "/tmp/calc_cli.sock";
// ----------- 1. Создаем объект сокета -----------------int conn_sd = socket(AF_UNIX, SOCK_DGRAM, 0);
if (conn_sd == -1) {
fprintf(stderr, "Could not create socket: %s\n",
strerror(errno));
exit(1);
}
// ----------- 2. Привязываем файл клиентского сокета -----------// Удаляем ранее созданный файл сокета, если таковой существует
unlink(client_sock_file);
// Подготавливаем клиентский адрес
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, client_sock_file,
sizeof(addr.sun_path) - 1);
int result = bind(conn_sd,
(struct sockaddr*)&addr, sizeof(addr));
if (result == -1) {
close(conn_sd);
fprintf(stderr, "Could not bind the client address: %s\n",
strerror(errno));
exit(1);
}

Сетевые сокеты  649
// ----------- 3. Подключаемся к серверу -------------------// Подготавливаем серверный адрес
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, server_sock_file,
sizeof(addr.sun_path) - 1);
result = connect(conn_sd,
(struct sockaddr*)&addr, sizeof(addr));
if (result == -1) {
close(conn_sd);
fprintf(stderr, "Could no connect: %s\n", strerror(errno));
exit(1);
}
datagram_client_loop(conn_sd);
return 0;
}

Как уже объяснялось ранее и как мы можем видеть в данном листинге, клиент обязан привязаться к файлу сокета. И конечно, чтобы начать клиентский цикл, в конце
main следует вызвать другую функцию. В данном случае это datagram_client_loop.
В ней по-прежнему много общего между потоковым и датаграммным клиентами.
Основное различие заключается в использовании функций recvfrom и sendto
вместо read и write. Объяснение, которое приводилось в предыдущем подразделе,
актуально и для датаграммного клиента.
Теперь пришло время поговорить о сетевых сокетах. Как вы увидите, все различия
при переходе с UDS на сетевые сокеты будут находиться в функциях main клиентской и серверной программ.

Сетевые сокеты
Еще одно широко используемое семейство адресов сокетов — AF_INET . К нему
относятся любые каналы, создаваемые поверх сетевого соединения. В отличие от
потоковых и датаграммных сокетов UDS, не имеющих никаких протоколов, для
сетевых сокетов существует два общеизвестных протокола. TCP-сокеты создают
потоковый канал между двумя процессами, а UDP-сокеты — датаграммный канал,
который может применять любое количество процессов.
В следующих разделах мы увидим, как разрабатываются программы на основе
TCP- и UDP-сокетов, и рассмотрим реальные примеры в рамках проекта «Калькулятор».

650   Глава 20



Программирование сокетов

TCP-сервер
Программа, использующая TCP-сокет для прослушивания и приема разных запросов (то есть TCP-сервер), отличается от потокового сервера, который прослушивает
конечную точку UDS. Этих отличий два: во-первых, при вызове функции socket
указывается другое семейство адресов, AF_INET вместо AF_UNIX, и, во-вторых, адрес,
который она использует для привязки, имеет другую структуру.
В остальном, если говорить об операциях ввода/вывода, то TCP-сокет ведет себя
так же, как и UDS. Следует отметить, что TCP-сокет является потоковым, поэтому
для него должен подойти код, рассчитанный на потоковые сокеты домена Unix.
Возвращаясь к проекту «Калькулятор», все отличия следует искать в функциях
main, где мы создаем объект сокета и привязываем его к конечной точке. Остальной
код должен остаться без изменений. В листинге 20.29 вы можете видеть функцию
main, принадлежащую TCP-серверу.
Листинг 20.29. Главная функция TCP-сервера (server/tcp/main.c)

int main(int argc, char** argv) {
// ----------- 1. Создаем объект сокета -----------------int server_sd = socket(AF_INET, SOCK_STREAM, 0);
if (server_sd == -1) {
fprintf(stderr, "Could not create socket: %s\n",
strerror(errno));
exit(1);
}
// ----------- 2. Привязываем файл сокета -----------------// Подготавливаем адрес
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(6666);
...
// ----------- 3. Подготавливаем резерв -----------------...
// ----------- 4. Начинаем принимать клиентов --------accept_forever(server_sd);
}

return 0;

Если сравнить этот код с функцией main, которую мы видели в листинге 20.17,
то можно заметить упомянутые ранее отличия. Для привязанного адреса конеч-

Сетевые сокеты   651

ной точки теперь применяется структура sockaddr_in, а не sockaddr_un. Функция
listen используется тем же образом, а для обработки входящих соединений вызывается та же функция accept_forever.
В завершение отмечу кое-что относительно операций ввода/вывода: будучи потоковым, TCP-сокет обладает теми же свойствами; следовательно, его можно
использовать так же, как и любой другой потоковый сокет. Иными словами, мы
можем вызывать все те же функции read, write и close.
Теперь обсудим TCP-клиент.

TCP-клиент
Здесь тоже все должно быть похоже на потоковый клиент, работающий с UDS.
Отличия, упомянутые выше, актуальны и для TCP-сокета на стороне соединителя
и ограничены функцией main.
В листинге 20.30 вы можете видеть главную функцию TCP-клиента.
Листинг 20.30. Главная функция TCP-клиента (client/tcp/main.c)

int main(int argc, char** argv) {
// ----------- 1. Создаем объект сокета -----------------int conn_sd = socket(AF_INET, SOCK_STREAM, 0);
if (conn_sd == -1) {
fprintf(stderr, "Could not create socket: %s\n",
strerror(errno));
exit(1);
}
// ------------ 2. Подключаемся к серверу -------------------// Находим IP-адрес, которому принадлежит это сетевое имя
...
// Подготавливаем адрес
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr = *((struct in_addr*)host_entry->h_addr);
addr.sin_port = htons(6666);
...
stream_client_loop(conn_sd);
return 0;
}

652   Глава 20



Программирование сокетов

Изменения очень похожи на те, которые мы видели в TCP-сервере. Здесь используется другое семейство адресов и другая структура для хранения адреса сокета.
Остальной код не претерпел изменений, поэтому подробно обсуждать TCP-клиент
нет нужды.
Поскольку TCP-сокеты потоковые, тот же общий код позволяет обрабатывать новые
клиентские запросы. Это можно видеть на примере функции stream_client_loop,
которая является частью общей клиентской библиотеки в проекте «Калькулятор».
Теперь вы знаете, зачем мы создали две общие библиотеки: одну для клиентской программы, а вторую для серверной. Это было сделано для того, чтобы сократить объем
кода. Если один и тот же код подходит для двух разных ситуаций, то его всегда лучше
вынести в библиотеку, которую можно будет использовать повторно.
Рассмотрим серверную и клиентскую UDP-программы; как вы сами сможете убедиться, они более или менее похожи на то, что мы уже видели в TCP-программах.

UDP-сервер
UPD-сокеты являются сетевыми и датаграммными. Поэтому мы можем ожидать
высокой степени сходства с кодом, написанным для TCP-сервера и для датаграммного сервера, который использовал UDS.
Кроме того, главное различие между UDP- и TCP-сокетами, независимо от того,
в какой программе они применяются: клиентской или серверной, состоит в том, что
UDP-сокет имеет тип SOCK_DGRAM. Семейство адресов остается тем же, поскольку
оба вида сокетов являются сетевыми. Листинг 20.31 содержит главную функцию
UDP-сервера.
Листинг 20.31. Главная функция UDP-сервера (server/udp/main.c)

int main(int argc, char** argv) {
// ----------- 1. Создаем объект сокета -----------------int server_sd = socket(AF_INET, SOCK_DGRAM, 0);
if (server_sd == -1) {
fprintf(stderr, "Could not create socket: %s\n",
strerror(errno));
exit(1);
}
// ----------- 2. Привязываем файл сокета -----------------// Подготавливаем адрес
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(9999);

Сетевые сокеты   653
...
// ----------- 3. Начинаем обслуживать запросы --------serve_forever(server_sd);
return 0;
}

Обратите внимание: UDP-сокеты являются датаграммными. Поэтому весь код, написанный для датаграммных сокетов домена Unix, актуален и для них. Например,
работа с UDP-сокетами требует применения функций recvfrom и sendto. В связи
с этим для обслуживания входящих датаграмм была использована та же функция
serve_forever. Она входит в состав общей серверной библиотеки, в которой собран
код, относящийся к датаграммам.
О коде UDP-сервера было сказано достаточно. Посмотрим, как выглядит UDPклиент.

UDP-клиент
Код UDP- и TCP-клиентов очень похож, однако они используют сокеты разных
типов и различные функции для обработки входящих сообщений; в UDP-клиенте
задействована функция из датаграммного клиента, основанного на UDS. В листинге 20.32 вы можете видеть функцию main.
Листинг 20.32. Главная функция UDP-клиента (client/udp/main.c)

int main(int argc, char** argv) {
// ----------- 1. Создаем объект сокета -----------------int conn_sd = socket(AF_INET, SOCK_DGRAM, 0);
if (conn_sd == -1) {
fprintf(stderr, "Could not create socket: %s\n",
strerror(errno));
exit(1);
}
// ------------ 2. Подключаемся к серверу -------------------...
// Подготавливаем адрес
...
datagram_client_loop(conn_sd);
return 0;
}

654   Глава 20



Программирование сокетов

Это была последняя концепция данной главы. Мы рассмотрели разные общеизвестные типы сокетов и показали, как на языке C реализовать последовательности действий слушателя и соединителя в контексте потоковых и датаграммных
каналов.
Проект «Калькулятор» имеет много аспектов, о которых я даже не упомянул. Поэтому настоятельно рекомендую вам пройтись по коду, найти эти места и попытаться их понять. Наличие полностью рабочего примера поможет вам исследовать
концепции, которые можно встретить в реальных приложениях.

Резюме
В этой главе мы:
zz в рамках обзора методов IPC познакомились с различными типами взаимодей-

ствия, каналов, носителей и сокетов;
zz исследовали проект «Калькулятор», рассмотрев его прикладной протокол и ал-

горитм сериализации, который он использует;
zz увидели, как с помощью сокетов UDS установить клиент-серверное соединение

и как их задействовать в проекте «Калькулятор»;
zz по очереди обсудили потоковые и датаграммные каналы, создаваемые с при-

менением сокетов домена Unix;
zz рассмотрели, как с помощью TCP- и UDP-сокетов, которые использовались

в примере с калькулятором, можно создать клиент-серверный канал межпроцессного взаимодействия.
Следующая глава посвящена интеграции C с другими языками программирования. Такая интеграция позволяет загрузить библиотеку, написанную на C, в среду
выполнения другого языка, такого как Java. В рамках следующей главы мы также
поговорим об интеграции с C++, Java, Python и Golang.

21

Интеграция
с другими языками

Навыки написания программ и библиотек на языке C могут оказаться еще полезнее, чем можно было бы ожидать. Благодаря важной роли в разработке операционных систем язык C проникает в другие области. Написанные на нем библиотеки
потенциально могут загружаться и применяться в других языках программирования. Пользуясь преимуществами высокоуровневых языков, вы можете задействовать в их средах эффективный и быстрый код, написанный на C.
В данной главе подробно это обсудим и покажем, как интегрировать разделяемые
библиотеки C с некоторыми популярными языками программирования.
Мы рассмотрим следующие ключевые темы:
zz выясним, благодаря чему возможна интеграция как таковая. Это даст вам

общее представление о том, как интегрируются разные языки;
zz создадим библиотеку для работы со стеком на C и соберем ее в виде разделя-

емого объектного файла. Он будет использоваться рядом других языков программирования;
zz пройдемся по C++, Java, Python и Golang и рассмотрим загрузку и использова-

ние библиотеки для работы со стеком.
В целом следует отметить, что в данной главе мы будем работать с пятью разными
подпроектами, написанными на разных языках, поэтому в целях предотвращения
любых проблем с их сборкой и выполнением все действия будут выполняться
в Linux. Я предоставлю достаточно информации и для macOS, но основное внимание будет уделяться сборке и запуску исходников в Linux. В репозитории GitHub
для этой книги доступны дополнительные скрипты, которые помогут вам собрать
исходники в macOS.
В первом разделе речь пойдет об интеграции с другими языками как таковой.
Вы увидите, что делает ее возможной. Это заложит фундамент для дальнейшего
обсуждения в контексте других сред выполнения за пределами C.

656   Глава 21



Интеграция с другими языками

Что делает интеграцию возможной
Как уже объяснялось в главе 10, язык C кардинально изменил разработку операционных систем. Но, помимо этого, он также сделал возможным создание поверх него
других языков программирования общего назначения. В наши дни мы называем
их высокоуровневыми. В большинстве своем компиляторы этих языков написаны
на C, а остальные разработаны с помощью других инструментов и компиляторов,
которые тоже написаны на C.
Язык программирования общего назначения, неспособный применять или предоставлять возможности операционной системы, совершенно бесполезен. Вы можете
писать на нем, однако написанное нельзя будет выполнить ни в одной ОС. Такой
язык можно задействовать в теоретических целях, но в промышленном смысле он
ни на что не годится. Поэтому языки, и особенно их компиляторы, должны уметь
генерировать рабочие программы. Как вы уже знаете, возможности компьютера
предоставляются с помощью операционной системы. И, какой бы та ни была, они
должны быть доступны в языке, чтобы написанная на нем программа, которая выполняется в этой системе, могла использовать их.
И здесь на помощь приходит C. Возможности Unix-подобных операционных систем доступны в виде API, который предоставляет стандартная библиотека C. Если
компилятор хочет создать рабочую программу, то должен дать возможность скомпилированной программе работать со стандартной библиотекой C опосредованным
образом. Независимо от языка и наличия в нем скомпилированной стандартной
библиотеки, когда написанная на нем программа обращается к той или иной функции (такой как открытие файла), этот запрос должен быть передан стандартной
библиотеке C, откуда он может быть направлен на выполнение в ядро. Примером
этого служит язык Java, предоставляющий пакет Java Standard Edition (Java SE).
Раз уж мы заговорили о Java, программы, написанные на этом языке, компилируются в промежуточный код, который называется байт-кодом. Его выполнение требует установки среды выполнения Java (Java Runtime Environment, JRE).
В основе JRE лежит виртуальная машина (ВМ), которая загружает байт-код
и выполняет его внутри себя. Эта ВМ должна уметь имитировать возможности
и сервисы, реализованные в стандартной библиотеке C, и предоставлять их программам, которые выполняются внутри нее. Поскольку каждая платформа имеет
собственную реализацию стандартной библиотеки C и по-своему поддерживает
стандарты POSIX и SUS, для каждой операционной системы должна быть собрана
своя виртуальная машина.
В заключение отмечу: в качестве библиотек, которые загружаются в другие языки,
могут выступать только разделяемые объектные файлы. Статическую библиотеку
загрузить не получится, поскольку ее можно скомпоновать только с исполняемым
или разделяемым объектным файлом. В Unix-подобных системах разделяемые
объектные файлы имеют расширение .so, а в macOS — .dylib.

Получение необходимых материалов   657

В этом коротком разделе я попытался дать вам базовое понимание того, зачем
нужна возможность загружать библиотеки C (в частности, разделяемые библиотеки), и показать, как они используются в большинстве языков программирования,
поскольку многие из них поддерживают загрузку и применение разделяемых объектных файлов.
Дальше мы напишем библиотеку C и загрузим ее в разные языки программирования для дальнейшего использования. Именно этим мы вскоре займемся, но сначала
следует объяснить, как получить материалы, предусмотренные для данной главы,
и выполнить команды, показанные в терминалах.

Получение необходимых материалов
В данной главе полно ресурсов из разных языков программирования, и я хочу,
чтобы вы могли собрать и запустить все примеры. Поэтому я выделил данную главу
для перечисления некоторых основных моментов, которые следует учитывать при
сборке исходного кода.
Прежде всего вам нужно получить материалы, подготовленные для этой главы.
Как вам уже должно быть известно, у нее есть репозиторий, поделенный на главы.
Каталог этой главы называется ch21-integration-with-other-languages. В терминале 21.1
показаны команды, которые позволят вам клонировать данный репозиторий
и перейти в корень текущей главы.
Терминал 21.1. Клонирование репозитория GitHub этой книги и переход
в корневой каталог главы
$ git clone https://github.com/PacktPublishing/Extreme-C.git
...
$ cd Extreme-C/ch21-integration-with-other-languages
$

Что касается терминалов, представленных в этой главе, то мы исходим из того, что
команды выполняются в корневом каталоге ch21-integration-with-other-languages. Если
нам нужно перейти в другое место, то мы предоставим соответствующие команды,
но все происходит внутри каталога главы.
Кроме того, в целях сборки исходного кода на вашем компьютере должны быть
установлены Java Development Kit (JDK), Python и Golang. В зависимости от вашей операционной системы (Linux или macOS) и дистрибутива Linux, команды
установки могут отличаться.
В заключение отмечу: у исходного кода, написанного на других языках, должна
быть возможность использовать библиотеку для работы со стеком на C, которая
будет рассмотрена в следующем разделе. Она уже должна быть готова на момент

658   Глава 21



Интеграция с другими языками

сборки исходников. Поэтому, прежде чем идти дальше, обязательно прочитайте
следующий раздел и соберите описанный в нем разделяемый объектный файл.
Итак, вы узнали, как получить материалы данной главы. Теперь можно переходить
к обсуждению нашей библиотеки на языке C.

Библиотека для работы со стеком
В этом разделе мы разработаем небольшую библиотеку, которая будет загружаться
и использоваться программами, написанными на других языках помимо С. В основе
библиотеки лежит класс Stack, который предоставляет базовые операции для работы
с объектами в стеке, такие как push и pop. Данные объекты создаются и уничтожаются самой библиотекой; для этого предусмотрены конструктор и деструктор.
В листинге 21.1 вы можете видеть публичный интерфейс библиотеки, который
находится в заголовочном файле cstack.h.
Листинг 21.1. Публичный интерфейс библиотеки Stack (cstack.h)

#ifndef _CSTACK_H_
#define _CSTACK_H_
#include
#ifdef __cplusplus
extern "C" {
#endif
#define TRUE 1
#define FALSE 0
typedef int bool_t;
typedef struct {
char* data;
size_t len;
} value_t;
typedef struct cstack_type cstack_t;
typedef void (*deleter_t)(value_t* value);
value_t make_value(char* data, size_t len);
value_t copy_value(char* data, size_t len);
void free_value(value_t* value);
cstack_t* cstack_new();
void cstack_delete(cstack_t*);

Библиотека для работы со стеком   659
// Функции поведения
void cstack_ctor(cstack_t*, size_t);
void cstack_dtor(cstack_t*, deleter_t);
size_t cstack_size(const cstack_t*);
bool_t cstack_push(cstack_t*, value_t value);
bool_t cstack_pop(cstack_t*, value_t* value);
void cstack_clear(cstack_t*, deleter_t);
#ifdef __cplusplus
}
#endif
#endif

Как уже объяснялось в главе 6, эти объявления составляют публичный интерфейс класса Stack. Роль структуры атрибутов данного класса играет cstack_t.
Я выбрал это название, поскольку stack_t уже используется в стандартной библиотеке C, и я предпочитаю избегать неясности в данном коде. Для структуры
атрибутов применяется предварительное объявление, поэтому она не содержит
никаких полей. Все детали будут предоставлены в исходном файле, который послужит ее реализацией. У этого класса также есть конструктор, деструктор и ряд
операций, таких как push и pop. Все они принимают в качестве первого аргумента
указатель типа cstack_t, обозначающий объект, с которым работают. Этот подход к написанию класса Stack был рассмотрен в главе 6, в момент обсуждения
неявной инкапсуляции.
В листинге 21.2 показана реализация класса Stack. Она также содержит определение структуры атрибутов cstack_t.
Листинг 21.2. Определение класса Stack (cstack.c)

#include
#include
#include "cstack.h"
struct cstack_type {
size_t top;
size_t max_size;
value_t* values;
};
value_t copy_value(char* data, size_t len) {
char* buf = (char*)malloc(len * sizeof(char));
for (size_t i = 0; i < len; i++) {
buf[i] = data[i];

660  Глава 21



Интеграция с другими языками

}
return make_value(buf, len);
}
value_t make_value(char* data, size_t len) {
value_t value;
value.data = data;
value.len = len;
return value;
}
void free_value(value_t* value) {
if (value) {
if (value->data) {
free(value->data);
value->data = NULL;
}
}
}
cstack_t* cstack_new() {
return (cstack_t*)malloc(sizeof(cstack_t));
}
void cstack_delete(cstack_t* stack) {
free(stack);
}
void cstack_ctor(cstack_t* cstack, size_t max_size) {
cstack->top = 0;
cstack->max_size = max_size;
cstack->values = (value_t*)malloc(max_size * sizeof(value_t));
}
void cstack_dtor(cstack_t* cstack, deleter_t deleter) {
cstack_clear(cstack, deleter);
free(cstack->values);
}
size_t cstack_size(const cstack_t* cstack) {
return cstack->top;
}
bool_t cstack_push(cstack_t* cstack, value_t value) {
if (cstack->top < cstack->max_size) {
cstack->values[cstack->top++] = value;
return TRUE;
}
return FALSE;
}

Библиотека для работы со стеком  661
bool_t cstack_pop(cstack_t* cstack, value_t* value) {
if (cstack->top > 0) {
*value = cstack->values[--cstack->top];
return TRUE;
}
return FALSE;
}
void cstack_clear(cstack_t* cstack, deleter_t deleter) {
value_t value;
while (cstack_size(cstack) > 0) {
bool_t popped = cstack_pop(cstack, &value);
assert(popped);
if (deleter) {
deleter(&value);
}
}
}

В этом определении видно, что каждый объект стека основан на массиве; кроме
того, в стеке можно хранить любое значение. Соберем библиотеку и сгенерируем
из нее разделяемый объектный файл. В следующих разделах данный файл будет
загружаться другими языками программирования.
В терминале 21.2 показано, как из имеющихся исходных файлов создать разделя­
емую объектную библиотеку. Эти команды рассчитаны на Linux, и чтобы они работали в macOS, их необходимо немного изменить. Обратите внимание: они должны
выполняться в корневом каталоге главы, как уже объяснялось ранее.
Терминал 21.2. Сборка библиотеки для работы со стеком и создание разделяемого объектного
файла в Linux
$ gcc -c -g -fPIC cstack.c -o cstack.o
$ gcc -shared cstack.o -o libcstack.so
$

Стоит отметить: те же команды можно выполнить и в macOS, но для этого в системе
должна быть доступна команда gcc, которая ссылается на компилятор clang. В противном случае данную библиотеку в macOS можно собрать, используя команды,
показанные в терминале 21.3. Обратите внимание: при таком раскладе разделяемые
объектные файлы будут иметь расширение .dylib.
Терминал 21.3. Сборка библиотеки для работы со стеком и создание разделяемого объектного
файла в macOS
$ clang -c -g -fPIC cstack.c -o cstack.o
$ clang -dynamiclib cstack.o -o libcstack.dylib
$

662  Глава 21



Интеграция с другими языками

Итак, мы получили объектный файл разделяемой библиотеки. Теперь можно написать программы на других языках, которые будут его загружать. Прежде чем
демонстрировать загрузку и использование нашей библиотеки в других средах
выполнения, необходимо написать несколько тестов, чтобы проверить ее функцио­
нальность. Код, показанный в листинге 21.3, создает стек, выполняет некоторые
доступные операции и проверяет, соответствуют ли полученные результаты нашим
ожиданиям.
Листинг 21.3. Код, проверяющий функциональность класса Stack (cstack_tests.c)

#include
#include
#include
#include "cstack.h"
value_t make_int(int int_value) {
value_t value;
int* int_ptr = (int*)malloc(sizeof(int));
*int_ptr = int_value;
value.data = (char*)int_ptr;
value.len = sizeof(int);
return value;
}
int extract_int(value_t* value) {
return *((int*)value->data);
}
void deleter(value_t* value) {
if (value->data) {
free(value->data);
}
value->data = NULL;
}
int main(int argc, char** argv) {
cstack_t* cstack = cstack_new();
cstack_ctor(cstack, 100);
assert(cstack_size(cstack) == 0);
int int_values[] = {5, 10, 20, 30};
for (size_t i = 0; i < 4; i++) {
cstack_push(cstack, make_int(int_values[i]));
}
assert(cstack_size(cstack) == 4);

Библиотека для работы со стеком  663
int counter = 3;
value_t value;
while (cstack_size(cstack) > 0) {
bool_t popped = cstack_pop(cstack, &value);
assert(popped);
assert(extract_int(&value) == int_values[counter--]);
deleter(&value);
}
assert(counter == -1);
assert(cstack_size(cstack) == 0);
cstack_push(cstack, make_int(10));
cstack_push(cstack, make_int(20));
assert(cstack_size(cstack) == 2);
cstack_clear(cstack, deleter);
assert(cstack_size(cstack) == 0);
// Чтобы при вызове деструктора в стеке что-то было.
cstack_push(cstack, make_int(20));
cstack_dtor(cstack, deleter);
cstack_delete(cstack);
printf("All tests were OK.\n");
return 0;
}

Мы использовали утверждения для проверки возвращаемых значений. В терминале 21.4 показан вывод этого кода после его сборки и выполнения в Linux. Не забывайте, что мы находимся в корневом каталоге главы.
Терминал 21.4. Сборка и запуск тестов библиотеки
$ gcc -c -g cstack_tests.c -o tests.o
$ gcc tests.o -L$PWD -lcstack -o cstack_tests.out
$ LD_LIBRARY_PATH=$PWD ./cstack_tests.out
All tests were OK.
$

При запуске итогового исполняемого файла, cstack_tests.out, мы устанавливаем
переменную среды LD_LIBRARY_PATH, которая указывает на каталог с libcstack.so, поскольку программа должна найти и загрузить необходимые разделяемые библиотеки.
Как видно в этом терминале, все тесты были успешно пройдены. Это значит,
с точки зрения функциональности наша библиотека работает должным образом.
Было бы неплохо проверить нашу библиотеку еще и на предмет нефункциональных
требований, таких как расход ресурсов или отсутствие утечек памяти.

664  Глава 21



Интеграция с другими языками

В терминале 21.5 показано, как с помощью утилиты valgrind проверить выполняемые тесты на любые потенциальные утечки памяти.
Терминал 21.5. Выполнение тестов с использованием утилиты valgrind
$ LD_LIBRARY_PATH=$PWD valgrind --leak-check=full ./cstack_tests.
out
==31291== Memcheck, a memory error detector
==31291== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==31291== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==31291== Command: ./cstack_tests.out
==31291==
All tests were OK.
==31291==
==31291== HEAP SUMMARY:
==31291==
in use at exit: 0 bytes in 0 blocks
==31291==
total heap usage: 10 allocs, 10 frees, 2,676 bytes allocated
==31291==
==31291== All heap blocks were freed -- no leaks are possible
==31291==
==31291== For counts of detected and suppressed errors, rerun with: -v
==31291== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$

Как видите, у нас нет никаких утечек памяти. Это позволяет нам чувствовать
определенную уверенность по отношению к написанной нами библиотеке. Таким
образом, поиск первопричин любых проблем с памятью следует начинать со среды
выполнения, в которой загружена библиотека.
Тестирование на языке C будет рассмотрено в следующей главе. Вместо инструкции assert, которую вы видели в терминале 21.3, лучше использовать модульные
тесты и выполнять их с помощью фреймворка модульного тестирования наподобие
CMocka.
В следующих разделах мы интегрируем библиотеку для работы со стеком в программы, написанные на четырех языках. Начнем с C++.

Интеграция с C++
Интеграцию с C++ можно считать самой простой. C++ — своего рода объектно-ориентированное расширение C. Компиляторы этих языков генерируют похожие объектные файлы. Поэтому программе на C++ проще загружать разделяемые библиотеки C
по сравнению с другими языками. Иными словами, программа на C++ способна
в равной степени загружать объектные файлы, написанные на C и C++. Единственным проблемным моментом в некоторых случаях может быть декорирование имен
в C++, описанное в главе 2. В следующем подразделе я напомню, что это такое.

Интеграция с C++   665

Декорирование имен в C++
Если более подробно, то в языке C++ имена символов, относящихся к функциям
(как к глобальным, так и к методам классов), декорируются. В основном так поддерживаются возможности наподобие пространства имен и перегрузка функций,
которых нет в C. При сборке кода на языке C с использованием компилятора C++
данная функция включена по умолчанию, поэтому мы ожидаем увидеть декорированные имена символов. Взгляните на код в листинге 21.4.
Листинг 21.4. Простая функция на C (test.c)

int add(int a, int b) {
return a + b;
}

Если собрать этот файл с помощью компилятора C (в данном случае clang), то
в объектном файле можно будет увидеть символы, показанные в терминале 21.6.
Обратите внимание: файла test.c нет в репозитории GitHub данной книги.
Терминал 21.6. Сборка файла test.c с использованием компилятора C
$ clang -c test.c -o test.o
$ nm test.o
0000000000000000 T _add
$

Как видите, мы имеем символ с именем _add, который ссылается на функцию add,
определенную выше. Теперь соберем этот же файл с помощью компилятора C++
(в данном случае clang++) (терминал 21.7).
Терминал 21.7. Сборка файла test.c с помощью компилятора C++
$ clang++ -c test.c -o test.o
clang: warning: treating 'c' input as 'c++' when in C++ mode, this
behavior is deprecated [-Wdeprecated]
$ nm test.o
0000000000000000 T __Z3addii
$

Компилятор clang++ сгенерировал предупреждение о том, что в ближайшем будущем поддержка компиляции кода на C так, будто он написан на C++, исчезнет.
Но, поскольку данная возможность все еще есть (просто помечена как устаревшая),
мы видим, что имя символа, сгенерированного для представленной выше функции,
было декорировано и отличается от того, которое было получено при использовании clang. Это определенно может привести к проблемам на стадии компоновки,
когда будет проводиться поиск определенного символа.

666  Глава 21



Интеграция с другими языками

Чтобы устранить эту проблему, код на языке C необходимо завернуть в специальный блок, который не даст компилятору C++ декорировать имена символов.
В результате при компиляции с помощью clang и clang++ названия символов
будут совпадать. В листинге 21.5 показан код — модифицированная версия листинга 21.4.
Листинг 21.5. Определение функции в специальном блоке языка C (test.c)

#ifdef __cplusplus
extern "C" {
#endif
int add(int a, int b) {
return a + b;
}
#ifdef __cplusplus
}
#endif

Эта функция помещается в блок extern "C" { ... }, только если у нас уже определен
макрос __cplusplus. Наличие последнего — признак того, что код собирается компилятором C++. Снова скомпилируем этот код с помощью clang++ (терминал 21.8).
Терминал 21.8. Компиляция новой версии файла test.c с использованием clang++
$ clang++ -c test.c -o test.o
clang: warning: treating 'c' input as 'c++' when in C++ mode, this
behavior is deprecated [-Wdeprecated]
$ nm test.o
0000000000000000 T _add
$

В этот раз сгенерированный символ не декорирован. Что касается нашей библио­теки
для работы со стеком, то, учитывая все вышесказанное, все объявления необходимо
поместить вблок extern "C" { … }. Вот почему вы можете видеть данный блок в листинге 21.1. Таким образом, при компоновке программы на C++ с нашей библиотекой символы можно будет найти внутри libcstack.so (или libcstack.dylib).
extern "C" — это спецификация компоновки. Подробности можно найти
по следующим ссылкам:
yy https://isocpp.org/wiki/faq/mixing-c-and-cpp;
yy https://stackoverflow.com/questions/1041866/what-is-the-effect-ofextern-c-in-c.

Интеграция с C++   667

Теперь пришло время написать код на C++, который будет использовать нашу
библиотеку для работы со стеком. Как вы вскоре увидите, это простая инте­
грация.

Код на C++
Мы узнали, как отключить декорирование имен при использовании кода на языке C в проекте на C++. Теперь можем написать программу на C++, которая будет
использовать библиотеку для работы со стеком. Для начала обернем нашу биб­
лиотеку в класс, который является главным составным компонентом объектноориентированной программы на C++. Доступ к возможностям библиотеки лучше
предоставлять в объектно-ориентированной манере, вместо того чтобы вызывать
функции C напрямую.
В листинге 21.6 показан класс, в который заворачивается код библиотеки для
работы со стеком.
Листинг 21.6. Класс языка C++, в который завернута функциональность, предоставляемая
библиотекой для работы со стеком (c++/Stack.cpp)

#include
#include
#include
#include "cstack.h"
template
value_t CreateValue(const T& pValue);
template
T ExtractValue(const value_t& value);
template
class Stack {
public:
// Конструктор
Stack(int pMaxSize) {
mStack = cstack_new();
cstack_ctor(mStack, pMaxSize);
}
// Деструктор
~Stack() {
cstack_dtor(mStack, free_value);
cstack_delete(mStack);
}

668  Глава 21



Интеграция с другими языками

size_t Size() {
return cstack_size(mStack);
}
void Push(const T& pItem) {
value_t value = CreateValue(pItem);
if (!cstack_push(mStack, value)) {
throw "Stack is full!";
}
}
const T Pop() {
value_t value;
if (!cstack_pop(mStack, &value)) {
throw "Stack is empty!";
}
return ExtractValue(value);
}
void Clear() {
cstack_clear(mStack, free_value);
}
private:
cstack_t* mStack;
};

Данный класс имеет несколько важных аспектов, которые необходимо отметить.
zz Он содержит приватную переменную cstack_t — указатель на объект, создава­
емый функцией cstack_new, принадлежащей статической библиотеке. Это мож-

но считать ссылкой на объект, который существует на уровне языка C и создается/управляется отдельной библиотекой C. Указатель mStack аналогичен
файловому дескриптору (или ссылке), ссылающемуся на файл.
zz В класс завернуты все функции, предоставляемые библиотекой для работы

со стеком. Так делает не всякая обертка вокруг библиотеки C; обычно доступ
предоставляется к ограниченному набору функций.
zz Данный класс является шаблонным. То есть способен работать с различными

типами данных. Как видите, мы объявили две шаблонные функции для сериализации и десериализации объектов разных типов: CreateValue и ExtractValue.
С помощью этих функций класс превращает объект C++ в байтовый массив
(сериализация) и наоборот (десериализация).
zz Мы определяем специализированную шаблонную функцию для типа std::string.

Таким образом, мы можем с помощью нашего класса хранить значения данного
типа. Стоит отметить, что std::string — это стандартный тип языка C++, предназначенный для строковых значений.

Интеграция с C++  669
zz Наша библиотека позволяет размещать множество значений разных типов

в одном и том же экземпляре стека. Значение можно преобразовать в массив
символов и обратно. Взгляните на структуру value_t в листинге 21.1. Ей нужен
всего лишь указатель car. В отличие от библиотеки C этот класс, написанный
на C++, является типобезопасным, и каждый его экземпляр может работать
только с определенным типом данных.
zz В C++ каждый класс имеет как минимум один конструктор и один деструктор.

Таким образом, соответствующий объект стека будет удобно инициализировать
в конструкторе и финализировать в деструкторе.
Мы хотим, чтобы наш класс на C++ умел работать со строковыми значениями. Поэтому нам нужно написать подходящие функции сериализации и десериализации,
которые можно будет использовать в рамках данного класса. В листинге 21.7 содержатся определения функций, которые преобразуют массив символов языка C
в объект std::string и наоборот.
Листинг 21.7. Специализированные шаблонные функции, предназначенные
для сериализации/десериализации типа std::string. Они используются внутри класса
на языке C++ (c++/Stack.cpp)

template
value_t CreateValue(const std::string& pValue) {
value_t value;
value.len = pValue.size() + 1;
value.data = new char[value.len];
strcpy(value.data, pValue.c_str());
return value;
}
template
std::string ExtractValue(const value_t& value) {
return std::string(value.data, value.len);
}

Это специализация типа std::string для объявленной шаблонной функции, которая используется в классе. Она определяет, как объект std::string должен быть
преобразован в массив символов C и, наоборот, как массив символов C можно
превратить в объект std::string.
В листинге 21.8 показан метод main, который использует класс C++.
Листинг 21.8. Главная функция, использующая класс Stack из C++ (c++/Stack.cpp)

int main(int argc, char** argv) {
Stack stringStack(100);
stringStack.Push("Hello");
stringStack.Push("World");

670   Глава 21



Интеграция с другими языками

stringStack.Push("!");
std::cout