EnglishРусский Map
Message Passing vs Shared Memory

Передача сообщений - это разделяемое изменяемое состояние

title
Передача сообщений - это разделяемое изменяемое состояние
type
summary
summary
Исследование багов в Go (Tu et al., 2019) подтверждает прогноз Эдварда Ли: каналы - это конкурентные очереди со всеми багами разделяемого состояния
tags
concurrency, golang, distributed-systems
created
2026-04-25
updated
2026-09-13
lang
ru
source_updated
2026-09-13
translated
2026-09-14
translator
lllm/antigravity/gemini-3.7-flash-medium

Автор causality-blog утверждает, что спор между разделяемой памятью и передачей сообщений с самого начала был ложной дихотомией, и использует Go в качестве эмпирического примера. В 2006 году Эдвард Ли опубликовал статью The Problem with Threads, где предсказал: замена механизма координации с блокировок на сообщения изменит синтаксис сбоев, но не саму базовую модель. Три года спустя Go вышел на рынок с противоположной ставкой - "не общайтесь через разделение памяти; разделяйте память через общение" - и получил масштабное распространение в Docker, Kubernetes, etcd, gRPC и CockroachDB. Это превратило Go в самую заметную реальную проверку гипотезы передачи сообщений в истории индустрии.

Что показало исследование багов

Ту и его коллеги изучили 171 реальный баг конкурентности в ключевых проектах на Go и опубликовали статью Understanding Real-World Concurrency Bugs in Go на ASPLOS 2019. Около 58% блокирующих багов (когда горутины зависают и не могут продолжить работу) были вызваны именно передачей сообщений, а не разделяемой памятью. То, что задумывалось как лекарство, порождало те же самые проблемы, что и болезнь.

Передача сообщений действительно устранила один класс ошибок: несинхронизированный доступ к памяти. Если две горутины взаимодействуют исключительно через каналы, они не могут одновременно изменять одну и ту же переменную. Но устранение гонок данных не устраняет сбои координации: взаимные блокировки, утечки, нарушения протоколов и недетерминированное планирование никуда не исчезают. Встроенный в Go детектор взаимных блокировок выявил лишь 2 из 21 блокирующего бага, проверенных исследователями. Детектор гонок показал себя лучше на неблокирующих ошибках, обнаружив примерно половину, но это всё равно оставляет половину всех продакшен-багов конкурентности невидимыми для инструментов, созданных для их поиска. threadsanitizer-limits объясняет часть оставшейся половины: общие гонки выходят за рамки того, что проверяет детектор гонок данных, а фиксированные бюджеты памяти TSan пропускают и часть обычных гонок данных.

Большинство этих багов жили очень долго. Они пережили тестирование и код-ревью в кодовых базах Go, находящихся под самым пристальным вниманием.

Утечка в Kubernetes

Упрощённый баг из статьи сводится к ошибке в один символ:

func finishReq(timeout time.Duration) ob {
    ch := make(chan ob)
    go func() {
        result := fn()
        ch <- result  // blocks forever if timeout wins
    }()
    select {
    case result = <-ch:
        return result
    case <-time.After(timeout):
        return nil
    }
}

Если fn() превышает таймаут, родительская функция возвращает nil, и из ch никто не читает. Дочерняя горутина навсегда блокируется на небуферизованной отправке. Сборщик мусора Go освобождает память из-под объектов, но не собирает горутины, заблокированные на каналах. В Kubernetes каждая утёкшая горутина удерживает ссылки и не отдаёт память; под нагрузкой они накапливаются, пока процесс не начнёт деградировать или не будет завершён по OOM. Исправление заключается в замене make(chan ob) на make(chan ob, 1).

Аргумент о структурной идентичности

Если сопоставить этот код с аналогичным багом на Java, там используется BlockingQueue<Result> из java.util.concurrent - пакета, находящегося по соседству с Mutex и Semaphore. Ни один Java-разработчик не назвал бы это передачей сообщений; он сказал бы: "я использую разделяемую конкурентную очередь" и понимал бы, что она несёт в себе все риски разделяемого изменяемого состояния. Но код на каналах Go имеет ровно ту же структуру: та же разделяемая изменяемая структура данных, та же семантика блокировок, та же утечка при исчезновении потребителя. Изменился только словарь терминов.

Первородный грех

Артур О'Дуайер в разборе той же статьи обозначил то, что он назвал первородным грехом каналов Go: на самом деле это не каналы. У настоящего канала есть две раздельные конечные точки (конец отправителя и конец получателя) с разными типами и возможностями. Если последний получатель исчезает, среда исполнения может обнаружить это, разблокировать отправителей и выполнить очистку. В каналах Go ничего этого нет: это единый объект, конкурентная очередь, разделяемая между любым количеством горутин, удерживающих на неё ссылку. Любая горутина может отправлять, любая может принимать, нет направленной типизации и нет способа для среды исполнения понять, что одна из сторон исчезла.

Если это понять, категории багов из исследования становятся предсказуемыми:

  • Deadlock - горутина A отправляет данные и ждёт ответа в другом канале; B делает обратное. Обе блокируются. Это циклическая зависимость по разделяемому состоянию, выраженная через очереди вместо блокировок. Встречается в Docker, Kubernetes, gRPC.
  • Утечка - никто не читает из канала; отправитель блокируется навсегда. Разделяемая очередь удерживает ссылку на горутину. Пример из Kubernetes выше.
  • Гонка - несколько горутин читают из одного канала; планировщик среды исполнения выбирает одну из них недетерминированно. Встречается в etcd и CockroachDB.
  • Нарушение протокола - горутина отправляет сообщение, которое получатель не ждёт, отправляет в закрытый канал (что приводит к панике) или закрывает уже закрытый канал.

Каждый из них - классический баг разделяемого изменяемого состояния, замаскированный под передачу сообщений. Полную классификацию см. в go-channel-bug-patterns.

Erlang как проверка в строгой форме

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

Формулировка автора: передача сообщений решает проблемы конкурентности так же, как перенос беспорядка из одной комнаты в другую решает проблему захламлённости. Механизм коммуникации (канал, почтовый ящик, очередь сообщений) сам по себе является разделяемым изменяемым ресурсом и наследует все проблемы, которые всегда были у разделяемого изменяемого состояния. См. message-passing-vs-shared-memory.

Что дальше

Эссе завершается заделом на продолжение. Если обе стороны дихотомии терпят неудачу по одной и той же структурной причине, возможно, сама дихотомия ошибочна, и правильный вопрос - какой фундамент объединяет оба подхода. Автор намекает, что некоторые языки пробовали другие, весьма глубокие основания, но ни один из них не стал мейнстримом - именно этому будет посвящён следующий материал.

Источники

  • Lee, The Problem with Threads, IEEE Computer 39.5 (2006)
  • Tu et al., Understanding Real-World Concurrency Bugs in Go, ASPLOS 2019
  • O'Dwyer, Understanding Real-World Concurrency Bugs in Go (blog post, June 2019)
  • Christakis & Sagonas, Static Detection of Race Conditions in Erlang, PADL 2010