Одна мысль за раз

30 карточек и 23 шаблона кода (№ 2–24). Прочитай → закрой → объясни по памяти. Полные ответы сохранены под каждой темой.

Перейти к написанию кода ↓

1 · Архитектура фон Неймана

Команды и данные — в общей памяти

Выбрать команду → Разобрать → Выполнить

Точность: В современных CPU кэши команд и данных могут быть раздельными. Общая модель памяти от этого не исчезает.

Вопрос для памяти

Назови пять компонентов и три стадии исполнения.

Ключ

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

Полный ответ для зачёта

Архитектура фон Неймана основана на хранении команд и данных в общей адресуемой памяти. Основные компоненты: процессор с арифметико логическим устройством, устройством управления и регистрами; память; устройства ввода и вывода; связи между ними. Счётчик команд указывает, откуда взять следующую инструкцию. Цикл работы включает выборку, декодирование и исполнение. Команды обычно идут последовательно, а переходы меняют порядок. Обмен процессора с памятью ограничивает производительность — это узкое место фон Неймана.

Загрузить два числа из памяти → сложить в АЛУ → сохранить результат.

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

2 · Конвейеризация

Разные команды — на разных стадиях

Команда A: исполнение → B: декодирование → C: выборка

Точность: Это снимок стадий одновременно. Одна команда не обязательно выполняется быстрее; конвейер не означает несколько команд за такт.

Вопрос для памяти

Почему ветвление может замедлить конвейер?

Ключ

Нужно знать следующий адрес; ошибочно выбранный путь сбрасывается.

Полный ответ для зачёта

Конвейер делит исполнение команды на стадии, например выборку, декодирование, выполнение, доступ к памяти и запись результата. Стадии разных команд перекрываются во времени. После заполнения конвейера повышается пропускная способность, но задержка одной команды не обязана уменьшаться. Мешают конфликты по данным, ресурсам и управлению. Используют остановки, перенаправление результатов и предсказание переходов. Ошибка предсказания требует сбросить неверно начатые команды и теряет такты.

Пять стадий не означают пять команд за такт: простой конвейер в идеале завершает одну команду за такт.

Конвейеризация и суперскалярность — разные способы ускорения.

3 · Регистры процессора

Регистры — рабочие ячейки внутри CPU

Операнды → Флаги → Адреса

Точность: Сегментные регистры x86 связаны с сегментацией. Это группы по назначению, не ступени скорости.

Вопрос для памяти

Чем регистр флагов отличается от регистра общего назначения?

Ключ

Хранит признаки и управление, а не произвольный операнд программы.

Полный ответ для зачёта

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

После сравнения чисел условный переход использует флаги.

Устройство сегментных регистров зависит от архитектуры и режима CPU.

4 · Тактовая частота

Такты в секунду — ещё не полезная работа

6 млрд тактов → ÷ 3 ГГц → ≈ 2 секунды

Точность: CPI — среднее число тактов на инструкцию. Сравнение по частоте верно только при прочих равных.

Вопрос для памяти

Назови два фактора производительности кроме частоты.

Ключ

IPC или CPI, память, кэш, число ядер при параллельной нагрузке.

Полный ответ для зачёта

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

Если программа требует 6 миллиардов тактов, при 3 ГГц это около 2 секунд CPU, при неизменных остальных условиях.

1 ГГц не означает миллиард выполненных инструкций в секунду.

5 · Прерывания

Событие → обработчик → возможный возврат

Сохранить состояние → Обработать событие → Вернуться или завершить

Точность: Исключения возникают при исполнении команд. Не после каждого сбоя можно просто продолжить; системный вызов может использовать отдельную инструкцию.

Вопрос для памяти

Зачем нужна таблица векторов?

Ключ

Сопоставить номер события с точкой входа обработчика.

Полный ответ для зачёта

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

Таймер даёт ОС возможность переключить выполняемый поток.

Не всякое исключение позволяет продолжить сбойную инструкцию без исправления причины.

6 · Режимы привилегий

Приложение просит — ядро проверяет

Приложение → Системный вызов → Ядро

Точность: Кольца 1 и 2 тоже есть. Обычная библиотечная функция не обязательно вызывает ядро.

Вопрос для памяти

Почему обычная функция библиотеки не всегда является системным вызовом?

Ключ

Она может полностью выполняться в пользовательском режиме.

Полный ответ для зачёта

Режимы привилегий ограничивают допустимые инструкции и доступ к памяти и устройствам. В x86 предусмотрены кольца 0–3: кольцо 0 наиболее привилегированное, кольцо 3 наименее привилегированное. Типичная ОС использует их для ядра и пользовательских программ. Для защищённой операции приложение выполняет системный вызов, а ядро проверяет аргументы и права. Нарушение защиты вызывает исключение. Такое разделение обеспечивает изоляцию, устойчивость и безопасность системы.

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

Кольца 1 и 2 существуют в x86, но обычно не нужны обычной схеме ядро плюс приложения.

7 · Кэш и когерентность

Кэш хранит рядом; когерентность согласует копии

L1 → L2 / L3 → ОЗУ

Точность: Размеры и разделение кэшей зависят от CPU. Когерентность не делает counter++ атомарным.

Вопрос для памяти

Почему counter++ остаётся опасным при когерентных кэшах?

Ключ

Это чтение, изменение и запись; два потока могут потерять обновление.

Полный ответ для зачёта

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

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

Когерентность не заменяет мьютекс и не гарантирует нужный порядок всех операций программы.

8 · Суперскалярность и внеочередное выполнение

Несколько команд; готовые могут идти раньше

Ждёт память → Независимая готова → Её можно выполнить

Точность: Переименование регистров убирает ложные зависимости. Истинную зависимость результата обходить нельзя.

Вопрос для памяти

В чём отличие от обычного конвейера?

Ключ

Конвейер перекрывает стадии; суперскалярность допускает несколько команд за такт.

Полный ответ для зачёта

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

Пока одна команда ждёт чтение памяти, независимое сложение может быть выполнено.

Внеочередное выполнение не разрешает игнорировать истинную зависимость результата одной команды от другой.

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

Адрес процесса и физический адрес — разные

Адрес процесса → Отображение → Физическая память

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

Вопрос для памяти

Чем страница отличается от сегмента?

Ключ

Страница фиксированного размера; сегмент отражает логическую область и имеет переменный размер.

Полный ответ для зачёта

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

Две соседние виртуальные страницы могут лежать в несоседних физических кадрах.

Виртуальная память — не просто дополнительная память на диске.

10 · Таблицы страниц и трансляция адреса

Номер страницы меняется; смещение остаётся

Страница 1 + 0x234 → Кадр 9 + 0x234 → Адрес 0x9234

Точность: Пример для страниц 4096 байт. Промах TLB не равен page fault: трансляция может находиться в таблице.

Вопрос для памяти

Что меняется при трансляции, а что остаётся?

Ключ

Номер страницы заменяется номером кадра, смещение не меняется.

Полный ответ для зачёта

MMU преобразует виртуальный адрес в физический. Адрес разбивается на номер виртуальной страницы и смещение. TLB кэширует недавние трансляции; при промахе выполняется обход таблиц страниц аппаратурой или с участием ОС, в зависимости от архитектуры. Запись таблицы содержит номер кадра и признаки присутствия, доступа и изменения. После проверки прав физический адрес получается из номера кадра и исходного смещения. Если страница отсутствует или доступ запрещён, возникает исключение page fault, которое обрабатывает ОС.

Страница 4096 байт, адрес 0x1234: страница 1, смещение 0x234. Если её кадр 9, физический адрес 0x9234.

Промах TLB не равен page fault: запись может существовать в таблице страниц.

11 · Замещение страниц

Нет свободного кадра — выбери жертву

Нужен кадр → Выбрать страницу → Вытеснить

Точность: Изменённую страницу может понадобиться записать. Слишком частая подкачка вызывает thrashing; точный LRU затратен.

Вопрос для памяти

Что учитывает Clock?

Ключ

Бит недавнего обращения и циклический просмотр кандидатов.

Полный ответ для зачёта

Алгоритм замещения выбирает страницу для вытеснения, когда необходимо освободить физический кадр. FIFO удаляет страницу, загруженную раньше остальных; LRU — ту, к которой дольше всего не обращались; Clock приближает оценку недавнего использования через бит обращения. Оптимальный алгоритм вытеснил бы страницу с самым далёким будущим обращением, но будущее неизвестно, поэтому это эталон сравнения. Изменённую страницу может потребоваться записать в хранилище. Слишком частые вытеснения вызывают thrashing — система тратит большую часть времени на подкачку.

При кадрах [1,2,3] и загрузке 4 FIFO вытесняет 1, если она была загружена первой.

LRU и FIFO могут выбирать разные страницы: возраст загрузки не равен давности обращения.

12 · Фрагментация памяти

Внутри блока — внутренняя; между — внешняя

Свободно 6 КБ → Ещё 6 КБ отдельно → Блок 10 КБ не влезает

Точность: Это пример внешней фрагментации при требовании непрерывного блока. Страницы не устраняют внутренние потери.

Вопрос для памяти

Потеря 3 КБ внутри выделенной страницы — какая фрагментация?

Ключ

Внутренняя.

Полный ответ для зачёта

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

Нужен блок 10 КБ, а свободны два несоседних по 6 КБ — пример внешней фрагментации.

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

13 · Динамическая память в C

Выделить → проверить → использовать → освободить

tmp = realloc(p, size) → Проверить tmp → После успеха p = tmp

Точность: Здесь size > 0. При отказе старый блок сохранён. Не каждый malloc вызывает ядро; free не обязан сразу возвращать память ОС.

Вопрос для памяти

Возвращает ли free память ОС немедленно?

Ключ

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

Полный ответ для зачёта

malloc выделяет запрошенное число байтов без инициализации, calloc выделяет массив и обнуляет его байты. realloc изменяет размер блока, может переместить его и сохраняет старые данные в пределах меньшего размера. При неудаче realloc с ненулевым размером возвращает NULL, а старый блок остаётся действительным. free освобождает блок; free(NULL) допустим. Это функции библиотеки, а не прямые системные вызовы. Аллокатор управляет аренами и свободными блоками и при необходимости запрашивает память у ОС; в Linux для этого могут использоваться brk и mmap. Каждый malloc не обязан обращаться к ядру.

int *tmp = realloc(p, bytes); if (tmp != NULL) p = tmp; Размер bytes заранее проверен и больше нуля.

Нельзя сразу писать p = realloc(p, bytes): при неудаче теряется единственный адрес старого блока. Здесь не используется realloc(p, 0).

14 · Утечки памяти и инструменты

Утечка — выделил и не освободил вовремя

Выделили → Потеряли последний адрес → Блок остался занят

Точность: Утечка не равна use after free. Проверка утечек через LeakSanitizer зависит от платформы; тесты не доказывают отсутствие всех ошибок.

Вопрос для памяти

Чем утечка отличается от висячего указателя?

Ключ

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

Полный ответ для зачёта

Утечка памяти — сохранение выделенной памяти после того, как она перестала быть нужна, без возможности или намерения своевременно её освободить. Типичная причина — потеря последнего указателя или пропуск free на ветке ошибки. Valgrind Memcheck отслеживает обращения к памяти и сообщает, в частности, об утечках и недопустимых доступах. AddressSanitizer добавляет проверки при компиляции и помогает найти выходы за границы, use after free и другие ошибки. Обнаружение утечек через LeakSanitizer зависит от платформы и настроек. Отсутствие отчёта не доказывает отсутствие ошибок на непроверенных путях.

clang -std=c11 -g -fsanitize=address -fno-omit-frame-pointer main.c -o app В Linux: valgrind --leak-check=full ./app

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

15 · Стек и куча

Стек — обычно до выхода; куча — до free

Локальный p → Указывает на блок → Блок живёт до free

Точность: C задаёт длительность хранения, а не обязательный физический стек. Доступ к куче не обязательно медленнее: важны кэши.

Вопрос для памяти

Можно ли вернуть указатель на malloc блок?

Ключ

Да, если блок ещё существует и вызывающая сторона получает обязанность освободить его.

Полный ответ для зачёта

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

В int *p = malloc(10 * sizeof *p) локальный p и выделенный блок имеют разное время жизни.

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

16 · Выравнивание данных

Адрес должен подходить требованиям типа

char: 1 байт → Возможный padding → int: например 4 байта

Точность: Размеры и выравнивание зависят от реализации. Невыровненный доступ через указатель может нарушить правила C.

Вопрос для памяти

Почему нельзя всегда вычислять размер структуры сложением полей?

Ключ

Есть внутренний и завершающий padding.

Полный ответ для зачёта

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

struct { char c; int x; }; часто занимает 8 байтов при char=1, int=4 и выравнивании int=4, но это не универсальная гарантия C.

Упаковка структуры может уменьшить размер, но усложнить или замедлить доступ.

17 · Семафор Дейкстры

Семафор считает разрешения

Есть 3 разрешения → P → Осталось 2

Точность: Обычный счётчик int с -- не становится семафором. Внутренняя реализация счётчика может отличаться от учебной модели.

Вопрос для памяти

Что происходит при P, когда разрешений нет?

Ключ

Поток ждёт разрешение, обычно блокируясь.

Полный ответ для зачёта

Семафор — примитив синхронизации с неотрицательным числом доступных разрешений и атомарными операциями P и V. P, или wait, получает разрешение, уменьшая счётчик, либо блокируется до его появления. V, или post, возвращает разрешение и позволяет ожидающему потоку продолжить. Счётный семафор ограничивает число одновременных пользователей ресурса, двоичный моделирует доступность одного разрешения. Конкретное внутреннее представление счётчика зависит от реализации. Атомарность операций необходима, чтобы два потока не получили одно разрешение.

Семафор с начальным значением 3 допускает до трёх одновременных пользователей ресурса.

Обычный int и операция -- не образуют корректный семафор между потоками.

18 · Двоичный семафор и мьютекс

Мьютекс — владелец; семафор — разрешение

Мьютекс: захватил → Защитил данные → Сам освободил

Точность: Счётный семафор, созданный с 1, не всегда ограничен единицей: лишние post могут увеличить значение.

Вопрос для памяти

Кто имеет право unlock обычного мьютекса?

Ключ

Поток владелец.

Полный ответ для зачёта

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

Поток производителя делает post после подготовки данных, потребитель выполняет wait.

Обычный счётный семафор с начальным значением 1 не всегда принудительно ограничен единицей: лишние post могут увеличить счётчик.

19 · Взаимная блокировка

Держу A, жду B; держишь B, ждёшь A

Поток 1: A → ждёт B → ↔ → Поток 2: B → ждёт A

Точность: Это четыре условия Коффмана. В исходном вопросе фамилия указана иначе; механизм остаётся тем же.

Вопрос для памяти

Перечисли четыре условия без подсказки.

Ключ

Взаимное исключение; удержание и ожидание; отсутствие принудительного отъёма; цикл ожидания.

Полный ответ для зачёта

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

Все потоки сначала берут A, потом B — цикл A→B→A не возникнет.

В исходном списке написано «по Коберну»; общепринятое название — условия Коффмана. Четыре условия описывай уверенно, спорить о фамилии не требуется.

20 · Критическая секция

Общие данные — общий протокол защиты

lock(m) → counter++ → unlock(m)

Точность: Потоки должны использовать один и тот же замок. Защита записи не оправдывает несинхронизированное чтение.

Вопрос для памяти

Почему два разных мьютекса не защищают один счётчик?

Ключ

Потоки могут одновременно владеть разными замками и войти в секцию.

Полный ответ для зачёта

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

lock(m); counter++; unlock(m); Все потоки используют один и тот же m.

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

21 · Условные переменные

Проснулся — проверь условие снова

while (empty) → wait(cv, m) → Проверить empty снова

Точность: Условная переменная не копит разрешения. Данные хранят состояние; пробуждение не гарантирует, что условие уже истинно.

Вопрос для памяти

Кто хранит факт наличия работы — cv или очередь?

Ключ

Очередь и её состояние под мьютексом; cv только помогает ожидать изменение.

Полный ответ для зачёта

Условная переменная используется вместе с мьютексом и предикатом над общими данными. wait атомарно отпускает мьютекс и начинает ожидание, а перед возвратом вновь захватывает его. Условие проверяют в цикле while, потому что возможны ложные пробуждения и изменение состояния другим потоком. signal будит одного ожидающего, broadcast — всех. В отличие от семафора, условная переменная не накапливает разрешения: состояние хранится в защищаемых данных.

lock(m); while (empty) wait(cv, m); take_item(); unlock(m); Это псевдокод.

if вместо while не гарантирует истинность условия после пробуждения.

22 · Состояние гонки и гонка данных

Два раза прочитали 10 — два раза записали 11

Поток A читает 10 → Поток B читает 10 → Оба пишут 11

Точность: Это иллюстрация потерянного обновления. Для неатомарного общего int гонка данных в C означает UB, а не гарантированные 11.

Вопрос для памяти

Достаточно ли атомарных чтения и записи для атомарного увеличения?

Ключ

Нет; нужна атомарная read modify write операция либо замок.

Полный ответ для зачёта

Race condition — зависимость корректности результата от непредсказуемого порядка выполнения. Data race в модели C возникает при конфликтующих действиях разных потоков над одной областью памяти, когда хотя бы одно действие — неатомарная запись и нет требуемого отношения синхронизации. Такая гонка данных приводит к неопределённому поведению. Защита: мьютексы, подходящие атомарные операции, разделение владения или передача сообщений. Атомарность отдельных обращений не всегда устраняет логическую гонку составной операции.

Проверить, что очередь не пуста, и удалить элемент нужно как одно согласованное действие.

volatile не делает counter++ атомарным и не создаёт межпоточную синхронизацию.

23 · Алгоритм Петерсона

Два флага желания и очередь turn

flag[i] = true → turn = j → Ждать flag[j] && turn == j

Точность: Петерсон — для двух участников. В C нужны атомарные объекты и подходящий порядок памяти; обычные переменные и volatile не подходят.

Вопрос для памяти

Зачем turn, если уже есть два флага?

Ключ

Разрешить конфликт, когда оба одновременно хотят войти.

Полный ответ для зачёта

Алгоритм Петерсона обеспечивает взаимное исключение для двух участников с флагами желания войти и переменной очереди. Участник i устанавливает flag[i], уступает очередь j и ждёт, пока flag[j] истинен и turn равен j. После секции он сбрасывает свой флаг. При классических предположениях атомарных обращений и последовательной согласованности достигаются взаимное исключение, прогресс и ограниченное ожидание. В современном C обычные общие переменные дают гонку данных; для учебной реализации требуются атомарные объекты с подходящим порядком памяти, например sequential consistency. volatile этого не исправляет.

Псевдокод: flag[i]=true; turn=j; while(flag[j] && turn==j) {} critical_section(); flag[i]=false;

Классический алгоритм предназначен для двух потоков и использует активное ожидание.

24 · Указатели и ссылки

p — адрес; *p — значение; &x — адрес x

int x = 7 → int *p = &x → *p = 9 → x равен 9

Точность: Сдвиг допустим в границах массива и до позиции за концом. Ссылка C++ — другой механизм, не синтаксис C.

Вопрос для памяти

Чем отличаются p++, (*p)++ и *p++?

Ключ

Следующий указатель; увеличение значения; получить прежнее значение и затем сдвинуть указатель.

Полный ответ для зачёта

Указатель — объект, хранящий адрес другого объекта или функции. В выражении &var получают адрес var, а *ptr обозначает объект по адресу ptr, если указатель действителен. ptr++ перемещает указатель к следующему элементу его типа, а не увеличивает хранимое число. В C нет ссылок как отдельного типа языка; для сравнения, ссылка C++ обычно служит псевдонимом существующего объекта, должна быть инициализирована и не переназначается как указатель. Указатель можно переназначить и присвоить ему NULL.

int x=7; int *p=&x; *p=9; Теперь x равен 9. Выражение (*p)++ увеличило бы x.

Нельзя выполнять p++ и затем разыменовывать его, если p указывал на одиночный x: там нет второго элемента.

25 · Указатель на функцию

Передай правило как функцию

positive(x): x > 0 → Передать positive → Фильтр вызывает pred(x)

Точность: Передают positive, не positive(3). Тип функции должен совпадать; int *f(int) объявляет другую вещь.

Вопрос для памяти

Прочитай int (*pred)(int) словами.

Ключ

pred — указатель на функцию, которая принимает int и возвращает int.

Полный ответ для зачёта

Указатель на функцию хранит адрес функции совместимого типа и позволяет вызывать её косвенно. Объявление int (*pred)(int) задаёт указатель на функцию с аргументом int и результатом int. Вызов pred(x) эквивалентен (*pred)(x). Такие указатели применяют для обратных вызовов, предикатов, обработчиков событий и таблиц операций. Тип аргументов и возвращаемого значения должен соответствовать вызываемой функции. Скобки в объявлении существенны: int *f(int) объявляет функцию, возвращающую указатель.

int positive(int x) { return x > 0; } Затем find_if(a, n, positive).

Передают positive, а не positive(3): второе уже вызывает функцию и даёт число.

26 · Арифметика указателей

Прибавил 1 — перешёл на один элемент

int* p, адрес 1000 → p + 1 → 1004, если sizeof(int)=4

Точность: Адреса условные. Не формируй a-1; NULL+0 и арифметика void* не являются допустимыми приёмами стандартного C.

Вопрос для памяти

Можно ли читать *(a+n) при длине n?

Ключ

Нет: это позиция сразу за массивом.

Полный ответ для зачёта

К указателю на элемент массива можно прибавлять и вычитать целое число в границах того же массива, включая позицию сразу за его концом. Смещение на k соответствует k элементам, а не k байтам. Указатель за концом допустим как граница, но его нельзя разыменовывать. Разность двух указателей внутри одного массива даёт число элементов и имеет тип ptrdiff_t, если результат представим. Складывать два указателя нельзя. Арифметика void* не входит в стандартный C; для обхода байтов используют unsigned char*.

Если sizeof(int)=4, то p+3 соответствует смещению на 12 байтов. *(a+2) обозначает третий элемент.

Нельзя даже формировать указатель a-1, чтобы потом сравнить его с a. NULL+0 также не является допустимым способом получить границу массива.

27 · Квалификатор volatile

volatile — особые обращения, не замок

volatile counter → counter++ → Всё ещё не атомарно

Точность: volatile не требует обходить кэш CPU. Правила доступа зависят от реализации; для простого сигнального флага используют volatile sig_atomic_t.

Вопрос для памяти

Защитит ли volatile int counter операцию counter++?

Ключ

Нет.

Полный ответ для зачёта

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

volatile sig_atomic_t stop; может применяться как простой флаг в обработчике сигнала при соблюдении ограничений C.

volatile не означает «значение обязательно каждый раз берётся из физической ОЗУ, минуя кэш».

28 · Препроцессор C

Препроцессор готовит текст до компиляции

SQR(2 + 3) → ((2 + 3) * (2 + 3)) → 25

Точность: Для #define SQR(x) ((x)*(x)). Скобки не спасают SQR(i++): аргумент вычисляется повторно.

Вопрос для памяти

Чем #ifdef отличается от обычного if?

Ключ

Выбирает текст при подготовке исходника, а if выбирает действие во время выполнения.

Полный ответ для зачёта

Препроцессор выполняет обработку директив до компиляции C. #include включает содержимое заголовка, #define определяет объектный или функциональный макрос, #if, #ifdef и #endif управляют условной компиляцией. Защита от повторного включения строится через include guards. Макрос подставляет токены и не является обычной типизированной функцией, поэтому аргументы и всё выражение заключают в скобки и избегают аргументов с побочными эффектами. Для простых вычислений функция часто безопаснее макроса.

#define SQR(x) ((x) * (x)) Для SQR(2+3) получится 25, но SQR(i++) опасен.

Даже правильно расставленные скобки не устраняют повторное вычисление аргумента макроса.

29 · Неопределённое поведение

UB: стандарт больше не обещает результат

int a[3] → a[3] = 1 → Выход за границу → UB

Точность: Беззнаковая арифметика работает по модулю диапазона; её переполнение само по себе не UB, но может испортить размер блока.

Вопрос для памяти

Почему «у меня один раз работало» не оправдывает UB?

Ключ

Стандарт не гарантирует результат при другом запуске, вводе или оптимизации.

Полный ответ для зачёта

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

int a[3]; a[3]=1; — выход за границы: разрешены индексы 0, 1, 2.

Беззнаковая арифметика вычисляется по модулю диапазона; её переполнение само по себе не UB, но может испортить размер выделяемого блока.

30 · Соглашения о вызовах

Договор: где аргументы и кто очищает стек

Caller → Вызов по общему ABI → Callee

Точность: Это платформенные соглашения. На Windows x64 отдельный ABI; правила 32 битного x86 туда буквально не переносятся.

Вопрос для памяти

Кто очищает стек в типичном x86 cdecl и stdcall?

Ключ

cdecl — caller; stdcall — callee.

Полный ответ для зачёта

Соглашение о вызовах определяет передачу аргументов и результата, сохранение регистров и очистку стека. В типичных 32 битных соглашениях Microsoft x86 cdecl передаёт аргументы через стек справа налево, стек очищает вызывающая функция. stdcall использует похожую передачу, но очищает стек вызываемая функция. fastcall передаёт часть подходящих аргументов через регистры, остальные через стек. Эти детали зависят от платформы и ABI. На Windows x64 действует отдельное соглашение с первыми четырьмя аргументами в соответствующих регистрах; переносить туда таблицу x86 буквально нельзя.

Для Microsoft x86 fastcall первые два подходящих целочисленных аргумента обычно передаются через ECX и EDX.

Слова caller и callee означают вызывающую и вызываемую функции соответственно.

Написание кода по памяти

Посмотри на шаблон. Закрой его. Напиши сам. Потом сравни. Фрагменты — части проверенных решений, не отдельные программы.

Задача 2 · Обратный вывод массива

Обход с конца

Сначала --p, потом *p.

Полная функция. При n>0 arr указывает на n существующих int. При n=0 выход до арифметики указателя.

Показать код
void print_reverse(int *arr, size_t n) {
    if (n == 0) { putchar('\n'); return; }
    int *p = arr + n;
    while (p != arr) {
        --p;
        printf("%d", *p);
        if (p != arr) putchar(' ');
    }
    putchar('\n');
}
Задача 3 · Копирование массива

Копирование

Прочитал → записал → сдвинул оба.

Полная функция. dest вмещает n элементов; частично перекрывающиеся области не поддерживаются.

Показать код
void copy_array(int *dest, const int *src, size_t n) {
    while (n > 0) {
        *dest++ = *src++;
        --n;
    }
}
Задача 4 · Слияние отсортированных массивов

Два входа и один выход

Переноси меньшую голову.

Фрагмент merge: входы отсортированы, память результата уже выделена и проверена. out указывает на начало результата.

Показать код
while (m > 0 && n > 0) {
    if (*a <= *b) { *out++ = *a++; --m; }
    else { *out++ = *b++; --n; }
}
while (m > 0) { *out++ = *a++; --m; }
while (n > 0) { *out++ = *b++; --n; }
Задача 5 · Палиндром массива

Два указателя навстречу

Сравнить края → сдвинуть к центру.

Полная функция. При n>0 массив существует; для n<2 результат 1.

Показать код
int is_palindrome(const int *arr, size_t n) {
    if (n < 2) return 1;
    const int *left = arr, *right = arr + n - 1;
    while (left < right) {
        if (*left != *right) return 0;
        ++left;
        --right;
    }
    return 1;
}
Задача 6 · Матрица одним блоком

Адрес ячейки матрицы

Строка × число столбцов + столбец.

Полная get. mat содержит rows*cols элементов, произведения размеров заранее проверены. assert — отладочная проверка.

Показать код
int get(const int *mat, size_t rows, size_t cols,
        size_t i, size_t j) {
    assert(i < rows && j < cols);
    (void)rows;
    return *(mat + i * cols + j);
}
Задача 7 · Сортировка пузырьком

Обмен соседей

Сохрани первое → перенеси второе → верни первое.

Фрагмент bubbleSort внутри цикла: p и p+1 указывают на существующих соседей; changed объявлен.

Показать код
if (*p > *(p + 1)) {
    int tmp = *p;
    *p = *(p + 1);
    *(p + 1) = tmp;
    changed = 1;
}
Задача 8 · Факториал

Факториал

Начни с 1; умножай от 2 до n.

Полная функция. Учебный диапазон 0..20; вне диапазона 0 и errno=ERANGE.

Показать код
unsigned long long factorial(int n) {
    if (n < 0 || n > 20) { errno = ERANGE; return 0; }
    unsigned long long result = 1;
    for (int i = 2; i <= n; ++i)
        result *= (unsigned long long)i;
    return result;
}
Задача 9 · Числа Фибоначчи

Фибоначчи

Сначала сумма, затем сдвиг пары.

Фрагмент fib после проверки: 2<=n<=93, F0=0 и F1=1. Нельзя вычислять лишнее F94.

Показать код
unsigned long long a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
    unsigned long long next = a + b;
    a = b;
    b = next;
}
return b;
Задача 10 · Наибольший общий делитель

Алгоритм Евклида

Остаток становится вторым числом.

Фрагмент gcd: x и y уже содержат модули аргументов в unsigned. Ответ больше INT_MAX не помещается в возвращаемый int.

Показать код
while (y != 0) {
    unsigned rem = x % y;
    x = y;
    y = rem;
}
if (x > INT_MAX) { errno = ERANGE; return 0; }
return (int)x;
Задача 11 · Наименьшее общее кратное

НОК без раннего умножения

Сначала дели на НОД.

Фрагмент lcm: x и y — положительные модули, u — их НОД. Нулевые аргументы уже обработаны.

Показать код
unsigned q = x / u;
if (q > (unsigned)INT_MAX / y) {
    errno = ERANGE;
    return 0;
}
return (int)(q * y);
Задача 12 · Проверка простоты

Проверка простоты

Делители только до корня.

Полная функция. d<=n/d заменяет d*d<=n без умножения, которое могло бы переполниться.

Показать код
int is_prime(int n) {
    if (n < 2) return 0;
    for (int d = 2; d <= n / d; ++d)
        if (n % d == 0) return 0;
    return 1;
}
Задача 13 · Разложение на простые множители

Вытащить повторяющийся множитель

Дели, пока делится; считай степень.

Фрагмент разложения внутри перебора d>=2; n>1, first объявлен. После перебора отдельно выводится оставшийся простой множитель.

Показать код
int k = 0;
while (n % d == 0) { n /= d; ++k; }
if (k != 0) {
    if (!first) printf(" * ");
    printf("%d^%d", d, k);
    first = 0;
}
Задача 14 · Быстрое возведение в степень

Бинарная степень

Нечётный бит → умножение; степень пополам.

Фрагмент power после проверки exp>=0. Все выполняемые произведения должны помещаться в long long; переполнение здесь не проверяется.

Показать код
long long result = 1;
while (exp > 0) {
    if (exp % 2 != 0) result *= base;
    exp /= 2;
    if (exp > 0) base *= base;
}
return result;
Задача 15 · Сумма цифр

Сумма цифр

%10 берёт цифру, /10 убирает.

Фрагмент digit_sum: x — модуль числа в unsigned, полученный без знакового переполнения.

Показать код
int sum = 0;
do {
    sum += (int)(x % 10);
    x /= 10;
} while (x != 0);
return sum;
Задача 16 · Десятичное число в двоичное

Двоичная запись

Остатки получаются с конца.

Фрагмент print_binary: n — unsigned; bits содержит sizeof(unsigned)*CHAR_BIT+1 байт. do while выводит и исходный ноль.

Показать код
char *out = bits;
do {
    *out++ = (char)('0' + n % 2);
    n /= 2;
} while (n != 0);
*out = '\0';
while (out != bits) putchar(*--out);
putchar('\n');
Задача 17 · Первый элемент по предикату

Поиск по предикату

Подходит? Верни адрес.

Полная функция. pred — действительная функция int(int); при n>0 arr содержит n элементов.

Показать код
int *find_if(int *arr, size_t n, int (*pred)(int)) {
    while (n > 0) {
        if (pred(*arr)) return arr;
        ++arr;
        --n;
    }
    return NULL;
}
Задача 18 · Фильтрация по предикату

Фильтр

Вход двигается всегда, выход — при успехе.

Полная функция. result вмещает n элементов; произвольное частичное перекрытие не поддерживается.

Показать код
size_t filter(int *arr, size_t n, int (*pred)(int),
              int *result) {
    size_t count = 0;
    while (n > 0) {
        if (pred(*arr)) {
            *result++ = *arr;
            ++count;
        }
        ++arr;
        --n;
    }
    return count;
}
Задача 19 · Разбиение по предикату

Граница partition

Переноси подходящее к границе.

Полная функция. arr содержит n int; pred корректен. Возврат может быть указателем за концом; читать его нельзя.

Показать код
int *partition(int *arr, size_t n, int (*pred)(int)) {
    int *boundary = arr;
    while (n > 0) {
        if (pred(*arr)) {
            int tmp = *boundary;
            *boundary = *arr;
            *arr = tmp;
            ++boundary;
        }
        ++arr;
        --n;
    }
    return boundary;
}
Задача 20 · Гласные и согласные

Нормализация латинской буквы

Заглавную в строчную; знаки пропустить.

Фрагмент цикла count_letters для ASCII совместимой латиницы. Счётчики гласных и согласных находятся дальше; это не обработка русского UTF-8.

Показать код
unsigned char c = (unsigned char)*s++;
if (c >= 'A' && c <= 'Z')
    c = (unsigned char)(c - 'A' + 'a');
if (c < 'a' || c > 'z') continue;
Задача 21 · Переворот числа

Переворот числа

К результату допиши последнюю цифру.

Фрагмент reverse_number: x, result, limit — unsigned; result=0. Предел зависит от знака, а знак восстанавливается после цикла.

Показать код
do {
    unsigned digit = x % 10;
    if (result > (limit - digit) / 10) {
        errno = ERANGE;
        return 0;
    }
    result = result * 10 + digit;
    x /= 10;
} while (x != 0);
Задача 22 · Скобочная последовательность

Закрыть последнюю открытую скобку

Проверь тип вершины, затем сними.

Фрагмент is_balanced внутри обработки закрывающей скобки c. top — размер стека; stack корректен. Открывающие скобки уже складываются отдельно.

Показать код
char expected = c == ')' ? '(' : c == ']' ? '[' : '{';
if (top == 0 || *(stack + top - 1) != expected) {
    ok = 0;
    break;
}
--top;
Задача 23 · Замена символов в строке

Замена символа

Проверил → заменил → сдвинул.

Полная функция. str — изменяемая строка с завершающим нулём, не строковый литерал. new_char=0 обрежет видимый текст.

Показать код
void replace_char(char *str, char old_char, char new_char) {
    while (*str != '\0') {
        if (*str == old_char) *str = new_char;
        ++str;
    }
}
Задача 24 · Бинарный поиск

Бинарный поиск

Держи кандидатов в [left,right).

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

Показать код
int binary_search(const int *arr, size_t n, int target) {
    size_t left = 0, right = n;
    while (left < right) {
        size_t mid = left + (right - left) / 2;
        int value = *(arr + mid);
        if (value == target) {
            if (mid > INT_MAX) { errno = ERANGE; return -1; }
            return (int)mid;
        }
        if (value < target) left = mid + 1;
        else right = mid;
    }
    return -1;
}