Компьютерные системы: архитектура и программирование [Рэндал Э. Брайант] (pdf) читать онлайн

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


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

Рэндал Э. Брайант
Дэвид Р. О'Халларон

Компьютерные системы:

архитектура и программирование
3-е издание

Computer Systems

A Programmer’s Perspective
Third edition

Randal E. Bryant

Carnegie Mellon University

David R. O’Hallaron
Carnegie Mellon University

Компьютерные системы:

архитектура и программирование
3-е издание

Рэндал Э. Брайант

университет Карнеги–Меллона

Дэвид Р. О'Халларон

университет Карнеги–Меллона

Москва, 2022

УДК 004.2
ББК 32.972
Б87

Б87 Рэндал Э. Брайант, Дэвид Р. О'Халларон
Компьютерные системы: архитектура и программирование. 3-е изд. /
пер. с англ. А. Н. Киселева. – М.: ДМК Пресс, 2022. – 994 с.: ил.
ISBN 978-5-97060-492-2
В книге описываются стандартные элементы архитектуры, такие как центральный процессор, память, порты ввода-вывода, а также операционная система, компилятор, компоновщик и сетевое окружение. Демонст­рируются способы
представления данных и программ на машинном уровне, приемы оптимизации
программ, особенности управления потоками выполнения и виртуальной памятью, а также методы сетевого и параллельного программирования. Приведенные в книге примеры для процессоров, совместимых с Intel (x86_64), написаны
на языке C и выполняются в операционной системе Linux.
Издание адресовано студентам и преподавателям по IT-специальностям,
а также будет полезно разработчикам, желающим повысить свой профес­
сиональный уровень и писать программы, эффективно использующие возможности компьютерной архитектуры.

Authorized translation from the English language edition, entitled Computer Systems:
A Programmer’s Perspective, 3rd Edition, by Randal E. Bryant and David R. O’Hallaron, published
by Pearson Education, Inc, publishing as Pearson, Copyright © 2016.
Все права защищены. Любая часть этой книги не может быть воспроизведена в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения
владельцев авторских прав.
Материал, изложенный в данной книге, многократно проверен. Но, поскольку вероятность технических ошибок все равно существует, издательство не может гарантировать
абсолютную точность и правильность приводимых сведений. В связи с этим издательство
не несет ответственности за возможные ошибки, связанные с использованием книги.

ISBN 978-0-13-409266-9 (англ.)
ISBN 978-5-97060-492-2 (рус.)

Copyright © 2016, 2011, and 2003 by
Randal E. Bryant and David R. O'Hallaron, 2021
© Оформление, перевод на русский язык,
издание, ДМК Пресс, 2022

Cтудентам и преподавателям курса 15-213
университета Карнеги–Меллона, вдохновившим
нас на переработку и уточнение этого издания

Оглавление
Предисловие от издательства...................................................................... 17
Вступление..................................................................................................... 18
Об авторах...................................................................................................... 34
Глава 1. Экскурс в компьютерные системы................................................ 36
1.1. Информация – это биты + контекст...........................................................38
1.2. Программы, которые переводятся другими программами
в различные формы....................................................................................39
1.3. Как происходит компиляция.....................................................................41
1.4. Процессоры читают и интерпретируют инструкции, хранящиеся
в памяти.......................................................................................................42
1.4.1. Аппаратная организация системы............................................................... 42
1.4.2. Выполнение программы hello...................................................................... 44
1.5. Различные виды кеш-памяти....................................................................46
1.6. Устройства памяти образуют иерархию....................................................47
1.7. Операционная система управляет работой аппаратных средств............48
1.7.1. Процессы........................................................................................................ 49
1.7.2. Потоки............................................................................................................. 50
1.7.3. Виртуальная память....................................................................................... 51
1.7.4. Файлы.............................................................................................................. 53
1.8. Обмен данными в сетях..............................................................................53
1.9. Важные темы...............................................................................................55
1.9.1. Закон Амдала................................................................................................. 56
1.9.2. Конкуренция и параллелизм........................................................................ 57
1.9.3. Важность абстракций в компьютерных системах....................................... 60
1.10. Итоги..........................................................................................................61
Библиографические заметки................................................................................. 61
Решения упражнений............................................................................................. 61
Часть I
Структура программы и ее выполнение.................................................... 63
Глава 2. Представление информации и работа с ней................................ 64
2.1. Хранение информации...............................................................................68
2.1.1. Шестнадцатеричная система счисления...................................................... 68
2.1.2. Размеры данных............................................................................................ 71
2.1.3. Адресация и порядок следования байтов.................................................... 74
2.1.4. Представление строк..................................................................................... 80
2.1.5. Представление программного кода............................................................. 81
2.1.6. Введение в булеву алгебру............................................................................ 82
2.1.7. Битовые операции в С................................................................................... 85
2.1.8. Логические операции в С.............................................................................. 87

Оглавление  7
2.1.9. Операции сдвига в С...................................................................................... 88
2.2. Целочисленные представления.................................................................90
2.2.1. Целочисленные типы.................................................................................... 91
2.2.2. Представление целых без знака................................................................... 92
2.2.3. Представление в дополнительном коде....................................................... 94
2.2.4. Преобразования между числами со знаком и без знака............................. 99
2.2.5. Числа со знаком и без знака в С.................................................................. 104
2.2.6. Расширение битового представления числа............................................. 106
2.2.7. Усечение чисел............................................................................................. 109
2.2.8. Советы по приемам работы с числами со знаком и без знака................. 111
2.3. Целочисленная арифметика....................................................................113
2.3.1. Сложение целых без знака.......................................................................... 113
2.3.2. Сложение целых в дополнительном коде.................................................. 118
2.3.3. Отрицание целых в дополнительном коде................................................ 123
2.3.4. Умножение целых без знака....................................................................... 124
2.3.5. Умножение целых в дополнительном коде............................................... 124
2.3.6. Умножение на константу............................................................................ 128
2.3.7. Деление на степень двойки......................................................................... 130
2.3.8. Заключительные размышления о целочисленной арифметике.............. 134
2.4. Числа с плавающей точкой.......................................................................135
2.4.1. Дробные двоичные числа............................................................................ 136
2.4.2. Представление значений с плавающей точкой в стандарте IEEE............ 139
2.4.3. Примеры чисел............................................................................................ 141
2.4.4. Округление................................................................................................... 146
2.4.5. Операции с плавающей точкой.................................................................. 148
2.4.6. Значения с плавающей точкой в С............................................................. 150
2.5. Итоги..........................................................................................................151
Библиографические заметки............................................................................... 152
Домашние задания................................................................................................ 153
Правила представления целых чисел на битовом уровне................................. 154
Правила представления чисел с плавающей точкой на битовом уровне......... 165
Решения упражнений........................................................................................... 167

Глава 3. Представление программ на машинном уровне........................184
3.1. Историческая перспектива......................................................................187
3.2. Программный код.....................................................................................190
3.2.1. Машинный код............................................................................................. 190
3.2.2. Примеры кода.............................................................................................. 192
3.2.3. Замечание по форматированию................................................................. 195
3.3. Форматы данных.......................................................................................197
3.4. Доступ к информации..............................................................................198
3.4.1. Спецификаторы операндов........................................................................ 200
3.4.2. Инструкции перемещения данных............................................................ 201
3.4.3. Примеры перемещения данных................................................................. 205
3.4.4. Вталкивание данных в стек и выталкивание из стека.............................. 208
3.5. Арифметические и логические операции...............................................209

8

 Оглавление
3.5.1. Загрузка эффективного адреса................................................................... 210
3.5.2. Унарные и бинарные операции.................................................................. 212
3.5.3. Операции сдвига.......................................................................................... 212
3.5.4. Обсуждение.................................................................................................. 213
3.5.5. Специальные арифметические операции................................................. 215

3.6. Управление................................................................................................218
3.6.1. Флаги условий.............................................................................................. 218
3.6.2. Доступ к флагам........................................................................................... 219
3.6.3. Инструкции перехода.................................................................................. 222
3.6.4. Кодирование инструкций перехода........................................................... 223
3.6.5. Реализация условного ветвления потока управления.............................. 225
3.6.6. Реализация условного ветвления потока данных..................................... 229
3.6.7. Циклы............................................................................................................ 235
3.6.8. Оператор switch........................................................................................... 245
3.7. Процедуры.................................................................................................250
3.7.1. Стек времени выполнения.......................................................................... 251
3.7.2. Передача управления................................................................................... 252
3.7.3. Передача данных.......................................................................................... 256
3.7.4. Локальные переменные на стеке................................................................ 258
3.7.5. Локальные переменные в регистрах.......................................................... 260
3.7.6. Рекурсивные процедуры............................................................................. 262
3.8. Распределение памяти под массивы и доступ к массивам....................264
3.8.1. Базовые принципы...................................................................................... 264
3.8.2. Арифметика указателей.............................................................................. 266
3.8.3. Вложенные массивы.................................................................................... 267
3.8.4. Массивы фиксированных размеров........................................................... 268
3.8.5. Массивы переменных размеров................................................................. 271
3.9. Структуры разнородных данных.............................................................273
3.9.1. Структуры..................................................................................................... 273
3.9.2. Объединения................................................................................................ 276
3.9.3. Выравнивание.............................................................................................. 279
3.10. Комбинирование инструкций управления потоком выполнения
и передачи данных в машинном коде.....................................................282
3.10.1. Указатели.................................................................................................... 283
3.10.2. Жизнь в реальном мире: использование отладчика GDB...................... 284
3.10.3. Ссылки на ячейки за границами выделенной памяти
и переполнение буфера............................................................................... 286
3.10.4. Предотвращение атак методом переполнения буфера.......................... 290
3.10.5. Поддержка кадров стека переменного размера...................................... 295

3.11. Вычисления с плавающей точкой..........................................................298
3.11.1. Операции перемещения и преобразования данных.............................. 300
3.11.2. Операции с плавающей точкой в процедурах......................................... 305
3.11.3. Арифметические операции с плавающей точкой................................... 305
3.11.4. Определение и использование констант с плавающей точкой.............. 307
3.11.5. Поразрядные логические операции с числами с плавающей точкой.... 308
3.11.6. Операции сравнения значений с плавающей точкой............................. 309

Оглавление  9
3.11.7. Заключительные замечания об операциях с плавающей точкой........... 312
3.12. Итоги........................................................................................................312
Библиографические заметки............................................................................... 313
Домашние задания................................................................................................ 314
Решения упражнений........................................................................................... 325

Глава 4. Архитектура процессора...............................................................349
4.1. Архитектура системы команд Y86-64......................................................352
4.1.1. Состояние, видимое программисту........................................................... 352
4.1.2. Инструкции Y86-64...................................................................................... 353
4.1.3. Кодирование инструкций........................................................................... 355
4.1.4. Исключения в архитектуре Y86-64............................................................. 360
4.1.5. Программы из инструкций Y86-64............................................................. 361
4.1.6. Дополнительные сведения об инструкциях Y86-64.................................. 366
4.2. Логическое проектирование и язык HCL................................................368
4.2.1. Логические вентили.................................................................................... 368
4.2.2. Комбинационные цепи и булевы выражения в HCL................................. 369
4.2.3. Комбинационные цепи для слов и целочисленные выражения в HCL.... 371
4.2.4. Принадлежность множеству....................................................................... 375
4.2.5. Память и синхронизация............................................................................ 375
4.3. Последовательные реализации Y86-64 (SEQ).........................................378
4.3.1. Организация обработки в несколько этапов............................................. 378
4.3.2. Аппаратная реализация последовательной архитектуры SEQ................ 387
4.3.3. Синхронизация в последовательной реализации SEQ............................. 391
4.3.4. Реализация этапов в последовательной версии SEQ................................ 394
4.4. Общие принципы конвейерной обработки............................................402
4.4.1. Вычислительные конвейеры....................................................................... 402
4.4.2. Подробное описание работы конвейера.................................................... 404
4.4.3. Ограничения конвейерной обработки....................................................... 406
4.4.4. Конвейерная обработка с обратной связью............................................... 408
4.5. Конвейерные реализации Y86-64............................................................409
4.5.1. SEQ+: переупорядочение этапов обработки.............................................. 409
4.5.2. Добавление конвейерных регистров.......................................................... 411
4.5.3. Переупорядочение сигналов и изменение их маркировки...................... 415
4.5.4. Прогнозирование следующего значения PC............................................. 416
4.5.5. Риски конвейерной обработки................................................................... 418
4.5.6. Обработка исключений............................................................................... 431
4.5.7. Реализация этапов в PIPE............................................................................ 434
4.5.8. Управляющая логика конвейера................................................................. 441
4.5.9. Анализ производительности...................................................................... 451
4.5.10. Незаконченная работа............................................................................... 454
4.6. Итоги..........................................................................................................457
4.6.1. Имитаторы Y86-64....................................................................................... 458
Библиографические заметки............................................................................... 458
Домашние задания................................................................................................ 459
Решения упражнений........................................................................................... 465

10

 Оглавление

Глава 5. Оптимизация производительности программ...........................478
5.1. Возможности и ограничения оптимизирующих компиляторов...........481
5.2. Выражение производительности программы........................................484
5.3. Пример программы..................................................................................486
5.4. Устранение неэффективностей в циклах................................................490
5.5. Сокращение вызовов процедур...............................................................493
5.6. Устранение избыточных ссылок на память............................................495
5.7. Общее описание современных процессоров..........................................498
5.7.1. Общие принципы функционирования....................................................... 498
5.7.2. Производительность функционального блока.......................................... 502
5.7.3. Абстрактная модель работы процессора.................................................... 504
5.8. Развертывание циклов.............................................................................510
5.9. Увеличение степени параллелизма.........................................................514
5.9.1. Несколько аккумуляторов........................................................................... 515
5.9.2. Переупорядочение операций.........................................................520
5.10. Обобщение результатов оптимизации комбинирующего кода..........524
5.11. Некоторые ограничивающие факторы.................................................525
5.11.1. Вытеснение регистров............................................................................... 525
5.11.2. Прогнозирование ветвлений и штрафы за ошибки предсказания........ 526
5.12. Понятие производительности памяти..................................................530
5.12.1. Производительность операций загрузки................................................. 530
5.12.2. Производительность операций сохранения............................................ 531
5.13. Жизнь в реальном мире: методы повышения производительности.. 537
5.14. Выявление и устранение узких мест производительности.................538
5.14.1. Профилирование программ...................................................................... 538
5.14.2. Использование профилировщика при выборе кода для оптимизации.540
5.15. Итоги........................................................................................................544
Библиографические заметки............................................................................... 545
Домашние задания................................................................................................ 545
Решения упражнений........................................................................................... 548
Глава 6. Иерархия памяти...........................................................................553
6.1. Технологии хранения информации.........................................................554
6.1.1. Память с произвольным доступом............................................................. 554
6.1.2. Диски............................................................................................................ 562
6.1.3. Твердотельные диски.................................................................................. 572
6.1.4 Тенденции развития технологий хранения................................................ 574
6.2. Локальность...............................................................................................577
6.2.1. Локальность обращений к данным программы........................................ 577
6.2.2. Локальность выборки инструкций............................................................. 579
6.2.3. В заключение о локальности....................................................................... 579
6.3. Иерархия памяти......................................................................................581
6.3.1. Кеширование в иерархии памяти.............................................................. 582
6.3.2. В заключение об иерархии памяти............................................................ 585
6.4. Кеш-память...............................................................................................586

Оглавление  11
6.4.1. Обобщенная организация кеш-памяти .................................................... 586
6.4.2. Кеш с прямым отображением..................................................................... 588
6.4.3. Ассоциативные кеши................................................................................... 595
6.4.4. Полностью ассоциативные кеши................................................................ 597
6.4.5. Проблемы с операциями записи................................................................ 600
6.4.6. Устройство реальной иерархии кешей....................................................... 601
6.4.7. Влияние параметров кеша на производительность.................................. 602

6.5. Разработка программ, эффективно использующих кеш.......................603
6.6. Все вместе: влияние кеша на производительность программ..............608
6.6.1. Гора памяти.................................................................................................. 608
6.6.2. Переупорядочение циклов для улучшения пространственной
локальности.................................................................................................. 612
6.6.3. Использование локальности в программах............................................... 615

6.7. Итоги..........................................................................................................616
Библиографические заметки............................................................................... 616
Домашние задания................................................................................................ 617
Решения упражнений........................................................................................... 627
Часть II
Выполнение программ в системе..............................................................633
Глава 7. Связывание.....................................................................................634
7.1. Драйверы компиляторов..........................................................................636
7.2. Статическое связывание...........................................................................637
7.3. Объектные файлы.....................................................................................638
7.4. Перемещаемые объектные файлы...........................................................638
7.5. Идентификаторы и таблицы имен...........................................................640
7.6. Разрешение ссылок...................................................................................643
7.6.1. Как компоновщик разрешает ссылки на повторяющиеся имена............ 644
7.6.2. Связывание со статическими библиотеками............................................. 648
7.6.3. Как компоновщики разрешают ссылки на статические библиотеки....... 651
7.7. Перемещение.............................................................................................652
7.7.1. Записи перемещения................................................................................... 653
7.7.2. Перемещение ссылок................................................................................... 654
7.8. Выполняемые объектные файлы.............................................................657
7.9. Загрузка выполняемых объектных файлов.............................................659
7.10. Динамическое связывание с разделяемыми библиотеками................660
7.11. Загрузка и связывание с разделяемыми библиотеками
из приложений..........................................................................................662
7.12. Перемещаемый программный код........................................................665
7.13. Подмена библиотечных функций..........................................................668
7.13.1. Подмена во время компиляции................................................................ 669
7.13.2. Подмена во время компоновки................................................................ 670
7.13.3. Подмена во время выполнения................................................................ 671
7.14. Инструменты управления объектными файлами.................................673
7.15. Итоги........................................................................................................673

12

 Оглавление
Библиографические заметки............................................................................... 674
Домашние задания................................................................................................ 674
Решения упражнений........................................................................................... 677

Глава 8. Управление исключениями..........................................................680
8.1. Исключения...............................................................................................682
8.1.1. Обработка исключений............................................................................... 683
8.1.2. Классы исключений..................................................................................... 685
8.1.3. Исключения в системах Linux/x86-64........................................................ 687
8.2. Процессы...................................................................................................690
8.2.1. Логический поток управления................................................................... 691
8.2.2. Конкурентные потоки управления............................................................. 692
8.2.3. Изолированное адресное пространство.................................................... 693
8.2.4. Пользовательский и привилегированный режимы.................................. 693
8.2.5. Переключение контекста............................................................................ 694
8.3. Системные вызовы и обработка ошибок................................................695
8.4. Управление процессами...........................................................................696
8.4.1. Получение идентификатора процесса....................................................... 697
8.4.2. Создание и завершение процессов............................................................ 697
8.4.3. Утилизация дочерних процессов................................................................ 701
8.4.4. Приостановка процессов............................................................................. 706
8.4.5. Загрузка и запуск программ....................................................................... 707
8.4.6. Запуск программ с помощью функций fork и execve................................ 709
8.5. Сигналы.....................................................................................................712
8.5.1. Терминология сигналов.............................................................................. 714
8.5.2. Посылка сигналов........................................................................................ 715
8.5.3. Получение сигналов.................................................................................... 717
8.5.4. Блокировка и разблокировка сигналов...................................................... 720
8.5.5. Обработка сигналов..................................................................................... 721
8.5.6. Синхронизация потоков во избежание неприятных ошибок
конкурентного выполнения........................................................................ 730
8.5.7. Явное ожидание сигналов........................................................................... 732

8.6. Нелокальные переходы............................................................................735
8.7. Инструменты управления процессами....................................................739
8.8. Итоги..........................................................................................................739
Библиографические заметки............................................................................... 740
Домашние задания................................................................................................ 740
Решения упражнений........................................................................................... 747
Глава 9. Виртуальная память......................................................................750
9.1. Физическая и виртуальная адресация....................................................752
9.2. Пространства адресов...............................................................................753
9.3. Виртуальная память как средство кеширования....................................754
9.3.1. Организация кеша DRAM............................................................................ 754
9.3.2. Таблицы страниц......................................................................................... 755
9.3.3. Попадание в кеш DRAM.............................................................................. 756
9.3.4. Промах кеша DRAM..................................................................................... 757

Оглавление  13
9.3.5. Размещение страниц................................................................................... 758
9.3.6. И снова о локальности................................................................................. 759

9.4. Виртуальная память как средство управления памятью.......................759
9.5. Виртуальная память как средство защиты памяти................................761
9.6. Преобразование адресов..........................................................................762
9.6.1. Интегрирование кешей и виртуальной памяти........................................ 765
9.6.2. Ускорение трансляции адресов с помощью TLB . ..................................... 766
9.6.3. Многоуровневые таблицы страниц............................................................ 767
9.6.4. Все вместе: сквозное преобразование адресов......................................... 769
9.7. Практический пример: система памяти Intel Core i7/Linux...................773
9.7.1. Преобразование адресов в Core i7.............................................................. 774
9.7.2. Система виртуальной памяти Linux........................................................... 776
9.8. Отображение в память.............................................................................780
9.8.1. И снова о разделяемых объектах................................................................ 781
9.8.2. И снова о функции fork................................................................................ 783
9.8.3. И снова о функции execve........................................................................... 783
9.8.4. Отображение в память на уровне пользователя с помощью
функции mmap............................................................................................. 785

9.9. Динамическое распределение памяти....................................................786
9.9.1. Функции malloc и free.................................................................................. 787
9.9.2. Что дает динамическое распределение памяти........................................ 790
9.9.3. Цели механизмов распределения памяти и требования к ним............... 791
9.9.4. Фрагментация.............................................................................................. 792
9.9.5. Вопросы реализации................................................................................... 793
9.9.6. Неявные списки свободных блоков ........................................................... 794
9.9.7. Размещение распределенных блоков......................................................... 796
9.9.8. Разбиение свободных блоков..................................................................... 796
9.9.9. Увеличение объема динамической памяти............................................... 797
9.9.10. Объединение свободных блоков............................................................... 797
9.9.11. Объединение с использованием граничных тегов.................................. 798
9.9.12. Все вместе: реализация простого механизма распределения
памяти........................................................................................................... 800
9.9.13. Явные списки свободных блоков.............................................................. 807
9.9.14. Раздельные списки свободных блоков..................................................... 808

9.10. Сборка мусора.........................................................................................811
9.10.1. Основы сборки мусора.............................................................................. 811
9.10.2. Алгоритм сборки мусора Mark&Sweep..................................................... 813
9.10.3. Консервативный алгоритм Mark&Sweep для программ на C................. 814
9.11. Часто встречающиеся ошибки...............................................................815
9.11.1. Разыменование недопустимых указателей............................................. 815
9.11.2. Чтение неинициализированной области памяти................................... 816
9.11.3. Переполнение буфера на стеке................................................................. 816
9.11.4. Предположение о равенстве размеров указателей и объектов,
на которые они указывают.......................................................................... 816
9.11.5. Ошибки занижения или завышения на единицу.................................... 817
9.11.6. Ссылка на указатель вместо объекта........................................................ 817

14

 Оглавление
9.11.7. Неправильное понимание арифметики указателей............................... 818
9.11.8. Ссылки на несуществующие переменные............................................... 818
9.11.9. Ссылка на данные в свободных блоках.................................................... 818
9.11.10. Утечки памяти.......................................................................................... 819

9.12. Итоги........................................................................................................819
Библиографические заметки............................................................................... 820
Домашние задания................................................................................................ 821
Решения упражнений........................................................................................... 824
Часть III
Взаимодействие программ.........................................................................829
Глава 10. Системный уровень ввода/вывода.............................................830
10.1. Ввод/вывод в Unix...................................................................................831
10.2. Файлы.......................................................................................................832
10.3. Открытие и закрытие файлов................................................................833
10.4. Чтение и запись файлов.........................................................................835
10.5. Надежные чтение и запись с помощью пакета RIO..............................836
10.5.1. Функции RIO небуферизованного ввода/вывода.................................... 837
10.5.2. Функции RIO буферизованного ввода..................................................... 838
10.6. Чтение метаданных файла.....................................................................842
10.7. Чтение содержимого каталога................................................................843
10.8. Совместное использование файлов.......................................................845
10.9. Переадресация ввода/вывода................................................................848
10.10. Стандартный ввод/вывод.....................................................................849
10.11. Все вместе: какие функции ввода/вывода использовать?.................849
10.12. Итоги......................................................................................................851
Библиографические заметки............................................................................... 852
Домашние задания................................................................................................ 852
Решения упражнений........................................................................................... 853
Глава 11. Сетевое программирование........................................................854
11.1. Программная модель клиент–сервер....................................................854
11.2. Компьютерные сети................................................................................855
11.3. Всемирная сеть интернет.......................................................................860
11.3.1. IP-адреса..................................................................................................... 861
11.3.2. Доменные имена интернета..................................................................... 863
11.3.3. Интернет-соединения............................................................................... 866
11.4. Интерфейс сокетов.................................................................................867
11.4.1. Структуры адресов сокетов....................................................................... 868
11.4.2. Функция socket........................................................................................... 869
11.4.3. Функция connect........................................................................................ 869
11.4.4. Функция bind.............................................................................................. 870
11.4.5. Функция listen............................................................................................ 870
11.4.6. Функция accept........................................................................................... 870
11.4.7. Преобразование имен хостов и служб...................................................... 872
11.4.8. Вспомогательные функции для интерфейса сокетов............................. 876

Оглавление  15
11.4.9. Примеры эхо-клиента и эхо-сервера....................................................... 879
11.5. Веб-серверы.............................................................................................881
11.5.1. Основные сведения о вебе........................................................................ 881
11.5.2. Веб-контент................................................................................................ 882
11.5.3. Транзакции HTTP...................................................................................... 884
11.5.4. Обслуживание динамического контента................................................. 886
11.6. Все вместе: разработка небольшого веб-сервера TINY........................889
11.7. Итоги........................................................................................................896
Библиографические заметки............................................................................... 896
Домашние задания................................................................................................ 897
Решения упражнений........................................................................................... 898

Глава 12. Конкурентное программирование.............................................901
12.1. Конкурентное программирование с процессами.................................903
12.1.1. Конкурентный сервер, основанный на процессах.................................. 904
12.1.2. Достоинства и недостатки подхода на основе процессов...................... 905
12.2. Конкурентное программирование с мультиплексированием
ввода/вывода.............................................................................................906
12.2.1. Конкурентный на основе мультиплексирования ввода/вывода,
управляемый событиями............................................................................ 909
12.2.2. Достоинства и недостатки мультиплексирования ввода/вывода.......... 913

12.3. Конкурентное программирование с потоками выполнения...............914
12.3.1. Модель выполнения многопоточных программ..................................... 914
12.3.2. Потоки Posix............................................................................................... 915
12.3.3. Создание потоков...................................................................................... 916
12.3.4. Завершение потоков.................................................................................. 916
12.3.5. Утилизация завершившихся потоков...................................................... 917
12.3.6. Обособление потоков................................................................................ 917
12.3.7. Инициализация потоков........................................................................... 918
12.3.8. Конкурентный многопоточный сервер.................................................... 918
12.4. Совместное использование переменных несколькими потоками
выполнения...............................................................................................920
12.4.1. Модель памяти потоков............................................................................ 921
12.4.2. Особенности хранения переменных в памяти........................................ 921
12.4.3. Совместно используемые переменные.................................................... 922
12.5. Синхронизация потоков выполнения с помощью семафоров............922
12.5.1. Граф выполнения....................................................................................... 925
12.5.2. Семафоры................................................................................................... 928
12.5.3. Использование семафоров для исключительного доступа
к ресурсам..................................................................................................... 929
12.5.4. Использование семафоров для организации совместного
доступа к ресурсам....................................................................................... 930
12.5.5. Все вместе: конкурентный сервер на базе предварительно
созданных потоков...................................................................................... 935

12.6. Использование потоков выполнения для организации
параллельной обработки..........................................................................938
12.7. Другие вопросы конкурентного выполнения.......................................944

16

 Оглавление
12.7.1. Безопасность в многопоточном окружении............................................ 944
12.7.2. Реентерабельность..................................................................................... 946
12.7.3. Использование библиотечных функций в многопоточных
программах................................................................................................... 947
12.7.4. Состояние гонки......................................................................................... 948
12.7.5. Взаимоблокировка (тупиковые ситуации)............................................... 950

12.8. Итоги........................................................................................................953
Библиографические заметки............................................................................... 953
Домашние задания................................................................................................ 954
Решения упражнений........................................................................................... 958
Приложение А. Обработка ошибок............................................................963
A.1. Обработка ошибок в системе Unix..........................................................963
A.2. Функции-обертки обработки ошибок.....................................................965
Библиография..............................................................................................968
Предметный указатель................................................................................975

Предисловие от издательства
Отзывы и пожелания
Мы всегда рады отзывам наших читателей. Расскажите нам, что вы думаете об этой
книге – что понравилось или, может быть, не понравилось. Отзывы важны для нас, чтобы выпускать книги, которые будут для вас максимально полезны.
Вы можете написать отзыв на нашем сайте www.dmkpress.com, зайдя на страницу книги и оставив комментарий в разделе «Отзывы и рецензии». Также можно послать письмо главному редактору по адресу dmkpress@gmail.com;
при этом укажите название книги в теме письма.
Если вы являетесь экспертом в какой-либо области и заинтересованы в написании
новой книги, заполните форму на нашем сайте по адресу http://dmkpress.com/authors/publish_
book/ или напишите в издательство по адресу dmkpress@gmail.com.

Список опечаток
Хотя мы приняли все возможные меры для того, чтобы обеспечить высокое качество
наших текстов, ошибки все равно случаются. Если вы найдете ошибку в одной из наших
книг – возможно, ошибку в основном тексте или программном коде, – мы будем очень
благодарны, если вы сообщите нам о ней. Сделав это, вы избавите других читателей от
недопонимания и поможете нам улучшить последующие издания этой книги.
Если вы найдете какие-либо ошибки в коде, пожалуйста, сообщите о них главному
редактору по адресу dmkpress@gmail.com, и мы исправим это в следую­щих тиражах.

Нарушение авторских прав
Пиратство в интернете по-прежнему остается насущной проблемой. Издательства
«ДМК Пресс» и Pearson очень серьезно относятся к вопро­сам защиты авторских прав
и лицензирования. Если вы столкнетесь в интернете с незаконной публикацией какой-либо из наших книг, пожалуйста, пришлите нам ссылку на интернет-ресурс, чтобы
мы могли применить санкции.
Ссылку на подозрительные материалы можно прислать по адресу элект­ронной почты dmkpress@gmail.com.
Мы высоко ценим любую помощь по защите наших авторов, благодаря которой мы
можем предоставлять вам качественные материалы.

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

Что нужно знать перед прочтением
Эта книга посвящена системам с аппаратной архитектурой x86-64, являющиеся последним этапом на пути развития, который прошли Intel и ее конкуренты, начинавшие
с микропроцессора 8086 в 1978 году. В соответст­вии с соглашениями об именовании,
принятыми в Intel в отношении их линейки микропроцессоров, этот класс микропроцессоров в просторечии называется «x86». По мере развития полупроводниковых
технологий, позволяющих размещать на одном кристалле все больше и больше транзисторов, производительность и объем внутренней памяти процессоров значительно
увеличились. В ходе этого прогресса они перешли от 16-разрядных слов к 32-разрядным и выпустили процессор IA32, а совсем недавно произошел переход к 64-разрядным
словам и появилась архитектура x86-64.
Мы рассмотрим, как машины с этой архитектурой выполняют программы на языке C в
Linux. Linux – одна из операционных систем, ведущих свою родо­словную от операцион­
ной системы Unix, первоначально разработанной в Bell Laboratories. К другим членам
этого класса операционных систем относятся Solaris, FreeBSD и MacOS X. В последние
годы эти операционные системы сохраняли высокий уровень совместимости благодаря
усилиям по стандартизации POSIX и Standard Unix Specification. То есть сведения, что
приводятся в этой книге, почти напрямую применимы ко всем этим «Unix-подобным»
операционным системам.

Вступление  19

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

В тексте содержится множество примеров программного кода, которые мы компилировали и опробовали в системах Linux. Мы предполагаем, что у вас есть доступ к
такой системе, что вы можете входить в нее и уме­ете выполнять простые действия,
такие как получение списка файлов или переход в другой каталог. Если ваш компьютер
работает под управлением Microsoft Windows, то мы рекомендуем установить одну из
множества виртуальных машин (например, VirtualBox или VMWare), которые позволяют программам, написанным для одной операционной системы (гостевой ОС), запус­
каться в другой (несущей ОС, или хост-ОС).
Также предполагается, что читатель знаком с С или C++. Если весь опыт программиста
ограничивается работой с Java, переход потребует от него больше усилий, но авторы
окажут всю необходимую помощь. Java и С имеют общий синтаксис и управляющие
операторы. Однако в С есть свои особенности (в частности, указатели, явное распределение динамической памяти и форматируемый ввод/вывод), которых нет в Java. К счас­
тью, С – не очень сложный язык, он прекрасноописан в классической книге Брайана
Кернигана и Денниса Ритчи [61]. Вне зависимости от «подкованности» читателя в области программирования, эта книга послужит ценным дополнением к его библиотеке.
Если прежде вы использовали только интерпретируемые языки, такие как Python, Ruby
или Perl, то вам определенно стоит посвятить некоторое время изучению C, прежде чем
продолжить читать эту книгу.
В начальных главах книги рассматривается взаимодействие между программами на
С и их аналогами на машинном языке. Все примеры на машинном языке были созданы
с помощью компилятора GNU GCC на процессоре x86-64. Наличия какого бы то ни было
опыта работы с аппаратными средствами, машинными языками или программирования в ассемблере не предполагается.

Как читать книгу
Изучение принципов работы компьютерных систем с точки зрения программиста –
занятие очень увлекательное, потому что проходит в интерактивном режиме. Изучив
что-то новое, вы тут же можете это проверить и получить результат, что называется, из
первых рук. На самом деле авторы полагают, что единственным способом познания систем является их практическое исследование: либо путем решения конкретных упражнений, либо написанием и выполнением программ в реально существующих системах.
Система является предметом изучения всей книги. При представлении какой-либо
новой концепции в тексте она будет сопровождаться иллюстрацией в форме одной или
нескольких практических задач, которые нужно сразу же постараться решить, чтобы
проверить правильность понимания изложенного. Решения упражнений приводятся в
конце каждой главы. Пытайтесь сначала самостоятельно решать эти практические задачи и только потом проверяйте правильность выбранного пути. В конце каждой главы
также представлены домашние задания различной степени сложнос­ти. Каж­дому домашнему заданию присвоен определенный уровень сложности:
 для решения достаточно нескольких минут; требуется минимальный объем
программирования (или не требуется вообще);


для решения потребуется до 20 минут. Часто нужно написать и опробовать
программный код. Многие такие задания созданы на основе задач, приведенных в примерах;

20

 Вступление
 для решения потребуется приложить значительные усилия; по времени на
решение может уйти до 2 часов. Как правило, для выполнения этих заданий
требуется написать и опробовать значительный объем кода;
 лабораторная работа, на выполнение которой может уйти до 10 часов.

Все примеры кода в тексте книги отформатированы автоматически (без всякого
ручного вмешательства) и получены из программ на С, скомпилированных с помощью
GCC и протестированных в Linux. Конечно, в вашей системе может быть установлена
другая версия gcc или вообще другой компилятор, генерирующий другой машинный
код, но общее поведение примеров должно быть таким же. Весь исходный код доступен
на веб-странице книги csapp.cs.cmu.edu. Имена файлов с исходным кодом документируются с использованием горизонтальных полос, окружающих отформатированный
код. Например, программу из листинга 1 можно найти в файле hello.c в каталоге code/
intro/. Мы советуем обязательно опробовать примеры программ у себя по мере их появления.
Листинг 1. Типичный пример кода

code/intro/hello.c
1
2
3
4
5
6
7

#include
int main()
{
printf("hello, world\n");
return 0;
}

code/intro/hello.c
Чтобы не раздувать объем и без того большой книги, мы создали несколько приложений, размещенных в интернете, которые содержат сведения, дополняющие основную книгу. Они отмечены в книге заголовками в форме «Приложение в интернете
ТЕМА:ПОДТЕМА», где ТЕМА кратко описывает основную тему, а ПОДТЕМА – раздел
темы. Например, «Приложение в интернете DATA:BOOL» содержит сведения по булевой алгебре, дополняющие описание представлений данных в главе 2, а «Приложение
в интернете ARCH:VLOG» содержит описание приемов проектирования процессоров с
использованием языка описания оборудования Verilog, дополняющее обсуждение конструкции процессора в главе 4. Все эти приложения доступны на веб-странице книги
(https://csapp.cs.cmu.edu/3e/waside.html).

Обзор книги
Книга состоит из 12 глав, охватывающих основные принципы компьютерных систем:
Глава 1. Экскурс в компьютерные системы.
В этой главе описываются основные идеи и темы, относящиеся к компьютерным системам, на примере исследования жизненного цикла простой программы «hello, world».
Глава 2. Представление информации и работа с ней.
Здесь описывается компьютерная арифметика с упором на свойства представлений числа без знака и числа в дополнительном двоичном коде, которые
имеют значение для программистов. В данной главе рассматривается представление чисел и, следовательно, диапазон значений, которые можно запрограммировать для отдельно взятого размера слова. Авторы обсуждают влияние

Вступление  21
преобразований типов чисел со знаком и без знака и математические свойства
арифметических операций. Для начинающих программистов часто оказывается откровением, что сложение (в дополнительном коде) или умножение двух
положительных чисел может дать отрицательный результат. С другой стороны, арифметика дополнительного кода удовлетворяет многим алгебраическим
свойствам целочисленной арифметики, благодаря чему компилятор может
преобразовать операцию умножения на константу в последовательность сдвигов и сложений. Для иллюстрации принципов и применения булевой алгебры
авторы используют поразрядные операции С. Формат IEEE с плавающей точкой
описывается в терминах представления значений и математических свойств
операций с плаваю­щей точкой.
Абсолютное понимание компьютерной арифметики принципиально для создания надежных программ. Например, программисты и компиляторы не могут
заменить выражение (x < y) на (x – y < 0) из-за возможности переполнения.
Они не могут даже заменить его выражением (-y < -x) из-за асимметричности диапазона отрицательных и положительных чисел в дополнительном коде.
Арифметическое переполнение является обычным источником ошибок программирования и уязвимостей в системе безопасности, однако мало в какой
книге можно найти описание свойств компьютерной арифметики, сделанное с
точки зрения самого программиста.
О чем рассказывается во врезках?
На протяжении всей книги вам будут встречаться подобные примечания. В них приводятся некоторые дополнительные сведения к обсуждаемой теме. Примечания преследуют несколько целей. Одни представляют собой небольшие исторические экскурсы.
Например, как появились С, Linux и Internet? Другие разъясняют какие-либо понятия.
Например, чем отличаются кеш, множество и блок? Третьи примечания описывают примеры «из жизни». Например, как ошибка в вычислениях с плавающей точкой уничтожила
французскую ракету или какие геометрические и функ­циональные параметры имеют
коммерческие жесткие диски. И наконец, некоторые примечания – это всего лишь забавные комментарии.

Глава 3. Представление программ на машинном уровне.
Авторы научат читать машинный код x86-64, созданный компилятором С. Здесь
представлены основные шаблоны инструкций для различных управляющих
структур, таких как условные операторы, циклы и операторы выбора. Также
рассматривается реализация процедур, включая выделение места на стеке, условные обозначения использования реестров и передачу параметров. В главе
рассматриваются различные структуры данных, например структуры, объединения и массивы, их размещение в памяти и доступ к ним. Здесь еще будет показано, как выглядят программы с точки зрения машины, что поможет понять
распространенные уязвимости, такие как переполнение буфера, и шаги, которые программист, компилятор и операционная система могут предпринять для
уменьшения этих угроз. Изучение данной главы поможет повысить профессио­
нальный уровень, потому что при этом появится понимание, как компьютер
воспринимает программы.
Глава 4. Архитектура процессора.
В этой главе описываются комбинаторные и последовательные логические
элементы, после чего демонстрируется, как эти элементы можно объединить в

22

 Вступление
информационный канал, выполняющий упрощенный набор инструкций x86-64
с названием «Y86-64». Сначала будет рассматриваться однотактный тракт данных. Его архитектура проста, но не отличается высоким быстродействием. Затем будет представлено понятие конвейерной обработки, в которой различные
шаги, необходимые для обработки инструкции, реализуются как отдельные этапы. В каждый конкретный момент этапы конвейера могут обрабатывать разные
инст­рукции. Получившийся в результате пятиступенчатый процессорный конвейер намного более реалистичен. Управляющая логика процессора описывается с использованием простого языка описания аппаратных средств – HCL. Проекты аппаратного обеспечения, написанные на HCL, можно компилировать и
объединять в симуляторы, а затем использовать для создания описания Verilog,
пригодного для производства реального оборудования.
Глава 5. Оптимизация производительности программ.
В этой главе представлен ряд методов повышения производительности кода,
при этом идея состоит в том, что программисты учатся писать код на C так, чтобы компилятор мог затем сгенерировать эффективный машинный код. Сначала
рассматриваются преобразования, сокращающие объем работы, которую предстоит выполнить программе, и, следовательно, которые должны стать стандартной практикой при написании любых программ для любых машин. Затем мы
перейдем к преобразованиям, повышающим степень параллелизма на уровне
команд в сгенерированном машинном коде, чтобы повысить их производительность на современных «суперскалярных» процессорах. Для обоснования
этих преобразований будет представлена простая модель работы современных
процессоров и показано, как измерить потенциальную производительность
программы с точки зрения критических путей с использованием графического представления программы. Вы будете удивлены, насколько можно ускорить
программу, применив простые преобразования к коду на C.
Глава 6. Иерархия памяти.
Память является для программистов одной из самых «заметных» частей компьютерной системы. До этой главы читатели полагались на концептуальную
модель памяти в форме одномерного массива с постоянным временем доступа.
На практике память представляет собой иерархию запоминающих устройств
разной емкости, стоимости и быстродействия. В главе рассматриваются разные типы памяти, такие как ОЗУ и ПЗУ, а также геометрические параметры и
устройство существующих современных дисковых накопителей, организация
этих запоминающих устройств в иерархию. Авторы показывают возможность
иерархической организации посредством локальности ссылок. Данные идеи
конкретизируются представлением уникального взгляда на систему памяти как
на «гору памяти» со «скалами» временной локальности и «склонами» пространственной локальности. В заключение рассказывается, как повысить производительность программных приложений путем усовершенствования их временной
и пространственной локальности.
Глава 7. Связывание.
В данной главе описывается статическое и динамическое связывание, включая такие понятия, как: перемещаемые и выполняемые объектные файлы,
разрешение символов, перемещение, статические библиотеки, разделяемые
библиотеки, перемещаемый код и подмена библиотечных функций (library
interpositioning). Тема связывания редко рассматривается в книгах по компьютерным системам, но авторы решили включить ее в эту книгу по двум причи-

Вступление  23
нам. Во-первых, некоторые типичные ошибки, с которыми сталкиваются программисты, как раз возникают на этапе связывания и особенно характерны для
крупных программных пакетов. Во-вторых, объектные файлы, создаваемые
компоновщиками, связаны с такими понятиями, как загрузка, виртуальная память и отображение памяти.
Глава 8. Управление исключениями.
В этой главе авторы отступают от однопрограммной модели, вводя общую
концепцию потока управления исключениями (не совпадающего с обычным
потоком управления путем ветвления в условных операторах и в точках вызова процедур). Мы рассмотрим примеры потоков управления исключениями,
сущест­вующих на всех уровнях системы, от аппаратных исключений и прерываний низкого уровня до переключения контекста между параллельными процессами, внезапных изменений в потоке управления, вызванных передачей сигналов Linux, и нелокальных переходов в С, разрывающих стройную структуру
стека.
В этой части книги будет представлено фундаментальное понятие процесса как
абстракции выполняющейся программы. Здесь авторы расскажут, как работают
процессы, как их создавать и как ими можно управлять из прикладных программ, и покажут, как прикладные программисты могут запустить несколько
процессов с помощью системных вызовов Linux. По окончании этой главы вы
сможете написать простую командную оболочку для Linux с поддержкой управления заданиями. Эта глава также станет первым знакомством с недетерминированным поведением, возникающим при параллельных вычислениях.
Глава 9. Виртуальная память.
Описывает представление системы виртуальной памяти с целью дать некоторое
понимание ее особенностей и принципов работы. Здесь вы узнаете, как разные
процессы, действующие одновременно, могут использовать один и тот же диапазон адресов, совместно использовать одни страницы памяти и иметь индивидуальные копии других. В этой главе также описываются вопросы, связанные
с управлением виртуальной памятью и манипуляциями с ней. В частности, мы
уделим большое внимание инструментам распределения памяти из стандартной библио­теки, таким как malloc и free. Обсуждение данной темы преследует несколько целей. Прежде всего оно подкрепляет концепцию о том, что пространство виртуальной памяти является всего лишь массивом байтов, который
программа может разделить на блоки разного размера для хранения данных.
Помогает понять последствия ошибок обращения к памяти в программах, такие как утечки и недействительные ссылки в указателях. Наконец, многие программисты реализуют свои инструменты распределения памяти, оптимизированные под требования и характеристики конкретного приложения. Эта глава
в большей степени, чем любая другая, демонстрирует преимущества неразрывного освещения аппаратных и программных аспектов компьютерных систем.
Книги о традиционных компьютерных архитектурах и операционных системах
обычно представляют виртуальную память только с одной стороны.
Глава 10. Системный уровень ввода/вывода.
В этой главе рассматриваются основные концепции ввода/вывода в системе
Unix, такие как файлы и дескрипторы. Авторы описывают совместное использование файлов, принципы работы переадресации ввода/вывода и доступ к
метаданным файлов. Здесь также представлен пример разработки надежного
пакета буферизованного ввода/вывода, прекрасно справляющегося с любопыт-

24

 Вступление
ным поведением подсистемы ввода/вывода, известным как недостача, когда
библиотечные функции возвращают только часть ввода. В главе описывается
стандартная библио­тека ввода/вывода языка C и ее связь с подсистемой ввода/
вывода в Linux с упором на ограничения стандартного ввода/вывода, делающие
его непригодным для сетевого программирования. Вообще говоря, темы, охваченные в этой главе, служат основой для двух следующих глав, посвященных
сетевому и параллельному программированию.
Глава 11. Сетевое программирование.
Сети являются своеобразными устройствами ввода/вывода для программ, объединяющими многие из понятий, описанных ранее: процессы, сигналы, порядок следования байтов, отображение памяти и динамическое распределение
пространства запоминающих устройств. Сетевые программы также являются
одними из первых кандидатов на применение приемов параллельного программирования, о котором рассказывается в следующей главе. Данная глава – лишь
тонкий срез глобального предмета сетевого программирования, необходимый
для создания прос­тенького веб-сервера. Здесь будет представлена модель клиент–сервер, лежащая в основе всех сетевых приложений. Авторы представят
взгляд программиста на сеть Интернет и покажут, как писать сетевые клиенты
и серверы, используя интерфейс сокетов. И наконец, в главе будет представлен
протокол HTTP и разработан простой веб-сервер.
Глава 12. Конкурентное программирование.
Эта глава описывает принципы конкурентного (параллельного) программирования на примере сетевого сервера. Авторы сравнивают и противопоставляют
три основных механизма, используемых для создания конкурентных программ:
процессы, мультиплексирование ввода/вывода и потоки выполнения – и показывают возможность их использования при создании серверов, способных
обслуживать множество одновременных соединений. Здесь же описаны основные принципы синхронизации с использованием семафорных операций Р и V,
безопас­ность потоков выполнения и реентерабельность, а также состояние взаимоблокировки. Конкурентное программирование играет важную роль в большинстве сетевых приложений. Также в этой главе описывается конкурентное
программирование на уровне потоков выполнения, что позволяет ускорить
решение задач на многоядерных процессорах. Чтобы все ядра правильно и эффективно работали над одной вычислительной задачей, требуется тщательная
координация потоков, выполняющихся конкурентно.

Что нового в этом издании
Первое издание этой книги было опубликовано в 2003 году, а второе – в 2011. Учитывая быстрое развитие компьютерных технологий, содержимое книги на удивление
хорошо сохранило свою актуальность. Принципиальное устройство компьютеров
Intel x86, на которых выполняются программы на C под управлением Linux (и других
похожих операционных систем), мало изменилось за эти годы. Однако изменения в
аппаратных технологиях, компиляторах, интерфейсах программных библиотек и накопленный опыт преподавания потребовали существенного пересмотра материала
книги.
Самым большим изменением по сравнению со вторым изданием является переход с
представления, основанного на сочетании IA32 и x86-64, к представлению, основанному исключительно на x86-64. Это смещение акцента повлия­ло на содержимое многих
глав. Вот краткое перечисление основных значительных изменений.

Вступление  25
Глава 1. Экскурс в компьютерные системы.
Мы переместили обсуждение закона Амдала из главы 5 в эту главу.
Глава 2. Представление информации и работа с ней.
В многочисленных отзывах читатели и рецензенты сообщают, что некоторые
сведения в этой главе сложны для понимания. Поэтому мы постарались упростить форму подачи материала, поясняя моменты, которые обсуждаются в строгом математической стиле. Это позволит читателям сначала просмотреть математические детали, чтобы получить общее представление, а затем вернуться к
подробному описанию.
Глава 3. Представление программ на машинном уровне.
Мы перешли от представления, основанного на комбинации IA32 и x86-64, к
представлению только на основе x86-64. Мы также обновили примеры кода,
гене­рируемые более свежими версиями gcc. Как результат глава претерпела
сущест­венные изменения, включая изменение порядка, в котором представлены некоторые концепции. Мы также добавили описание аппаратной поддержки
вычислений с плавающей точкой. А для сохранения совместимости добавили
приложение в интернете, описывающее машинный код для IA32.
Глава 4. Архитектура процессора.
Мы обновили описание архитектуры процессора, выполнив переход с 32-разрядной архитектуры на архитектуру с поддержкой 64-разрядных слов и операций.
Глава 5. Оптимизация производительности программ.
Мы обновили эту главу, отразив возможности последних поколений процессоров x86-64 в плане производительности. С введением большего количества
функциональных модулей и более сложной логики управления разработанная
нами модель производительности программ, основанная на представлении
программ в форме потока данных, стала предсказывать производительность
еще надежнее, чем раньше.
Глава 6. Иерархия памяти.
Мы обновили эту главу с учетом последних технологий.
Глава 7. Связывание.
Мы переписали эту главу, перейдя на архитектуру x86-64, расширили обсуждение использования глобальной таблицы смещений GOT и таб­лицы компоновки процедур PLT для создания перемещаемого кода и добавили новый раздел
о мощном методе связывания, известном как library interpositioning (подмена
библиотечных функций).
Глава 8. Управление исключениями.
Мы добавили более строгое описание обработчиков сигналов, включив функции, которые можно безопасно вызывать при обработке асинхронных сигналов,
специальные рекомендации по написанию обработчиков сигналов и использование sigsuspend для приостановки обработчиков.
Глава 9. Виртуальная память.
Эта глава изменилась незначительно.

26

 Вступление
Глава 10. Системный уровень ввода/вывода.
Мы добавили новый раздел о файлах и иерархии файлов, но в остальном эта
глава изменилась незначительно.
Глава 11. Сетевое программирование.
Мы представили новые методы протоколонезависимого и потоко­безопасного сетевого программирования с использованием современных функций getaddrinfo
и getnameinfo, пришедших на смену устаревшим и нереентерабельным функциям
gethostbyname и gethostbyaddr.
Глава 12. Параллельное программирование.
Мы расширили обсуждение параллельного программирования, добавив потоки
выполнения, использование которых позволяет программам быстрее решать
свои задачи на многоядерных машинах.

Также мы добавили и пересмотрели множество практических и домашних заданий
по всей книге.

Происхождение книги
Книга родилась из вводного курса, разработанного в университете Карнеги–Меллона (УКМ) осенью 1998 года и получившего название «15-213: Введение в компьютерные
системы». С тех пор курс читается в каждом семестре, и в каждом семестре его слушают
более 400 студентов, от второкурсников до аспирантов, самых разных специальностей.
Данный курс стал основой для большинства других курсов по компьютерным системам
в университете Карнеги–Меллона.
Идея этого курса заключалась в том, чтобы познакомить студентов с компьютерами, взглянув на них с другой стороны. Мало кто из студентов смог бы самостоятельно
построить компьютерную систему. С другой стороны, от большинства обучающихся и
даже инженеров по вычислительной технике требуется повседневное использование
компьютеров и умение программировать. Поэтому авторы данной книги решили начать знакомство с системами с точки зрения программиста и при выборе тем использовали следующий своеобразный фильтр: тема будет освещаться только в том случае,
если она связана с производительностью, корректностью или с полезными свойствами
пользовательских программ на C.
К примеру, исключены темы, связанные с аппаратными сумматорами и конструкцией шин. В курсе также имелись темы, посвященные машинному языку, но вместо
подробного рассмотрения языка ассемблера мы предпочли сконцентрироваться на
том, как компилятор транслирует конструкции языка С в машинный код, включая операции с указателями, циклы, вызовы процедур и возврат из них. Кроме того, мы решили более широко взглянуть на систему как на комплекс аппаратных и программных
средств и включили в книгу такие темы, как связывание, загрузка, процессы, сигналы,
опти­мизация производительности, виртуальная память, ввод/вывод, а также сетевое и
параллельное программирование.
Данный подход позволил сделать курс практичным, конкретным, наглядным и на
редкость интересным для студентов. Ответная реакция с их стороны и со стороны коллег по факультету была незамедлительной и положительной, и авторы книги поняли,
что преподаватели из других учебных заведений тоже смогут воспользоваться их наработками. Это и стало предпосылкой появления данной книги, написанной лекционным
конспектом курса и которую мы теперь обновили, чтобы отразить изменения в технологиях и подходах к реали­зации систем.
Благодаря выходу новых изданий и переводам этой книги на разные языки она и
многие ее варианты стали частью учебных программ по информатике и компьютерной
инженерии в сотнях колледжей и университетов по всему миру.

Вступление  27

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

Курс «Устройство компьютеров» с традиционными темами, раскрытыми в нетрадиционном стиле. Охватываются такие традиционные темы, как логическая модель, архитектура процессора, язык ассемблера и системы памяти. При
этом больше внимания должно уделяться программной стороне. Например,
обсуждение данных должно быть связано с типами данных и операциями в
программах на C, а обсуждение ассемблерного кода основываться не на рукописном машинном коде, а на сгенерированном компилятором C.

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

Базовый курс «Введение в компьютерные системы», разработанный для подготовленных программистов, которые понимают влияние оборудования, операционной системы и системы компиляции на производительность и правильность прикладных программ. Сущест­венное отличие от УК+ – отсутствие
охвата низкоуровневой архитектуры процессора. Вместо этого программисты
учатся работать с высокоуровневой моделью современного процессора. Курс
ВКС хорошо вписывается в 10-недельную четверть, но при необходимости может быть продлен до 15-недельного семестра, если проходить его в неторопливом темпе.

ВКС+. Базовый курс «Введение в компьютерные системы» с дополнительным охватом таких тем системного программирования, как ввод/вывод системного
уровня, сетевое и параллельное программирование. В университете Карнеги–
Меллона это семестровый курс, охватывающий все главы данной книги, кроме
низкоуровневой архитектуры процессора.
СП. Курс «Системное программирование». Этот курс похож на ВКС+, но в нем не
преподается оптимизация операций с плавающей запятой и производительности, а также уделяется больше внимания системному программированию,
включая управление процессами, динамическое связывание, ввод/вывод на
уровне системы, сетевое программирование и параллельное программирование. Преподаватели могут добавить информацию из других источников для
обсуждения дополнительных тем, таких как демоны, управление терминалом
и межпроцессные взаимодействия в Unix.
Как показывает табл. 1, эта книга дает студентам и преподавателям массу возможностей. Если вы хотите, чтобы ваши ученики познакомились с низкоуровневой архитектурой процессоров, то этот вариант доступен в курсах УК и УК+. С другой стороны, если вы хотите переключиться с текущего курса «Устройство компьютеров» на
курс ВКС или ВКС+, но опасаетесь вносить радикальные изменения сразу, то можете организовать постепенный переход к ВКС. Вы можете начать с курса УК, который
преподносит традиционные темы нетрадиционным способом, а освоившись с этим
материалом – переходить к УК+ и в конечном итоге к ВКС. Если студенты не имеют
опыта программирования на C (например, они программировали только на Java), то

28

 Вступление

вы можете потратить несколько недель на чтение лекций о C, а затем переходить к
чтению курса УК или ВКС.
Таблица 1. Пять категорий курсов о компьютерных системах, основанных на книге «Компьютерные системы: архитектура и программирование». Курс ВКС+ – это курс 15-213 в университете Карнеги–Меллона.
Примечание: символ  означает частичный охват главы, как то: (1) только аппаратная часть; (2) без динамического распределения памяти; (3) без динамического связывания; (4) без представления данных с
плавающей точкой
Глава

Тема

1

Экскурс в компьютерные системы

2

Представление данных

3

Машинный язык

4

Архитектура процессора

5

Оптимизация кода

6

Иерархия памяти

7

Связывание

8

Управление исключениями

9

Виртуальная память

10

Ввод/вывод на уровне системы

11

Сетевое программирование

12

Параллельное программирование

Курс
УК

УК+

ВКС

ВКС+

СП















(4)


(1)








(2)





(3)





(3)






(1)







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

Преподавателям: примеры лабораторных работ в классе
Курс ВКС+ в университете Карнеги–Меллона получил очень высокие оценки от студентов. Медианный балл 5,0/5,0 и средний балл 4,6/5,0 являются типичными оценками
студентов курса. Основными достоинствами студенты называют забавные, увлекательные и актуальные лабораторные работы, которые доступны на веб-странице книги. Вот
примеры лабораторных работ, которые поставляются с книгой.
Представление данных.
Эта лабораторная работа требует от студентов реализовать простые логические
и арифметические функции с использованием строго ограниченного подмножества языка C. Например, они должны вычислить абсолютное значение числа,
используя только битовые операции. Эта лабо­раторная работа помогает студентам понять представление типов данных в языке C на двоичном уровне и особенности побитовых операций.
Двоичные бомбы.
Двоичная бомба – это программа, которая передается студентам в виде скомпилированного файла. При запуске она предлагает пользователю ввести шесть
разных строк. Если при вводе будет допущена ошибка, то бомба «взрывается» – выводит сообщение об ошибке и регистрирует событие на сервере оценки.
Студенты должны «обезвредить» свои уникальные бомбы, дизассемблировав

Вступление  29
программы и определив, как должны выглядеть эти шесть строк. Лабораторная
работа учит студентов понимать язык ассемблера, а также заставляет их научиться пользоваться отладчиком.
Переполнение буфера.
Студенты должны изменить поведение двоичного выполняемого файла, используя уязвимость переполнения буфера. Эта лабораторная работа учит студентов осторожности обращения со стеком и показывает опасности кода, уязвимого для атак переполнения буфера.
Архитектура.
Некоторые домашние задания из главы 4 можно объединить в лабораторную работу, где студенты изменяют HCL-описание процессора, добавляя новые инструкции, изменяя политику прогнозирования ветвле­ний или добавляя и удаляя обходные пути и регистрируя порты. Полученные процессоры можно моделировать
и запускать с помощью авто­матических тестов, которые обнаруживают большинство возможных ошибок. Эта лабораторная работа позволяет студентам познакомиться с захватывающими аспектами проектирования процессоров, не требуя
полного знания языков логического проектирования и описания оборудования.
Производительность.
Студенты должны оптимизировать производительность основных функ­ций
приложения, таких как свертка или транспонирование матриц. Эта лабораторная работа наглядно демонстрирует свойства кеш-памяти и дает студентам
опыт низкоуровневой оптимизации программ.
Кеш.
Эта лабораторная работа является альтернативой лабораторной работе «Производительность». В ней студенты должны написать симулятор кеша общего
назначения, а затем оптимизировать базовые функции программы транспонирования матрицы так, чтобы минимизировать коли­чество промахов кеша. При
этом мы используем инструмент Valgrind для трассировки реальных адресов.
Командная оболочка.
Студенты создают свою программу командной оболочки Unix с поддержкой
управления заданиями, включая комбинации клавиш Ctrl+C и Ctrl+Z, а также команды fg, bg и jobs. В этой лабораторной работе учащие­ся впервые знакомятся с параллельным программированием и получают четкое представление об
управлении процессами в Unix, сигналах и их обработке.
Распределение памяти.
Студенты реализуют свои версии malloc, free и (необязательно) realloc. Эта лабораторная работа помогает студентам получить четкое представление о структуре и организации данных и требует от них оценки различных компромиссов
между эффективностью потребления памяти и времени выполнения.
Прокси.
Студенты реализуют параллельный веб-прокси, находящийся между брау­зером
и остальной частью Всемирной паутины. Эта лабораторная работа знакомит студентов с такими темами, как веб-клиенты и серверы, и связывает воедино многие
концепции курса, такие как порядок следования байтов, файловый ввод/вывод,
управление процессами, сигналы, обработка сигналов, отображение памяти, сокеты и параллельное выполнение. Студентам нравится видеть, как работают их
программы, обслуживающие реальные веб-браузеры и веб-серверы.

30

 Вступление

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

Благодарности к третьему изданию
Нам приятно поблагодарить всех, кто помог в создании этого третьего издания книги «Компьютерные системы: архитектура и программирование».
Мы хотели бы сказать спасибо нашим коллегам из университета Карнеги–Меллона,
которые преподавали курс ВКС на протяжении многих лет и представили так много
ценных отзывов: Гая Блеллока (Guy Blelloch), Роджера Данненберга (Roger Dannenberg),
Дэвида Экхардта (David Eckhardt), Франца Франчетти (Franz Franchetti), Грега Гангера
(Greg Ganger), Сета Гольдштейна (Seth Goldstein), Халеда Харраса (Khaled Harras), Грега
Кесдена (Greg Kesden), Брюса Мэггса (Bruce Maggs), Тодда Моури (Todd Mowry), Андреаса
Новацика (Andreas Nowatzyk), Фрэнка Пфеннинга (Frank Pfenning), Маркуса Пуэшеля
(Markus Pueschel) и Энтони Роу (Anthony Rowe). Дэвид Винтерс (David Winters) очень
помог в установке и настройке эталонного Linux-сервера.
Джейсон Фриттс (Jason Fritts; университет Сент-Луиса) и Синди Норрис (Cindy Norris;
штат Аппалачи) предоставили нам подробные и вдумчивые рецензии ко второму изданию. Или Гонг (Yili Gong; Уханьский университет) перевел книгу на китайский язык,
обеспечил поддержку страницы книги с исправ­лениями для китайской версии и предоставил множество замечаний об ошибках. Годмар Бэк (Godmar Back; Технологический
институт Вирджинии) помог значительно улучшить книгу, познакомив нас с понятиями безопаснос­ти при обработке асинхронных сигналов и протоколонезависимого сетевого программирования.
Большое спасибо нашим внимательным читателям, сообщившим об ошибках во втором издании: Рами Аммари (Rami Ammari), Полу Анагностопулосу (Paul Anagnostopoulos),
Лукасу Бааренфангеру (Lucas Bärenfänger), Годмару Бэку (Godmar Back), Джи Бину (Ji
Bin), Шарбелу Боусеману (Sharbel Bousemaan), Ричарду Каллахану (Richard Callahan),
Сету Чайкену (Seth Chaiken), Ченг Чену (Cheng Chen), Либо Чену (Libo Chen), Тао Ду (Tao
Du), Паскалю Гарсия (Pascal Garcia), Или Гонгу (Yili Gong), Рональду Гринбергу (Ronald
Greenberg), Дорухану Гулозу (Dorukhan Gülöz), Донгу Хану (Dong Han), Доминику Хельму
(Dominik Helm), Рональду Джонсу (Ronald Jones), Мустафе Каздагли (Mustafa Kazdagli),
Гордону Киндлманну (Gordon Kindlmann), Санкару Кришнану (Sankar Krishnan), Канаку
Кшетри (Kanak Kshetri), Джунлин Лу (Junlin Lu), Цянцян Луо (Qiangqiang Luo), Себастьяну Луи (Sebastian Luy), Лей Ма (Lei Ma), Эшвину Нанджаппа (Ashwin Nanjappa), Грегуару
Паради (Gregoire Paradis), Йонасу Пфеннингеру (Jonas Pfenninger), Карлу Пичотта (Karl
Pichotta), Дэвиду Рэмси (David Ramsey), Каустабху Рою (Kaustabh Roy), Дэвиду Селвараджу (David Selvaraj), Санкару Шанмугаму (Sankar Shanmugam), Доминику Смулковска
(Dominique Smulkowska), Дагу Сорбо (Dag Sørbø), Майклу Спиру (Michael Spear), Ю Танака (Yu Tanaka), Стивену Трикановичу (Steven Tricanowicz), Скотту Райту (Scott Wright),
Вайки Райту (Waiki Wright), Чженшень Яну (Zhengshan Yan), Хану Сю (Han Xu), Фиро Янь
(Firo Yang), Шуанг Янь (Shuang Yang), Джону Е (John Ye), Такето Ёсида (Taketo Yoshida),
Яну Чжу (Yan Zhu) и Майклу Зинку (Michael Zink).
Также благодарим наших читателей, которые внесли свой вклад в создание лабораторных работ, в том числе Годмара Бэка (Godmar Back; Технологический институт
Вирджинии), Таймона Била (Taymon Beal; Вустерский политехнический институт),
Арана Клаусона (Aran Clauson; университет Западного Вашингтона), Кэри Грея (Cary
Gray; колледж Уитона), Пола Хайдака (Paul Haiduk; аграрно-технический университет
Западного Техаса), Лена Хейми (Len Hamey; университет Маккуори), Эдди Келера (Eddie
Kohler; Гарвард), Хью Лауэра (Hugh Lauer; Вустерский политехнический институт),
Роберта Марморштейне (Robert Marmorstein; университет Лонгвуда) и Джеймса Рили
(James Riely; университет Де Поля).

Вступление  31
И снова Пол Анагностопулос (Paul Anagnostopoulos) из Windfall Software мас­терски
справился с набором книги и руководил производственным процессом. Большое спасибо Полу и его звездной команде: Ричарду Кэмпу (Richard Camp; корректура), Дженнифер
МакКлейн (Jennifer McClain, корректура), Лорел Мюллер (Laurel Muller; художест­венное
оформление) и Теду Ло (Ted Laux; составление предметного указателя). Пол даже заметил ошибку в нашем описании происхождения аббревиатуры BSS, которая оставалась
незамеченной с момента выхода первого издания!
Наконец, мы хотим поблагодарить наших друзей из Prentice Hall. Марсию Хортон
(Marcia Horton) и нашего редактор Мэтта Гольдштейна (Matt Goldstein), которые неутомимо поддерживали и ободряли нас, и мы глубоко благодарны им за это.

Благодарности ко второму изданию
Мы глубоко признательны всем, кто так или иначе помогал нам писать это второе
издание книги «Компьютерные системы: архитектура и программирование».
В первую очередь мы благодарим наших коллег, преподававших курс ВКС в университете Карнеги–Меллона, за их бесценные отзывы и поддержку: Гая Блеллока (Guy Blelloch),
Роджера Данненберга (Roger Dannenberg), Дэвида Экхардта (David Eckhardt), Грега Гангера (Greg Ganger), Сета Гольдштейна (Seth Goldstein), Грега Кесдена (Greg Kesden), Брюса
Мэггса (Bruce Maggs), Тодда Моури (Todd Mowry), Андреаса Новацика (Andreas Nowatzyk),
Фрэнка Пфеннинга (Frank Pfenning) и Маркуса Пуэшеля (Markus Pueschel).
Также благодарим наших остроглазых читателей, которые сообщили об обнаруженных ими ошибках и опечатках в первом издании: Дэниела Амеланга (Daniel Amelang),
Руи Баптиста (Rui Baptista), Куарупа Баррейринхаса (Quarup Barreirinhas), Майкла Бомбика (Michael Bombyk), Йорга Брауэра (Jörg Brauer), Джордана Бро (Jordan Brough), Исин
Цяо (Yixin Cao), Джеймса Кэролла (James Caroll), Руи Карвальо (Rui Carvalho), Хонг-Ки
Чоя (Hyoung-Kee Choi), Эла Дэвиса (Al Davis), Гранта Дэвиса (Grant Davis), Кристиана
Дюфур (Christian Dufour), Мао Фан (Mao Fan), Тима Фримана (Tim Freeman), Инге Фрика
(Inge Frick), Макса Гебхардта (Max Gebhardt), Джеффа Голдблата (Jeff Goldblat), Томаса Гросса (Thomas Gross), Анитe Гупта (Anita Gupta), Джона Хемптона (John Hampton),
Хип Хонга (Hiep Hong), Грега Исраэльсена (Greg Israelsen), Рональда Джонса (Ronald
Jones), Хауди Каземи (Haudy Kazemi), Брайана Келла (Brian Kell), Константина Кусулиса (Constantine Kousoulis), Сашу Краковяк (Sacha Krakowiak), Аруна Кришнасвами
(Arun Krishnaswamy), Мартина Куласа (Martin Kulas), Майкла Ли (Michael Li), Зеянь Ли
(Zeyang Li), Рики Лю (Ricky Liu), Марио Ло Конте (Mario Lo Conte), Дирка Мааса (Dirk
Maas), Девона Мейси (Devon Macey), Карла Марциника (Carl Marcinik), Уилла Марреро
(Will Marrero), Симона Мартинса (Simone Martins), Тао Мен (Tao Men), Марка Моррисси
(Mark Morrissey), Венката Наиду (Venkata Naidu), Бхаса Налаботула (Bhas Nalabothula),
Томаса Ниманна (Thomas Niemann), Эрика Пескина (Eric Peskin), Дэвида По (David Po),
Энн Роджерс (Anne Rogers), Джона Росса (John Ross), Майкла Скотта (Michael Scott), Сейки (Seiki), Рэй Ши (Ray Shih), Даррена Шульца (Darren Shultz), Эрика Силкенсена (Erik
Silkensen), Сурьянто (Suryanto), Эмиля Тарази (Emil Tarazi), Наванана Тера-Ампорнпунта (Nawanan Theera-Ampornpunt), Джо Трдинича (Joe Trdinich), Майкла Тригобофф
(Michael Trigoboff), Джеймса Труппа (James Troup), Мартина Вопатека (Martin Vopatek),
Алана Уэста (Alan West), Бетси Вольф (Betsy Wolff), Тима Вонга (Tim Wong), Джеймса
Вудраффа (JamesWoodruff), Скотта Райта (Scott Wright), Джеки Сяо (Jackie Xiao), Гуаньпэн Сюй (Guanpeng Xu), Цин Сюй (Qing Xu), Карен Янь (Caren Yang), Инь Юншень (Yin
Yongsheng), Ван Юаньсюань (Wang Yuanxuan), Стивена Чжану (Steven Zhang) и Дай Чжун
(Day Zhong). Особая благодарность Инге Фрик (Inge Frick), которая обнаружила малозаметную ошибку в нашем примере с блокировкой и копированием, и Рики Лю (Ricky Liu)
за его потрясающие навыки корректуры.
Наши коллеги из Intel Labs – Эндрю Чен (Andrew Chien) и Лимор Фикс (Limor Fix) –
оказывали нам невероятную поддержку на протяжении всей работы над книгой. Стив

32

 Вступление

Шлоссер (Steve Schlosser) любезно предоставил некоторые характеристики дисковода.
Кейси Хелфрич (Casey Helfrich) и Майкл Райан (Michael Ryan) установили и обслуживали
наш новый сервер с процессором Core i7 на борту. Майкл Козуч (Michael Kozuch), Бабу
Пиллаи (Babu Pillai) и Джейсон Кэмпбелл (Jason Campbell) предоставили ценную информацию о производительности системы памяти, многоядерных системах и энерго­
сбережении. Фил Гиббонс (Phil Gibbons) и Шимин Чен (Shimin Chen) поделились своим
значительным опытом в разработке твердотельных дисков.
Мы смогли привлечь многих талантливых специалистов, в том числе Вен-Мей Хву
(Wen-Mei Hwu), Маркуса Пуэшеля (Markus Pueschel) и Иржи Симса (Jiri Simsa), и они не
только поделились с нами подробными отзывами, но и дали множество ценных советов.
Джеймс Хоу (James Hoe) помог создать Verilog-версию процессора Y86 и выполнил всю
работу, необходимую для синтеза действующего оборудования.
Большое спасибо нашим коллегам, представившим свои отзывы к рукопи­си, среди них: Джеймс Арчибальд (James Archibald; университет Бригама Янга), Ричард Карвер (Richard Carver; университет Джорджа Мейсона), Мирела Дамиан (Mirela Damian;
университет Вилланова), Питер Динда (Peter Dinda; Северо-Западный университет),
Джон Фиор (John Fiore; университет Темпл), Джейсон Фриттс (Jason Fritts; университет Сент-Луиса), Джон Грейнер (John Greiner; университет Райса), Брайан Харви (Brian
Harvey; университет Калифорнии, Беркли), Дон Хеллер (Don Heller; университет штата
Пенсильвания), Вей Чунг Сю (Wei Chung Hsu; университет Миннесоты), Мишель Хью
(Michelle Hugue; университет Мэриленда), Джереми Джонсон (Jeremy Johnson; университет Дрекселя), Джефф Кеннинг (Geoff Kuenning; колледж Харви Мадда), Рики Лю
(Ricky Liu), Сэм Мэдден (Sam Madden; Массачусетский технологический институт), Фред
Мартин (Fred Martin; Массачусетский университет, Лоуэлл), Абрахам Матта (Abraham
Matta; Бостонскийуниверситет), Маркус Пуэшель (Markus Pueschel; Университет Карнеги–Меллона), Норман Рэмси (Norman Ramsey; Университет Тафтса), Гленн Рейнманн
(Glenn Reinmann; Калифорнийский университет в Лос-Анджелесе), Микела Тауфер
(Michela Taufer; университет Делавэра) и Крейг Зиллес (Craig Zilles; Иллинойсский университет в Урбане-Шампейне).
Пол Анагностопулос (Paul Anagnostopoulos) из Windfall Software проделал большую
работу по верстке книги и руководил производственной группой. Большое спасибо
Полу и его превосходной команде: Рику Кэмпу (Rick Camp; редактор), Джо Сноудену (Joe
Snowden; верстальщик), Мэри Эллен Н. Оливер (MaryEllen N. Oliver; корректор), Лорел
Мюллер (Laurel Muller; художник) и Теду Лауксу (Ted Laux; составление предметного
указателя).
Наконец, мы хотим поблагодарить наших друзей из Prentice Hall. Марсию Хортон
(Marcia Horton), которая всегда была рядом с нами, и нашего редактора Мэтта Гольдштейна (Matt Goldstein), от начала и до конца обеспечивавшего безупречное руководство процессом. Мы глубоко благодарны им за их помощь, поддержку и идеи.

Благодарности к первому изданию
Мы безмерно благодарны всем друзьям и коллегам за их вдумчивую критику и сердечную поддержку. Отдельное спасибо студентам курса 15-213, чья энергия и энтузиазм «не давали нам засохнуть». Спасибо Нику Картеру (Nick Carter) и Винни Фьюриа
(Vinny Furia) за то, что любезно предоставили нам пакет malloc.
Гай Блеллок (Guy Blelloch), Грег Кесден (GregKesden), Брюс Мэггс (Bruce Maggs) и
Тодд Маори (Todd Mowry) являлись преподавателями курса на протяжении нескольких семестров, и их неоценимая поддержка помогала нам постоянно совершенствовать представляемый материал. Духовным руководством и постоянным содействием
во время работы мы обязаны Хербу Дерби (Herb Derby). Алан Фишер (Allan Fisher), Гарт
Гибсон (Garth Gibson), Томас Гросс (Thomas Gross), Сатья (Satya), Питер Стинкисте (Peter
Steenkiste) и Хью Чанг (Hui Zhang) дали нам толчок для начала работы над книгой.

Вступление  33
Предложение Гарта «запустило маховик», было поддержано и тщательно проработано
командой Алана Фишера. Марк Стелик (Mark Stehlik) и Питер Ли (Peter Lee) оказали неоценимую помощь в организации материала этой книги и привели его в соответствие
с учебным планом. Грег Кесден (Greg Kesden) дал ценные отзывы, описав влияние ВКС
на курс ОС (операционные системы). Грег Гэнгер (Greg Ganger) и Джири Шиндлер (Jiri
Schindler) любезно предоставили некоторые характеристики дисководов и ответили на
наши вопросы о существующих в настоящее время дисках. Том Стрикер (Tom Stricker)
показал нам «гору памяти». Джеймс Хоу (James Hoe) поделился интересными идеями о
том, как следует подавать материал об архитектуре процессора.
Группа студентов – Халил Амири (Khalil Amiri), Анжела Демке Браун (Angela Demke
Brown), Крис Колохан (Chris Colohan), Джейсон Кроуфорд (Jason Crawford), Питер Динда
(Peter Dinda), Хулио Лопес (Julio Lopez), Брюс Лоукамп (Bruce Lowekamp), Джефф Пирс
(Jeff Pierce), Санджей Рао (Sanjay Rao), Баладжи Сарпешкар (Balaji Sarpeshkar), Блейк
Шолль (Blake Scholl), Санжи Сешиа (Sanjit Seshia), Грег Стефан (Greg Steffan), Тианкай
Ту (Tiankai Tu), Кип Уокер (Kip Walker) и Йинглайн Цзе (Yinglian Xie) – помогали в разработке содержимого курса. В частности, Крис Колохан придумал увлекательный (и забавный) стиль представления материала, используемый до сих пор, а также разработал
легендарную «двоичную бомбу», оказавшуюся превосходным инструментом обучения
работе с машинным кодом и концепциям отладки.
Крис Бауэр (Chris Bauer), Алан Кокс (Alan Cox), Питер Динда (Peter Dinda), Сандия
Дуаркадис (Sandhya Dwarkadas), Джон Грейнер (John Greiner), Брюс Джейкоб (Bruce
Jacob), Барри Джонсон (Barry Johnson), Дон Хеллер (Don Heller), Брюс Лоукамп (Bruce
Lowekamp), Грег Моррисетт (Greg Morrisett), Брайан Ноубл (Brian Noble), Бобби Отмер
(Bobbie Othmer), Билл Пью (Bill Pugh), Майкл Скотт (Michael Scott), Марк Сматермен
(Mark Smotherman), Грег Стефан (Greg Steffan) и Боб Уайер (Bob Wier) потратили кучу
времени на ознакомление с рукописью книги. Отдельное спасибо Элу Дэвису (Al Davis;
университет Юты), Питеру Динда (Peter Dinda; Северо-Западный университет), Джону
Грейнеру (John Greiner; университет Райе), Вей Су (Wei Hsu; университет Миннесоты),
Брюсу Лоукампу (Bruce Lowekamp; колледж Уильяма и Мэри), Бобби Отмеру (Bobbie
Othmer; Университет Миннесоты), Майклу Скотту (Michael Scott; университет Рочестера) и Бобу Уайеру (Bob Wier; колледж Роки Маунтин) за тестирование бета-версий лабораторных работ. Также огромное спасибо всем их студентам!
Нам очень хочется поблагодарить наших коллег из Prentice Hall. Марсия Хортон
(Marcia Horton), Эрик Фрэнк (Eric Frank) и Гарольд Стоун (Harold Stone) оказывали постоянную неослабевающую поддержку в течение всего процесса работы. Гарольд при
этом помогал в точном описании исторических фактов по созданию процессорных архитектур RISC и CISC. Джерри Ралия (Jerry Ralya) глубоко изучала материал и многому
научила нас в плане литературного изложения своих мыслей.
И наконец, хочется выразить свою признательность техническим писателям Брайану Кернигану (Brian Kernighan) и покойному В. Ричарду Стивенсу (W. Richard Stevens) за
то, что они доказали нам, что и техническую литературу можно писать красиво.
Огромное спасибо всем.
Рэнди Брайант
Дэйв О’Халларон
Питсбург, Пенсильвания

Об авторах
Рэндал Э. Брайант (Randal E. Bryant) в 1973 г. по-

лучил степень бакалавра в Мичиганском университете,
после чего поступил в аспирантуру Технологического института в Массачусетсе. В 1981 г. получил степень доктора
наук по теории вычислительных машин и систем. В течение трех лет работал ассис­тентом профессора в Калифорнийском технологическом институте; на факультет
в Карнеги–Меллон пришел в 1984 г. Пять лет возглавлял
факультет информатики и затем десять лет занимал пост
декана этого же факультета. В настоящее время является
профессором информатики в университете. Он также проводит встречи с представителями Департамента электротехники и вычислительной техники.
Вот уже 40 лет профессор Брайант преподает курсы по компьютерным системам
как на уровне бакалавриата, так и на уровне магистратуры. За многие годы преподавания курсов компьютерной архитектуры он начал смещать акцент с проектирования
компью­теров к тому, как программисты могли бы писать более эффективные и надежные программы, более полно понимая системы. Вместе с профессором О’Холлароном
разработал в университете Карнеги–Меллон учебный курс 15-213 «Введение в компьютерные системы», легший в основу данной книги. Также преподавал курсы по алгоритмам, программированию, компьютерным сетям, распределенным системам и проектированию СБИС (сверхбольших интегральных схем).
Исследования профессора Брайанта имеют отношение к проектированию инструментальных программных средств в помощь разработчикам аппаратных средств при
верификации корректности создаваемых ими систем. Сюда входят несколько видов
моделирующих программ, а также инструменты формальной верификации, доказывающие корректность проектирования посредством математических методов. Им опуб­
ликовано свыше 150 технических работ. Результаты исследований профессора Брайанта используются ведущими производителями компьютерной техники, включая Intel,
IBM, Fujitsu и Microsoft. Является лауреатом многих наград за исследования, в числе
которых две награды за изобретения, а также награда за технические достижения от
Semiconductor Research Corporation (SRC), премия Канеллакиса за теоретические и
практические исследования от Association for Computer Machinery (ACM), премия Бейкера (W. R. G. Baker Award), премия Эммануэля Пиора (Emmanuel Piore Award), премия
Фила Кауфмана (Phil Kaufman Award) и премия Ричарда Ньютона (A. Richard Newton
Award) от Institute of Electrical and Electronics Engineers (IEEE). Является сотрудником
как АСМ, так и IEEE и членом Национальной академии США и Американской академии
искусств и наук.

Об авторах  35
Дэвид Р. О’Холларон (David R. O’Hallaron) – профессор

информатики, электротехники и вычислительной техники в
университете Карнеги–Меллона. Получил докторскую степень в университете Вирджинии. С 2007 по 2010 год занимал
должность директора Intel Labs в Питтсбурге.
В течение 20 лет преподавал курсы по компьютерным системам на уровне бакалавриата и магистратуры по таким темам, как компьютерная архитектура, введение в компьютерные системы, проектирование параллельных процессоров и
сетевые службы. Вместе с профессором Брайантом разработал курс в университете Карнеги–Меллона, который привел
к созданию этой книги. В 2004 году был награжден премией
Герберта Саймона (Herbert Simon Award) за выдающиеся успехи в преподавании от школы компьютерных наук CMUSchool of Computer Science, лауреаты которой выбираются
на основе опросов студентов.
Профессор О’Халларон занимается исследованиями в области компьютерных систем, уделяя особое внимание программным системам для научных вычислений, вычислений с интенсивным использованием данных и виртуализации. Одной из самых
известных примеров его работ является проект Quake, в котором участвовала группа
специалистов по информатике, инженеров-строи­телей и сейсмологов. Все вместе они
реализовали возможность предсказывать движение земной коры во время сильных
землетрясений. В 2003 году профессор О’Халларон и другие члены команды Quake получили премию Гордона Белла (Gordon Bell Prize) – высшую международную награду
в области высокопроизводительных вычислений. В настоящее время он работает над
проблемой автогрейдинга, то есть над программами, способными оценивать качество
других программ.

Глава

1
Экскурс в компьютерные
системы

1.1.

Информация – это биты + контекст.

1.2.

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

1.3.

Как происходит компиляция.

1.4.

Процессоры читают и интерпретируют инструкции, хранящиеся в памяти.

1.5.

Различные виды кеш-памяти.

1.6.

Устройства памяти образуют иерархию.

1.7.

Операционная система управляет работой аппаратных средств.

1.8.

Обмен данными в сетях.

1.9.

Важные темы.

1.10. Итоги.

К



Библиографические заметки.



Домашние задания.



Решения упражнений.

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

Экскурс в компьютерные системы  37
компьютером. Узнаете, как оптимизировать программный код на языке С путем применения специальных приемов, которые используют особенности современных процессоров и систем памяти. Получите представление о том, как компилятор реализует
вызовы процедур и как использовать эти знания, чтобы избежать прорех в системе
защиты, вызванных сбоями, возникающими в результате переполнения буферов, которые мешают работе сетевого программного обеспечения. Научитесь распознавать и
избегать неприятных ошибок во время связывания, которые приводят в замешательство программистов средней руки. Узнаете, как написать свою командную оболочку,
свой пакет процедур динамического распределения памяти и даже свой собственный
веб-сервер. Познакомитесь с перспективами и подводными камнями параллельного
выполнения – с темой, которая приобретает все большее значение с распространением
процессоров, имеющих несколько ядер.
Происхождение языка программирования C
Язык С разрабатывался с 1969 года по 1973 год Деннисом Ритчи (Dennis Ritchie), сотрудником Bell Laboratories. Национальный институт стандартизации США (American National
Standards Institute, ANSI) утвердил стандарт ANSI С в 1989 году. Этот стандарт дает определение языка программирования С и набора библиотечных функций, известных как
стандартная библиотека С. Керниган и Ритчи описали язык в своей классической книге,
которую в кругах программистов любовно называют не иначе как «K&R» [61]. По словам
Ритчи [92], язык С – это «причудливый, порочный и в то же время безусловный успех».
Так все-таки почему успех?
• Язык С был тесно связан с операционной системой Unix. Он разрабатывался с самого
начала как язык системного программирования для Unix. Большая часть ядра, а также
все вспомогательные инструменты и библиотеки были написаны на С. По мере роста
популярности Unix во второй половине семидесятых и в начале восьмидесятых годов
прошлого столетия многим пришлось столкнуться с языком С, и многим он понравился. Поскольку система Unix была практически полностью написана на С, она легко переносилась на новые машины, а это, в свою очередь, увеличивало круг пользователей
и самого языка С, и операционной системы Unix.
• С – простой, компактный язык. Его разработкой занимался один человек, а не многочисленный комитет, и результатом явился четкий непротиворечивый язык с небольшим бременем прошлого. В книге K&R дается полное описание языка и стандартной
библиотеки, приводятся многочисленные примеры и упражнения, и на это потребовалось всего 261 страница. Простота языка С облегчает его изучение и перенос на
разные компьютеры.
• С разрабатывался для решения практических задач. Первоначально его целью была
реализация операционной системы Unix. Потом обнаружилось, что на нем можно
писать любые программы, потому что сам язык давал такую возможность.
С – это язык системного программирования, и в то же время в нем имеются все средства,
обеспечивающие возможность написания прикладных программ. Он полностью удовлетворяет потребности некоторой части программистов, но все же подходит не для всех
ситуаций. Указатели языка С часто оказываются причиной различных недоразумений и
программных ошибок. Языку не хватает также прямой поддержки таких полезных абстракций, как классы, объекты и исключения. Более новые версии этого языка, такие как
C++ и Java, позволяют решать подобного рода проблемы и для программ прикладного
уровня.

В своих, ставших классическими книгах по программированию на языке С [61] Керниган (Kernighan) и Ритчи (Ritchie) начинают знакомить читателя с языком программирования на примере простой программы hello (которая всего лишь выводит текст
приветствия), представленной в лис­тинге 1.1. И хотя это очень простая программа, все

38

 Глава 1. Экскурс в компьютерные системы

основные части системы должны работать согласованно, чтобы довести ее до успешного завершения. В каком-то смысле цель этой книги заключается в том, чтобы помочь
вам понять, что происходит и как, когда вы запускаете программу hello в своей системе.
Листинг 1.1. Программа hello (источник: [60])

code/intro/hello.c
1
2
3
4
5
6
7

#include
int main()
{
printf("hello, world\n");
return 0;
}

code/intro/hello.c
Мы начнем изучение систем с того, что исследуем жизненный цикл программы
hello от момента ее написания программистом до момента, когда она выполняется
системой, выводит свое незатейливое послание и завершается. Двигаясь вперед по
жизненному циклу этой программы, мы будем вводить основные понятия, терминологию и компоненты, вступающие в игру. В последую­щих главах мы подробнее
остановимся на этих понятиях и идеях.

1.1. Информация – это биты + контекст
Наша программа hello начинает свой жизненный цикл как исходная программа (или
файл с исходным кодом), которую программист создает с помощью текстового редактора
и сохраняет в файле с именем hello.с. Исходный код программы представляет собой
последовательность битов, каждый из которых принимает значение 0 или 1, организованных в 8-битные блоки, получившие название байты. Каждый байт в исходном коде
представляет некоторый символ.
Большинство компьютерных систем представляют текстовые символы в стандарте
ASCII (American Standard Code for Information Interchange – американский стандартный
код обмена информацией), согласно которому каждый символ представляется уникальным однобайтным целым числом1. Например, в листинге 1.2 программа hello.с
показана в кодах ASCII.
Листинг 1.2. Представление текстового файла hello в кодах ASCII
#
i
n
35 105 110

c
l
u
d
e
99 108 117 100 101

SP
32

h
104

>
62

\n
10

\n i
n
t
10 105 110 116

\n
10

SP
32

SP
32

SP
32

l
o
108 111

,
44

SP w
o
r
l
d
32 119 111 114 108 100

SP
32
1

SP
32

SP m
32 109

<
s
t
d
i
o
60 115 116 100 105 111
a
i
n
97 105 110

SP p
r
i
n
t
f
32 112 114 105 110 116 102

SP r
e
t
u
r
n
32 114 101 116 117 114 110

(
40

)
41

.
46

\n {
10 123

(
40

"
h
e
l
34 104 101 108

\
n
92 110

"
34

)
41

;
59

\n
10

SP
32

;
59

\n }
10 125

\n
10

0
48

SP
32

Для представления текстов на языках, отличных от английского, используются другие методы
кодирования. См. врезку «Стандарт Юникода для представления текста» в главе 2.

1.2. Программы, которые переводятся другими программами в различные формы  39
Программа hello.с хранится в файле как последовательность байтов. Каждый байт
принимает целочисленное значение, соответствующее некоторому символу. Например, первый байт имеет значение 35, которому соответствует символ «#». Второй байт
имеет значение 105, которому соответствует символ «i», и т. д. Обратите внимание, что
текстовая строка заканчивается невидимым символом перевода строки «\n», который
представлен целым значением 10. Такие файлы, как hello.с, содержащие исключительно символы, называются текстовыми файлами, а все другие – двоичными файлами.
Представление файла hello.с иллюстрирует одну из фундаментальных идей. Вся информация в системе, включая файлы на дисках, программы и данные пользователей,
хранящиеся в памяти, а также данные, передаваемые по сети, представляется в виде
битовых блоков. Единственное, что отличает разные виды данных друг от друга, – контекст, в котором мы их рассматриваем. Например, в разных контекстах одна и та же последовательность байтов может представлять целое число, число с плавающей точкой,
строку символов или машинную инструкцию.
Как программисты мы должны понимать машинное представление чисел, поскольку они не тождественны целым или вещественным числам. Они суть конечные
приближения, которые могут вести себя непредсказуемым образом. Эта фундаментальная идея широко используется в главе 2.

1.2. Программы, которые переводятся другими
программами в различные формы
Программа hello начинает свою жизнь как программа на языке высокого уровня, поскольку в этой форме она может быть прочитана и понята человеком. Однако, чтобы запустить программу hello.с в системе, операторы на языке С должны быть преобразованы другими программами в некоторую последовательность инструкций на машинном
языке низкого уровня. Эти инструкции затем упаковываются в выполняемую объектную
программу и сохраняются в двоичном файле на диске. Объектные программы также называются выполняемыми объектными файлами.
В операционной системе Unix преобразование исходного файла в объектный выполняется драйвером компилятора:
unix> gcc -о hello hello.с

Здесь драйвер компилятора GCC читает исходный файл hello.c и транслирует его
в выполняемый объектный файл hello. Трансляция выполняется в четыре этапа, как
показано на рис. 1.1. Совокупность программ, выполняющих эти четыре этапа (препроцессор, компилятор, ассемблер и компоновщик), называется системой компиляции.

• Этап препроцессора (или этап предварительной обработки). Препроцессор (срр)

изменяет исходную программу в соответствии с директивами, которые начинаются с символа «#». Например, директива #inciude в строке 1 программы hello.с заставляет препроцессор прочитать содержимое системного заголовочного файла stdio.h и вставить его непосредственно в текст программы.
В результате получается другая программа на языке С, обычно с расширением .i.

• Этап компиляции. Компилятор (ccl) транслирует текстовый файл hello.i в текс­
товый файл hello.s, который содержит программу на языке ассемб­лера. Эта программа включает следующее определение функции main:
1
2
3
4

main:
subq $8, %rsp
movl $.LC0, %edi
call puts

40

 Глава 1. Экскурс в компьютерные системы
5
6
7

movl $0, %eax
addq $8, %rsp
ret

Каждый оператор в строках 2–7 этого определения точно описывает одну из
инструкций низкоуровневого машинного языка в текстовой форме. Польза
языка ассемблера прежде всего в том, что он представляет общий выходной
язык для компиляторов разных языков высокого уровня. Например, компиляторы языка С и языка Fortran генерируют выходные файлы на одном и том же
языке ассемблера.
printf.o
hello.c

Исходная
Source
программа
program
(текст)
(text)

ПрепроPreцессор
processor
(cpp)

hello.i

МодифициModified
рованная
source
исходная
program
программа
(text)
(текст)

Компиля
тор
Compiler
(cc1)

hello.s

Ассемблер
Assembler

hello.o

Программа

(as)

ПеремещаRelocatable
емый
объobject
ектный
код
programs
(двоичный)
(binary)

Assembly
на
program
ассемблере
(text)
(текст)

КомпоновLinker
щик
(ld)

hello

ВыполняExecutable
емый
объobject
ектный
код
program
(двоичный)
(binary)

Рис. 1.1. Система компиляции

• Этап ассемблирования. Ассемблер (as) транслирует файл

hello.s в машинные
инструкции, упаковывает их в форму, известную как перемещаемый объектный
код, и запоминает результат в объектном файле hello.о. Файл hello.о – это двоичный файл, содержащий 17 байт, которые кодируют машинные инструкции,
составляющие функцию main. Если открыть hello.o в текстовом редакторе, то вы
увидите совершенно непонятную абракадабру.

• Этап компоновки. Обратите внимание, что наша программа

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

О проекте GNU
GСС – один из множества полезных инструментов, созданных в рамках проекта GNU (сокращенно от «GNU’s Not Unix» – «GNU – не Unix»). Проект GNU – это освобожденная от
налогов благотворительная акция, основанная Ричардом Столлменом (Richard Stallman)
в 1984 году с амбициозной целью разработать законченную Unix-подобную систему,
исходный код которой не обременен ограничениями на его изменение и распространение. В рамках проекта GNU была разработана среда со всеми основными компонентами
операционной системы Unix, за исключением ядра, которое разрабатывалось отдельно,
а именно в рамках проекта Linux. Среда GNU включает редактор emacs, компилятор GCC,
отладчик GDB, ассемблер, компоновщик, утилиты для манипуляции двоичными файлами
и другие компоненты. Компилятор GCC развился и ныне поддерживает множество разных языков и способен генерировать код для множества разных машин. В число поддерживаемых языков входят: C, C++, Fortran, Java, Pascal, Objective-C и Ada.
Проект GNU – замечательное достижение, однако сплошь и рядом ему не уделяют
должного внимания. Современная мода на программные продукты с открытым исходным кодом (обычно ассоциируется с Linux) обязана своим интеллектуальным происхождением понятию свободное программное обеспечение, возникшему в рамках проекта
GNU («свободное» – в смысле «свобода слова», но не в смысле «бесплатное пиво»). Более
того, операционная система Linux во многом обязана своей популярности инструментальным средствам GNU, которые позволяют развернуть среду для ядра системы Linux.

1.3. Как происходит компиляция  41

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

• Оптимизация производительности программы. Современные компиляторы –
это сложные инструменты, которые обычно производят эффективный программный код. Однако мы, программисты, должны хотя бы немного понимать
машинный код и знать, как компилятор транслирует разные инструкции на C,
чтобы принимать осознанные решения при разработке своих программ. Например, всегда ли оператор switch является более эффективным, чем некоторая
последовательность операторов if-then-else? Какие накладные расходы несет
вызов функции? Является ли оператор while более эффективным, чем оператор for? Какой способ обращения к элементам массива эффективнее – по указателю или по индексам? Почему наш цикл выполняется намного быстрее, если
накап­ливать сумму в локальной переменной вместо аргумента, который передается по ссылке? Можно ли ускорить функцию, если просто расставить круглые
скобки в арифметическом выражении?

В главе 3 мы представим машинный язык x86-64 последних поколений компьютеров с Linux, Macintosh и Windows. Там мы расскажем, как компиляторы транслируют различные конструкции языка С в этот язык. В главе 5 вы узнаете, как оптимизировать производительность своих программ, выполняя простые преобразования
в коде на C, которые помогают компилятору лучше справляться со своей задачей.
А в главе 6 вы будете изучать иерархическую природу системы памяти, узнаете,
как компиляторы языка С хранят массивы данных и как можно использовать эти
знания, чтобы ускорить работу программ на C.

• Понимание ошибок времени компоновки. Как показывает наш опыт, некоторые

из наиболее запутанных программных ошибок порождаются компоновщиком, особенно при создании больших программных систем. Например, что
означает сообщение компоновщика о том, что он не может разрешить ссылку?
Чем отличаются статичес­кие и глобальные переменные? Что случится, если
в разных файлах на С объявить две глобальные переменные с одинаковыми
именами? Чем отличаются статические и динамические библиотеки? Почему
важен порядок перечисления библиотек в командной строке? И самое неприятное: почему ошибки, источником которых является компоновщик, остаются
незаметными до выполнения программы? Ответы на все эти вопросы вы получите в главе 7.

• Как избежать прорех в системе защиты. В течение многих лет уязвимость, по-

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

42

 Глава 1. Экскурс в компьютерные системы

1.4. Процессоры читают и интерпретируют инструкции,
хранящиеся в памяти
Итак, наша исходная программа hello.с прошла через систему компиляции, которая
преобразовала ее в выполняемый объектный файл с именем hello и сохранила на диске.
Чтобы запустить выполняемый файл в системе Unix, нужно ввести его имя с клавиатуры в другой программе, известной как командная оболочка:
linux>./hello
hello, world
linux>

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

1.4.1. Аппаратная организация системы
Чтобы понять, что происходит с программой hello во время выполнения, мы должны
иметь представление о том, как устроена аппаратная часть типичной вычислительной
системы, блок-схема которой показана на рис. 1.2. Эта конкретная блок-схема отражает
устройство современных систем Intel, однако примерно такое же устройство имеют все
вычислительные системы. Пусть вас сейчас не беспокоит сложность этой блок-схемы –
мы будем рассматривать различные ее детали на протяжении всей книги.
CPU

Блок
регистров
Register
file
PC

ALU

Системная

шинаbus
System

Memory
bus
Шина памяти

Мост
I/O
ввода/
bridge
вывода

Шина
Bus интерфейса
interface

Main
Память
memory

Шина ввода/вывода
I/O bus
Контроллер
USB
USB
controller
Mouse
Мышь Keyboard
Клавиатура

Graphics
Графический
адаптер
adapter

Контроллер
Disk
диска
controller

Display
Дисплей
Диск
Disk

Expansion
slots for
Слоты
расширения
для
подключения
other
devicesдругих
such
устройств,
как
as
networkтаких
adapters
сетевые адаптеры

Выполняемый
файл
hello
executable
stored
helloonнаdisk
диске

Рис. 1.2. Аппаратная организация типичной вычислительной системы.
CPU: центральное процессорное устройство или просто процессор,
ALU: арифметико-логическое устройство, PC (program counter): счетчик команд,
USB (Universal Serial Bus): универсальная последовательная шина

1.4. Процессоры читают и интерпретируют инструкции, хранящиеся в памяти  43

Шины
Вычислительную систему пронизывает совокупность электрических провод­ников,
так называемых шин, по которым байты информации циркулируют между компонентами системы. Обычно шины конструируются таким образом, чтобы по ним можно
было передавать байты порциями фиксированного размера, которые называют словами. Число байтов в слове (размер слова) является одним из фундаментальных параметров, которые изменяются от системы к системе. В большинстве современных систем
используются слова с размером 4 байта (32 бита) или 8 байт (64 бита). В этой книге мы
будем использовать понятие «слово» без определения конкретного размера и уточнять
его в случаях, когда это необходимо.

Устройства ввода/вывода
Устройства ввода/вывода – это средства связи с внешним миром. В нашем примере системы имеется четыре устройства ввода/вывода: клавиатура и мышь для ввода
данных пользователем, устройство для отображения данных пользователю и дисковое
устройство (или просто диск) для долгосрочного хранения данных и программ. В начальный момент выполняемый файл хранится на диске.
Каждое устройство ввода/вывода подключено к шине ввода/вывода посредством конт­
роллера или адаптера. Различие между ними заключается в их конструктивных особеннос­
тях. Контроллеры – это платы с наборами мик­росхем, установленные в самом устройстве
или на главной печатной плате (ее еще часто называют материнской платой). Адаптер –
это плата, которая подключается через контактное гнездо на материнской плате. Независимо от конструкции таких устройств, их назначение заключается в том, чтобы передавать
информацию между шиной и устройством ввода/вывода в обоих направлениях.
В главе 6 более подробно описана работа таких устройств ввода/вывода, как диск.
В главе 10 вы узнаете, как пользоваться интерфейсом ввода/вывода системы Unix для
доступа к устройствам из вашей прикладной программы. Мы же сосредоточим основное внимание на особо интересном классе устройств – сетевых адаптерах, принцип работы с которыми, впрочем, легко обобщить на любые другие устройства.

Основная память
Основная память – временное хранилище, в котором находятся сама программа и
данные, которыми она манипулирует во время выполнения. Физически основная память состоит из совокупности микросхем динамической памяти с произвольным доступом (Dynamic Random Access Memory, DRAM). Логически основная память организована
в виде линейного массива байтов, каждый из которых имеет свой уникальный адрес
(индекс элемента массива); отсчет адресов начинается с нуля. Машинные инструкции,
составляющие программу, могут состоять из разного числа байтов. Размер элементов
данных, соответствующих переменным в программах на С, зависит от их типов. Например, на машине x86_64, работающей под Linux, тип данных short требует двух байт,
типы int и float – четырех байт, а типы long и double – восьми байт.
В главе 6 мы более подробно рассмотрим, как работают технологии памяти, такие как
DRAM, и как из них конструируется основная память.

Процессор
Центральный процессор (ЦП; Central Processing Unit, CPU), или просто процессор, –
это механизм, который интерпретирует (или выполняет) инст­рукции, хранящиеся в
основной памяти. Его ядро составляет устройст­во памяти с емкостью в одно слово (или
регистр) – счетчик команд (Program Counter, PC). В любой конкретный момент времени
он хранит адрес некоторой машинной инструкции в основной памяти2.
2

Аббревиатура PC также часто расшифровывается как Personal Computer (персональный
компью­тер). Различие между понятиями должно быть очевидно из контекста.

44

 Глава 1. Экскурс в компьютерные системы

С момента включения системы и до ее выключения процессор снова и снова выполняет инструкцию, на которую указывает счетчик команд, и затем обновляет значение
счетчика, выполняя переход к следующей инструкции. Кажется, что процессор работает в соответствии с очень простой моделью выполнения инструкций, определяемой
его архитектурным набором команд. Согласно этой модели инструкции выполняются в
строгой последовательности, а выполнение одной инструкции происходит в несколько
этапов. Сначала процессор читает инструкцию из памяти по адресу в счетчике команд
(PC), интерпретирует биты инструкции, выполняет простые операции, определяемые
инструкцией, а затем обновляет значение счетчика, записывая в него адрес следующей инст­рукции, которая может размещаться за текущей или где-то в другом месте в
памяти.
Существует всего несколько таких простых операций, и все они связаны с обслуживанием основной памяти, блока регистров и арифметико-логичес­кого устройства
(Arithmetic/Logic Unit, ALU). Блок регистров – небольшое запоминающее устройство из
совокупности регистров, каждый из которых имеет свое уникальное имя и может хранить одно слово. Устройство ALU вычисляет новые значения данных и адресов. Назовем
лишь несколько примеров простых операций, которые процессор может выполнить по
требованию той или иной инструкции:

• загрузка: копирует байт или слово из основной памяти в регистр, затирая при
этом предыдущее содержимое этого регистра;

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

• выполнение арифметико-логической операции: копирует содержимое двух ре­

гистров в ALU, выполняет соответствующую операцию с этими словами и запо­
минает результат в одном из регистров, затирая при этом предыдущее содер­
жимое этого регистра;

• переход: извлекает слово из самой инструкции и копирует его в счетчик команд
(PC), затирая предыдущее его содержимое.

Мы говорим «кажется, что процессор работает в соответствии с очень прос­той моделью, определяемой его архитектурным набором», но на самом деле механика работы современных процессоров намного сложнее, чем было описано выше. Поэтому
мы будем различать архитектурный набор команд процессора, определяющий эффект
каждой машинной инст­рукции, и его микроархитектуру, определяющую фактическую
реализацию процессора. В процессе знакомства с машинным кодом в главе 3 мы рассмотрим абстракцию архитектурного набора. В главе 4 вы узнаете больше о том, как
в действительности устроены процессоры. В главе 5 мы опишем модель работы процессора с поддержкой предсказаний и оптимизации производительности программ на
машинном языке.

1.4.2. Выполнение программы hello
Ознакомившись с простым описанием аппаратной организации и операций, мы начинаем понимать, что происходит, когда мы запускаем наш пример программы. Здесь
мы должны опустить множество деталей, которые учтем позже, а пока нас вполне удовлетворит общая картина.
Сначала свои инструкции выполнит командная оболочка, ожидающая, когда мы введем команду с клавиатуры. Как только мы введем символы ./hello, командная оболочка прочитает каждый из них в регистр и сохранит в основной памяти, как показано на
рис. 1.3.

1.4. Процессоры читают и интерпретируют инструкции, хранящиеся в памяти  45
CPU
CPU
Register
file
Блок
регистров
ALU

PC

Системная

шинаbus
System

Memory
bus
Шина памяти

Мост
I/O
ввода/
bridge
вывода

Шина
Bus интерфейса
interface

Main
“hello”
"hello"
Память
memory

Шина ввода/вывода
I/O bus
USB
Контроллер
controller
USB

Graphics
Графический
адаптер
adapter

Mouse
Мышь Keyboard
Клавиатура

Display
Дисплей

Контроллер
Disk
диска
controller

Пользователь
User
ввелtypes
команду

Слоты
расширения
для
Expansion
slots for
подключения
other
devicesдругих
such
устройств,
таких
как
asсетевые
network
adapters
адаптеры

Диск
Disk

"hello"
“hello”

Рис. 1.3. Чтение команды hello с клавиатуры
Когда мы нажмем клавишу Enter, оболочка воспримет это как сигнал окончания
ввода команды. После этого она загрузит выполняемый файл hello, осущест­вив последовательность инструкций, которая скопирует в основную память программные коды
и данные, содержащиеся в объектном файле hello на диске. Данные включают строку
символов "hello, world\n", которая в конечном итоге будет выведена на экран.
Используя метод, известный как прямой доступ к памяти (Direct Memory Access,
DMA; см. главу 6), данные перемещаются с диска непосредственно в оперативную память, минуя процессор. Этот шаг показан на рис. 1.4.
CPU
Блок регистров
ALU

PC

Системная
шина

Шина памяти

Мост
ввода/
вывода

Шина интерфейса

Память

"hello, world\n"

Код программы
"hello"

Шина ввода/вывода
Контроллер
USB
Мышь Клавиатура

Графический
адаптер
Дисплей

Контроллер
диска

Диск

Слоты расширения для
подключения других
устройств, таких как
сетевые адаптеры
Выполняемый файл
hello на диске

Рис. 1.4. Загрузка выполняемого файла в основную память

46

 Глава 1. Экскурс в компьютерные системы

Как только код и данные из объектного файла hello будут загружены в память,
процессор начинает выполнять машинные инструкции подпрограммы main в программе hello. Эти инструкции копируют байты строки "hello, worid\n" из основной
памяти в регистры, а оттуда – на дисплей, на экране которого они затем отображаются. Эти этапы показаны на рис. 1.5.
CPU
Register
file
Блок
регистров
ALU

PC

Системная

System
шинаbus

Memory
bus
Шина памяти
"hello,world\n”
world\n"
“hello,
Main
Память
memory
Код
программы
hello
code

Мост

Шина
Bus интерфейса
interface

ввода/вывода

"hello"

I/O bus
Шина ввода/вывода

Контроллер
USB
USB
controller

Graphics
Графический
адаптер
adapter

Mouse
Keyboard
Мышь Клавиатура

Контроллер
Disk
диска
controller

Display
Дисплей

"hello, world\n"

“hello, world\n”

Disk
Диск

Expansion
slots for
Слоты
расширения
для
подключения
other
devicesдругих
such
устройств,
как
as
networkтаких
adapters
сетевые адаптеры

Выполняемый
файл
hello
executable
helloon
на disk
диске
stored

Рис. 1.5. Вывод строки из основной памяти на экран дисплея

1.5. Различные виды кеш-памяти
Важный урок из этого простого примера заключается в том, что система затрачивает уйму времени на перемещение информации из одного места в другое. Машинные
инструкции в программе hello в начальный момент хранятся на диске. Когда производится загрузка программы, они копируются в основную память. По мере выполнения
программы ее инструкции копируются из основной памяти в процессор. Аналогично
строка данных "hello, world\n", которая первоначально хранилась на диске, копируется в основную память, а затем из основной памяти на устройство отображения. С точки
зрения программиста такой большой объем копирования ложится тяжким бременем
на систему и существенно снижает «истинную производительность» программы. Следовательно, главная цель системных проектировщиков заключается в том, чтобы максимально ускорить операции копирования данных.
В силу физических законов чем больше запоминающее устройство, тем медленнее
оно работает. И в то же время создание быстродействующих запоминающих устройств
обходится дороже, чем более медленных. Например, емкость диска может оказаться в
1000 раз больше объема оперативной памяти, но, чтобы прочитать слово с диска, требуется в 10 млн раз больше времени, чем из основной памяти.
Аналогично типичный блок регистров может хранить лишь несколько сотен байтов
информации, в то время как основная память – миллиарды. Однако процессор способен
читать данные из регистров примерно в 100 раз быстрее, чем из основной памяти. Более того, по мере развития полупроводниковых технологий расхождения в скорости доступа к регистрам и к основной памяти продолжают углубляться. Увеличить быстродействие процессора намного проще, чем заставить основную память работать быстрее.

1.6. Устройства памяти образуют иерархию  47
Чтобы уменьшить разрыв между процессором и основной памятью, создатели процессоров включили в них небольшие быстродействующие устройства хранения, получившие название кеш-память (или просто кеш) и служащие для временного хранения
информации, которая, возможно, потребуется процессору в ближайшем будущем. На
рис. 1.6 показана типичная система с кеш-памятью. Кеш L1 (его еще называют кешем
первого уровня) внутри процессора может хранить десяткитысяч байт, и доступ к ним
осуществляется почти так же быстро, как к регистрам. Еще больший объем имеет кеш
L2 (кеш второго уровня) – он может вместить от сотен тысяч до миллионов байт. Этот
кеш связан с процессором специальной шиной. Скорость доступа к кешу L2 в 5 раз ниже
скорости доступа к кешу L1, и все равно она в 5–10 раз выше скорости доступа к основной памяти. Кеши L1 и L2 построены по технологии, известной как статическая
память с произвольным доступом (Static Random Access Memory, SRAM). Более новые
и более мощные системы имеют три уровня кешей: L1, L2 и L3. Идея кеширования
состоит в том, чтобы дать системе возможность получить положительный эффект от
наличия большого объема памяти и очень короткого времени доступа за счет улучшения локальности – тенденции программ использовать данные и код, находящиеся в
ограниченных областях памяти. Использование кешей для хранения данных, к которым программа, вероятно, будет часто обращаться, позволяет выполнять большинство
операций с памятью, используя быстрые кеши.
Процессор
CPU chip
Блок
регистров
Register
file
Cache
Кеш-память

ALU

memories

Системная

шинаbus
System
Шина
интерфейса
Bus interface

Memory
bus
Шина памяти

Мост
I/O
ввода/
bridge
вывода

Main
Память
memory

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

1.6. Устройства памяти образуют иерархию
Идея поместить небольшое, зато более быстрое запоминающее устройство (кеш-память) между процессором и более емким, но с худшим быстродействием запоминающим устройством (основной памятью) оказалась весьма плодотворной. Фактически
запоминающие устройства в любой вычислительной системе образуют иерархию, подобную изображенной на рис. 1.7. По мере движения по этой иерархии сверху вниз
устройства становятся все медленнее, объемнее, а стоимость хранения одного байта
уменьшается. Регистры находятся на вершине иерархии, которая обозначается как уровень 0 (L0). Кеш-память занимает уровни с 1 по 3 (L1–L3). Основная память находится
на уровне 4 (L4) и т. д.
Основная идея иерархии памяти заключается в том, что память одного уровня
служит кешем для следующего нижнего уровня. То есть блок регистров – это кеш для
кеш-памяти L1. Кеши L1 и L2 служат кешами для кеш-памяти L2 и L3 соответственно.

48

 Глава 1. Экскурс в компьютерные системы

Кеш L3 служит кешем для основной памяти, а та, в свою очередь, – кешем для диска.
В некоторых сетевых системах с распределенными файловыми системами локальный
диск служит кешем для данных, хранящихся на дисках других систем.
L0:

Быстрые,
Smaller,
небольшой
faster,
емкости
and
иcostlier
дорогостоя­
щие byte)
запоми­
(per
нающие
storage
устройства
devices

L1:

L2:

L3:

Медленные,
Larger,
большой
slower,
емкости
and
иcheaper
недорогие
запоми(per
byte)
нающие
storage
устройства
devices
L6:

L1
cache
Кеш
L1
(SRAM)
(SRAM)

CPU registers
hold хранят
words слова,
Регистры
процессора
полученные
из кеш-памяти
retrieved from
cache memory.

L2
cache
Кеш
L2
(SRAM)
(SRAM)

Кеш
L1 хранит
кеш-строки,
L1 cache
holds
cache lines
полученные
из кеша
L2
retrieved from
L2 cache.
Кеш
L2 хранит
кеш-строки,
L2 cache
holds
cache lines
полученные
из кеша
L3
retrieved from
L3 cache.

Кеш
L3
L3
cache
(SRAM)
(SRAM)

L3 cache
holds
cache lines
Кеш
L3 хранит
кеш-строки,
полученные
из памяти
retrieved from
memory.

Основная
память
Main memory
(DRAM)
(DRAM)

L4:

L5:

Регист­
Regs
ры

Localвторичные
secondaryзапоминающие
storage
Локальные
(local
disks) диски)
устройства
(локальные
secondary
storage
УдаленныеRemote
вторичные
запоминающие
устройства
(распределенные
файловые
системы,
(distributed file
systems,
Web веб-серверы)
servers)

Main memory
disk
blocks
Основная
памятьholds
хранит
блоки,
полученные
с локальных
дисков
retrieved from
local disks.
Local disksдиски
holdхранят
files
Локальные
файлы,
полученные
с дисков
retrieved
from disks
on
удаленных
сетевыхserver.
серверов
remote network

Рис. 1.7. Пример иерархии памяти
Подобно тому, как программисты используют знание структуры кешей LI и L2 для
повышения производительности своих программ, можно использовать структуру всей
иерархии памяти. Более подробно эти вопросы будут рассмотрены в главе 6.

1.7. Операционная система управляет работой
аппаратных средств
Вернемся к нашей программе hello. Когда оболочка загружала и запускала программу
hello и когда программа hello отображала свое сообщение, ни та ни другая программа не обращалась напрямую к клавиатуре, диску или основной памяти. Для этого они
пользовались услугами, предоставляемыми операционной системой. Операционную
систему можно представить как некоторый слой программного обеспечения между
прикладной программой и аппаратными средствами, как показано на рис. 1.8. Любые
операции с аппаратными средствами прикладные программы должны выполнять через операционную систему.
Applicationпрограммы
programs
Прикладные

Программное

Software
обеспечение

Операционная
система
Operating system
Процессор
Processor

Основная
память
Main memory

Устройства
I/O
devices
ввода/вывода

Аппаратные
Hardware

средства
Рис. 1.8. Многослойная организация компьютерной системы

Операционная система прежде всего должна отвечать двум основным требованиям: (1) защищать аппаратные средства от катастрофических действий вышедшей
из-под контроля программы и (2) предоставлять приложениям простые и единообраз-

1.7. Операционная система управляет работой аппаратных средств  49
ные механизмы манипулирования сложными и часто сильно отличающимися низкоуровневыми аппаратными средствами. Для этого операционная система предлагает
набор фундаментальных абстракций, показанных на рис. 1.8: процессы, виртуальную
память и файлы. Как видно по рис. 1.9, файлы являются абстракцией устройств ввода/
вывода, виртуальная память является абстракцией как основной памяти, так и дисковых устройств ввода/вывода, а процессы – абстракцией процессора, основной памяти и
устройств ввода/вывода. Обсудим эти абстракции по очереди.
Процессы
Processes
Виртуальная
память
Virtual memory
Файлы
Files
Процессор
Processor

Основная
память
Main memory

Устройства
I/O
devices
ввода/вывода

Рис. 1.9. Абстракции, предоставляемые операционной системой

1.7.1. Процессы
Когда программа, такая как hello, запускается в современной системе, операционная
система создает иллюзию, что она – единственная программа, выполняющаяся в системе. С точки зрения программы создается впечатление, что только она распоряжается
процессором, основной памятью и устройствами ввода/вывода. Процессор как бы выполняет инструкции программы подряд, одну за другой, без прерываний, и только код
программы и ее данные являются единственными объектами, пребывающими в памяти системы. Источником таких иллюзий является понятие процесса, одной из наиболее
важных и успешных идей в теории вычислительных машин и систем.
Процесс – это абстракция операционной системы, представляющая выполняемую
программу. В одной и той же системе одновременно может выполняться множество
процессов, и в то же время каждому процессу кажется, что только он пользуется аппаратными средствами. Под одновременным (или парал­лельным) выполнением мы понимаем поочередное выполнение инструкций то одного, то другого процесса. В большинстве систем существует больше процессов, выполняющихся одновременно, чем
процессоров, на которых они выполняются.
Традиционные системы могут выполнять только одну программу в каждый конкретный момент времени, тогда как новые многоядерные процессоры способны одновременно выполнять программный код нескольких программ. В любом случае может показаться, что один процессор реализует несколько процессов одновременно, если будет
переключаться между ними очень быст­ро. Операционная система проделывает такое
чередование с помощью механизма переключения контекста. Чтобы упростить остальную часть этого обсуждения, мы будем рассматривать только однопроцессорную систему
с единственным процессором. К обсуждению многопроцессорных систем мы вернемся
в разделе 1.9.2.
Операционная система хранит всю информацию о состоянии, необходимую для правильного выполнения процесса. Состояние, известное как контекст, содержит такие
сведения, как текущее значение счетчика команд PC, состоя­ние блока регистров и содержимое основной памяти. В любой конкретный момент времени в системе выполняется только один процесс. Когда опера­ционная система принимает решение передать
управление некоторому другому процессу, она производит переключение контекста,
запоминая контекст текущего процесса и восстанавливая контекст нового, после чего
передает управление новому процессу. Новый процесс возобновляет выполнение точ-

50

 Глава 1. Экскурс в компьютерные системы

но с того места, в котором он был прерван. Эту идею иллюстрирует рис. 1.10 на примере
нашей программы hello.
Процесс AA
Process

Time
Время

Процесс B
B
Process
code
КодUser
приложения

read

Прерывание

Disk interrupt
от диска
Return
Возврат
from
read
из read

Kernel
code
Код ядра

КодUser
приложения
code
Код ядра
Kernel
code
КодUser
приложения
code

Context
Переключение
switch
контекста
Context
Переключение
switch
контекста

Рис. 1.10. Переключение контекстов процессов
В рассматриваемом нами примере существует два процесса: процесс командной
оболочки и процесс hello. Первоначально система выполняет только один процесс, а
именно процесс командной оболочки, которая ожидает ввода команды. Когда мы обращаемся к нему с требованием запустить программу hello, оболочка выполняет наше
требование, вызывая специальную функцию, так называемый системный вызов, который передает управление операционной системе. Операционная система сохраняет
контекст процесса оболочки, создает новый процесс hello с его контекстом, а затем
передает управление новому процессу hello. После завершения hello операционная система восстанавливает контекст процесса оболочки и возвращает ему управление, а он
затем ждет ввода следующей команды.
Как показано на рис. 1.10, переключение с одного процесса на другой производится
ядром операционной системы. Ядро – это часть кода операционной системы, которая
всегда находится в памяти. Когда прикладная программа требует от операционной системы какого-либо действия, например чтения из файла или записи в файл, она выполняет специальную инструкцию системного вызова, передавая управление ядру. Затем
ядро выполняет запрошенную операцию и возвращает управление прикладной программе. Обратите внимание, что ядро – это не отдельный процесс, а блок кода и структур данных, которые система использует для управления всеми процессами.
Реализация абстракции процесса требует тесного взаимодействия аппаратных и
программных средств операционной системы. В главе 8 мы выясним, как это делается, а также как прикладные программы могут создавать свои собственные процессы и
управлять ими.

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

1.7. Операционная система управляет работой аппаратных средств  51

Unix, Poslx и Standard Unix Specification
В шестидесятые годы прошлого столетия господствовали большие и сложные операционные системы, такие как OS/360, разработанная компанией IBM, и Multics, разработанная
компанией Honeywell. И если OS/360 была одной из наиболее успешных операционных
систем того периода, то Multics влачила жалкое сущест­вование в течение многих лет и не
смогла добиться широкого признания. Компания Bell Laboratories первоначально была одним из партнеров, разрабатывавших проект Multics, но в 1969 году отказалась от участия в
этом проекте по причине его чрезмерной сложности и ввиду отсутствия положительных результатов. Полученный при разработке системы отрицательный опыт сподвиг группу исследователей компании – Кена Томпсона (Ken Thompson), Денниса Ритчи (Dennis Ritchie), Дуга
Макилроя (Doug Mcllroy) и Джо Оссанну (Joe Ossanna) – приступить в 1969 году к работе над
более простой операционной системой для компьютера PDP-7 компании DEC, написанной
исключительно на машинном языке. Многие из идей новой системы, такие как иерархическая файловая система и понятие командной оболочки как процесса пользовательского
уровня, были заимствованы из системы Multics, но реализованы в виде более простого и
компактного пакета программ. В 1970 году Брайан Керниган (Brian Kernighan) выбрал для
новой системы название «Unix» как противовес названию «Multics», тем самым подчеркнув
неповоротливость и тяжеловесность системы Multics (в некотором приближении Multics
можно перевести как «многогранный», в том же контексте Unix можно перевести как «одно­
гранный». – Прим. перев.). Ядро Unix было переписано на языке С в 1973 году, а сама операционная система была представлена широкой публике в 1974 году [93].
Поскольку компания Bell Labs предоставила высшим учебным заведениям исходные
коды на очень выгодных условиях, у операционной системы Unix появилось множество
сторонников среди студентов и преподавателей различных университетов. Работа, оказавшая большое влияние на дальнейшее развитие, была выполнена в Калифорнийском
университете Беркли в конце семидесятых и в начале восьмидесятых годов, когда исследователи из Беркли добавили виртуальную память и сетевые протоколы в последовательность выпусков, получивших название Unix 4.xBSD (Berkeley Software Distribution). Одновременно компания Bell Labs наладила выпуск своих собственных версий Unix, которые
стали известны как System V Unix. Версии других поставщиков программного обеспечения,
таких как система Sun Microsystems Solaris, были построены на базе оригинальных версий
BSD и System V.
Осложнения возникли в середине восьмидесятых годов, когда производители операционной системы предприняли попытку выбрать собственные направления в развитии, добавляя новые свойства, часто нарушающие совместимость с прежними версиями.
Чтобы пресечь эти сепаратистские тенденции, институт стандартизации IEEE (Institute
for Electrical and Electronics Engineers – Институт инженеров по электротехнике и электронике) возглавил усилия по стандартизации системы Unix. Позже Ричард Столлман
(Richard Stallman) окрестил продукт этих усилий как «Posix». В результате было получено
семейство стандартов, известное как стандарты Posix, которые решали такие проблемы,
как интерфейс языка С для системных вызовов в Unix, командные оболочки и утилиты, потоки и сетевое программирование. Запущенный вслед за этим отдельный проект по стандартизации, известный как стандартная спецификация Unix (Standard Unix
Specification, SUS), объединил свои усилия с Posix для создания единого унифицированного стандарта систем Unix. В результате этого сотрудничества различия между разными
версиями Unix почти исчезли.

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

52

 Глава 1. Экскурс в компьютерные системы

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

Виртуальная
память
ядра
Kernel virtual
memory
Пользовательский
User stack стек
(создается
приrun
запуске)
(created at
time)

Отображаемая
область
памяти
Memory-mapped
region
for
для разделяемых
библиотек
shared libraries

Память,
invisibleнедоступная
to
пользовательскому
коду
user code

Функция
printf printf
function

Динамическая
память
Run-time heap
(создается
)
(created
bymalloc
malloc)
Данные, доступные
Read/write
data
для
чтения/записи
Код и данные, доступные

Read-only
code
and data
только для
чтения

Начало
Program
программы
start

Загружаются
Loaded
from the
из
выполняемого
hellohello
executable file
файла

0

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

• Программный код и данные. Программный код каждого процесса всегда начина-

ется с одного и того же адреса, за ним следуют ячейки памяти, соответствующие
глобальным переменным. Область с кодом и данными инициализируется непосредственно содержимым выполняемого объектного файла, в нашем случае
hello. Более подробно эта часть адресного пространства будет описана в главе 7,
где мы исследуем связывание и загрузку.

• Динамическая память (куча). Непосредственно за кодом и данными следует

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

1.8. Обмен данными в сетях  53

• Совместно используемые (разделяемые) библиотеки. Ближе к середине адресного

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

• Стек. В верхней части виртуального адресного пространства находится стек

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

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

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

1.7.4. Файлы
Файл – это всего лишь последовательность байтов, не более и не менее. Каждое устройство ввода/вывода, в том числе диски, клавиатуры, устройства отображения и даже сети,
моделируется соответствующим файлом. Все операции ввода/вывода в системе выполняются путем чтения и записи в файлы посредством нескольких системных вызовов, образующих подсистему ввода/вывода Unix.
Это простое и элегантное понятие файла, тем не менее, обладает глубоким смыслом,
поскольку обеспечивает унифицированное представление всего разнообразия файлов,
которые могут входить в состав системы. Например, прикладные программисты, манипулирующие содержимым дискового файла, могут быть абсолютно не знакомы с дисковыми технологиями и при этом чувствовать себя вполне комфортно. Более того, одна и
та же программа будет прекрасно выполняться в разных системах, использующих разные
дисковые технологии. Подсистема ввода/вывода Unix будет рассматриваться в главе 10.

1.8. Обмен данными в сетях
До этого момента в нашем экскурсе мы рассматривали систему как изолированную
совокупность аппаратных и программных средств. На практике современные системы часто соединены с другими системами посредством компьютерных сетей. С точки
зрения отдельной системы, сеть можно рассматривать как еще одно устройство ввода/
вывода, как показано на рис. 1.12. Когда система копирует некоторую последовательность байтов из основной памяти в сетевой адаптер, поток данных устремляется через
сеть в другую машину, а не, скажем, в локальный дисковый накопитель. Аналогично
система может читать данные, отправленные другими машинами, и сохранять в своей
основной памяти.

54

 Глава 1. Экскурс в компьютерные системы
Процессор
CPU chip
Register
file
Блок
регистров
ALU

PC

Системная

System
шинаbus

Шина памяти
Memory
bus

Мост
I/O
ввода/
bridge
вывода

Шина
интерфейса
Bus interface

Main
Память
memory

Слоты расширения
Expansion
slots
Шина ввода/вывода
I/O bus
Контроллер
USB
USB
controller

Graphics
Графический
адаптер
adapter

Mouse
Мышь Keyboard
Клавиатура

Monitor
Дисплей

Контроллер
Disk
диска
controller

Диск
Disk

Network
Сетевой
адаптер
adapter

Network
Сеть

Рис. 1.12. Сеть – это еще одно устройство ввода/вывода
С пришествием глобальных сетей, таких как интернет, копирование информации
между машинами стало одним из наиболее важных применений компьютерных систем. Например, такие приложения, как электронная поч­та, обмен мгновенными сообщениями, Всемирная паутина, FTP (File Transfer Protocol – протокол передачи файлов)
и telnet, основаны на копировании информации по сети.
Возвращаясь к нашему примеру hello, мы можем воспользоваться знакомым приложением telnet, чтобы запустить программу hello на удаленной машине. Предположим, что мы решили воспользоваться клиентом на нашей машине для подключения
к telnet-серверу на удаленной машине. После регистрации на удаленной машине запустится удаленная командная оболочка, которая будет ждать от нас ввода команд. С этого момента процесс дистанционного запуска программы hello требует выполнения
пяти простых действий, представленных на рис. 1.13.
1.
Пользователь
1.User
types
вводит
с клавиатуры
“hello
” at the
"hello"
keyboard

5. Telnet-сервер
5.
Client prints
посылает
строку ”
“hello,
world\n
"hello, world\n"
stringклиенту
on display

Local
Локальный
telnet
клиент
telnet
client

2. Клиент
посылает
строку
2.
Client sends
“hello

"hello"
string
to telnet-серверу
telnet server

4. Клиент
Telnet выводит
server sends
строку

"hello,
на” string
дисплей
“hello,world\n"
world\n

to client

Remote
Удаленный
telnettelnet
сервер
server

3.
строку”
3.Сервер
Serverпередает
sends “hello
"hello"
string
to theкомандной
shell, which
которая
runsоболочке,
the hello
program
запускает
программу
hello
and passes
the output
toи the
telnetвывод
server
передает
telnet-серверу

Рис. 1.13. Использование telnet для запуска программы hello на удаленной машине
После ввода строки hello на стороне клиента и нажатия клавиши Enter клиент отправит эту строку telnet-серверу. Когда сервер получит эту строку из сети, он передаст ее
своей командной оболочке. Затем удаленная командная оболочка запустит программу
hello и передаст выходную строку telnet-серверу. Наконец, telnet-сервер перешлет полученную строку клиенту по сети, а тот отобразит ее на локальном дисплее.

1.9. Важные темы  55
Такой тип обмена между клиентами и серверами характерен для всех сетевых приложений. В главе 11 вы узнаете, как создавать сетевые приложения, и примените полученные знания для создания простого веб-сервера.

Проект Linux
В августе 1991 года финский аспирант по имени Линус Торвальдс (Linus Torvalds) скромно
объявил о завершении разработки ядра новой Unix-подобной операционной системы:
От: torvalds@klaava.Helsinki.FI (Linus Benedict Torvalds)
Сетевая телеконференция: comp.os.minix
Тема: Что бы вы хотели прежде всего видеть в minix?
Резюме: ограниченный опрос, касающийся моей новой операционной системы
Дата: 25 августа 91 20:57:08 по Гринвичу
Вниманию всех, кто пользуется системой minix:
Я разрабатываю (бесплатную) операционную систему (это всего лишь хобби,
система небольшая и спроектирована непрофессионально, в отличие от GNU)
для персональных компьютеров AT 386/486. Проект вызревал с апреля,
сейчас он приобретает законченный вид. Я хотел бы узнать мнение людей,
работающих с minix (одобряют или не одобряют мою систему), поскольку моя
операционная система в какой-то степени напоминает minix (то же
физическое размещение файловой системы, в силу практических причин,
наряду с другими общими чертами).
В настоящий момент я перенес программы bash(1.08) и gcc(1.40), и, как ни
странно, они работают. Это означает, что через несколько месяцев мне
удастся получить кое-что полезное, и мне хотелось бы знать, что бы
хотело видеть большинство в моем программном продукте. Благодарен за
любые предложения, в то же время я не обещаю, что все выполню.
Linus (torvalds@kruuna.helsinki.fi)

Как указывает Торвальдс, отправной точкой для создания Linux стала операционная
система Minix, разработанная Эндрю С. Таненбаумом (Andrew S. Tanenbaum) в образовательных целях [113].
Все остальное, как говорится, уже история. Операционная система Linux превратилась в техническое и культурное явление. Объединившись с проектом GNU, проект Linux
позволил получить полную, совместимую со стандартами Posix версию операционной
системы Unix, включая ядро и всю поддерживающую его инфраструктуру. Система Linux
успешно работает на самых разных компьютерах, от карманных до мейнфреймов. Группа
разработчиков компании IBM умудрилась впихнуть ее даже в наручные часы!

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

56

 Глава 1. Экскурс в компьютерные системы

1.9.1. Закон Амдала
Джин Амдал (Gene Amdahl), один из пионеров компьютерных вычислений, сделал
простое, но далеко идущее наблюдение об эффективности повышения производительности одной части системы, которое стало известно как закон Амдала. Согласно этому
закону, когда мы ускоряем одну часть системы, прирост общей производительности
системы зависит не только от величины ускорения этой части системы, но и насколько
значимой она является. Рассмотрим систему, в которой для выполнения некоторого
приложения требуется время Told. Предположим, что некоторая часть системы потребляет долю α этого времени, и мы повысили ее производительность в k раз. То есть компоненту изначально требовалось время αTold, а теперь (αTold)/k. Таким образом, общее
время выполнения будет

Tnew = (1 − α)Told + (αTold)/k
= Told[(1 − α) + α/k].

Отсюда прирост скорости S = Told/Tnew можно вычислить как


1
S = –––––––––––––.
(1 − α) + α/k

(1.1)

Рассмотрим пример, когда часть системы, которая изначально потребляла 60 %
времени (α = 0,6), ускоряется в 3 раза (k = 3). В этом случае общее ускорение составит
1/[0,4 + 0,6/3] = 1,67×. Несмотря на значительное ускорение основной части системы, общее ускорение оказалось значительно меньше ускорения одной части. Это основная идея
закона Амдала – чтобы значительно ускорить работу всей системы, нужно повысить скорость очень большой части всей систе­мы.
Упражнение 1.1 (решение в конце главы)
Представьте, что вы работаете водителем грузовика и вас наняли для перевозки груза картофеля из города Бойсе, штат Айдахо, в город Миннеаполис, штат Миннесота, на расстояние
2500 километров. По вашим оценкам, вы ездите со средней скоростью 100 км/ч, то есть на
выполнение рейса потребуется в общей сложности 25 часов.
1. Вы услышали в новостях, что в штате Монтана, на который приходится 1500 км пути,
только что отменили ограничение скорости. Ваш грузовик может двигаться со скорос­
тью 150 км/ч. Каким могло бы быть общее ускорение рейса?
2. Вы можете купить новый турбокомпрессор для своего грузовика на сайте www.
fasttrucks.com. У них есть множество разных моделей, но чем эффективнее турбокомпрессор, тем дороже он стоит. Насколько быстро вы должны проехать штат Монтана,
чтобы получить общее ускорение в 1,67 раза?

Упражнение 1.2 (решение в конце главы)
Отдел маркетинга вашей компании пообещал вашим клиентам, что производительность
следующей версии программного обеспечения улучшится в 2 раза. Вам поручили выполнить это обещание. Вы определили, что можно ускорить только 80 % системы. Насколько
(то есть какое значение k) вам нужно ускорить эту часть, чтобы достичь общего увеличения
производительности в 2 раза?

1.9. Важные темы  57

Выражение относительной производительности
Лучший способ выразить увеличение производительности – это отношение вида Told/Tnew,
где Told – время, необходимое для выполнения в исходной версии системы, а Tnew – время, необходимое для выполнения в измененной версии. Если улучшение действительно
имеет место быть, то это число будет больше 1,0. Для обозначения таких отношений мы
используем окончание «×», то есть запись «2,2×» читается как «в 2,2 раза».
Также часто используется более традиционный способ выражения относительного изменения в процентах, особенно в случаях, когда изменение небольшое, но определение
этого способа неоднозначно. Как должен вычисляться процент? Как 100 ∙ (Told − Tnew)/Tnew?
Или, может быть, как 100 ∙ (Told − Tnew)/Told? Или как-то иначе? К тому же этот способ менее
наглядный для больших изменений. Понять фразу «производительность улучшилась на
120 %» сложнее, чем «производительность улучшилась в 2,2 раза».

Один интересный частный случай закона Амдала – рассмотреть эффект выбора k
равным ∞. То есть можно взять некоторую часть системы и ускорить ее до такой степени, что на ее работу будет тратиться ничтожно мало времени. В результате получаем


1
S ∞ = –––––––––.
(1 − α)

(1.2)

Так, например, если мы сможем ускорить 60 % системы до такой степени, что она
практически не будет потреблять времени, то чистое ускорение все равно составит
только 1/0,4 = 2,5×.
Закон Амдала описывает общий принцип ускорения любого процесса. Однако он может применяться не только к ускорению компьютерных систем, но также может помочь
компании, пытающейся снизить стоимость производства бритвенных лезвий, или студенту, желающему улучшить свой средний балл. И все же он наиболее актуален в мире
компьютеров, где производительность нередко улучшается в 2 или более раз. Таких высоких значений можно достичь только путем оптимизации больших частей системы.

1.9.2. Конкуренция и параллелизм
На протяжении всей истории развития цифровой вычислительной техники постоянными движущими силами, толкающими к совершенствованию, были два требования: компьютеры должны делать больше и работать быстрее. Оба этих параметра улучшаются, когда процессор одновременно может выполнять большее количество задач.
Для обозначения общей идеи одновременного выполнения множества действий мы
используем термин конкуренция, а для обозначения использования конкуренции для
ускорения работы системы – термин параллелизм. Параллелизм может использоваться
в компьютерной систе­ме на нескольких уровнях абстракции. Мы выделяем здесь три
уровня, от самого верхнего до самого нижнего в систем­ной иерархии.

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

58

 Глава 1. Экскурс в компьютерные системы

страницы с одного веб-сервера. Она также дает возможность одному пользователю одновременно выполнять несколько задач, например открыть веб-браузер в одном окне,
текстовый процессор в другом и одновременно воспроизводить потоковую музыку. До
недавнего времени в большинстве случаев все вычисления выполнялись одним процессором, даже если ему приходилось переключаться между несколькими задачами. Эта
конфигурация известна как однопроцессорная система.
Когда в системе имеется несколько процессоров, все они управляются одним ядром
операционной системы, и мы получаем многопроцессорную систему. Такие системы
были доступны для крупномасштабных вычислений начиная с 1980-х годов, но с появлением многоядерных процессоров и технологии гиперпоточности они стали обычным
явлением. На рис. 1.14 показана классификация этих различных типов процессоров.
Многоядерные процессоры состоят из нескольких процессоров (называемых «ядрами»), интегрированных на один кристалл. На рис. 1.15 показана организация типичного многоядерного процессора, имеющего в одной микросхеме четыре ядра, каждое
со своими кешами L1 и L2, причем каждый кеш L1 разделен на две части: одна предназначена для хранения недавно выбиравшихся инструкций, а другая – данных. Ядра
совместно используют кеш-память более высокого уровня (L3), а также интерфейс с
основной памятью. Эксперты прогнозируют, что вскоре на одном кристалле будут размещаться десятки, а то и сотни ядер.
All processors
Все
системы
Multiprocessors
Многопроцессорные

Однопроцессорные
Uniprocessors

Процессоры
с подМного­
MultiHyperдержкой
ядерные
core
threaded
процессоры гиперпоточности

Рис. 1.14. Классификация систем
в зависимости от количества процессоров.
Многопроцессорные системы становятся все
более распространенными с появлением
многоядерных процессоров и технологии
гиперпоточности

Processor package
Микросхема
процессора

Ядро 00
Core

Ядро 33
Core

Regs
Регистры

Кеш
L1
данных
d-cache
L1

Regs
Регистры

Кеш
L1
инструкций
i-cache
L1

...

Универсальный
кеш L2
L2 unified cache

Кеш
L1
данных
d-cache
L1

Кеш
L1
инструкций
i-cache
L1

Универсальный
кеш L2
L2 unified cache

Универсальный
кеш L3
L3 unified cache
(shared
by all
cores)
(общий для
всех
ядер)

Main memory
Основная
память

Рис. 1.15. Организация многоядерного процессора.
Четыре процессорных ядра размещены на одном кристалле

1.9. Важные темы  59
Гиперпоточность, которую иногда называют одновременной многопоточностью, –
это технология, позволяющая одному процессору выполнять сразу несколько потоков
управления. Это предполагает наличие нескольких копий определенных аппаратных
средств процессора, таких как счетчики инструкций и блоки регистров, при этом другие
аппаратные компоненты, такие как блок арифметических операций с плавающей точкой, наличествуют в единст­венном числе. В отличие от обычного процессора, которому требуется около 20 000 тактов для переключения между потоками, гиперпотоковый
процессор выбирает потоки для выполнения на циклической основе. Это позволяет
процессору полнее использовать свои ресурсы. Например, если один поток должен дождаться загрузки некоторых данных в кеш, то процессор может продолжить выполнение другого потока. Например, каждое ядро в процессоре Intel Core i7 может выполнять
два потока, поэтому четырехъядерная система фактически способна параллельно выполнять восемь потоков.
Использование технологий многопроцессорной обработки позволяет повысить производительность системы, предлагая два преимущества. Во-первых, уменьшает необходимость моделирования конкуренции при выполнении нескольких задач. Как уже
упоминалось, даже персональный компьютер, используемый одним человеком, может
выполнять множество действий одновременно. Во-вторых, дает возможность выполнять каждую отдельную прикладную программу быстрее, правда при условии, что в ней
имеется несколько потоков управления, способных эффективно выполняться параллельно. То есть даже притом что принципы параллелизма формировались и изучались
на протяжении более 50 лет, только появление многоядерных и гиперпоточных систем
способствовало появлению желания использовать приемы разработки прикладных
программ, которые могут применять параллелизм на уровне потоков, реализованный
на аппаратном уровне. Более подробно поддержка конкурентного выполнения и ее использование рассматриваются в главе 12.

Параллелизм на уровне инструкций
На гораздо более низком уровне абстракции современные процессоры могут выполнять несколько инструкций одновременно. Это свойство известно как параллелизм на
уровне инструкций. Например, ранним микропроцессорам, таким как Intel 8086 1978 года
выпуска, для выполнения одной инструкции требовалось несколько тактов (обычно 3–10).
Более современные процессоры могут выполнять по 2–4 инструкции за такт. Для выполнения любой конкретной инструкции требуется намного больше времени, например
20 тактов или больше, но процессор использует ряд хитрых приемов для одновременной
обработки до 100 инструкций. В главе 4 мы рассмотрим использование конвейерной обработки, в которой дейст­вия, необходимые для выполнения инструкции, разделены на
этапы, а аппаратное обеспечение процессора организовано в виде последовательности
стадий, каждая из которых выполняет один из этапов. Стадии могут работать параллельно, выполняя разные части разных инструкций. Мы увидим, что для поддержания скорости выполнения, близкой к 1 инструкции на такт, не требуется ничего особенно сложного.
Процессоры, которые могут выполнять более одной инструкции за такт, называют
суперскалярными. Большинство современных процессоров поддерживают суперскалярные операции. В главе 5 мы опишем высокоуровневую модель таких процессоров
и покажем, как прикладные программисты могут использовать эту модель для прогнозирования производительности своих программ, а затем писать программы так,
чтобы сгенерированный код достигал более высокой степени параллелизма на уровне
инструкций и, следовательно, работал быстрее.

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

60

 Глава 1. Экскурс в компьютерные системы

ций. Этот режим известен как одиночный поток команд, множественный поток данных
(Single-Instruction, Multiple-Data, SIMD). Например, в последних поколениях процессоров Intel и AMD есть инструкции, которые могут параллельно складывать 8 пар чисел с
плавающей точкой одинарной точности (тип данных float в языке C).
Эти инструкции SIMD предоставляются в основном для ускорения приложений, обрабатывающих изображения, звук и видео. Некоторые компиляторы пытаются автоматически использовать параллелизм этого вида в программах на C, но все же более
надежным методом является разработка программ с использованием специальных типов векторных данных, поддерживаемых компиляторами, такими как GCC. Мы кратко
опишем этот стиль программирования в приложении в интернете OPT:SIMD в рамках
общего описания способов оптимизации программ в главе 5.

1.9.3. Важность абстракций в компьютерных системах
Абстракции – одна из важнейших концепций информатики. Например, одной из
рекомендуемых практик программирования является создание прос­того прикладного программного интерфейса (Application Program Interface, API) – набора функций,
позволяющих программистам использовать код, не вникая в особенности его работы.
Разные языки программирования предоставляют разные формы и уровни поддержки
абстракции, такие как объявления классов в Java и прототипов функций в C.
Мы уже познакомились с некоторыми абстракциями, изображенными на рис. 1.16,
которые встречаются в компьютерных системах. Архитектурный набор команд обес­
печивает абстракциюфизического устройства процессора. Согласно этой абстракции,
программа в машинном коде ведет себя так, будто она выполняется на процессоре,
который обрабатывает инструкции по одной. Физический процессор устроен намного
сложнее и может выполнять несколько инструкций параллельно, но всегда в соответствии с этой простой последовательной моделью. Придерживаясь одной и той же модели выполнения, разные реализации процессоров могут обрабатывать один и тот же
машинный код, предлагая различные затраты и производительность.
Виртуальная
машина
Virtual machine
Процессы
Processes
Архитектурный
Instruction
set
набор
инструкций
architecture

Виртуальная
память
Virtual memory
Файлы
Files

Operating system
Операционная
система

Processor
Процессор

Main
memory
Основная
память

Устройства
I/O
devices
ввода/
вывода

Рис. 1.16. Некоторые абстракции, предоставляемые компьютерной системой.
Основная задача компьютерных систем – обеспечить абстрактные представления
на разных уровнях, чтобы скрыть сложность фактических реализаций
На уровне операционной системы мы ввели три абстракции: файлы как абстракцию
устройств ввода/вывода, виртуальную память как абстракцию памяти программ и процессы как абстракцию выполняющихся программ. К этим абстракциям мы можем добавить еще одну: виртуальную машину, предоставляющую абстракцию всего компьютера,
включая операционную систему, процессор и программы. Идея виртуальной машины
была представлена компанией IBM еще в 1960-х годах, но лишь не так давно она стала привлекать особое внимание как способ управления компьютерами, которые должны запускать программы, разработанные для разных операционных систем (таких как

1.10. Итоги  61
Microsoft Windows, Mac OS X и Linux) или разных версий одной и той же операционной
системы.
Мы вернемся к этим абстракциям в следующих разделах книги.

1.10. Итоги
Компьютерные системы состоят из аппаратных и программных средств, которые
взаимо­действуют с целью выполнения прикладных программ. Информация внутри
компьютера представлена в виде групп битов, которые интерпретируются в зависимости от контекста. Программы транслируются другими программами в различные
формы. Сначала они представлены в виде исходного текста ASCII, затем преобразуются
компиляторами и компоновщиками в выполняемые файлы.
Процессоры читают и интерпретируют двоичные инструкции, находящиеся в основной памяти. Поскольку большую часть времени компьютеры тратят на копирование
данных между основной памятью, устройствами ввода/вывода и регистрами процессора, память системы образует некоторую иерархию, на вершине которой находятся
регистры процессора, далее следуют несколько уровней аппаратной кеш-памяти, затем
основная DRAM-память и дисковая память. Чем выше находится устройство памяти в
иерархии, тем выше его быстродействие и стоимость в пересчете на один бит. Кроме
того, устройства памяти, находящиеся выше в иерархии, служат кешем для устройств
памяти, находящихся ниже. Программисты могут опти­мизировать производительность своих программ на языке С, изучив и воспользовавшись особенностями иерархии
памяти.
Ядро операционной системы играет роль посредника между прикладными программами и аппаратными средствами. Оно реализует три фундаментальные абстракции:
1) файлы, абстрагирующие устройства ввода/вывода;
2) виртуальную память, абстрагирующую как основную память, так и дисковую;
3) процессы, абстрагирующие процессоры, основную память и устройства ввода/
вывода.
Наконец, сети дают компьютерным системам возможность обмениваться данными
между собой. С точки зрения конкретной системы, сеть есть не что иное, как устройство
ввода/вывода.

Библиографические заметки
Ритчи (Ritchie) написал интересные заметки о первых шагах языка С и систе­мы Unix
[91, 92]. Ритчи и Томпсон (Ritchie and Thompson) впервые опубли­ковали отчет о системе [93]. Зильбершатц, Галвин и Ганье (Silberschatz, Galvin and Gagne) представили исчерпывающую историю появления разных версий Unix [102]. Веб-страницы проектов
GNU (www.gnu.org) и Linux (www.linux.org) содержат текущую и историческую информацию разного характера. Информация о стандартах Posix тоже доступна онлайн (www.
unix.org).

Решения упражнений
Решение упражнения 1.1
Эта задача показывает, что закон Амдала применим не только к компьютерным системам.
1. В терминах уравнения 1.1 мы имеем α = 0,6 и k = 1,5. Если говорить более конкретно, преодоление 1500 километров через штат Монтана займет 10 часов.

62

 Глава 1. Экскурс в компьютерные системы
Остальная часть пути также займет 10 часов. Несложные вычисления дают нам
ускорение 25/(10 + 10) = 1,25×.
2. В терминах уравнения 1.1 мы имеем α = 0,6, и требуется определить k, чтобы
на выходе получить S = 1,67. Если говорить более конкретно, то чтобы ускорить
поездку в 1,67 раза, мы должны уменьшить общее время до 15 часов. На пре­
одоление части пути за пределами штата Монтана по-прежнему потребуется
10 часов, поэтому мы должны пересечь Монтану за 5 часов. Для этого нужно
ехать со скоростью 300 км/ч, что довольно много для грузовика!

Решение упражнения 1.2
Лучше всего действие закона Амдала исследовать на наглядных примерах. Эта задача требует взглянуть на уравнение 1.1 с необычной точки зрения.
Для решения данной задачи нужно просто применить уравнение. Итак, дано: S = 2 и
α = 0,8, мы должны найти k:




1
2 = –––––––––––––––––
(1 − 0,8) + 0,8/k

0,4 + 1,6/k = 1,0
k = 2,67

Часть

I

Структура программы
и ее выполнение
Н

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

Глава

2

Представление информации
и работа с ней

С

2.1.

Хранение информации.

2.2.

Целочисленное представление.

2.3.

Целочисленная арифметика.

2.4.

Числа с плавающей точкой.

2.5.

Итоги.



Библиографические заметки.



Домашние задания.



Решения упражнений.

овременные компьютеры хранят и обрабатывают информацию, предс­тавленную в
виде двоичных сигналов. Эти скромные двоичные знаки, или биты, формируют основу цифровой революции. Всем знакомая десятичная система счисления используется
уже 1000 лет; она изобретена в Индии. В XII веке арабские математики усовершенствовали ее, а на Западе она появилась в XIII веке «с помощью» итальянского математика
Леонардо Пизано (1170–1250), более известного под именем Фибоначчи. Использование деся­тичного исчисления естественно для людей, у которых на руках по десять
пальцев, однако для машин, способных хранить и обрабатывать информацию, более
приемлемы двоичные величины. Двоичные сигналы легко представлять, хранить и передавать, к примеру как наличие или отсутствие отверстия в перфоленте, как высокое
или низкое напряжение в электрической цепи или как магнитная стрелка, указывающая одним концом на север, а другим на юг. Электронные схемы для хранения информации и осуществления расчетов по двоичным сигналам очень просты и надежны; они
позволяют производителям объединять миллионы таких схем на одном кремниевом
кристалле.
Сам по себе отдельный бит не представляет особой ценности. Однако при объединении битов в группы и применении особой интерпретации, придающей определенное
значение разным комбинациям битов, можно представить элементы любого конечного
множества. Например, применяя систему двоичных чисел, можно использовать группы битов для кодирования неотрицательных чисел. Используя стандартную систему
кодировки символов, можно кодировать буквы и символы в документе. В данной главе
рассматриваются оба этих вида кодировок, а также кодировки для представления отрицательных чисел и аппроксимации вещественных чисел.

Представление информации и работа с ней

 65

Далее мы рассмотрим три важнейших способа представления чисел. Представление без знака основано на традиционном двоичном представлении чисел больше или
равных нулю. Представление в дополнительном коде является наиболее распространенным способом кодирования целых чисел со знаком, которые могут быть как положительными, так и отрицательными. Представление чисел с плавающей точкой – это
двоичная версия привычного нам обозначения вещественных чисел. Используя различные представления, компьютеры выполняют арифметические операции, например
сложение и умножение, анало­гичные соответствующим операциям с целыми и вещест­
венными числами.
Для чисел компьютеры используют ограниченное количество битов, поэтому некоторые операции могут вызывать переполнение, когда числа оказываются слишком большими, чтобы их можно было представить в двоичном виде ограниченным количеством
битов. Переполнение может приводить к поразительным результатам. Например, на
большинстве современных компьютеров (использующих 32-разрядное представление
типа int) расчет выражения
200 * 300 * 400 * 500

дает результат –884 901 888. Это противоречит правилам целочисленной арифметики –
произведение положительных чисел дало отрицательный результат.
С другой стороны, компьютерная целочисленная арифметика удовлетворяет многим известным правилам целочисленной арифметики. Например, ассоциативность и
коммутативность умножения; так, вычисление любого из следующих выражений на
языке С даст в результате –884 901 888:
(500
((500
((200
400

* 400)
* 400)
* 500)
* (200

*
*
*
*

(300
300)
300)
(300

*
*
*
*

200)
200
400
500))

Компьютер может и не дать ожидаемого результата, но он, по крайней мере, последователен!
Арифметика с плавающей точкой обладает совершенно другими математическими
свойствами. Произведение множества положительных чисел всегда будет положительным, хотя в случае переполнения вы получите в результате особую величину: +∞. С другой стороны, арифметика с плаваю­щей точкой не ассоциативна из-за конечной точности представления. Например, выражение (3.14+1е20)-1е20 на большинстве машин
вернет 0.0, а 3.14+(1е2О-1е20) вернет 3.14. Различия свойств целочисленной арифметики и арифметики с плавающей точкой проистекают из разницы в подходах к обработке
конечного количества их представлений – целочисленные представления охватывают
сравнительно небольшой диапазон значений, но точно отражают каждое число в этом
диапазоне, в то время как представления с плавающей точкой могут охватывать весьма
широкий диапазон значений, но конкретные значения отражают только приблизительно.
Путем изучения фактических числовых представлений можно определить, какие
диапазоны величин могут быть представлены, а также свойства различных арифметических операций. Понимание этого аспекта совершенно необходимо для создания программ, работающих корректно во всем диапазоне числовых величин и переносимых
на разные типы машин, операционных систем и компиляторов. Как вы узнаете далее,
из-за некоторых тонкостей компьютерной арифметики возникло множество уязвимостей. На заре компьютерных вычислений программные ошибки причиняли людям
неудобства, только когда они возникали случайно, теперь есть легионы хакеров, которые пытаются использовать любую найденную ошибку, чтобы получить несанкцио­
нированный доступ к чужим системам. Это возлагает на программистов особую обя-

66

 Глава 2. Представление информации и работа с ней

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

Для кодирования числовых величин в компьютерах используется несколько различных двоичных представлений. Вы познакомитесь с такими представлениями в главе 3, когда мы приступим к изучению темы программирования на машинном уровне.
А в этой мы опишем приемы кодирования и дадим некоторое практическое обоснование используемых представлений чисел.
Для выполнения математических операций непосредственно на уровне битов разработаны несколько способов. Знание этих способов чрезвычайно важно для понимания
машинного кода, сгенерированного компилятором, стремящегося оптимизировать вычисление арифметических выражений.
Наша трактовка основана на базовом наборе математических принципов. Сначала мы рассмотрим основные способы представления чисел, после чего выведем такие
свойства, как диапазон представимых чисел, их представление на двоичном уровне,
а также свойства арифметических операций. Мы полагаем, что данный материал полезнее рассматривать с такой абстрактной точки зрения, потому что программистам
необходимо обладать четким пониманием, как компьютерная арифметика соотносится с более знакомой арифметикой целых и вещественных чисел.
Язык программирования C++ основан на С и использует точно такие же представления данных и операции. Все сказанное в данной главе о С в равной степени относится
и к C++. Определение языка Java, напротив, создало новый набор стандартов для представления данных и операций. Если стандарт С предназначался для широкого применения, то стандарт Java довольно специфичен в отношении форматов представления
данных. В этой главе в нескольких местах мы особо выделим представления и операции, поддерживаемые в Java.

Представление информации и работа с ней

 67

История развития языка C
Как рассказывалось во врезке «Происхождение языка программирования C» в главе 1,
язык C был разработан Деннисом Ритчи из Bell Laboratories для использования с операционной системой Unix (также разработанной в Bell Labs). В то время большинство
системного программного обеспечения, такого как операционные систе­мы, приходилось
писать в основном на ассемблере, чтобы иметь доступ к низко­уровневым представлениям
различных типов данных. Например, на других языках высокого уровня того времени было
невозможно написать распределитель памяти, такой как библиотечная функция malloc.
Оригинальная версия языка C, созданная в Bell Labs, была задокументирована в первом
издании книги Брайана Кернигана и Денниса Ритчи [60]. Со временем язык C продолжал
эволюционировать благодаря усилиям нескольких групп стандартизации. Первая серьезная ревизия оригинальной версии C из Bell Labs привела к появлению стандарта ANSI C в
1989 году, разработанному группой под эгидой Американского национального института
стандартов (American National Standards Institute, ANSI). ANSI C сильно отличался от Bell
Labs C, особенно в способе объявления функций. ANSI C описан во втором издании книги
Кернигана и Ритчи [61], которая до сих пор считается одной из лучших книг о C.
Международная организация по стандартизации (International Standards Organization,
ISO) взяла на себя ответственность за продолжение стандартизации языка C, приняв в 1990
году версию, которая была практически такой же, как ANSI C, и получила название «ISO C90».
Эта же организация спонсировала обновление языка в 1999 году, в результате чего появился «ISO C99». Среди прочего, эта версия включала некоторые новые типы данных и
обеспечивала поддержку текстовых строк с символами, которых нет в английском языке. В
2011 году был утвержден более свежий стандарт, получивший название «ISO C11». И снова
в язык были добавлены новые типы данных и функции. Большинство из этих новшеств
сохранили обратную совместимость, то есть программы, написанные в соответствии с более ранним стандартом (по крайней мере, ISO C90), будут показывать такое же поведение
после компиляции с использованием компилятора, поддерживающего новые стандарты.
Коллекция компиляторов GNU (GNU Compiler Collection, GCC) может компилировать
программы в соответствии с соглашениями, принятыми в нескольких различных версиях языка C, опираясь на различные параметры командной строки, перечисленные в
табл. 2.1. Например, чтобы скомпилировать программу prog.c согласно стандарту ISO
C11, можно ввести такую команду:
linux> gcc –std=c11 prog.c
Таблица 2.1. Выбор разных версий C в командной строке GCC
Версия C
GNU 89
ANSI, ISO C90
ISO C99
ISO C11

Параметр командной строки GCC
без параметров, -std=gnu89
-ansi, -std=c89
-std=c99
-std=c11

Похожий эффект дают параметры -ansi и -std=c89 – код компилируется в соответствии со стандартом ANSI или ISO C90. (C90 иногда называют «C89», потому что разработка этого стандарта началась в 1989 году.) Параметр -std=c99 заставляет компилятор
следовать соглашениям ISO C99.
На момент написания этой книги, когда в командной строке не указывался ни один из
перечисленных параметров, программа компилировалась в соответствии с версией ISO
C90, при этом допускалось использовать некоторые особенности из стандартов C99 и
C11, а также из C++ и некоторые другие, поддерживаемые компилятором GCC. В рамках
проекта GNU была разработана версия, сочетающая в себе ISO C11 и другие возможности, которые можно включить с помощью парамет­ра командной строки -std=gnu11.
(В настоящее время эта реализация не завершена.) Со временем она станет версией по
умолчанию.

68

 Глава 2. Представление информации и работа с ней

Роль указателей в С
Указатели являются основной особенностью С. Они обеспечивают механизм ссылок на
элементы структур данных, включая массивы. Подобно переменной, указатель имеет
два аспекта: значение и тип. Значением является адрес местоположения определенного
объекта, а типом – тип объекта (например, целое число или число с плавающей точкой),
хранящегося в этом местоположении.
Истинное понимание указателей требует изучения их представления и реализации на
машинном уровне. Этому будет уделено основное внимание в главе 3, а кульминацией
станет более подробное описание в разделе 3.10.1.

2.1. Хранение информации
Вместо отдельных битов в большинстве компьютеров наименьшим элементом хранения в памяти являются блоки по 8 бит – байты. На машинном уровне программа видит
память как очень большой массив байтов, называемый виртуальной памятью. Каждый
байт имеет уникальный номер, называемый адресом, а множество всех возможных
адресов называется виртуальным адресным пространством. Как подсказывает название, виртуальное адресное прост­ранство – это всего лишь концептуальное представление памяти в программе. Фактическая реализация, описываемая в главе 9, основана
на использовании оперативной (RAM) и дисковой памяти, а также специальных аппаратных и программных средств операционной системы, обеспечивающих программы
таким массивом байтов, кажущимся непрерывным.
В следующих главах мы покажем, как компилятор и система времени выполнения
делят пространство памяти на более управляемые единицы, предназначенные для
хранения различных программных объектов, то есть данных, инструкций и управляющей информации. Распределение и управление памятью для разных частей программы используют различные механизмы. Все управление осуществляется в рамках
виртуального адресного пространства. Например, значение указателя в С, на что бы
он не указывал – на целое число, структуру или на какой-то другой объект, – является виртуальным адресом первого байта этого объекта в памяти. Компилятор С также имеет в своем распоряжении информацию о типе каждого указателя, благодаря
чему может генерировать разный машинный код для доступа к значению, на которое
ссылается указатель, в зависимости от типа этого значения. Однако, несмотря на то
что компилятор С обладает информацией о типе, сгенерированная им программа в
машинном коде не несет никакой информации о типах данных. На машинном уровне
каждый объект программы – это просто блок байтов, а сама программа – последовательность байтов.

2.1.1. Шестнадцатеричная система счисления
Один байт состоит из восьми бит. В двоичной системе интервал его значений от
000000002 до 111111112. В десятичном целочисленном представлении байт охватывает
диапазон от 010 до 25510. Двоичное представление слишком громоздкое, а для десятичного очень утомительно выполнять преобразования в битовые комбинации и обратно. Вместо всего этого битовые комбинации записываются в шестнадцатеричной
нотации. В шестнадцатеричной системе счисления используются цифры от 0 до 9 и
буквы от А до F. В табл. 2.2 показаны десятичные и двоичные значения шестнадцатеричных цифр. При записи в шестнадцатеричной форме байт охватывает диапазон
от 0016 до FF16.

2.1. Хранение информации  69
Таблица 2.2. Шестнадцатеричная форма записи чисел.
Каждая шестнадцатеричная цифра представляет одно из 16 возможных значений
Шестнадцатеричное число
Десятичная величина
Двоичная величина

0
0
0000

1
1
0001

2
2
0010

3
3
0011

4
4
0100

5
5
0101

6
6
0110

7
7
0111

Шестнадцатеричное число
Десятичная величина
Двоичная величина

8
8
1000

9
9
1001

A
10
1010

B
11
1011

C
12
1100

D
13
1101

E
14
1110

F
15
1111

В C числовые константы, начинающиеся с 0х или 0X, интерпретируются как шестнадцатеричные. Буквы от А до F можно записывать как в верхнем, так и в нижнем ре­гистре.
Например, число FA1D37B16 можно записать как 0xFA1D37B, как 0xfa1d37b или даже использовать верхний и нижний регистры, например 0xFa1D37b. В этой книге для представления шестнадцатеричных величин мы будем использовать обозначения, принятые в C.
Обычной задачей при работе с машинным кодом является преобразование вручную
между десятичным, двоичным и шестнадцатеричным представлениями битовых комбинаций. Преобразование между двоичным и шестнадцатеричным представлениями
выполняется просто, по одному шестнадцатеричному знаку за раз. Для этого можно использовать таблицу соответствий, представленную в табл. 2.2. Один из простых способов выполнения преобразований в уме – запоминание десятичных эквивалентов шестнадцатеричных чисел A, C и F. Шестнадцатеричные величины B, D и E можно перевести в
десятичные вычислением их величин относительно первых трех.
Например, предположим, что дано число 0x17А4С. Его можно преобразовать в двоичный формат путем развертывания каждой шестнадцатеричной цифры следующим
образом:
Шестнадцатеричное
Двоичное

1
0001

7
0111

3
0011

A
1010

4
0100

С
1100

Это дает двоичное значение 000101110011101001001100.
И наоборот, имея двоичное число 1111001010110110110011, преобразовать его в
шестнадцатеричное можно, предварительно разбив его на группы по четыре бита. Но
имейте в виду, что если общее число битов не кратно четырем, то двоичное представление нужно дополнить ведущими нулями слева. Затем каждая группа из четырех бит
преобразуется в соответствую­щее шестнадцатеричное число:
Двоичное
Шестнадцатеричное

11
3

1100
C

1010
A

1101
D

1011
B

0011
3

Упражнение 2.1 (решение в конце главы)
Выполните следующие преобразования:
1.
2.
3.
4.

0x39A7F8 – в двоичное.
Двоичное 1100100101111011 – в шестнадцатеричное.
0xD5E4C – в двоичное.
Двоичное 1001101110011110110101 – в шестнадцатеричное.

Когда величина х является степенью двойки, т. е. х = 2n для некоторого n, тогда x можно легко записать в шестнадцатеричной форме, помня, что двоичное представление x –

70

 Глава 2. Представление информации и работа с ней

это просто 1, за которой следует n нулей. Шестнадцатеричная цифра 0 – это четыре
двоичных нуля. Поэтому для n, записанного в форме i + 4j, где 0 ≤ i ≤ 3, x можно записать
как шестнадцатеричную цифру 1 (i = 0), 2 (i = 1), 4 (i = 2) или 8 (i = 3), за которой следует j
шестнадцатеричных нулей. В качестве примера рассмотрим число x = 2048 = 211. В данном случае мы имеем n = 11 = 3 + 4 · 2, что дает шестнадцатеричное число 0x800.
Упражнение 2.2 (решение в конце главы)
Заполните пустые клетки в следующей таблице, подставив десятичные и шестнадцатеричные представления различных степеней двойки:
Степень

2n (десятичное)

9

2n (шестнадцатеричное)

512

0x200

19
16 384
0x10000
17
32
0x80

В общем случае преобразование между десятичным и шестнадцатеричным представлениями требует использования операций умножения или деления. Чтобы преобразовать десятичное число x в шестнадцатеричное, можно многократно делить x на 16,
получая частное q и остаток r, так что x = q · 16 + r. Затем r преобразуется в шестнадцатеричную цифру, которая занимает младшую позицию, и процесс повторяется для
шестнадцатеричного числа q, доводится повторением процесса с q до наименьшего
значимого. Вот пример преобразования десятичного числа 314 156:


314,156 = 19,634 · 16 + 12 (C)



19,634 = 1,227 · 16 + 2

(2)



1,227 = 76 · 16 + 11

(B)



76 = 4 · 16 + 12

(C)

4 = 0 · 16 + 4

(4)



Из получившегося результата можно рассчитать шестнадцатеричное представление

0х4CB2C.

И наоборот, для преобразования шестнадцатеричного числа в десятичное нужно умножить каждую из шестнадцатеричных цифр на соответствующую степень числа 16.
Например, десятичный эквивалент числа 0x7AF рассчитывается следующим образом:
7 · 162 + 10 · 16 + 15 = 7 · 256 + 10 · 16 + 15 = 1792 + 160 + 15 = 1967.
Упражнение 2.3 (решение в конце главы)
Один байт можно представить двумя шестнадцатеричными цифрами. Заполните пустые
клетки таблицы, подставляя десятичные, двоичные и шестнадцатеричные величины разным
значениям байтов:

2.1. Хранение информации  71
Десятичное
0

Двоичное

Шестнадцатеричное

0000 0000

0x00

167
62
188
0011 0111
1000 1000
1111 0011
0x52
0xAC
0xE7

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

или


Преобразовать 0xabcd в десятичную форму
Преобразовать 123 в шестнадцатеричную форму.

Упражнение 2.4 (решение в конце главы)
Без преобразования чисел из десятичной системы счисления в двоичную попытайтесь решить следующие арифметические задачи, преобразуя ответы в шестнадцатеричную форму.
Подсказка: просто модифицируйте методы, используемые для выполнения десятичного сложения и вычитания для использования шестнадцатеричной системы счисления.
1. 0x503c + 0x8 =
2. 0x503c – 0x40 =
3. 0x503с + 64 =
4. 0x50ea – 0x503c =

2.1.2. Размеры данных
Каждый компьютер имеет размер машинного слова, указывающий номинальный
размер указателя. Поскольку виртуальные адреса кодируются такими словами, то наиболее важным системным параметром, определяемым размером слова, является максимальный размер виртуального адресного пространства. То есть для машины с размером слова в w бит диапазон виртуальных адресов охватывает от 0 до 2w – 1, обеспечивая
программе дос­туп максимум к 2w байт.

72

 Глава 2. Представление информации и работа с ней

В настоящее время наблюдается масштабный переход от машин с размером слова
в 32 бита к машинам с размером слова в 64 бита. В первую очередь такой переход был
выполнен в сфере масштабных научных вычислений и баз данных, далее последовали
настольные компьютеры и ноутбуки, а совсем недавно 64-разрядные процессоры появились на смартфонах. Размер слова в 32 бита ограничивает виртуальное адресное
пространство 4 гигабайтами (записывается 4 Гбайт), т. е. немногим более 4×109 байт.
Переход на 64-разрядный размер слова увеличил размер виртуального адресного пространства до 16 эксабайт, т. е. около 1,84×1019 байт.
Большинство 64-разрядных машин способны также выполнять программы, скомпилированные для 32-разрядной архитектуры, обеспечивая тем самым обратную совмес­
тимость. Так, например, если программу prog.c скомпилировать с директивой
linux> gcc –m32 prog.c

то эта программа будет корректно выполняться и на 32-разрядной машине, и на 64-разрядной. Программа, скомпилированная с директивой
linux> gcc –m64 prog.c

напротив, будет выполняться только на 64-разрядной машине. Далее мы будем называть программы «32-разрядными» или «64-разрядными», только чтобы подчеркнуть,
как они скомпилированы, но не для того, чтобы указать, на машинах какого типа они
могут выполняться.
Компьютеры и компиляторы поддерживают множество форматов данных, используя
различные способы кодирования данных, например целых чисел и чисел с плавающей
точкой, а также различные размеры. Например, многие машины имеют инструкции для
манипуляций с отдельными байтами, а также целыми числами длиной 2, 4 и 8 байт. Они
поддерживают числа с плавающей точкой длиной 4 и 8 байт.
Язык С поддерживает множество форматов данных, целых и с плавающей точкой.
В табл. 2.3 перечислены разные типы данных и их размеры в байтах. (В разделе 2.2 мы
обсудим связь между типичными размерами и определяемыми стандартом языка C.)
Точный размер некоторых типов данных зависит от способа компиляции программы.
Мы покажем размеры для типичных 32- и 64-разрядных программ. Целые числа могут
быть со знаком, т. е. способны представлять отрицательные и положительные значения, или без знака, т. е. способны представлять только неотрицательные значения. Тип
данных char представляет единственный байт. Несмотря на то что название char связано с запоминанием одного символа (character) в текстовой строке, его также можно
использовать для хранения целых величин. Типы данных short, int и long представляют значения разных размеров. Но даже при компиляции в 64-разрядной системе тип
данных int обычно имеет размер 4 байта. Тип данных long в 32-разрядных программах
обычно имеет размер 4 байта, а в 64-разрядных – 8 байт.
Новичок в C? Объявление указателей
Для любого типа данных T объявление

T *р;
указывает, что р – это переменная указателя, указывающего на объект типа T. Например:
char *p;

– это объявление указателя на объект типа char.

Чтобы избежать неоднозначности, связанной с «типичными» размерами и настройками компилятора, в ISO C99 был определен класс типов данных с фиксированными

2.1. Хранение информации  73
размерами, не зависящими от настроек компилятора и архитектуры компьютера. Среди них типы int32_t и int64_t, имеющие размеры 4 и 8 байт соответственно. Использование целочисленных типов фиксированного размера – лучший способ для программистов сохранить полный контроль над представлениями данных.
Большинство типов данных кодируют числовые значения как значения со знаком,
за исключением случаев, когда им предшествует ключевое слово unsigned или используется специальное объявление типа без знака фиксированного размера. Исключением является тип данных char. Большинство компиляторов и машин обрабатывают этот
тип как целочисленный тип со знаком, однако стандарт C не гарантирует этого. Вместо
этого, как показано в квадратных скобках, программист должен использовать объявление signed char, чтобы гарантировать 1-байтное представление со знаком. Однако во
многих контекстах поведение программы не зависит от того, является тип данных char
со знаком или без знака.
Язык C допускает разный порядок следования ключевых слов и позволяет включать
или опускать необязательные ключевые слова. Например, все следую­щие объявления
определяют один и тот же тип:
unsigned long
unsigned long int
long unsigned
long unsigned int

Мы же в этой книге будем следовать форме объявления, показанной в табл. 2.3.
Таблица 2.3. Типичные размеры (в байтах) основных типов данных в языке C.
Размер зависит от способа компиляции программы. Здесь показаны типичные размеры
для 32- и 64-разрядных программ
Объявление в языке С
Со знаком

Без знака

[signed] char
short
int
long
int32_t
int64_t
char *
float
double

unsigned char
unsigned short
unsigned
unsigned long
uint32_t
uint64_t

Байты
В 32-разрядных
В 64-разрядных
программах
программах
1
1
2
2
4
4
4
8
4
4
8
8
4
8
4
4
8
8

В табл. 2.3 также показано, что указатель (например, переменная, объявленная с типом char *) использует полноразмерное слово программы. Большинство машин также
поддерживают два разных формата с плавающей точкой: одинарной точности, в C объявляется как float, и двойной точности, объявляется как double. Для переменных этих
типов отводится 4 и 8 байт соответственно.
Программисты должны стремиться делать свои программы переносимыми между
разными машинами и компиляторами. Один из аспектов переносимос­ти – нечувствительность к точным размерам различных типов данных. Стандарты C устанавливают
нижние границы числовых диапазонов для разных типов данных, как будет показано
ниже, но не устанавливают верхних границ (кроме типов фиксированного размера). На
32-разрядных машинах и в 32-разрядных программах, преобладавших в период примерно с 1980-е по 2010-е годы, многие программы писались с учетом размеров, ука-

74

 Глава 2. Представление информации и работа с ней

занных в табл. 2.3 для 32-разрядных программ. С переходом на 64-разрядные машины
многие скрытые зависимости от размера слова стали приводить к ошибкам при пере­
носе старых программ на новые машины. Например, многие программис­ты традиционно полагали, что объект, объявленный с типом int, можно использовать для хранения
указателя. Это предположение было справедливо для большинст­ва 32-разрядных программ, но привело к проблемам в 64-разрядных программах.

2.1.3. Адресация и порядок следования байтов
Для программных объектов, занимающих несколько байтов, необходимо установить
два правила: каков будет адрес объекта и как должны располагаться байты в памяти.
Практически во всех машинах такие объекты хранятся в виде непрерывных последовательностей байтов, а адресом многобайтного объекта служит наименьший адрес ячейки памяти. Например, предположим, что переменная x типа int имеет адрес 0x100, т. е.
выражение взятия адреса &x вернет 0x100. Тогда четыре байта, составляющих значение
переменной x, будут храниться в ячейках памяти 0x100, 0x101, 0x102 и 0x103.
Существует два общепринятых правила, определяющих порядок следования байтов в
таких объектах. Рассмотрим целое число длиной w бит, имеющее битовое представление
[xw−1, xw−2, ..., x1, x0], где xw−1 – наибольший значащий бит, а х0 – наименьший значимый. Если
предположить, что w кратно восьми, то эти биты можно сгруппировать в байты, где наибольший значащий байт будет включать биты [xw−1, xw−2, ..., xw−8], наименьший значащий –
биты [x7, x6, ..., x0], а другие байты будут включать биты из середины. В одних машинах
объект будет храниться в памяти в порядке от наименьшего значащего байта к наибольшему значащему, а в других – наоборот. Первое правило: порядок, когда первым следует
наименьший значащий байт, называется обратным, или остроконечным (little endian).
Второе правило: порядок, когда первым следует наибольший значащий байт, называется
прямым, или тупоконечным (big endian).
Теперь вернемся к нашему примеру с переменной x типа int с адресом 0x100, хранящей шестнадцатеричное значение 0x01234567. Порядок расположения байтов в ячейках
с адресами от 0x100 до 0x103 зависит от типа машины:
Прямой порядок (big endian – тупоконечный):

...

0x100

0x101

0x102

0x103

01

23

45

67

...

Обратный порядок (little-endian – остроконечный):

...

0x100

0x101

0x102

0x103

67

45

23

01

...

Обратите внимание, что в слове 0x01234567 старший байт имеет шестнадцатеричное
значение 0x01, а младший – 0x67.
В большинстве Intel-совместимых машин используется исключительно обратный
(little-endian) порядок следования байтов. В большинстве машин, выпускаемых IBM и
Oracle (в 2010 году компания Oracle приобрела компанию Sun Microsystems и вошла
в число производителей вычислительных машин), используется прямой (big-endian)
порядок следования байтов. Обратите внимание, что мы сказали «в большинстве». Соглашения не разделяются строго по корпоративным границам. Например, обе компании, IBM и Oracle, производят машины, использующие Intel-совместимые процессоры,
поддерживающий обратный (little-endian) порядок следования байтов. Многие современные микро­процессоры поддерживают прямой (big-endian) порядок байтов, и их

2.1. Хранение информации  75
можно настроить для работы как с прямым (big-endian), так и с обратным (little-endian)
порядком следования байтов. Однако на практике порядок байтов фиксируется с выбором конкретной операционной системы. Например, микропроцессоры ARM, используемые во многих сотовых телефонах, поддерживают оба порядка следования байтов,
но две наиболее распространенные операционные систе­мы – Android (от Google) и IOS
(от Apple) – используют только обратный (little-endian) порядок байтов.
О происхождении терминов «little endian» (остроконечный)
и «big endian» (тупоконечный)
Вот как в 1726 году Джонатан Свифт описывал историю последователей разбивания
яйца с тупого и острого конца:
...Лиллипутия и Блефуску... Эти две могущественные державы ведут между собой
ожесточенную войну на протяжении тридцати шести лун. Поводом к войне послужили следующие обстоятельства. Всеми разделяется убеждение, что вареные яйца при
употреблении в пищу испокон веков разбивались с тупого конца; но дед нынешнего императора, будучи ребенком, порезал себе палец за завтраком, разбивая яйцо
означенным древним способом. Тогда император, отец ребенка, обнародовал указ,
предписывающий всем его подданным под страхом строгого наказания разбивать
яйца с острого конца. Этот закон до такой степени озлобил население, что, по словам
наших летописей, стал причиной шести восстаний, во время которых один император потерял жизнь, а другой – корону. Мятежи эти постоянно разжигались монархами
Блефуску, а после их подавления изгнанники всегда находили приют в этой империи.
Насчитывают до одиннадцати тысяч фанатиков, которые в течение этого времени
пошли на казнь, лишь бы не разбивать яйца с острого конца. Были напечатаны сотни
томов, посвященных этой полемике, но книги Тупоконечников давно запрещены, и
вся партия лишена законом права занимать государственные должности.
В свое время Свифт зло иронизировал по поводу непрекращающихся стычек между Англией (Лиллипутия) и Францией (Блефуску). Дэнни Коэн (Danny Cohen), родоначальник сетевых протоколов, впервые применил эти термины для описания упорядочения байтов [24], после чего они получили широкое распространение.

Люди оказываются удивительно эмоциональными, вступая в спор о том, какой порядок байтов правильный. На самом деле термины «little endian» (остроконечный) и
«big endian» (тупоконечный) заимствованы из книги Джонатана Свифта «Путешествия
Гулливера», где описывается вражда двух группировок, спорящих о том, с какого конца
следует разбивать сваренное всмятку яйцо: с тупого или острого. Точно так же, как и
в случае с пресловутым яйцом, не сущест­вует технологического смысла ставить один
способ упорядочения байтов выше другого, и фактически вся полемика сводится к банальному пикированию на общественно-политические темы. Пока будет существовать
выбор из двух способов и его приверженцы будут последовательны, до тех пор выбор
будет произвольным.
Для большинства прикладных программистов порядок следования байтов, используемый их машинами, полностью невидим; программы, скомпилированные для
любого класса машин, дают идентичные результаты. Однако иног­да порядок байтов
становится проблемой. Первый случай – когда двоичные данные передаются по сети
между разными машинами. Проблема возникает, когда данные, созданные машиной
с обратным (little-endian) порядком байтов, отправляются на машину с прямым (bigendian) порядком байтов или наоборот, в результате чего для принимающей программы байты в словах следуют не по порядку. Чтобы избежать таких проблем, сетевые
приложения должны следовать установленным соглашениям о порядке следования

76

 Глава 2. Представление информации и работа с ней

байтов: отправляющая машина должна преобразовывать данные из внутреннего
представления гарантированно в сетевой стандарт, а принимающая – из сетевого
стандарта в свое внутреннее представление. Мы увидим примеры этих преобразований в главе 11.
Второй случай, когда порядок байтов приобретает важность, – интерпретация последовательностей байтов, представляющих целочисленные данные. Это часто имеет
место при исследовании машинного кода программ. Например, в файле с машинным
кодом для процессора Intel x86_64 имеется следующая строка:
4004d3:

01 05 43 0b 20 00

add

%eax,0x200b43(%rip)

Эта строка созданадизассемблером, инструментом, извлекающим последовательности инструкций из выполняемого файла программы. Более подробно такие инструменты и способы интерпретации подобных строк мы рассмотрим в главе 3. А пока прос­
то заметим, что эта строка содержит последовательность шестнадцатеричных байтов
01 05 43 0b 20 0 – представление инструкции, складывающей слово данных со значением, хранящимся по адресу, который сам вычисляется сложением числа 0x200b43 с содержимым счетчика инструкций – адреса следующей инструкции. Если взять последние
четыре байта данной последовательности 43 0b 20 00 и записать их в обратном порядке, то получится 00 20 0b 43. Если отбросить начальный ноль, то получится значение
0x200b43, записанное в правой час­ти. То, что байты следуют в обратном порядке, – обычное дело для машин с обратным (little-endian) порядком байтов. Естественный способ
записи последовательности байтов следующий: младший байт записывается слева, а
старший – справа, хоть это и противоречит обычному способу записи чисел, когда старший значащий разряд записывается слева, а младший – справа.
Третий случай, когда порядок байтов важен, – при разработке программ, которые,
что называется, действуют «в обход» обычной системы типов. В языке С это можно
сделать с использованием приведения (cast) или объединения (union), чтобы получить
возможность ссылаться на объект, который может интерпретироваться по-разному.
В общем и целом подобные «трюки», мягко говоря, не поощряются большинством программистов, однако они могут быть полезными и даже необходимыми для программирования на системном уровне.
В листинге 2.1 показан код на С, в котором используется приведение типа для доступа и вывода различных программных объектов в виде последовательнос­тей байтов.
Для определения типа данных byte_pointer как указателя на объект типа unsigned char
используется typedef. Такой указатель на байт ссылается на последовательность байтов,
в которой каждый байт интерпретируется как неотрицательное целое число. Первая
подпрограмма show_bytes принимает адрес последовательности байтов через параметр
типа byte_pointer и счетчик байтов. Счетчик байтов передается через параметр типа
size_t – предпочтительный тип для представления размеров разных структур данных.
Она выводит отдельные байты в шестнадцатеричном виде. Директива форматирования %.2x указывает, что целое число должно быть преобразовано в шестнадцатеричный
вид, содержащий не меньше двух цифр.
Листинг 2.1. Функции вывода программных объектов в виде последовательностей байтов.
Они используют приведение типа, чтобы выполнить свою работу в обход системы типов.
Подобные функции легко определить для других типов данных
1 #include
2
3 typedef unsigned char *byte_pointer;
4
5 void show_bytes(byte_pointer start, size_t len) {

2.1. Хранение информации  77
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

int i;
for (i = 0; i < len; i++)
printf(" %.2x", start[i]);
printf("\n");
}
void show_int(int x) {
show_bytes((byte_pointer) &x, sizeof(int));
}
void show_float(float x) {
show_bytes((byte_pointer) &x, sizeof(float));
}
void show_pointer(void *x) {
show_bytes((byte_pointer) &x, sizeof(void *));
}

Процедуры show_int, show_float и show_pointer демонстрируют, как можно использовать процедуру show_bytes для вывода в виде последовательностей байтов программных объектов с типами int, float и void * соответственно. Обратите внимание, что они
просто передают указатель &x на свой аргумент x в вызов show_bytes, приводя указатель
к типу unsigned char *. Эта операция приведения типа указывает компилятору, что программа будет интерпретировать указатель как указатель на последовательность байтов,
а не на объект исходного типа данных. Этот указатель будет затем указывать на самый
младший адрес, занимаемый объектом.
Эти процедуры используют оператор sizeof для определения количества байтов,
зани­маемых объектом. Как правило, выражение sizeof(T) возвращает количество байтов, необходимых для хранения объекта типа T. Использование sizeof вместо фиксированного значения – это одно из условий написания кода, переносимого на разные
типы машин.
Мы запустили код из листинга 2.2 на нескольких разных машинах и получили результаты, показанные в табл. 2.4. В эксперименте использовались следующие машины:
Linux 32

с процессором Intel IA32 и работающая под управлением Linux;

Windows

с процессором Intel IA32 и работающая под управлением Windows;

Sun



с процессором Sun Microsystems SPARC и работающая
под управлением Solaris (в настоящее время эти машины
выпускаются Oracle);

Linux 64

с процессором Intel x86-64 и работающая под управлением Linux.

Листинг 2.2. Примеры представления в виде последовательностей байтов.
Этот код выводит объекты данных в виде последовательностей байтов

code/data/show-bytes.c
1 void test_show_bytes(int val) {
2
int ival = val;
3
float fval = (float) ival;
4
int *pval = &ival;
5
show_int(ival);
6
show_float(fval);
7
show_pointer(pval);
8 }

code/data/show-bytes.c

78

 Глава 2. Представление информации и работа с ней

Таблица 2.4. Представления различных значений данных в виде последователь­ностей
байтов. Результаты для int и float идентичны, за исключением порядка байтов.
Значения указателя зависят от типа машины
Машина

Значение

Тип

Байты
(в шестнадцатеричном виде)

Linux 32
Windows
Sun
Linux 64

12345
12345
12345
12345

int
int
int
int

39
39
00
39

30
30
00
30

00
00
30
00

00
00
39
00

Linux 32
Windows
Sun
Linux 64

12345.0
12345.0
12345.0
12345.0

float
float
float
float

00
00
46
00

e4
e4
40
e4

40
40
e4
40

46
46
00
46

Linux 32
Windows
Sun
Linux 64

&ival
&ival
&ival
&ival

int
int
int
int

e4
b4
ef
b8

f9
cc
ff
11

ff
22
fa
e5

bf
00
0c
ff ff 7f 00 00

*
*
*
*

Аргумент 12345 имеет шестнадцатеричное представление 0x00003039. Для типа int
получаем идентичные результаты для всех машин, кроме порядка байтов. Можно заметить, что наименьший значащий байт 0x39 выводится первым в Linux 32, Windows
и Linux 64, что указывает на использование обратного (little-endian) порядка байтов
в этих машинах, а в Sun – последним, что указывает на использование прямого (bigendian) порядка байтов. Подобным же образом идентичны байтовые представления
данных типа float, кроме порядка байтов. Однако значения указателей абсолютно различны. В разных конфигурациях машин и операционных систем используются разные
правила распределения памяти. Стоит отметить одну особенность: машины с Linux 32,
Windows и Sun используют 4-байтные адреса, а Linux 64 – 8-байтные.

Новичок в C? Об определении своих имен для типов данных
Объявление typedef в языке С дает возможность определять свои имена для типов данных. Это может заметно улучшить читаемость кода, потому что глубоко вложенные объявления типов порой очень сложно расшифровывать.
Синтаксис typedef подобен синтаксису объявления переменной, за исключением
того, что в данном случае используется имя типа, а не переменной. То есть объявление
переменной с типом byte_pointer в листинге 2.1 равноценно объявлению переменной
с типом unsigned char. Например, строки
typedef int *int_pointer;
int_pointer ip;

определяют тип int_pointer как указатель на значение типа int и объявляют переменную ip с этим типом. Как вариант эту переменную можно было объявить как
int *ip;

2.1. Хранение информации  79
Новичок в C? Форматированный вывод с printf
Функция printf (наряду со своими «сестрами» fprintf и sprintf) дает возможность управлять форматом вывода данных. Первый аргумент – это строка описания формата, а все
остальные – значения для вывода. Внутри строки формата каждая последовательность
символов, начинающаяся с «%», определяет, как форматировать соответствующий аргумент. Вот несколько типичных примеров: %d – для вывода десятичного целого числа,
%f – числа с плавающей точкой и %с – символа, код которого задан в аргументе.
Определение формата для вывода типов данных фиксированного размера, таких как
немного сложнее, как будет описано во врезке «Еще о целочисленных типах
фиксированного размера» далее в этой главе.
int_32t,

Обратите внимание: даже притом что целочисленные данные и данные с плавающей
точкой представляют числовое значение 12345, они выглядят совершенно по-разному:
0x00003039 представляет целое число и 0x4640E400 – число с плавающей запятой. Причина в том, что для представления этих двух типов данных используются разные схемы кодирования. Если развернуть эти шестнадцатеричные представления в двоичную
форму и сдвинуть их соответствующим образом, то мы увидим последовательность
из 13 совпадающих битов, обозначенных звездочками ниже:
0
0
0
0
3
0
3
9
00000000000000000011000000111001
*************
4
6
4
0
E
4
0
0
01000110010000001110010000000000

Это не случайно. Мы еще вернемся к этому примеру, когда начнем изучать форматы
представления чисел с плавающей точкой.
Новичок в C? Указатели и массивы
В функции show_bytes (листинг 2.1) видна тесная взаимосвязь между указателями и массивами (подробно рассматривается в разделе 3.8). Видно, что эта функция принимает
аргумент start типа byte_pointer (который определен как указатель на unsigned char).
Однако в строке 8 можно видеть ссылку start[i] на массив. В языке С можно разыменовать указатель, используя форму записи обращения к массиву и указатель для ссылки
на элементы массива. В данном примере ссылка start[i] указывает, что требуется прочитать байт в i-й позиции в массиве, находящемся по адресу start.

Новичок в C? Создание и разыменование указателей
В строках 13, 17 и 21 в листинге 2.1 можно видеть две операции, уникальные для С
(и C++). Оператор & «взятия адреса» создает указатель. Во всех трех строках выражение &x создает указатель на ячейку, содержащую переменную х. Тип указателя зависит
от типа х, соответственно, эти три указателя получают типы int *, float * и void **. (Тип
данных void * – это особый тип указателя без информации о типе.)
Оператор приведения типа преобразует один тип данных в другой. Следовательно,
приведение (byte_pointer) &x указывает на то, что какой бы тип не имел ранее указатель &х, теперь он является указателем на данные типа unsigned char. Приведение
типа не изменяет сам указатель, эта операция просто сообщает компилятору, что данный
указатель ссылается на данные нового типа.

80

 Глава 2. Представление информации и работа с ней
Создание таблицы ASCII
Командой man ascii можно вывести таблицу с кодами символов ASCII.
Упражнение 2.5 (решение в конце главы)
Рассмотрим три следующих вызова show_bytes:
int val = 0x87654321;
byte_pointer valp = (byte_pointer) &val;
show_bytes(valp, 1); /* A. */
show_bytes(valp, 2); /* B. */
show_bytes(valp, 3); /* C. */

Укажите, какие значения будут выведены каждым вызовом на машинах с обратным (littleendian) и прямым (big-endian) порядками следования байтов.
A. Обратный порядок: __________

Прямой порядок: __________

B. Обратный порядок: __________

Прямой порядок: __________

C. Обратный порядок: __________

Прямой порядок: __________

Упражнение 2.6 (решение в конце главы)
Используя show_int и show_float, мы определили, что целое число 3510593 имеет шестнадцатеричное представление 0x00359141, тогда как число с плавающей точкой 3510593.0
имеет шестнадцатеричное представление 0x4A564504.
1. Запишите эти два шестнадцатеричных значения в двоичной форме.
2. Сдвиньте две строки относительно друг друга до совпадения битов. Сколько битов совпадает?
3. Какие части строк не совпадают?

2.1.4. Представление строк
Строка в языке С кодируется массивом символов и завершается нулем (нулевым
символом). Значения символов определяются кодировкой, самой распространенной из которых является ASCII. Следовательно, если запустить процедуру show_bytes
с аргументами "12345" и 6 (чтобы включить завершающий нулевой символ), тогда в
результате получится последовательность байтов 31 32 33 34 35 00. Обратите внимание, что в кодировке ASCII десятичная цифра x имеет код 0x3x, а завершающий
байт имеет шестнадцатеричное представление 0x00. Тот же результат будет получен
в любой системе, использующей ASCII, независимо от порядка следования байтов и
размера слова. Вследствие этого текстовые данные менее зависимы от платформы,
нежели двоичные.
Упражнение 2.7 (решение в конце главы)
Что выведет следующий вызов show_bytes?
const char *s = "abcdef";
show_bytes((byte_pointer) s, strlen(s));

Обратите внимание, что буквы от «а» до «z» имеют коды ASCII от 0x61 до 0x7а.

2.1. Хранение информации  81

Стандарт Юникода для представления текста
Набор символов ASCII подходит для кодировки документов на английском языке, однако в нем отсутствуют некоторые буквы, например французского языка, такие как «ҫ».
Данный набор символов абсолютно непригоден для представления документов на таких
языках, как греческий, русский и китайский.
За прошедшие годы было разработано и предложено множество методов кодирования текста на разных языках. Консорциум Unicode Consortium разработал наиболее полный стандарт кодирования текста. Текущий стандарт Unicode (версия 7.0) включает более
100 000 символов для широкого спектра языков, в том числе языки Древнего Египта и
Вавилона. К их чести, технический комитет Unicode Technical Committee отклонил предложение включить стандартное письмо Клингонов – вымышленной цивилизации из телесериала Star Trek (Звездный путь).
Базовая кодировка, известная как «универсальный набор символов» Юникод, использует 32-разрядное представление символов. Казалось бы, это требует, чтобы каждый
символ в текстовой строке занимал 4 байта. Однако возможны альтернативные варианты
кодирования, когда для наиболее распространенных символов требуется всего 1 или 2
байта, а для менее распространенных – больше. В частности, в кодировке UTF-8 каждый
символ кодируется как последовательность байтов так, что для стандартных символов
ASCII используются те же однобайтовые коды, что и в ASCII, в том смысле, что все последовательности символов ASCII выглядят точно так же и в кодировке UTF-8.
Язык программирования Java использует Юникод для представления своих строк.
Программные библиотеки поддержки Юникода доступны также для C.

2.1.5. Представление программного кода
Рассмотрим следующую функцию на С:
1 int sum(int x, int y) {
2
return x + y;
3 }

При компилировании на машинах, которые мы использовали в экспериментах
выше, будет создан машинный код, имеющий следующее представление в виде байтов:
Linux 32

55 89 e5 8b 45 0c 03 45 08 c9 c3

Windows

55 89 e5 8b 45 0c 03 45 08 5d c3

Sun

81 c3 e0 08 90 02 00 09

Linux 64

55 48 89 e5 89 7d fc 89 75 f8 03 45 fc c9 c3

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

82

 Глава 2. Представление информации и работа с ней

2.1.6. Введение в булеву алгебру
В основе кодирования, хранения и обработки информации компьютерами лежат
двоичные значения, поэтому вокруг значений 0 и 1 развился обширный массив математических дисциплин. Все началось с работы Джорджа Буля (George Boole; 1815–1864),
вышедшей в 1850 году и получившей название булева алгебра. Джордж Буль заметил,
что, кодируя логические значения «истина» и «ложь» в виде двоичных значений 1 и 0,
можно сформулировать алгебру, отражающую основные принципы логических рассуждений.
Простейшая булева алгебра определяется на множестве двух элементов {0,1}.
В табл. 2.5 показано несколько операций в этой алгебре. Символы представления операций подобраны так, что совпадают с битовыми операторами в языке С, которые мы
рассмотрим ниже. Булева операция ~ соответствует логической операции НЕ, обозначаемой в логике высказываний символом ¬. То есть говорится, что ¬P истинно, когда P
не истинно, и на­оборот. Соответственно, ~р равно 1, когда ~р равно нулю, и наоборот.
Булева операция & соответствует логической операции И, обозначаемой в логике высказываний символом ∧. Мы говорим, что Р ∧ Q выполняется, когда и Р и Q истинны.
Соответственно, р & q равно единице только тогда, когда p = 1 и q = 1. Булева операция |
соответствует логической операции ИЛИ, обозначаемой в логике высказываний символом ∨. Мы говорим, что Р ∨ Q выполняется, когда истинно Р или Q. Соответственно, р | q
равно единице только тогда, когда р = 1 или q = 1. Булева операция ^ соответствует логической операции ИСКЛЮЧАЮЩЕЕ ИЛИ, обозначаемой в логике высказываний символом ⊕. Мы говорим, что Р ⊕ Q выполняется, когда истинно Р или Q, но не оба. Соответственно, р ^ q равно единице, когда либо р = 1 и q = 0, либо p = 0 и q = 1.
Таблица 2.5. Операции булевой алгебры. Двоичные значения 1 и 0 кодируют логические
значения «истина» и «ложь», а операции ~, &, |, и ^ кодируют логические операции НЕ, И,
ИЛИ и ИСКЛЮЧАЮЩЕЕ ИЛИ соответственно
~
0
1

1
0

&

0

1

|

0

1

^

0

1

0
1

0
0

0
1

0
1

0
1

1
1

0
1

0
1

1
0

Клод Шэннон (Claude Shannon; 1916–2001), ставший впоследствии основоположником теории информации, впервые обратил внимание на связь булевой алгебры и дискретной логики. В своей магистерской диссертации (в 1937 году) он доказал, что булеву
алгебру можно применять к проектированию и анализу сетей электромеханических
реле. Несмотря на то что с тех пор компьютерные технологии продвинулись очень далеко, булева алгебра по-прежнему играет центральную роль в проектировании и анализе
цифровых систем.
Четыре логические операции можно распространить на битовые векторы, строки
нулей и единиц некоторой фиксированной длины w. Мы определяем операции над битовыми векторами как применение к соответствующим элементам аргументов. Пусть
a и b обозначают битовые векторы [aw–1, aw–2, ..., a0] и [bw−1, bw−2, ..., b0] соответственно. Мы
определяем a & b как битовый вектор с длиной w, где i-й элемент равен значению выражения ai & bi для 0 ≤ i < w. Аналогичным образом на битовые векторы распространяются
операции |, ^ и ~.
В качестве примера рассмотрим случай, когда w = 4 и аргументами являются векторы
a = [0110] и b = [1100]. Четыре операции a & b, a | b, a ^ b и a ~ b дают в результате:
0110
& 1100

0110
| 1100

0100

1110

0110
^ 1100
1010

~ 1100
0011

2.1. Хранение информации  83
Одним из полезных применений битовых векторов является представление конечных множеств. Например, любое подмножество A ⊆ {0, 1, ..., w − 1} можно представить
в виде битового вектора [aw−1, ..., a1, a0], где ai = 1 в том и только том случае, если i ∈ A.
Например, помня, что aw–1 записывается слева, а а0 – справа, вектор а = [01101001] представляет множество А = {0, 3, 5, 6}, а вектор b = [01010101] – множество В = {0, 2, 4, 6}. При
такой интерпретации булевы операции | и & соответствуют объединению и пересечению множеств, операция ~ соответствует дополнению множества. Например, операция
а & b дает битовый вектор [01000001], тогда как A ∩ B = {0, 6}.
Мы еще увидим примеры практического применения представления множеств в
виде битовых векторов. Например, в главе 8 будет показано, что существует ряд различных сигналов, которые могут прервать выполнение программы. Мы сможем выборочно
включать или отключать различные сигналы, задав маску в виде битового вектора, где
1 в битовой позиции i указывает, что сигнал i включен, а 0 – отключен. То есть маска
представляет собой множество разрешенных сигналов.
Упражнение 2.8 (решение в конце главы)
Заполните следующую таблицу, подставив результаты булевых операций с битовыми векторами.
Операция

Результат

a

[01101001]

b

[01010101]

~a
~b
a&b
a|b
a^b

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

Источник
света
Light sources

Экран
Glass
screen

Красный
Red
Наблюдатель
Observer
Зеленый
Green

Синий
Blue

84

 Глава 2. Представление информации и работа с ней
Мы можем создать восемь разных цветов, включая (1) или выключая (0) тот или иной источник света:
Красный
(R)

Зеленый
(G)

Синий
(B)

Цвет

0
0
0
0
1
1
1
1

0
0
1
1
0
0
1
1

0
1
0
1
0
1
0
1

Черный
Синий
Зеленый
Голубой
Красный
Алый
Желтый
Белый

Каждый из этих цветов можно представить в виде битового вектора с длиной 3 и применить к ним булевы операции.
1. Дополнение цвета формируется выключением включенного цвета и включением выключенного. Какие цвета будут считаться дополнениями к восьми перечисленным?
2. Опишите эффект применения булевых операций к следующим цветам:
Синий | Зеленый

=

Желтый & Голубой =
Красный ^ Алый

=

Приложение в интернете DATA:BOOL. Еще о булевой алгебре и булевых кольцах
Логические операции |, & и ~, применяемые к битовым векторам с длиной w, образуют
булеву алгебру для любого целого числа w > 0. Самым простым является случай, когда
w = 1 и имеется только два элемента, но в более общем случае имеется 2w битовых векторов с длиной w. Булева алгебра обладает многими свойствами арифметики целых чисел.
Например, подобно тому, как умножение распределяется по сложению, записывается
как a · (b + c) = (a · b) + (a · c), логическая операция & распределяет по |, записывается
как a & (b | c) = (a & b) | (а & c). Вдобавок, однако, логическая операция | распределяется
по &, поэтому можно написать a | (b & c) = (a | b) & (a | c), тогда как следующее тождество
a + (b · c) = (a + b) · (a + c) неверно для любых целых чисел.
Рассматривая операции ^, & и ~, применяемые к битовым векторам с длиной w, мы
получаем другую математическую форму, известную как булево кольцо. Булевы кольца
имеют много общих свойств с целочисленной арифметикой. Например, одно из свойств
целочисленной арифметики состоит в том, что каждое значение x имеет аддитивную
инверсию –x, такую что x + –x = 0. Аналогичное свойство имеет место для булевых колец,
где ^ рассматривается как операция «сложения», но в данном случае каждый элемент является своей собственной аддитивной инверсией. То есть a ^ a = 0 для любого значения a.
(Здесь мы используем 0 для представления битового вектора из одних нулей.) Все это
справедливо и для одиночных битов, поскольку 0 ^ 0 = 1 ^ 1 = 0, и для битовых векторов.
Это свойство сохраняется даже после переупорядочения членов выражения, поэтому
(a ^ b) ^ a = b. Это свойство приводит к некоторым интересным результатам и хитростям,
которые мы исследуем в упражнении 2.10.

2.1. Хранение информации  85

2.1.7. Битовые операции в С
Одной из полезных особенностей С является поддержка булевых операций с битами.
На самом деле символы, использованные для представления булевых операций, применяются и в С: | – ИЛИ (OR), & – И (AND); ~ – НЕ (NOT) и ^ – ИСКЛЮЧАЮЩЕЕ ИЛИ
(EXCLUSIVE-OR). Они применяются к данным любых целочисленных типов, включая
перечисленные в табл. 2.3. Вот некоторые примеры вычисления выражений с данными
типа char:
Двоичное
выражение

Выражение C

Двоичный
результат

Шестнадцатеричный
результат

~0x41

~[0100 0001]

[1011 1110]

0xBE

~0x00

~[0000 0000]

[1111 1111]

0xFF

0x69 & 0x55

[0110 1001] & [0101 0101]

[0100 0001]

0x41

0x69 | 0x55

[0110 1001] | [0101 0101]

[0111 1101]

0x7D

Как показывают примеры, лучшим способом определения результата выражения
с битовыми операциями является преобразование шестнадцатеричных аргументов в
двоичные представления, выполнение операции с этими двоичными представлениями
и обратное преобразование в шестнадцатеричную форму.
Упражнение 2.10 (решение в конце главы)
В качестве примера применимости свойства a ^ a = 0 к любому битовому вектору a рассмот­
рим следующую программу:
1
2
3
4
5

void inplace_swap(int *x,
*y = *x ^ *y; /* Шаг 1
*x = *x ^ *y; /* Шаг 2
*y = *x ^ *y; /* Шаг 3
}

int *y) {
*/
*/
*/

Уже по названию можно утверждать, что суть этой процедуры заключается в перестановке значений, хранящихся в ячейках, на которые ссылаются переменные-указатели x и y.
Обратите внимание, что в отличие от обычной методики перестановки двух значений, здесь
нет необходимости в третьей ячейке для временного хранения одного из значений на время перемещения другого. Такой способ не дает выигрыша в производительности; выигрыш
имеет место лишь в форме интеллектуального развлечения.
Начиная с исходных значений а и b в ячейках, на которые ссылаются x и y, заполните
следующую таблицу значениями, получающимися сохраненными в ячейках после каждого
шага. Для демонстрации эффекта используйте свойства ^. Помните о том, что каждый элемент является собственной аддитивной инверсией (а ^ a = 0).
Шаг

*x

*y

Начальное состояние

a

b

Шаг 1
Шаг 2
Шаг 3

86

 Глава 2. Представление информации и работа с ней
Упражнение 2.11 (решение в конце главы)
Вооружившись функцией inplace_swap из упражнения 2.10, вы решили написать код, который будет менять местами элементы массива, находящиеся на противоположных концах,
двигаясь к середине.
В результате вы пришли к следующей функции:
1
2
3
4
5
6
7

void reverse_array(int a[], int cnt) {
int first, last;
for (first = 0, last = cnt-1;
first > k выполняет арифметический сдвиг вправо значения x на k позиций,
а x >>> k выполняет логический сдвиг.

Сдвиг на k для больших значений k
Каким должен быть результат сдвига для типа данных, состоящего из w бит, на некоторое
число k ≥ w? Например, каков должен быть результат вычисления следую­щих выражений,
если предположить, что тип данных int имеет w = 32:
int
lval = 0xFEDCBA98 > 36;
unsigned uval = 0xFEDCBA98u >> 40;

Стандарты C старательно избегают указывать, что делать в таком случае. На многих
машинах инструкции сдвига учитывают только младшие log2w бит величины сдвига при
сдвиге w-разрядного значения, поэтому величина сдвига вычисляется как k mod w. Например, при w = 32 указанные выше три операции сдвига дадут те же результаты, что и
операции сдвига на 0, 4 и 8 бит соответственно:
lval
aval
uval

0xFEDCBA98
0xFFEDCBA9
0x00FEDCBA

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

Проблемы с приоритетом операторов в операциях сдвига
Иногда может возникнуть соблазн написать выражение 1

(int) 2147483648U

Со знаком

1 *

–1

>

–2

Со знаком

1

(unsigned) –1

>

–2

Без знака

1

Упражнение 2.21 (решение в конце главы)
Предполагая, что выражения оцениваются на 32-разрядной машине, использующей арифметику в дополнительном коде, заполните следующую таблицу, описывая результаты преобразований и операций сравнения, как это сделано в табл. 2.12.
Выражение
–2147483647–1 == 2147483648U
–2147483647–1 < 2147483648
–2147483647–1U < 2147483648
–2147483647–1 < –2147483648
–2147483647–1U < –2147483648

Тип

Результат

106

 Глава 2. Представление информации и работа с ней

Приложение в интернете DATA:MIN. Запись TMin в С
В табл. 2.12 и в упражнении 2.21 мы предусмотрительно заменили TMin32 на –2147483647–1.
Почему бы просто не написать –2147483648 или 0x80000000?
Если заглянуть в заголовочный файл limits.h, то можно увидеть, что в нем используется тот же метод, что использовали мы для записи TMin32 и TMax32:
/* Минимальное и максимальное значения для типа int со знаком. */
#define INT_MAX
2147483647
#define INT_MIN
(–INT_MAX – 1)

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

2.2.6. Расширение битового представления числа
Одной из обычных операций является преобразование между целыми числами с
разной длиной слова, сохраняющее числовое значение. Конечно, это невозможно, когда целевой тип данных слишком мал, для представления нужной величины. Однако
преобразование типа меньшего размера в тип большего размера всегда должно быть
возможно.
Для преобразования числа без знака в больший тип данных достаточно прос­то добавить в представление ведущие нули. Такая операция называется дополнением нулями.
ПРИНЦИП: расширение преобразования числа без знака дополнением нулями.
⃗ = [0, ..., 0, uw−1, uw−2, ..., u0]
Определим битовые векторы u⃗ = [uw−1, uw−2, ..., u0] с длиной w и u'

⃗ = B2Uw'( u').

с длиной w', где w' > w. Тогда B2Uw( u)



Нетрудно показать, что этот принцип следует непосредственно из определения
представления чисел без знака, заданного уравнением 2.1.
Для преобразования числа в дополнительном коде в больший тип данных применяется правило расширения знакового разряда с добавлением в представление копий
самого старшего бита. Далее мы выделим жирным знаковый бит xw–1, чтобы показать,
как происходит расширение знакового разряда.
ПРИНЦИП: расширение знакового разряда в представлении в дополнительном
коде.
⃗ = [xw−1, ..., xw−1, xw−1,
Определим битовые векторы x⃗ = [xw−1, xw−2, ..., x0] с длиной w и x'
xw−2, ..., x0] с длиной w', где w' > w. Тогда B2Uw(x)
⃗ = B2Uw'(x').




В качестве примера рассмотрим следующий код:
1 short sx = –12345;

/* -12345 */

2.2. Целочисленные представления  107
2
3
4
5
6
7
8
9
10
11
12
13

unsigned short usx = sx;
int x = sx;
unsigned ux = usx;

/* 53191 */
/* –12345 */
/* 53191 */

printf("sx = %d:\t", sx);
show_bytes((byte_pointer) &sx, sizeof(short));
printf("usx = %u:\t", usx);
show_bytes((byte_pointer) &usx, sizeof(unsigned short));
printf("x
= %d:\t", x);
show_bytes((byte_pointer) &x, sizeof(int));
printf("ux = %u:\t", ux);
show_bytes((byte_pointer) &ux, sizeof(unsigned));

Если запустить эту программу, скомпилированную в 32-разрядном режиме, на машине с прямым (big endian) порядком следования байтов, где используется представление в дополнительном коде, то она выведет:
sx
usx
x
ux

=
=
=
=

–12345:
53191:
–12345:
53191:

cf
cf
ff
00

c7
c7
ff cf c7
00 cf c7

Несмотря на то что представление –12 345 в дополнительном коде и представление
53 191 в форме без знака идентичны в архитектуре с 16-разрядными словами, они различны в архитектуре с 32-разрядными словами. В частности, –12 345 имеет шестнадцатеричное представление 0xFFFFCFC7, тогда как 53 191 – шестнадцатеричное представление 0x0000CFC7. Первое расширено дополнительным знаковым разрядом: 16 копий
старшего бита 1, в шестнадцатеричном представлении 0xFFFF, добавлены в качестве
ведущих битов. Последнее расширено 16 ведущими нулями, в шестнадцатеричном
представлении 0x0000.
Для иллюстрации на рис. 2.6 показан результат увеличения размера слова w = 3 до
w = 4 за счет расширения знакового разряда. Битовый вектор [101] представляет значение –4 + 1 = –3. Расширение знака дает битовый вектор [1101], представляющий значение –8 + 4 + 1 = –3. Как видите, для w = 4 комбинация двух старших битов, –8 + 4 = –4,
совпадает со значением знакового бита для w = 3. Точно так же битовые векторы [111] и
[1111] представляют значение –1.
–23 = –8
–22 = –4
22 = 4
21 = 2
20 = 1
–8 –7 –6 –5 –4 –3 –2 –1 0 1 2 3 4 5 6 7 8
[101]
[1101]
[111]
[1111]

Рис. 2.6. Примеры расширения знака с w = 3 до w = 4. Для w = 4 общий вес старших
двух битов равен –8 + 4 = –4, что соответствует весу знакового бита для w = 3

108

 Глава 2. Представление информации и работа с ней

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

ВЫВОД: расширение числа в дополнительном коде путем расширения знака.
Пусть w' = w + k. Мы должны доказать, что



B2Tw+k([xw−1, ..., xw−1, xw−1, xw−2, ..., x0]) = B2Tw([xw−1, xw−2, ..., x0]).
k раз



Доказательство выполнено методом математической индукции. То есть если можно
доказать, что расширение знакового разряда на один бит сохраняет числовую величину, то это свойство будет выполняться при расширении знакового разряда на произвольное число битов. Итак, задача сокращается до доказательства того, что
B2Tw+1([xw−1, ..., xw−1, xw−2, ..., x0]) = B2Tw([xw−1, xw−2, ..., x0]).
Расширение левой части выражения в соответствии с уравнением (2.3) дает:
w−1
w

B2Tw+1([xw−1, ..., xw−1, xw−2, ..., x0]) = –xw−1 2 + �xi 2i
i=0



w

w−2

= –xw−1 2 + xw−1 2

w−1

+�xi 2i
i=0
w−2



w

= –xw−1 (2 – 2w−1) +�xi 2i
i=0
w−2



= –xw−1 2w−1 +�xi 2i
i=0



= B2Tw(xw−1, xw−2, ..., x0).

Ключевым свойством, использованным здесь, было равенство 2w − 2w−1 = 2w−1. В соответствии с ним общим эффектом добавления бита с весом −2w и преобразования бита,
имеющего вес −2w−1, в бит с весом 2w–1 является сохранение первоначального числового
значения.
Упражнение 2.22 (решение в конце главы)
Применив уравнение 2.3, покажите, что каждый из следующих битовых векторов является
представлением −5 в дополнительном коде:
1. [1011]
2. [11011]
3. [111011]
Обратите внимание, что второй и третий векторы можно вывести из первого расширением
знакового разряда.

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

2.2. Целочисленные представления  109
1
2
3
4
5

short sx = -12345; /* –12345 */
unsigned uy = sx; /* Мистика! */
printf("uy = %u:\t", uy);
show_bytes((byte_pointer) &uy, sizeof(unsigned));

Если запустить его на машине с прямым (big endian) порядком следования байтов,
то этот код выведет:
uy = 4294954951:

ff ff cf c7

Это показывает, что при преобразовании из short в unsigned программа сначала изменила размер, а затем тип. То есть (unsigned) sx эквивалентно (unsigned) (int) sx, в
результате чего было получено значение 4 294 954 951, а не (unsigned) (unsigned short)
sx, что дало бы 53 191. Это соглашение дейст­вительно определяется стандартами C.
Упражнение 2.23 (решение в конце главы)
Рассмотрим следующие функции на С:
int fun1(unsigned word) {
return (int) ((word > 24);
}
int fun2(unsigned word) {
return ((int) word > 24;
}

Предположим, что они выполняются в 32-разрядной программе на машине, использующей
арифметику в дополнительном коде. Также предположим, что сдвиг вправо значений со знаком выполняется арифметически, тогда как сдвиг вправо значений без знака – логически.
1. Заполните следующую таблицу, показывающую результаты, возвращаемые этими функциями для нескольких разных аргументов. Вам будет удобнее использовать шестнадцатеричное представление. Просто помните, что старшие биты шестнадцатеричных цифр
с 8 по F равны 1.
w

fun1(w)

fun2(w)

0x00000076
0x87654321
0x000000C9
0xEDCBA987

2. Опишите словами, какие вычисления выполняет каждая из этих функций.

2.2.7. Усечение чисел
Предположим, что значение вместо расширения требуется сократить. Такое сокращение, например, имеет место в следующем коде:
1 int x = 53191;
2 short sx = (short) x;
3 int y = sx;

/* –12345 */
/* –12345 */

Приведение переменной к типу short вызовет усечение 32-разрядного значения типа
int до 16-разрядного значения short. Как уже отмечалось выше, такая 16-разрядная

комбинация является представлением числа –12 345 в дополнительном коде. При преобразовании обратно в int расширение дополнительным знаковым разрядом заполнит

110

 Глава 2. Представление информации и работа с ней

старшие 16 бит единицами, и в результате получится 32-разрядное представление числа
–12 345 в дополнительном коде.
При усечении w-разрядного числа x⃗ = [xw−1, xw−2, ..., x0] до k-разрядного представления
происходит простое отбрасывание старших w – k бит и в результате получается битовый
вектор x'
⃗ = [xk−1, xk−2, ..., x0]. Усечение числа может изменить его значение – это одна из
разновидностей переполнения. Для числа без знака мы легко можем определить, какое
значение получится.
ПРИНЦИП: усечение числа без знака.
⃗ – результат усечения до k бит: x'
⃗ = [xk−1,
Пусть x⃗ – битовый вектор [xw−1, xw−2, ..., x0], а x'

⃗ и x' = B2Uk(x').
⃗ Тогда x' = x mod 2k.
xk−2, ..., x0]. Пусть x = B2Uw(x)



В основе этого принципа лежит следующая идея: все усеченные биты имеют веса
вида 2i, где i ≥ k, и при выполнении операции деления по модулю каждый из этих весов
уменьшается до нуля. Формально эта идея подкрепляется следую­щим выводом:
ВЫВОД: усечение числа без знака.
Применение операции деления по модулю к уравнению 2.1 дает
w−1

B2Uw(xw−1, xw−2, ..., x0) mod 2k = ��xi 2i � mod 2k
i=0

k−1



= ��xi 2i � mod 2k
i=0

k−1



= �xi 2i
i=0



=
B2Uk([xk−1, xk−2, ..., x0]).

В этом выводе мы используем свойство 2i mod 2k = 0 для любого i ≥ k.


Аналогичное свойство имеет операция усечения числа в дополнительном коде, за
исключением того, что потом она преобразует самый старший бит в знаковый бит.
ПРИНЦИП: усечение числа в дополнительном коде.
⃗ – результат усечения до k бит: x'
⃗ = [xk−1,
Пусть x⃗ – битовый вектор [xw−1, xw−2, ..., x0], а x'

⃗ и x' = B2Tk(x').
⃗ Тогда x' = U2Tk(x mod 2k).
xk−2, ..., x0]. Пусть x = B2Tw(x)



В этой формулировке результатом x mod 2 будет число от 0 до 2 – 1. Применение к
нему функции U2Tk приведет к преобразованию веса самого старшего бита xk−1 с 2k−1 в
−2k−1. Это можно увидеть на примере преобразования значения x = 53 191 из типа int в
тип short. Поскольку 216 = 65 536 ≥ x, мы получаем x mod 216 = x. Но при преобразовании этого числа в 16-разрядное представление в дополнительном коде мы получим x =
53 191 – 65 536 = –12 345.
k

k

ВЫВОД: усечение числа в дополнительном коде.
Используя доказательство, подобное тому, что приводилось для случая усечения числа без знака, мы получаем:
B2Tw([xw−1, xw−2, ..., x0]) mod 2k = B2Uk([xk−1, xk−2, ..., x0]).

2.2. Целочисленные представления  111
То есть x mod 2k можно представить числом без знака, имеющим битовое представление [xk–1, xk–2, ..., x0]. Преобразование его в представление в дополнительном коде дает
x = U2Tk(x mod 2k).



То есть, учитывая все вышесказанное, эффект усечения чисел без знака можно выразить так:
B2Uk([xk−1, xk−2, ..., x0]) = B2Uw([xw−1, xw−2, ..., x0]) mod 2k,

(2.9)

а эффект усечения чисел в дополнительном коде – так:
B2Tw([xk−1, xk−2, ..., x0]) = U2Tk(B2Uw([xw−1, xw−2, ..., x0]) mod 2k).

(2.10)

Упражнение 2.24 (решение в конце главы)
Предположим, что 4-разрядное значение (представленное шестнадцатеричными цифрами
от 0 до F) усекается до 3-разрядного значения (представленного шестнадцатеричными цифрами от 0 до 7). Заполните следующую таблицу, подставив результат усечения чисел без
знака и в дополнительном коде, представленных указанными комбинациями битов:
Шестнадцатеричное число

Без знака
Усеченное

Дополнительный код

Исходное

Усеченное

Исходное

Исходное

0

0

0

0

2

2

2

2

9

1

9

–7

B

3

11

–5

F

7

15

–1

Усеченное

Объясните, как к этим случаям применяются уравнения (2.9) и (2.10).

2.2.8. Советы по приемам работы с числами
со знаком и без знака
Как было показано выше, неявное приведение знакового типа к беззнаковому влечет за собой некоторое неочевидное поведение. Неочевидные функции часто приводят
к программным ошибкам, обусловленным нюансами неявного приведения типов, которые очень трудно обнаружить. Поскольку преобразование выполняется без явного
свидетельства в коде, программисты могут не замечать его последствий.
Следующие два упражнения иллюстрируют некоторые трудноуловимые ошибки, которые могут возникнуть из-за неявного приведения типов и использования беззнаковых типов.
Упражнение 2.25 (решение в конце главы)
Взгляните на следующий код, реализующий суммирование элементов массива a, где количество элементов представлено параметром length:

1 /* ВНИМАНИЕ: этот код содержит ошибку */
2 float sum_elements(float a[], unsigned length) {

112

 Глава 2. Представление информации и работа с ней
3
4
5
6
7
8
9 }

int i;
float result = 0;
for (i = 0; i 0;
}

Проверив эту функцию на некоторых примерах данных, вы заметили, что она работает не
всегда правильно. Вы провели исследования и выяснили, что при 32-разрядной компиляции тип данных size_t определяется (через typedef) в заголовочном файле stdio.h как
беззнаковый.
1. В каких случаях эта функция даст неверный результат?
2. Объясните, как возникает этот неверный результат.
3. Покажите, как исправить код, чтобы он работал надежно.

Мы уже видели несколько примеров, когда тонкие нюансы беззнаковой арифметики, и особенно неявное преобразование типа со знаком в тип без знака, могут
привести к ошибкам или уязвимостям. Один из способов избежать подобных ошибок – отказаться от использования чисел без знака. На самом деле мало какие языки,
кроме С, поддерживают целые числа без знака. Очевидно, что разработчики этих языков проектировали их более основательно, чем те того заслуживали. Например, Java
поддерживает только целые числа со знаком и требует их реализации с арифметикой
в дополнительном коде. Обычный оператор сдвига вправо >> гарантированно выполняет арифметический сдвиг. Для логического сдвига вправо определен специальный
оператор >>>.
Значения без знака очень полезны, если слова рассматриваются лишь как наборы
битов, без какой бы то ни было числовой интерпретации. Это имеет место, например,
когда слово используется как набор флагов, описывающих булевы условия. Адреса не
имеют знака, поэтому системные программисты считают, что типы без знака имеют
свои преимущества. Значения без знака также могут пригодиться в математических пакетах для реализации арифметики сравнений по модулю и арифметических операций
с многократно увеличенной точностью, где числа представлены в виде массивов слов.

2.3. Целочисленная арифметика  113

2.3. Целочисленная арифметика
Многие начинающие программисты с удивлением обнаруживают, что результатом
сложения двух положительных чисел может стать отрицательное число, а сравнение
х < у может дать иной результат, нежели сравнение х – у < 0. Эти свойства являются
порождением конечной природы компьютерной арифметики. Понимание ее нюансов
помогает программистам писать более надежный код.

2.3.1. Сложение целых без знака
Рассмотрим два неотрицательных целых, x и y, таких что 0 < х, у < 2w. Каж­дое из этих
чисел можно представить в виде w-разрядного целого без знака. Однако если вычислить их сумму, то результат может оказаться в диапазоне 0 ≤ x + y ≤ 2w+1 − 2. Для представления этой суммы может потребоваться w + 1 бит. Например, на рис. 2.7 показан график
функции х + у, когда х и у имеют 4-разрядные представления. Аргументы (показаны по
горизонтальным осям) изменяются в диапазоне от 0 до 15, но диапазон суммы шире –
от 0 до 30. График данной функции имеетформу наклонной плоскос­ти. Если возникнет необходимость обеспечить поддержку такой суммы с количест­вом битов w + 1 и
возможности ее сложения с другим значением, тогда может потребоваться w + 2 бит и
т. д. Такое прогрессирующее увеличение длины слова означает, что для полного представления результатов арифметических операций мы не можем устанавливать границы длины слова. Некоторые языки программирования, например Lisp, на самом деле
поддерживают арифметику бесконечной точности, чтобы обеспечить поддержку произвольной (разумеется, в пределах машинной памяти) целочисленной арифметики.
Чаще всего языки программирования поддерживают арифметику фиксированной точности, и, следовательно, такие операции, как «сложение» и «умножение», отличаются от
эквивалентных операций с целыми числами.

32
28
24
20
16

14
12

12

10

8

8
6

4
4

0
0

2

4

2
6

8

10

0
12

14

Рис. 2.7. Целочисленное сложение. Сложение 4-разрядных аргументов
может дать в результате 5-разрядную сумму

114

 Глава 2. Представление информации и работа с ней

Давайте определим операцию +uw для аргументов x и y, где 0 ≤ x, y < 2w, как результат
усечения целочисленной суммы x + y до w бит и последующей интер­претации результата как числа без знака. Эту операцию можно рассмат­ривать как форму арифметики
по модулю, вычисление суммы по модулю 2w путем простого отбрасывания любых битов с весом больше 2w-1 в битовом представлении x + y. Например, рассмотрим 4-разрядные представления чисел x = 9 и y = 12 – [1001] и [1100] соответственно. Их сумма
равна 21 и имеет 5-разрядное представление [10101]. Но если отбросить старший бит,
мы получим [0101], т. е. десятичное значение 5. Это соответствует результату операции 21 mod 16 = 5.
Уязвимость в getpeername
В 2002 году программисты, участвовавшие в создании операционной системы с открытым исходным кодом FreeBSD, обнаружили, что их реализация библиотечной функции
getpeername имеет уязвимость. Упрощенная версия их реализации выглядела примерно
так:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

/*
* Иллюстрация уязвимости, аналогичной той, что была обнаружена
* в реализации getpeername() в ОС FreeBSD
*/
/* Объявление библиотечной функции memcpy */
void *memcpy(void *dest, void *src, size_t n);
/* Область памяти ядра, хранящей данные, которые доступны
* пользователю
*/
#define KSIZE 1024
char kbuf[KSIZE];
/* Копировать до maxlen байт из памяти ядра в буфер пользователя */
int copy_from_kernel(void *user_dest, int maxlen) {
/* Счетчик байтов len -- наименьшее из размера буфера и maxlen */
int len = KSIZE < maxlen ? KSIZE : maxlen;
memcpy(user_dest, kbuf, len);
return len;
}

Здесь, в строке 7, показан также прототип библиотечной функции memcpy, которая
предназначена для копирования указанного количества байтов n из одной области памяти в другую.
Функция copy_from_kernel, начинающаяся в строке 14, предназначена для копирования некоторых данных, поддерживаемых ядром операционной системы, в указанную область памяти, доступную пользователю. Большинство структур данных, поддерживаемых
ядром, не должны быть доступны для чтения пользователям, потому что могут содержать
конфиденциальную информацию о других пользователях и о других заданиях, выполняемых в системе, но область, объявленная с именем kbuf, предназначена для того, чтобы
пользователь мог читать из нее. Параметр maxlen определяет длину буфера user_dest,
выделенного пользователем. Вычисление в строке 16 гарантирует, что скопировано будет не больше байтов, чем доступно в исходном или целевом буфере.
А теперь представьте, что какой-то злонамеренный программист пишет код, который
вызывает copy_from_kernel с отрицательным значением maxlen. В этом случае операция взятия минимального из двух чисел в строке 16 присвоит это значение переменной
len, которое затем будет передано как параметр n в memcpy. Обратите внимание, однако,
что параметр n объявлен с типом size_t. Этот тип данных определяется (через typedef)
в библиотечном файле stdio.h.

2.3. Целочисленная арифметика  115

Обычно в 32-разрядных программах он определяется как unsigned, а в 64-разрядных – как unsigned long. Поскольку аргумент n имеет беззнаковый тип, memcpy будет
рассматривать отрицательное значение как очень большое положительное число и
попытается скопировать это количество байтов из области ядра в буфер пользователя.
Копирование такого количества байтов (по крайней мере 231) на самом деле может не
сработать, потому что в процессе копирования могут быть обнаружены недопустимые
адреса, но, как бы то ни было, программа может читать области памяти ядра, доступ к
которым должен быть для нее закрыт.
Эта проблема обусловлена несоответствием типов данных: в одном месте параметр
длины является целым со знаком; в другом месте – целым без знака. Такие несоответствия
могут стать источниками ошибок и, как показывает этот пример, даже привести к уязвимостям. К счастью, не было зафиксировано ни одного свидетельства об использовании
этой уязвимости в FreeBSD. Разработчики выпус­тили рекомендацию по безопас­ности
«FreeBSD-SA-02:38.signed-error», в которой систем­ным администраторам сообщалось,
как наложить исправление, устраняющее уязвимость. Ошибка может быть исправлена
путем объявления параметра maxlen в copy_from_kernel с типом size_t, чтобы он соответствовал параметру n в memcpy. Также следовало объявить локальную переменную
len и возвращаемое значение с типом size_t.

Охарактеризовать операцию +wu можно следующим образом.
ПРИНЦИП: сложение целых без знака.
Для x и y таких, что 0 ≤ x, y < 2w:

x +wu y = �

x + y < 2w
норма
.

w
w+1
2 ≤x+y0

w

ВЫВОД: отрицание целого без знака.

.

(2.12)



118

 Глава 2. Представление информации и работа с ней

Когда x = 0, аддитивная обратная величина явно равна 0. Для случая x > 0 рассмотрим
значение 2w – x. Обратите внимание, что это число находится в диапазоне 0 < 2w − x < 2w.
Также можно заметить, что (x + 2w – x) mod 2w = 2w mod 2w = 0. Следовательно, это обратная
x величина относительно +wu .

2.3.2. Сложение целых в дополнительном коде
При сложении целых в дополнительном коде мы должны решить, что делать, если
результат окажется слишком большим (положительным) или слишком маленьким (отрицательным) для представления. Для целых чисел х и у в диапазоне –2w–1 ≤ x, y ≤ 2w−1 − 1
их сумма находится в диапазоне −2w ≤ x + y ≤ 2w − 2 и потенциально может потребовать
w + 1 бит для точного представления. Как и ранее, будем избегать бесконечного увеличения размера данных путем усечения представления до w бит. Впрочем, результат не
столь прост в математичес­ком отношении, нежели сложение по модулю.
ПРИНЦИП: сложение целых в дополнительном коде.
Для целых чисел со знаком x и y в диапазоне –2w-1 ≤ x, y ≤ 2w−1 − 1:
x + y – 2 w,

x + y = � x + y,
t
w

x + y + 2w,

2w–1 ≤ x + y

положительное переполнение

.
–2w–1 ≤ x + y < 2w−1 норма
x + y < –2w–1
отрицательное переполнение

(2.13)



Этот принцип иллюстрирует рис. 2.10, где слева показана сумма x + y, имеющая значение в диапазоне −2w ≤ x + y ≤ 2w – 2, и результат усечения суммы до w-разрядного числа
в дополнительном коде – справа. (Обозначения «Случай 1» – «Случай 4» на этом рисунке
мы используем для анализа в формальном выводе принципа.) Когда сумма x + y превышает TMaxw (случай 4), мы говорим, что произошло положительное переполнение. В этом
случае усечение заключается в вычитании 2w из суммы. Когда сумма x + y меньше TMinw
(случай 1), мы говорим, что произошло отрицательное переполнение. В этом случае усечение заключается в добавлении 2w к сумме.
x+y
+2w Положительное переполнение
Positive overflow

Случай
Case44

x +ty
+2w–1

+2w–1

Случай
Case33
0

Normal
Норма

0

Случай
Case22
–2w–1

Случай
Case11
–2w

–2w–1

Отрицательное
переполнение
Negative overflow

Рис. 2.10. Связь между сложением целых без знака и целых в дополнительном коде.
Когда сумма x + y меньше, чем –2w–1, возникает отрицательное переполнение.
Когда больше или равна 2w–1, происходит положительное переполнение

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

2.3. Целочисленная арифметика  119
пользуют одну и ту же машинную инструкцию для выполнения сложения как целых со
знаком, так и без знака.

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



x +wt y = U2Tw(T2Uw(x) +wu T2Uw(y)).

(2.14)

Согласно уравнению 2.6 мы можем записать T2Uw(x) как xw–12 + x и T2Uw(y) как
yw–12w + y. В соответствии со свойством операции +wu, что она является прос­тым сложением
по модулю 2w, а также свойством сложения по модулю мы имеем:
w

x +wt y = U2Tw(T2Uw(x) +wu T2Uw(y))
= U2Tw[(xw−12w + x + yw−12w + y) mod 2w]
= U2Tw[(x + y) mod 2w].
Члены xw−12w и yw−12w сокращаются, потому что они равны 0 по модулю 2w.
Для лучшего понимания идеи определим z как целочисленную сумму z ≐ х + у, z' как
z' ≐ z mod 2w и z" как z" ≐ U2Tw(z'). Величина z" равна x +wt y. Анализ можно разделить на
четыре случая, как показано на рис. 2.10.
1. −2w ≤ z < −2w−1. Тогда мы имеем z' = z + 2w. Это дает 0 < z' < –2w–1 + 2w = 2w–1. Согласно уравнению (2.7), z' находится в таком диапазоне, что z" = z'. Такой случай
называется отрицательным переполнением. Было выполнено сложение двух
отрицательных чисел x и y (только в этом случае выполняется условие z < –2w–1)
и получен неотрицательный результат z" = х + у + 2w.

2. −2w−1 ≤ z < 0. Тогда мы имеем z' = z + 2w, что дает –2w–1 + 2w = 2w–1 ≤ z' < 2. Согласно
уравнению (2.7), z’ находится в таком диапазоне, что z" = z' – 2w и, следовательно,
z" = z' – 2w = z + 2w – 2w = z. To есть сумма z" в дополнительном коде равна целочисленной сумме x + y.
3. 0 ≤ z < 2w−1. Тогда мы имеем z' = z, что дает 0 ≤ z' < 2w–1. И снова сумма z" в дополнительном коде равна целочисленной сумме х + у.
4. 2w−1 ≤ z < 2w. Тогда мы имеем z' = z, что дает 2w−1 ≤ z' < 2w. Но в этом диапазоне мы
имеем z" = z' –2w, что дает z" = x + y – 2w. Такой случай называется положительным переполнением. Было выполнено сложение двух положительных чисел x и
y (только в этом случае выполняется условие z ≥ 2w−1), и получен отрицательный
результат z" = x + y – 2w.


В качестве иллюстрации в табл. 2.13 приводится несколько примеров сложения целых в дополнительном коде для w = 4. Для каждого примера указан случай, к которому
он относится в выводе уравнения (2.13). Обратите внимание, что 24 = 16, и, следовательно, отрицательное переполнение выдает результат на 16 больше целочисленной
суммы, а положительное переполнение – на 16 меньше. В таблице также показаны
битовые представления операндов и результатов. Обратите внимание, что данный
результат можно получить двоичным сложением операндов и усечением его (результата) до 4 бит.

120

 Глава 2. Представление информации и работа с ней

Таблица 2.13. Примеры сложения целых в дополнительном коде. Битовое
представление 4-разрядной суммы в дополнительном коде можно получить
путем двоичного сложения операндов и усечения результата до 4 бит
x

y

x+y

+4t x

Случай

−8
[1000]

−5
[1011]

–13
[10011]

3
[0011]

1

–8
[1000]

–8
[1000]

–16
[10000]

0
[0000]

1

–8
[1000]

5
[0101]

–3
[11101]

–3
[1101]

2

2
[0010]

5
[0101]

7
[00111]

7
[0111]

3

5
[0101]

5
[0101]

10
[01010]

–6
[1010]

4

Рисунок 2.11 иллюстрирует сложение целых в дополнительном коде с длиной слова w = 4. Диапазон операндов от –8 до 7. Когда х + у < –8, сложение в дополнительном
коде дает отрицательное переполнение, что вызывает увеличение суммы на 16. Когда
–8 < х + у < 8, сложение дает х + у. Когда х + у ≥ 8, сложение дает положительное переполнение, вызывающее уменьшение суммы на 16. Каждый из этих трех диапазонов образует на схеме наклонную плоскость.

8

Норма

Отрицательное
переполнение

Положительное
переполнение

6
4
2
0

6

22

4
2

24

0

26

22

28

24
28

26

24

26
22

0

2

28
4

6

Рис. 2.11. Сложение целых в дополнительном коде.
При размере слова 4 бита сложение может дать отрицательное переполнение,
когда x + y < −8, и положительное переполнение, когда x + y ≥ 8
Уравнение 2.13 позволяет выявить случаи, когда имеет место переполнение.
ПРИНЦИП: определение переполнения при сложении целых в дополнительном
коде.

2.3. Целочисленная арифметика  121
Для x и y в диапазоне TMinw ≤ x, y ≤ TMaxw пусть s ≐ x +wt y. Тогда можно сказать, что
вычисление s дало положительное переполнение тогда и только тогда, когда x > 0 и y > 0,
но s ≤ 0. Вычисление дает отрицательное переполнение тогда и только тогда, когда x < 0
и y < 0, но s ≥ 0.


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

ВЫВОД: определение переполнения при сложении целых в дополнительном коде.
Сначала проанализируем положительное переполнение. Если оба операнда x > 0 и
y > 0, но s ≤ 0, то явно произошло положительное переполнение. И наоборот, положительное переполнение требует выполнения условий (1) x > 0 и y > 0 (в противном случае
x + y < TMaxw) и (2) s ≤ 0 (из уравнения 2.13). Аналогичный набор аргументов справедлив
и для отрицательного переполнения.


Упражнение 2.29 (решение в конце главы)
Заполните следующую таблицу по аналогии с табл. 2.13. Приведите целые значения 5-разрядных аргументов, их сумм как целочисленных, битовые представления сумм в дополнительном коде и случай из вывода уравнения 2.13.
x

y

[10100]

[10001]

[11000]

[11000]

[10111]

[01000]

[00010]

[00101]

[01100]

[00100]

x+y

+5t x

Случай

Упражнение 2.30 (решение в конце главы)
Напишите функцию со следующим прототипом:
/* Определяет, можно ли сложить аргументы без переполнения */
int tadd_ok(int x, int y);

Эта функция должна возвращать 1, если аргументы x и y можно сложить, не вызвав переполнения.

122

 Глава 2. Представление информации и работа с ней

Упражнение 2.31 (решение в конце главы)
Вашему коллеге не хватило терпения на анализ условий переполнения при сложении двух
целых в дополнительном коде, и он представил следующую реализацию tadd_ok:
/* Определяет, можно ли сложить аргументы без переполнения */
/* ВНИМАНИЕ: этот код содержит ошибку. */
int tadd_ok(int x, int y) {
int sum = x+y;
return (sum-x == y) && (sum-y == x);
}

Этот код вызвал у вас смех. Объясните почему.

Упражнение 2.32 (решение в конце главы)
Вам поручено написать функцию tsub_ok с аргументами x и y, которая возвращает 1, если
вычисление x – y не вызовет переполнения. Только что написав функцию для упражнения 2.30, вы пишете следующее:
/* Определяет, можно ли вычислить разность аргументов без переполнения */
/* ВНИМАНИЕ: этот код содержит ошибку. */
int tsub_ok(int x, int y) {
return tadd_ok(x, -y);
}

Для каких значений x и y эта функция даст неверный результат? Разработка правильной
версии этой функции будет предложена в упражнении 2.74 (для самостоятельного решения).

Упражнение 2.33 (решение в конце главы)
Комбинацию битов с длиной w = 4 можно представить одной шестнадцатеричной цифрой.
Представьте эти цифры в дополнительном коде и заполните следующую таблицу, определив
соответствующие аддитивные инверсии.
x
Шестнадцатеричное

–4t x
Десятичное

Десятичное

Шестнадцатеричное

0
5
8
D
F

Какие особенности можно отметить в комбинациях битов, созданных отрицанием целых
в дополнительном коде и без знака (см. упражнение 2.28)?

2.3. Целочисленная арифметика  123

Приложение в интернете DATA:NEG. Битовое представление отрицания целых
в дополнительном коде
Есть несколько интересных способов выполнить отрицание целых в дополнительном
коде в битовом представлении. Особенно интересны следующие два метода, которые
могут пригодиться, например, когда при отладке программы встречается значение
0xfffffffa, а кроме того, они помогают понять природу представления в виде дополнительного кода.
Один из методов отрицания целых в дополнительном коде в битовом представлении
состоит в добавлении битов и последующем увеличении результата. В языке C мы можем заявить, что для любого целого значения x выражения –x и ~x + 1 дают идентичный
результат.
Вот несколько примеров с 4-разрядным размером слова:
[0101]

x⃗

~ x⃗

incr(~ x)


5

[1010]

–6

[1011]

–5

[0111]

7

[1000]

–8

[1001]

–7

[1100]

–4

[0011]

3

[0100]

4

[0000]

0

[1111]

–1

[0000]

0

[1000]

–8

[0111]

7

[1000]

–8

Из нашего предыдущего примера мы знаем, что дополнением для 0xf является 0x0, а
дополнением для 0xa является 0x5, поэтому 0xfffffffa – это представление числа −6 в
дополнительном коде.
Второй способ выполнить отрицание числа x в дополнительном коде основан на разделении битового вектора на две части. Пусть k – это позиция крайней правой единицы,
поэтому битовое представление x имеет вид [xw−1, xw−2, ..., xk+1, 1, 0, ..., 0]. (Это возможно
при условии x ≠ 0.) Отрицание записывается в двоичной форме как [~xw−1, ~xw−2, ..., ~xk+1,
1, 0, ..., 0]. То есть мы дополняем каждый бит слева от позиции k.
Проиллюстрируем эту идею на примере некоторых 4-разрядных чисел, где выделим
крайний правый шаблон 1, 0, ..., 0 курсивом:
x

~x

[1100]

–4

[0100]

4

[1000]

–8

[1000]

–8

[0101]

5

[1011]

–5

[0111]

7

[1001]

–7

2.3.3. Отрицание целых в дополнительном коде
Как мы знаем, всякое число х в диапазоне TMinw ≤ x ≤ TMaxw имеет аддитивную инверсию при +wt , которая обозначается как –wt .
ПРИНЦИП: отрицание целых в дополнительном коде.
Для x в диапазоне TMinw ≤ x ≤ TMaxw его отрицание в дополнительном коде –wt вычисляется по формуле:

124

 Глава 2. Представление информации и работа с ней

–wt x = �

TMinw,

x = TMinw

–x,

x > TMinw

.

(2.15)



То есть для w-разрядного сложения целых в дополнительном коде TMinw является
собственной аддитивной инверсией, в то время как для любого другого значения x имеется аддитивная инверсия –x.
ВЫВОД: отрицание целых в дополнительном коде.
Обратите внимание, что TMinw + TMinw = −2w−1 + −2w−1 = −2w. Эта операция вызовет
отрицательное переполнение и, следовательно, TMinw +wt TMinw = −2w + 2w = 0. Для таких
значений x, что x > TMinw, значение −x также можно представить как w-разрядное число
в дополнительном коде, и их сумма будет −x + x = 0.



2.3.4. Умножение целых без знака
Целые числа х и у в диапазоне 0 ≤ x, y ≤ 2w−1 можно представить как w-разрядные
числа без знака, однако их произведение х · у может находиться в диа­пазоне от 0
до (2w − 1)2 = 22w − 2w+1 + 1. Для представления результата может потребоваться как минимум 2w бит. Вместо этого беззнаковое умножение в С возвращает младшие w бит
из 2w-разрядного целочисленного произведения. Обозначим это значение как x *wu y.
Усечение числа без знака до w бит эквивалентно вычислению его значения по модулю 2w, что дает:
ПРИНЦИП: умножение целых без знака.
Для x и y таких, что 0 ≤ x, y ≤ UMaxw:


x *wu y = (х · у) mod 2w.

(2.16)



2.3.5. Умножение целых в дополнительном коде
Целые числа х и у в диапазоне −2w−1 ≤ x, y ≤ 2w−1 − 1 можно представить как w-разрядные числа в дополнительном коде, однако их произведение х · у может находиться в
диапазоне от −2w−1 · (2w−1 − 1) = −22w−2 + 2w−1 до −2w−1 · −2w−1 = 22w−2. Для представления в дополнительном коде это может потребовать как минимум 2w бит. Вместо этого умножение со знаком в языке С обычно выполняется усечением 2w-разрядного произведения
до w бит.
Обозначим это значение как x *wu y. Усечение числа в дополнительном коде до w бит
эквивалентно вычислению его значения по модулю 2w с последующим преобразованием из представления без знака в представление в дополнительном коде, что дает:
ПРИНЦИП: умножение целых в дополнительном коде.
Для x и y таких, что TMinw ≤ x, y ≤ TMaxw:


x *wt y = U2Tw ((х · у) mod 2w).

(2.17)


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

2.3. Целочисленная арифметика  125
ПРИНЦИП: произведения целых без знака и целых в дополнительном коде на битовом уровне эквивалентны.
Пусть x⃗ и y⃗ – битовые векторы с длиной w. Определим целые числа x и y как значения, представленные этими векторами в форме дополнительного кода: x = B2Tw ( x⃗ )
и y = B2Tw (y⃗ ). Определим неотрицательные целые числа x' и y' как значения, представленные этими векторами в форме без знака: x' = B2Tw (x⃗ ) и y' = B2Tw (y⃗ ). Тогда
T2Bw (x *wt y) = U2Bw (x' *wu y').


В качестве иллюстрации в табл. 2.14 показаны результаты умножения различных
3-разрядных чисел. Для каждой пары операндов на битовом уровне выполняется умножение без знака и в дополнительном коде, даю­щее 6-разрядные произведения, а затем
усекаем их до 3 бит. Усеченное произведение без знака всегда равно х · у mod 8. Битовые
представления обоих усеченных произведений идентичны, даже притом что полные
6-разрядные представления отличаются.
Таблица 2.14. Примеры 3-разрядного беззнакового умножения и умножения
в дополнительном коде. Несмотря на то что полные представления произведений
на битовом уровне могут отличаться, представления усеченных произведений идентичны
Режим

x

y

x·y

Усеченное x · y

Без знака
В дополнительном коде

5
–3

[101]
[101]

3
3

[011]
[011]

15
–9

[001111]
[110111]

7
–1

[111]
[111]

Без знака
В дополнительном коде

4
–4

[100]
[100]

7
–1

[111]
[111]

28
4

[011100]
[000100]

4
–4

[100]
[100]

Без знака
В дополнительном коде

3
3

[011]
[011]

3
3

[011]
[011]

9
9

[001001]
[001001]

1
1

[001]
[001]

ВЫВОД: произведения целых без знака и целых в дополнительном коде на битовом
уровне эквивалентны.
Из уравнения 2.6 имеем x' = x + xw−12w и y' = y + yw−12w. Вычисление произведения этих
значений по модулю 2w дает:
(х' · у') mod 2w = [(x + xw−12w) · (y + yw−12w)] mod 2w
= [х · у + (xw−1 y + yw−1 x)2w + xw−1 yw−122w] mod 2w
= (х' · у') mod 2w.

(2.18)


Члены с весом 2w и 22w сокращаются из-за оператора модуля. Согласно уравнению 2.17
имеем x *wt y = U2Tw ((х · у) mod 2w). Мы можем применить операцию T2Uw к обеим сторонам, чтобы получить:
T2Uw (x *wt y) = T2Uw �U2Tw ((х · у) mod 2w)� = (х · у) mod 2w.

Подставив этот результат в уравнения 2.16 и 2.18, получаем:
T2Uw (x *wt y) = (х' · у') mod 2w = x' *wu y'.

Теперь можно применить U2Bw к обеим сторонам, чтобы получить:
U2Bw (T2Uw (x *wt y)) = T2Bw (x *wt y) = U2Bw (x' *wu y').

126

 Глава 2. Представление информации и работа с ней

Уязвимость в библиотеке XDR
В 2002 году обнаружилось, что код, поставляемый компанией Sun Microsystems в составе
библиотеки XDR – широко используемого средства обмена структурами данных между
программами, – имеет уязвимость, возникающую из-за возможности переполнения при
умножении.
Ниже приводится похожий код с такой же уязвимостью:
1 /* Иллюстрация уязвимого кода, аналогичного тому, что был обнаружен
2 * в библиотеке XDR компании Sun.
3 */
4 void* copy_elements(void *ele_src[], int ele_cnt, size_t ele_size) {
5
/*
6
* Выделяет буфер для ele_cnt объектов с размерами ele_size байт
7
* каждый и копирует в него данные из ele_src
8
*/
9
void *result = malloc(ele_cnt * ele_size);
10
if (result == NULL)
11
/* malloc потерпела неудачу */
12
return NULL;
13
void *next = result;
14
int i;
15
for (i = 0; i < ele_cnt; i++) {
16
/* Скопировать i-й объект */
17
memcpy(next, ele_src[i], ele_size);
18
/* Переместить указатель на следующий объект */
19
next += ele_size;
20
}
21
return result;
22 }

Функция copy_elements предназначена для копирования ele_cnt структур данных,
каждая из которых имеет размер ele_size байт, в буфер, выделенный функцией в строке 9. Требуемый объем буфера вычисляется как ele_cnt * ele_size.
Однако представьте, что злонамеренный программист вызывает эту функцию с

ele_cnt, равным 1 048 577 (220 + 1), и ele_size, равным 4096 (212), из 32-разрядной про-

граммы. Тогда при умножении в строке 9 произойдет переполнение, в результате чего
будет выделено только 4096 байт, а не 4 294 971 392, необходимых для хранения такого
количества данных. Цикл, начинающийся со строки 15, будет пытаться скопировать все
эти байты, пока не выйдет за границы выделенного буфера и, как следствие, не разрушит
другие структуры данных. Это может привести к сбою программы или иным нарушениям
в работе системы.
Код, созданный компанией Sun, использовался почти в каждой операционной системе
и в таких распространенных программах, как Internet Explorer и система аутентификации Kerberos. Группа реагирования на компьютерные чрезвычайные ситуации (Computer
Emergency Response Team, CERT) – организация, управляемая институтом разработки
программного обеспечения Карнеги–Меллона, обнаружила эту уязвимость и выпустила
рекомендацию «CA-2002-25», получив которую, многие компании поспешили исправить
свой код. К счастью, сообщений о проблемах, вызванных этой уязвимостью, не поступало.
Подобная уязвимость существовала во многих реализациях библиотечной функции

calloc. Впоследствии она была исправлена. К сожалению, многие програм­мисты вызывают функции распределения памяти, такие как malloc, используя в качестве аргументов

арифметические выражения, не проверяя их на переполнение. Разработка надежной
версии calloc будет предложена вам в упражнении 2.76.

2.3. Целочисленная арифметика  127
Упражнение 2.34 (решение в конце главы)
Заполните следующую таблицу, подставив результаты умножения различных 3-разрядных
чисел, по аналогии с табл. 2.14:
Режим

x

y

Усеченное x · y

x·y

Без знака
В дополнительном коде

_______
_______

[100]
[100]

_______
_______

[101]
[101]

_______
_______

_______
_______

_______
_______

_______
_______

Без знака
В дополнительном коде

_______
_______

[010]
[010]

_______
_______

[111]
[111]

_______
_______

_______
_______

_______
_______

_______
_______

Без знака
В дополнительном коде

_______
_______

[110]
[110]

_______
_______

[110]
[110]

_______
_______

_______
_______

_______
_______

_______
_______

Упражнение 2.35 (решение в конце главы)
Вы получили задание разработать функцию tmult_ok, которая определяет, можно ли умножить два аргумента, не вызывая переполнения. Вот ваше решение:
/* Определяет возможность умножения аргументов без переполнения */
int tmult_ok(int x, int y) {
int p = x*y;
/* Либо x == 0, либо разделить p на x и сравнить с y */
return !x || p/x == y;
}

Вы проверили эту функцию с несколькими значениями x и y, и, похоже, она работает
правильно. Однако ваш коллега говорит вам: «Коль скоро я не могу использовать вычитание
для проверки переполнения при сложении (см. упражнение 2.31), то как вы можете использовать деление для проверки переполнения при умножении?»
Придумайте математическое обоснование своего подхода. Для начала докажите, что случай x = 0 обрабатывается правильно. В противном случае рассмотрите w-разрядные числа
x (x ≠ 0), y, p и q, где p – результат умножения x и y в дополнительном коде, а q – результат
деления p на x.
1. Покажите, что x · y, целочисленное произведение x и y, можно записать в форме x · y = p
+ t2w, где t ≠ 0 тогда и только тогда, когда при вычислении p имеет место переполнение.
2. Покажите, что p можно записать в форме p = x · q + r, где |r| < |x|.
3. Покажите, что q = y тогда и только тогда, когда r = t = 0.

Упражнение 2.36 (решение в конце главы)
Для случая, когда тип данных int имеет размер 32 бита, разработайте версию tmult_ok
(упражнение 2.35), которая использует 64-разрядную точность типа данных int64_t без
использования деления.

Упражнение 2.37 (решение в конце главы)
Вам дано задание исправить уязвимость в коде библиотеки XDR, показанном во врезке
«Уязвимость в библиотеке XDR», возникающей, когда оба типа данных, int и size_t, имеют
размер 32 бита. Вы решили исключить возможность переполнения при умножении, вы-

128

 Глава 2. Представление информации и работа с ней

числив количество байтов, которые нужно выделить, используя для этого тип uint64_t. Вы
заменили исходный вызов malloc (строка 9), как показано ниже:
uint64_t asize =
ele_cnt * (uint64_t) ele_size;
void *result = malloc(asize);

Напомним, что аргумент функции malloc имеет тип size_t.
1. Устранит ли ваш код уязвимость?
2. Как еще можно изменить код, чтобы устранить уязвимость?

2.3.6. Умножение на константу
Традиционно инструкция целочисленного умножения на многих машинах выполнялась довольно медленной, требуя 10 или более тактов, в то время как другие целочисленные операции, такие как сложение, вычитание, операции с битами и сдвиг, требовали только 1 такта. Даже на Intel Core i7 Haswell, который мы используем в качестве
эталонной машины, целочисленное умножение занимает 3 такта. Как следствие одна из
важных оптимизаций, используемых компиляторами, – это попытка заменить умножение на константу комбинацией операций сдвига и сложения. Сначала мы рассмотрим
случай умножения на степень двойки, а затем обобщим его на произвольные константы.
ПРИНЦИП: умножение на степень 2.
Пусть x – целое число без знака, представленное комбинацией битов [xw–1, xw–2, ..., x0].
Тогда для любого k ≥ 0 представление x2k без знака из w + k бит задается как [xw−1, xw−2, ...,
x0, 0, ..., 0], где справа добавлено k нулей.
Так, например, 11 можно представить в 4-разрядном виде как [1011]. Сдвиг влево
на k = 2 дает 6-разрядный вектор [101100], представляющий число без знака 11 · 4 = 44.


ВЫВОД: умножение на степень двойки.
Это свойство можно вывести с помощью уравнения 2.1:
w−1

B2Uw+k([xw−1, xw−2, ..., x0, 0, ..., 0]) = �xi 2i+k
i=0

w−1



= ��xi 2i � · 2k
i=0



= x2k.


Сдвиг влево на k бит при фиксированном размере слова отбрасывает старшие k бит,
давая
[xw−k−1, xw−k−2, ..., x0, 0, ..., 0],
но то же верно для умножения слов фиксированного размера. Таким образом, сдвиг
значения влево эквивалентен умножению без знака на степень 2:
ПРИНЦИП: умножение целых без знака на степень двойки.

2.3. Целочисленная арифметика  129
В языке C для беззнаковых переменных x и k со значениями x и k, такими что 0 ≤ k < w,
выражение x nslookup ics.cs.cmu.edu
*** Can't find ics.cs.cmu.edu: No answer

11.3.3. Интернет-соединения
Клиенты и серверы в интернете обмениваются между собой данными, посылая и
принимая потоки байтов через соединения. Соединения называются двухточечными,
или двухсторонними (point-to-point), потому что связывают пару процессов. Соединения называют полнодуплексными (full-duplex), потому что данные могут одновременно передаваться в обоих направлениях. И еще со­единения называют надежными
(reliable), потому что если отвлечься от некоторых катастрофических событий, таких
как повреждение кабеля пресловутым беспечным экскаваторщиком, то поток байтов,
посылаемый процессом-отправителем, в конечном итоге будет получен процессом-получателем в том порядке, в каком он был отправлен.
Сокет – это конечная точка соединения. Каждый сокет имеет адрес сокета, который
состоит из IP-адреса и 16-разрядного целочисленного номера порта2 и обозначается
как address:port.
Порт в адресе сокета клиента назначается ядром автоматически, когда клиент посылает запрос на соединение, и называется эфемерным портом (ephemeral port). В то
же время порт в адресе сокета сервера – это обычно хорошо известный номер порта,
присвоенный соответствующей службе. Например, веб-серверы обычно используют
порт 80, а серверы электронной почты – порт 25. Каждая служба, которой назначен
постоянный хорошо известный порт, имеет хорошо известное имя службы. Например,
веб-служба имеет хорошо известное имя http, а служба электронной почты – имя smtp.
Соответствия между хорошо известными именами служб и номерами портов определяются в файле /etc/services.
Соединение однозначно идентифицируется адресами сокетов обеих его конечных
точек. Такая пара адресов сокетов называется парой сокетов и обозначается кортежем
2

Это программные порты; они никак не связаны с аппаратными портами в сетевых коммутаторах и маршрутизаторах.

11.4. Интерфейс сокетов  867
(cliaddr:cliport, servaddr:servport), где cliaddr – IP-адрес клиента, cliport – порт клиента,
servaddr – IP-адрес сервера и servport – порт сервера. Например, на рис. 11.10 показано
соединение между веб-клиентом и веб-сервером.
Адрес сокета
Client
socket клиента
address
128.2.194.242:51213
Клиент
Client

Адрес сокета
Server
socket сервера
address
208.216.181.15:80

Пара сокетов,
образующих
Connection
socketсоединение
pair

Server
Сервер
(порт 80)
80)
(port

(128.2.194.242:51213, 208.216.181.15:80)

Адрес host
хостаaddress
клиента
Client
128.2.194.242

Адрес хоста
сервера
Server
host address
208.216.181.15

Рис. 11.10. Интернет-соединение
В этом примере сокет веб-клиента имеет адрес 128.2.194.242:51213, где 51213 –
номер эфемерного порта, назначенный ядром, а сокет веб-сервера имеет адрес:
208.216.181.15:80, где 80 – хорошо известный номер порта, присвоенный веб-службам.
Эти адреса сокетов клиента и сервера однозначно идентифицируют соединение между
клиентом и сервером: (128.2.194.242:51213, 1208.216.181.15:80).

11.4. Интерфейс сокетов
Интерфейс сокетов – это набор функций, использующихся вместе с функциями ввода/
вывода Unix для создания сетевых приложений. Он реализован в большинстве современных систем, включая все варианты операционных систем Unix, Windows и Macintosh. На
рис. 11.11 показана обобщенная схема использования интерфейса сокетов в контексте
обычной транзакции клиент–сервер. Держите этот рисунок перед глазами, пока мы будем обсуждать отдельные функции.
Клиент
Client

Сервер
Server

getaddrinfo

getaddrinfo

socket

socket
open_listenfd
bind

open_clientfd

listen
connect

Connection
Запрос
request
соединения

accept

rio_writen

rio_readlineb

rio_readlineb

rio_writen

close

EOF

Ожидание
запроса
Await connection
соединения
request from
от следующего
next client
клиента

rio_readlineb
close

Рис. 11.11. Использование интерфейса сокетов сетевыми приложениями

868

 Глава 11. Сетевое программирование

11.4.1. Структуры адресов сокетов
С точки зрения ядра операционной системы Linux, сокет – это конечная точка соединения. С точки зрения программы для Linux сокет – это открытый файл с соответствующим дескриптором.
Адреса сокетов хранятся в виде 16-байтных структур sockaddr_in, показанных в
листинге 11.2. В интернет-приложениях поле sin_family имеет значение AF_INET, поле
sin_port хранит 16-разрядный номер порта, а поле sin_addr – 32-разрядный IP-адрес. IPадрес и номер порта всегда хранятся в сетевом порядке следования байтов (big-endian).
Происхождение интерфейса сокетов
Интерфейс сокетов был разработан исследователями Калифорнийского университета
в Беркли в начале 1980-х годов. По этой причине его часто называют сокетами Беркли
(Berkeley sockets). Исследователи из Беркли разработали интерфейс сокетов, позволяющий работать с любым базовым протоколом. В первой реализации использовался протокол TCP/IP, в который они включили ядро Unix 4.2BSD, распространившееся по многочисленным университетам и лабораториям. Это было важное событие в истории интернета.
Практически за одну ночь тысячи пользователей получили доступ к протоколу TCP/IP и
его исходному коду. Это вызвало огромный ажиотаж среди пользователей и подтолкнуло
дальнейшие исследования в области сетевых технологий.

Листинг 11.2. Структура адресов сокетов

code/netp/netpfragments.c
/* Структура адреса сокета интернета */
struct sockaddr_in {
uint16_t sin_family;
/* Семейство протоколов (всегда AF_INET) */
uint16_t sin_port;
/* Номер порта с сетевым порядком байтов */
struct in_addr sin_addr;
/* IP-адрес с сетевым порядком байтов */
unsigned char sin_zero[8]; /* Дополнение до sizeof(struct sockaddr) */
};
/* Обобщенная структура адреса сокета (для функций connect, bind и accept) */
struct sockaddr {
uint16_t sa_family; /* Семейство протоколов */
char sa_data[14];
/* Адрес */
};

code/netp/netpfragments.c
Что означает окончание _in?
Окончание _in – это сокращение от internet, а не от input.

Функции connect, bind и accept требуют указателя на структуру с адресом сокета для
определенного протокола. Разработчикам интерфейса сокетов пришлось столкнуться
с проблемой – как определить эти функции, чтобы иметь возможность использовать
любую структуру адреса сокета. В настоящее время принято использовать универсальный
тип указателя void *. Но в ту пору этот тип отсутствовал в языке С, и было решено
определить функции так, чтобы они принимали указатель на универсальную структуру
sockaddr (листинг 11.2), а затем потребовать от приложений приведения указателя

11.4. Интерфейс сокетов  869
на конкретную структуру к типу указателя на эту универсальную структуру. Чтобы
упростить программный код, мы последуем рекомендациям Стивенса и определим
следующий тип:
typedef struct sockaddr SA;

И будем применять этот тип всякий раз, когда нам понадобится привести указатель
на структуру sockaddr_in к типу указателя на универсальную структуру sockaddr.

11.4.2. Функция socket
Чтобы получить дескриптор сокета, клиенты и серверы используют функцию socket.
#include
#include
int socket(int domain, int type, int protocol);

Возвращает неотрицательный дескриптор в случае успеха,
−1 в случае ошибки
Чтобы создать сокет – конечную точку соединения, – можно вызвать функцию socket
с жестко зашитыми аргументами:
clientfd = Socket(AF_INET, SOCK_STREAM, 0);

где AF_INET указывает, что мы собираемся использовать 32-разрядные IP-адреса,
a SOCK_STREAM указывает, что сокет будет конечной точкой соединения. Однако для
получения значений аргументов и чтобы сделать программу независимой от протокола, лучше использовать функцию getaddrinfo (раздел 11.4.7). Мы покажем, как использовать getaddrinfo в паре с socket, в разделе 11.4.8.
Дескриптор clientfd, возвращаемый функцией socket, открыт только час­тично и не
может использоваться в операциях чтения и записи. Порядок завершения процедуры
открытия сокета зависит от того, в каком приложении она выполняется – клиентском
или серверном. В следующем разделе описывается, как завершить процедуру открытия
сокета на стороне клиента.

11.4.3. Функция connect
Клиент устанавливает соединение с сервером вызовом функции connect:
#include
int connect(int clientfd, const struct sockaddr *addr,
socklen_t addrlen);

Возвращает 0 в случае успеха, −1 в случае ошибки
Функция connect предпринимает попытку установить соединение с сервером, имеющим адрес сокета addr, где addrlen – значение sizeof(sockaddr_in). Функция connect
блокирует выполнение процесса до момента, когда соединение будет успешно установлено или возникнет ошибка. Если соединение успешно установлено, то дескриптор clientfd будет готов выполнять чтение и запись, а установленное соединение будет описываться парой сокетов:
(x:y, addr.sin_addr:addr.sin_port)

870

 Глава 11. Сетевое программирование

где x – IP-адрес клиента, а y – эфемерный порт, уникально идентифицирующий процесс
на хосте клиента. Так же, как в случае с функцией socket, для подготовки аргументов
лучше использовать функцию getaddrinfo (раздел 11.4.8).

11.4.4. Функция bind
Остальные функции сокетов – bind, listen и accept – используются серверами для
установки соединения с клиентами.
#include
int bind(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);

Возвращает 0 в случае успеха, −1 в случае ошибки
Функция bind требует от ядра связать адрес сокета сервера в addr с дескриптором сокета sockfd. Аргумент addrlen – это значение sizeof(sockaddr_in). Так же, как в случае с
функцией socket, для подготовки аргументов лучше использовать функцию getaddrinfo
(раздел 11.4.8).

11.4.5. Функция listen
Клиенты действуют как активная сторона и инициируют запросы на со­единения.
Серверы пассивны и ожидают запросов от клиентов. По умолчанию ядро полагает, что
дескриптор, созданный функцией socket, соответствует активному сокету, существующему на другом конце соединения. Сервер вызывает функцию listen, чтобы сообщить
ядру, что дескриптор будет использоваться сервером, а не клиентом.
#include
int listen(int sockfd, int backlog);

Возвращает 0 в случае успеха, −1 в случае ошибки
Функция listen преобразует активный сокет sockfd в пассивный (listen – слушающий) сокет, который может принимать запросы на соединения от клиентов. Аргумент
backlog определяет, сколько запросов на соединение ядро должно поставить в очередь,
прежде чем начнет отклонять запросы. Точное понимание аргумента backlog требует
подробного изучения протокола TCP/IP, что выходит за рамки этой книги. В дальнейшем мы будем передавать в этом аргументе большое значение, например 1024.

11.4.6. Функция accept
Серверы ожидают запросов на соединение от клиентов, вызывая функцию accept:
#include
int accept(int listenfd, struct sockaddr *addr, int *addrlen);

Возвращает неотрицательный дескриптор подключенного
сокета в случае успеха, −1 в случае ошибки
Функция accept ожидает получения от клиентов запросов на соединение, которые
поступают в слушающий сокет listenfd, затем записывает адрес сокета клиента в addr и

11.4. Интерфейс сокетов  871
возвращает подключенный сокет, который можно использовать для обмена данными с
клиентом посредством функций ввода/вывода Unix.
Различия между слушающим и подключенными сокетами приводят в замешательство многих студентов. Слушающий сокет служит конечной точкой для запросов клиента на соединение. Обычно он создается один раз и существует на протяжении всего
времени выполнения сервера. Подключенный сокет – это конечная точка соединения,
установленного между клиентом и сервером. Он создается всякий раз, когда сервер
принимает запрос на соединение, и сущест­вует только на протяжении периода обслуживания клиента.
На рис. 11.12 показано, какие роли играют слушающий и подключенный сокеты. На
этапе 1 сервер вызывает функцию accept, которая принимает де­скриптор слушающего
сокета и ждет получения запроса. Для большей определенности будем считать, что это
дескриптор 3. Не забывайте, что дескрипторы 0–2 зарезервированы для стандартных
потоков ввода/вывода.
listenfd(3)
Клиент
Client

Сервер
Server

1.Сервер
Serverблокируется
blocks in accept
1.
в вызове,
функции
accept
и ждет появления
waiting for
connection
request on
запроса
наdescriptor
соединениеlistenfd
в сокете listenfd
listening
.

clientfd
Connection
Запрос
соединения
request
Клиент
Client

listenfd(3)
Сервер
Server

2. Клиент посылает запрос на соединение
2. Clientфункции
makesconnect
connection
request
by
вызовом
, которая
блокирует
calling клиента
and blocking in connect.
процесс

clientfd
listenfd(3)
Клиент
Client

Сервер
Server

clientfd

connfd(4)

connfd
accept.
Server
returnsconnfd
3.3.Сервер
получает
изfrom
accept. Клиент
Client returnsизfrom
connect
возвращается
connect
. Теперь. Connection
соединение
is nowclientfd
established
between
clientfd
между
и connfd
установлено
and connfd.

Рис. 11.12. Роли слушающего и подключенного сокетов
На этапе 2 клиент вызывает функцию connect, которая посылаетзапрос на соединение сокету listenfd. На этапе 3 функция accept открывает новый подключенный сокет connfd (который мы будем обозначать дескриптором 4), устанавливает соединение
между clientfd и connfd, а затем возвращает connfd приложению. На стороне клиента функция connect возвращает управление, и с этого момента клиент и сервер могут
передавать данные, выполняя операции чтения и записи с дескрипторами clientfd и
connfd.
Почему возникло деление на слушающий и подключенный сокеты?
Вас, возможно, удивляет, почему интерфейс сокетов различает слушающие и подключенные сокеты. На первый взгляд это выглядит ненужным усложнением. Однако такое
деление между этими двумя видами сокетов приносит определенную пользу, потому что
позволяет проектировать серверы, выполняющиеся конкурентно и способные обслуживать одновременно множество клиентов. Например, всякий раз, когда слушающий сокет
получает запрос на соединение, мы можем запустить новый процесс, обменивающийся
данными с клиентом через дескриптор подключенного сокета. Более подробно о конкурентных серверах рассказывается в главе 12.

872

 Глава 11. Сетевое программирование

11.4.7. Преобразование имен хостов и служб
Linux предоставляет пару мощных функций – getaddrinfo и getnameinfo – для преобразования между двоичными структурами адресов сокетов и строковыми представлениями имен хостов, адресов хостов, имен служб и номеров портов. При использовании
в сочетании с интерфейсом сокетов они позволяют писать сетевые программы, не зави­
сящие от конкретной версии протокола IP.

Функция getaddrinfo
Функция getaddrinfo преобразует строковое представление имени хоста, адреса хос­
та, имени службы и номера порта в структуру адреса сокета. Это современная замена
устаревшим функциям gethostbyname и getservbyname. В отличие от устаревших функций, getaddrinfo – реентерабельная (раздел 12.7.2) и работает с любыми протоколами.
#include
#include
#include
int getaddrinfo(const char *host, const char *service,
const struct addrinfo *hints,
struct addrinfo **result);

Возвращает 0 в случае успеха, ненулевой
код ошибки в случае ошибки
void freeaddrinfo(struct addrinfo *result);

Ничего не возвращает
const char *gai_strerror(int errcode);

Возвращает текст сообщения об ошибке
Для заданных имен хоста и службы (два компонента адреса сокета) getaddrinfo возвращает в result указатель на список структур addrinfo, каж­дая из которых ссылается на
адрес сокета, соответствующего хосту и службе (рис. 11.13).
После вызова getaddrinfo клиент выполняет обход этого списка и по очереди пробует
каждый адрес сокета, пока вызовы socket и connect не завершатся успехом и соединение
не будет установлено. Точно так же сервер пробует каждый адрес сокета в полученном
списке, пока вызовы socket и bind не завершатся успехом и дескриптор не будет привязан к действительному адресу сокета. Чтобы избежать утечек памяти, по завершении
процедуры соединения приложение должно освободить список, вызвав freeaddrinfo.
Если getaddrinfo возвращает ненулевой код ошибки, то приложение может вызвать
gai_strerror, чтобы преобразовать код в строку с описанием ошибки.
В аргументе host функции getaddrinfo можно передать строку с доменным именем
или числовым адресом (например, IP-адресом в десятично-точечном представлении).
В аргументе service можно передать имя службы (например, http) или десятичный номер порта. Если преобразовывать имя хоста в адрес не требуется, то в аргументе host
можно передать NULL. То же относится к аргументу service. Однако хотя бы один из них
должен быть указан.
В необязательном аргументе hints можно передать структуру addrinfo (лис­тинг 11.3),
обеспечивающую, чтобы подсказать функции getaddrinfo, какие адреса сокетов она может включить в возвращаемый список. В аргументе hint требуется определить только
поля ai_family, ai_socktype, ai_protocol и ai_flags. В остальных полях должны передаваться нули (или NULL). На практике обычно вызывается функция memset для обнуления всей структуры, а затем устанавливаются выбранные поля:

11.4. Интерфейс сокетов  873
Структуры
addrinfo
addrinfo
structs

result
ai_canonname

Структуры
адресов structs
сокетов
Socket
address

ai_addr
ai_next

NULL
ai_addr
ai_next

NULL
ai_addr
NULL

Рис. 11.13. Структура данных, возвращаемая функцией getaddrinfo
Листинг 11.3. Структура addrinfo, используемая функцией getaddrinfo

code/netp/netpfragments.c
struct addrinfo {
int
ai_flags;
/* Аргумент с флагами-подсказками */
int
ai_family;
/* Первый аргумент для socket */
int
ai_socktype; /* Второй аргумент для socket */
int
ai_protocol; /* Третий аргумент для socket */
char
*ai_canonname; /* Каноническое имя хоста */
size_t
ai_addrlen;
/* Размер структуры ai_addr */
struct sockaddr *ai_addr;
/* Указатель на структуру адреса сокета */
struct addrinfo *ai_next;
/* Указатель на следующий элемент списка */
};

code/netp/netpfragments.c

• по умолчанию getaddrinfo может возвращать адреса сокетов IPv4 и IPv6. Если

передать в поле ai_family значение AF_INET, то в возвращаемый список будут
включены только адреса IPv4. Если передать значение AF_INET6, то getaddrinfo
вернет только адреса IPv6;

• по умолчанию для каждого уникального адреса, соответствующего аргумен-

ту host, функция getaddrinfo может вернуть до трех структур addrinfo, отличающихся полем ai_socktype: одну для надежных соединений, одну для обмена
дейтаграммами (в этой книге не рассматриваются) и одну для низкоуровневых
сокетов (в этой книге не рассматриваются). Если в ai_socktype передать значение SOCK_STREAM, то для каждого уникального адреса getaddrinfo вернет не
более одной структуры addrinfo, определяющей адрес сокета, который можно
использовать в качестве конечной точки соединения. Мы будем использовать
эту особенность во всех наших примерах программ;

874

 Глава 11. Сетевое программирование

• поле ai_flags – это битовая маска, влияющая на поведение по умолчанию. Она
создается путем объединения различных значений по ИЛИ. Вот некоторые из
флагов, которые мы считаем полезными:

 AI_ADDRCONFIG. Этот флаг рекомендуется устанавливать при использовании надежных соединений [34]. Он подсказывает функции getaddrinfo, что
та должна возвращать адреса IPv4 только в том случае, если локальный хост
настроен на использование IPv4. Аналогично для IPv6;
 AI_CANONNAME. По умолчанию поле ai_canonname имеет значение NULL.
Если установить этот флаг, то getaddrinfo запишет в поле ai_canonname первой структуры addrinfo в списке каноническое (официальное) имя хоста
(рис. 11.13);
 AI_NUMERICSERV. По умолчанию в аргументе service может передаваться
имя службы или номер порта. Этот требует интерпретировать аргумент
service как номер порта;
 AI_PASSIVE. По умолчанию getaddrinfo возвращает адреса сокетов, которые могут использоваться клиентами (активные сокеты) в вызовах connect.
Этот флаг требует возвращать адреса пассивных сокетов, которые могут
использоваться серверами в роли слушающих сокетов. В этом случае в аргументе host должно передаваться значение NULL, а в возвращаемых структурах адресов сокетов поле адреса будет содержать подстановочный адрес,
сообщающий ядру, что этот сервер будет принимать запросы к любому из
IP-адресов данного хоста. Мы будем использовать эту особенность во всех
наших примерах серверов.
Создавая структуры addrinfo в возвращаемом списке, getaddrinfo заполняет все
поля, кроме ai_flags. Поле ai_addr указывает на структуру адреса сокета, поле ai_addrlen
определяет размер этой структуры, а поле ai_next указывает на следующую структуру
addrinfo в списке. Другие поля описывают различные атрибуты адреса сокета.
Одна из замечательных особенностей getaddrinfo – непрозрачность полей в структуре addrinfo, в том смысле, что они могут передаваться непосредственно функциям
интерфейса сокетов без дополнительных манипуляций со стороны приложения. Например, ai_family, ai_socktype и ai_protocol можно напрямую передать в вызов socket.
Точно так же ai_addr и ai_addrlen можно передать напрямую в вызовы connect и bind.
Эта особенность позволяет писать клиенты и серверы, независимые от конкретной версии протокола IP.

Функция getnameinfo
Функция getnameinfo является обратной по отношению к функции getaddrinfo. Она
преобразует структуру адреса сокета в соответствующие строки с именами хоста и
службы. Это современная замена устаревшим функциям gethostbyaddr и getservbyport,
и, в отличие от этих функций, она реентерабельна и не зависит от протокола.
#include
#include
int getnameinfo(const struct sockaddr *sa, socklen_t salen,
char *host, size_t hostlen,
char *service, size_t servlen, int flags);

Возвращает 0 в случае успеха, ненулевой код ошибки
в случае ошибки

11.4. Интерфейс сокетов  875
Аргумент sa указывает на структуру адреса сокета с размером salen, аргумент host
указывает на буфер с размером hostlen, и аргумент service – на буфер с размером servlen.
Функция getnameinfo преобразует структуру адреса сокета sa в соответствую­щие строки с именами хоста и службы и копирует их в буферы host и service. Если getnameinfo
возвращает ненулевой код ошибки, то приложение может преобразовать его в строку с
описанием, вызвав gai_strerror.
Если имя хоста не нужно, то в host можно передать NULL и в hostlen – ноль. То же
относится к имени службы, но хотя бы одна пара аргументов должна быть ненулевой.
Аргумент flags – это битовая маска, влияющая на поведение по умолчанию. Она создается путем объединения различных значений по ИЛИ. Вот пара флагов, которые могут пригодиться:

• NI_NUMERICHOST. По умолчанию getnameinfo пытается вернуть доменное имя
в host. Если передать этот флаг, то getnameinfo вернет адрес в числовой форме;

• NI_NUMERICSERV. По умолчанию getnameinfo выполняет поиск в /etc/services

и, если возможно, возвращает имя службы вместо номера порта. Если передать
этот флаг, то getnameinfo просто вернет номер порта.

В листинге 11.4 показана простая программа HOSTINFO, которая использует

getaddrinfo и getnameinfo для отображения доменного имени в соответствующие ему

IP-адреса. Она похожа на программу NSLOOKUP из раздела 11.3.2.

Листинг 11.4. HOSTINFO отображает доменное имя в соответствующие ему IP-адреса

code/netp/hostinfo.c
1 #include "csapp.h"
2
3 int main(int argc, char **argv)
4 {
5
struct addrinfo *p, *listp, hints;
6
char buf[MAXLINE];
7
int rc, flags;
8
9
if (argc != 2) {
10
fprintf(stderr, "usage: %s \n", argv[0]);
11
exit(0);
12
}
13
14
/* Получить список структур addrinfo */
15
memset(&hints, 0, sizeof(struct addrinfo));
16
hints.ai_family = AF_INET;
/* Только IPv4 */
17
hints.ai_socktype = SOCK_STREAM; /* Только для надежных соединений */
18
if ((rc = getaddrinfo(argv[1], NULL, &hints, &listp)) != 0) {
19
fprintf(stderr, "getaddrinfo error: %s\n", gai_strerror(rc));
20
exit(1);
21
}
22
23
/* Выполнить обход списка и вывести IP-адреса */
24
flags = NI_NUMERICHOST; /* Получать адреса вместо доменных имен */
25
for (p = listp; p; p = p->ai_next) {
26
Getnameinfo(p->ai_addr, p->ai_addrlen, buf, MAXLINE, NULL, 0, flags);
27
printf("%s\n", buf);
28
}
29
30
/* Освободить ресурсы */

876

 Глава 11. Сетевое программирование

31
32
33
34 }

Freeaddrinfo(listp);
exit(0);

code/netp/hostinfo.c
Сначала она инициализирует структуру hints, чтобы getaddrinfo возвращала нужные
адреса. В данном случае нас интересуют 32-разрядные IP-адреса (строка 16), которые
можно использовать в качестве конечных точек надежных соединений (строка 17). Поскольку нам нужны только доменные имена, мы вызываем getaddrinfo со значением
NULL в аргументе service.
После вызова getaddrinfo программа выполняет обход списка структур addrinfo и с
помощью getnameinfo преобразует каждый адрес сокета в строку с десятично-точечным представлением адреса. Закончив обход, программа освобождает список вызовом
freeaddrinfo (хотя в такой простой программе этого можно было и не делать).
Запустив HOSTINFO, мы увидели, что имя twitter.com отображается в четыре
IP-адреса. Тот же результат мы получили от команды NSLOOKUP в разделе 11.3.2.
linux> ./hostinfo twitter.com
199.16.156.102
199.16.156.230
199.16.156.6
199.16.156.70

Упражнение 11.4 (решение в конце главы)
Функции getaddrinfo и getnameinfo внутренне используют функции inet_pton и inet_
ntop и, соответственно, обеспечивают более высокий уровень абстракции, не зависящий
от конкретного формата адреса. Чтобы ощутить, насколько это удобно, напи­шите версию
HOSTINFO (листинг 11.4), используя inet_ntop вместо getnameinfo для преобразования
каждого адреса сокета в строку с адресом в десятично-точечной форме.

11.4.8. Вспомогательные функции для интерфейса сокетов
Прямое использование функции getaddrinfo и интерфейса сокетов может показаться
сложным для тех, кто только познакомился с ними. Поэтому мы посчитали правильным
решением написать вспомогательные функции-обертки open_clientfd и open_listenfd.

Функция open_clientfd
Функция open_clientfd может использоваться на стороне клиента для установки соединения с сервером.
#include "csapp.h"
int open_clientfd(char *hostname, char *port);

Возвращает дескриптор в случае успеха, −1 в случае ошибки
Функция open_clientfd устанавливает соединение с сервером, действующим на хосте
hostname и принимающим запросы на соединение на порту port. Она возвращает дес-

11.4. Интерфейс сокетов  877
криптор открытого сокета, готового к вводу/выводу. Реализация функции приводится
в листинге 11.5.
Листинг 11.5. open_clientfd: вспомогательная функция, устанавливающая соединение
с сервером; реентерабельная и не зависящая от протокола

code/src/csapp.c
1 int open_clientfd(char *hostname, char *port) {
2
int clientfd;
3
struct addrinfo hints, *listp, *p;
4
5
/* Получить список потенциальных адресов сервера */
6
memset(&hints, 0, sizeof(struct addrinfo));
7
hints.ai_socktype = SOCK_STREAM; /* Открыть надежное соединение */
8
hints.ai_flags = AI_NUMERICSERV; /* ... номер порта -- число. */
9
hints.ai_flags |= AI_ADDRCONFIG; /* Рекомендуемый флаг для соединений */
10
Getaddrinfo(hostname, port, &hints, &listp);
11
/* Найти адрес сокета, с которым удастся установить соединение */
12
13
for (p = listp; p; p = p->ai_next) {
14
/* Создать дескриптор сокета */
15
if ((clientfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0)
16
continue; /* Неудача, попробовать следующий адрес */
17
18
/* Установить соединение с сервером */
19
if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1)
20
break; /* Соединение установлено! */
21
Close(clientfd); /* Ошибка соединения, попробовать следующий адрес */
22
}
23
24
/* Освободить ресурсы */
25
Freeaddrinfo(listp);
26
if (!p) /* Ни с одним адресом не удалось установить соединение */
27
return -1;
28
else
/* Вернуть сокет, которому удалось установить соединение */
29
return clientfd;
30 }

code/src/csapp.c
Наша функция вызывает getaddrinfo и получает список структур addrinfo, каждая из
которых ссылается на структуру адреса сокета, потенциально пригодного для установки
соединения с сервером, действующим на хосте hostname и прослушивающим порт port.
Затем выполняется обход списка и производятся попытки установить соединение с
каждым из адресов по очереди, пока вызовы socket и connect не увенчаются успехом.
Если соединение установить не удалось, дескриптор сокета закрывается, и предпринимается попытка использовать следующий адрес. Если вызов connect завершился успехом, память, занятая списком, освобождается, и вызывающей программе возвращается
дескриптор сокета, готовый к выполнению операций ввода/вывода.
Обратите внимание, что в коде нет ничего, что делало бы его зависимым от конкретной версии протокола IP. Аргументы socket и connect автоматически генерируются вызовом getaddrinfo, что делает наш код ясным и переносимым.

Функция open_listenfd
Функция open_listenfd может использоваться на стороне сервера и создает дескриптор сокета, готового принимать запросы на соединение.

878

 Глава 11. Сетевое программирование
#include "csapp.h"
int open_listenfd(char *port);

Возвращает дескриптор в случае успеха, −1 в случае ошибки
Функция open_listenfd возвращает дескриптор сокета, слушающего порт port и готового принимать запросы на соединение. Ее реализация приводится в листинге 11.6.
Листинг 11.6. open_listenfd: вспомогательная функция, которая открывает и возвращает
дескриптор слушающего сокета; реентерабельная и не зависящая от протокола

code/src/csapp.c
1 int open_listenfd(char *port)
2 {
3
struct addrinfo hints, *listp, *p;
4
int listenfd, optval=1;
5
6
/* Получить список потенциальных адресов сервера */
7
memset(&hints, 0, sizeof(struct addrinfo));
8
hints.ai_socktype = SOCK_STREAM;
/* Принимать соединения */
9
hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG; /* ... на любом IP-адресе */
10
hints.ai_flags |= AI_NUMERICSERV;
/* ... номер порта -- число */
11
Getaddrinfo(NULL, port, &hints, &listp);
12
13
/* Найти адрес сокета, который можно связать */
14
for (p = listp; p; p = p->ai_next) {
15
/* Создать дескриптор сокета */
16
if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0)
17
continue; /* Неудача, попробовать следующий адрес */
18
/* Предотвратить появление ошибки "Адрес уже используется" */
19
20
Setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,
21
(const void *)&optval , sizeof(int));
22
23
/* Связать дескриптор с адресом */
24
if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0)
25
break; /* Успех */
26
Close(listenfd); /* Ошибка, попробовать следующий адрес */
27
}
28
29
/* Освободить ресурсы */
30
Freeaddrinfo(listp);
31
if (!p) /* Ни один адрес не подошел */
32
return -1;
33
34
/* Сделать сокет слушающим и готовым принимать запросы на соединение */
35
if (listen(listenfd, LISTENQ) < 0) {
36
Close(listenfd);
37
return -1;
38
}
39
return listenfd;
40 }

code/src/csapp.c
Она организована так же, как open_clientfd. Сначала вызывается getaddrinfo, а затем
выполняется обход полученного списка, пока вызовы socket и bind не завершатся успе-

11.4. Интерфейс сокетов  879
хом. Обратите внимание на вызов функции setsockopt (здесь не описывается) в строке 20, с помощью которой производится настройка сокета сервера, чтобы его можно
было остановить, перезапустить и начать немедленно принимать запросы на соединение. По умолчанию после перезапуска сервер будет отклонять запросы на соединение в
течение примерно 30 секунд, что может затруднить отладку.
Функция getaddrinfo вызывается с флагом AI_PASSIVE и значением NULL в аргументе host, поэтому поле адреса во всех возвращаемых структурах адресов сокетов будет
содержать подстановочный адрес, сообщающий ядру, что этот сервер будет принимать
запросы на любом IP-адресе этого хоста.
В заключение вызывается функция listen, которая превращает сокет listenfd в слушающий сокет и возвращает результат вызывающей программе. Если listen терпит неудачу, то дескриптор закрывается, чтобы избежать утечки памяти.

11.4.9. Примеры эхо-клиента и эхо-сервера
Лучший способ изучения интерфейса сокетов – изучение примеров его использования на практике. В листинге 11.7 показана реализация эхо-клиента. После соединения
с сервером клиент входит в цикл, в котором многократно читает текстовую строку из
стандартного ввода, посылает ее серверу, читает ответ сервера и выводит результат в
стандартный вывод. Цикл прерывается, когда функция fgets получает признак конца
файла EOF из стандартного ввода, когда пользователь нажмет комбинацию Ctrl+D на
клавиатуре или когда будет достигнут конец файла, переадресованного в стандартный
ввод.
После завершения цикла клиент закрывает дескриптор. В результате этого север получает признак EOF достижения конца файла, который обнаруживается при получении
нулевого значения от функции rio_readlineb. Закрыв де­скриптор, клиент завершает
работу. По завершении процесса клиента ядро автоматически закрывает все открытые
им дескрипторы, поэтому вызов Close в строке 24 не особенно нужен. Однако в программировании считается хорошим тоном явно закрывать все дескрипторы, которые
были открыты.
Листинг 11.7. Функция main эхо-клиента

code/netp/echoclient.c
1 #include "csapp.h"
2
3 int main(int argc, char **argv)
4 {
5
int clientfd;
6
char *host, *port, buf[MAXLINE];
7
rio_t rio;
8
9
if (argc != 3) {
10
fprintf(stderr, "usage: %s \n", argv[0]);
11
exit(0);
12
}
13
host = argv[1];
14
port = argv[2];
15
16
clientfd = Open_clientfd(host, port);
17
Rio_readinitb(&rio, clientfd);
18
19
while (Fgets(buf, MAXLINE, stdin) != NULL) {
20
Rio_writen(clientfd, buf, strlen(buf));
21
Rio_readlineb(&rio, buf, MAXLINE);

880

 Глава 11. Сетевое программирование

22
23
24
25
26 }

Fputs(buf, stdout);
}
Close(clientfd);
exit(0);

code/netp/echoclient.c
В листинге 11.8 показана функция main эхо-сервера. После открытия слушающего дескриптора запускается бесконечный цикл. Каждая итерация этого цикла начинается с
того, что сервер переходит в режим ожидания получения запроса на соединение от клиента. При получении такого запроса сервер выводит доменное имя и IP-адрес клиента и
вызывает функцию echo, которая продолжит обслуживание клиента. После того как echo
вернет управление, функция main закрывает связанный дескриптор, и когда клиент и
сервер закроют соответствующие дескрипторы, соединение разрывается.
Листинг 11.8. Функция main итеративного эхо-сервера

code/netp/echoserveri.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

#include "csapp.h"
void echo(int connfd);
int main(int argc, char **argv)
{
int listenfd, connfd;
socklen_t clientlen;
struct sockaddr_storage clientaddr; /* Достаточно места для любого адреса */
char client_hostname[MAXLINE], client_port[MAXLINE];
if (argc != 2) {
fprintf(stderr, "usage: %s \n", argv[0]);
exit(0);
}
listenfd = Open_listenfd(argv[1]);
while (1) {
clientlen = sizeof(struct sockaddr_storage);
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
Getnameinfo((SA *) &clientaddr, clientlen, client_hostname, MAXLINE,
client_port, MAXLINE, 0);
printf("Connected to (%s, %s)\n", client_hostname, client_port);
echo(connfd);
Close(connfd);
}
exit(0);
}

code/netp/echoserveri.c
Переменная clientaddr в строке 9 – это структура адреса сокета, которая передается
в вызов accept. Перед возвратом accept записывает в clientaddr адрес сокета клиента
на другом конце соединения. Обратите внимание, что clientaddr объявляется с типом
struct sockaddr_storage, а не struct sockaddr_in. По определению, структура sockaddr_
storage достаточно велика, чтобы вмес­тить адрес сокета любого типа, что обеспечивает независимость реализации от протокола.
Обратите внимание, что простой эхо-сервер может обслуживать клиентов только по
одному. Поэтому такие серверы называют итеративными. В главе 12 мы покажем, как

11.5. Веб-серверы  881
строить более сложные конкурентные серверы, способные обслуживать одновременно
несколько клиентов.
Наконец, в листинге 11.9 показана реализация функции echo, которая принимает
строки от клиента и посылает их обратно, пока вызов функции rio_readlineb в строке 10
не вернет признак конца файла.
Листинг 11.9. Фунция echo, которая принимает строки от клиента и посылает их обратно

code/netp/echo.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14

#include "csapp.h"
void echo(int connfd)
{
size_t n;
char buf[MAXLINE];
rio_t rio;
Rio_readinitb(&rio, connfd);
while((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
printf("server received %d bytes\n", (int)n);
Rio_writen(connfd, buf, n);
}
}

code/netp/echo.c

Что означает EOF для сетевых соединений?
Идея использования признака EOF вызывает путаницу в умах студентов, особенно в
контексте сетевых соединений. Во-первых, вы должны осознавать, что в реальнос­ти не
существует символа EOF. На самом деле EOF – это условие (условие достижения конца
файла), которое обнаруживается ядром. Приложение обнаруживает условие EOF, когда
получает ноль от функции read. Для дисковых файлов условие EOF возникает, когда позиция в текущем файле выходит за пределы файла. Для сетевых сое­динений EOF возникает в случаях, когда какой-то процесс закрывает соединение на своем конце. Процесс
на другом конце соединения обнаруживает условие EOF, когда делает попытку прочитать
последний байт из потока.

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

11.5.1. Основные сведения о вебе
Веб-клиенты и веб-серверы взаимодействуют, используя текстовый протокол прикладного уровня, известный как протокол передачи гипертекста (Hypertext Transfer
Protocol, HTTP). Протокол HTTP достаточно прост. Веб-клиент (браузер) открывает соединение с сервером и запрашивает некоторый контент (содержимое). В ответ сервер
посылает затребованный контент, после чего разрывает соединение. Браузер читает
контент и отображает его на экране.

882

 Глава 11. Сетевое программирование

Чем отличается веб-сервер от обычной службы поиска файлов, такой как FTP? Основное отличие заключается в том, что веб-контент может быть напи­сан на языке описания
гипертекстовых документов (Hypertext Markup Language, HTML). Программа (страница)
на языке HTML содержит инструкции (теги), которые сообщают браузеру, как отображать различные фрагменты текс­та и графические объекты. Например, код
Make me bold!

предписывает вывести текст, заключенный между тегами и , жирным шрифтом. Однако основное достоинство языка HTML заключается в возможности включать
в страницы указатели (гиперссылки) на контент, хранящийся на одном из хостов в интернете. Например, HTML-строка
Carnegie Mellon

представляет собой команду выделения текстового объекта Carnegie Mellon и создания
гиперссылки на HTML-файл с именем index.html, находящийся на веб-сервере университета Карнеги–Меллона (Carnegie-Melone University, CMU). Если пользователь щелкнет
на выделенном текстовом объекте, то браузер затребует соответствующий файл HTML с
сервера CMU и отобразит его на экране.
Происхождение Всемирной паутины
Всемирная паутина (World Wide Web) была изобретена Тимом Бернерсом-Ли (Tim
Berners-Lee), специалистом по программному обеспечению в швейцарской физичес­кой
лаборатории Европейский центр ядерных исследований (European Center for Nuclear
Research, CERN). В 1989 г. Бернерс-Ли опубликовал в международных научных изданиях заметку с предложением создания распределенной гипертекстовой системы, которая
соединяла бы сеть узлов линиями передачи данных. Назначение предложенной системы заключалось в том, чтобы помочь ученым CERN совместно использовать и управлять информацией. Через два с небольшим года после того, как Бернерс-Ли реализовал
первый веб-сервер и веб-браузер, была создана небольшая сеть, охватывающая серверы CERN и несколько других сайтов. В 1993 г. произошло поворотное событие, когда
Марк Андриссен (Магc Andreesen; впоследст­вии основавший компанию Netscape) и его
коллеги из Национального центра по применению суперкомпьютеров (National Center
for Supercomputing Applications, NCSA) разработали и внедрили графический браузер
MOSAIC для всех трех основных платформ: Linux, Windows и Macintosh. С появлением
браузера MOSAIC интерес к вебу приобрел характер взрыва, при этом число веб-сайтов ежегодно увеличивалось в 10 и более раз. К 2015 году в мире существовало более
975 млн сайтов (по данным отчета Netcraft Web Survey).

11.5.2. Веб-контент
Для веб-клиентов и веб-серверов контент (содержимое) представляет собой последовательность байтов со связанным с ними типом MIME (Multipurpose Internet Mail
Extensions – многоцелевые расширения электронной почты интернета). В табл. 11.1 показаны некоторые общеупотребительные типы MIME.
Веб-серверы формируют контент для клиентов двумя разными способами:

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

11.5. Веб-серверы  883
Таблица 11.1. Примеры типов MIME
Тип MIME

Описание

text/html

HTML-страница

text/plain

Неформатированный текст

application/postscript

Документ Postscript

image/gif

Двоичные данные, изображение в формате GIF

image/png

Двоичные данные, изображение в формате PNG

image/jpeg

Двоичные данные, изображение в формате JPEG

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

Каждый элемент контента, возвращаемый веб-сервером, ассоциируется с некоторым
файлом, которым управляет этим контентом. Каждый из этих файлов имеет уникальное имя – унифицированный указатель информационного ресурса (Universal Resource
Locator, URL). Например, URL
http://www.google.com:80/index.html

идентифицирует HTML-файл с именем /index.html на хосте www.google.com, который
управляется веб-сервером, прослушивающим порт 80. Номер порта можно не указывать, потому что по умолчанию используется хорошо известный порт 80, присвоенный
службе HTTP. Указатели URL исполняемых файлов могут включать аргументы после
имени файла. Символ ? отделяет имя файла от аргументов, а между собой аргументы
отделяются символом &. Например, URL
http://bluefish.ics.cs.cmu.edu:8000/cgi-bin/adder?15000&213

идентифицирует выполняемый файл с именем /cgi-bin/adder, который вызывается с
двумя строковыми аргументами: 15000 и 213. Клиенты и серверы используют разные
части URL во время обработки транзакции. Например, клиент использует префикс
http://www.google.com:80

чтобы определить сервер, с которым требуется устанавливать соединение: где находится сервер и какой порт он прослушивает. Сервер использует суффикс
/index.html
для поиска файла в своей файловой системе и чтобы определить тип запрошенного
контента: статический или динамический.
Выделим несколько моментов, которые необходимо понимать, чтобы правильно интерпретировать суффиксы URL:

• не существует стандартных правил определения типа контента, на который

ссылается URL. У каждого сервера свои правила работы с файлами. Обычно разделение контента по типам производится путем определения набора каталогов,
таких как cgi-bin, в которых должны храниться все выполняемые файлы;

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

884

 Глава 11. Сетевое программирование
может быть настроен так, что все файлы со статическим контентом хранятся в
каталоге /usr/httpd/html, а все файлы с динамическим контентом – в каталоге
/usr/httpd/cgi-bin;

• минимальный суффикс в URL – это символ /, который все серверы интерпре-

тируют как имя файла некоторой стандартной начальной страницы, такой как
/index.html. Это объясняет, почему можно извлечь начальную страницу прос­
тым вводом доменного имени в адресную строку браузера: браузер добавляет
отсутствующий символ / в конец URL и передает его серверу, который заменяет / некоторым стандартным именем файла.

11.5.3. Транзакции HTTP
Протокол HTTP основан на текстовых строках, передаваемых через соединения, поэтому для выполнения транзакций с любым веб-сервером можно воспользоваться программой TELNET операционной системы Linux. Программа TELNET практически была
вытеснена более защищенной программой SSH, однако она остается весьма полезной
при отладке серверов, которые общаются с клиентами, передавая текстовые строки.
В листинге 11.10 показан сеанс использования TELNET для запроса домашней страницы веб-сервера AOL.
Листинг 11.10. Пример взаимодействия с сервером HTTP, обслуживающим статический контент
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

linux> telnet www.aol.com 80
Trying 205.188.146.23...
Connected to aol.com.
Escape character is '^]'.
GET / HTTP/1.1
Host: www.aol.com

Клиент: открывает соединение с сервером
Telnet выводит 3 строки в терминал

Клиент: строка запроса
Клиент: заголовок запроса HTTP/1.1
Клиент: пустая строка завершает заголовки
HTTP/1.0 200 OK
Сервер: строка ответа
MIME-Version: 1.0
Сервер: и пять заголовков ответа
Date: Mon, 8 Jan 2010 4:59:42 GMT
Server: Apache-Coyote/1.1
Content-Type: text/html
Сервер: тело ответа – это разметка HTML
Content-Length: 42092
Сервер: размер тела ответа -- 42092 байта
Сервер: пустая строка завершает заголовки

Сервер: первая строка HTML в теле ответа
...
Сервер: 766 строк HTML в теле ответа (не показаны)

Сервер: последняя строка HTML в теле ответа
Connection closed by foreign host.
Сервер: закрывает соединение
linux>
Клиент: закрывает соединение и завершается

В строке 1 мы запускаем программу TELNET в командной оболочке Linux и требуем установить соединение с веб-сервером AOL. Программа TELNET выводит в терминал три строки, открывает соединение, а затем ждет, когда мы введем текст (строка 5).
Каждый раз, когда мы вводим текстовую строку и нажимаем клавишу Enter, программа
TELNET читает эту строку, добавляет в конец символы перевода строки (\r\n) и отправляет строку серверу. Это соответствует стандарту HTTP, который требует, чтобы каждая
строка заканчивалась парой символов возврата каретки и перевода строки. Чтобы
инициировать транзакцию, мы вводим HTTP-запрос (строки 5–7). Сервер возвращает
HTTP-ответ (строки 8–17) и закрывает соединение (строка 18).

11.5. Веб-серверы  885

Запросы HTTP
HTTP-запрос состоит из строки запроса (строка 5), за которой может следовать несколько заголовков запроса (строка 6), завершающихся пустой текстовой строкой (строка 7). Строка запроса имеет форму:
метод URI версия
HTTP имеет несколько разных методов, в том числе GET, POST, OPTIONS, HEAD, PUT,
DELETE и TRACE. Мы рассмотрим только наиболее часто используемый метод GET, который применяется в подавляющем большинстве HTTP-зап­росов. Метод GET требует
от сервера сгенерировать и вернуть контент, соответст­вующий универсальному идентификатору ресурса (Uniform Resource Identifier, URI). Идентификатор URI – это суффикс
из URL, включающий имя файла и необязательные аргументы3.
Поле версия в строке запроса сообщает версию HTTP, которой соответствует запрос.
Самая последняя версия – НТТР/1.1 [37]. Ей предшествовала более простая версия
НТТР/1.0, появившаяся на свет в 1996 г. Версия НТТР/1.1 определяет дополнительные
заголовки для поддержки дополнительных возможностей, таких как кеширование
и безопасность, а также механизм, позволяющий клиенту и серверу выполнять многочисленные транзакции через одно и то же соединение. На практике две указанные
выше версии совместимы, потому что клиенты и серверы, поддерживающие НТТР/1.0,
просто игнорируют неизвестные им заголовки НТТР/1.1.
Запрос в строке 5 требует от сервера отыскать и вернуть HTML-файл /index.html. Он
также сообщает серверу, что остальная часть запроса будет представлена в формате
НТТР/1.1.
Заголовки запросов несут дополнительную информацию серверу, такую как название браузера или тип MIME, который тот готов принять. В общем случае каждый заголовок имеет следующий формат:
Имя-заголовка: данные-заголовка
Нам пока достаточно знакомства только с одним заголовком – Host (строка 6), который обязательно должен присутствовать в запросах НТТР/1.1, но не требуется в запросах НТТР/1.0. Заголовок Host используется прокси-кешами, служащими посредниками
между браузером и сервером, где находится требуемый контент. Между клиентом и
сервером может находиться множество прокси-серверов. Данные в заголовке Host, который идентифицирует доменное имя оригинального сервера, позволяют прокси-серверу определить кешированную у себя копию запрошенного контента.
Но вернемся к нашему примеру в листинге 11.10. Пустая строка, завершающая заголовки в строке 7 (получившаяся в результате нажатия клавиши Enter), дает серверу
команду вернуть запрошенный файл HTML.

Ответы HTTP
HTTP-ответы подобны HTTP-запросам. HTTP-ответ состоит из строки ответа (строка 8 в листинге 11.10), за которой могут следовать несколько заголовков ответа (строки 9–13), завершающиеся пустой строкой (строка 14), и тело ответа (строки 15–17).
Строка ответа имеет вид:
версия код-состояния сообщение-о-состоянии
Поле версия сообщает версию HTTP, которой соответствует ответ. Код состоя­ния – это
положительное целое трехзначное число, сообщающее результат обра­ботки запроса.
3

На самом деле это верно, только когда контент запрашивается браузером. Если контент запрашивает прокси-сервер, то URI должен быть полным URL.

886

 Глава 11. Сетевое программирование

Сообщение о состоянии описывает код состояния. В табл. 11.2 перечислены некоторые
наиболее часто встречающиеся коды состоя­ния и соот­ветствующие им сообщения.
Таблица 11.2. Некоторые коды состояний HTTP
Код
Сообщение о состоянии
состояния

Описание

200

OK («хорошо»)

Запрос обработан без ошибок

301

Moved permanently
(«перемещено навсегда»)

Контент перемещен на хост, имя которого
указано в заголовке «Location» ответа

400

Bad request
(«неправильный, некорректный запрос»)

Запрос не был понят сервером

403

Forbidden
(«запрещено (не уполномочен)»)

У сервера недостаточно привилегий
для доступа к запрошенному файлу

404

Not found («не найдено»)

Сервер не нашел запрошенный файл

501

Not implemented
(«не реализовано»)

Сервер не поддерживает метод, указанный
в запросе

505

HTTP version not supported
(«версия HTTP не поддерживается»)

Сервер не поддерживает версию HTTP,
указанную в запросе

Заголовки ответа в строках 9–13 (листинг 11.10) несут дополнительную информацию
об ответе. Для нас самыми важными заголовками являются Content-Type (тип содержимого; строка 12), который сообщает клиенту тип MIME содержимого в теле ответа,
и Content-Length (длина содержимого; строка 13), сообщающий размер тела ответа в
байтах.
За пустой строкой, завершающей заголовки (в строке 14), следует тело ответа, которое содержит требуемый контент.

11.5.4. Обслуживание динамического контента
Если задуматься о том, как сервер генерирует динамический контент для клиента,
возникают определенные вопросы. Например, как клиент передает серверу аргументы? Как сервер передает эти аргументы дочернему процессу? Как сервер передает другую информацию дочернему процессу, которая может ему понадобиться для создания
затребованного контента? Куда дочерний процесс посылает результаты? На все эти
вопросы дает ответы фактический стандарт, получивший название общего шлюзового
интерфейса (Common Gateway Interface, CGI).

Как клиент передает аргументы серверу?
При выполнении запроса GET аргументы передаются в URI. Как мы уже знаем, символ ? отделяет имя файла от аргументов, а каждый аргумент отделяется от остальных
символом &. Пробелы в аргументах недопустимы и должны быть представлены в виде
строк %20. Подобные коды существуют и для других специальных символов.

Как сервер передает аргументы дочернему процессу?
Получив такой запрос
GET /cgi-bin/adder?15000&213 HTTP/1.1

сервер вызывает функцию fork и запускает дочерний процесс, который затем вызывает функцию execve, чтобы запустить программу /cgi-bin/adder. Программы, такие как

11.5. Веб-серверы  887
adder, часто называют программами CGI в силу того, что они подчиняются правилам
стандарта CGI. Прежде чем обратиться к функции execve, дочерний процесс присваивает переменной QUERY_STRING окружения CGI значение 15000&213, на которое программа adder может ссылаться во время выполнения с помощью функции getenv.

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

Описание

QUERY_STRING

Аргументы программы

SERVER_PORT

Порт, прослушиваемый родителем

REQUEST_METHOD

GET или POST

REMOTE_HOST

Доменное имя клиента

REMOTE_ADDR

Десятично-точечный IP-адрес клиента

CONTENT_TYPE

Только для POST: тип MIME тела запроса

CONTENT_LENGTH

Только для POST: размер в байтах тела запроса

Куда дочерний процесс отправляет свой вывод?
Программа CGI выводит сгенерированный контент в стандартный вывод. Прежде
чем дочерний процесс загрузит и выполнит программу CGI, он вызывает функцию dup2,
чтобы переадресовать стандартный вывод в дескриптор, связанный с клиентом. То есть
все, что программа CG1 выводит в стандартный вывод, передается непосредственно
клиенту.
Обратите внимание, что родительский процесс не знает ни типа, ни размера содержимого, которое генерирует дочерний процесс, поэтому вся ответственность за создание заголовков ответа Content-type и Content-length, а также за вывод пустой строки,
завершающей раздел заголовков, возлагается на дочерний процесс.
В листинге 11.11 показана простая программа CGI, складывающая два аргумента и
возвращающая клиенту файл HTML с суммой. В листинге 11.12 показана расшифровка
транзакции HTTP, соответствующей обслуживанию динамического контента программой adder.
Листинг 11.11. Программа CGI, складывающая два аргумента

code/netp/tiny/cgi-bin/adder.c
1 #include "csapp.h"
2
3 int main(void) {
4
char *buf, *p;
5
char arg1[MAXLINE], arg2[MAXLINE], content[MAXLINE];
6
int n1=0, n2=0;
7
8
/* Извлечь аргументы */
9
if ((buf = getenv("QUERY_STRING")) != NULL) {
10
p = strchr(buf, '&');
11
*p = '\0';

888

 Глава 11. Сетевое программирование

12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 }

strcpy(arg1, buf);
strcpy(arg2, p+1);
n1 = atoi(arg1);
n2 = atoi(arg2);
}
/* Сконструировать тело ответа */
sprintf(content, "QUERY_STRING=%s", buf);
sprintf(content, "Welcome to add.com: ");
sprintf(content, "%sTHE Internet addition portal.\r\n", content);
sprintf(content, "%sThe answer is: %d + %d = %d\r\n",
content, n1, n2, n1 + n2);
sprintf(content, "%sThanks for visiting!\r\n", content);
/* Сгенерировать HTTP-ответ */
printf("Connection: close\r\n");
printf("Content-length: %d\r\n", (int)strlen(content));
printf("Content-type: text/html\r\n\r\n");
printf("%s", content);
fflush(stdout);
exit(0);

code/netp/tiny/cgi-bin/adder.c
Листинг 11.12. Расшифровка транзакции HTTP, соответствующей обслуживанию
динамического контента
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

linux> telnet kittyhawk.cmcl.cs.cmu.edu 8000 Клиент: открывает соединение
Trying 128.2.194.242...
Connected to kittyhawk.cmcl.cs.cmu.edu.
Escape character is '^]'.
GET /cgi-bin/adder?15000&213 HTTP/1.0 Клиент: строка запроса
Клиент: пустая строка завершает заголовки
HTTP/1.0 200 OK
Сервер: строка ответа
Server: Tiny Web Server
Сервер: идентификатор сервера
Content-length: 115
Adder: ожидается 115 байт в теле ответа
Content-type: text/html
Adder: тело ответа в формате HTML
Adder: пустая строка завершает заголовки
Welcome to add.com: THE Internet addition portal. Adder: первая строка HTML
The answer is: 15000 + 213 = 15213 Adder: вторая строка HTML в теле ответа
Thanks for visiting!
Adder: третья строка HTML в теле ответа
Connection closed by foreign host.
Сервер: закрывает соединение
linux>
Клиент: закрывает соединение и завершается

Упражнение 11.5 (решение в конце главы)
В разделе 10.11 мы предупреждали вас об опасностях использования стандартных функций
ввода/вывода языка C в сетевых приложениях. В то же время программа CGI, представленная в листинге 11.11, может без всяких проблем использовать стандартный ввод/вывод.
Почему?

11.6. Все вместе: разработка небольшого веб-сервера TINY

 889

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

11.6. Все вместе: разработка небольшого
веб-сервера TINY
В завершение обсуждения сетевого программирования мы разработаем небольшой
веб-сервер TINY (крохотный). Сервер TINY – довольно интересная программа. Она сочетает в себе многие из идей, которые мы рассмотрели выше, такие как управление
процессами, ввод/вывод Unix, интерфейс сокетов, а также протокол HTTP, и при этом
объем программного кода составляет всего 250 строк. И хотя он уступает в функциональности, надежности и безопасности настоящим веб-серверам, он достаточно мощный, чтобы обслуживать статический и динамический контент. Мы настоятельно рекомендуем вам изучить и опробовать его. Это довольно занимательно подключиться к
своему серверу с помощью браузера и наблюдать, как он отображает на экране сложную
веб-страницу с текстом и графикой.

Функция main сервера TINY
В листинге 11.13 представлена функция main сервера TINY. TINY – это итеративный
сервер, прослушивающий порт, номер которого передается в аргументе командной
строки. Открыв слушающий сокет вызовом open_listenfd, сервер TINY входит в бесконечный цикл, характерный для серверов, принимаязапросы на соединение (строка 32),
выполняя транзакции (строка 36) и закрывая свой конец соединения (строка 37).
Листинг 11.13. Веб-сервер TINY

code/netp/tiny/tiny.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

/*
* tiny.c – Простой итеративный веб-сервер HTTP/1.0, принимающий
* запросы GET и обслуживающий статический и динамический контент
*/
#include "csapp.h"
void doit(int fd);
void read_requesthdrs(rio_t *rp);
int parse_uri(char *uri, char *filename, char *cgiargs);
void serve_static(int fd, char *filename, int filesize);
void get_filetype(char *filename, char *filetype);
void serve_dynamic(int fd, char *filename, char *cgiargs);
void clienterror(int fd, char *cause, char *errnum,
char *shortmsg, char *longmsg);
int main(int argc, char **argv)
{
int listenfd, connfd;
char hostname[MAXLINE], port[MAXLINE];
socklen_t clientlen;
struct sockaddr_storage clientaddr;

890

 Глава 11. Сетевое программирование

23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39 }

/* Проверить аргументы командной строки */
if (argc != 2) {
fprintf(stderr, "usage: %s \n", argv[0]);
exit(1);
}
listenfd = Open_listenfd(argv[1]);
while (1) {
clientlen = sizeof(clientaddr);
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
Getnameinfo((SA *) &clientaddr, clientlen, hostname, MAXLINE,
port, MAXLINE, 0);
printf("Accepted connection from (%s, %s)\n", hostname, port);
doit(connfd);
Close(connfd);
}

code/netp/tiny/tiny.c

Функция doit
Функция doit, представленная в листинге 11.14, обрабатывает одну HTTP-транзакцию. Она сначала читает и анализирует строку запроса (строки 11–14). Обратите внимание, что для чтения строки запроса здесь используется функция rio_readlineb (лис­
тинг 10.6).
Листинг 11.14. doit – обработчик HTTP-транзакций в TINY

code/netp/tiny/tiny.c
1 void doit(int fd)
2 {
3
int is_static;
4
struct stat sbuf;
5
char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
6
char filename[MAXLINE], cgiargs[MAXLINE];
7
rio_t rio;
8
9
/* Прочитать строку запроса и заголовки */
10
Rio_readinitb(&rio, fd);
11
Rio_readlineb(&rio, buf, MAXLINE);
12
printf("Request headers:\n");
13
printf("%s", buf);
14
sscanf(buf, "%s %s %s", method, uri, version);
15
if (strcasecmp(method, "GET")) {
16
clienterror(fd, method, "501", "Not implemented",
17
"Tiny does not implement this method");
18
return;
19
}
20
read_requesthdrs(&rio);
21
22
/* Проанализировать URI из запроса GET */
23
is_static = parse_uri(uri, filename, cgiargs);
24
if (stat(filename, &sbuf) < 0) {
25
clienterror(fd, filename, "404", "Not found",
26
"Tiny couldn't find this file");
27
return;
28
}

11.6. Все вместе: разработка небольшого веб-сервера TINY
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46 }

 891

if (is_static) { /* Обслужить статический контент */
if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) {
clienterror(fd, filename, "403", "Forbidden",
"Tiny couldn't read the file");
return;
}
serve_static(fd, filename, sbuf.st_size);
}
else { /* Обслужить динамический контент */
if (!(S_ISREG(sbuf.st_mode)) || !(S_IXUSR & sbuf.st_mode)) {
clienterror(fd, filename, "403", "Forbidden",
"Tiny couldn't run the CGI program");
return;
}
serve_dynamic(fd, filename, cgiargs);
}

code/netp/tiny/tiny.c
Сервер TINY поддерживает только метод GET. Если клиент пошлет запрос с другим
методом (например, POST), то сервер вернет ему сообщение об ошибке и вернется в
функцию main (строки 15–19), которая затем закроет соединение и перейдет к ожиданию следующего запроса на соединение. В противном случае doit читает и (как вы увидите далее) игнорирует любые заголовки в запросе (строка 20).
Далее анализируется URI, из которого извлекается имя файла и, возможно пустая,
строка с аргументами CGI. Попутно устанавливается флаг-признак типа контента – статический или динамический (строка 23). Если файл на диске отсутствует, клиенту возвращается сообщение об ошибке и выполняется возврат в функцию main.
Наконец, если клиент запросил статический контент, то проверяется вид файла и
наличие разрешений на его чтение (строка 31). Если все в порядке, то содержимое файла отправляется клиенту (строка 36). Аналогично, если клиент запросил динамический
контент, проверяется наличие разрешения на выполнение файла (строка 39), и если
разрешение имеется, то производится обслуживание динамического контента (строка 44).

Функция clienterror
В сервере TINY отсутствуют многие функции обработки ошибок, которые имеются в
настоящих серверах. Тем не менее он выполняет проверку на наличие некоторых очевидных ошибок и уведомляет клиентов о них. Функция clienterror, представленная в
листинге 11.15, отсылает клиенту HTTP-ответ с соответствующим кодом состояния и
сообщением в строке ответа, а также файл HTML в теле ответа, описывающий причину
ошибки.
Листинг 11.15. clienterror посылает сообщение об ошибке клиенту

code/netp/tiny/tiny.c
1 void clienterror(int fd, char *cause, char *errnum,
2 char *shortmsg, char *longmsg)
3 {
4
char buf[MAXLINE], body[MAXBUF];
5
6
/* Сконструировать тело HTTP-ответа */
7
sprintf(body, "Tiny Error");

892

 Глава 11. Сетевое программирование

8
9
10
11
12
13
14
15
16
17
18
19
20
21 }

sprintf(body,
sprintf(body,
sprintf(body,
sprintf(body,

"%s\r\n", body);
"%s%s: %s\r\n", body, errnum, shortmsg);
"%s%s: %s\r\n", body, longmsg, cause);
"%sThe Tiny Web server\r\n", body);

/* Вывести HTTP-ответ */
sprintf(buf, "HTTP/1.0 %s %s\r\n", errnum, shortmsg);
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-type: text/html\r\n");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-length: %d\r\n\r\n", (int)strlen(body));
Rio_writen(fd, buf, strlen(buf));
Rio_writen(fd, body, strlen(body));

code/netp/tiny/tiny.c
Напомним вам, что в HTTP-ответе должен быть указан размер и тип содержимого
тела ответа. Поэтому мы решили сконструировать HTML-страницу в виде единой строки, чтобы потом было проще определить ее размер. Обратите также внимание, что для
вывода здесь используется надежная функция rio_writen (листинг 10.2).

Функция read_requesthdrs
Сервер TINY не использует информацию, содержащуюся в заголовках запросов. Он
просто читает ее вызовом функции read_requesthdrs (листинг 11.16) и игнорирует. Обратите внимание, что пустая строка, завершающая блок заголовков запроса, состоит из
пары символов возврата каретки и перевода строки, наличие которой проверяет строка 6.
Листинг 11.16. read_requesthdrs читает заголовки запроса и игнорирует их

code/netp/tiny/tiny.c
1 void read_requesthdrs(rio_t *rp)
2 {
3
char buf[MAXLINE];
4
5
Rio_readlineb(rp, buf, MAXLINE);
6
while(strcmp(buf, "\r\n")) {
7
Rio_readlineb(rp, buf, MAXLINE);
8
printf("%s", buf);
9
}
10
return;
11 }

code/netp/tiny/tiny.c

Функция parse_uri
Сервер TINY предполагает, что статический контент хранится в текущем каталоге, а
выполняемые файлы, генерирующие динамический контент, – в каталоге ./cgi-bin. То
есть любой URI, содержащий строку cgi-bin, считается запросом динамического контента. Если имя файла не указано, используется имя по умолчанию ./home.html.
Эту стратегию реализует функция parse_uri (листинг 11.17). Она выполняет синтаксический анализ URI, выделяя из него имя файла и необязательную строку аргументов CGI. Если запрашивается статический контент (строка 5), то строка аргументов CGI
очищается (строка 6), после чего URI преобразуется в строку относительного пути к
файлу, например ./index.html (строки 7–8). Если URI заканчивается символом / (стро-

11.6. Все вместе: разработка небольшого веб-сервера TINY

 893

ка 9), то в конец добавляется имя файла по умолчанию (строка 10). С другой стороны,
если запрашивается динамический контент (строка 13), то из URI извлекаются все имеющиеся аргументы CG1 (строки 14–20), а остальная часть URI преобразуется в относительный путь к файлу (строки 21–22).
Листинг 11.17. parse_uri анализирует URI HTTP-запроса

code/netp/tiny/tiny.c
1 int parse_uri(char *uri, char *filename, char *cgiargs)
2 {
3
char *ptr;
4
5
if (!strstr(uri, "cgi-bin")) { /* Статический контент */
6
strcpy(cgiargs, "");
7
strcpy(filename, ".");
8
strcat(filename, uri);
9
if (uri[strlen(uri)-1] == '/')
10
strcat(filename, "home.html");
11
return 1;
12
}
13
else { /* Динамический контент */
14
ptr = index(uri, '?');
15
if (ptr) {
16
strcpy(cgiargs, ptr+1);
17
*ptr = '\0';
18
}
19
else
20
strcpy(cgiargs, "");
21
strcpy(filename, ".");
22
strcat(filename, uri);
23
return 0;
24
}
25 }

code/netp/tiny/tiny.c

Функция serve_static
Сервер TINY обслуживает пять разных типов статического контента: файлы HTML,
обычные текстовые файлы и изображения в форматах GIF, PNG и JPEG.
Функция serve_static (листинг 11.18) посылает HTTP-ответ с содержимым локального
файла. Сначала функция определяет тип контента по расширению файла (строка 7), а затем
посылает строку ответа и заголовки клиенту (строки 8–13). Обратите внимание на то, что
раздел заголовков завершает пустая строка.
Затем тело ответа посылается клиенту путем копирования содержимого файла в де­
скриптор fd. Здесь есть некоторые тонкости, поэтому рассмотрим эту операцию подробнее. Строка 18 открывает файл filename для чтения и получает дескриптор. В строке 19
вызовом mmap файл отображается в виртуальную память. Вспомните, как рассказывалось в разделе 9.8, вызов функции mmap отображает первые filesize байт из файла srcfd
в приватную область виртуальной памяти, которая начинается с адреса srcp.
Листинг 11.18. serve_static посылает клиенту статический контент

code/netp/tiny/tiny.c
1 void serve_static(int fd, char *filename, int filesize)
2 {
3
int srcfd;

894
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

 Глава 11. Сетевое программирование
char *srcp, filetype[MAXLINE], buf[MAXBUF];
/* Послать заголовки ответа клиенту */
get_filetype(filename, filetype);
sprintf(buf, "HTTP/1.0 200 OK\r\n");
sprintf(buf, "%sServer: Tiny Web Server\r\n", buf);
sprintf(buf, "%sConnection: close\r\n", buf);
sprintf(buf, "%sContent-length: %d\r\n", buf, filesize);
sprintf(buf, "%sContent-type: %s\r\n\r\n", buf, filetype);
Rio_writen(fd, buf, strlen(buf));
printf("Response headers:\n");
printf("%s", buf);
/* Послать тело ответа клиенту */
srcfd = Open(filename, O_RDONLY, 0);
srcp = Mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0);
Close(srcfd);
Rio_writen(fd, srcp, filesize);
Munmap(srcp, filesize);
}
/*
* get_filetype – определяет тип файла по расширению
*/
void get_filetype(char *filename, char *filetype)
{
if (strstr(filename, ".html"))
strcpy(filetype, "text/html");
else if (strstr(filename, ".gif"))
strcpy(filetype, "image/gif");
else if (strstr(filename, ".png"))
strcpy(filetype, "image/png");
else if (strstr(filename, ".jpg"))
strcpy(filetype, "image/jpeg");
else
strcpy(filetype, "text/plain");
}

code/netp/tiny/tiny.c
После отображения файла в память он становится ненужным, поэтому закрывается
(строка 20). Если этого не сделать, возникает риск фатальной утечки памяти. Строка 21 выполняет фактическую отправку файла клиенту. Функция rio_writen копирует
filesize байт, начиная с адреса srcp в памяти (куда отображен файл), в дескриптор
соединения с клиентом. И наконец, строка 22 освобождает область виртуальной памяти, отведенной для файла. Это необходимо, чтобы избежать возможной фатальной
утечки памяти.

Функция serve_dynamic
Сервер TINY поддерживает обслуживание динамического контента любого типа,
создавая дочерний процесс, который затем запускает программу CGI. Функция serve_
dynamic (листинг 11.19) начинается с отправки строки ответа, показывающей клиенту,
что все идет нормально, с информационным заголовком Server. В обязанности программы CGI входит отправка остальной части ответа. Обратите внимание, что эта операция не так надежна, как хотелось бы, потому что не предусматривает обработку ошибок, которые могут возникнуть в процессе выполнения программ CGI.

11.6. Все вместе: разработка небольшого веб-сервера TINY

 895

Обработка преждевременного закрытия соединений
Несмотря на то что базовые функции веб-сервера достаточно просты, мы не хотим
вызвать у вас ложное чувство, что написание настоящего сервера – пустяковое дело.
Созда­ние надежного веб-сервера, способного безаварийно действовать в течение долгого времени, – трудная задача, требующая глубокого понимания особенностей программирования в Linux. Например, если сервер выполняет последовательность операций
запи­си в соединение, уже закрытое клиентом (например, если пользователь щелкнул на
кнопке Stop (Остановить)), то первая из таких операций записи выполнится успешно, но
вторая и последующие операции повлекут передачу процессу сервера сигнала SIGPIPE,
стандартное поведение которого – завершение процесса. Если процесс настроил обработку или игнорирование сигнала SIGPIPE, то вторая и последующие операции записи
будут возвращать –1 со значением EPIPE в переменной errno. Функции strerr и perror
возвращают для ошибки EPIPE сообщение «Broken pipe» (разрыв канала) – настолько
неинформативное, что ставило в тупик не одно поколение студентов. То есть надежный
сервер должен перехватывать сигнал SIGPIPE и проверять, не вернул ли вызов функции write ошибку EPIPE.

Листинг 11.19. serve_dynamic посылает клиенту динамический контент

code/netp/tiny/tiny.c
1 void serve_dynamic(int fd, char *filename, char *cgiargs)
2 {
3
char buf[MAXLINE], *emptylist[] = { NULL };
4
5
/* Отправить первую часть HTTP-ответа */
6
sprintf(buf, "HTTP/1.0 200 OK\r\n");
7
Rio_writen(fd, buf, strlen(buf));
8
sprintf(buf, "Server: Tiny Web Server\r\n");
9
Rio_writen(fd, buf, strlen(buf));
10
11
if (Fork() == 0) { /* Потомок */
12
/* Настоящий сервер в этом месте устанавливает переменные CGI */
13
setenv("QUERY_STRING", cgiargs, 1);
14
/* Переадресовать stdout в дескриптор соединения с клиентом */
15
Dup2(fd, STDOUT_FILENO);
16
Execve(filename, emptylist, environ); /* Запустить CGI-программу */
17
}
18
Wait(NULL); /* Родитель ждет, чтобы утилизировать дочерний процесс */
19 }

code/netp/tiny/tiny.c
После отправки первой части ответа создается новый дочерний процесс (строка 11).
Он инициализирует переменную окружения QUERY_STRING аргументами CGI из запроса идентификатора URI (строка 13). Обратите внимание, что в этом месте настоящие
серверы устанавливают также другие переменные окружения CGI, но мы для экономии
места опускаем эти действия.
Далее дочерний переадресует свой стандартный вывод в дескриптор со­единения
(строка 15), а затем загружает и запускает программу CGI (строка 16). Поскольку программа CGI выполняется в контексте дочернего процесса, она имеет доступ ко всем открытым файлам и переменным окружения, которые был доступны до вызова функции
execve. Следовательно, все, что программа CGI записывает в стандартный вывод, будет
немедленно отправляться клиентс­кому процессу без всякого вмешательства родитель-

896

 Глава 11. Сетевое программирование

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

11.7. Итоги
В основу каждого сетевого приложения положена модель клиент–сервер. В соот­ветствии
с этой моделью приложение состоит из сервера и одного или нескольких клиентов. Сервер управляет ресурсами, предоставляя услуги своим клиентам, для чего необходимо
тем или иным способом манипулировать ресурсами. Базовой операцией модели клиент–сервер является транзакция, предусматривающая отправку запроса клиентом, за
которым следует ответ сервера.
Клиент и серверы обмениваются данными через всемирную сеть, известную как
интернет. С точки зрения программиста интернет можно рассматривать как коллекцию хос­тов, разбросанных по всему миру и обладающих следующими свойствами:
(1) каждый хост имеет уникальный 32-разрядный адрес, который называют IP-адресом;
(2) множество IP-адресов отображается в множество доменных имен интернета;
(3) процессы на разных хостах в интернете могут обмениваться данными через установленные соединения.
Клиенты и серверы устанавливают соединения с помощью интерфейса сокетов. Сокет – это конечная точка соединения, которая в приложении представлена в форме де­
скриптора файла. Интерфейс сокетов предоставляет функции для открытия и закрытия
дескрипторов. Клиенты и серверы обмениваются данными, записывая и читая содержимое этих дескрипторов.
Веб-серверы и их клиенты (такие как браузеры) обмениваются данными посредством протокола HTTP. Браузер запрашивает у сервера статический или динамический
контент. Запрос статического контента обслуживается путем извлечения файлов с
диска сервера и доставки его клиенту. Запрос динамического контента обслуживается путем выполнения программы в контексте дочернего процесса сервера и возврата
его вывода клиенту. Стандарт CGI формулирует множество правил, регламентирующих
передачу клиентами аргументов серверу, передачу этих аргументов и другой информации дочернему процессу и передачу вывода дочернего процесса обратно клиенту.
Прос­той, но вместе с тем нормально функционирующий процесс, который обслуживает и статический, и динамический контент, можно реализовать несколькими сотнями
строк программного кода на языке C.

Библиографические заметки
Официальный источник информации об интернете содержится во множест­ве бесплатно распространяемых нумерованных документов, известных как запросы на комментарии (Requests for Comments, RFC). Каталог документов RFC с поддержкой поиска
доступен по адресу:
http://rfc-editor.org/rfc.html

Документы RFC обычно написаны для разработчиков инфраструктуры интернета и
часто изобилуют многочисленными деталями, не представляющими интереса для случайного читателя. В то же время не существует более авторитетного источника информации. Протокол НТТР/1.1 задокументирован в RFC 2616. Обязательный для поддержки
список типов MIME доступен по адресу:
http://www.iana.org/assignments/media-types

Книга Керриска (Kerrisk) – это библия по всем аспектам программирования в Linux,
где подробно рассказывается о современном сетевом программировании [62]. Сущест­

11.7. Итоги  897
вует несколько хороших публикаций общего назначения по организации компьютерных сетей [65, 84, 114]. Великий технический писатель У. Ричард Стивенс (W. Richard
Stevens) написал серию классических книг по таким вопросам, как современное программирование в системе Unix [111], прото­колы интернета [109, 120, 107] и программирование сетевых задач в систе­ме Unix [108, 110]. Студенты, серьезно изучающие программирование в Unix-подобных системах, возможно, захотят изучить вопрос в полном
объеме. К нашему величайшему сожалению, Стивенс умер 1 сентября 1999 года. Нам
очень не хватает его книг.

Домашние задания
Упражнение 11.6 
1. Измените сервер TINY так, чтобы он возвращал клиенту каждую строку запроса
и каждый заголовок.
2. Попробуйте из своего браузера запросить статический контент у сервера TINY.
Сохраните вывод сервера TINY в файле.
3. Исследуйте вывод сервера TINY и определите, какую версию протокола HTTP
использует ваш браузер.
4. Изучите стандарт НТТР/1.1 по документу RFC 2616 и определите содержимое
каждого заголовка HTTP-запроса, поступившего от вашего брау­зера. Документ
RFC 2616 можно получить по адресу: www.rfc-editor.org/rfc.html.

Упражнение 11.7 
Расширьте сервер TINY так, чтобы он мог обслуживать видеофайлы MPG. Проверьте
правильность вашего решения, воспользовавшись настоящим браузером.

Упражнение 11.8. 
Измените сервер TINY так, чтобы он утилизировал дочерние в обработчике сигнала
SIGCHLD, а не ждал их завершения.

Упражнение 11.9 
Измените сервер TINY так, чтобы при обслуживании статического контента он копировал затребованный файл в подключенный дескриптор, используя функции malloc,
rio_readn и rio_writen вместо mmap и rio_writen.

Упражнение 11.10 
1. Напишите форму HTML для CGI-функции adder из листинга 11.11. Форма должна включать два текстовых поля ввода, в которые пользователи могут ввести два
числа, чтобы получить их сумму. Форма должна отправлять содержимое полей,
используя метод GET.
2. Проверьте, правильно ли работает ваша программа, воспользовавшись настоящим браузером, чтобы запросить форму у сервера TINY, заполнить ее и отправить обратно серверу TINY, а затем отобразить на экране динамический контент, вычисленный программой adder.

Упражнение 11.11 
Расширьте сервер TINY, добавив в него поддержку метода HTTP HEAD. Проверьте, правильно ли работает ваш сервер, воспользовавшись для этой цели протоколом
TELNET.

898

 Глава 11. Сетевое программирование

Упражнение 11.12 
Расширьте сервер TINY, добавив в него способность обслуживать запрос динамического контента методом HTTP POST. Проверьте работу вашего сервера, воспользовавшись для этой цели веб-браузером.

Упражнение 11.13 
Измените сервер TINY так, чтобы он надежно (без аварийного завершения) обрабатывал сигнал SIGPIPE и ошибку EPIPE, которые возникают, когда функция write пытается выполнить запись в преждевременно закрытое соединение.

Решения упражнений
Решение упражнения 11.1
Шестнадцатеричный адрес
0x0
0xffffffff
0x7f000001
0xcdbca079
0x400c950d
0xcdbc9217

Десятично-точечный адрес
0.0.00
255.255.255.255
127.0.0.1
205.188.160.121
64.12.149.13
205.188.146.23

Решение упражнения 11.2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

code/netp/hex2dd.c

#include "csapp.h"
int main(int argc, char **argv)
{
struct in_addr inaddr; /* Адрес с сетевым порядком байтов */
uint32_t addr;
/* Адрес с аппаратным порядком байтов */
char buf[MAXBUF];
/* Буфер для строки с представлением адреса */
if (argc != 2) {
fprintf(stderr, "usage: %s \n", argv[0]);
exit(0);
}
sscanf(argv[1], "%x", &addr);
inaddr.s_addr = htonl(addr);
if (!inet_ntop(AF_INET, &inaddr, buf, MAXBUF))
unix_error("inet_ntop");
printf("%s\n", buf);
exit(0);
}

code/netp/hex2dd.c

Решение упражнения 11.3
1 #include "csapp.h"
2
3 int main(int argc, char **argv)

code/netp/dd2hex.c

11.7. Итоги  899
4 {
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 }

struct in_addr inaddr; /* Адрес с сетевым порядком байтов */
int rc;
if (argc != 2) {
fprintf(stderr, "usage: %s \n", argv[0]);
exit(0);
}
rc = inet_pton(AF_INET, argv[1], &inaddr);
if (rc == 0)
app_error("inet_pton error: invalid dotted-decimal address");
else if (rc < 0)
unix_error("inet_pton error");
printf("0x%x\n", ntohl(inaddr.s_addr));
exit(0);

code/netp/dd2hex.c

Решение упражнения 11.4
В следующем решении обратите внимание, насколько сложнее использовать функцию inet_ntop, требующую приведения типов и многоуровневых ссылок на вложенные
элементы структуры. Функция getnameinfo намного проще, потому что всю эту работу
она выполняет автоматически.
code/netp/hostinfo-ntop.c
1 #include "csapp.h"
2
3 int main(int argc, char **argv)
4 {
5
struct addrinfo *p, *listp, hints;
6
struct sockaddr_in *sockp;
7
char buf[MAXLINE];
8
int rc;
9
10
if (argc != 2) {
11
fprintf(stderr, "usage: %s \n", argv[0]);
12
exit(0);
13
}
14
15
/* Получить список структур addrinfo */
16
memset(&hints, 0, sizeof(struct addrinfo));
17
hints.ai_family = AF_INET;
/* Только IPv4 */
18
hints.ai_socktype = SOCK_STREAM; /* Только для постоянных соединений */
19
if ((rc = getaddrinfo(argv[1], NULL, &hints, &listp)) != 0) {
20
fprintf(stderr, "getaddrinfo error: %s\n", gai_strerror(rc));
21
exit(1);
22
}
23
24
/* Обход списка и вывод IP-адресов */
25
for (p = listp; p; p = p->ai_next) {
26
sockp = (struct sockaddr_in *)p->ai_addr;
27
Inet_ntop(AF_INET, &(sockp->sin_addr), buf, MAXLINE);
28
printf("%s\n", buf);
29
}
30

900

 Глава 11. Сетевое программирование

31
32
33
34
35 }

/* Освобождение ресурсов */
Freeaddrinfo(listp);
exit(0);

code/netp/hostinfo-ntop.c

Решение упражнения 11.5
Причина, почему стандартные функции ввода/вывода можно без опаски использовать в программах CGI, заключается в том, что программы CGI выполняются в дочерних процессах и им не нужно явно закрывать любой из потоков ввода/вывода. Когда
дочерний процесс прекращает работу, ядро автоматичес­ки закрывает все дескрипторы.

Глава

12
Конкурентное
программирование

12.1. Конкурентное программирование с процессами.
12.2. Конкурентное программирование с мультиплексированием ввода/вывода.
12.3. Конкурентное программирование с потоками выполнения.
12.4. Совместное использование переменных несколькими потоками выполнения.
12.5. Синхронизация потоков выполнения с помощью семафоров.
12.6. Использование потоков выполнения для организации параллельной обработки.
12.7.

Другие вопросы конкурентного выполнения.

12.8. Итоги.


Библиографические заметки.



Домашние задания.



Решения упражнений.

К

ак рассказывалось в главе 8, логические потоки управления называются конкурентными, если они перекрываются во времени. Это явление, называемое конкурентным
выполнением, или конкуренцией, проявляется на многих уровнях любой компьютерной
системы. В число знакомых примеров входят обработчики аппаратных исключений,
процессы и обработчики сигналов Linux.
До сих пор конкуренция рассматривалась нами в основном как механизм, используемый ядром для одновременного выполнения множества прикладных программ.
Однако конкуренция не ограничивается ядром. Она может играть важную роль в самих прикладных программах. Например, мы уже видели, как обработчики сигналов
Linux позволяют приложениям реагировать на асинхронные события, такие как ввод
пользователем Ctrl+C или обращение программы к произвольной области виртуальной
памяти. Конкуренция на уровне приложения также может принести пользу в других
областях:

• доступ к медленным устройствам ввода/вывода. Когда приложение ожидает
поступления данных с медленного устройства ввода/вывода, например с дискового накопителя, то ядро загружает процессор другими задачами. Аналогично отдельные приложения могут использовать конкуренцию чередованием
полезных действий с запросами ввода/вывода;

902

 Глава 12. Конкурентное программирование

• взаимодействие с пользователями. Пользователям необходима возможность од-

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

• сокращение задержек путем откладывания выполнения. Иногда приложения могут

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

• одновременное обслуживание нескольких сетевых клиентов. Итеративные сете-

вые серверы, о которых рассказывалось в главе 12, непрактичны, потому что
в каждый конкретный момент времени они могут обслуживать только одного
клиента. В результате, пока обслуживается один клиент, остальные будут вынуждены ждать своей очереди. Для настоящих серверов, от которых требуется
обслуживать сотни или тысячи клиентов в секунду, такое положение вещей недопустимо. Более практичное решение – реализовать сервер с использованием
механизмов конкуренции, когда для обслуживания каждого клиента создается
отдельный поток управления. Это позволяет серверу одновременно обслуживать мно­жество клиентов и предотвращать узурпацию сервера одним клиентом;

• организация параллельных вычислений на многоядерных процессорах. Многие со-

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

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

• процессы – при использовании этого механизма все потоки управления орга-

низованы как отдельные процессы, которые планируются и поддерживаются
ядром. Поскольку процессы имеют раздельные виртуальные адресные пространства, то для их взаимодействий между собой необходимо использовать
специальные механизмы межпроцессных взаимодействий (Interprocess Communication, IPC);

• мультиплексирование ввода/вывода – разновидность механизмов конкурентно-

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

• потоки выполнения – логические потоки управления, выполняющиеся в контек-

сте одного процесса и планируемые ядром. Потоки можно рассматривать как

12.1. Конкурентное программирование с процессами  903
симбиоз двух вышеописанных механизмов: они планируются ядром, подобно
процессам, и совместно используют общее виртуальное адресное пространство.
В этой главе мы рассмотрим все эти три механизма поддержки конкуренции. А чтобы дискуссия была максимально конкретной, мы будем работать над одним и тем же
приложением – конкурентной версией нашего эхо-сервера, рассматривавшегося в разделе 11.4.9.

12.1. Конкурентное программирование с процессами
Самый простой способ реализации конкурентной программы – создание процессов с
использованием знакомых функций: fork, exec и waitpid. Например, в конкурентном
веб-сервере можно принимать запросы клиентов в родительском процессе и создавать
дочерние процессы для их обработки.
Для большей наглядности предположим, что имеются два клиента и сервер, принимающий запросы через дескриптор слушающего сокета (пусть это будет дескриптор 3).
Предположим также, что сервер принимает запрос от клиента 1 и возвращает дескриптор
подключенного сокета (скажем, 4), как показано на рис. 12.1. После приема запроса сервер
создает дочерний процесс, получающий полную копию таблицы дескрипторов сервера.
Дочерний процесс закрывает свою копию слушающего дескриптора 3, а родительский
процесс – свою копию подключенного дескриптора 4, потому что он ему больше не нужен. Эта ситуация показана на рис. 12.2, где дочерний процесс «занят» обслуживанием
клиента.

Client 11
Клиент

Передача
Data
данных
transfers

Запрос
Connection
на соединение
request

clientfd

Дочерний
Child 1
процесс 1
connfd(4)

Клиент
Client 1

listenfd(3)
Сервер
Server

clientfd

listenfd(3)

Сервер
Server

connfd(4)
Client 22
Клиент

clientfd

Рис. 12.1. Шаг 1: сервер принимает
запрос от клиента

Клиент
2
Client 2
clientfd

Рис. 12.2. Шаг 2: сервер создает
дочерний процесс
для обслуживания клиента

Поскольку дескрипторы подключенного сокета в родительском и дочернем процессах ссылаются на один и тот же элемент в таблице открытых файлов, родительский процесс должен закрыть свою копию дескриптора подключенного сокета. Иначе элемент
в таблице открытых файлов, соответствующий дескриптору подключенного сокета 4,
никогда не будет удален и образующаяся утечка памяти в конечном итоге приведет к
исчерпанию доступной памяти и аварийному завершению сервера.
Теперь предположим, что после того, как родительский процесс создаст потомка для
обслуживания клиента 1, он принимает новый запрос от клиента 2 и создает новый
дескриптор подключенного сокета (например, 5), как показано на рис. 12.3. После этого
родительский процесс создает новый дочерний процесс, который обслуживает своего
клиента 2, используя дескриптор 5 подключенного сокета, как показано на рис. 12.4.
После этого родительский процесс может перейти к ожиданию следующего запроса, а
два созданных дочерних процесса продолжат обслуживание своих клиентов.

904

 Глава 12. Конкурентное программирование
Дочерний
Child 1
процесс 1

Передача
Data
данных
transfers

connfd(4)

Дочерний
Child 1
процесс 1

Передача
Data
данных
transfers

connfd(4)

Client 1
Клиент

listenfd(3)

clientfd

Server
Сервер

Client 1
Клиент

clientfd

listenfd(3)
Server
Сервер

connfd(5)

Client 2
Клиент
2

Connection
Запрос
наrequest
соединение

clientfd

Рис. 12.3. Шаг 3: сервер принимает
запрос от другого клиента

Client
Клиент22

clientfd

Передача
Data
данных
transfers
Дочерний
Child 2
процесс 2
connfd(5)

Рис. 12.4. Шаг 4: сервер создает
еще один дочерний процесс для
обслуживания другого клиента

12.1.1. Конкурентный сервер, основанный на процессах
В листинге 12.1 представлен код конкурентного эхо-сервера, основанного на процессах. Функция echo, используемая сервером в строке 29, была показана в листинге 11.9. Вот
несколько интересных моментов, заслуживающих внимания:

• во-первых, серверы обычно работают довольно продолжительное время, поэтому

они должны иметь обработчик сигнала SIGCHLD, чтобы утилизировать так называемые зомби-процессы (строки 4–9). Поскольку обработчик сигнала SIGCHLD
блокирует работу процесса на время своего выполнения и сигналы Linux не ставятся в очередь, то обработчик SIGCHLD должен быть готов к утилизации нескольких зомби-процессов;

• во-вторых, родительский и дочерний процессы должны закрывать свои копии

connfd (строки 33 и 30 соответственно). Как уже отмечалось, это особенно важно
для родительского процесса, который должен закрыть свою копию дескриптора
подключенного сокета, чтобы избежать утечки памяти;

• наконец, из-за особенностей работы механизма подсчета ссылок на элементы

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

Листинг 12.1. Реализация конкурентного эхо-сервера на основе процессов.
Родитель создает дочерний процесс для обслуживания каждого нового запроса

code/conc/echoserverp.c
1
2
3
4
5
6
7
8
9

#include "csapp.h"
void echo(int connfd);
void sigchld_handler(int sig)
{
while (waitpid(-1, 0, WNOHANG) > 0)
;
return;
}

12.1. Конкурентное программирование с процессами  905
10
11 int main(int argc, char **argv)
12 {
13
int listenfd, connfd;
14
socklen_t clientlen;
15
struct sockaddr_storage clientaddr;
16
17
if (argc != 2) {
18
fprintf(stderr, "usage: %s \n", argv[0]);
19
exit(0);
20
}
21
22
Signal(SIGCHLD, sigchld_handler);
23
listenfd = Open_listenfd(argv[1]);
24
while (1) {
25
clientlen = sizeof(struct sockaddr_storage);
26
connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
27
if (Fork() == 0) {
28
Close(listenfd); /* Потомок закрывает слушающий сокет */
29
echo(connfd);
/* Потомок обслуживает клиента */
30
Close(connfd);
/* Потомок закрывает подключенный сокет */
31
exit(0);
/* Потомок завершает работу */
32
}
33
Close(connfd); /* Родитель закрывает подключенный сокет (важно!) */
34
}
35 }

code/conc/echoserverp.c
Unix IPC
Вы уже видели несколько примеров межпроцессных взаимодействий (IPC) в этой книге.
Функция waitpid и сигналы, представленные в главе 8, – элементарные механизмы IPC,
позволяющие обмениваться короткими сообщениями процессам, выполняющимся на
одном хосте. Интерфейс сокетов, описанный в главе 11, – это еще одна важная форма
IPC, позволяющая процессам на разных хостах обмениваться произвольными потоками
байтов. Однако под термином Unix IPC подразумевается великое множество механизмов, позволяющих одним процессам взаимодействовать с другими, выполняющимися
на одном и том же хосте. Примерами таких механизмов могут служить каналы, очереди,
разделяемая память System V и семафоры System V, однако мы не будем обсуждать их,
так как это выходит за рамки нашей книги. Все подробности можно найти в книге Керриска (Kerrisk) [62].

12.1.2. Достоинства и недостатки подхода
на основе процессов
Процессы реализуют ясную модель совместного использования информации о состоя­
нии между родителем и потомком: таблицы файлов используются совместно, а пространства адресов – нет. Изолированность адресных пространств процессов является
как преимуществом, так и недостатком. Никакой процесс не сможет случайно повредить
данные, принадлежащие другому процессу, что избавляет от массы досадных ошибок.
Это – очевидное преимущество.
С другой стороны, изолированные адресные пространства затрудняют совместное использование процессами информации о состоянии. Для совместного использования ин-

906

 Глава 12. Конкурентное программирование

формации они должны прибегать к механизмам межпроцессных взаимодействий. Другой недостаток подхода на основе процессов – меньшая скорость обслуживания клиентов
из-за высоких непроизводительных издержек на создание новых процессов и межпроцессные взаимодействия.
Упражнение 12.1 (решение в конце главы)
После того как родительский процесс закрывает дескриптор подключенного сокета в строке 33, дочерний процесс по-прежнему может взаимодействовать с клиентом, используя
свою копию дескриптора. Почему?

Упражнение 12.2 (решение в конце главы)
Если удалить строку 30 в листинге 12.1, где дочерний процесс закрывает дескриптор подключенного сокета, код по-прежнему оставался бы корректным в том смысле, что это не
привело бы к утечке памяти. Почему?

12.2. Конкурентное программирование
с мультиплексированием ввода/вывода
Предположим, что перед нами поставлена задача написать эхо-сервер, обрабатывающий команды пользователя в интерактивном режиме. В этом случае сервер должен
откликаться на два независимых события ввода/вывода: (1) клиент посылает запрос на
соединение и (2) пользователь вводит команду с клавиатуры. Какого события следует
ожидать первым? Ни один вариант не является идеальным. При ожидании запроса на
соединение в accept сервер не сможет реагировать на команды. Точно так же, ожидая
ввода команды в read, сервер не сможет принимать новые запросы на соединение.
Одно из решений этой дилеммы – использовать так называемое мультиплексирование ввода/вывода. Основная идея состоит в том, чтобы вызвать функцию select, которая
вернет управление приложению, только после появления одного или нескольких событий ввода/вывода, перечисленных ниже:

• когда любой дескриптор из множества {0, 4} будет готов к чтению;
• когда любой дескриптор из множества {1, 2, 7} будет готов к записи;
• тайм-аут, если истекли 152,13 секунды в ожидании события ввода/вывода.
Далее мы рассмотрим только первый сценарий: ожидание, кода один или несколько
дескрипторов из множества будут готовы к чтению. Более подробное обсуждение этой
темы вы найдете в [62, 110].
Функция select управляет множествами типа fd_set, известными как наборы де­
скрипторов. Логически набор дескрипторов рассматривается как битовая маска размера n:
bn−1, ..., b1, b0
Каждый бит bk соответствует дескриптору k. Дескриптор k является членом набора
дескрипторов, если bk = 1. Для набора дескрипторов поддерживаются следующие операции: (1) размещение; (2) присваивание значения одной пере­менной этого типа другой; (3) изменение и проверка битов с помощью макросов FD_ZERO, FD_SET, FD_CLR и
FD_ISSET.

12.2. Конкурентное программирование с мультиплексированием ввода/вывода  907
#include
int select(int n, fd_set *fdset, NULL, NULL, NULL);

Возвращает ненулевое количество дескрипторов, готовых
к чтению, −1 в случае ошибки
FD_ZERO(fd_set *fdset);
FD_CLR(int fd, fd_set *fdset);
FD_SET(int fd, fd_set *fdset);
FD_ISSET(int fd, fd_set *fdset);

/* Сбросить все биты в fdset */
/* Сбросить бит для fd в fdset */
/* Установить бит для fd в fdset */
/* Бит для fd в fdset установлен? */

Макроопределения для управления наборами дескрипторов
Для наших целей в этой главе интерес представляют только два аргумента функции

select: набор дескрипторов fdset (набор чтения) и количество элементов n в наборе
чтения. Функция select блокирует процесс, пока хотя бы один дескриптор из набора

не будет готов к чтению. Дескриптор k считается готовым к чтению, если следующая
операция чтения из этого дескриптора не заблокирует процесс. Как побочный эффект
функция select модифицирует набор fd_set, на который ссылается аргумент fdset,
возвращая в нем подмножество из набора чтения, называемое набором готовых де­
скрипторов. Возвращаемое функцией значение указывает количество элементов в наборе готовых дескрипторов. Обратите внимание, что набор чтения необходимо обновлять при каждом вызове select.
Лучше всего изучать особенности работы select на конкретных примерах. В листинге 12.2 показано, как можно использовать select для реализации итеративного эхо-сервера, принимающего команды пользователя через стандартный ввод.
Листинг 12.2. Итеративный эхо-сервер, использующий прием мультиплексирования
ввода/вывода. Сервер вызывает функцию select и ждет запроса на соединение на дескрипторе слушающего сокета и появления команды на дескрипторе стандартного ввода

code/conc/select.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

#include "csapp.h"
void echo(int connfd);
void command(void);
int main(int argc, char **argv)
{
int listenfd, connfd;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
fd_set read_set, ready_set;
if (argc != 2) {
fprintf(stderr, "usage: %s \n", argv[0]);
exit(0);
}
listenfd = Open_listenfd(argv[1]);
FD_ZERO(&read_set);
/* очистить набор чтения */
FD_SET(STDIN_FILENO, &read_set); /* Добавить stdin в набор чтения */
FD_SET(listenfd, &read_set);
/* Добавить listenfd в набор чтения */
while (1) {

908

 Глава 12. Конкурентное программирование

23
ready_set = read_set;
24
Select(listenfd+1, &ready_set, NULL, NULL, NULL);
25
if (FD_ISSET(STDIN_FILENO, &ready_set))
26
command(); /* Обработать команду со стандартного ввода */
27
if (FD_ISSET(listenfd, &ready_set)) {
28
clientlen = sizeof(struct sockaddr_storage);
29
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
30
echo(connfd); /* Вернуть введенный текст клиенту до EOF */
31
Close(connfd);
32
}
33
}
34 }
35
36 void command(void) {
37
char buf[MAXLINE];
38
if (!Fgets(buf, MAXLINE, stdin))
39
exit(0); /* EOF */
40
printf("%s", buf); /* Обработать введенную команду */
41 }

code/conc/select.c
Начнем с вызова функции open_listenfd (листинг 11.6), чтобы открыть слушающий
сокет (строка 16), и затем используем FD_ZERO для создания пустого набора чтения
(строка 18):
listenfd
3
read_set (0):
0

2
0

stdin
0
0

1
0

Далее в строках 19–20 определяется набор чтения, включающий дескрипторы 0
(стандартный ввод) и 3 (слушающий сокет):
listenfd
3
read_set ({0, 3}):
1

2
0

1
0

stdin
0
1

В этой точке начинается рабочий цикл сервера. Однако вместо ожидания запроса на
соединение вызовом функции accept вызывается функция select, которая блокирует
процесс, пока слушающий дескриптор или дескриптор стандартного ввода не будет готов к чтению (строка 24). Например, ниже приводится значение ready_set, которое будет возвращено функцией select, если пользователь нажмет клавишу Enter, вследствие
чего дескриптор стандартного ввода станет готовым к чтению:
listenfd
3
read_set ({0}):
0

2
0

1
0

stdin
0
1

После возврата из select процесс может определить, какой из дескрипторов готов
к считыванию, воспользовавшись макросом FD_ISSET. Если готов стандартный ввод
(строка 25), то вызывается функция command, читающая и обрабатывающая команду.
Если готов дескриптор слушающего сокета (строка 27), то вызывается функция accept
для получения подключенного дескриптора, после чего вызывается функция echo (лис­
тинг 11.9), возвращающая введенный текст обратно клиенту, пока клиент не закроет
соединение со своей стороны.

12.2. Конкурентное программирование с мультиплексированием ввода/вывода  909
Несмотря на то что эта программа может служить отличным примером использования функции select, она не лишена недостатков. Проблема заключается в том, что
после соединения с клиентом она продолжает возвращать ему введенный текст, пока
клиент не закроет соединение со своей стороны. То есть если кто-то другой введет команду, сервер не сможет обработать ее, пока не завершит обслуживание клиента. Более
удачное решение – производить эхо-вывод построчно, возвращаясь в главный цикл
сервера.
Упражнение 12.3 (решение в конце главы)
В системах Linux ввод комбинации Ctrl+D служит признаком конца файла (EOF). Что случится, если ввести Ctrl+D в программе в листинге 12.3, пока она заблокирована в вызовеselect?

12.2.1. Конкурентный на основе мультиплексирования
ввода/вывода, управляемый событиями
Мультиплексирование ввода/вывода можно использовать как основу для создания
конкурентных программ, управляемых событиями, потоки управления в которых продвигаются вперед от события к событию. Основная идея заключается в моделировании
потоков управления в форме конечных автоматов. Говоря неформально, конечным
автоматом называется набор состояний, событий ввода и переходов, отображающих
состоя­ния и коллекцию событий ввода в состояния. Каждый переход отображает пару
(состояние ввода, событие ввода) в состояние вывода. Петлей в графе состояний называют переход из состояния в то же состояние. Конечные автоматы обычно изображаются в виде ориентированных графов, где узлы обозначают состояния, ребра – переходы,
а метки ребер – события ввода. Конечный автомат начинает выполнение в некоем начальном состоянии. Каждое событие ввода запускает переход из текущего состояния в
следующее.
Для каждого нового клиента k параллельный сервер, основанный на мультиплексировании ввода/вывода, создает новый конечный автомат sk и ассоциирует его с де­
скриптором соединения dk. Как показано на рис. 12.5, каждый конечный автомат sk
имеет одно состояние («ожидание готовности дескриптора dk к чтению»), одно событие
ввода («дескриптор dk готов к чтению») и один переход («чтение текстовой строки из
дескриптора dk»).

Input event:
Событие
ввода:
“descriptor
«дескриптор ddkk
готов кfor
чтению»
is ready
reading”

Transition:
Переход:
“read a«читать
text line
from
строку
из дескриптора
descriptor
dk” dk»

State:
Состояние:
“waiting
for descriptor
«ждать
готовности dk to
be
ready for
дескриптора
dkreading”
к чтению»

Рис. 12.5. Конечный автомат,
соответствующий потоку управления
в конкурентном эхо-сервере,
управляемом событиями

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

910

 Глава 12. Конкурентное программирование

В листинге 12.3 показан код конкурентного сервера на основе мультиплексирования
ввода/вывода, управляемого событиями. Набор активных клиентов хранится в структуре pool (строки 3–11). После инициализации пула pool вызовом init_pool (строка 27)
сервер входит в бесконечный цикл. В каждой итерации сервер вызывает функцию select
для выявления событий ввода двух типов: запроса на соединение от нового клиента и
готовности к чтению одного из дескрипторов соединения, соответствующего тому или
иному клиенту. При поступлении запроса на соединение (строка 35) сервер открывает
соединение (строка 37) и вызывает функцию add_client, чтобы добавить клиента в пул
(строка 38). Наконец, когда какой-то дескриптор становится готовым для чтения, сервер вызывает функцию check_client и возвращает соответствующему клиенту отправленную им строку (строка 42).
Листинг 12.3. Конкурентный на основе мультиплексирования ввода/вывода, управляемый
событиями. В каждой итерации сервер возвращает клиенту отправленную им строку,
если обнаруживается дескриптор, готовый к чтению

code/conc/echoservers.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

#include "csapp.h"
typedef struct { /* Пул для хранения дескрипторов установленных соединений */
int maxfd;
/* Наибольший дескриптор в read_set */
fd_set read_set; /* Набор всех активных дескрипторов */
fd_set ready_set; /* Подмножество дескрипторов, готовых к чтению */
int nready;
/* Число дескрипторов, готовых к чтению */
int maxi;
/* Максимальный индекс в массиве клиентов */
int clientfd[FD_SETSIZE];
/* Набор активных дескрипторов соединений */
rio_t clientrio[FD_SETSIZE]; /* Набор активных буферов для чтения */
} pool;
int byte_cnt = 0; /* Счетчик байтов, полученных сервером */
int main(int argc, char **argv)
{
int listenfd, connfd;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
static pool pool;
if (argc != 2) {
fprintf(stderr, "usage: %s \n", argv[0]);
exit(0);
}
listenfd = Open_listenfd(argv[1]);
init_pool(listenfd, &pool);
while (1) {
/* Ждать готовности некоторого дескриптора */
pool.ready_set = pool.read_set;
pool.nready = Select(pool.maxfd+1, &pool.ready_set, NULL, NULL, NULL);
/* Если готов дескриптор слушающего сокета, добавить клиента в пул */
if (FD_ISSET(listenfd, &pool.ready_set)) {
clientlen = sizeof(struct sockaddr_storage);
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
add_client(connfd, &pool);
}

12.2. Конкурентное программирование с мультиплексированием ввода/вывода  911
41
42
43
44 }

/* Вернуть текст, полученный через дескриптор сокета соединения */
check_clients(&pool);
}

code/conc/echoservers.c
Функция init_pool (листинг 12.4) инициализирует пул клиентов. Массив clientfd –
это набор дескрипторов сокетов открытых соединений с клиентами, в котором значение
–1 означает пустой слот. Первоначально набор дескрипторов сокетов открытых соединений не содержит действительных дескрипторов, а слушающий дескриптор является
единственным в наборе чтения, который передается в вызов select (строки 10–12).
Листинг 12.4. init_pool инициализирует пул активных клиентов

code/conc/echoservers.c
1 void init_pool(int listenfd, pool *p)
2 {
3
/* Первоначально нет ни одного соединения */
4
int i;
5
p->maxi = -1;
6
for (i=0; i< FD_SETSIZE; i++)
7
p->clientfd[i] = -1;
8
9
/* Первоначально listenfd –- единственный дескриптор в наборе чтения */
10
p->maxfd = listenfd;
11
FD_ZERO(&p->read_set);
12
FD_SET(listenfd, &p->read_set);
13 }

code/conc/echoservers.c
Функция add_client (листинг 12.5) добавляет в пул нового клиента. Отыскав пустой
слот в массиве clientfd, сервер добавляет в него дескриптор сокета открытого соединения и инициализирует соответствующий буфер чтения RIO так, что дескриптор можно
передать в вызов rio_readlineb (строки 8–9). Затем дескриптор соединения добавляется
в набор чтения для функции select (строка 12) и обновляются некоторые глобальные
свойства пула. В переменной maxfd (строки 15–16) сохраняется наибольший дескриптор
(для select), и в переменной maxi (строки 17–18) сохраняется наибольший индекс в массиве clientfd, чтобы функции check_clients не приходилось просматривать весь массив.
Листинг 12.5. add_client добавляет нового клиента в пул соединений

code/conc/echoservers.c
1 void add_client(int connfd, pool *p)
2 {
3
int i;
4
p->nready--;
5
for (i = 0; i < FD_SETSIZE; i++) /* Найти пустой слот */
6
if (p->clientfd[i] < 0) {
7
/* Добавить дескриптор соединения в пул */
8
p->clientfd[i] = connfd;
9
Rio_readinitb(&p->clientrio[i], connfd);
10
11
/* Добавить дескриптор в набор чтения */
12
FD_SET(connfd, &p->read_set);
13

912

 Глава 12. Конкурентное программирование

14
15
16
17
18
19
20
21
22
23 }

/* Обновить maxfd и maxi */
if (connfd > p->maxfd)
p->maxfd = connfd;
if (i > p->maxi)
p->maxi = i;
break;
}
if (i == FD_SETSIZE) /* Пустые слоты закончились */
app_error("add_client error: Too many clients");

code/conc/echoservers.c
Функция check_clients (листинг 12.6) читает из дескриптора, готового к чтению, строку,
отправленную клиентом. Если чтение строки увенчалось успехом, то она возвращается назад клиенту (строки 15–18). Обратите внимание, что в строке 15 наращивается накопительный счетчик байтов, полученных сервером от всех клиентов. Если обнаруживается признак
конца файла (EOF) из-за того, что клиент закрыл свой конец соединения, то сервер закрывает соединение со своей стороны (строка 23) и удаляет дескриптор из пула (строки 24–25).
Листинг 12.6. check_clients обслуживает подключенных клиентов

code/conc/echoservers.c
1 void check_clients(pool *p)
2 {
3
int i, connfd, n;
4
char buf[MAXLINE];
5
rio_t rio;
6
7
for (i = 0; (i maxi) && (p->nready > 0); i++) {
8
connfd = p->clientfd[i];
9
rio = p->clientrio[i];
10
11
/* Если дескриптор готов для чтения, вернуть клиенту его строку */
12
if ((connfd > 0) && (FD_ISSET(connfd, &p->ready_set))) {
13
p->nready--;
14
if ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
15
byte_cnt += n;
16
printf("Server received %d (%d total) bytes on fd %d\n",
17
n, byte_cnt, connfd);
18
Rio_writen(connfd, buf, n);
19
}
20
21
/* Обнаружен конец файла, удалить дескриптор из пула */
22
else {
23
Close(connfd);
24
FD_CLR(connfd, &p->read_set);
25
p->clientfd[i] = -1;
26
}
27
}
28
}
29 }

code/conc/echoservers.c
В контексте модели конечного автомата функция select выявляет события ввода, а
функция add_client создает новый конечный автомат. Функция check_client выполняет
переходы, отправляя клиентам их строки, а также удаляет данный конечный автомат,
когда клиент разрывает соединение.

12.2. Конкурентное программирование с мультиплексированием ввода/вывода  913
Упражнение 12.4 (решение в конце главы)
В программе сервера, показанной в листинге 12.3, мы аккуратно инициализируем переменную pool.ready_set непосредственно перед каждым вызовом select. Почему?

12.2.2. Достоинства и недостатки мультиплексирования
ввода/вывода
Сервер в листинге 12.3 представляет прекрасный пример преимуществ и недостатков событийно-ориентированного программирования, основанного на мультиплексировании ввода/вывода. Одно из преимуществ этого подхода к программированию –
возможность для программистов точнее управлять поведением своих программ, чем
при использовании моделей процессов. Например, в событийно-ориентированном
конкурентном сервере проще обеспечить предпочтительное обслуживание определенных клиентов, чем в конкурентном сервере на основе процессов.
Другое преимущество – событийно-ориентированный сервер с мультиплексированием ввода/вывода, работает в контексте одного процесса, а это значит, что каждый
логический поток управления имеет доступ ко всему адресному пространству процесса. Это упрощает совместное использование данных потоками. Дополнительное
преимущество выполнения в единственном процессе – конкурентный сервер поддается отладке, как любая последовательная программа, с использованием уже знакомых
инст­рументов, таких как GDB. И наконец, событийно-ориентированные проекты часто
значительно более эффективны и производительны, чем проекты, основанные на процессах, потому что не требуют переключения контекста процесса для планирования
нового потока.
Значительным недостатком событийно-ориентированных программ является сложность программирования. Например, для реализации конкурентного событийно-ориентированного эхо-сервера потребовалось написать в три раза больше кода, чем для
того же сервера на основе процессов, и, к сожалению, эта сложность растет по мере
уменьшения степени распараллеливания. Под распараллеливанием здесь подразумевается количество команд, выполняемое каждым логическим потоком управления в
заданный период времени. Например, в рассматриваемом конкурентном сервере степень распараллеливания определяется количеством команд, необходимых для чтения
текстовой строки. Пока один логический поток управления читает текстовую строку,
все остальные стоят на месте в ожидании. Для нашего примера в таком положении
вещей нет ничего страшного, однако это делает событийно-ориентированный сервер
уязвимым для клиентов-злоумышленников, которые могут отправить только часть
текстовой строки, после чего останавливают процесс передачи. Адаптация событийноориен­тированного сервера для подобного рода ситуаций – задача нетривиальная, тогда
как в сервере на основе процессов она решается автоматически.
Веб-серверы, управляемые событиями
Несмотря на недостатки, описанные в разделе 12.2.2, современные высокопроизводительные серверы, такие как Node.js, nginx и Tornado, используют подход мультиплексирования ввода/вывода с управлением событиями, потому что этот прием дает значительный выигрыш в производительности, по сравнению с приемами на основе процессов и
потоков.

914

 Глава 12. Конкурентное программирование

12.3. Конкурентное программирование
с потоками выполнения
Итак, мы рассмотрели два подхода к созданию конкурентных приложений: на основе
процессов и с мультиплексированием ввода/вывода. Ядро автоматически планирует
каждый процесс. Каждый процесс имеет свое изолированное адресное пространство,
что затрудняет совместное использование данных. Во втором подходе логические потоки управления планируются с использованием мультиплексирования ввода/вывода.
Так как все эти логические потоки действуют в рамках одного процесса, они все вместе
используют одно адресное пространство. В этом разделе мы рассмотрим третий подход, основанный на потоках выполнения, объединяющий все преимущества двух первых подходов.
Поток выполнения (thread) – это логический поток управления, выполняющийся в
контексте процесса. До сих пор программы, описывавшиеся в книге, содержали один
поток на процесс. Однако современные системы позволяют писать многопоточные
программы, выполняющиеся конкурентно в одном процессе. Каждый поток имеет свой
контекст, включающий уникальный целочисленный идентификатор потока (Thread
ID, TID), стек, указатель стека, счетчик команд, регистры общего назначения и флаги
состояния. Все выполняющиеся в процессе потоки совместно используют полное виртуальное адресное пространство этого процесса.
Решения на основе потоков выполнения сочетают в себе лучшие качества решений
на основе процессов и мультиплексирования ввода/вывода. Потоки выполнения, как и
процессы, автоматически планируются ядром и распознаются им по целочисленным
идентификаторам. Подобно решениям на основе мультиплексирования ввода/вывода,
потоки выполнения действуют в контексте одного процесса и совместно используют
все виртуальное адресное пространство процесса, включая код, данные, библиотеки и
открытые файлы.

12.3.1. Модель выполнения многопоточных программ
Модель выполнения многопоточных программ сходна с моделью выполнения программ, запускающих несколько процессов. Рассмотрим пример на рис. 12.6. Каждый
процесс начинает свое существование в виде единственного потока выполнения, который принято называть основным, или главным, потомком. В определенной точке
главный поток создает дочерний поток, и с этого момента эти два потока выполняются
конкурентно. В какой-то момент управление передается этому дочернему потоку в результате переключения контекста, или потому что главный поток обратился к медленному системному вызову, такому как read или sleep, или потому что он был прерван системным таймером. Одноранговый поток выполняется в течение некоторого времени,
после чего управление опять передается главному потоку.
Порядок выполнения потоков имеет некоторые важные отличия от выполнения процессов. Так как контекст потока намного меньше контекста процесса, переключение
контекста между потоками происходит быстрее, чем переключение контекста между
процессами. Другое важное отличие: отношения «родитель/потомок» между потоками носят чисто номинальный характер. Потоки, выполняющиеся в рамках одного
процесса, образуют пул равноправных потоков выполнения. Главный поток отличается
от других потоков только тем, что в процессе он всегда запускается первым. Основное
следст­вие такой организации потоков в виде пула равноправных потоков заключается в том, что любой поток может завершить выполнение или дождаться завершения
любого другого потока. Более того, все потоки могут читать и изменять одни и те же
совместно используемые данные.

12.3. Конкурентное программирование с потоками выполнения

 915

Time
Время
Thread
Thread
Поток 1 1
Поток 2 2
(main thread)
(peer thread)
(главный
поток) (дочерний
поток)
Переключение
Thread context switch
контекста потоков
Переключение
Thread context switch
контекста потоков
Переключение
Thread context switch
контекста потоков

Рис. 12.6. Конкурентное выполнение потоков

12.3.2. Потоки Posix
Потоки Posix (Posix threads, Pthreads) – стандартный интерфейс для управления потоками выполнения из программ на C. Он был принят на вооружение в 1995 г. и реализован в большинстве систем Unix. Интерфейс Pthreads определяет порядка 60 функций,
позволяющих программам создавать, останавливать и утилизировать потоки выполнения, безопасно использовать данные совместно с другими потоками в том же процессе
и уведомлять друг друга об изменениях в состоянии системы.
В листинге 12.7 показана простая программа, использующая потоки Posix. Главный
поток создает дочерний поток и ожидает его завершения. Дочерний поток выводит
строку «Hello, world!\n» и завершается. Когда главный поток обнаруживает окончание
выполнения дочернего потока, он завершает процесс вызовом exit. Это первая наша
многопоточная программа, поэтому разберем ее подробнее. Код и локальные данные
для потока инкапсулируются в процедуру потока. Как показывает прототип в строке 2,
процедура потока принимает один нетипизированный указатель и возвращает нетипизированный указатель. Если понадобится передать в процедуру потока несколько
аргументов, их следует поместить в структуру и передать указатель на эту структуру.
Анало­гично, если понадобится вернуть из процедуры потока несколько значений, то их
следует поместить в структуру и вернуть указатель на нее.
Листинг 12.7. hello.c: программа «Hello, world!» на основе интерфейса Pthreads

code/conc/hello.c
1
2
3
4
5
6
7
8
9
10
11
12
13

#include "csapp.h"
void *thread(void *vargp);
int main()
{
pthread_t tid;
Pthread_create(&tid, NULL, thread, NULL);
Pthread_join(tid, NULL);
exit(0);
}
void *thread(void *vargp) /* Процедура потока */
{

916

 Глава 12. Конкурентное программирование

14
15
16 }

printf("Hello, world!\n");
return NULL;

code/conc/hello.c
Строка 4 отмечает начало кода главного потока. Главный поток объявляет одну локальную переменную tid, которая будет использоваться для хранения идентификатора
дочернего потока (строка 6). Главный поток создает новый дочерний поток вызовом
функции pthread_create (строка 7). При возврате из pthread_create главный поток и
вновь созданный дочерний поток выполняются конкурентно, a в tid сохраняется идентификатор нового потока. Главный поток переходит в ожидание завершения дочернего
потока вызовом pthread_join в строке 8. Наконец, главный поток вызывает exit (строка 9) и завершает выполнение процесса (и всех потоков, которые могли иметься в процессе, в данном случае только главного).
В строках 12–16 определяется процедура потока, которая выполняется дочерними
потоками. В этом примере она просто выводит строку, после чего выполнение потока
завершается выполнением оператора return в строке 15.

12.3.3. Создание потоков
Потоки могут создавать другие потоки вызовом функции pthread_create:
#include
typedef void *(func)(void *);
int pthread_create(pthread_t *tid, pthread_attr_t *attr,
func *f, void *arg);

Возвращает 0 в случае успеха, ненулевое значение в случае ошибки
Функция pthread_create создает новый поток и запускает процедуру потока в контексте нового потока с аргументом arg. Аргумент attr можно использовать для изменения
атрибутов по умолчанию вновь созданного потока, однако мы не будем обсуждать этот
аспект и в наших примерах всегда будем передавать в этом аргументе значение NULL.
После возврата из pthread_create аргумент tid содержит идентификатор вновь созданного потока. Новый поток может узнать свой идентификатор вызовом функции
pthread_self.
#include
pthread_t pthread_self(void);

Возвращает идентификатор вызвавшего потока

12.3.4. Завершение потоков
Поток может завершить выполнение одним из следующих способов:

• неявно, возвратом из процедуры потока;
• явно, вызовом функции pthread_exit;
#include
void pthread_exit(void *thread_return);

Ничего не возвращает

12.3. Конкурентное программирование с потоками выполнения

 917

• некоторый поток может вызвать функцию exit, которая завершит процесс и все
его потоки;

• другой поток может завершить текущий вызовом функции
идентификатором текущего потока.

pthread_cancel с

#include
int pthread_cancel(pthread_t tid);

Возвращает 0 в случае успеха, ненулевое значение
в случае ошибки

12.3.5. Утилизация завершившихся потоков
Поток может дождаться завершения другого потока вызовом функции pthread_join.
#include
int pthread_join(pthread_t tid, void **thread_return);

Возвращает 0 в случае успеха, ненулевое значение
в случае ошибки
Функция pthread_join блокирует выполнение вызывающего потока до остановки
потока с идентификатором tid, по адресу void **thread_retum помещает указатель на
возвращаемое процедурой потока значение, после чего утилизирует (освобождает) ресурсы памяти, удерживаемые прерванным потоком.
Обратите внимание, что, в отличие от функции wait, функция pthread_join ожидает
прекращения выполнения только конкретного потока. С помощью pthread_join нельзя
дождаться завершения произвольного потока. Это может усложнить код из-за необходимости использовать другие, менее понятные механизмы для обнаружения завершения
произвольного потока. Стивенс (Stevens) вполне убедительно заявляет о том, что такое
поведение является следствием ошибки в спецификации [110].

12.3.6. Обособление потоков
В любой момент времени поток может быть присоединяемым (joinable) или обособ­
ленным (detached). Присоединяемый поток может быть остановлен и утилизирован
другими потоками. Его ресурсы памяти (например, стек) не освобождаются, пока он
не будет утилизирован другим потоком. Напротив, обособленный поток не может быть
остановлен и утилизирован другими потоками. При прекращении выполнения его ресурсы освобождаются системой автоматически.
По умолчанию потоки создаются присоединяемыми. Во избежание утечек памяти
каждый присоединяемый поток должен быть утилизирован явно другим потоком или
обособлен вызовом функции pthread_detach.
#include
int pthread_detach(pthread_t tid);

Возвращает 0 в случае успеха, ненулевое значение
в случае ошибки
Функция pthread_detach обособляет поток tid. Потоки могут обособляться самостоятельно вызовом функции pthread_detach с аргументом pthread_self().

918

 Глава 12. Конкурентное программирование

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

12.3.7. Инициализация потоков
Функция pthread_once позволяет инициализировать состояние, связанное с процедурой потока:
#include
pthread_once_t once_control = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *once_control,
void (*init_routine)(void));

Всегда возвращает 0
Переменная once_control – это глобальная или статическая переменная, которая
всегда инициализируется значением pthread_once_unit. При первом вызове pthread_
once с аргументом once_control она вызывает init_routine – функцию без входных аргументов, которая ничего не возвращает. Последующие обращения к pthread_once с аргументом ptnread_once ни к чему не приводят. Функция pthread_once используется для
динамической инициализации глобальных переменных, совместно используемых несколькими потоками. Один из примеров будет показан в разделе 12.5.5.

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

code/conc/echoservers.c
1
2
3
4
5
6
7
8
9
10
11
12

#include "csapp.h"
void echo(int connfd);
void *thread(void *vargp);
int main(int argc, char **argv)
{
int listenfd, *connfdp;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
pthread_t tid;

12.3. Конкурентное программирование с потоками выполнения
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

 919

if (argc != 2) {
fprintf(stderr, "usage: %s \n", argv[0]);
exit(0);
}
listenfd = Open_listenfd(argv[1]);
while (1) {
clientlen=sizeof(struct sockaddr_storage);
connfdp = Malloc(sizeof(int));
*connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen);
Pthread_create(&tid, NULL, thread, connfdp);
}
}
/* Процедура потока */
void *thread(void *vargp)
{
int connfd = *((int *)vargp);
Pthread_detach(pthread_self());
Free(vargp);
echo(connfd);
Close(connfd);
return NULL;
}

code/conc/echoservers.c
Первый момент связан с передачей дескриптора соединения в вызов pthread_create.
Очевидное решение – передать указатель на дескриптор, как показано ниже:
connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
Pthread_create(&tid, NULL, thread, &connfd);

Затем дочерний поток мог бы разыменовать указатель и сохранить полученное значение в локальной переменной:
void *thread(void *vargp) {
int connfd = *((int *)vargp);
.
.
.
}

Однако это ошибочное решение, потому что возникает состояние гонки между оператором присваивания в дочернем и вызовом accept в главном потоке. Если оператор
присваивания завершится до следующего вызова accept, то локальная переменная
connfd в дочернем потоке получит корректное значение дескриптора. Но если присваивание завершится после accept, то локальная переменная connfd в дочернем потоке
получит номер дескриптора следующего соединения. В результате может сложиться ситуация, когда два потока будут обслуживать один и тот же дескриптор. Во избежание
потенциальной «гонки на выживание» необходимо сохранить дескриптор соединения,
возвращаемый функцией accept, в выделенном блоке динамической памяти, как показано в строках 21–22. Мы еще вернемся к вопросу состояния гонки в разделе 12.7.4.
Другой тонкий момент – возможность утечек памяти в процедуре потока. Поскольку
потоки не утилизируются явно, каждый из них нужно обособлять, чтобы его ресурсы
памяти утилизировались по завершении (строка 31). Более того, необходимо своевременно освобождать блоки памяти, выделенные главным потоком (строка 32).

920

 Глава 12. Конкурентное программирование

Упражнение 12.5 (решение в конце главы)
В сервере, основанном на процессах (листинг 12.1), дескриптор установленного соединения
мы закрывали в обоих процессах, родительском и дочернем. Однако в многопоточном сервере (листинг 12.8) дескриптор соединения закрывается только в дочернем потоке. Почему?

12.4. Совместное использование переменных
несколькими потоками выполнения
С точки зрения программиста, одним из привлекательных аспектов многопоточности
является простота, с какой потоки могут совместно использовать одни и те же переменные. Однако подобное совместное использование может быть коварным. Чтобы напи­
сать корректную программу, необходимо четко понимать, что подразумевается под
совместным использованием и как это совместное использование работает.
Для понимания особенностей совместного использования переменных в программе
рассмотрим несколько основополагающих вопросов: (1) базовая модель памяти потоков, (2) особенности хранения экземпляров переменных в памяти и (3) сколько потоков обращается к каждому из этих экземпляров. Переменная используется совместно
(является общей), если только потоки обращаются к определенному экземпляру этой
переменной.
Для большей конкретики рассмотрим пример программы в листинге 12.9. Она выглядит несколько запутанной, но достаточно наглядно иллюстрирует некоторые тонкости понятия совместного использования переменных. Программа-пример состоит из главного
потока, создающего два дочерних потока. Главный поток передает каждому дочернему потоку уникальный идентификатор, используемый для вывода в сообщении вместе со счетчиком вызовов процедуры потока.
Листинг 12.9. Пример программы, иллюстрирующей некоторые аспекты совместного
использования

code/conc/sharing.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

#include "csapp.h"
#define N 2
void *thread(void *vargp);
char **ptr; /* Глобальная переменная */
int main()
{
int i;
pthread_t tid;
char *msgs[N] = {
"Hello from foo",
"Hello from bar"
};
ptr = msgs;
for (i = 0; i < N; i++)
Pthread_create(&tid, NULL, thread, (void *)i);
Pthread_exit(NULL);
}

12.4. Совместное использование переменных несколькими потоками выполнения  921
22 void *thread(void *vargp)
23 {
24
int myid = (int)vargp;
25
static int cnt = 0;
26
printf("[%d]: %s (cnt=%d)\n", myid, ptr[myid], ++cnt);
27
return NULL;
28 }

code/conc/sharing.c

12.4.1. Модель памяти потоков
Пул потоков выполняется в контексте процесса. Каждый поток имеет свой контекст
потока, включающий идентификатор потока, указатель стека, счетчик команд, флаги
условий и значения регистров общего назначения. И все потоки совместно используют
остальную часть контекста процесса. Сюда входят виртуальное адресное пространство
процесса, включая код, доступный только для чтения, данные, доступные для чтения и/
или записи, динамическую память, любой код и данные разделяемых библиотек. Потоки также используют одно и то же множество открытых файлов.
С одной стороны, потоки не могут читать и изменять значения регистров друг друга.
С другой стороны, любой поток может получить доступ к любой ячейке в совместно
используемой виртуальной памяти. Если какой-либо поток изменит ячейку памяти, то
любой другой поток заметит это изменение, если прочитает эту ячейку. То есть регист­
ры никогда не используются совместно, а виртуальная память – всегда.
Модель памяти для стека потока не так прозрачна. Стеки находятся в области виртуального адресного пространства, отведенной для стеков, и доступ потоков к ней обычно
осуществляется независимо. Здесь употреблено слово «обычно», а не «всегда», потому
что стек потока не защищен от доступа других потоков. Поэтому если какому-то потоку
удастся получить указатель на стек другого потока, он сможет прочитать и записать
свои данные в любую часть этого стека. В программе-примере это показано в строке 26,
где дочерние потоки косвенно обращаются к содержимому стека главного потока через
переменную ptr.

12.4.2. Особенности хранения переменных в памяти
Переменные в многопоточных программах на C отображаются в виртуальную память в соответствии с их классами памяти.

• Глобальные переменные. Глобальной называется любая переменная, объявлен-

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

• Локальные автоматические переменные. Это переменные, объявленные внутри

функции без атрибута static. Во время выполнения каждый поток хранит свои
экземпляры любых локальных автоматических переменных в стеке. Это верно,
даже если несколько потоков одновременно выполняют одну и ту же процедуру.
Например, в этой программе имеется только один экземпляр локальной переменной tid; он хранится в стеке главного потока. Обозначим его именем tid.m.
Другой пример: в программе имеется два экземпляра локальной переменной myid: один в стеке дочернего потока 0, а другой – в стеке дочернего потока 1.
Обозначим эти экземпляры как myid.p0 и myid.p1 соответственно.

922

 Глава 12. Конкурентное программирование

• Локальные статические переменные. Локальная статическая переменная – это

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

12.4.3. Совместно используемые переменные
Мы говорим, что переменная v используется совместно, если к одному из ее экземп­
ляров обращается более одного потока. Например, переменная cnt в программе-примере является совместно используемой, потому что она имеет только один экземпляр
и к этому экземпляру обращаются оба дочерних потока. С другой стороны, myid не является совместно используемой, потому что к каждому из двух ее экземпляров обращается только один поток. Важно понимать, что такие локальные автоматические переменные, как msgs, также могут использоваться совместно.
Упражнение 12.6 (решение в конце главы)
1. Используя анализ из раздела 12.4, заполните следующую таблицу словами «Да» и
«Нет» для программы-примера. В первом столбце запись v.t обозначает экземпляр переменной v, находящийся в локальном стеке потока t, где t – либо m (главный поток),
либо p0 (дочерний поток 0), либо p1 (дочерний поток 1).
Экземпляр
переменной

Доступен
главному потоку

дочернему потоку 0

дочернему потоку 1

ptr
cnt
i.m
msgs.m
myid.p0
myid.p1

2. Какая из переменных – ptr, cnt, i, msgs и myid – является совместно используемой?

12.5. Синхронизация потоков выполнения с помощью
семафоров
Совместное использование переменных может быть удобным, но при этом не исключается возможность появления ошибок синхронизации. Рассмотрим программу badcnt.c
в листинге 12.10. Она создает два потока, каждый из которых увеличивает совместно
используемый счетчик с именем cnt.
Листинг 12.10. badcnt.c : программа с ошибкой синхронизации

code/conc/badcnt.c
1 /* ВНИМАНИЕ: этот код содержит ошибку! */
2 #include "csapp.h"
3
4 void *thread(void *vargp); /* Прототип процедуры потока */

12.5. Синхронизация потоков выполнения с помощью семафоров  923
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

/* Совместно используемая глобальная переменная */
volatile long cnt = 0; /* Счетчик */
int main(int argc, char **argv)
{
long niters;
pthread_t tid1, tid2;
/* Проверить входные аргументы */
if (argc != 2) {
printf("usage: %s \n", argv[0]);
exit(0);
}
niters = atoi(argv[1]);
/* Создать потоки и ждать их завершения */
Pthread_create(&tid1, NULL, thread, &niters);
Pthread_create(&tid2, NULL, thread, &niters);
Pthread_join(tid1, NULL);
Pthread_join(tid2, NULL);
/* Проверить результат */
if (cnt != (2 * niters))
printf("BOOM! cnt=%ld\n", cnt);
else
printf("OK cnt=%ld\n", cnt);
exit(0);
}
/* Процедура потока */
void *thread(void *vargp)
{
long i, niters = *((long *)vargp);
for (i = 0; i < niters; i++)
cnt++;
return NULL;
}

code/conc/badcnt.c
Поскольку каждый поток увеличивает счетчик niters раз, можно ожидать, что конечное значение будет равно 2 × niters. Однако если попробовать запус­тить badcnt.с
несколько раз, то мы не только получим неправильный ответ, но и каждый раз ответы
будут разными!
linux> ./badcnt 1000000
BOOM! cnt=1445085
linux> ./badcnt 1000000
BOOM! cnt=1915220
linux> ./badcnt 1000000
BOOM! cnt=1404746

В чем же дело? Чтобы разобраться в проблеме, нужно изучить ассемблерный код
счетного цикла (строки 40–41), представленный на рис. 12.7.

924

 Глава 12. Конкурентное программирование
code forнаthread
i
КодAsm
i-го потока
ассемблере
movq
testq
jle
movl

i-го for
потока
на Ci
CКод
code
thread

(%rdi), %rcx
%rcx, %rcx
.L2
$0, %rdx

Hi : Head
инициализация
цикла

.L3:
movq cnt(%rip),%rdx
addq $1, %rdx
movq %rdx,cnt(%rip)

for (i = 0; i < niters; i++)
cnt++;

addq
cmpq
jne
.L2:

$1, %rax
%rcx, %rax
.L3

загрузить
Li : Load
cntcnt
увеличитьcnt
cnt
Ui : Update
сохранить
Si : Store
cntcnt
увеличение и проверка
Ti : Tail
переменной цикла

Рис. 12.7. Ассемблерный код цикла из программы badcnt.c (строки 40–41)
Давайте для простоты разобьем код цикла для i-го потока на пять частей.
Hi: блок инструкций инициализации цикла.
Li: инструкция, загружающая совместно используемую переменную cnt в регистр
аккумулятора %rdxi, где %rdxi обозначает значение регистра %rdx в i-м потоке.
Ui: инструкция, увеличивающая значение %rdxi.
Si: инструкция, сохраняющая увеличенное значение %rdxi в совместно используемую переменную cnt.
Ti: блок инструкций, завершающих итерацию цикла.
Обратите внимание, что инструкции в начале и в конце цикла оперируют только локальными переменными в стеке, тогда как Li, Ui и Si оперируют содержимым совместно
используемой переменной счетчика.
Когда два дочерних потока в badcnt.c выполняются на единственном процессоре, машинные команды выполняются одна за другой в некотором порядке, то есть инструкции в этих двух потоках чередуются некоторым образом. К сожалению, одни из этих
чередований приведут к корректному результату, а другие – нет.
В общем случае невозможно предсказать, выберет ли операционная система корректный порядок выполнения потоков. Например, в табл. 12.1 (a) показан корректный
порядок выполнения инструкций. После того как каждый поток обновит совместно
используемую переменную cnt, она будет хранить в памяти значение 2, что является
ожидаемым результатом.
Таблица 12.1. Порядок выполнения инструкций в первой итерации цикла в программе badcnt.c
(a) Корректный порядок выполнения
Шаг Поток Инстр. %rdx1 %rdx2

(b) Некорректный порядок выполнения
cnt

Шаг

Поток

Инстр. %rdx1 %rdx2

cnt

1

1

H1





0

1

1

H1





0

2

1

L1

0



0

2

1

L1

0



0

3

1

U1

1



0

3

1

U1

1



0

4

1

S1

1



1

4

2

H2





0

5

2

H2





1

5

2

L2



0

0

6

2

L2



1

1

6

1

S1

1



1

12.5. Синхронизация потоков выполнения с помощью семафоров  925
(a) Корректный порядок выполнения

(b) Некорректный порядок выполнения

Шаг Поток Инстр. %rdx1 %rdx2

cnt

Шаг

Поток

Инстр. %rdx1 %rdx2

cnt

7

2

U2



2

1

7

1

T1

1



1

8

2

S2



2

2

8

2

U2



1

1

9

2

T2



2

2

9

2

S2



1

1

10

1

T1

1



2

10

2

T2



1

1

С другой стороны, в табл. 12.1 (b) показан порядок выполнения инструкций, порождающий некорректный результат. Проблема возникает из-за того, что поток 2 загружает cnt на шаге 5 уже после того, как поток 1 загрузил cnt на шаге 2, но до того, как
поток 1 успеет сохранить измененное значение на шаге 6. То есть каждый поток сохраняет в переменную cnt значение 1. Понятия корректного и некорректного порядка
выполнения инструкций можно пояснить с помощью графа выполнения, который мы
представим в следующем разделе.
Упражнение 12.7 (решение в конце главы)
Заполните таблицу для следующего порядка выполнения инструкций в badcnt.с.
Шаг

Поток

Инстр.

%rdx1

%rdx2

cnt

1

1

H1





0

2

1

L1

3

2

H2

4

2

L2

5

2

U2

6

2

S2

7

1

U1

8

1

S1

9

1

T1

10

2

T2

Получится ли в этом случае корректное значение cnt?

12.5.1. Граф выполнения
Граф выполнения моделирует выполнение n конкурентных потоков в виде траектории через n-мерное декартово пространство. Каждая ось k соответствует выполнению
потока k. Каждая точка (I1, I2, ..., In) представляет состояние, когда поток k (k = 1, ..., n)
выполнил инструкцию Ik. Начало графа соответствует первоначальному состоянию, в котором ни один из потоков не выполнил ни одной инструкции.
На рис. 12.8 показан двумерный граф выполнения первой итерации цикла в программе badcnt.c. Горизонтальная ось соответствует потоку 1, вертикальная – потоку 2.
Точка (L1, S2) соответствует состоянию, в котором поток 1 выполнил L1 а поток 2 выполнил S2.

926

 Глава 12. Конкурентное программирование
Поток 22
Thread

T2

(L1, S2)

S2
U2
L2
H2
H1

L1

U1

S1

T1

Thread
Поток
1 1

Рис. 12.8. Граф выполнения первой итерации цикла в программе badcnt.c
Граф выполнения представляет выполнение инструкций как переход из одного состояния в другое. Переходы изображаются в виде направленных ребер из одной точки
к соседней с ней. Разрешаются переходы вправо (выполнение инструкции в потоке 1)
или вверх (выполнение инструкции в потоке 2). Две инструкции не могут выполняться
одновременно: диагональные переходы не допускаются. Программы никогда не выполняются в обратном направлении, поэтому переходы вниз или влево также недопус­
тимы.
Порядок выполнения программы моделируется в виде траектории в пространстве
состояний. На рис. 12.9 показана траектория, соответствующая выполнению инструкций в следующем порядке:
H1, L1, U1, H2, L2, S1, T1, U2, S2, T2.
Для потока i инструкции (Li, Ui, Si), манипулирующие содержимым совместно используемой переменной cnt, образуют критическую секцию (относительно совместно
используемой переменной cnt), которая не должна пересекаться с критической секцией
другого потока. Иными словами, мы должны гарантировать, что каждый поток имеет
исключительный доступ к общей переменной, пока выполняет инструкции в своей критической секции. В целом такой режим доступа называют взаимоисключающим.
Поток 22
Thread

T2
S2
U2
L2
H2
H1

L1

U1

S1

T1

Рис. 12.9. Пример траектории

Thread
Поток 1 1

12.5. Синхронизация потоков выполнения с помощью семафоров  927
Пересечение двух критических секций определяет область пространст­ва состояний,
называемую небезопасной областью. На рис. 12.10 показана небезопас­ная область для
переменной cnt. Обратите внимание, что небезопас­ная область примыкает к областям
по своему периметру, но не включает их. Например, состояния (H1, H2) и (S1, U2) примыкают к небезопасной области, но не являются ее частью. Траектория, огибающая
небезопасную область, называется безопасной траекторией, и наоборот, траектория,
пролегающая через любое состояние в небезопасной области, называется небезопасной
траекторией. На рис. 12.10 показаны примеры безопасной и небезопасной траекторий,
проходящих через пространство состояний программы badcnt.c. Верхняя траектория
огибает небезопасную область по левой и верхней сторонам и, следовательно, является безопасной. Нижняя траектория пересекает небезопасную область и, следовательно,
является небезопасной.
Поток 22
Thread

T2

Safe trajectory
Безопасная
траектория

Critical
Критическая
section
секция
wrt cnt
cnt
в отношении

Unsafe
Небезопасная
trajectory
траектория

Unsafe region
Небезопасная
область

S2
U2
L2
H2
H1

L1

U1

S1

T1

Thread
Поток 1 1

Criticalсекция
section
wrt cnt cnt
Критическая
в отношении

Рис. 12.10. Безопасная и небезопасная траектории.
Траектории, пересекающие небезопасную область, небезопасны.
Траектории, огибающие небезопасную область, безопасны
Любая безопасная траектория будет корректно изменять совместно используемый
счетчик. Для гарантии корректного выполнениярассматриваемой многопоточной
программы – да и любой конкурентной программы, в которой совместно используются
глобальные структуры данных, – так или иначе необходимо синхронизировать потоки,
чтобы они всегда следовали по безопасным траекториям.
Упражнение 12.8 (решение в конце главы)
Используя граф выполнения на рис. 12.10, классифицируйте следующие траек­тории как
безопасные или небезопасные:

1. H1, L1, U1, S1, H2, L2, U2, S2, T2, T1
2. H2, L2, H1, L1, U1, S1, T1, U2, S2, T2
3. H1, H2, L2, U2, S2, L1, U1, S1, T1, T2

928

 Глава 12. Конкурентное программирование

12.5.2. Семафоры
Эдсгер Дейкстра (Edsger Dijkstra) – первый, кто изучил и сформулировал дисциплину
конкурентного программирования, предложил классическое решение проблемы синхронизации различных потоков выполнения, основанное на особом типе переменной,
называемой семафором. Семафор s – это глобальная переменная с неотрицательным
целочисленным значением, манипулировать которой могут только две специальные
операции, называемые P и V.
P(s): если значение s не равно нулю, тогда P уменьшает значение s и немедленно
возвращает управление. Если значение s равно нулю, тогда поток приостанавливается до момента, когда другой поток выполнит операцию V и s примет ненулевое значение. После возобновления поток вновь пробует выполнить операцию P, уменьшает значение s и возвращает управление вызывающему процессу.
V(s): операция V увеличивает значение s на единицу. При наличии потоков, заблокированных в операции Р и ожидающих, когда s примет ненулевое значение,
операция V перезапускает только один из этих потоков, который затем завершает свою операцию Р уменьшением значения s.
Происхождение названий Р и V
Эдсгер Дейкстра – уроженец Нидерландов. Названия Р и V происходят из голландс­кого
языка, от слов proberen (проверить) и verhogen (увеличить).

Проверка и уменьшение значения в операции P выполняются атомарно, в том смысле, что как только семафор s принимает ненулевое значение, уменьшение его значения не может быть прервано ни программными, ни аппаратными прерываниями.
Операция увеличения значения в V тоже выполняется атомарно. Обратите внимание,
что определение V не определяет порядок выбора потока для возобновления из числа
ожидающих. Единственное требование: операция V должна возобновить ровно один
ожидающий поток. То есть когда на семафоре ожидают несколько потоков, невозможно
предугадать, какой из них будет возобновлен в результате выполнения V.
Определения P и V гарантируют, что выполняющаяся программа никогда не окажется в состоянии, когда корректно инициализированный семафор имеет отрицательное
значение. Это свойство, называемое инвариантом семафора, делает семафоры мощным
инструментом управления траекториями конкурентных программ, не позволяющим
им пересекать небезопасные области.
Стандарт определяет несколько функций управления семафорами.
#include
int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_wait(sem_t *s); /* P(s) */
int sem_post(sem_t *s); /* V(s) */

Возвращает 0 в случае успеха, −1 в случае ошибки
Функция sem_init инициализирует семафор sem значением value. Каждый семафор должен инициализироваться перед использованием. В наших примерах средний
аргумент всегда равен 0. Программы выполняют операции P и V вызовом функций
sem_wait и sem_post соответственно. Для краткости мы будем использовать функции-обертки P и V:

12.5. Синхронизация потоков выполнения с помощью семафоров  929
#include "csapp.h"
void P(sem_t *s); /* Функция-обертка для sem_wait */
void V(sem_t *s); /* Функция-обертка для sem_post */

Ничего не возвращают

12.5.3. Использование семафоров для исключительного
доступа к ресурсам
Семафоры реализуют удобный способ организовать исключительный дос­туп к общим переменным. Основная идея состоит в том, чтобы связать семафор s, изначально
равный 1, с общей переменной (или набором общих переменных), а затем окружить
соответствующую критическую секцию в коде операциями P(s) и V(s).
Семафор, используемый таким образом для защиты общих переменных, называется
бинарным семафором, потому что может принимать только два значения, 0 и 1. Бинарные семафоры, предназначенные для организации исключительного доступа, часто называют мьютексами. Выполнение операции P над мьютексом называется блокировкой
мьютекса. Аналогично выполнение операции V называется разблокировкой мьютекса.
О потоке, который заблокировал, но еще не разблокировал мьютекс, говорят, что он
удерживает мьютекс. Семафор, который используется как счетчик для набора доступных ресурсов, называется счетным семафором.
Ограничение графов выполнения
Графы выполнения прекрасно справляются с задачей визуализации хода выполнения
конкурентных программ в однопроцессорных системах и объяснения необходимости
синхронизации. Однако они имеют свои ограничения, в частности в том, что касается
конкурентного выполнения в многопроцессорных системах, где множество пар процессор/кеш совместно используют одну и ту же основную память. Поведение многопроцессорной системы нельзя объяснить с помощью графов выполнения. Например, система
памяти в многопроцессорной системе может находиться в состоянии, не соответствующем никакой траектории на графе выполнения. Но суть от этого не меняется: доступ к
совместно используемым переменным всегда нужно синхронизировать.

Граф выполнения на рис. 12.11 показывает, как мы будем использовать бинарные
семафоры для синхронизации потоков в нашей программе-счетчике.
Каждая точка в пространстве состояний подписана значением семафоров в этом состоянии. Ключевая идея состоит в том, что определенные комбинации операций P и V
создают набор состояний, образующих запрещенную область, где s < 0. Из-за инвариантности семафора никакая допустимая траектория не может включать состояния из
запрещенной области. А поскольку запрещенная область полностью охватывает небезопасную область, то никакая допустимая траектория не сможет коснуться какой-либо
части небезопасной области. То есть любая допустимая траектория безопасна, если,
следуя ей, программа корректно увеличивает счетчик, независимо от порядка выполнения инструкций.
Проще говоря, запрещенная область, созданная операциями P и V, делает невозможным одновременное выполнение инструкций в критической секции несколькими
потоками, то есть семафоры обеспечивают исключительный доступ к критической области.

930

 Глава 12. Конкурентное программирование
Поток 22
Thread
1

1

0

0

0

0

1

1

1

1

0

0

0

0

1

1

0

0

0

0

0

0

T2
V(s)

Forbidden region
Запрещенная
область

S2
U2
L2
P(s)

ПервонаInitially
чально
ss1
=1

H2

–1

–1

–1

–1

–1

–1

–1

0

0

–1

0

0

–1

–1

–1

–1

0

0

0

0

–1

–1

–1

–1

0

0

1

1

0

0

0

1

1

Unsafe region
Небезопасная
область

1

1

H1

0

P(s)

0

L1

0

0

U1

0

S1

1

V(s)

1

T1

Thread
Поток
1 1

Рис. 12.11. Использование семафора для организации исключительного доступа.
Недопустимые состояния, где s < 0, определяют запрещенную область,
которая окружает небезопасную область и не позволяет допустимым
траекториям касаться небезопасной области
Итак, чтобы правильно синхронизировать потоки в нашей программе-счетчике из
листинга 12.10 с использованием семафоров, сначала объявим семафор с именем mutex:
volatile long cnt = 0; /* Счетчик */
sem_t mutex;
/* Семафор для защиты счетчика */

затем инициализируем его в функции main:
Sem_init(&mutex, 0, 1); /* mutex = 1 */

и, наконец, защитим операцию изменения счетчика cnt в процедуре потока, окружив
ее операциями P и V:
for (i = 0; i < niters; i++) {
P(&mutex);
cnt++;
V(&mutex);
}

Теперь благодаря семафору наша программа всегда будет давать верный результат:
linux> ./goodcnt 1000000
OK cnt=2000000
linux> ./goodcnt 1000000
OK cnt=2000000

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

12.5. Синхронизация потоков выполнения с помощью семафоров  931
гой поток о том, что некоторое условие в состоянии программы стало верным. Классический пример: проблемы производитель–потребитель и читателей–писателей.

Проблема производитель–потребитель
Суть проблемы производитель–потребитель показана на рис. 12.12. Поток-производитель и поток-потребитель совместно используют буфер ограниченного объема с емкостью n единиц. Поток-производитель создает новые элементы и добавляет их в буфер.
Поток-потребитель извлекает элементы из буфера и использует (потребляет) их. Также
возможны варианты с разными количествами производителей и потребителей.
Producer
Потокпроизводитель
thread

Bounded
Ограниченный
buffer
буфер

Consumer
Потокпотребитель
thread

Рис. 12.12. Проблема производитель–потребитель.
Производитель генерирует элементы и добавляет их в буфер ограниченного объема.
Потребитель извлекает элементы из буфера и «потребляет» их
Поскольку добавление и извлечение элементов связаны с обновлением совместно
используемых переменных, необходимо гарантировать исключительный доступ к буферу. Однако одной этой гарантии недостаточно. Необходимо также организовать совместное использование буфера. Если буфер полон (нет места для новых элементов), то
производитель должен дождаться, когда потребитель извлечет из буфера хотя бы один
элемент. Аналогично, если буфер пуст, то потребитель должен дождаться появления в
нем хотя бы одного элемента.
Взаимодействие производителя и потребителя – обычное явление в реальных системах. Например, в мультимедийной системе задачей производителя является кодирование видеокадров, а потребителя – их раскодирование и отображение на экране.
Цель буфера – обеспечить плавное отображение видеопотока без подтормаживаний,
вызываемых различиями в производительности операций кодирования и декодирования отдельных кадров. Для производителя буфер служит пространством, куда можно
складывать готовые кадры, а для потребителя – источником закодированных кадров.
Другой расхожий пример: создание графических пользовательских интерфейсов. Производитель определяет события мыши и клавиатуры и вставляет их в буфер. Потребитель извлекает эти события из буфера в некой приоритетной последовательности и
рисует изображение на экране.
В этом разделе мы разработаем простой пакет под названием SBUF, который можно будет использовать в программах типа производитель–потребитель. В следующем
разделе мы используем его для создания конкурентного сервера. Пакет SBUF работает
с буферами типа sbuf_t (листинг 12.11). Элементы хранятся в целочисленном массиве
buf с размером n в динамической памяти. Переменные front и rear хранят индексы первого и последнего элементов в массиве. Доступ к буферу синхронизируется тремя семафорами. Семафор mutex обеспечивает исключительный доступ к буферу. Семафоры
slots и items служат счетчиками пустых ячеек и доступных для извлечения элементов
соответственно.
Листинг 12.11. sbuf_t: буфер ограниченного объема, используемый пакетом SBUF

code/conc/sbuf.c
1 typedef struct {
2
int *buf;
/* Массив, лежащий в основе буфера */
3
int n;
/* Максимальное число ячеек в массиве */
4
int front;
/* buf[(front+1)%n] -- первый элемент */

932

 Глава 12. Конкурентное программирование

5
int rear;
6
sem_t mutex;
7
sem_t slots;
8
sem_t items;
9 } sbuf_t;

/*
/*
/*
/*

buf[rear%n] – последний элемент */
Защищает доступ к buf */
Счетчик пустых ячеек */
Счетчик готовых к извлечению элементов */

code/conc/sbuf.c
В листинге 12.12 показана полная реализация пакета SBUF. Функция sbuf_init выделяет память для буфера, устанавливает значения front и rear, соответствующие пустому
буферу, и присваивает начальные значения трем семафорам. Эта функция вызывается один раз перед обращением к любой из трех других функций. Функция sbuf_deinit
осво­бождает память, занятую буфером, когда приложение прекращает его использование. Функция sbuf_insert ожидает появления свободной ячейки, блокирует мьютекс,
добавляет элемент, разблокирует мьютекс, после чего объявляет о наличии нового элемента. Функция sbuf_remove ожидает появления элемента в буфере, блокирует мьютекс,
удаляет элемент из начала буфера, разблокирует мьютекс, после чего сигнализирует о
появлении пустой ячейки.
Листинг 12.12. SBUF: пакет для синхронизации конкурентного доступа к ограниченному буферу

code/conc/sbuf.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

#include "csapp.h"
#include "sbuf.h"
/* Создает пустой буфер FIFO с n ячейками */
void sbuf_init(sbuf_t *sp, int n)
{
sp->buf = Calloc(n, sizeof(int));
sp->n = n;
/* Буфер вмещает до n элементов */
sp->front = sp->rear = 0;
/* Буфер пуст, если front == rear */
Sem_init(&sp->mutex, 0, 1); /* Бинарный семафор для блокировки */
Sem_init(&sp->slots, 0, n); /* Изначально буфер имеет n пустых ячеек */
Sem_init(&sp->items, 0, 0); /* Изначально в буфере нет готовых элементов */
}
/* Освобождает буфер sp */
void sbuf_deinit(sbuf_t *sp)
{
Free(sp->buf);
}
/* Добавляет элемент в конец буфера sp */
void sbuf_insert(sbuf_t *sp, int item)
{
P(&sp->slots);
P(&sp->mutex);
sp->buf[(++sp->rear)%(sp->n)] = item;
V(&sp->mutex);
V(&sp->items);
}

/*
/*
/*
/*
/*

Ждать появления пустой ячейки */
Заблокировать буфер */
Добавить элемент */
Разблокировать буфер */
Сообщить о новом элементе */

/* Извлекает и возвращает первый элемент из буфера sp */
int sbuf_remove(sbuf_t *sp)
{
int item;
P(&sp->items);
/* Ждать появления элемента */

12.5. Синхронизация потоков выполнения с помощью семафоров  933
36
37
38
39
40
41 }

P(&sp->mutex);
item = sp->buf[(++sp->front)%(sp->n)];
V(&sp->mutex);
V(&sp->slots);
return item;

/*
/*
/*
/*

Заблокировать буфер */
Извлечь элемент */
Разблокировать буфер */
Сообщить о пустой ячейке */

code/conc/sbuf.c
Упражнение 12.9 (решение в конце главы)
Пусть p обозначает количество производителей, c – количество потребителей, а n – размер
буфера в элементах. Для каждого из следующих сценариев укажите, необходим ли мьютекс
в sbuf_insert и sbuf_remove.
1. p = 1, c = 1, n > 1
2. p = 1, c = 1, n = 1
3. p >1, c > 1, n = 1

Проблема читателей–писателей
Проблема читателей–писателей является обобщением проблемы исключительного
доступа. Представьте, что имеется несколько потоков, выполняющихся конкурентно,
которые обращаются к общему объекту, такому как структура данных в оперативной
памяти или база данных на диске. Одни потоки только читают данные из объекта, другие изменяют его. Потоки, изменяющие объект, называются писателями. Потоки, которые только читают, называются читателями. Писатели должны иметь исключительный
доступ к объекту, но читатели могут читать данные одновременно с неограниченным
числом других читателей. В общем случае существует неограниченное количество читателей и писателей, действующих конкурентно.
Подобные сценарии не редкость в реальных системах. Например, в онлайн-системе
бронирования авиабилетов неограниченное количество клиентов могут одновременно проверять наличие свободных мест, но только клиент, бронирующий место, должен
иметь исключительный доступ к базе данных. Другой пример: веб-прокси с многопоточным кешированием – неограниченное количество потоков может извлекать сущест­
вующие страницы из общего кеша, но любой поток, записывающий новую страницу в
кеш, должен иметь исключительный доступ.
Проблема читателей–писателей имеет несколько вариантов, каждый из которых
отличается приоритетами читателей и писателей. В первом варианте предпочтение
отдается читателям, чтобы ни одному читателю не приходилось ждать, если только писателю уже не был предоставлен исключительный доступ к объекту. То есть ни один читатель не должен ждать только потому, что своей очереди уже ждет писатель. Во втором
варианте предпочтение отдается писателям, чтобы, когда писатель будет готов писать,
он мог выполнить запись как можно скорее. В отличие от первого варианта, читатель,
запросивший доступ после писателя, должен ждать, даже если писатель тоже ждет.
В листинге 12.13 показано решение первого варианта проблемы читателей–писателей. Так же как решения многих проблем с синхронизацией, это решение только кажется
простым. Семафор w управляет доступом к критичес­ким секциям, которые обращаются к общему объекту. Семафор mutex защищает дос­туп к общей переменной readcnt –
счетчику читателей, выполняющих в данный момент критическую секцию. Писатель
блокирует мьютекс w каждый раз, когда входит в критическую секцию, и разблокирует
после выхода из нее. Это гарантирует, что в любой момент времени критическую сек-

934

 Глава 12. Конкурентное программирование

цию будет выполнять только один писатель. С другой стороны, только первый читатель,
вошедший в критическую секцию, блокирует мьютекс w, и только последний читатель,
покинувший критическую секцию, разблокирует его. Мьютекс w игнорируется читателями, которые входят и выходят, пока присутствуют другие читатели. Это означает, что
пока один читатель удерживает мьютекс w, неог­раниченное количество читателей могут беспрепятственно войти в критичес­кую секцию.
Листинг 12.13. Решение первого варианта проблемы читателей–писателей.
Предпочтение отдается читателям
/* Глобальные переменные */
int readcnt;
/* Первоначально = 0 */
sem_t mutex, w; /* Оба первоначально = 1 */
void reader(void)
{
while (1) {
P(&mutex);
readcnt++;
if (readcnt == 1) /* Первый читатель */
P(&w);
V(&mutex);
/* Критическая секция */
/* Выполняется чтение */
P(&mutex);
readcnt--;
if (readcnt == 0) /* Последний читатель */
V(&w);
V(&mutex);
}
}
void writer(void)
{
while (1) {
P(&w);
/* Критическая секция */
/* Выполняется запись */
V(&w);
}
}

Правильное решение любой из проблем читателей-писателей может привес­ти к
голоданию, когда поток блокируется на неопределенный срок и не может продолжать
выполняться. Например, в решении, показанном в листинге 12.13, писатель может заблокироваться надолго, пока не иссякнет поток читателей.
Упражнение 12.10 (решение в конце главы)
Решение первого варианта проблемы читателей–писателей в листинге 12.13 отдает предпочтение читателям, но это предпочтение не строгое, в том смысле, что поток-писатель,

12.5. Синхронизация потоков выполнения с помощью семафоров  935
покидающий критическую секцию, может возобновить ожидающий поток-писатель вместо
ожидающего потока-читателя. Опишите сценарий, в котором такое нестрогое предпочтение
позволило бы группе потоков-писателей вызвать голодание потоков-читателей.

12.5.5. Все вместе: конкурентный сервер на базе
предварительно созданных потоков
Выше мы видели, как можно использовать семафоры для доступа к общим переменным и ресурсам. Для закрепления изученного реализуем конкурентный сервер, который будет заранее создавать потоки для обслуживания клиентов и использовать их.
В конкурентном сервере в листинге 12.8 для обслуживания каждого нового клиента
создается новый поток. Недостаток такого подхода заключается в значительных затратах, связанных с созданием нового потока для каждого нового клиента. Сервер, основанный на предварительной организации поточной обработки, снижает эти издержки,
используя модель производитель–потребитель, показанную на рис. 12.13. Сервер состоит из главного потока и набора рабочих потоков. Главный поток принимает запросы
на соединение от клиентов и помещает дескрипторы подключенных сокетов в буфер.
Каждый рабочий поток извлекает из буфера очередной дескриптор, обслуживает клиента и затем ждет появления следующего дескриптора.
Пул рабочих
потоков
Pool
of worker
threads
Service
client клиента
Обслуживает
Client
Клиент

Главный
Master
поток
thread

Добавляет
Insert
дескрипторы
descriptors

Buffer
Буфер

Client
Клиент

Обслуживает
Service clientклиента

Извлекает
Remove
дескрипторы
descriptors

...

...

Accept
Принимает
connections
соединения

Рабочий
Worker
поток
thread

Рабочий
Worker
поток
thread

Рис.12.13. Конкурентный сервер на базе предварительно созданных потоков.
Набор существующих потоков извлекает и обрабатывает дескрипторы подключенных
сокетов из ограниченного буфера
В листинге 12.14 показано, как можно использовать пакет SBUF для реализации конкурентного эхо-сервера с предварительно созданными потоками. Пос­ле инициализации буфера sbuf (строка 24) главный поток создает множество рабочих потоков (строки
25–26). Затем главный поток входит в бесконечный цикл, в котором принимает запросы на соединение и добавляет дескрипторы подключенных сокетов в sbuf. Все рабочие
потоки действуют очень просто: ожидают появления в буфере дескриптора подключенного сокета (строка 39), после чего вызывают функцию echo_cnt для отправки клиентам
отправленных ими строк.
Листинг 12.14. Конкурентный сервер на базе предварительно созданных потоков. Сервер
основан на модели производитель–потребитель с одним потоком-производителем
и несколькими потоками-потребителями

code/conc/echoservert-pre.c
1 #include "csapp.h"
2 #include "sbuf.h"
3 #define NTHREADS 4

936
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

 Глава 12. Конкурентное программирование
#define SBUFSIZE 16
void echo_cnt(int connfd);
void *thread(void *vargp);
sbuf_t sbuf; /* Общий буфер с дескрипторами подключенных сокетов */
int main(int argc, char **argv)
{
int i, listenfd, connfd;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
pthread_t tid;
if (argc != 2) {
fprintf(stderr, "usage: %s \n", argv[0]);
exit(0);
}
listenfd = Open_listenfd(argv[1]);
sbuf_init(&sbuf, SBUFSIZE);
for (i = 0; i < NTHREADS; i++) /* Создать рабочие потоки */
Pthread_create(&tid, NULL, thread, NULL);
while (1) {
clientlen = sizeof(struct sockaddr_storage);
connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
sbuf_insert(&sbuf, connfd); /* Добавить connfd в буфер */
}
}
void *thread(void *vargp)
{
Pthread_detach(pthread_self());
while (1) {
int connfd = sbuf_remove(&sbuf); /* Извлечь connfd из буфера */
echo_cnt(connfd);
/* Обслужить клиента */
Close(connfd);
}
}

code/conc/echoservert-pre.c
Функция echo_cnt (листинг 12.15) является версией функции echo из листинга 11.9, которая записывает суммарное число байтов, полученных от всех клиентов, в глобальную
переменную byte_cnt. Этот код особенно интересен тем, что он демонстрирует общую
методику инициализации пакетов, вызываемых из процедур потоков. В данном случае
необходимо инициализировать счетчик byte_cnt и семафор mutex. Один из подходов,
которые мы использовали в пакетах SBUF и RIO, требовал, чтобы главный поток явно
вызывал функцию инициализации. Другой подход, представленный здесь, основан на
использовании функции pthread_once (строка 19) для вызова функции инициализации,
как только какой-либо поток в первый раз вызовет функцию echo_cnt. Преимущество
данного подхода заключается в простоте использования пакета. Недостаток – каждое
обращение к echo_cnt вызывает функцию pthread_once, которая по большей части не
делает ничего полезного.

12.5. Синхронизация потоков выполнения с помощью семафоров  937
Листинг 12.15. echo_cnt: версия функции echo, подсчитывающей общее количество
полученных байтов

code/conc/echo-cnt.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

#include "csapp.h"
static int byte_cnt; /* Счетчик байтов */
static sem_t mutex; /* и защищающий его мьютекс */
static void init_echo_cnt(void)
{
Sem_init(&mutex, 0, 1);
byte_cnt = 0;
}
void echo_cnt(int connfd)
{
int n;
char buf[MAXLINE];
rio_t rio;
static pthread_once_t once = PTHREAD_ONCE_INIT;
Pthread_once(&once, init_echo_cnt);
Rio_readinitb(&rio, connfd);
while((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
P(&mutex);
byte_cnt += n;
printf("server received %d (%d total) bytes on fd %d\n",
n, byte_cnt, connfd);
V(&mutex);
Rio_writen(connfd, buf, n);
}
}

code/conc/echo-cnt.c
После инициализации пакета функция echo_cnt инициализирует пакет RIO буферизованного ввода/вывода (строка 20), после чего возвращает клиенту полученную от
него строку. Обратите внимание, что доступ к совместно используемой переменной
byte_cnt в строках 23–24 защищен операциями P и V.
Событийно-ориентированные многопоточные программы
Мультиплексирование ввода/вывода – не единственный способ к созданию событийно-ориентированных программ. Например, возможно, вы заметили, что разработанный
нами конкурентный сервер на базе предварительно созданных потоков на самом деле
является событийно-ориентированным с простыми конечными автоматами для главного
и рабочих потоков. Главный поток имеет два состояния (ожидание запроса на соединение и ожидание появления свободного места в буфере), два события ввода/вывода
(поступление запроса на соединение и появление свободного места в буфере) и два
перехода (прием запроса на соединение и добавление дескриптора в буфер). Аналогично каждый рабочий поток имеет одно состояние (ожидание появления дескриптора в
буфере), одно событие ввода/вывода (появление дескриптора в буфере) и один переход
(извлечение дескриптора из буфера).

938

 Глава 12. Конкурентное программирование

12.6. Использование потоков выполнения
для организации параллельной обработки
До сих пор в нашем исследовании конкуренции мы предполагали, что конкурентные
потоки выполняются в однопроцессорной системе. Однако большинство современных
машин имеют многоядерные процессоры. Конкурентные программы часто работают
быстрее на таких машинах, потому что ядро операционной системы планирует конкурентные потоки на нескольких ядрах, а не последовательно на одном ядре. Поддержка
такого параллелизма критически важна в таких приложениях, как высоконагруженные
веб-серверы, серверы баз данных и большие научные приложения, и становится все
более полезной для основных приложений, таких как веб-браузеры, электронные таб­
лицы и процессоры документов.
На рис. 12.14 показана связь между последовательными, конкурентными и параллельными программами. Множество всех программ можно разбить на непересекающиеся множества последовательных и конкурентных программ. Последовательная
программа работает как единственный логический поток управления. Конкурентная
программа – как несколько конкурентных потоков. А параллельная программа – это
конкурентная программа, выполняющаяся на нескольких процессорах. То есть мно­
жество параллельных программ являются подмножеством конкурентных программ.
All
Всеprograms
программы
Concurrent
Конкурентныеprograms
программы
Parallel
Параллельные
программы
programs

Последовательные
Sequential
programs
программы

Рис. 12.14. Связь между множествами последовательных, конкурентных
и параллельных программ
Подробное обсуждение параллельных программ выходит за рамки этой книги, и все
же знакомство с некоторыми простыми примерами поможет вам понять некоторые
важные аспекты параллельного программирования. Например, давайте посмотрим,
как можно организовать параллельное вычисление суммы последовательности целых
чисел 0, ..., n − 1. Конечно, для этой конк­ретной задачи существует аналитическое решение (формула), но тем не менее этот краткий и простой пример позволит нам сделать
несколько интересных замечаний о параллельных программах.
Самый простой подход к распределению работы между разными потоками состоит в том, чтобы разбить последовательность на t непересекающихся областей, а затем назначить каждому из t разных потоков работу над своей областью. Для простоты
предположим, что n кратно t, так что каждая область имеет n/t элементов. Рассмотрим
несколько различных способов организации параллельной обработки отдельных областей несколькими потоками.
Самый простой и понятный вариант – заставить все потоки накапливать сумму в
общей глобальной переменной, защищенной мьютексом. В листинге 12.16 показано,
как это можно реализовать. В строках 28–33 главный поток создает дочерние потоки,
а затем ожидает их завершения. Обратите внимание, что главный поток передает небольшое целое число каждому потоку, который служит уникальным идентификатором
потока. Каждый дочерний поток будет использовать свой идентификатор, чтобы определить, с какой частью последовательности он должен работать. Эта идея передачи

12.6. Использование потоков выполнения для организации параллельной обработки  939
небольшого уникального идентификатора дочерним потокам широко используется во
многих параллельных приложениях. После завершения дочерних потоков глобальная
переменная gsum будет содержать окончательную сумму. Затем главный поток использует аналитическое решение для проверки результата (строки 36–37).
Листинг 12.16. Функция main программы psum-mutex. Использует несколько потоков
для вычисления суммы элементов последовательности в общей переменной, защищенной
мьютексом

code/conc/psum-mutex.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

#include "csapp.h"
#define MAXTHREADS 32
void *sum_mutex(void *vargp); /* Процедура потока */
/* Общие глобальные переменные */
long gsum = 0;
/* Общая сумма */
long nelems_per_thread; /* Количество суммируемых элементов */
sem_t mutex;
/* Мьютекс для защиты глобальной суммы */
int main(int argc, char **argv)
{
long i, nelems, log_nelems, nthreads, myid[MAXTHREADS];
pthread_t tid[MAXTHREADS];
/* Получить аргументы */
if (argc != 3) {
printf("Usage: %s \n", argv[0]);
exit(0);
}
nthreads = atoi(argv[1]);
log_nelems = atoi(argv[2]);
nelems = (1L 4), когда каждое из
четырех ядер занято выполнением хотя бы одного потока. Время выполнения немного
увеличивается с увеличением количества потоков из-за накладных расходов на переключение контекста между потоками на одном ядре. По этой причине параллельные
программы часто пишут так, чтобы каждое ядро выполняло ровно один поток.
Хотя абсолютное время выполнения является конечной мерой производительности
любой программы, существуют некоторые полезные относительные показатели, которые помогут оценить, насколько хорошо параллельная программа использует потенциальные возможности параллелизма. Ускорение параллельной программы обычно
определяется как

12.6. Использование потоков выполнения для организации параллельной обработки  943
T
Sp = –––1––,
Tp



где p – количество ядер процессора, Tp – время выполнения на p ядрах. Эту формулу
иног­да называют формулой строгого масштабирования. Когда T1 – это время выполнения последовательной версии программы, то метрику Sp называют абсолютным
ускорением. Когда T1 – это время выполнения параллельной версии программы, выполняющейся на одном ядре, то Sp называют относительным ускорением. Абсолютное
ускорение является более точной мерой преимуществ параллелизма, чем относительное. Параллельные программы часто страдают от накладных расходов на синхронизацию, даже если они выполняются на одном процессоре, и эти накладные расходы могут
искусственно завышать относительные значения ускорения, поскольку они увеличивают значение в числителе. С другой стороны, абсолютное ускорение измерить труднее,
чем относительное, потому что для этого требуются две разные версии программы. Для
сложных параллельных программ писать еще и последовательную версию может оказаться нецелесообразным либо из-за высокой сложнос­ти кода, либо просто потому, что
исходный код недоступен.
Похожая мера, известная как эффективность, определяется как

Tp
T1
Ep = –––––= –––––
p
pTp



и обычно измеряется в процентах в диапазоне (0, 100]. Эффективность – это мера накладных расходов из-за распараллеливания. Программы с высокой эффективностью
тратят больше времени на выполнение полезной работы и меньше на синхронизацию
и обмен данными.
В табл. 12.2 показаны различные значения показателей ускорения и эффективности
для нашей параллельной программы суммирования числовой последовательности. Эффективность выше 90 % – это очень хорошо, но не все так просто. Мы смогли добиться
высокой эффективности, потому что нашу задачу легко распараллелить. На практике
такое бывает редко. Параллельное программирование активно исследуется на протяжении десятилетий. С появлением многоядерных процессоров, количество ядер в которых удваивается каждые несколько лет, параллельное программирование продолжает
оставаться глубокой, сложной и активной областью исследований.
Таблица 12.2. Оценки ускорения и эффективности на основе измерений,
представленных на рис. 12.15
Потоки (t)

1

2

4

8

16

Ядра (p)

1

2

4

4

4

1,06

0,54

0,28

0,29

0,30

Время выполнения (Tp)
Ускорение (Sp)
Эффективность (Ep)

1

1,9

3,8

3,7

3,5

100 %

98 %

95 %

91 %

88 %

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

944

 Глава 12. Конкурентное программирование

Слабое масштабирование часто является более точной мерой, чем строгое, потому
что точнее отражает наше желание использовать более мощные машины для выполнения большего объема работы. Это особенно верно для научных вычислений, где размер
задачи легко увеличить, а чем больше размер задачи, тем точнее прогнозы природных
явлений. Однако существуют приложения, размер которых не так легко увеличить, и
для оценки этих приложений лучше подходит мера строгого масштабирования. Например, объем работы, выполняемой приложениями, которые обрабатывают сигналы в реальном времени, часто определяется свойствами физических датчиков, генерирующих
эти сигналы. Для увеличения общего объема работы потребуется использовать большее
количество разных физических датчиков, что может оказаться неосущест­вимыми или
нецелесообразным. Параллелизм в таких приложениях обычно используется для выполнения фиксированного объема работы в как можно более короткие сроки.
Упражнение 12.11 (решение в конце главы)
Вычислите недостающие значения в следующей таблице в предположении строгого масш­
табирования.
Потоки (t)
Ядра (p)

1
1

2
2

4
4

Время выполнения (Tp)
Ускорение (Sp)

12

8
1,5

6

Эффективность (Ep)

100 %

50 %

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

12.7.1. Безопасность в многопоточном окружении
В многопоточных программах мы должны уделять особое внимание безопасности
функций в многопоточном окружении. Функция считается потокобезопасной (threadsafe), если она всегда возвращает корректные результаты при обращении к ней из
множества потоков, выполняющихся конкурентно. Если в таком окружении функция
может возвращать некорректные результаты, то ее называют потоконебезопасной
(thread-unsafe).
Различают четыре (пересекающихся) класса потоконебезопасных функций.
Класс 1:

функции, не защищающие совместно используемые переменные. Эта проблема уже встречалась нам в функции thread (листинг 12.10), которая наращивает незащищенную глобальную переменную счетчика. Функции этого

12.6. Использование потоков выполнения для организации параллельной обработки  945
класса сравнительно легко сделать потокобезопасными: достаточно защитить совместно используемые переменные операциями синхронизации,
такими как P и V. Преимущество такого решения – отсутствие необходимости изменять вызывающий код; недостаток – операции синхронизации
замедляют выполнение функции.
Класс 2:

функции, сохраняющие состояние между вызовами. Генератор псевдослучайных чисел – вот простой пример функции из этого класса. Рассмотрим пакет генератора псевдослучайных чисел в листинге 12.20.

Листинг 12.20. Потоконебезопасный генератор псевдослучайных чисел (на основе [61])

code/conc/rand.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14

unsigned next_seed = 1;
/* rand – возвращает псевдослучайное целое число в диапазоне 0..32767 */
unsigned rand(void)
{
next_seed = next_seed*1103515245 + 12543;
return (unsigned)(next_seed>>16) % 32768;
}
/* srand – устанавливает начальное значение для генератора rand() */
void srand(unsigned new_seed)
{
next_seed = new_seed;
}

code/conc/rand.c
Функция rand является потоконебезопасной, потому что результат текущего вызова зависит от промежуточного результата в предыдущей итерации.
При многократном вызове rand из одного потока после вызова srand можно ожидать получения повторяющихся последовательностей чисел. Однако
данное предположение не оправдывается, если rand вызывается из нескольких потоков.
Единственный способ сделать такую функцию потокобезопасной – переписать ее так, чтобы она не использовала никаких статических данных, а
информация о состоянии передавалась в аргументах. Недостаток подобного
решения – программист вынужден изменить не только саму функцию, но и
вызывающий ее код. В большой программе, где функция может вызываться
из сотни мест, внесение таких изменений может оказаться сложной задачей,
подверженной ошибкам.
Класс З: функции, возвращающие указатель в статической переменной. Некоторые
функции, такие как gethostbyname, сохраняют результат в статической переменной и возвращают указатель на нее. Вызов таких функций из конкурентных потоков может закончиться катастрофой, потому что результаты,
вычисленные для одного потока, могут быть затерты результатами, вычисленными для другого потока.
Есть два решения этой проблемы. Первое – переписать функцию так, чтобы вызывающая программа передавала адрес переменной, куда следует
сохранить результат. Это устраняет из цепочки вычислений все совместно
используемые данные, однако требует от программиста изменить также
вызывающий код.

946

 Глава 12. Конкурентное программирование
Если потоконебезопасную функцию трудно или невозможно модифицировать (например, она находится в библиотеке), то можно порекомендовать
альтернативный прием блокировки и копирования. Идея заключается в том,
чтобы в каждой точке вызова потоконе­безопасной функции выполнить
такую последовательность дейст­вий: захватить мьютекс, вызвать потоконебезопасную функцию, динамически выделить память для результата,
скопировать в нее результат и освободить мьютекс. Другой довольно привлекательный способ: определить потокобезопасную функцию-обертку,
выполняющую блокировку и копирование, а затем заменить все вызовы
небезопасной функции вызовами функции-обертки. Например, в лис­
тинге 12.21 представлена потокобезопасная версия gethostbyname, использующая прием блокировки и копирования.

Листинг 12.21. Потокобезопасная функция-обертка вокруг функции ctime из стандартной
библиотеки языка C. В этом примере используется прием блокировки и копирования
при использовании функций из третьего класса потоконебезопасных функций

code/conc/ctime-ts.c
1 char *ctime_ts(const time_t *timep, char *privatep)
2 {
3
char *sharedp;
4
5
P(&mutex);
6
sharedp = ctime(timep);
7
strcpy(privatep, sharedp); /* Скопировать из общей памяти в приватную */
8
V(&mutex);
9
return privatep;
10 }

code/conc/ctime-ts.c
Класс 4:

функции, вызывающие потоконебезопасные функции. Если функция f вызывает потоконебезопасную функцию g, то можно ли считать функцию f потоконебезопасной? Как сказать... Если g – функция из класса 2, сохраняющего
состояние между вызовами, то f тоже будет потоконебезопасной, и у вас не
будет иного выхода, как переписать g. Однако если функция g принадлежит
классу 1 или 3, тогда f может оставаться потокобезопасной, если все вызовы
и совместно используемые результаты будут защищены мьютексом. Хороший пример такой ситуации показан в листинге 12.21, демонстрирующем
прием блокировки и копирования для написания безопасной функции, вызывающей небезопасную.

12.7.2. Реентерабельность
Существует важный класс потокобезопасных функций, называемых реентерабельными, характеризующихся тем, что они не используют никаких общих данных. Несмотря
на то что термины потокобезопасная и реентерабельная иногда (неверно) используют
как синонимы, между ними существует четкое разделение, которое следует учитывать.
На рис. 12.16 показано, как связаны потокобезопасные и потоконебезопасные функции. Множество реентерабельных функций – это подмножество потокобезопасных
функций.
Как правило, реентерабельные функции более эффективны, чем нереентерабельные
потокобезопасные функции, потому что не требуют операций синхронизации. Более
того, единственный способ преобразовать небезопасную функцию класса 2 в безопас-

12.6. Использование потоков выполнения для организации параллельной обработки  947
ную – переписать ее так, чтобы она стала реентерабельной. Например, в листинге 12.22
показана реентерабельная версия функции rand из листинга 12.20. Идея состоит в том,
чтобы заменить статическую переменную: next_seed замещается указателем, передаваемым вызывающей программой.
Листинг 12.22. rand_r: реентерабельная версия функции rand из листинга 12.20

code/conc/rand-r.c
1
2
3
4
5
6

/* rand_r – возвращает псевдослучайное целое число в диапазоне 0..32767 */
int rand_r(unsigned int *nextp)
{
*nextp = *nextp * 1103515245 + 12345;
return (unsigned int)(*nextp / 65536) % 32768;
}

code/conc/rand-r.c
All функции
functions
Все
Потокобезопасные
Thread-safe
функции
functions
Reentrant
Реентерабельные
functions
функции

Thread-unsafe
Потоконебезопасные
functions
функции

Рис. 12.16. Связь между множествами
реентерабельных, потокобезопасных
и потоконебезопасных функций

Возможно ли, изучив код некоторой функции, объявить ее явно реентерабельной?
К сожалению, этозависит от разных факторов. Если все аргументы функции передаются по значению (т. е. не по ссылкам) и все используемые данные хранятся в локальных
автоматических переменных в стеке (т. е. функция не обращается к статическим или
глобальным переменным), то такую функцию можно смело назвать реентерабельной –
ее реентерабельность можно подтвердить независимо от способа обращения к ней.
Однако если сделать допущения менее строгими и позволить передачу некоторых
параметров по ссылкам, то такая функция будет неявно реентерабельной, в том смысле, что реентерабельной она будет, только если потоки аккуратно передают указатели
на их локальные данные. К примеру, функция rand_r в листинге 12.22 является неявно
реентерабельной.
Используя термин реентерабельная в этой книге, мы всегда подразумеваем как явно,
так и неявно реентерабельные функции. Однако важно понимать, что реентерабельность иногда является свойством как вызывающей программы, так и вызываемой.
Упражнение 12.12 (решение в конце главы)
Функция ctime_ts в листинге 12.21 является потокобезопасной, но нереентерабельной.
Объясните почему.

12.7.3. Использование библиотечных функций
в многопоточных программах
Большинство функций Linux, включая функции из стандартной библиотеки С (malloc,
free, realloc, printf, scanf и др.), являются потокобезопасными. В табл. 12.3 перечислены основные исключения. (Полный список вы найдете в [110]). Функция strtok – уста-

ревшая (использовать ее не рекомендуется) и используется для парсинга строк. Функ-

948

 Глава 12. Конкурентное программирование

ции asctime, ctime и localtime обычно применяются для преобразования в/из разных
форматов времени и дат. Функции gethostbyname, gethostbyaddr и inet_ntoa – устаревшие функции, используемые в сетевом программировании, на смену которым пришли
реен­терабельные версии getaddrinfo, getnameinfo и inet_ntop (глава 11). За исключением
rand и strtok, все эти потоконебезопасные функции относятся к классу 3 и возвращают
указатель на статическую переменную. При необходимости использовать одну из этих
функций в многопоточной программе рекомендуется применять прием блокировки и
копирования. Однако этот прием имеет ряд недостатков. Во-первых, операции синхронизации замедляют выполнение программы. Во-вторых, функции, возвращающие указатели на сложные вложенные структуры, требуют глубокого копирования всей иерархии
структур, возвращаемой в результате. В-третьих, прием блокировки и копирования неприменим к потоконебезопасным функциям из класса 2, таким как rand, которые сохраняют свое состояние между вызовами.
Таблица 12.3. Некоторые потоконебезопасные библиотечные функции
Потоконебезопасная
функция

Класс потоконебезопасных
функций

Потокобезопасная версия
в Linux

rand

2

rand_r

strtok

2

strtok_r

asctime

3

asctime_r

ctime

3

ctime_r

gethostbyaddr

3

gethostbyaddr_r

gethostbyname

3

gethostbyname_r

inet_ntoa

3



localtime

3

localtime_r

По этим причинам в Linux реализованы реентерабельные версии большинства потоконебезопасных функций. Имена реентерабельных функций всегда оканчиваются на_г.
Например, реентерабельная версия asctime называется asctime_r. Мы советуем всегда
использовать реентерабельные версии.

12.7.4. Состояние гонки
Состояние гонки возникает, когда корректность программы зависит от соблюдения
условия, требующего, чтобы один поток достиг точки x в своем потоке управления раньше, чем другой поток достигнет точки y. Состояние гонки обычно возникает, когда программист предполагает, что потоки выберут какую-то особую траекторию в пространстве состояний, забывая правило, гласящее, что многопоточные программы должны
работать корректно при выборе любой вероятной траектории.
Природу состояния гонки проще понять на примере. Рассмотрим простую программу в листинге 12.23.
Листинг 12.23. Программа с состоянием гонки

code/conc/race.c
1
2
3
4
5
6

/* ВНИМАНИЕ: этот код содержит ошибку! */
#include "csapp.h"
#define N 4
void *thread(void *vargp);

12.6. Использование потоков выполнения для организации параллельной обработки  949
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

int main()
{
pthread_t tid[N];
int i;
for (i = 0; i < N; i++)
Pthread_create(&tid[i], NULL, thread, &i);
for (i = 0; i < N; i++)
Pthread_join(tid[i], NULL);
exit(0);
}
/* Процедура потока */
void *thread(void *vargp)
{
int myid = *((int *)vargp);
printf("Hello from thread %d\n", myid);
return NULL;
}

code/conc/race.c
Главный поток создает четыре дочерних потока и каждому передает указатель на
его уникальный целочисленный идентификатор. Каждый дочерний поток копирует
этот идентификатор в локальную переменную (строка 22) и выводит сообщение, содержащее идентификатор. Программа выглядит довольно простой, однако, запустив ее
у себя, мы получили некорректный результат:
linux> ./race
Hello from thread
Hello from thread
Hello from thread
Hello from thread

1
3
2
3

Проблема вызвана состоянием гонки между дочерними и главным потоками. Сможете заметить причину? Вот что происходит в действительности: когда главный поток
создает дочерний поток в строке 13, он передает указатель на локальную переменную i
в стеке. На этом этапе имеет место гонка между увеличением переменной i в строке 12
и разыменованием аргумента в строке 22. Если дочерний поток успеет выполнить строку 22 до того, как главный поток выполнит строку 12, то переменная myid получит корректный идентификатор. Иначе она будет содержать идентификатор какого-то другого
потока. Самое досадное в этой ситуации, что получение корректного ответа зависит от
особенностей планирования потоков в ядре. В системе, описанной в этой книге, мы не
смогли получить корректный ответ, однако в других системах такая программа иногда
может работать корректно, и программист окажется в блаженном неведении о наличии
в его продукте серьезной ошибки.
Чтобы избавиться от состояния гонки, можно динамически выделить отдельный
блок для каждого целочисленного идентификатора и передать указатель на блок в процедуру потока, как показано в листинге 12.24 (строки 12–14). Обратите внимание, что
процедура потока должна освобождать блок памяти, чтобы избежать утечек памяти.
Листинг 12.24. Корректная версия программы из листинга 12.23

code/conc/norace.c
1 #include "csapp.h"
2 #define N 4
3

950
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

 Глава 12. Конкурентное программирование
void *thread(void *vargp);
int main()
{
pthread_t tid[N];
int i, *ptr;
for (i = 0; i < N; i++) {
ptr = Malloc(sizeof(int));
*ptr = i;
Pthread_create(&tid[i], NULL, thread, ptr);
}
for (i = 0; i < N; i++)
Pthread_join(tid[i], NULL);
exit(0);
}
/* Процедура потока */
void *thread(void *vargp)
{
int myid = *((int *)vargp);
Free(vargp);
printf("Hello from thread %d\n", myid);
return NULL;
}

code/conc/norace.c
Запустив эту версию в своей системе, мы получили корректный результат:
linux> ./norace
Hello from thread
Hello from thread
Hello from thread
Hello from thread

0
1
2
3

Упражнение 12.13 (решение в конце главы)
В листинге 12.24 может возникнуть соблазн освободить выделенный блок памяти сразу
после строки 14 в главном потоке, а не в дочернем. Но это будет неправильно. Почему?

Упражнение 12.14 (решение в конце главы)
1. В листинге 12.24 мы устранили состояние гонки распределением отдельного блока
памяти для каждого целочисленного идентификатора. Предложите другой способ, при
котором не потребуется обращаться к функциям malloc и free.
2. Каковы преимущества и недостатки этого подхода?

12.7.5. Взаимоблокировка (тупиковые ситуации)
Семафоры потенциально могут привести к одной из ошибок времени выполнения,
называемой взаимоблокировкой, когда потоки блокируются, ожидая условия, которое
никогда не выполнится. Для понимания природы взаимоблокировок (тупиковых ситуа­

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

• программист некорректно упорядочил операции P и V так, что это привело к

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

• зона перекрытия запрещенных областей охватывает набор состояний, называ-

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

• взаимоблокировка – это особенно сложная проблема, потому что ее не всегда

можно спрогнозировать. Некоторые «удачные» траектории выполнения обогнут зону тупиковой ситуации; другим же не повезет. На рис. 12.17 есть примеры
обеих ситуаций. Последствия для программиста могут оказаться, мягко говоря,
не очень приятными. Программа может без проблем выполниться 1000 раз, а
на 1001-й возникнет тупиковая ситуация. Или программа будет прекрасно работать на одной машине, но «зависать» на другой. Хуже всего, что воспроизвес­
ти эту ошибку удается далеко не всегда, потому что разные выполнения имеют
разные траектории.
Thread
Поток 2 2

A trajectory
that doesбез
notвзаимоблокировки
deadlock
Траектория
выполнения
...

V(s)

...
Forbidden
Запрещенная
region
область
для s
for

V(t )

...
Forbidden
Запрещенная
область
region
дляtt
for

Deadlock
Состояние
взаиstate
моблокировки
d

P(s)
...

Deadlock
Зона
region
взаимоблокировки

P(t )
...

ПервонаInitially
чально
s1
s=1
t1
t=1

A trajectoryзаканчивающаяся
that deadlocks взаимоблокировкой
Траектория,
...

P(s)

...

P(t)

...

V(s)

...

V(t)

Поток 1 1
Thread

Рис. 12.17. Граф выполнения для программы с проблемой взаимоблокировки

952

 Глава 12. Конкурентное программирование

Программы попадают в тупиковые ситуации по многим причинам, и избежать их –
задача достаточно сложная. Впрочем, при использовании бинарных семафоров, как в
случае на рис. 12.19, можно применить следующее простое и эффективное правило:
Всегда захватывать и освобождать мьютексы в одном и том же порядке: если все
потоки в программе захватывают мьютексы всегда в одном и том же порядке и
освобождают их в обратном порядке, то состояние взаи­моблокировки в такой
программе не возникает.
Например, состояния взаимоблокировки, показанного на рис. 12.17, можно избежать, если потоки будут захватывать сначала мьютекс s, а затем t. На рис. 12.18 показан
получившийся в результате граф выполнения.
Thread
Поток 2 2
V(s)

...

Forbidden
Запрещенная
region
область
for
дляss

V(t )

...

Запрещенная
Forbidden
область
region
for t
для t

P(t )
...
P(s)
...

ПервонаInitially
чально
s1
s=1
t1
t=1

...

P(s)

...

P(t)

...

V(s)

...

V(t)

Thread
Поток
1 1

Рис. 12.18. Граф выполнения для программы, не страдающей проблемой взаимоблокировки
Упражнение 12.15 (решение в конце главы)
Взгляните на следующую программу, в которой используется пара семафоров для организации исключительного доступа к общим данным:
Первоначально: s = 1, t = 0.
Поток 1:
P(s);
V(s);
P(t);
V(t);

Поток 2:
P(s);
V(s);
P(t);
V(t);

1. Нарисуйте граф выполнения для этой программы.
2. Всегда ли будет возникать тупиковая ситуация?
3. Если да, то какое простое изменение первоначальных значений семафоров устранит
риск тупиковой ситуации?

12.8. Итоги  953
4. Нарисуйте граф выполнения для получившейся программы, не страдающей проб­лемой
взаимоблокировки.

12.8. Итоги
Конкурентная программа состоит из набора логических потоков управления, перекрывающихся во времени. В этой главе мы исследовали три разных механизма, используемых при построении конкурентных программ: процессы, мультиплексирование ввода/
вывода и потоки. На всем протяжении дискуссии в качестве основного примера использовался конкурентный сетевой сервер.
Процессы автоматически планируются ядром, и так как они имеют изолированные
адресные пространства, для применения общих данных им приходится использовать
механизмы межпроцессных взаимодействий (IPC). Событийно-ориентированные программы создают свои конкурентные потоки управления, основанные на модели конечных автоматов, а для явного плани­рования потоков используют мультиплексирование ввода/вывода. Так как такие программы выполняются в рамках одного процесса,
совместное использование данных между потоками управления происходит быстро
и просто. Подход на основе потоков выполнения являет собой симбиоз (гибрид) двух
предыдущих подходов. Подобно процессам, потоки выполнения автомати­чески планируются ядром. Подобно логическим потокам управления на основе мультиплексирования ввода/вывода, потоки выполнения действуют в контексте одного процесса и могут
быстро и просто использовать общие данные.
Независимо от механизма управления, синхронизация конкурентного доступа к общим данным представляет собой сложную проблему. В помощь нам были разработаны операции P и V с семафорами. Операции с семафорами можно использовать для
организации исключительного доступа к общим данным, а также для совместного использования таких ресурсов, как буферы в программах, построенных на основе модели
производитель–потребитель. Конкурентный эхо-сервер с предварительно подготовленными потоками выполнения предоставляет убедительный пример этих двух сценариев использования семафоров.
Конкуренция является источником многих других сложностей. Функции, вызываемые потоками, должны обладать свойством потокобезопасности. Мы определили четыре класса потоконебезопасных функций и рассмотрели предложения, реализация которых поможет добиться их безопасности в много­поточном окружении. Реентерабельные
функции – подмножество потокобезопасных функций, не имеющих доступа к каким бы
то ни было общим данным. Реентерабельные функции часто более эффективны, чем
нереентерабельные, потому что не требуют синхронизации. В числе других сложностей,
возникающих в конкурентных программах, можно назвать состояния гонки и взаимоблокировки. Состояние гонки возникает, когда программист делает некорректные
допущения о том, как ядро планирует потоки выполнения. Взаи­моблокировки возникают, когда поток ожидает события, которое никогда не произойдет.

Библиографические заметки
Операции с семафорами предложены Дейкстрой (Dijkstra) [31]. Концепция графов
выполнения представлена Коффманом (Coffman) [23] и позже формализована Карсоном
(Carson) и Рейнольдсом (Reynolds) [16]. Проблема читателей–писателей описана Куртуа
(Courtois) с коллегами в [25]. В книгах об операционных системах подробно описывают
классические проблемы синхронизации, такие как проблемы обедающих философов,
спящего парикмахера и курильщика [102, 106, 113]. Книга Бутенхофа (Butenhof) [15]
включает подробное описание интерфейса переносимой операционной системы

954

 Глава 12. Конкурентное программирование

(POSIX). Статья Биррелла (Birrell) [90] – прекрасное введение в многопоточное программирование и его проблемы. В книге Рейндерса (Reinders) [90] описывается библиотека
C/C++, упрощающая проектирование и реализацию многопоточных программ. Есть
книги, охватывающие основы параллельного программирования на многоядерных системах [47, 71]. Пью (Pugh) описывает слабые стороны методов взаимодействий между
потоками выполнения Java с использованием памяти и предлагает модели замещения
памяти [88]. Густафсон (Gustafson) предложил модель ускорения слабого масштабирования [43] в качестве альтернативы строгому масштабированию.

Домашние задания
Упражнение 12.16 
Напишите версию программы hello.с, создающую и утилизирующую n присоединяемых дочерних потоков, где n – аргумент командной строки.

Упражнение 12.17 
1. В листинге 12.25 есть ошибка. Предполагается, что поток приостанавливается
на 1 секунду, а затем выводит строку. Однако когда мы запустили эту программу
в своей системе, она ничего не вывела. Почему?
2. Эту ошибку можно исправить, заменив функцию exit в строке 10 одним из двух
разных вызовов функций Pthreads. Что это за функции?
Листинг 12.25. Программа с ошибкой для упражнения 12.17

code/conc/hellobug.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

/* ВНИМАНИЕ: этот код содержит ошибку! */
#include "csapp.h"
void *thread(void *vargp);
int main()
{
pthread_t tid;
Pthread_create(&tid, NULL, thread, NULL);
exit(0);
}
/* Процедура потока */
void *thread(void *vargp)
{
Sleep(1);
printf("Hello, world!\n");
return NULL;
}

code/conc/hellobug.c

Упражнение 12.18 
Используя граф выполнения на рис. 12.10, классифицируйте следующие траектории
как безопасные или небезопасные:
1. H2, L2, U2, H1, L1, S2, U1, S1, T1, T2
2. H2, H1, L1, U1, S1, L2, T1, U2, S2, T2
3. H1, L1, H2, L2, U2, S2, U1, S1, T1, T2

12.8. Итоги  955

Упражнение 12.19 
Решение первой проблемы читателей–писателей в листинге 12.13 отдает слабое
предпочтение потокам-читателям, потому что поток-писатель, покидающий свою
критическую секцию, может возобновить ожидающий поток-писатель вместо потока-читателя. Найдите решение, отдающее предпочтение потокам-читателям, чтобы
поток-писатель, покидающий свою критическую секцию, всегда возобновлял ожидающий поток-читатель, если он существует.

Упражнение 12.20 
Рассмотрим более простой вариант задачи о читателях–писателях, когда имеется не
более N потоков-читателей. Найдите решение, отдающее равное предпочтение потокам-читателям и потокам-писателям, в том смысле, что ожидающие потоки-читатели
и потоки-писатели имеют равные шансы на получение доступа к ресурсу. Подсказка:
эту задачу можно решить, используя один счетный семафор и один мьютекс.

Упражнение 12.21 
Найдите решение второй проблемы читателей–писателей, когда предпочтение отдается потокам-писателям, а не читателям.

Упражнение 12.22 
Проверьте свое понимание функции select, изменив сервер в листинге 12.2 так, чтобы он отображал не более одной текстовой строки в каждой итерации цикла сервера.

Упражнение 12.23 
Событийно-ориентированный конкурентный эхо-сервер в листинге 12.3 имеет уязвимость, позволяющую клиенту-злоумышленнику вызвать отказ в обслуживании других клиентов отправкой неполной текстовой строки. Напишите усовершенствованную
версию сервера, который смог бы обрабатывать такие неполные текстовые строки без
блокировки.

Упражнение 12.24 
Функции ввода/вывода в пакете RIO (раздел 10.5) безопасны в многопоточном окружении. Являются ли они также реентерабельными?

Упражнение 12.25 
В конкурентном эхо-сервере с предварительно созданным пулом потоков каждый
поток вызывает функцию echo_cnt (листинг 12.15). Является ли функция echo_cnt потокобезопасной? Является ли она реентерабельной? Почему да или почему нет?

Упражнение 12.26 
Используйте прием блокировки и копирования для реализации потокобезопасной
нереентерабельной версии gethostbyname с именем gethostbyname_ts. Правильное решение должно выполнять глубокое копирование структуры hostent под защитой мьютекса.

Упражнение 12.27 
В некоторых руководствах по сетевому программированию предлагается следующий подход для реализации чтения и записи с сокетами: перед взаимодействием с клиентом откройте два стандартных потока ввода/вывода на одном дескрипторе сокета
соединения: один – для чтения, а другой – для записи:

956

 Глава 12. Конкурентное программирование

FILE *fpin, *fpout;
fpin = fdopen(sockfd, "r");
fpout = fdopen(sockfd, "w");

Когда сервер закончит взаимодействие с клиентом, закройте оба потока:
fclose(fpin);
fclose(fpout);

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

Упражнение 12.28 

Обусловит перемена мест двух операций V, показанных на рис. 12.18, появление тупиковых ситуаций в программе или нет? Обоснуйте ответ изображением графа выполнения для четырех возможных случаев:
Случай 1
Поток 1
Поток 2
P(s)
P(s)
P(t)
P(t)
V(s)
V(s)
V(t)
V(t)

Случай 2
Поток 1
Поток 2
P(s)
P(s)
P(t)
P(t)
V(s)
V(t)
V(t)
V(s)

Случай 3
Поток 1
Поток 2
P(s)
P(s)
P(t)
P(t)
V(t)
V(s)
V(s)
V(t)

Случай 4
Поток 1
Поток 2
P(s)
P(s)
P(t)
P(t)
V(t)
V(t)
V(s)
V(s)

Упражнение 12.29 

Может ли следующая программа попасть в тупиковую ситуацию? Почему да или почему нет?
Первоначально: a = 1, b = 1, c = 1.
Поток 1:
P(a);
P(b);
V(b);
P(c);
V(c);
V(a);

Поток 2:
P(c);
P(b);
V(b);
V(c);

Упражнение 12.30 

Следующая программа может попасть в тупиковую ситуацию.
Первоначально: a = 1, b = 1, c = 1.
Поток 1:
P(a);
P(b);
V(b);
P(c);
V(c);
V(a);

Поток 2:
P(c);
P(b);
V(b);
V(c);
P(a);
V(a);

Поток 3:
P(c);
V(c);
P(b);
P(a);
V(a);
V(b);

1. Перечислите, какие пары мьютексов каждый поток может удерживать одновременно.
2. Если a < b < c, то какой поток нарушает правило захвата мьютексов в определенном порядке?
3. Покажите для данных потоков новый порядок блокировки, который гарантирует отсутствие тупиковых ситуаций.

12.8. Итоги  957

Упражнение 12.31 
Реализуйте tfgets, версию стандартной функции fgets, которая автоматически возвращает NULL, если не получит строку из стандартного ввода в течение 5 секунд. Функция должна быть реализована в пакете с названием tfgets-proc.c и использовать процессы, сигналы и нелокальные переходы. Она не должна использовать функцию alarm.
Протестируйте решение, используя программу в листинге 12.26.
Листинг 12.26. Тестовая программа для упражнений 12.31–12.33

code/conc/tfgets-main.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

#include "csapp.h"
char *tfgets(char *s, int size, FILE *stream);
int main()
{
char buf[MAXLINE];
if (tfgets(buf, MAXLINE, stdin) == NULL)
printf("BOOM!\n");
else
printf("%s", buf);
exit(0);
}

code/conc/tfgets-main.c

Упражнение 12.32 
Реализуйте версию функции tfgets из упражнения 12.31, которая использует функцию
select. Функция должна быть реализована в пакете с названием tfgets-select.c. Протес­

тируйте решение, используя программу в листинге 12.26. Допус­кается предположить, что
стандартный ввод связан с дескриптором 0.

Упражнение 12.33 
Реализуйте многопоточную версию функции tfgets из упражнения 12.31. Функция
должна быть реализована в пакете с названием tfgets-thread.c. Протестируйте решение, используя программу в листинге 12.26.

Упражнение 12.34 
Напишите параллельную многопоточную версию функции умножения мат­риц N×M.
Сравните ее производительность с производительностью последовательной версии.

Упражнение 12.35 
Реализуйте конкурентную версию веб-сервера TINY на основе процессов. Ваше решение должно создавать новый дочерний процесс для обработки каждого нового запроса на соединение. Протестируйте решение с помощью настоящего веб-браузера.

Упражнение 12.36 
Реализуйте конкурентную версию веб-сервера TINY на основе мультиплексирования
ввода/вывода. Протестируйте решение с помощью настоящего веб-браузера.

958

 Глава 12. Конкурентное программирование

Упражнение 12.37 
Реализуйте конкурентную версию веб-сервера TINY на основе потоков. Ваше решение должно создавать новый дочерний поток для обработки каждого нового запроса на
соединение. Протестируйте решение с помощью настоящего веб-браузера.

Упражнение 12.38 
Реализуйте конкурентную версию веб-сервера TINY с предварительно созданным
пулом потоков. Ваше решение должно динамически увеличивать или уменьшать количество потоков в пуле в зависимости от текущей нагрузки. Одна из возможных стратегий – удвоение числа потоков при заполнении буфера дескрипторов и сокращение
наполовину при опустошении буфера. Протестируйте решение с помощью настоящего
веб-браузера.

Упражнение 12.39 
Веб-прокси – это программа, играющая роль посредника между веб-сервером и браузером. В этой схеме браузер контактирует не с веб-сервером, а с веб-прокси, который
пересылает запрос серверу. Сервер возвращает ответ веб-прокси, а тот пересылает его
браузеру. Напишите простой веб-прокси, поддерживающий возможность фильтрации
и регистрации запросов.
1. Для начала реализуйте веб-прокси, который принимает запросы, анализирует их, пересылает серверу и возвращает результаты браузеру. Прокси должен
регист­рировать URL всех запросов в системном журнале на диске, а также блокировать запросы любых URL, содержащихся в файле фильтра на диске.
2. Затем добавьте в прокси возможность обслуживать одновременно множество
запросов путем создания отдельного потока для каждого запроса. Ожидая ответа от удаленного сервера, прокси должен продолжать обрабатывать незавершенные запросы от других браузеров.
Проверьте свою реализацию с настоящим веб-сервером.

Решения упражнений
Решение упражнения 12.1
Когда родительский процесс создает новый дочерний процесс, тот получает копию
дескриптора сокета открытого соединения и счетчик ссылок на соответствующую
запи­сь в таблице файлов увеличивается с 1 до 2. Когда родитель закрывает свою копию дескриптора, количество ссылок уменьшается с 2 до 1. Поскольку ядро не закрывает файлы, пока счетчик ссылок в таблице открытых файлов не уменьшится до нуля,
дескрип­тор в дочернем процессе остается открытым.

Решение упражнения 12.2
Когда по какой-либо причине процесс завершается, ядро закрывает все открытые им
дескрипторы. Поэтому копия дескриптора в дочернем процессе будет закрыта автоматически по его завершении.

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

12.8. Итоги  959
чае вернет нулевой результат, сообщающий о достижении конца файла. Таким образом,
ввод Ctrl+D заставляет функцию select вернуть дескриптор 0 в множестве дескрипторов, готовых к чтению.

Решение упражнения 12.4
Переменная pool.ready_set повторно инициализируется перед каждым вызовом
select, потому что она служит и входным, и выходным аргументом. На входе в ней
передается множество дескрипторов для наблюдения, а на выходе – множество де­
скрипторов, готовых к чтению.

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

Решение упражнения 12.6
Основная идея в том, что переменные на стеке являются локальными для потока,
тогда как глобальные и статические переменные являются общими. Такие статичес­
кие переменные, как cnt, довольно коварны, потому что сов­местное использование их
ограничено рамками функций, где они объявлены, в данном случае – рамками процедуры потоков.
1. Вот заполненная таблица:
Экземпляр
переменной
ptr
cnt
i.m
msgs.m
myid.p0
myid.p1

главному потоку
да
нет
да
да
нет
нет

Доступен
дочернему потоку 0
да
да
нет
да
да
нет

дочернему потоку 1
да
да
нет
да
нет
да

Дополнительные примечания:
ptr – глобальная переменная, изменяется главным потоком и читается до­

черними потоками;

cnt – статическая переменная с единственным экземпляром в памяти; ис­

пользуется для чтения и записи обоими дочерними потоками;

i.m – локальная автоматическая переменная, хранящаяся в стеке главного

потока. Несмотря на то что ее значение передается дочерним потокам,
они никогда не обращаются к ней напрямую, и, следовательно, она не
является совместно используемой;
msgs.m – локальная автоматическая переменная, хранящаяся в стеке главного
потока. К ней косвенно обращаются оба дочерних потока через ptr;
myid.p0 и myid.p1 – экземпляры локальной автоматической переменной, хра­
нящиеся в стеках дочерних потоков 0 и 1 соответственно.
2. Переменные ptr, cnt и msgs используются несколькими потоками, следовательно, они являются совместно используемыми.

960

 Глава 12. Конкурентное программирование

Решение упражнения 12.7
Важно помнить, что нельзя делать никаких предположений о том, в каком порядке
ядро будет планировать потоки.
Шаг
1
2
3
4
5
6
7
8
9
10

Поток
1
1
2
2
2
2
1
1
1
2

Инстр.
H1
L1
H2
L2
U2
S2
U1
S1
T1
T2

%rdx1

0




1
1
1


%rdx2



0
1
1



1

cnt
0
0
0
0
0
1
1
1
1
1

В конце переменная cnt получит некорректное значение 1.

Решение упражнения 12.8
Это упражнение поможет вам проверить правильность понимания безопасных и небезопасных траекторий в графе выполнения. Траектории, такие как 1 и 3, огибающие
критические области, безопасны и приводят к корректным результатам.
1. H1, L1, U1, S1, H2, L2, U2, S2, T2, T1

безопасная

2. H2, L2, H1, L1, U1, S1, T1, U2, S2, T2

небезопасная

3. H1, H2, L2, U2, S2, L1, U1, S1, T1, T2

безопасная

Решение упражнения 12.9
1. p = 1, c = 1, n > 1: да, мьютекс необходим, потому что производитель и потребитель могут конкурентно обращаться к буферу.
2. p = 1, c = 1, n = 1: нет, мьютекс не нужен, потому что непустой буфер эквивалентен полному буферу. Когда буфер содержит элемент, производитель блокируется. Когда буфер пуст, блокируется потребитель. То есть в любой момент времени
только один поток имеет доступ к буферу и исключительность доступа гарантируется без использования мьютекса.
3. p >1, c > 1, n = 1: нет, мьютекс не нужен, потому что в этом случае применимы те
же аргументы, что и в предыдущем.

Решение упражнения 12.10
Предположим, что конкретная реализация семафора использует стек потоков LIFO,
ожидающих его освобождения. Когда поток блокируется на семафоре в операции P, его
идентификатор помещается в стек. Точно так же операция V извлекает идентификатор
верхнего потока из стека и возобновляет этот поток. При такой реализации с использованием стека злонамеренный поток-писа­тель сможет в своей критической секции
просто подождать, пока появится другой поток-писатель, ожидающий освобождения
семафора, и затем освободить этот семафор. В этом сценарии ожидающий поток-читатель может ждать вечно, пока два писателя передают управление туда-сюда.
Обратите внимание: использование очереди FIFO вместо стека LIFO может показаться более логичным, и все же применение такого стека не является неправильным и
не нарушает семантику операций P и V.

12.8. Итоги  961

Решение упражнения 12.11
Это упражнение поможет вам проверить свое понимание ускорения и эффективности параллельных вычислений.
Потоки (t)
Ядра (p)
Время выполнения (Tp)
Ускорение (Sp)
Эффективность (Ep)

1
1
12
1
100 %

2
2
8
1.5
75 %

4
4
6
2
50 %

Решение упражнения 12.12
Функция ctime_ts не является реентерабельной, потому что каждый вызов использует одну и ту же статическую переменную, возвращаемую функцией ctime. Однако она
является потокобезопасной, потому что доступ к общей переменной защищен операциями P и V, следовательно, каждый вызов имеет исключительный доступ.

Решение упражнения 12.13
Если освободить блок сразу после обращения к pthread_create в строке 14, то возникнет новое состояние гонки, на этот раз между вызовом free в главном потоке и оператором присваивания в строке 24 в процедуре потока.

Решение упражнения 12.14
1. Другое решение: передача целого числа i напрямую вместо передачи указателя
на i:
for (i = 0; i < N; i++)
Pthread_create(&tid[i], NULL, thread, (void *)i);

В процедуре потока аргумент приводится обратно к типу int и присваи­вается
переменной myid:
int myid = (int) vargp;

2. Преимуществом является уменьшение накладных расходов на вызовы malloc и
free. Недостаток – предположение, что указатели по крайней мере не меньше
типа int. Это предположение верно для всех современных систем, но в будущем
оно может стать ложным.

Решение упражнения 12.15
1. Граф выполнения для оригинальной программы показан на рис. 12.19.
2. Программа всегда попадает в тупиковые ситуации, потому что любая возможная траектория, в конце концов, окажется в состоянии взаимо­блокировки.
3. Для устранения потенциальной взаимоблокировки инициализируйте бинарный семафор t значением 1 вместо 0.
4. Граф выполнения для исправленной программы показан на рис. 12.20.

 Глава 12. Конкурентное программирование
Поток 22
Thread

...

962

V(t)

...

Forbidden
Запрещенная
region
область
for tt
для

...

P(t)

...
V(s)

Запрещенная
Forbidden
область
region
для tfor t

...

Forbidden
Запрещенная
region
область
for ss
для

P(s)
...

ПервонаInitially
чально
s1
s=1
tt0
=1

...

P(s)

...

V(s)

...

P(t)

...

V(t)

Thread
Поток
11

Рис. 12.19. Граф выполнения для программы, страдающей
проблемой взаимоблокировки
Thread
Поток 22

V(t)

...

Forbidden
Запрещенная
region
область
for tt
для

P(t)

...
V(s)
...

Forbidden
Запрещенная
region
область
for s
для
s

P(s)
...

ПервонаInitially
чально
s1
s=1
tt1
=1

...

P(s)

...

V(s)

...

P(t)

...

V(t)

Поток
11
Thread

Рис. 12.20. Граф выполнения для исправленной программы,
не страдающей проблемой взаимоблокировки

Приложение

А
Обработка ошибок

П

рограммисты должны всегда проверять коды ошибок, возвращаемые функциями
системного уровня. Всегда есть вероятность, что что-то пойдет не так, и имеет
смысл использовать информацию о состоянии, передаваемую ядром. К сожалению,
разработчики часто пренебрегают проверкой ошибок, потому что эти проверки загромождают код, превращая, например, одну строку кода в многострочный условный
оператор. Проверка ошибок также вносит в программу определенного рода путаницу,
потому что разные функции по-разному сообщают об ошибках.
Работая над этой книгой, мы постоянно сталкивались с подобными проблемами.
С одной стороны, хотелось бы, чтобы примеры были краткими и прос­тыми, а с другой –
не хотелось насаждать ложное мнение о том, что проверять наличие ошибок не нужно
вообще. Для решения этих проблем на вооружение был принят принцип, основанный
на использовании оберток, реализующих обработку ошибок, впервые предложенный
Ричардом Стивенсом (Richard Stevens) в его работе, посвященной сетевому программированию [110].
Идея заключается в том, что при наличии некоторой системной функции foo определяется функция-обертка Foo с теми же аргументами, которая вызывает системную
функцию и осуществляет проверку ошибок. При выявлении ошибки обертка выводит информативное сообщение и прерывает выполнение процесса. В противном
случае возвращает управление вызывающей программе. Обратите внимание, что в
отсутствие ошибок поведение обертки ничем не отличается от поведения системной функции. И наоборот, если программа выполняется корректно с обертками, то
она будет выполняться корректно, если вместо оберток использовать системные
функции.
Обертки упакованы в один файл (csapp.c), который компилируется и связывается
с каждой программой. Отдельный заголовочный файл (csapp.h) содержит прототипы
функций-оберток.
В данном приложении представлено руководство по разным способам обработки ошибок в системах Unix, а также примеры различных стилей оформления оберток с обработкой ошибок. Копии файлов csapp.h и csapp.c вы найдете на веб-сайте
CS:APP.

A.1. Обработка ошибок в системе Unix
Для вызова системных функций, с которыми можно столкнуться в этой книге, используются три разных стиля обработки ошибок: Unix, Posix и GAI.

964

 Приложение А. Обработка ошибок

Обработка ошибок в стиле Unix
Такие функции, как fork и wait, разработанные на заре появления систем Unix (как и
некоторые старые функции Posix), перегружают возвращаемые значения кодами ошибок и полезными результатами. Например, когда функция wait сталкивается с ошибкой
(например, отсутствие дочернего процесса), она возвращает –1 и записывает код ошибки в глобальную переменную errno. Если функция wait завершается успехом, то возвращается полезный результат – идентификатор утилизированного дочернего процесса.
Обработка ошибок в стиле Unix обычно выполняется так:
1 if ((pid = wait(NULL)) < 0) {
2
fprintf(stderr, "wait error: %s\n", strerror(errno));
3
exit(0);
4 }

Функция strerror возвращает текстовую строку с описанием конкретного значения
в errno.

Обработка ошибок в стиле Posix
Многие из новейших функций Posix, такие как функции из пакета Pthreads, возвращают только признак успешности (0) или неудачи (ненулевое значение) выполнения.
Все полезные результаты возвращаются в аргументах, передаваемых по ссылке. Данный подход называется обработкой ошибок в стиле Posix. Например, Posix-функция
pthread_create указывает в возвращаемом значении признак успеха или неудачи, а
идентификатор созданного потока (полезный результат) – в первом аргументе. Обработка ошибок в стиле Posix обычно выполняется так:
1 if ((retcode = pthread_create(&tid, NULL, thread, NULL)) != 0) {
2
fprintf(stderr, "pthread_create error: %s\n", strerror(retcode));
3
exit(0);
4 }

Функция strerror возвращает текстовую строку с описанием конкретного значения
в retcode.

Обработка ошибок в стиле GAI
Функции getaddrinfo (GAI) и getnameinfo возвращают ноль в случае успеха или ненулевое значение в случае ошибки. Обработка ошибок в стиле GAI обычно выполняется
так:
1 if ((retcode = getaddrinfo(host, service, &hints, &result)) != 0) {
2
fprintf(stderr, "getaddrinfo error: %s\n", gai_strerror(retcode));
3
exit(0);
4 }

Функция gai_strerror возвращает текстовую строку с описанием конкретного значения в retcode.

Функции получения текстовых описаний ошибок
На протяжении всей книги для представления различных стилей обработки ошибок
используются следующие функции, возвращающие текстовые описания ошибок:

A.2. Функции-обертки обработки ошибок  965
#include "csapp.h"
void
void
void
void

unix_error(char *msg);
posix_error(int code, char *msg);
gai_error(int code, char *msg);
app_error(char *msg);

Ничего не возвращают
По именам функций unix_error, posix_error и gai_error видно, что они выводят сообщение об ошибке в стиле Unix, Posix и GAI соответственно, после чего завершают процесс. Функция app_error включена для удобства обнаружения ошибок на прикладном
уровне. Она просто выводит входную строку и завершает процесс. Реализации всех этих
функций показаны в листинге A.1.
Листинг A.1. Функции вывода сообщений об ошибках

code/src/csapp.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

void unix_error(char *msg) /* Для обработки ошибок в стиле Unix */
{
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(0);
}
void posix_error(int code, char *msg) /* Для обработки ошибок в стиле Posix */
{
fprintf(stderr, "%s: %s\n", msg, strerror(code));
exit(0);
}
void gai_error(int code, char *msg) /* Для обработки ошибок в стиле Getaddrinfo */
{
fprintf(stderr, "%s: %s\n", msg, gai_strerror(code));
exit(0);
}
void app_error(char *msg) /* Для обработки ошибок на прикладном уровне */
{
fprintf(stderr, "%s\n", msg);
exit(0);
}

code/src/csapp.c

A.2. Функции-обертки обработки ошибок
Ниже приводится несколько примеров различных функций-оберток обработки ошибок.

• Обертки обработки ошибок в стиле Unix. В листинге A.2 представлена функ­
ция-обертка обработки ошибок в стиле Unix для функции wait. Если wait возвращает признак ошибки, то обертка выводит информативное сообщение и
завершает процесс. В противном случае вызывающей программе возвращается PID.

966

 Приложение А. Обработка ошибок

Листинг A.2. Обертка обработки ошибок в стиле Unix для wait

code/src/csapp.c
1 pid_t Wait(int *status)
2 {
3
pid_t pid;
4
5
if ((pid = wait(status)) < 0)
6
unix_error("Wait error");
7
return pid;
8 }

code/src/csapp.c
В листинге A.3 представлена функция-обертка обработки ошибок в стиле Unix
для функции kill. Обратите внимание, что в отличие от wait эта функция возвращает void в случае успеха.

Листинг A.3. Обертка обработки ошибок в стиле Unix для kill

code/src/csapp.c
1 void Kill(pid_t pid, int signum)
2 {
3
int rc;
4
5
if ((rc = kill(pid, signum)) < 0)
6
unix_error("Kill error");
7 }

code/src/csapp.c

• Обертки обработки ошибок в стиле Posix. В листинге A.4 представлена функ­ция-

обертка обработки ошибок в стиле Posix для функции pthread_detach. Подобно
большинству функций Posix, она не перегружает возвращаемое значение кодами ошибок, поэтому при успешном выполнении функция-обертка возвращает void.

Листинг A.4. Обертка обработки ошибок в стиле Posix для pthread_detach

code/src/csapp.c
1 void Pthread_detach(pthread_t tid) {
2
int rc;
3
4
if ((rc = pthread_detach(tid)) != 0)
5
posix_error(rc, "Pthread_detach error");
6 }

code/src/csapp.c

• Обертки обработки ошибок в стиле GAI. В листинге A.5 представлена функ­цияобертка обработки ошибок в стиле GAI для функции getaddrinfo.

A.2. Функции-обертки обработки ошибок  967
Листинг A.5. Обертка обработки ошибок в стиле GAI для getaddrinfo

code/src/csapp.c
1
2
3
4
5
6
7
8

void Getaddrinfo(const char *node, const char *service,
const struct addrinfo *hints, struct addrinfo **res)
{
int rc;
if ((rc = getaddrinfo(node, service, hints, res)) != 0)
gai_error(rc, "Getaddrinfo error");
}

code/src/csapp.c

Библиография

1

[1]

Advanced Micro Devices, Inc. Software Optimization Guide for AMD64 Processors, 2005.
Publication Number 25112.

[2]

Advanced Micro Devices, Inc. AMD64 Architecture Programmer’s Manual, Volume 1:
Application Programming, 2013. Publication Number 24592.

[3]

Advanced Micro Devices, Inc. AMD64 Architecture Programmer’s Manual, Volume 3:
General-Purpose and System Instructions, 2013. Publication Number 24594.

[4]

Advanced Micro Devices, Inc. AMD64 Architecture Programmer’s Manual, Volume 4:
128-Bit and 256-Bit Media Instructions, 2013. Publication Number 26568.

[5]

K. Arnold, J. Gosling, and D. Holmes. The Java Programming Language, Fourth Edition.
Prentice Hall, 20051.

[6]

T. Berners-Lee, R. Fielding, and H. Frystyk. Hypertext transfer protocol – HTTP/1.0.
RFC 1945, 1996.

[7]

A. Birrell. An introduction to programming with threads. Technical Report 35, Digital
Systems Research Center, 1989.

[8]

A. Birrell, M. Isard, C. Thacker, and T. Wobber. A design for high-performance flash
disks. SIGOPS Operating Systems Review 41 (2): 88–93, 2007.

[9]

G. E. Blelloch, J. T. Fineman, P. B. Gibbons, and H. V. Simhadri. Scheduling irregular
parallel computations on hierarchical caches. In Proceedings of the 23rd Symposium on
Parallelism in Algorithms and Architectures (SPAA), pages 355–366. ACM, June 2011.

[10]

S. Borkar. Thousand core chips: A technology perspective. In Proceedings of the 44th
Design Automation Conference, pages 746–749. ACM, 2007.

[11]

D. Bovet and M. Cesati. Understanding the Linux Kernel, Third Edition. O’Reilly Media,
Inc., 2005.

[12]

A. Demke Brown and T. Mowry. Taming the memory hogs: Using compiler-inserted
releases to manage physical memory intelligently. In Proceedings of the 4th Symposium
on Operating Systems Design and Implementation (OSDI), pages 31–44. Usenix, October
2000.

[13]

R. E. Bryant. Term-level verification of a pipelined CISC microprocessor. Technical Report CMU-CS-05-195, Carnegie Mellon University, School of Compu­ter Science, 2005.

[14]

R. E. Bryant and D. R. O’Hallaron. Introducing computer systems from a programmer’s
perspective. In Proceedings of the Technical Symposium on Computer Science Education
(SIGCSE), pages90–94. ACM, February 2001.

[15]

D. Butenhof. Programming with Posix Threads. Addison-Wesley, 1997.

[16]

S. Carson and P. Reynolds. The geometry of semaphore programs. ACM Transactions on
Programming Languages and Systems 9 (1): 25–53, 1987.

[17]

J. B. Carter, W. C. Hsieh, L. B. Stoller, M. R. Swanson, L. Zhang, E. L. Brunvand, A. Davis,
C.-C. Kuo, R. Kuramkote, M. A. Parker, L. Schaelicke, and T. Tateyama. Impulse: Buil­
ding a smarter memory controller. In Proceedings of the 5th International Symposium on
High Performance Computer Architecture (HPCA), pages 70–79. ACM, January 1999.

Джеймс Гослинг, Билл Джой, Язык программирования Java SE 8, Вильямс, 2015, ISBN: 978-5-84591875-8, 978-0-13-390069-9. – Прим. перев.

Библиография  969
[18]

K. Chang, D. Lee, Z. Chishti, A. Alameldeen, C. Wilkerson, Y. Kim, and O. Mutlu. Improving DRAM performance by parallelizing refreshes with accesses. In Proceedings of
the 20th International Symposium on High-Performance Computer Architecture (HPCA).
ACM, February 2014.

[19]

S. Chellappa, F. Franchetti, and M. Püschel. How to write fast numerical code: A small
introduction. In Generative and Transformational Techniques in Software Engineering II,
volume 5235 of Lecture Notes in Computer Science, pages 196–259. Springer-Verlag,
2008.

[20]

P. Chen, E. Lee, G. Gibson, R. Katz, and D. Patterson. RAID: High-performance, reliable
secondary storage. ACM Computing Surveys 26 (2): 145–185, June 1994.

[21]

S. Chen, P. Gibbons, and T. Mowry. Improving index performance through prefetching.
In Proceedings of the 2001 ACM SIGMOD International Conference on Management of
Data, pages 235–246. ACM, May 2001.

[22]

T. Chilimbi, M. Hill, and J. Larus. Cache-conscious structure layout. In Proceedings of
the 1999 ACM Conference on Programming Language Design and Implementation (PLDI),
pages 1–12. ACM, May 1999.

[23]

E. Coffman, M. Elphick, and A. Shoshani. System deadlocks. ACM Computing Surveys
3 (2): 67–78, June 1971.

[24]

D. Cohen. On holy wars and a plea for peace. IEEE Computer 14 (10): 48–54, October
1981.

[25]

P. J. Courtois, F. Heymans, and D. L. Parnas. Concurrent control with “readers” and
“writers”. Communications of the ACM 14 (10): 667–668, 1971.

[26]

C. Cowan, P. Wagle, C. Pu, S. Beattie, and J. Walpole. Buffer overflows: Attacks and defenses for the vulnerability of the decade. In DARPA Information Survivability Conference
and Expo (DISCEX), volume 2, pages 119–129, March 2000.

[27]

J. H. Crawford. The i486 CPU: Executing instructions in one clock cycle. IEEE Micro
10 (1): 27–36, February 1990.

[28]

V. Cuppu, B. Jacob, B. Davis, and T. Mudge. A performance comparison of contemporary
DRAM architectures. In Proceedings of the 26th International Symposium on Computer
Architecture (ISCA), pages 222–233, ACM, 1999.

[29]

B. Davis, B. Jacob, and T. Mudge. The new DRAM interfaces: SDRAM, RDRAM, and variants. In Proceedings of the 3rd International Symposium on High Performance Computing
(ISHPC), volume 1940 of Lecture Notes in Computer Science, pages 26–31. Springer-Verlag, October 2000.

[30]

E. Demaine. Cache-oblivious algorithms and data structures. In Lecture Notes from
the EEF Summer School on Massive Data Sets. BRICS, University of Aarhus, Denmark,
2002.

[31]

E. W. Dijkstra. Cooperating sequential processes. Technical Report EWD-123, Technological University, Eindhoven, the Netherlands, 1965.

[32]

C. Ding and K. Kennedy. Improving cache performance of dynamic applications
through data and computation reorganizations at run time. In Proceedings of the 1999
ACM Conference on Programming Language Design and Implementation (PLDI), pages 229–241. ACM, May 1999.

[33]

M. Dowson. The Ariane 5 software failure. SIGSOFT Software Engineering Notes 22 (2):
84, 1997.

[34]

U. Drepper. User-level IPv6 programming introduction. Документ доступен по адресу http://www.akkadia.org/drepper/userapi-ipv6.html, 2008.

970
[35]

[36]

ELF-64 Object File Format, Version 1.5 Draft 2, 1998. Документ доступен по адресу

http://www.uclibc.org/docs/elf-64-gen.pdf.

R. Fielding, J. Gettys, J. Mogul, H. Frystyk, L. Masinter, P. Leach, and T. Ber­ners-Lee.
Hypertext transfer protocol – HTTP/1.1. RFC 2616, 1999.

[38]

M. Frigo, C. E. Leiserson, H. Prokop, and S. Ramachandran. Cache-oblivious algorithms.
In Proceedings of the 40th IEEE Symposium on Foundations of Computer Science (FOCS),
pages 285–297. IEEE, August 1999.

[39]

M. Frigo and V. Strumpen. The cache complexity of multithreaded cache oblivious algorithms. In Proceedings of the 18th Symposium on Parallelism in Algorithms and Architectures (SPAA), pages 271–280. ACM, 2006.

[40]

G. Gibson, D. Nagle, K. Amiri, J. Butler, F. Chang, H. Gobioff, C. Hardin, E. Riedel,
D. Rochberg, and J. Zelenka. A cost-effective, high-bandwidth storage architecture. In
Proceedings of the 8th International Conference on Architectural Support for Programming
Languages and Operating Systems (ASPLOS), pages 92–103. ACM, October 1998.

[41]

G. Gibson and R. Van Meter. Network attached storage architecture. Communications of
the ACM 43 (11): 37–45, November 2000.
Google. IPv6 Adoption. Документ доступен по адресу http://www.google.com/intl/en/
ipv6/statistics.html.
J. Gustafson. Reevaluating Amdahl’s law. Communications of the ACM 31 (5): 532–533,
August 1988.
L. Gwennap. New algorithm improves branch prediction. Microprocessor Report 9 (4),
March 1995.
S. P. Harbison and G. L. Steele, Jr. C, A Reference Manual, Fifth Edition. Prentice Hall,
20022.
J. L. Hennessy and D. A. Patterson. Computer Architecture: A Quantitative Approach,
Fifth Edition. Morgan Kaufmann, 20113.
M. Herlihy and N. Shavit. The Art of Multiprocessor Programming. Morgan Kaufmann, 2008.
C. A. R. Hoare. Monitors: An operating system structuring concept. Communications of
the ACM 17 (10): 549–557, October 1974.
Intel Corporation. Intel 64 and IA-32 Architectures Optimization Reference Manual. Документ доступен по адресу https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf.
Intel Corporation. Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume
1: Basic Architecture. Документ доступен по адресу http://www.intel.com/content/
www/us/en/processors/architectures-software-developer-manuals.html.
Intel Corporation. Intel 64 and IA-32 Architectures Software Developer’s Manual,
Volu­me 2: Instruction Set Reference. Документ доступен по адресу http://www.intel.
com/content/www/us/en/processors/architectures-software-developer-manuals.html.
Intel Corporation. Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume
3a: System Programming Guide, Part 1. Документ доступен по адресу http://www.intel.
com/content/www/us/en/processors/architectures-software-developer-manuals.html.

[43
[44]
[45]
[46]
[47]
[48]
[49]

[50]

[51]

[52]

3

M. W. Eichen and J. A. Rochlis. With microscope and tweezers: An analysis of the Internet virus of November, 1988. In Proceedings of the IEEE Symposium on Research in
Security and Privacy, pages 326–343. IEEE, 1989.

[37]

[42]

2

 Библиография

Харбисон Сэмюел П., Стил Гай Л., Язык программирования С, Бином-Пресс, 2009, ISBN: 978-59518-0334-4. – Прим. перев.
Паттерсон Хеннесси, Архитектура компьютера и проектирование компьютерных систем. Питер, 2012, ISBN: 978-5-459-00291-1. – Прим. перев.

Библиография  971
[53]

Intel Corporation. Intel Solid-State Drive 730 Series: Product Specification. Документ
доступен по адресу http://www.intel.com/content/www/us/en/solid-state-drives/ssd730-series-spec.html.

[54]

Intel Corporation. Tool Interface Standards Portable Formats Specification, Version 1.1,
1993. Order number 241597.

[55]

F. Jones, B. Prince, R. Norwood, J. Hartigan, W. Vogley, C. Hart, and D. Bondurant. Memory – a new era of fast dynamic RAMs (for video applications). IEEE Spectrum, pages 43–45, October 1992.

[56]

R. Jones and R. Lins. Garbage Collection: Algorithms for Automatic Dynamic Memory
Management. Wiley, 1996.

[57]

M. Kaashoek, D. Engler, G. Ganger, H. Briceo, R. Hunt, D. Maziers, T. Pinckney, R. Grimm,
J. Jannotti, and K. MacKenzie. Application performance and flexi­bility on Exokernel
systems. In Proceedings of the 16th ACM Symposium on Operating System Principles
(SOSP), pages 52–65. ACM, October 1997.

[58]

R. Katz and G. Borriello. Contemporary Logic Design, Second Edition. Prentice Hall, 2005.

[59]

B. W. Kernighan and R. Pike. The Practice of Programming. Addison-Wesley, 19994.

[60]

B. Kernighan and D. Ritchie. The C Programming Language, First Edition. Prentice Hall,
19785.

[61]

B. Kernighan and D. Ritchie. The C Programming Language, Second Edition. Prentice
Hall, 1988.

[62]

Michael Kerrisk. The Linux Programming Interface. No Starch Press, 20106.

[63]

T. Kilburn, B. Edwards, M. Lanigan, and F. Sumner. One-level storage system. IRE
Transactions on Electronic Computers EC-11: 223–235, April 1962.

[64]

D. Knuth. The Art of Computer Programming, Volume 1: Fundamental Algorithms, Third
Edition. Addison-Wesley, 19977.

[65]

J. Kurose and K. Ross. Computer Networking: A Top-Down Approach, Sixth Edition. Addison-Wesley, 2012.

[66]

M. Lam, E. Rothberg, and M. Wolf. The cache performance and optimizations of blocked algorithms. In Proceedings of the 4th International Conference on Architectural Support for Programming Languages and Operating Systems
(ASPLOS), pages 63–74. ACM, April 1991.

[67]

4

5

6

7

D. Lea. A memory allocator. Документ доступен по адресу http://gee.cs.oswego.edu/dl/

html/malloc.html, 1996.

[68]

C. E. Leiserson and J. B. Saxe. Retiming synchronous circuitry. Algorithmica 6 (1–6),
June 1991.

[69]

J. R. Levine. Linkers and Loaders. Morgan Kaufmann, 1999.

[70]

David Levinthal. Performance Analysis Guide for Intel Core i7 Processor and Intel Xeon
5500 Processors. Документ доступен по адресу https://software.intel.com/sites/products/collateral/hpc/vtune/performance_analysis_guide.pdf.

Брайан Керниган, Роб Пайк, Практика программирования, Вильямс, 2017, ISBN: 978-5-84592005-8. – Прим. перев.
Брайан Керниган, Деннис Ритчи, Язык программирования C, Вильямс, 2017, ISBN: 978-5-84591874-1, 0-13-110362-8, 978-5-8459-1975-5. – Прим. перев.
Керриск Майкл, Linux API. Исчерпывающее руководство, Питер, 2018, ISBN: 978-5-496-02689-5. –
Прим. перев.
Кнут Дональд Эрвин, Искусство программирования. Том 1: Основные алгоритмы, Вильямс, 2019,
ISBN: 978-5-907144-23-1. – Прим. перев.

972

8

 Библиография

[71]

C. Lin and L. Snyder. Principles of Parallel Programming. AddisonWesley, 2008.

[72]

Y. Lin and D. Padua. Compiler analysis of irregular memory accesses. In Proceedings of
the 2000 ACM Conference on Programming Language Design and Implementation (PLDI),
pages 157–168. ACM, June 2000.

[73]

J. L. Lions. Ariane 5 Flight 501 failure. Technical Report, European Space Agency, July 1996.

[74]

S. Macguire. Writing Solid Code. Microsoft Press, 1993.

[75]

S. A. Mahlke, W. Y. Chen, J. C. Gyllenhal, and W. W. Hwu. Compiler code transformations
for superscalar-based high-performance systems. In Proceedings of the 1992 ACM/IEEE
Conference on Supercomputing, pages 808–817. ACM, 1992.

[76]

E. Marshall. Fatal error: How Patriot overlooked a Scud. Science, page 1347, March 13,
1992.

[77]

M. Matz, J. Hubička, A. Jaeger, and M. Mitchell. System V application binary interface AMD64 architecture processor supplement. Technical Report,
x86-64.org, 2013. Документ доступен по адресу http://www.x86-64.org/documentation_
folder/abi-0.99.pdf.

[78]

J. Morris, M. Satyanarayanan, M. Conner, J. Howard,D. Rosenthal, and F. Smith. Andrew: A distributed personal computing environment. Communications of the ACM,
pages 184–201, March 1986.

[79]

T. Mowry, M. Lam, and A. Gupta. Design and evaluation of a compiler algo­rithm for
prefetching. In Proceedings of the 5th International Conference on Architectural Support
for Programming Languages and Operating Systems (ASPLOS), pages 62–73. ACM, October 1992.

[80]

S. S. Muchnick. Advanced Compiler Design and Implementation. Morgan Kaufmann, 1997.

[81]

S. Nath and P. Gibbons. Online maintenance of very large random samples on flash
storage. In Proceedings of VLDB, pages 970–983. VLDB Endowment, August 2008.

[82]

M. Overton. Numerical Computing with IEEE Floating Point Arithmetic. SIAM, 2001.

[83]

D. Patterson, G. Gibson, and R. Katz. A case for redundant arrays of inexpensive
disks (RAID). In Proceedings of the 1998 ACM SIGMOD International Conference on
Management of Data, pages 109–116. ACM, June 1988.

[84]

L. Peterson and B. Davie. Computer Networks: A Systems Approach, Fifth Edition. Morgan
Kaufmann, 2011.

[85]

J. Pincus and B. Baker. Beyond stack smashing: Recent advances in exploiting buffer
overruns. IEEE Security and Privacy 2 (4): 20–27, 2004.

[86]

S. Przybylski. Cache and Memory Hierarchy Design: A Performance-Directed Approach.
Morgan Kaufmann, 1990.

[87]

W. Pugh. The Omega test: A fast and practical integer programming algorithm for
dependence analysis. Communications of the ACM 35 (8): 102–114, August 1992.

[88]

W. Pugh. Fixing the Java memory model. In Proceedings of the ACM Conference on Java
Grande, pages 89–98. ACM, June 1999.

[89]

J. Rabaey, A. Chandrakasan, and B. Nikolic. Digital Integrated Circuits: A Design
Perspective, Second Edition. Prentice Hall, 20038.

[90]

J. Reinders. Intel Threading Building Blocks. O’Reilly, 2007.

[91]

D. Ritchie. The evolution of the Unix timesharing system. AT&T Bell Laboratories
Technical Journal 63 (6 Part 2): 1577–1593, October 1984.

Рабаи Жан М., Чандракасан Ананта, Николич Боривож, Цифровые интегральные схемы. Методология проектирования, Вильямс, 2016, ISBN: 978-5-8459-1116-2, 0-13-090996-3. – Прим. перев.

Библиография  973
[92]

D. Ritchie. The development of the C language. In Proceedings of the 2nd ACM SIGPLAN
Conference on History of Programming Languages, pages 201–208. ACM, April 1993.

[93]

D. Ritchie and K. Thompson. The Unix timesharing system. Communications of the
ACM 17 (7): 365–367, July 1974.

[94]

M. Satyanarayanan, J. Kistler, P. Kumar, M. Okasaki, E. Siegel, and D. Steere. Coda:
A highly available file system for a distributed workstation environment. IEEE
Transactions on Computers 39 (4): 447–459, April 1990.

[95]

J. Schindler and G. Ganger. Automated disk drive characterization. Technical Report
CMUCS-99-176, School of Computer Science, Carnegie Mellon University, 1999.

[96]

F. B. Schneider and K. P. Birman. The monoculture risk put into context. IEEE Security
and Privacy 7 (1): 14–17, January 2009.

[97]

R. C. Seacord. Secure Coding in C and C++, Second Edition. Addison-Wesley, 20139.

[98]

R. Sedgewick and K. Wayne. Algorithms, Fourth Edition. Addison-Wesley, 201110.

[99]

H. Shacham, M. Page, B. Pfaff, E.-J. Goh, N. Modadugu, and D. Boneh. On the effectiveness of address-space randomization. In Proceedings of the 11th ACM Conference on
Computer and Communications Security (CCS), pages 298–307. ACM, 2004.

[100] J. P. Shen and M. Lipasti. Modern Processor Design: Fundamentals of Superscalar Processors. McGraw Hill, 2005.
[101] B. Shriver and B. Smith. The Anatomy of a High-Performance Microprocessor: A Systems
Perspective. IEEE Computer Society, 1998.
[102] A. Silberschatz, P. Galvin, and G. Gagne. Operating Systems Concepts, Ninth Edition.
Wiley, 2014.
[103] R. Skeel. Roundoff error and the Patriot missile. SIAM News 25 (4): 11, July 1992.
[104] A. Smith. Cache memories. ACM Computing Surveys 14 (3), September 1982.
[105] E. H. Spafford. The Internet worm program: An analysis. Technical Report CSD-TR-823,
Department of Computer Science, Purdue University, 1988.
[106] W. Stallings. Operating Systems: Internals and Design Principles, Eighth Edition. Prentice
Hall, 201411.
[107] W. R. Stevens. TCP/IP Illustrated, Volume 3: TCP for Transactions, HTTP, NNTP and the
Unix Domain Protocols. Addison-Wesley, 199612.
[108] W. R. Stevens. Unix Network Programming: Interprocess Communications, Se­cond Edition, volume 2. Prentice Hall, 199813.
[109] W. R. Stevens and K. R. Fall. TCP/IP Illustrated, Volume 1: The Protocols, Second Edition.
Addison-Wesley, 2011.
[110] W. R. Stevens, B. Fenner, and A. M. Rudoff. Unix Network Programming: The Sockets
Networking API, Third Edition, volume 1. Prentice Hall, 2003.
9

10

11

12

13

Роберт Сикорд, Безопасное программирование на C и C++, Вильямс, 2016, ISBN: 978-5-84591908-3. – Прим. перев.
Роберт Седжвик, Кевин Уэйн. Алгоритмы на Java, 4-е издание, Вильямс, 2012, ISBN: 978-5-84591781-2. – Прим. перев.
Столлингс Вильям, Операционные системы. Внутренняя структура и принципы проектирования,
Вильямс, 2020, ISBN: 978-5-907203-08-2. – Прим. перев.
У. Ричард Стивенс, Протоколы TCP/IP. Практическое руководство, Невский Диалект, 2004, ISBN:
5-7940-0093-7. – Прим. перев.
У. Р. Стивенс, Б. Феннер, Э. М. Рудофф, UNIX: разработка сетевых приложений, Питер, 2007, ISBN:
5-94723-991-4. – Прим. перев.

974

 Библиография

[111] W. R. Stevens and S. A. Rago. Advanced Programming in the Unix Environment, Third
Edition. Addison-Wesley, 201314.
[112] T. Stricker and T. Gross. Global address space, non-uniform bandwidth: A memory
system performance characterization of parallel systems. In Proceedings of the 3rd
International Symposium on High Performance Computer Architecture (HPCA), pages 168–179. IEEE, February 1997.
[113] A. S. Tanenbaum and H. Bos. Modern Operating Systems, Fourth Edition. Prentice Hall,
201515.
[114] A. S. Tanenbaum and D. Wetherall. Computer Networks, Fifth Edition. Prentice Hall,
201016.
[115] K. P. Wadleigh and I. L. Crawford. Software Optimization for High-Performance
Computing: Creating Faster Applications. Prentice Hall, 2000.
[116] J. F. Wakerly. Digital Design Principles and Practices, Fourth Edition. Prentice Hall, 2005.
[117] M. V. Wilkes. Slave memories and dynamic storage allocation. IEEE Transactions on
Electronic Computers, EC-14 (2), April 1965.
[118] P. Wilson, M. Johnstone, M. Neely, and D. Boles. Dynamic storage allocation: A survey
and critical review. In International Workshop on Memory Management, volume 986 of
Lecture Notes in Computer Science, pages 1–116. Sprin­ger-Verlag, 1995.
[119] M. Wolf and M. Lam. Adata locality algorithm. In Proceedings of the 1991 ACM Confe­
rence on Programming Language Design and Implementation (PLDI), pages 30–44, June
1991.
[120] G. R. Wright and W. R. Stevens. TCP/IP Illustrated, Volume 2: The Implementation. Addison-Wesley, 1995.
[121] J. Wylie, M. Bigrigg, J. Strunk, G. Ganger, H. Kiliccote, and P. Khosla. Survivable information storage systems. IEEE Computer 33: 61–68, August 2000.
[122] T.-Y. Yeh and Y. N. Patt. Alternative implementation of two-level adaptive branch
prediction. In Proceedings of the 19th Annual International Symposium on Computer
Architecture (ISCA), pages 451–461. ACM, 1998.

14

15

16

Стивенс Уильям Ричард, Раго Стивен А., UNIX. Профессиональное программирование, Питер,
2018, ISBN: 978-5-4461-0649-3, 978-0321637734. – Прим. перев.
Бос Херберт, Таненбаум Эндрю, Современные операционные системы, Питер, 2018, ISBN: 978-54461-1155-8. – Прим. перев.
Таненбаум Эндрю, Уэзеролл Дэвид, Компьютерные сети, Питер, 2019, ISBN: 978-5-4461-1248-7. –
Прим. перев.

Предметный указатель
Символы
8086 микропроцессор 187
8087 арифметический сопроцессор 135, 187
80286 микропроцессор 188
-O1 флаг оптимизации 190
-Og флаг оптимизации 190
-pg параметр компилятора 539
& [C] оператор взятия адреса
локальные переменные 258
указатели 79, 283
& [C] операция взятия адреса
указатели 206
* [C] оператор разыменования
указатели 283
* [C] операция разыменования указателя 206
/etc/services файл 866
/ргос файловая система 694
/sys файловая система 694
! [HCL] операция NOT 369
#include директива препроцессора 190
целочисленные типы
фиксированного размера 216
.align директива 362
.bss секция 639
.data секция 639
.debug секция 639
.interp секция 662
.line секция 640
.pos директива 362
.rel.data секция 639
.rel.text секция 639
.rodata секция 638
.so разделяемые библиотеки 661
.strtab секция 640
.symtab секция 639
.text секция 638
%ax [x86-64] младшие 16 разрядов
регистра %rax 200
%r8 [Y86-64] программный регистр 352
%rip счетчик инструкций 191
%rsp [Y86-64] регистр указателя стека 198

A
accept [Linux] функция 870
addq [Y86-64] сложение 354, 394
ADD [класс инструкций] сложение 210
Advanced Micro Devices (AMD) 186
совместимость с Intel 189
AFS (Andrew File System) 582
alarm [Linux] функция 717
alloca функция выделения памяти
на стеке 290
AMD (Advanced Micro Devices)
совместимость с Intel 189

andq [Y86-64] поразрядное И 354
AND [класс инструкций] И 210
ANSI (American National Standards Institute Американский национальный
институт стандартов) 67
ANSI C 67
AOK [Y86-64] код состояния, нормальное
выполнение 360
API (Application Program Interface
прикладной программный
интерфейс) 60
ARM A7 микропроцессор 350
ARM (Acorn RISC Machine) 75
микропроцессорная архитектура 358
AR архиватор Linux 673
AR архиватор в Linux 650
ASCII cnfylfhn^juhfybxtybz 81
ASCII стандарт
коды символов 80
asm директива 197
ATT формат представления ассемблерного
кода
и формат Intel 196
AVX (Advanced Vector eXensions
усовершенствованные векторные
расширения) 518
AVX (Advanced Vector eXtensions
усовершенствованные векторные
расширения) инструкции 298

B
Barracuda 7400 572
Bell Labs 67
bind [Linux] функция 870, 872
binutils пакет 673
break multstore команда в GDB 285
break инструкция
в операторе switch 245

C
calloc [С] функция 788
calloc функция [C Stdlib] распределение
памяти
уязвимость 126
calloc функция [C Stdlib] распределение
памяти
объявление 159
callq [x86-64] вызов процедуры
инструкция 253, 254
call [x86-64] вызов процедур 355
call [x86-64] вызов процедуры
инструкция 252
cd команда 833
CF [x86-64] флаг переноса 218

976

 Предметный указатель

char [C] тип данных 72, 91
CISC (Complex Instruction Set Computer
полный набор команд) 359
closedir [Linux] функция 844
close [Linux] функция 835
cltd [x86-64] расширение знакового разряда
в %eax до %rax 204
cmovae [x86-64] переместить если выше
или равно 232
cmova [x86-64] переместить если выше 232
cmovbe [x86-64] переместить если ниже или
равно 232
cmovb [x86-64] переместить, если ниже 232
cmove [x86-64] переместить, если равно 232
cmove [Y86-64] переместить, если равно 355
cmovge [x86-64] переместить, если больше
или равно 232
cmovg [x86-64] переместить, если больше 232
cmovle [x86-64] переместить, если меньше
или равно 232
cmovl [x86-64] переместить, если меньше 232
cmovne [x86-64] переместить, если
не равно 232
cmovns [x86-64] переместить, если
неотрицательное 232
cmovs [x86-64] переместить, если
отрицательное 232
cmpb [x86-64] сравнение байтов 219
cmpl [x86-64] сравнение двойных слов 219
cmpq [x86-64] сравнение четверных слов 219
cmpw [x86-64] сравнение слов 219
CMP [класс инструкций] сравнение 219
cmtest сценарий 450
connect [Linux] функция 869, 872
continue команда в GDB 285
Control Data Corporation 6600 процессор 502
copy_elements функция 126
copy_from_kernel функция 114
Core 2 микропроцессор 188
Core 2, микропроцессоры 561
Core i7, Haswell микропроцессор 188
Core i7, Sandy Bridge микропроцессор 188
Core i7, микропроцессоры
ландшафт горы памяти 609
шина QuickPath 561
CPE (Cycles Per Element циклов
на элемент) 484
CR3 регистр управления 774
Cray 1 суперкомпьютер 350
ctest сценарий 450
C язык
битовые операции 85
числа со знаком и без знака 104
C, язык
для решения практических задач 37
история развития 67
происхождение 37
стандартная библиотека 40
тесно связан с операционной системой
Unix 37
C язык программирования
статические библиотеки 648

C++ язык программирования 641
программные исключения 681
разрешение имен 644

D
DDD отладчик с графическим
интерфейсом 286
DEC [класс инструкций] декремент 210
delete [C++] функция 786
delete команда в GDB 285
Digital Equipment Corporation 86
DIMM (Dual Inline Memory Module
модули памяти с двухрядным
расположением контактов) 557
disas multstore команда в GDB 285
disas команда в GDB 285
divq [x86-64] деление без знака 215
dlclose [Unix] закрытие разделяемых
библиотек 664
DLL (Dynamic Link Libraries динамически
связываемые библиотеки) 661
dlopen [Unix] открытие разделяемых
библиотек 663
DMA (Direct Memory Access прямой доступ
к памяти) 45
double [C] тип данных 73
double [C] числа с плавающей точкой
двойной точности 150
double объявление чисел с плавающей
точкой двойной точности 198
do [С] вариант цикла while 235
DRAM
быстрая память со страничным
режимом 558
массивы 555
модули памяти 557
память с расширенными возможностями
вывода 559
расширенная память 558
синхронная память 559
синхронная память с удвоенной
скоростью обработки данных 559
DRAM (Dynamic Random Access Memory
динамическая память
с произвольным доступом) 43
dup2 [Linux] функция 848

E
ECHILD код ошибки 703
echo функция 287, 292
EINTR код ошибки 703
errno переменная 703
etest сценарий 450
Ethernet 856
execve [Linux] функция 707, 783
exit [Linux] функция 697

F
fclose [C] функция 849
fgets [C] функция 849

Предметный указатель  977
fingerd демон 289
FINGER команда 289
finish команда в GDB 285
float [C] числа с плавающей точкой
одинарной точности 150
float объявление чисел с плавающей точкой
одинарной точности 198
fopen [C] функция 849
for [C] оператор цикла 242
fork [Linux] функция 697, 783
fprintf [C Stdlib] функция
форматированного вывода 79
fputs [C] функция 849
fread [C] функция 849
freeaddrinfo [Linux] функция 872
FreeBSD-SA-02:38.signed-error 115
free [C] функция 788
fstat [Linux] функция 842
fwrite [C] функция 849

G
gai_strerror [Linux] функция 872
GCC (GNU Compiler Collection)
параметры 67
GCC компилятор
работа с 190
GDB GNU отладчик 193
GDB отладчик 285
getaddrinfo [Linux] функция 872
getenv [Linux] функция 708, 887
getnameinfo [Linux] функция 872, 874
getpgrp [Linux] функция 715
getpid [Linux] функция 697
getppid [Linux] функция 697
getrusage [Linux] функция 758
gets функция 287
GIPS (Giga-Instructions Per Second млрд
инструкций в секунду) 403
GNU, проект 40
goto [C] оператор передачи управления 225
GPROF профилировщик Unix 539

H
halt [Y86-64] инструкция остановки
выполнения 355
исключение 360
Haswell 230
Haswell микропроцессоры 488, 501
HCL (Hardware Control Language язык
описания аппаратных средств) 368
выражения выбора 373
целочисленные выражения 371
help команда в GDB 286
hotnl [Linux] функция 862
htest сценарий 450
htons [Linux] функция 862
HyperTransport шина 561

I
i386 микропроцессор 188

i486 микропроцессор 188
IA32 (Intel Architecture 32 bit)
машинный язык 187
IA32 (Intel Architecture 32-bit)
микропроцессоры 77
iaddq [Y86-64] инструкция сложения с
непосредственным значением 365
IBM
обработка инструкций не по порядку 502
процессоры Freescale 349
icode (код инструкции) 379
if [C] условный оператор 227
ifun (функция инструкции) 379
imem_error сигнал 401
imulq [x86-64] умножение со знаком 215
IMUL [класс инструкций] умножение 210
incq инструкция 212
INC [класс инструкций] инкремент 210
inet_ntop [Linux] функция 862
inet_pton [Linux] функция 862
INFINITY константа 150
info frame команда в GDB 286
info registers команда в GDB 286
int [C] целочисленный тип данных 72
Intel Core i7
практический пример системы памяти 773
Intel Corporation 186
INT_MAX константа, максимальное целое
со знаком 98
INT_MIN константа, минимальное целое со
знаком 98
iPhone 5S 350
IP-адреса 861
ISO C11 стандарт 67
ISO C90 стандарт 67
ISO C99 стандарт 67
типы фиксированного размера 72
целочисленные типы данных 95
ISO (International Standards Organization
Международная организация по
стандартизации) 67

J
jae [x86-64] перейти, если выше или равно 223
Java
байт-код 313
Java Native Interface (JNI) 665
Java язык
числовые диапазоны 98
Java язык программирования 641
ja [x86-64] перейти, если выше 223
jbe [x86-64] перейти, если ниже или равно 223
jb [x86-64] перейти, если ниже 223
je [x86-64] перейти, если не равно/не ноль 223
je [x86-64] перейти, если равно/ноль 223
je [Y86-64] перейти, если равно 354
je [Y86-64] переход, если равно 386
jge [x86-64] перейти, если больше
или равно 223
jg [x86-64] перейти, если больше 223
jle [x86-64] перейти, если меньше
или равно 223

978

 Предметный указатель

jl [x86-64] перейти, если меньше 223
jns [x86-64] перейти, если
неотрицательное 223
js [x86-64] перейти, если отрицательное 223
jtest сценарий 450

K
kill [Linux] функция 717
kill команда в GDB 285

L
L1 кеш 586
L2 кеш 586
L3 кеш 586
LDD инструмент 673
LD-LINUX.SO динамический компоновщик 671
LD_PRELOAD переменная окружения 671
LD статический компоновщик Unix 637
libc_start_main [C] функция 708
limits.h файл с определениями числовых
границ 98
limits.h файл с определениями числовых
пределов 106
Linux
история проекта 55
Linux операционная система 77
Lisp язык 113
listen [Linux] функция 870
long double [C] тип данных с плавающей
точкой расширенной точности 162
long double объявление чисел с плавающей
точкой расширенной точности 198
longjmp [Linux] функция 736
ls команда 832

M
malloc [C] функция 788
malloc [С Stdlib] выделение памяти в куче 67
man ascii команда 80
Mark&Sweep алгоритм сборки мусора 813
maxlen параметр 114
memcpy функция 115
memset функция, объявление 159
Microsoft Windows операционная система 77
MIME (Multipurpose Internet Mail Extensions многоцелевые расширения
электронной почты интернета) 882
mkdir команда 832
mmap [Linux] функция 785
MMX мультимедийные инструкции 188, 298
MOSAIC веб-браузер 882
movabsq [x86-64] перемещение
абсолютного четверного слова 202
MOVZ [класс инструкций] перемещение
с расширением нулями 203
mrmovq [Y86-64] память-регистр
перемещение 382
Multics 51
munmap [Linux] функция 786

N
NaN (не число)
представление 141
NEG [класс инструкций] отрицание 210
new [C++] функция 786
nexti команда в GDB 285
NFS (Network File System) 582
NM инструмент 673
nop инструкция, нет операции 420
NOT [класс инструкций] дополнение 210
ntohl [Linux] функция 862
ntohs [Linux] функция 862

O
O_APPEND константа 834
OBJDUMP дизассемблер 285
OBJDUMP инструмент GNU для просмотра
выполняемых файлов 654
OBJDUMP программа для чтения
машинного кода 193
O_CREAT константа 834
OF [x86-64] флаг переполнения 218
OF флаг переполнения 353
once_control переменная 918
open_clientfd функция 876
opendir[Linux] функция 844
open [Linux] функция 833
open_listenfd функция 877
optest сценарий 450
O_RDONLY константа 834
O_RDWR константа 834
OR [класс инструкций] ИЛИ 210
O_TRUNC константа 834
O_WRONLY константа 834

P
P6 микроархитектура 188
pause [Linux] функция 706
PCI Express (PCIe) 569
Pentium 4E микропроцессор 188
Pentium 4 микропроцессор 188
Pentium III микропроцессор 188
Pentium II микропроцессор 188
Pentium/MMX микропроцессор 188
Pentium Pro микропроцессор 188
PentiumPro микропроцессор 502
Pentium микропроцессор 188
PF [x86-64] флаг четности 197
PID (Process Identifier идентификатор
процесса) 697
PIPE процессор 416
реализация этапов 434
PMAP 739
popq [x86-64] инструкция выталкивания
со стека 208
popq [Y86-64] инструкция 355
поведение 366
Poslx 51
printf [C] функция 849
print команда в GDB 285
PS 739

Предметный указатель  979
pthread_cancel [Linux] функция 917
pthread_create [Linux] функция 916
pthread_detach [Linux] функция 917
pthread_exit [Linux] функция 916
pthread_join [Linux] функция 917
pthread_once [Linux] функция 918
pthread_self [Linux] функция 916
pushq [x86-64] инструкция вталкивания
в стек 208
pushq [Y86-64] инструкция 355
этапы обработки 366, 384
pushq инструкция сохранения четверного
слова 193

Q
qsort функция 541
quit команда в GDB 285

R
readdir [Linux] функция 843
READELF инструмент GNU для просмотра
объектных файлов 642
read [Linux] функция 835
realloc [C] функция 788
rep [ч86-64] инструкция повторения, как
no-op 224
ret [x86-64] инструкция возврата из
процедуры 224
ret инструкция
этапы обработки 387
rio_readinitb функция 839
rio_readlineb функция 839
rio_readnb функция 839
rio_readn функция 837
rio_writen функция 837
RISC (Reduced Instruction Set Computers
сокращенный набор команд) 359
rmdir команда 832
rmmovq [Y86-64] регистр-память
перемещение 382
run команда в GDB 285

S
salb [x86-64] сдвиг влево 213
SAR [класс инструкций] арифметический
сдвиг вправо 213
SAR [класс инструкций] сдвиг вправо
арифметический 210
SATA интерфейсы 570
sbrk [C] функция 788
scanf [C] функция 849
SCSI интерфейсы 570
select [Linux] функция 906
sem_init [Linux] функция 928
sem_post [Linux] функция 928
sem_wait [Linux] функция 928
setae [x86-64] установить, если выше или
равно 220
seta [x86-64] установить, если выше 220
setbe [x86-64] установить, если ниже или

равно 220
setb [x86-64] установить, если ниже 220
setenv [Linux] функция 709
sete [x86-64] установить, если равно 220
setge [x86-64] установить, если больше или
равно (со знаком) 220
setg [x86-64] установить, если больше
(со знаком) 220
setjmp [Linux] функция 736
setle [x86-64] установить, если меньше
или равно (со знаком) 220
setl [x86-64] установить, если меньше
(со знаком) 220
setne [x86-64] установить, если неравно 220
setns [x86-64] установить, если
неотрицательное 220
setpgid [Linux] функция 715
sets [x86-64] установить, если
отрицательное 220
SF [x86-64] флаг знака 218
SF флаг знака 353
SHL [класс инструкций] сдвиг влево 213
SHR [класс инструкций] логический сдвиг
вправо 213
SHR [класс инструкций] сдвиг вправо
логический 210
SIGABRT сигнал 713
sigaction [Linux] функция 729
sigaddset [Linux] функция 720
SIGALRM сигнал 713
sig_atomic_t [C] тип 724
SIG_BLOCK константа 720
SIGBUS сигнал 713
SIGCHLD сигнал 713
SIGCONT сигнал 697, 702, 713
sigdelset [Linux] функция 720
SIG_DFL константа 718
sigemptyset [Linux] функция 720
sigfillset [Linux] функция 720
SIGFPE сигнал 713
SIGHUP сигнал 713
SIG_IGN константа 718
SIGILL сигнал 713
SIGINT сигнал 713
SIGIO сигнал 713
sigismember [Linux] функция 720
SIGKILL сигнал 713
siglongjmp [Linux] функция 737
signal [Linux] функция 718
signed [C] тип данных 73
SIGPIPE сигнал 713
sigprocmask [Linux] функция 720
SIGPROF сигнал 713
SIGPWR сигнал 713
SIGQUIT сигнал 713
SIGSEGV сигнал 713
sigsetjmp [Linux] функция 737
SIG_SETMASK константа 720
SIGSTKFLT сигнал 713
SIGSTOP сигнал 697, 713
sigsuspend [Linux] функция 734
SIGTERM сигнал 713

980

 Предметный указатель

SIGTRAP сигнал 713
SIGTSTP сигнал 697, 713
SIGTTIN сигнал 697, 713
SIGTTOU сигнал 697, 713
SIG_UNBLOCK константа 720
SIGURG сигнал 713
SIGUSR1 сигнал 713
SIGUSR2 сигнал 713
SIGVTALRM сигнал 713
SIGWINCH сигнал 713
SIGXCPU сигнал 713
SIGXFSZ сигнал 713
SIMD (Single-Instruction, Multiple-Data
одиночный поток команд,
множественный поток данных) 60
sio_error [Linux] функция 723
sio_ltoa [Linux] функция 723
sio_putl [Linux] функция 723
sio_puts [Linux] функция 723
sio_strlen [Linux] функция 723
S_IRGRP константа 834
S_IROTH константа 834
S_IRUSR константа 834
S_IWGRP константа 834
S_IWOTH константа 834
S_IWUSR константа 834
S_IXGRP константа 834
S_IXOTH константа 834
S_IXUSR константа 834
sizeof [C] вычисляет размер объекта 77
size_t [Unix] тип данных без знака для
обозначения размеров 76
size_t [Unix] тип данных без знака для
обозначения размеров 128
SIZE инструмент 673
sleep [Linux] функция 706
socket [Linux] функция 869, 872
Solaris Sun Microsystems операционная
система 77
sprintf функция 288
sqrtss [x86-64] корень квадратный двойной
точности инструкция 306
sqrtss [x86-64] корень квадратный
одинарной точности инструкция 306
SRAM (Static Random Access Memory
статическая память с произвольным
доступом) 47
SSE (streaming SIMD extensions)
инструкции 188
SSE (Streaming SIMD Extensions потоковые
расширения SIMD) 298
Standard Unix Specification 51
static [C] атрибут функций и переменных 640
stat [Linux] функция 842
STDERR_FILENO константа 831
STDIN_FILENO константа 831
stdio.h [Unix] заголовочный файл
стандартной библиотеки ввода/
вывода 112
STDOUT_FILENO константа 831
stepi команда в GDB 285
STRACE 739

strcat функция 288
strcpy функция 288
STRINGS инструмент 673
STRIP инструмент 673
strlen [C Stdlib] функция вычисления длины
строки 491
struct [C] тип данных 273
subq [Y86-64] вычитание 381
SUB [класс инструкций] вычитание 210

T
TEST [класс инструкций] проверка 219
TLB (Translation Look-aside Buffer буфер
быстрого преобразования адреса) 455
TOP 739
typedef [C] определение типа 76

U
UINT_MAX константа, максимальное целое
без знака 98
Unicode (Юникод) набор символов 81
Unix 51
Unix IPC 905
Unix операционная система 67
unsetenv [Linux] функция 709
unsigned [C] тип данных 73, 91
UTF-8, кодировка символов 81

V
vaddss [x86-64] сложение двойной точности
инструкция 306
vaddss [x86-64] сложение одинарной
точности инструкция 306
VALGRIND программа 545
vandps [x86-64] поразрядное И инструкция 308
vdivsd [x86-64] деление двойной точности
инструкция 306
vdivss [x86-64] деление одинарной точности
инструкция 306
vmaxsd [x86-64] максимальное двойной
точности инструкция 306
vmaxss [x86-64] максимальное одинарной
точности инструкция 306
vminsd [x86-64] минимальное двойной
точности инструкция 306
vminss [x86-64] минимальное одинарной
точности инструкция 306
vmovsd [x86-64] перемещение значения
двойной точности 300
vmovss [x86-64] перемещение значения
одинарной точности 300
vmulsd [x86-64] умножение двойной
точности инструкция 306
vmulss [x86-64] умножение одинарной
точности инструкция 306
void* [C] нетипизированный указатель 79
volatile [C] спецификатор 724
vsubsd [x86-64] вычитание двойной
точности инструкция 306
vsubss [x86-64] вычитание одинарной
точности инструкция 306

Предметный указатель  981
VTUNE программа 545
vucomisd [x86-64] сравнение значений с
двойной точностью инструкция 309
vucomiss [x86-64] сравнение значений с
одинарной точностью инструкция 309
vxorps [x86-64] поразрядное
ИСКЛЮЧАЮЩЕЕ-ИЛИ инструкция 308

W
wait [Linux] функция 703
waitpid [Linux] функция 701
WCONTINUED константа 702
while [C] оператор 237
WIFCONTINUED(status) макрос 702
WIFEXITED(status) макрос 702
WIFSIGNALED(status) макрос 702
WIFSTOPPED(status) макрос 702
WNOHANG константа 702
write [Linux] функция 835
WSTOPSIG(status) макрос 702
WTERMSIG(status) макрос 702
WUNTRACED константа 702

X
x86-64 микропроцессоры 77
машинный язык 186
x87 процессоры 187
XDR библиотека, уязвимость 126
XMM регистры 300
xorq [Y86-64] исключающее или 354
XOR [класс инструкций] ИСКЛЮЧАЮЩЕЕИЛИ 210

Y
Y86-64 архитектура набора команд 351, 352
дополнительные сведения 366
набор инструкций 353
обработка исключений 360
YAS ассемблер Y86-64 363
YIS имитатор набора команд Y86-64 364
YMM регистры 300

Z
ZF [x86-64] флаг признака нуля 218
ZF флаг нуля 353

А
абелева группа 117
абсолютное ускорение 943
абстрактная модель работы процессора
Core i7 504
абстракции
важность в компьютерных системах 60
аварийное завершение 687
адаптер главной шины 570
адаптеры 43
аддитивная инверсия 84

адреса и адресация
Y86-64 354
виртуальная память 68
возврат из вызова процедуры 252
модель плоской адресации 188
операнды 200
порядок следования байтов 74
преобразование адресов в Core i7 774
адресное пространство 693
активные страницы 759
альтернативные представления целых со
знаком 99
Амдала закон 56, 538, 544
Американский национальный институт
стандартов (American National
Standards Institute, ANSI) 67
анализ производительности 452
аппаратная организация системы 42
аппаратная реализация Y86-64 387
аппаратные исключения 683
аппаратные модули 389
аппаратные прерывания 685
арифметика 65, 209
загрузка эффективного адреса 210, 211
задержка и время выпуска 503
насыщающая 158
обсуждение 213
операция сдвига 130
произвольной точности 113
специальная 215
с плавающей точкой 298, 305
унарная и бинарная 212
целочисленная 113
арифметика с плавающей точкой IEEE 135
арифметико-логическое устройство (АЛУ)
на этапе выполнения 379
архивы 649
архитектура набора команд (Instruction Set
Architecture, ISA) 191
архитектура набора команд (ISA, Instruction
Set Architecture) 349
архитектурный набор команд 44, 60
ассемблер 39, 184, 190
ассемблерный код 184
форматирование 195
ассоциативность
сложения чисел с плавающей точкой 149
ациклические цепи 369

Б
базовые принципы программирования 538
базовый регистр таблицы исключений 684
базовый регистр таблицы страниц 763
базовый указатель 296
байты 38, 68
кодирование в Y86-64 356
копирование 158
беззнаковое представление
сложение 113
безопасная оптимизация 481
безопасная траектория 927

982

 Предметный указатель

безопасные функции 721
бесконечность
представление 141
библиотеки
разделяемые 661, 662
биграмм подсчет 540
бинарные (двухместные) операции 212
бинарные семафоры 929
бит достоверности, в кешах 586
бит знака 95
бит изменения 776
битовые векторы 82
бит режима 693
бит ссылки 776
биты
обзор 64
блок-жертва 583
блоки
кеша 583
блокирование сигналов 714
блокировка мьютекса 929
блокировка сигналов 720
блоки управляющей логики 389
блок пролога 802
блок регистров 355
блок списания (retirement unit) 501
блок управления инструкциями (Instruction
Control Unit, ICU) 499
блок эпилога 802
булева алгебра 82
булева алгебра и функции
свойства 84
булевы выражения в HCL 369
булевы кольца 84
Буль, Джордж 82
буфер ассоциативной трансляции адресов
(Translation Lookaside Buffer, TLB) 766
буфер быстрого преобразования адреса 455
буферизованный ввод 839
буферы
операций сохранения 533

В
ввод/вывод 830
правила выбора функций для
использования 850
ввод/вывод с отображением в память 570
веб-контент 882
веб-серверы 881
векторные регистры 191
ветвление, условное 225, 528
switch 245
взаимоблокировка 950
взаимодействие с пользователями 902
видеопамять (VRAM) 559
виртуальная адресация 752
виртуальная машина 60
виртуальная память 51, 68, 660, 750
абстракция 49
интегрирование кешей и виртуальной
памяти 765
как средство кеширования 754

как средство управления памятью 760
механизмы неявного распределения
памяти 787
организация в Linux 777
сегменты 777
упрощение компоновки 760
часто встречающиеся ошибки 815
виртуальное адресное пространство 52, 68, 753
виртуальные адреса
Y86-64 353
в программировании на машинном
уровне 191
виртуальные страницы 294, 754
вирусы 290
внешние исключения
в конвейерной обработке 432
внешняя фрагментация памяти 793
внутренняя фрагментация памяти 793
возврат из процедуры, инструкция 355
вращающиеся диски 563
временная локальность 577, 585
время выполнения
компоновка 634
время доступа к диску 566
время загрузки 634
время компиляции 634
время ожидания
конвейерная обработка 402
время передачи, диски 566
время позиционирования для дисков 566
Всемирная паутина (World Wide Web) 882
встраиваемые процессоры
Y86-64 358
встраивание ассемблерного кода 197
встраивание функций 483
выборки этап
обработка инструкций 379
выборки, этап
обработка инструкций 381
последовательная обработка 388
процессор PIPE 434
трассировка 396
вызовы
и производительность 494
вызываемые процедуры 261
вынос кода 490
выполнение
трассировка 386
выполнение не по порядку 456, 498
выполнения, этап
обработка инструкций 379
последовательная обработка 389
процессор PIPE 439
трассировка 398
выполняемые объектные программы 39
выполняемые объектные файлы 39, 657
создание 636
выполняемые файлы 40
выполняемый код 190
выполняется всегда, стратегия
прогнозирования 417
высокоуровневое проектирование 537

Предметный указатель  983
выталкивания со стека операция 208
вытеснение блоков 583
вытеснение процессов 691
вытеснение регистров 525
вычитание
с плавающей точкой 306

Г
гигагерцы (ГГц) 484
гиперпоточность 58, 188
главный поток выполнения 914
глобальная таблица смещений (Global Offset Table, GOT) 666
глобальные переменные 639, 921
гора памяти 608
Гордон Мур (Gordon Moore) 189
Горнер Уильям (William G. Horner) 509
границы
задержки 498, 504
граф выполнения 925
графические адаптеры 570
граф процессов 699
графы
потоков данных 505
группы процессов 715
грязные страницы 776

Д
данные
продвижение 423
двойные слова 197
двоичная точка 137
двоичное представление 64
преобразование
в шестнадцатеричное представление 69
двоичные представления
дробные числа 136
двоичные файлы 39, 832
дейтаграммы 860
декодирование инструкций 499
декодирования, этап
обработка инструкций 379
последовательная обработка 389
процессор PIPE 436
трассировка 397
деление
инструкции 215
деление с плавающей точкой 306
дескриптор сокета 869
дескрипторы 831
Джон Хеннеси (John Hennessy) 359
диаграммы
аппаратные 389
конвейерной обработки 402
диапазоны
асимметрия 97, 106
байтов 68
типов данных 72
дизассемблеры 76, 97, 193
динамическая компиляция 294
динамическая память 659

вопросы реализации 793
неявные списки свободных блоков 794
объединение свободных блоков 794, 797
объединение с использованием
граничных тегов 798
организация свободных блоков 794
разбиение свободных блоков 794
размещение выделенных блоков 794
размещение свободных блоков 796
реализация механизма распределения
памяти 800
стратегии поиска свободных блоков 796
явные списки свободных блоков 807
динамическая память (куча) 52
динамическая память с произвольным
доступом (Dynamic Random Access
Memory, DRAM) 43
динамические компоновщики 661
динамический контент 663, 883
динамическое распределение памяти 786
динамическое связывание 661
с разделяемыми библиотеками 661
директивы ассемблера 362
директивы, ассемблера 196
диски 562
геометрия 562
логические блоки 567
подключение 568
дисковый привод 563
добавление конвейерных регистров 411
доменные имена 863
Дональд Кнут (Donald Knuth) 796
дополнение нулями 106
дополнительный код 65, 94
дорожки дисков 563
доставка сигнала 714, 715
доступ
для чтения 294
к дискам 570
к основной памяти 560
к регистрам IA32 198
к флагам 219
доступ к информации в x86-64
регистры 198
доступ к медленным устройствам ввода/
вывода 901
дочерний поток выполнения 914
драйвер компилятора 39
драйверы компиляторов 636
древовидные структуры 277
Дэвид Паттерсон (David Patterson) 359

Е
емкость
кешей 585
функциональных блоков 503
емкость диска 564
емкость отформатированного диска 568

З
завершение потока выполнения 916

984

 Предметный указатель

зависимости по данным в конвейерной
обработке 409
зависимости по управлению в конвейерной
обработке 409
зависимость между записью и чтением 533
зависимость по данным 418
зависимость по управлению 418
загрузка выполняемых объектных файлов 659
загрузчики 637, 660
задания 716
задержка
арифметических операций 503
инструкций 403
задержка из-за вращения диска 566
закон Амдала 56
закон Мура 189
закрытое адресное пространство 693
замещение страниц по требованию
(demand paging) 758
записи перемещения 637, 653
запись без размещения 600
запись с размещением 600
запрос RAS (Row Access Strobe строб адреса
ряда) 556
запрос CAS (Column Access Strobe строб
адреса колонки) 556
запуск на переднем плане 712
защита памяти 294
защитное значение 292
защищенный-do, стратегия трансляции 242
знак числа, в представлении с плавающей
точкой 139
зомби, процесс 701

И
И (AND) операция
булева 82
идентификатор потока выполнения (Thread
ID, TID) 914
идентификаторы, регистров 355
иерархия каталогов 833
иерархия памяти 47
изменения PC, этап
трасировка 401
ИЛИ (||) логическая операция 87
имена
глобальные 640
локальные 640
именованные каналы 832
имитатор набора команд Y86-64 364
имитаторы Y86-64 458
имя службы 866
имя файла 832
инвариант семафора 928
индексные регистры 201
инкремента инструкции 212
инструкции
арифметико-логические операции 44
в конвейерной обработке 453
декодирование 499
загрузка 44

классы 202
локальность выборки 579
переход 44
сохранение 44
требующие для выполнения нескольких
циклов 454
инструкция сложения с непосредственным
значением 365
инструменты управления процессами 739
интернет-черви 290
интерпретация комбинаций битов 64
интерсеть 858
интерфейс системных вызовов 686
интерфейс сокетов 860, 867
интерфейс шины 561
информация – это биты + контекст 38
ИСКЛЮЧАЮЩЕЕ ИЛИ (XOR) операция
булева 82
исключение 682
исключение по адресу, код состояния 401
исключения
обработка 683
исключительные инструкции 432
исключительные ситуации
Y86-64 353
исключительный доступ 926
исполнительный блок (Execution Unit, EU) 499
история архитектур RISC и CISC 358
история развития индустрии
проектирования процессоров 456
история развития языка C 67
итеративные серверы 880

К
кадр стека 251
каналы передачи отдельных байтов 390
каналы передачи отдельных битов 390
каналы передачи слов 390
канареечное значение 292
квант времени 692
Керниган, Брайан 67
кеш DRAM 754
кеш SRAM 754
кеш данных 500
кеши и кеш-память 586
ассоциативность, влияние на
производительность 603
ассоциативные 595
биты смещения слова в блоке 587
биты тега 586
виды промахов 584
время обработки попадания 602
локальность 577
назначение 553
оптимизация операций записи 600
организация 586
параметры 587
попадания 583
практические задачи 604
пример 591
проблемы с операциями записи 600

Предметный указатель  985
промахи 583
размер блока, влияние
на производительность 603
размер, влияние на производительность 603
с прямым отображением 588
универсальные 601
частота попаданий 602
частота промахов 602
штраф за промах 602
кеш инструкций 499
кеширование 582
кешированные страницы 754
кеш-память
виды 46
клиент, процесс 854
клиент-сервер, программная модель 854
код
профилирование 538
код завершения 697
код инструкции (icode) 379
кодирование инструкций перехода 223
коды
инструкций Y86-64 355
командная оболочка 44
комбинации особых случаев 446
компилятор 39, 184
компиляторы
возможности и ограничения
оптимизации 481
назначение 191
принцип действия 190
компоновщик 39, 184
компоновщики и компоновка 634
инструменты управления объектными
файлами 673
итоги 673
объектные файлы 638
разрешение ссылок 643
таблицы заголовков 658
компоновщик и компоновка 190
компьютерные сети 855
конвейерная обработка 230, 402, 503
глубокая 408
инструкций 526
ограничения 406
работа 404
регистры 403
конвейерная реализация Y86-64
сигналы 415
конвейерное выполнение
операций сохранения 532
конвейерные реализации Y86-64 409
конвейерные регистры 403, 411
конвейер с тремя этапами 404
конечные автоматы 909
конкурентноt выполнение 692
конкурентное выполнение
родительского и дочернего процессов 698
конкурентное программирование 901
конкурентные потоки управления 692
конкурентные приложения 902
конкурентные серверы 881

конкурентный эхо-сервер на основе
процессов 904
конкуренция 692
и параллелизм 57
на уровне потоков 58
передача управления по исключениям 681
константы
в Y86-64 356
диапазонов 98
с плавающей точкой 307
умножение на 128
контакты DRAM 556
контекст потока выполнения 914
контекст процесса 694
контроллеры 43
дисков 567
памяти 556
конфликтные промахи 584
концентраторы 856
копирование при записи 782
корневой каталог 833
косвенный переход 223
критическая секция 926
критические пути, анализ 480

Л
ленивое связывание 667
Леонардо Пизано (Фибоначчи) 64
линейное адресное пространство 753
логика предсказания переходов 230
логические операции
сдвиг 131
логический поток 691
логический поток управления 691
логический синтез 352
логическое проектирование 368
ложная фрагментация 797
локальность 47, 554, 577, 759
впрограммах 616
итоги 579
обращений к данным 577
формы 577
локальные автоматические переменные 921
локальные переменные 258
в регистрах 261
локальные статические переменные 922

М
максимальное из двух чисел с плавающей
точкой 306
мантисса в представлении с плавающей
точкой 139
маршрутизаторы 858
маска сигналов 715
маскирование, операция 86
массивы 264
арифметика указателей 266
базовые принципы 264
вложенные 267
и указатели 79
объявление 265

986

 Предметный указатель

переменного размера 271
представление в машинном коде 191
фиксированного размера 268
шаг обхода 578
масштабный коэффициент в ссылках
на память 201
материнская плата 43
машинный код 184, 190
медленные системные вызовы 729
Международная организация по
стандартизации (International
Standards Organization, ISO) 67
межпроцессные взаимодействия
(Interprocess Communication, IPC) 902
метаданные файлов 842
метастабильные состояния 555
метод Горнера 509
механизмы управления конвейером 444
механизмы явного распределения памяти 786
цели и требования 791
микроархитектура 498
микрооперации 499
микропроцессоры
Core i7 Haswell 502
микропроцессоры Intel
8086 187
80286 188
Core i7, Haswell 188
Core i7, Nehalem 188
i386 188
Pentium III 188
Pentium/MMX 188
эволюция 187
минимальное из двух чисел с плавающей
точкой 306
многозадачность 692, 694
многозонная запись, технология 564
многопоточность 50
многопроцессорные системы 49
многочленов вычисление 509
многоядерные процессоры 49, 188
модель памяти потоков 921
модули памяти 557
модуль управления памятью (Memory
Management Unit, MMU) 753
монокультура безопасности 290
мост ввода/вывода 560
мультимедийные инструкции 298
мультиплексирование ввода/вывода 902, 906
мультиплексоры 370
мьютексы 929

Н
набор, выбор в кеше с прямым
отображением 589
набор готовых дескрипторов 907
набор ожидания 701
наборы дескрипторов 906
наборы кеша 586
наиболее оптимальный, стратегия поиска 796
накопленная сумма 484

наложение указателей 482, 484
наносекунды (нс) 484
НЕ (NOT) операция
булева 82
небезопасная траектория 927
небуферизованный ввод/вывод 837
не выполняется никогда, стратегия
прогнозирования 417
недетерминированное поведение 704
незанятые страницы 754
неизбежные промахи 584
некешированные страницы 754
нелокальные переходы 735
ненормализованные значения, в
представлении с плавающей
точкой 140
неопределенное целое значение 151
непосредственное значение в регистр,
инструкция записи 354
непосредственное смещение 201
непосредственные операнды 200
неравномерное разбиение 406
неявная ведущая 1, представление 140
неявные списки свободных блоков 795
Нильса Хенрика Абель 117
номер виртуальной страницы 763
номер исключения 683
номер физической страницы 763
нормализованные значения,
в представлении с плавающей
точкой 140

О
обертки обработки ошибок в стиле GAI 966
обертки обработки ошибок в стиле Posix 966
обертки обработки ошибок в стиле Unix 965
обертки с поддержкой обработки ошибок 696
облуживание динамического контента 883
обмен данными в сетях 53
обновление, DRAM 555
обновления PC, этап
обработка инструкций 379
последовательная обработка 389
обороты в минуту 562
обработка ошибок в стиле GAI 964
обработка ошибок в стиле Posix 964
обработка ошибок в стиле Unix 964
обработка сигналов 718, 721
обработчики исключений 683
обработчики прерываний 685
обработчики сигналов 714, 718
обратная запись 600
обратная совместимость 67
обратное проектирование
машинный код 185
обратной записи, этап
обработка инструкций 379
последовательная обработка 389
обратный код (дополнение до единиц) 99
обращения к памяти, этап
обработка инструкций 379
последовательная обработка 389

Предметный указатель  987
процессор PIPE 440
трассировка 400
обслуживание статического контента 882
общее нарушение защиты 688
общий шлюзовой интерфейс (Common
Gateway Interface, CGI) 886
объединение (union) 76
объединения 276
доступ к битовым комбинациям 278
объектные файлы 193
выполняемые 638
перемещаемые 637, 638
разделяемые 638
объектный код 190, 193
объявление
объединений 276
объявление указателей 72
объявления
public и private 641
одиночный поток команд, множественный
поток данных (Single-Instruction,
Multiple-Data SIMD) 60
одновременное обслуживание нескольких
сетевых клиентов 902
однопроцессорные системы 49
ожидающие сигналы 714
округление
в представлении с плавающей точкой 146
округление вверх режим 147
округление вниз режим 147
округление в сторону нуля режим 147
округление до ближайшего целого режим 146
округление до четного режим 146
округление при делении 131
окружение вызова 736
операнды регистры 200
операции перемещения и преобразования
данных с плавающей точкой 300
операции сдвига 210, 212
операции с плавающей точкой
заключительные замечания 312
операционная система 48
операционные системы
Unix 67
оптимизация
компилятором 190
уровни 481
оптимизация производительности
программ 41
основная память 43
особые значения, в представлении
с плавающей точкой 141
останов 421
открытие файлов 831
отладка 284
относительная адресация
в Y86-64 356
относительное ускорение 943
отображение в память 660, 785
отображение памяти 761, 780
отрицание
целых в дополнительном коде 123

отрицание целых в дополнительном коде 123
отрицательное переполнение 118
оценка производительности параллельных
программ 942
ошибка аппаратного контроля 689
ошибка деления 688
ошибка обращения к отсутствующей
странице 455
ошибки времени компоновки 41
ошибки при работе с виртуальной памятью
неправильное понимание арифметики
указателей 818
ошибки завышения и занижения на
единицу 817
переполнение буфера на стеке 816
предположение о равенстве указателей и
объектов 816
разыменование недопустимого
указателя 815
ссылка на данные в свободных блоках 818
ссылка на указатель вместо объекта 817
ссылки на несуществующие
переменные 818
утечки памяти 819
чтение неинициализированной памяти 816
ошибки синхронизации 922

П
пакеты 858
память 553
Y86-64 353
в программировании на машинном
уровне 191
иерархия 47
иерерхия 581
основная 556
производительность 530
риски по данным 424
с произвольным доступом (ОЗУ) 554
устройство 378
память, обращения
производительность 495
память с произвольным доступом (ОЗУ) 376
параллелизм 515
SIMD 518
и конкуренция 57
на уровне инструкций 59, 480, 498
параллельное выполнение 692
параллельные вычисления на
многоядерных процессорах 902
параллельные потоки 692
пара сокетов 866
первичные входы логических вентилей 369
первый подходящий, стратегия поиска 796
переадресация ввода/вывода 848
перевод строки, символ 39
передача DMA 570
передача данных, в процедуры 256
передача управления 680
процедуры 252
передача управления по исключению
(Exceptional Control Flow, ECF) 681

988

 Предметный указатель

важность 681
исключения 682
передняя шина (Front Side Bus, FSB) 561
переименование регистров 502
переключение контекста 49
переменные
в многопоточных программах 920
перемещаемый программный код 665
перемещение 638, 652
абсолютных ссылок 656
относительных ссылок 655
ссылок 654
перемещение байта в двойное слово с
расширением знакового разряда 204
перемещение байта в двойное слово с
расширением нулями 204
перемещение байта в слово с расширением
знакового разряда 204
перемещение байта в слово с расширением
нулями 204
перемещение байта в четверное слово с
расширением знакового разряда 204
перемещение байта в четверное слово с
расширением нулями 204
перемещение байта, инструкция 202
перемещение данных 202
перемещение двойного слова в четверное
слово с расширением знакового
разряда 204
перемещение двойного слова,
инструкция 202
перемещение с дополнением нулями 203
перемещение слова в двойное слово с
расширением знакового разряда 204
перемещение слова в двойное слово с
расширением нулями 204
перемещение слова в четверное слово с
расширением знакового разряда 204
перемещение слова в четверное слово с
расширением нулями 204
перемещение слова, инструкция 202
перемещение четверного слова,
инструкция 202
перенастройка синхронизации цепи 411
переносимая обработка сигналов 728
переносимость и типы данных 73
переполнение 65
арифметическое 159
определение 120
при умножении 129
чисел с плавающей точкой 153
переполнение буфера 286
ограничение областей выполняемого
кода 294
переупорядочение операций 520
перехват сигналов 714, 718
переход назад выбирается всегда, вперед
никогда, стратегия
прогнозирования 417
переходом в середину, стратегия
трансляции 237
петлевой адрес 864

пикосекунды (пс) 484
планирование, процессов 694
планировщик 694
пластины, диски 562
плоская адресация 188
плотность записи на диски 564
плотность размещения дорожек
на дисках 564
побочные эффекты 484
поверхности дисков 562
поверхностная плотность записи
на дисках 564
повторяющиеся имена 644
подгонка методом наименьших
квадратов 485
поддомены 863
подкачка (swapping) 758
подмена библиотечных функций (library
interpositioning) 668
во время выполнения 671
во время компиляции 669
во время компоновки 670
подсистема ввода-вывода Unix 53
подставной код (эксплойт exploit code) 289
подсчет интервалов, схема 540
позиционирование операция 565
позиционно-независимые ссылки
на данные 666, 667
позиционно-независимые ссылки на
функции 667
позиционно-независимый код (Position-Independent Code, PIC) 661
показатель степени в представлении с
плавающей точкой 139
полностью конвейерные функциональные
блоки 503
полностью связанные выполняемые
файлы 658
положительное переполнение 118
получение сигналов 717
пользовательский режим 686, 694
попадание в кеш DRAM 756
поразрядные операции с плавающей
точкой 308
порты ввода/вывода 570
порядок байтов
в дизассемблированном коде 225
последним пришел, первым ушел,
принцип 208
последовательная реализация Y86-64 378
анализ 401
последовательное выполнение 218
постоянная память (ROM) 559
построение высокопроизводительных
веб-серверов 663
посылка сигнала 715
посылка сигналов с клавиатуры 716
поток выполнения 914
потоки Posix 915
потоки выполнения 902
потоки выполнения (нити) 50
поток каталога 844

Предметный указатель  989
потоконебезопасные функции 944
правила обработки сигналов 721
преднамеренные исключения 686
предсказание
ветвления 230
предсказание ветвления 499
штраф за неверное предсказание 500
представление программ на машинном
уровне
обзор 184
представление с плавающей точкой
одинарной точности 73
представление с плавающей точкой в
программах 135
на языке C 150
округление 146
особые значения 141
поддержка 72
половинная точность 162
стандарт IEEE 754 139
представление целых без знака 92
представление чисел с плавающей точкой
арифметика 65
кодирование 65
представления
в дополнительном коде 94
программного кода 81
строк 80
целочисленные 90
преобразование 64-разрядного целого в
число с плавающей точкой двойной
точности 302
преобразование 64-разрядного целого
в число с плавающей точкой
одинарной точности 302
преобразование адресов 762
преобразование с усечением числа
с плавающей точкой двойной
точности в целое 301
преобразование с усечением числа
с плавающей точкой двойной
точности в 64-разрядное целое 301
преобразование с усечением числа
с плавающей точкой одинарной
точности в 64-разрядное целое 301
преобразование с усечением числа
с плавающей точкой одинарной
точности в целое 301
преобразование целого в число
с плавающей точкой двойной
точности 302
преобразование целого в число с
плавающей точкой одинарной
точности 302
преобразование четверного слова в
восьмерное слово 215
преобразования
в десятичное представление 71
в нижний регистр 491
препроцессор 39, 190
препятствование оптимизации 479
приведение типа 76

приведение типов
явное 109
привилегированные инструкции 694
привилегированный режим 686
привилегированный режим выполнения 684
прикладной программный интерфейс (Application Program Interface, API) 60
приоритет операторов сдвига 89
проблема производитель–потребитель 931
проблема читателей–писателей 933
пробуксовка (thrashing) 759
пробуксовка, кеша 593
прогнозирование
в конвейерной реализации Y86-64 416
прогнозирование ветвления 416
прогнозирование ветвлений
штраф за неверное прогнозирование 526
программирование на машинном уровне
доступ к информации 198
историческая перспектива 187
программируемая постоянная память 559
программные объекты 68
программные регистры
риски по данным 424
программный код на машинном уровне 190
примеры 192
программ, профилирование 538
программы
из инструкций Y86-64 361
и процессы 709
программы CGI 887
продвижение данных 423
проектирование логики
комбинационные цепи 369
принадлежность к множеству 375
производительность
выражение 484
вытеснение регистров 525
кешей 602
низкоуровневая оптимизация 538
обзор 478
ограничивающие факторы 525
операций загрузки 531
последовательной реализации Y86-64 402
пример программы 487
стратегии повышения 537
производитель–потребитель, модель 931
произвольная стратегия замены 583
промахи емкости 584
промах кеша 455
промах кеша DRAM 757
промежутки между секторами диска 563
пропускная способность 503
пространства адресов 753
пространственная локальность 577, 585
переупорядочение циклов 612
пространство пользователя 685
протокол передачи гипертекста (Hypertext
Transfer Protocol, HTTP) 881
профилирование кода 480
процедура потока выполнения 915
процедуры 250

990

 Предметный указатель

операции с плавающей точкой 305
рекурсивные 263
процессор 43
процессоры
многоядерные 49, 576
обзор 349
суперскалярные 59
тенденции 574
эффективное время цикла 574
процессы 660, 690
абстракция 49
выполнение, состояние 697
завершение, состояние 697
коды ошибок 703
приостановка, состояние 697
утилизация дочерних процессов 701
прямой доступ к памяти (Direct Memory
Access, DMA) 45, 570
прямой код (величина знака) 99
прямой переход 223
прямой (тупоконечный, big endian) порядок
следования байтов 74
пузырьки, в конвейерной обработке 422

Р
рабочее множество 584
рабочий набор 759
равномерное приближение к нулю 141
разбиение свободных блоков 796
разблокировка мьютекса 929
развертывание цикла 485
развертывание циклов 510, 515
k × 1a 521
обзор 510
разделение времени 692
раздельная компиляция 634
разделяемые библиотеки 53, 661
разделяемые объекты 661, 781
размер машинного слова 71
размер слова 43
размеры
данных 71
разрешение имен 638
разрешение ссылок
на повторяющиеся имена 644
разыменование указателей 79
распространение программного
обеспечения 662
расширение битового представления 106
расширение знакового разряда 106, 204
расширение набора переменныхаккумуляторов 545
расширительные слоты 570
реализация механизма распределения
динамической памяти
выделение блоков 806
освобождение и объединение блоков 805
создание списка свободных блоков 803
реализация механизма распределения
памяти
константы и макроопределения 802
метод близнецов 810

простое разделение памяти 809
разделение с учетом размера 809
раздельные списки свободных блоков 808
сборка мусора 811
реализация управляющей логики в
конвейерной обработке 448
регистра спецификатор в Y86-64 356
регистров блок 355
регистр состояния
риски по данным 424
регистр флагов
риски по данным 424
регистры 43
блоки регистров 191
локальные 506
программные 352
только для записи 506
только для чтения 506
цикла 506
регистры кодов условий 191
регистры общего назначения 198
реентерабельные функции 722, 946
режим супервизора 694
режимы адресации 201
резидентный набор 759
риски конвейерной обработки 418
риски по данным 418
классы 424
риски по загрузке / использованию данных
решение 428
риски по управлению 418, 429
Ритчи, Деннис 67
Ричард У. Стивенс (W. Richard Stevens) 729
родительский каталог 832
родительский процесс 697
роль указателей в C 68
рычаг актуатора 565

С
сбои 686
сбой страницы 688
сборщики мусора 787
Свифт, Джонатан 75
сдвига, операции
для умножения 128
сегмент Ethernet 856
сегмент данных 658
сегмент кода 658
секторы дисков 563
семафоры 928
сервер, процесс 854
сетевой порядок следования байтов 861
сетевые адаптеры 570
сетевые приложения
запросы 855
сети
всемирная сеть интернет 860
механизм доставки 858
соединения 866
программные порты 866
сигналы 681, 697, 712

Предметный указатель  991
прием 714
терминология 714
символические методы 451
символические ссылки 832
символьные устройства 832
синхронизация 376
в последовательной реализации SEQ 391
синхронизация потоков 730
синхронизированные регистры 389
система компиляции 39
системная шина 560
системные вызовы 50
обработка ошибок 695
системные прерывания 686
сквозная запись 600
скорость вращения дисков 562
слабое масштабирование 943
слабо определенные имена 644
следующий подходящий, стратегия
поиска 796
слова 43, 197
размер 43
сложение
Y86-64 354
с плавающей точкой 306
целых в дополнительном коде 118
чисел с плавающей точкой 149
слой трансляции флеш-памяти 572
смещение
при делении 132
смещение в виртуальной странице 763
смещение в физической странице 764
событие 682
совместно используемые переменные 922
современные процессоры,
производительность 498
сокеты 866
сокращение задержек путем откладывания
выполнения 902
сопряжение с системой памяти 455
сортировки производительность 541
составные типы данных 191
состояние 682
бистабильное 554
видимое программисту 352
состояние гонки 948
спекулятивное выполнение 499, 527
сравнение
с плавающей точкой 309
сравнение байтов, инструкции 219
ссылки на память
операнды 201
стандартная библиотека ввода/
вывода 830, 849
стандартная библиотека С 40
стандартный ввод 831
стандартный вывод 831
стандартный вывод ошибок 831
статическая память (SRAM) 554
статическая память с произвольным
доступом (Static Random Access
Memory, SRAM) 47

статические компоновщики 637
статический контент 882
стек 53, 208
кадры переменного размера 295
обнаружение повреждений 292
стираемая программируемая постоянная
память 559
страничные блоки 754
стратегия вытеснения наиболее давно
использовавшихся блоков (Least
Recently Used, LRU) 583
стратегия размещения 584, 796
строго определенные имена 644
строка описания формата 79
структура программы 63
структуры 273
в программировании на машинном
уровне 191
структуры данных
выравнивание данных 280
структуры 273
суперскалярные микропроцессоры 498
суперскалярные операции 456
суперскалярные процессоры 59
суперъячейки 556
схема именования 858
счетные семафоры 929
счетчики инструкций
%rip 191
счетчики инструкций (PC)
на этапе выборки 379
счетчик инструкций 76
риски по данным 424
счетчик инструкций (PC)
архитектура набора команд Y86-64 353
счетчик команд 43
С язык
логические операции 87
операции сдвига 88
представление чисел с плавающей
точкой 150

Т
таблица
векторов прерываний 683
исключений 683
процессов 694
страниц 694
файлов 694
таблица виртуальных узлов v-node 845
таблица дескрипторов 845
таблица заголовков в формате ELF 638
таблица имен 639
таблица связывания процедур (Procedure
Linkage Table, PLT) 667
таблица файлов 845
таблицы
заголовков сегментов 658
таблицы переходов 247
твердотельные диски 560, 572
текстовые строки 832

992

 Предметный указатель

текстовые файлы 39, 832
текст, представление
ASCII 80
Юникод (Unicode) 81
текущий рабочий каталог 833
тестирование проекта 450
типы
имена 78
на машинном уровне 197
связь с указателями 68
топологическая сортировка 699
точки входа 659
точность, в представлении с плавающей
точкой 140
транзакции шины 560
транзакция записи 560
транзисторы в законе Мура 189
трансляция адресов 753
трассировка выполнения 381
требования к выравниванию 280
тупиковые ситуации 951

У
увеличение объема динамической
памяти 797
узкие места
профилировщиков 540
Уильям Кахан 135
указатели 68, 283
на функции 284
приведение к другому типу 283
создание 206
стека 251
указатель кадра 296
уменьшение высоты дерева 545
умножение
инструкции 215
целых без знака на степень двойки 128
целых в дополнительном коде на степень
двойки 129
чисел с плавающей точкой 149
умножение с плавающей точкой 306
умножение целых без знака 124
умножение целых в дополнительном
коде 124
унарные (одноместные) операции 212
универсальная последовательная шина
(Universal Serial Bus, USB) 569
управление зависимостями в конвейерной
обработке 408
управление кешем 584
управление потоком
переходы 222
процедуры 250
управления, поток 680
управляемые событиями, программы 909
управляющая логика в конвейерной
обработке 441
выявление особых случаев 444
обработка особых случаев 441
управляющие структуры 218
флаги условий 218

уровни
оптимизации 481
усечение чисел 109
условное ветвление 192
в ассемблерном коде 227
потока данных 229
установить, если равно, инструкция 220
установка обработчика 718
устройства ввода-вывода 43
уязвимость в getpeername 114

Ф
файл подкачки 781
файлы 53, 831
абсолютный путь 833
абстракция 49
блочные устройства 832
выполняемые 40
выполняемые объектные 39
двоичные 39
закрытие 832
каталоги 832
обычные 832
открытие файла 833
относительный путь 833
позиция в файле 831
пути к 833
совместное использование 845
создание файла 834
сокеты 832
текстовые 39
тип 832
чтение и запись 831
Фибоначчи (Пизано) 64
физическая адресация 752
физические адреса 752
физические страницы 754
физическое адресное пространство 753
флаг знака (sign flag) 218
флаги доступа к памяти 294
флаги условий
Y86-64 353
флаг переноса (carry flag) 218
флаг переполнения (overflow flag) 218
флаг признак нуля (zero flag) 218
флеш-память 560
фоновый режим 712
формат выполняемых и компонуемых
модулей (Executable and Linkable
Format, ELF) 638
форматированный вывод 79
формат переносимых выполняемых
файлов (Portable Executable, PE) 638
формат скалярных данных 298
формат элементов таблиц страниц 774
формула строгого масштабирования 943
фрагментация памяти 792
функции
в инструкциях Y86-64 355
функции ввода/вывода Unix 830
функции-обертки 672

Предметный указатель  993
функции системного уровня 689
функциональные блоки 500, 503
функция вывода сообщения об ошибке 696
функция инструкции (ifun) 379

Х
хеш-функция 542
холодные промахи 584
хранение информации 68

Ц
целочисленная арифметика
обзор 134
целочисленные представления 90
целочисленные типы 91
фиксированного размера 95
целые без знака 98
кодирование 65
целые со знаком 72, 91, 98
кодирование 65
преобразование в целые без знака 99
целые числа 65, 89
порядок следования байтов 74
советы по приемам работы 111
центральные процессорные устройства (CPU)
первоначальные наборы команд 359
циклы 235
do-while 235
неэффективности 490
обратное проектирование
(воссоздание) 236
цилиндры дисков 563

Ч
четверные слова 197
четности флаг условия 197

Ш
шаблон обращений с шагом 1 578
шаблон обращений с шагом k 578
шаблон последовательных обращений 578
шина для подключения периферийных
компонентов (Peripheral Component
Interconnect, PCI) 569
шины 43, 560
архитектуры 561, 569
шпиндель диска 562

Э
эксабайты 72
элементы таблицы страниц 755
энергонезависимая память 559
эталонные машины 489
этапы
реализация в последовательной версии
Y86-64 394
эфемерный порт 866
эффективный адрес 201
эхо-сервер, пример 879

Ю
Юникод (Unicode) набор символов 81

Я
явное ожидание сигналов 732
ядро операционной системы 683
язык описания гипертекстовых
документов (Hypertext Markup
Language, HTML) 882
ячейки
DRAM 556
SRAM 554

Книги издательства «ДМК ПРЕСС» можно купить оптом и в розницу
в книготорговой компании «Галактика»
(представляет интересы издательств
«ДМК ПРЕСС», «СОЛОН ПРЕСС», «КТК Галактика»).
Адрес: г. Москва, пр. Андропова, 38;
Тел.: +7(499) 782-38-89. Электронная почта: books@alians-kniga.ru.
При оформлении заказа следует указать адрес (полностью),
по которому должны быть высланы книги;
фамилию, имя и отчество получателя.
Желательно также указать свой телефон и электронный адрес.
Эти книги вы можете заказать и в интернет-магазине:
www.galaktika-dmk.com

Рэндал Э. Брайант
Дэвид Р. О'Халларон

Компьютерные системы: архитектура и программирование
3-е издание

Главный редактор

Мовчан Д. А.

dmkpress@gmail.com

Зам. главного редактора
Перевод
Корректор
Верстка
Дизайн обложки

Сенченкова Е. А.
Киселев А. Н.
Синяева Г. И.
Паранская Н. В.
Мовчан А. Г.

Гарнитура PT Serif. Печать цифровая.
Усл. печ. л. 80.76. Тираж 200 экз.
Веб-сайт издательства: www.dmkpress.com