SkillGridИзучай computer science на практике

Одновременность: когда два потока делят данные

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

17 мин · 55 XP

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

Начнём с самого маленького примера. Два потока, каждый миллион раз увеличивает один общий счётчик. Любой программист скажет, что ответ 2000000. Запустите и прочитайте число.

/* counter.c */
#include <pthread.h>
#include <stdio.h>

static long counter = 0;

static void *worker(void *arg) {
  (void)arg;
  for (long i = 0; i < 1000000; i++) {
    counter = counter + 1;
  }
  return NULL;
}

int main(void) {
  pthread_t a, b;
  pthread_create(&a, NULL, worker, NULL);
  pthread_create(&b, NULL, worker, NULL);
  pthread_join(a, NULL);
  pthread_join(b, NULL);
  printf("%ld\n", counter);
  return 0;
}

/* $ gcc -O2 -pthread counter.c -o counter && ./counter
 * 2000000   <- never guaranteed
 * 1431927   <- typical instead
 */Два потока, один общий счётчик, ни одной блокировки. Компилируется через gcc -O2 -pthread. Напечатанное значение меняется от запуска к запуску.

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

counter = 0

T1  r1 <- counter     r1 = 3417
T2  r2 <- counter     r2 = 3417
T1  r1 <- r1 + 1      r1 = 3418
T2  r2 <- r2 + 1      r2 = 3418
T1  counter <- r1     counter = 3418
T2  counter <- r2     counter = 3418

T1 done.  T2 done.  counter = 3418

Оба потока прочитали 3417, оба прибавили единицу, оба записали 3418. Приращений было два, а счётчик сдвинулся на один. Это и есть состояние гонки: результат зависит от того, какой порядок случайно выбрал планировщик, а не от программы. На реальном запуске программы выше получается около 1,4 миллиона вместо 2 миллионов, причём каждый раз другое число. Ошибка не редкая и не случайный шум. Это закономерное следствие чередования, которым вы не управляли.

Та же потеря случается везде, где есть образец «прочитать — изменить — записать»: баланс на счёте, счётчик ссылок, добавление элемента в список, счётчик соединений. Важен не оператор. Важно то, что значение читается, а потом записывается обратно двумя отдельными шагами, между которыми может проскочить другой поток.

Thread 1lockcounterThread 2acquiregrantedload 3417, add 1, store 3418acquireblocked, thread is descheduledreleasegrantedload 3418, add 1, store 3419

Для одного счётчика есть ответ легче. У процессоров есть атомарные команды «прочитать — изменить — записать» (lock xadd на x86, ldrex и strex на ARM), и компилятор выставляет их наружу как атомарные операции. Ни объекта блокировки, ни ожидающего потока, ни отдельной критической секции.

atomic_long counter = 0;

void *worker(void *arg) {
  (void)arg;
  for (long i = 0; i < 1000000; i++) {
    atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
  }
  return NULL;
}

/* lock xadd   -> uncontended, a few nanoseconds
 * contended   -> the cache line is ping-ponged between cores
 */

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

Теперь неприятная часть: почему программа со счётчиком прошла ваш тест? Гонке нужно конкретное чередование, а чередования не равновероятны. На одном свободном ядре оба потока подолгу работают до конца, и в плохое окно программа попадает, может быть, раз на миллион итераций цикла. На загруженной машине, или с подключённым отладчиком, или в проде под нагрузкой это окно ловится постоянно. Поэтому тест проходит на ноутбуке, а инцидент случается на сервере с 32 ядрами. Ошибки одновременности зависят от времени, а значит отсутствие сбоя не является доказательством правильности. Хуже того, добавленная для расследования печать может настолько изменить timing, что ошибка спрячется совсем.

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

Практика

  1. Программа со счётчиком теряет приращения. В каком месте тела цикла второй поток может навредить?

    Подсказки
    • Посчитайте машинные шаги, которые компилятор выдаёт для counter = counter + 1, и спросите, сколько из них обязаны идти неразрывно.
    • Проблема не в самой команде сложения, а в паре команд вокруг неё, которые переносят значение между памятью и процессором.
  2. Сопоставьте каждое исправление с той ошибкой, которую оно действительно убирает.

    Подсказки
    • Один из ответов убирает общую переменную целиком, другой только закрывает окно между чтением и записью.
    • Защита всей функции — самый тяжёлый инструмент в списке, а наполовину обновлённое значение — это другой симптом, нежели два приращения, схлопнувшиеся в одно.
  3. В счётчике лежит 3417, и в каждом потоке выполняется оператор counter = counter + 1 из примера со сбоем. Запишите пошаговую трассу, после которой счётчик останется равным 3418, используя те же два регистра r1 и r2.

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

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

Вопросы по этому уроку

Что такое операционная система на самом деле?

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

Зачем это, если я хочу просто писать код?

Потому что когда программа тормозит, зависает или съедает память, причина почти всегда здесь. Без этих знаний остаётся гадать, с ними — измерять.

Это бесплатно?

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

На какой возраст рассчитано?

Для любознательных подростков примерно с 12 лет и для взрослых, начинающих с нуля. Опыт программирования не предполагается: уроки по сетям и физике не требуют математики сверх школьной.

Нужно ли уже уметь программировать?

Нет. Логика, физика и сети начинаются с нуля. Код ты пишешь в разделе языков — и он тоже начинается с самого начала.

Нужно что-то устанавливать?

Нет. Всё работает в браузере: теория, практика и интерактивные симуляции. Скачивать нечего и настраивать нечего.

Сколько времени занимает один предмет?

Урок — от 10 до 15 минут. Грейд «джун» это примерно 12–20 уроков, то есть несколько недель регулярных занятий, а не один вечер.

Обязательно идти по порядку?

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