Суть Читай небольшие сниппеты — головоломку порядка event loop, переплетение thread'ов, lost-update data race и фикс через lock — и предсказывай поведение или выбирай правильный фикс.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 14 min
Баги конкурентности читаются в коде и в трассах того, что когда выполнилось. Прочти каждый сниппет, выясни порядок, в котором всё реально происходит, и выбери, что внимательный инженер заключит — или починит — первым.
Цель
Отработай ключевой навык этого юнита: трассировать, что когда выполняется. Предсказывай порядок вывода event loop, замечай, как два thread’а переплетаются над общим состоянием, распознавай lost-update data race и определяй lock, который его чинит, не перебарщивая.
Heads-up setTimeout не выполняет callback; он ставит его в очередь. Синхронный 'end' выполняется раньше callback'а из очереди, потому что callback запускается только когда call stack пуст. 0 — это задержка постановки в очередь, а не немедленный запуск.
Heads-up Задержка 0 всё равно ставит callback в очередь; он выполняется после завершения синхронного кода. 'timeout' печатается — он просто последний, когда стек опустеет.
Heads-up Синхронный код всегда выполняется первым. Таймер лишь делает callback пригодным для постановки в очередь; event loop не запустит его, пока 'start' и 'end' не напечатаны и стек не пуст.
Сниппет 2 — два thread’а, одна общая переменная
shared: total = 0Thread A: Thread B: r = read(total) r = read(total) // оба читают 0 r = r + 1 r = r + 1 write(total, r) write(total, r) // оба пишут 1
Викторина
Completed
Оба thread'а выполняют это по разу, параллельно, на двух ядрах. После того как оба закончат, чем может быть total — и как это называется?
Heads-up Это верно только если thread'ы не пересекаются. Поскольку read-add-write — это три отдельных шага, оба thread'а могут сначала прочитать 0, затем оба записать 1 — потеряв один инкремент. Результат зависит от переплетения, поэтому не всегда 2.
Heads-up Записи не гасятся; они перезаписывают. Если thread'ы случайно не пересекутся, ты корректно получишь 2. Значение равно 1 лишь в переплетении, где оба читают одно старое значение. Исход недетерминирован, а не всегда 1.
Heads-up Data race на простом счётчике здесь не падает; он молча выдаёт неверное число (часто 1 вместо 2). Опасность именно в том, что выглядит нормально и просто теряет обновления — race condition, а не крах.
Сниппет 3 — тот же код под нагрузкой
total = 0spawn 1000 threads, each does: total = total + 1 // без synchronizationjoin all threadsprint(total)
Викторина
Completed
На многоядерной машине каким будет вероятное напечатанное значение и почему?
Heads-up Только если инкременты никогда не пересекаются, что без synchronization не гарантировано. На практике многие thread'ы читают одно значение перед записью, поэтому обновления теряются и итог выходит ниже 1000, по-разному каждый запуск.
Heads-up Thread'ы не сбрасывают total в 0; каждый пишет (старое значение, что он прочитал) + 1. Часть инкрементов всё же доходит. Результат — некоторое число ниже 1000, а не 0: потерянные обновления уменьшают его, а не стирают.
Heads-up Порождение thread'ов не сериализует их — на нескольких ядрах они идут параллельно и могут свободно переплетаться. Прохождение по очереди через общее обновление — это именно то, чего здесь не хватает; именно это добавил бы lock.
Сниппет 4 — добавляем lock
total = 0lock = new Lock()each of 1000 threads does: lock.acquire() total = total + 1 // защищено: только один thread внутри за раз lock.release()
Викторина
Completed
Что меняет lock и какова цена?
Heads-up Lock делает противоположность 'всем сразу' — он проводит thread'ы через защищённую секцию по одному за раз. Так он убирает race, но также убирает parallelism для этой секции, поэтому это цена, а не ускорение.
Heads-up Пока один thread держит lock, никакой другой не может войти в защищённую секцию, поэтому read-add-write не может переплестись. Race исчез — результат ровно 1000. Lock — это именно то, что сериализует общее обновление.
Heads-up Thread'ы всё ещё выполняются и делают работу; lock лишь контролирует, когда они могут войти в общую секцию. Он координирует thread'ы, а не заменяет их.
Итог
Читать конкурентность — значит читать, когда что выполняется. Event loop ставит синхронный код первым, а callback’и последними (start, end, timeout). Когда работа разбита на thread’ы, делящие состояние, обычный total = total + 1 на деле read-add-write, поэтому параллельные thread’ы могут переплестись, прочитать одно значение и перезаписать друг друга — data race, теряющий обновления и дающий неверный недетерминированный итог. Lock чинит это, делая общую секцию неделимой: один thread за раз, точный результат восстановлен. Цена в том, что заблокированная секция выполняется последовательно. Зрелый ход — сохранить lock для корректности и сделать заблокированную область как можно меньше.