🧠 COMPUTER SCIENCE

Что такое состояние гонки и почему параллельность сложна

Два потока хотят добавить деньги на один счёт — и в итоге одна из операций просто исчезает. Это не магия и не баг компилятора, а состояние гонки: коварная ошибка, из-за которой параллельные программы ведут себя непредсказуемо.

Представь: два кассира одновременно берут одну и ту же тетрадь с балансом счёта, оба видят «100 рублей», оба прибавляют по 50 — а в тетради в итоге оказывается 150, а не 200. Полтинник просто испарился. В компьютере такое происходит миллионы раз в секунду, и называется это состоянием гонки. Почему так выходит и почему параллельность — одна из самых коварных тем в программировании?

Почему вообще всё делается параллельно

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

Сегодня даже в твоём телефоне восемь ядер. Чтобы программа стала быстрее, она должна уметь делить работу на куски и выполнять их параллельно — в нескольких потоках (threads). Поток — это как отдельный работник, который выполняет свою последовательность команд. Несколько потоков — несколько работников, и если они трудятся над одной задачей, дело идёт быстрее.

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

Что такое состояние гонки

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

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

  • прочитать текущее значение из памяти;
  • посчитать новое значение (старое + 50);
  • записать результат обратно в память.

Если один поток прочитал «100», а второй в этот же миг тоже прочитал «100» — оба посчитают «150» и оба запишут «150». Второй просто затрёт результат первого, как будто его и не было. Деньги исчезли. И заметь: код выглядит абсолютно правильным! Ошибки нет ни в одной строчке по отдельности — она прячется между строчками, в том, что потоки залезли друг другу в действие.

Состояние гонки — это баг, которого как будто нет в коде. Он живёт в моменте времени, а не в строчке.

Кухня, где все хватают одну сковородку

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

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

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

Как программисты усмиряют хаос

Хорошая новость: с гонками умеют бороться. Главная идея — синхронизация, то есть договорённости, кто и когда имеет право трогать общие данные. Вот основные инструменты:

  • Мьютекс (mutex, от «mutual exclusion» — взаимное исключение) — это как ключ от той самой кухни. Поток, который хочет войти в критическую секцию, должен сначала взять ключ. Пока ключ у него, остальные ждут. Закончил — вернул ключ, и его берёт следующий.
  • Атомарные операции — действия, которые процессор гарантированно выполняет «одним куском», неделимо. Никакой другой поток не может вклиниться в середину. Наше «прочитать-посчитать-записать» превращается в одно неразрывное действие, и испарившихся денег больше не будет.
  • Сообщения вместо общей памяти — иногда проще вообще не давать потокам общий ресурс. Пусть они не лезут в одну тетрадь, а передают друг другу записки. Нет общих данных — нет и гонки. На этом построены целые языки, например подход в Go и Erlang.

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

Почему это важно знать

Состояние гонки — не редкая экзотика. Из-за него падали банковские системы, ломались онлайн-игры, а однажды программная гонка в аппарате лучевой терапии Therac-25 привела к тому, что пациенты получали смертельные дозы облучения. Это один из самых известных и трагичных примеров того, к чему приводит недосмотренная параллельность.

Если ты учишься программировать, тебе не нужно бояться потоков — нужно их уважать. Запомни главное: как только два исполнителя касаются одной общей вещи, спрашивай себя — «а что будет, если они сделают это одновременно?». Этот простой вопрос отделяет код, который работает на твоём ноутбуке, от кода, который не разваливается под миллионами пользователей. Параллельность сложна не потому, что в ней много формул, а потому, что в ней исчезает привычный порядок — и тебе приходится думать обо всех возможных порядках сразу.

#computer science#параллельность#потоки#синхронизация#состояние гонки