2026 г.

Конкурентность в современных языках

Проект ЦИТадель

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

Раздел 1 вводит основные различия. Раздел 2 разбирает общую изменяемую память, разделы 3–5 — сообщения, неизменяемость и статические гарантии Rust. Раздел 6 посвящён async/await и лёгким потокам среды исполнения. Раздел 7 сводит варианты в таблицу, раздел 8 предлагает практикум.

1. Основные различия

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

Гонка данных — не любое состояние гонки. Гонка данных возникает, когда конфликтующие обращения к одной памяти не упорядочены требуемой синхронизацией. Точное определение и последствия зависят от модели памяти языка: в C и C++ такая гонка ведёт к неопределённому поведению, а Go ограничивает возможные реакции реализации, но всё равно считает гонку ошибкой. Состояние гонки (race condition) шире: нежелательный результат зависит от порядка событий, например две по отдельности синхронизированные операции «проверить остаток» и «списать» не образуют одной атомарной операции. Отсутствие гонок данных не гарантирует правильность протокола.

2. Почему общая изменяемая память трудна

Составная операция не обязательно атомарна. Инкремент счётчика обычно включает чтение, вычисление и запись. Если язык или библиотека не предоставляет атомарный read-modify-write, два исполнения могут потерять обновление.

Порядок видимости задаёт модель памяти. Компиляторы и процессоры вправе переупорядочивать действия в пределах гарантий языка, а ядра используют буферы и кэши. Поэтому исходный текст программы сам по себе не определяет, когда запись одного потока станет видна другому. Замки, каналы и атомарные операции создают отношения happens-before; конкретные гарантии нужно читать в модели памяти языка. Например, официальная модель памяти Go обещает последовательное объяснение исполнения программ без гонок данных.

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

3. Сообщения и изоляция

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

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

Каналы также допускают взаимоблокировки и утечки горутин. Подход «не делить» уменьшает число мест с общей изменяемой памятью, но в Go остаётся архитектурным выбором, который необходимо проверять тестами и детектором гонок.

4. Неизменяемые данные

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

5. Статические гарантии Rust

В безопасном Rust правило заимствования запрещает одновременно иметь изменяемый доступ и другой доступ к той же области. Типажи Send и Sync ограничивают передачу значений и совместное использование ссылок между потоками. Вместе эти механизмы исключают гонки данных в безопасном коде; реализация абстракции с unsafe обязана самостоятельно сохранить этот контракт.

Mutex<T> хранит защищаемое значение и предоставляет доступ к нему через охранный объект, получаемый после успешного захвата. Такой API связывает замок с данными лучше, чем отдельные переменные «замок» и «состояние». При этом компилятор не исключает взаимоблокировки, нарушение бизнес-инварианта несколькими корректно синхронизированными шагами или бесконечное ожидание.

6. Async/await и лёгкие потоки

Async/await отвечает прежде всего на вопрос «как ждать, не удерживая отдельный поток ОС». Когда операция приостанавливается на поддерживаемом ожидании, задача уступает исполнителя, который может продолжить другую работу. Это позволяет обслуживать большое число преимущественно ожидающих операций с ограниченным числом системных потоков. Механизм JavaScript подробнее описан в статье «Современный JavaScript и TypeScript».

Ось, по которой здесь расходятся языки, — кто планирует и что видно в типах:

  • Явный async (JavaScript, Python, C#, Rust): вызов асинхронной функции возвращает обещание, задачу или будущее значение, а ожидание отмечается в коде. Обычная функция не может синхронно получить результат такого ожидания без дополнительного механизма. Это делает точки приостановки видимыми, но распространяет асинхронные интерфейсы по цепочке вызовов.
  • Лёгкие потоки среды исполнения (горутины Go, виртуальные потоки Java): код использует блокирующий стиль, а среда размещает множество логических потоков на меньшем числе потоков ОС. Это упрощает последовательное чтение кода, но масштабируемость зависит от того, умеет ли конкретная блокирующая операция освободить поток-носитель и не удерживает ли программа неподходящие блокировки.

Если все задачи выполняются на одном событийном потоке и никакой другой поток не обращается к их памяти, одновременной гонки данных между этими задачами нет. Однако между двумя await другая задача может изменить состояние, поэтому последовательность «проверил — подождал — использовал» остаётся уязвимой для состояния гонки. Async не заменяет проектирование инвариантов.

7. Сводка и правило выбора

ПодходЧто обеспечиваетЧего не обеспечиваетИздержкиПримеры
Замки/атомикиЗаданный порядок и видимость операцийОтсутствие взаимоблокировок и правильность составного протоколаКоординация и конкуренция за общий ресурсБольшинство многопоточных сред
Сообщения/акторыСинхронизацию обмена; у акторов — изоляцию состоянияАвтоматическую передачу владения в Go, порядок всего протоколаОчереди, передача данных и архитектурные ограниченияGo, Erlang/Elixir, акторные библиотеки
НеизменяемостьЧтение без синхронизацииИзменяемую точку входа всё равно надо синхронизироватьДополнительные структуры и выделения памятиФП-языки; применимо во всех языках
Компилятор (владение)Отсутствие гонок данных статическиДедлоки, состояния гонкиОбучение, ограничения выразительностиRust (Send/Sync, Mutex владеет данными)
Async и лёгкие потоки (другая ось)Эффективное обслуживание множества ожиданийПравильность общего состоянияАсинхронные интерфейсы или ограничения планировщикаJS, Python, C#, Rust / Go, виртуальные потоки Java

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

8. Практикум: обнаружение и устранение гонок

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

Задание 1. Устроить гонку (Go). Запустите несколько горутин, каждая из которых многократно выполняет counter++ без синхронизации, и повторите опыт. Итог может отличаться от ожидаемого, но даже случайно правильное число не доказывает отсутствия гонки. Зафиксируйте отдельно наблюдаемый результат и наличие гонки по определению модели памяти.

Задание 2. Увидеть гонку инструментом. Запустите выполненный вариант командой go run -race или оформите его как тест и используйте go test -race. Разберите указанные конфликтующие обращения и стеки горутин. Помните, что динамический детектор анализирует только пути, выполненные данным запуском.

Задание 3. Починить трижды. Реализуйте счётчик с sync.Mutex, с атомарным инкрементом и с единственной горутиной, изменяющей значение по командам из канала. Проверьте правильность и измерьте варианты несколькими повторениями. Не обобщайте один микротест: результат зависит от конкуренции, пакетирования сообщений и оборудования.

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

Задание 5. Проверка компилятора (Rust Playground). Попробуйте передать изменяемую ссылку на локальный счётчик в несколько потоков. Разберите диагностику времени жизни и совместного доступа. Затем реализуйте общий счётчик как Arc<Mutex<i64>> и убедитесь, что содержимое доступно только через охранный объект замка. Отдельно найдите в документации назначение Send и Sync: конкретная ошибка исходного примера не обязана упоминать оба типажа.

Задание 6. Состояние гонки при синхронизированных данных. Реализуйте отдельно защищённые операции «прочитать остаток» и «списать». Запустите двух клиентов так, чтобы оба прошли проверку до списания. Затем перенесите проверку и изменение в одну критическую секцию, одну команду актору или корректный цикл compare-and-swap. Объясните, почему гонки данных не было, а нарушение инварианта было.

Задание 7*. Экономика ожидания. В Python сравните множество операций sleep на системных потоках и задачах asyncio. Увеличивайте число постепенно, например 100, 500 и 1000, измеряя память и время. Не начинайте с десятков тысяч потоков: ограничение среды само является частью результата.

Итоги

  • Конкурентность и параллелизм описывают разные свойства; гонка данных отличается от более общего состояния гонки.
  • Модель памяти задаёт видимость и порядок между потоками. Замки и атомики решают локальную синхронизацию, но составной инвариант требует отдельного проектирования.
  • Каналы Go помогают организовать передачу доступа, но не вводят владение автоматически; акторы дают более сильную изоляцию состояния.
  • Неизменяемость упрощает совместное чтение, а безопасный Rust исключает гонки данных статическими правилами. Взаимоблокировки и ошибки протокола остаются возможными.
  • Async и лёгкие потоки уменьшают стоимость ожидания, но сами по себе не обеспечивают правильность общего состояния.

Литература

  1. C. A. R. Hoare, "Communicating Sequential Processes," 1978/1985 — первоисточник модели каналов; свободно онлайн.
  2. J. Armstrong, "Making reliable distributed systems in the presence of software errors," PhD thesis, 2003 — акторы и «пусть падает» из первых рук.
  3. Документация Go по конкурентности и детектору гонок (go.dev/race_detector); глава "Fearless Concurrency" книги о Rust (doc.rust-lang.org).
  4. B. Nystrom, "What Color is Your Function?" — эссе, давшее имя проблеме цветных функций.
  5. Связанные материалы сайта: «Современный JavaScript и TypeScript» (событийный цикл и async) и курс «Распределённые системы», глава 1 (сообщения и сетевые отказы).
  6. Оглавление раздела «Языки и парадигмы программирования»; другие статьи цикла: «Как современные языки управляют памятью», «Как современные языки обращаются с ошибками» и «Системы типов: что доказывает компилятор».
404 Not Found

404 Not Found


nginx/1.24.0 (Ubuntu)

Связь с редакцией