Низкоуровневая многозадачность и асинхронность в C++ на Linux

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

1. Основы многопроцессорности: анатомия процесса, системные вызовы fork, exec и waitpid в Linux

Основы многопроцессорности: анатомия процесса, системные вызовы fork, exec и waitpid в Linux

Приветствую тебя, студент! Добро пожаловать на курс, который превратит тебя из простого написателя скриптов в настоящего системного инженера. Мы начинаем погружение в мир, где программы не просто выполняют код строчка за строчкой, а живут, размножаются, общаются и иногда даже умирают (и превращаются в зомби, я серьезно).

Сегодня мы не будем говорить о потоках (threads), которые живут внутри одного процесса. Мы начнем с базы — с самих процессов. Ты узнаешь, как операционная система Linux управляет программами и как ты, используя C++, можешь взять это управление в свои руки.

Модуль 1: Анатомия процесса в Linux

Прежде чем создавать процессы, давай разберемся, что это вообще такое. Когда ты запускаешь программу (например, свой скрипт на Python или скомпилированный бинарник C++), операционная система создает для неё процесс.

Процесс — это не просто код. Это контейнер, которому ОС выделяет ресурсы. У каждого процесса есть свой уникальный идентификатор — PID (Process ID). Также у каждого процесса есть родитель — PPID (Parent Process ID), тот процесс, который его запустил.

В памяти Linux процесс выглядит как слоеный пирог. Давай посмотрим на его устройство.

!Структура виртуальной памяти процесса: от кода программы до стека

Разберем слои этого пирога:

  • Text Segment (Сегмент кода): Здесь лежат машинные инструкции твоей программы. Этот участок памяти обычно доступен только для чтения, чтобы ты случайно не переписал свои же команды.
  • Data Segment (Сегмент данных): Здесь живут глобальные и статические переменные.
  • Heap (Куча): Динамическая память. Когда ты пишешь new в C++ или malloc, память берется отсюда. Куча растет снизу вверх.
  • Stack (Стек): Здесь хранятся локальные переменные функций и адреса возврата. Стек растет сверху вниз, навстречу куче.
  • Важный нюанс: В Linux каждый процесс считает, что у него есть вся память компьютера. Это иллюзия, которую создает Виртуальная память. Процесс А не может просто так залезть в память Процесса Б. Это обеспечивает безопасность и стабильность.

    Модуль 2: Системный вызов fork() — Искусство клонирования

    В Python ты, возможно, привык запускать скрипты и не думать, откуда они берутся. В C++ на Linux мы используем системные вызовы (system calls или syscalls). Это прямые обращения к ядру операционной системы с просьбой сделать что-то важное.

    Самый главный вызов для создания нового процесса — fork().

    Когда процесс вызывает fork(), происходит магия: ядро создает точную копию текущего процесса. Клонируется всё: код, переменные, состояние памяти, открытые файлы. С этого момента в системе работают два почти идентичных процесса.

    Как же их различить? По возвращаемому значению функции fork().

    * Родителю (тому, кто вызвал) функция возвращает PID ребенка. * Ребенку (новому процессу) функция возвращает 0.

    Давай посмотрим на код. Для работы нам понадобятся заголовки <unistd.h> (для fork, pid_t) и <sys/types.h>.

    Что здесь происходит? Строка pid_t pid = fork(); выполняется один раз, но возвращает управление дважды: один раз в родительском процессе, второй раз — в дочернем. После этой строчки код раздваивается.

    > "Fork — это как если бы вы подошли к ксероксу, положили себя на стекло, нажали кнопку, и из лотка вылезла ваша точная копия, которая сразу же начала жить своей жизнью."

    Модуль 3: Семейство exec() — Трансплантация мозга

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

    Для этого используется семейство функций exec (от слова execute — выполнять). Самые популярные: execl, execv, execvp.

    Что делает exec? Он полностью заменяет текущий образ процесса (код, данные, стек) новой программой с диска. PID остается тем же, но "начинка" меняется полностью.

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

    Пример использования execl:

    Обычно fork и exec используют в связке:

  • Родитель делает fork().
  • Ребенок внутри if (pid == 0) вызывает exec(), чтобы стать новой программой.
  • Родитель продолжает заниматься своими делами.
  • Модуль 4: waitpid() и атака зомби

    В мире Linux родители обязаны следить за своими детьми. Когда дочерний процесс завершается (делает return или exit()), он не исчезает бесследно. Он превращается в зомби (zombie process).

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

    Если родитель не прочитает код возврата, зомби будут висеть и засорять таблицу процессов. Чтобы этого избежать, родитель должен использовать системный вызов wait() или waitpid().

    Эти функции делают две вещи:

  • Приостанавливают родителя, пока ребенок не умрет (блокирующий вызов).
  • Считывают статус завершения ребенка и удаляют его из таблицы процессов.
  • Пример правильного воспитания процессов:

    !Жизненный цикл: создание через fork, работа и ожидание через waitpid

    Разбор макросов:

    * WIFEXITED(status): Проверяет, завершился ли процесс нормально (сам, а не был убит сигналом). * WEXITSTATUS(status): Извлекает тот самый код возврата (в нашем примере 42).

    Итоги

    Сегодня мы разобрали фундамент многозадачности в Linux:

  • Процесс — это изолированная программа с памятью и PID.
  • fork() — создает копию процесса. Помни: 0 — ребенку, PID — родителю.
  • exec() — заменяет тело процесса новой программой.
  • waitpid() — позволяет родителю дождаться завершения ребенка и предотвратить появление зомби.
  • Это низкоуровневая база. В C++ есть высокоуровневые обертки, но понимание того, как это работает "под капотом" через системные вызовы, делает тебя профессионалом, понимающим цену каждому созданному ресурсу. В следующей статье мы поговорим о том, как эти процессы могут общаться друг с другом (IPC).

    2. Многопоточность под капотом: системный вызов clone, библиотека pthreads и отличия потоков от процессов

    Многопоточность под капотом: системный вызов clone, библиотека pthreads и отличия потоков от процессов

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

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

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

    Модуль 1: Потоки против Процессов — Найди 10 отличий

    Давай начнем с аналогии.

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

    Поток — это когда мы подселяем к первому жильцу соседа в тот же самый дом. Теперь их двое (или больше). Они могут одновременно делать разные дела: один готовит на кухне, другой спит. Но ресурсы у них общие. Если один сломает стул на кухне, второй тоже не сможет на нем сидеть.

    Техническая разница

    Взглянем на память. Вспомни структуру процесса: Код, Данные, Куча, Стек.

    !Слева — классический процесс. Справа — многопоточный процесс: куча и код общие, но у каждого потока свой личный стек.

    Главное отличие: Потоки делят виртуальную память процесса, но имеют свой собственный стек.

    | Характеристика | Процесс (через fork) | Поток (Thread) | | :--- | :--- | :--- | | Память | Полная изоляция (Copy-on-Write) | Общая (кроме стека) | | Создание | Дорого (нужно копировать таблицы страниц) | Дешево (меньше накладных расходов) | | Общение | Сложно (IPC: каналы, сокеты) | Элементарно (через общие переменные) | | Опасность | Ошибка в одном не убивает другой | Ошибка (segfault) в одном убивает всех | | Контекст | Переключение медленное | Переключение быстрое |

    Модуль 2: Системный вызов clone() — Магия Linux

    В большинстве операционных систем (например, Windows) процесс и поток — это принципиально разные сущности ядра. Но Linux следует философии UNIX: "Всё есть файл... или процесс".

    В ядре Linux нет концепции потока как таковой. Там есть только Task (задача).

    И процесс, и поток для ядра Linux — это просто структуры task_struct. Разница лишь в том, насколько сильно эти задачи делят ресурсы между собой.

    Для создания и того, и другого используется системный вызов clone().

    * Когда ты вызываешь fork(), он внутри вызывает clone() с флагами, говорящими "скопируй всё и ничего не дели". * Когда ты создаешь поток, библиотека вызывает clone() с флагами "дели всё: память, файлы, дескрипторы".

    Вот как выглядит сигнатура (упрощенно):

    Ключевые флаги, которые превращают процесс в поток:

    * CLONE_VM: Использовать ту же виртуальную память (Memory). * CLONE_FS: Делить информацию о файловой системе (текущая директория и т.д.). * CLONE_FILES: Делить таблицу файловых дескрипторов. * CLONE_SIGHAND: Делить таблицу обработчиков сигналов.

    Если мы вызовем clone со всеми этими флагами, мы получим то, что в других ОС называется "потоком". В Linux это называется Lightweight Process (LWP) — легковесный процесс.

    Модуль 3: POSIX Threads (pthreads) — Классика жанра

    Работать с clone() вручную сложно и опасно (нужно самому выделять память под стек, следить за выравниванием). Поэтому программисты используют стандартную библиотеку pthreads (POSIX threads). Это стандарт API для работы с потоками в UNIX-системах.

    Даже std::thread в C++ на Linux — это просто красивая обертка над pthreads.

    Для работы нам понадобится заголовок <pthread.h> и флаг компилятора -pthread.

    Основные функции

  • pthread_create: Аналог fork, но для потока.
  • pthread_join: Аналог waitpid. Мы ждем, пока поток завершится.
  • pthread_self: Аналог getpid, возвращает ID текущего потока.
  • Давай напишем код, где создадим два потока.

    Обрати внимание: Мы передаем аргументы через void. Это старый Си-шный стиль полиморфизма. Мы приводим указатель к void при передаче и обратно к int* внутри функции. Это требует аккуратности: если ты передашь указатель на локальную переменную, которая исчезнет до того, как поток начнет работу, ты получишь мусор или краш.

    Модуль 4: Гонка данных (Race Condition) — Темная сторона силы

    Помнишь, я говорил, что общая память — это круто? Так вот, это также самый большой кошмар многопоточного программирования.

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

    Операция counter++ для процессора не является атомарной (неделимой). Она состоит из трех шагов:

  • LOAD: Прочитать значение из памяти в регистр процессора.
  • ADD: Увеличить значение в регистре.
  • STORE: Записать значение из регистра обратно в память.
  • Если два потока делают это одновременно, может произойти следующее:

  • Поток А читает counter (допустим, там 0).
  • Поток Б читает counter (там все еще 0, так как А не успел записать).
  • Поток А увеличивает 0 до 1 и пишет 1.
  • Поток Б увеличивает 0 до 1 и пишет 1.
  • Итог: Мы сделали два инкремента, но в памяти лежит 1, а не 2. Мы "потеряли" одно действие. Это называется Race Condition (состояние гонки).

    Закон Амдала

    Многопоточность не всегда ускоряет программу пропорционально количеству ядер. Существует закон, который ограничивает максимальное ускорение.

    Пусть — это ускорение, которое мы хотим получить. Пусть — доля программы, которую можно распараллелить (от 0 до 1), а — количество процессоров (потоков).

    Формула закона Амдала выглядит так:

    Где: * — итоговое ускорение (во сколько раз быстрее). * — доля кода, которая поддается распараллеливанию. * — доля кода, которая выполняется строго последовательно (инициализация, ожидание ввода-вывода, синхронизация). * — количество потоков.

    Что это значит? Если у тебя 10% программы () должно выполняться последовательно, то даже если ты возьмешь бесконечное количество процессоров (), максимальное ускорение никогда не превысит 10 раз ().

    Итоги

    Сегодня мы разобрали:

  • Потоки — это легковесные процессы, которые делят память, но имеют свои стеки.
  • clone() — системный вызов Linux, лежащий в основе и fork(), и создания потоков. Всё зависит от флагов.
  • pthreads — библиотека для управления потоками. pthread_create запускает, pthread_join ждет.
  • Race Condition — ошибка, возникающая при одновременном доступе к общим данным без защиты.
  • В следующей статье мы научимся защищать наши данные от гонок, используя мьютексы, семафоры и атомики. Готовься, будет жарко!

    3. Безопасность данных: примитивы синхронизации, мьютексы, семафоры и атомарные операции для предотвращения гонок

    Безопасность данных: примитивы синхронизации, мьютексы, семафоры и атомарные операции для предотвращения гонок

    Приветствую, коллега! В прошлой лекции мы столкнулись с пугающим зверем — Race Condition (состояние гонки). Мы увидели, как два потока, пытаясь одновременно изменить одну переменную, превращают данные в мусор. Это происходит потому, что потоки — это соседи по комнате, которые пытаются одновременно пройти в одну дверь.

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

    В Linux и C++ существует несколько уровней защиты. Мы пойдем от самых простых и тяжелых к самым быстрым и сложным.

    Модуль 1: Мьютекс (Mutex) — Один ключ на всех

    Самый базовый примитив синхронизации — это Мьютекс. Название происходит от Mutual Exclusion (взаимное исключение).

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

    Мьютекс работает точно так же. Это переменная специального типа, которая может быть в двух состояниях: заблокирована (locked) и разблокирована (unlocked).

    Как это работает в коде (pthreads)

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

    Основные операции:

  • pthread_mutex_lock(&mutex): Поток пытается захватить мьютекс. Если он свободен, поток его занимает и идет дальше. Если занят — поток засыпает (блокируется) и ждет, пока мьютекс освободится.
  • pthread_mutex_unlock(&mutex): Поток освобождает мьютекс. Если есть ждущие потоки, один из них просыпается и занимает его.
  • !Визуализация принципа взаимного исключения: только один поток владеет ресурсом, остальные ждут.

    Давай исправим наш пример с гонкой данных из прошлой лекции:

    Теперь counter всегда будет равен 2000000. Но у этого есть цена: программа работает медленнее, так как потоки тратят время на ожидание и переключение контекста.

    Deadlock (Взаимная блокировка)

    С мьютексами связана страшная проблема — Deadlock. Представь: * Поток А захватил Ресурс 1 и хочет Ресурс 2. * Поток Б захватил Ресурс 2 и хочет Ресурс 1.

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

    Модуль 2: Семафоры — Вышибала в клубе

    Мьютекс — это замок с одним ключом. А что, если у нас есть ресурс, которым могут пользоваться, скажем, 3 потока одновременно (например, пул подключений к базе данных)?

    Здесь на сцену выходит Семафор.

    Семафор — это счетчик. Он хранит целое число — количество доступных ресурсов.

    Алгоритм работы семафора можно описать простой формулой изменения его состояния при захвате ресурса:

    Где: * — новое значение счетчика семафора. * — текущее значение счетчика семафора. * — единица ресурса, которую забирает поток.

    Если было равно 0, то вычитание невозможно (мы не можем уйти в минус), и поток блокируется до тех пор, пока кто-то другой не вернет ресурс.

    В Linux мы используем заголовок <semaphore.h>.

    Основные функции:

  • sem_init(&sem, 0, N): Создать семафор с начальным значением .
  • sem_wait(&sem): Уменьшить счетчик (ждать, если 0).
  • sem_post(&sem): Увеличить счетчик (сигнализировать, что освободился).
  • Главное отличие от мьютекса: У мьютекса есть понятие "владельца" (кто закрыл, тот и открывает). У семафора владельца нет. Один поток может сделать wait, а другой — post. Это позволяет использовать семафоры не только для защиты, но и для сигнализации (один поток сообщает другому, что событие произошло).

    Модуль 3: Условные переменные (Condition Variables)

    Иногда потоку нужно не просто захватить ресурс, а дождаться определенного условия. Например: "Жди, пока в очереди не появятся данные".

    Новичок напишет так:

    Правильный способ — использовать Condition Variable (pthread_cond_t). Это механизм, который позволяет потоку "уснуть" и ждать, пока другой поток не "позвонит в колокольчик".

    Схема работы всегда идет в паре с мьютексом:

  • Захватываем мьютекс.
  • Проверяем условие.
  • Если условие не выполнено — вызываем pthread_cond_wait. Эта функция атомарно разблокирует мьютекс и усыпляет поток.
  • Когда поток просыпается (по сигналу), он снова владеет мьютексом и проверяет условие.
  • Модуль 4: Атомики — Скорость света

    Мьютексы и семафоры — это механизмы операционной системы. Когда ты вызываешь lock, происходит системный вызов, ядро вмешивается, переключает контексты. Это долго (по меркам процессора).

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

    Это специальные инструкции процессора (на x86 это префикс LOCK), которые гарантируют, что операция (чтение-изменение-запись) произойдет неделимо. Никто не сможет вклиниться посередине.

    В современном C++ (начиная с C++11) для этого есть библиотека <atomic>.

    Compare and Swap (CAS)

    Самая мощная атомарная операция — это compare_exchange. Логика такая: "Запиши в переменную значение X, но только если сейчас там лежит значение Y". На этом строятся lock-free (безблокировочные) структуры данных, которые работают невероятно быстро, но очень сложны в реализации.

    Итоги

    Сегодня мы вооружились инструментами для защиты данных:

  • Мьютекс — используй, когда нужно защитить кусок кода от одновременного доступа (критическая секция).
  • Семафор — используй, когда нужно ограничить количество одновременных доступов (пул ресурсов) или для простой сигнализации.
  • Condition Variable — используй, чтобы ждать события без загрузки процессора.
  • Атомики — используй для простых счетчиков и флагов, когда важна максимальная скорость.
  • В следующей части курса мы поднимемся на уровень выше и посмотрим, как C++ позволяет писать асинхронный код без ручного управления потоками, используя std::async и std::future.

    4. Асинхронность и ввод-вывод: файловые дескрипторы, неблокирующий режим и мультиплексирование через select и epoll

    Асинхронность и ввод-вывод: файловые дескрипторы, неблокирующий режим и мультиплексирование через select и epoll

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

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

    Сегодня мы узнаем, как один поток может обслуживать тысячи соединений одновременно. Мы погрузимся в мир асинхронного ввода-вывода (Async I/O) и мультиплексирования. Это та самая магия, на которой работают Nginx, Node.js и Redis.

    Модуль 1: Файловые дескрипторы — всё есть файл

    В философии UNIX (и Linux) есть золотое правило: «Всё есть файл». Твой жесткий диск — это файл. Клавиатура — это файл. Сетевое соединение (сокет) — это тоже файл.

    Когда твоя программа открывает файл или создает сетевое соединение, ядро операционной системы возвращает ей не сам объект, а Файловый Дескриптор (File Descriptor, FD).

    Файловый дескриптор — это просто неотрицательное целое число (int). Это индекс в таблице открытых файлов, которую ядро хранит для каждого процесса.

    !Таблица файловых дескрипторов: как простые числа связывают программу с ресурсами ОС

    По умолчанию у каждого процесса уже открыты три дескриптора: * 0stdin (стандартный ввод, то, что ты печатаешь в консоль). * 1stdout (стандартный вывод, то, что cout пишет на экран). * 2stderr (стандартный поток ошибок).

    Все системные вызовы для работы с вводом-выводом (read, write, close) принимают именно это число.

    Модуль 2: Блокирующий и Неблокирующий ввод-вывод

    Проблема блокировки

    Представь, что ты пишешь чат-сервер. Ты создал сокет (получил FD = 3) и ждешь сообщения от клиента, вызывая read(3, buffer, size).

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

    Если у тебя один поток, то пока ты ждешь Васю, ты не можешь ответить Пете. Вся программа парализована.

    Решение: O_NONBLOCK

    Мы можем сказать ядру: «Эй, если данных нет, не усыпляй меня, а просто скажи об этом и верни управление». Это называется неблокирующий режим.

    Чтобы перевести дескриптор в этот режим, используется системный вызов fcntl (File Control).

    Теперь read возвращает управление мгновенно. Если данных нет, он возвращает -1, а глобальная переменная errno становится равной EAGAIN (Try again — попробуй снова).

    Но тут возникает новая проблема: Busy Waiting (активное ожидание). Наш цикл while(true) крутится миллионы раз в секунду, постоянно спрашивая «Есть данные? А сейчас? А сейчас?». Это загружает процессор на 100% и греет воздух. Нам нужен механизм, который разбудит нас только тогда, когда данные действительно появятся.

    Модуль 3: Мультиплексирование через select()

    Здесь на сцену выходит I/O Multiplexing. Это способ наблюдать за множеством файловых дескрипторов одновременно и получать уведомление, когда хотя бы один из них готов к работе.

    Старейший системный вызов для этого — select(). Он появился еще в 80-х.

    Идея проста: ты даешь ядру список FD, которые тебе интересны, и говоришь: «Разбуди меня, если кто-то из них захочет читать или писать».

    Для работы с select используются специальные наборы fd_set.

    Почему select устарел?

    У select есть фатальные недостатки для высоких нагрузок:
  • Линейная сложность : При вызове select ты каждый раз передаешь весь список дескрипторов в ядро, а ядро пробегает по всем ним. Если у тебя 10 000 клиентов, это очень медленно.
  • Ограничение размера: Обычно fd_set имеет жесткий лимит (часто 1024 дескриптора). Чтобы слушать больше, нужно перекомпилировать ядро или использовать костыли.
  • Здесь — количество отслеживаемых дескрипторов. Сложность означает, что время выполнения растет прямо пропорционально количеству подключений.

    Модуль 4: epoll — Король производительности Linux

    Чтобы решить проблему C10K (проблема обслуживания 10 000 соединений), в Linux в 2002 году добавили epoll (event poll). Это стандарт де-факто для высокопроизводительных серверов на Linux.

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

    Работа с epoll состоит из трех этапов (системных вызовов):

  • epoll_create1(0): Создает экземпляр epoll и возвращает его собственный FD.
  • epoll_ctl(...): Управление. Добавить («подписаться»), удалить или изменить наблюдение за конкретным сокетом.
  • epoll_wait(...): Ожидание событий. Возвращает список только тех дескрипторов, которые реально сработали.
  • Сложность работы epoll — . Это значит, что время реакции не зависит от того, сколько всего клиентов подключено (100 или 100 000). Важно только количество активных событий.

    Пример использования epoll

    Edge Triggered (ET) vs Level Triggered (LT)

    У epoll есть два режима работы, о которых часто спрашивают на собеседованиях:

  • Level Triggered (LT) — режим по умолчанию (как у select). Если в буфере есть данные, epoll будет будить тебя снова и снова, пока ты всё не вычитаешь. Это надежно, но чуть медленнее.
  • Edge Triggered (ET) — режим для профи. epoll уведомит тебя только один раз, когда данные пришли. Если ты прочитал половину и забыл остальное, epoll больше не напомнит, и данные зависнут. Это требует идеальной логики работы с неблокирующим чтением (читать в цикле while, пока не получишь EAGAIN), но работает быстрее всего.
  • Итоги

    Сегодня мы разобрали фундамент асинхронного программирования:

  • Файловые дескрипторы — это целые числа, через которые мы общаемся с внешним миром.
  • Неблокирующий I/O (O_NONBLOCK) позволяет не зависать на операциях чтения/записи, но требует умной обработки ошибок (EAGAIN).
  • select() — старый способ следить за множеством сокетов, который плохо масштабируется ().
  • epoll — современный и быстрый механизм Linux (), позволяющий обрабатывать тысячи соединений в одном потоке.
  • В следующей статье мы поднимемся на уровень выше и посмотрим, как эти низкоуровневые кирпичики собираются в удобные абстракции C++: std::future, std::async и корутины.

    5. Межпроцессное взаимодействие (IPC): работа с сигналами, каналами (pipes) и разделяемой памятью

    Межпроцессное взаимодействие (IPC): работа с сигналами, каналами (pipes) и разделяемой памятью

    Приветствую, коллега! Мы прошли долгий путь. Мы научились создавать процессы-клоны через fork, запускать потоки через clone и pthreads, защищать данные мьютексами и даже обрабатывать тысячи соединений через epoll.

    Но до сих пор наши процессы жили как соседи в многоквартирном доме, которые никогда не разговаривают друг с другом. Процесс А делает свою работу, Процесс Б — свою. Но что, если Процесс А должен передать результат вычислений Процессу Б? Или Процесс-начальник должен приказать Процессу-работнику немедленно остановиться?

    Сегодня мы разрушим стены изоляции. Мы поговорим об IPC (Inter-Process Communication — Межпроцессное взаимодействие).

    В Linux существует множество способов заставить процессы общаться. Мы разберем три фундаментальных механизма: Сигналы (чтобы пнуть процесс), Каналы (чтобы передать данные) и Разделяемую память (чтобы вместе работать над одним листом бумаги).

    Модуль 1: Сигналы (Signals) — Асинхронные пинки

    Самый простой и древний способ коммуникации — это сигналы.

    Представь, что ты сидишь в наушниках и пишешь код. Вдруг кто-то подходит и хлопает тебя по плечу. Ты вздрагиваешь, отрываешься от работы и реагируешь.

    Сигнал — это программное прерывание. Операционная система (или другой процесс) отправляет уведомление твоему процессу о том, что произошло какое-то событие. При этом твой процесс приостанавливает выполнение основного кода и запускает специальную функцию — обработчик сигнала (signal handler).

    Популярные сигналы

    У каждого сигнала есть имя (начинается с SIG) и номер. Вот те, с которыми ты будешь встречаться чаще всего:

    * SIGINT (Signal Interrupt): Посылается, когда ты нажимаешь Ctrl+C в терминале. По умолчанию убивает процесс, но его можно перехватить (чтобы, например, корректно сохранить данные перед выходом). * SIGTERM (Signal Terminate): Вежливая просьба завершиться. Это делает команда kill по умолчанию. * SIGKILL: Выстрел в голову. Этот сигнал нельзя перехватить или игнорировать. Процесс убивается мгновенно ядром. * SIGSEGV (Segmentation Violation): Знаменитая ошибка сегментации. Процесс попытался залезть в чужую память.

    Перехват сигналов в C++

    В старом C использовалась функция signal(), но в современном Linux правильнее использовать системный вызов sigaction(). Он надежнее и гибче.

    Давай напишем программу, которая отказывается умирать по Ctrl+C.

    Теперь, если ты запустишь этот код и нажмешь Ctrl+C, программа не закроется, а выведет сообщение. Чтобы убить её реально, придется открыть другой терминал и выполнить kill -9 <PID> (послать SIGKILL).

    Модуль 2: Каналы (Pipes) — Односторонняя труба

    Сигналы хороши для управления, но они не могут передавать данные (кроме самого факта события). Если нужно переслать гигабайт текста от одного процесса другому, мы используем Pipe (канал).

    Pipe — это буфер в памяти ядра, у которого есть два конца: вход и выход. Это как водопроводная труба: в одну сторону заливаешь, из другой вытекает. Данные идут строго в одном направлении (Unidirectional).

    !Схематичное изображение передачи данных через анонимный канал от родителя к потомку.

    Системный вызов pipe()

    Для создания канала используется вызов pipe(int pipefd[2]). Он принимает массив из двух целых чисел и заполняет его файловыми дескрипторами:

    * pipefd[0] — конец для чтения (Read end). * pipefd[1] — конец для записи (Write end).

    Обычно pipe создают перед fork. После клонирования и родитель, и ребенок имеют доступ к этим дескрипторам. Чтобы канал работал правильно, каждый процесс должен закрыть тот конец трубы, который ему не нужен.

    Важный нюанс: Если ты забудешь закрыть неиспользуемые концы труб, программа может зависнуть навсегда. Например, если ребенок будет ждать данных (read), а родитель не закроет свой write-конец, ребенок будет думать, что данные еще могут прийти, и никогда не завершится.

    Модуль 3: Разделяемая память (Shared Memory) — Телепортация данных

    Каналы — это надежно, но медленно. Данные копируются из памяти процесса-писателя в буфер ядра, а потом из ядра в память процесса-читателя. Два копирования.

    Если нужно передавать видеопоток или огромные массивы чисел, это слишком накладно. Тут на сцену выходит Shared Memory.

    Идея проста: мы просим ядро выделить кусок физической памяти и отобразить его в виртуальное адресное пространство сразу двух (или более) процессов.

    !Иллюстрация того, как разные процессы видят одну и ту же физическую память через свои виртуальные адреса.

    Это самый быстрый способ IPC, потому что данные вообще не копируются. Если Процесс А записал число в ячейку, Процесс Б видит его мгновенно.

    Математика эффективности

    Давай сравним время передачи данных () для канала и разделяемой памяти.

    Для канала (Pipe):

    Где: * — общее время передачи. * — время системного вызова записи. * — время копирования данных внутри ядра. * — время системного вызова чтения.

    Для разделяемой памяти (Shared Memory):

    Где: * — время передачи. Оно стремится к нулю, так как после настройки памяти доступ происходит со скоростью работы с обычной RAM. Мы тратим время только на синхронизацию.

    POSIX Shared Memory

    В современном Linux используется стандарт POSIX. Нам понадобятся функции shm_open (открыть объект памяти) и mmap (отобразить его в адресное пространство).

    Пример (упрощенный): Писатель.

    Читатель делает то же самое, только открывает с O_RDONLY и читает из указателя ptr.

    Главная опасность

    С большой силой приходит большая ответственность. Поскольку ядро не контролирует доступ к разделяемой памяти (в отличие от каналов), возникает Race Condition (состояние гонки), о котором мы говорили в лекции про потоки.

    Если Процесс А пишет, а Процесс Б читает одновременно, Б может прочитать мусор. Поэтому разделяемую память всегда используют в связке с примитивами синхронизации (семафорами), которые могут работать между процессами.

    Итоги

    Сегодня мы научили наши процессы общаться:

  • Сигналы — используй для управления (старт, стоп, прерывание). Это как дернуть стоп-кран.
  • Каналы (Pipes) — используй для потоковой передачи данных между родственными процессами. Это надежно и просто.
  • Разделяемая память (Shared Memory) — используй для максимальной скорости передачи больших объемов данных. Но помни: тебе придется вручную настраивать синхронизацию (семафоры).
  • Теперь ты владеешь полным арсеналом системного программиста Linux. Ты можешь создавать процессы, распараллеливать задачи и связывать их в единую систему. Впереди нас ждет практика создания настоящего сетевого сервера!