IPC: pipes, sockets и shared memory

IPC (inter-process communication) — это обмен данными и синхронизация между разными процессами. У процессов раздельные виртуальные адресные пространства; передать указатель из одного процесса другому недостаточно: он не имеет смысла в чужой таблице страниц. Ядро либо копирует данные между адресными пространствами, либо предоставляет общий отображённый участок памяти и отдельный механизм согласования.

Зачем это на интервью

Вопрос об IPC проверяет не запоминание API, а границы изоляции: что именно принадлежит ядру, где происходит копирование, кто отвечает за закрытие ресурса и что будет при медленном получателе или аварийном завершении процесса. В Go это возникает при запуске дочерних программ через os/exec, Unix-сокетах для локальных демонов, интеграции с systemd и передаче файловых дескрипторов.

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

Минимум для E4

  • Объяснить, что процессы не разделяют обычную память и общаются через объекты ядра либо явно отображённую общую память.
  • Различать pipe, FIFO, Unix domain socket и TCP socket по направлению, области видимости и модели данных.
  • Знать, что pipe и SOCK_STREAM передают поток байтов без границ сообщений; один read не обязан вернуть одну запись write.
  • Понимать блокировку: запись ждёт свободного места, чтение ждёт данных; O_NONBLOCK, readiness через poll/epoll и тайм-ауты меняют поведение.
  • Закрывать неиспользуемые концы pipe во всех процессах и обрабатывать EOF, EPIPE и SIGPIPE.
  • Не считать shared memory «быстрой очередью без правил»: данные в ней требуют протокола публикации и синхронизации.

Углубление для E5/Senior

Выбор IPC — это в первую очередь выбор семантики отказа и управления потоком. Pipe удобен для короткого конвейера родитель—потомок: у него небольшой kernel buffer, естественный backpressure и EOF, когда закрыт последний writer. Но случайно унаследованный write-end не даст читателю увидеть EOF; сервис будет ждать вечно. Размер pipe регулируется и ограничен ядром, поэтому нельзя делать его неявным буфером большой очереди.

Unix domain socket (UDS) подходит для локального клиента и демона. Он поддерживает полнодуплексную связь, несколько клиентов, права файловой системы или Linux credentials (SO_PEERCRED), а также SCM_RIGHTS для передачи файловых дескрипторов. SOCK_STREAM даёт упорядоченный поток, SOCK_SEQPACKET сохраняет границы сообщений и порядок, SOCK_DGRAM передаёт датаграммы. UDS не заменяет авторизацию автоматически: проверять нужно peer credentials, права на каталог сокета и протокол приложения.

TCP выбирают, когда граница может стать сетевой или нужны стандартные балансировщики, TLS и наблюдаемость. Локальный TCP обычно дороже UDS из-за сетевого стека, но это не лицензия на микрооптимизацию: измеряют p50/p99, CPU, число копирований и сложность эксплуатации. TCP тоже поток байтов; framing — обязанность протокола, например фиксированный заголовок с длиной, varint или формат с безопасным лимитом размера.

Shared memory через mmap файла, memfd_create, POSIX shm_open или System V SHM избегает копирования полезной нагрузки между процессами, но не магически делает обмен lock-free. Нужны layout с версиями, выравнивание, атомарные состояния владения, acquire/release-порядок памяти и обработка падения владельца. Для уведомления используют futex, eventfd, semaphore или socket; busy-wait допустим только после измерения и с ограничением CPU. Для больших буферов часто передают дескриптор memfd по UDS, а control plane оставляют в сокете.

В production проектируйте лимиты до запуска: максимальный размер сообщения, число запросов в полёте, время ожидания, политика при переполнении (ждать, отклонять, сбрасывать) и метрики очереди/ошибок. При рестарте сервера определите владение именованным объектом, уборку stale socket path и совместимость формата shared memory. Не публикуйте адреса в shared memory: используйте смещения от начала mapping, иначе ASLR и разные адреса отображения сломают layout.

Ключевые понятия

МеханизмСемантикаСильные стороныОграничения
Anonymous pipeОднонаправленный поток байтов между связанными процессамиПростой конвейер, EOF и backpressureНет адреса для произвольного клиента, два pipe нужны для duplex
FIFO (named pipe)Поток байтов с именем в файловой системеНесвязанные локальные процессы могут встретиться по путиТе же framing и lifecycle-проблемы, что у pipe
Unix domain socketЛокальный stream/datagram/seqpacketDuplex, несколько клиентов, права и передача FDТолько одна машина; framing зависит от типа сокета
TCP socketСетевой упорядоченный поток байтовМежхостовая связь, зрелая инфраструктураСетевые отказы, handshake, framing и более высокая цена
Shared memoryОбщие физические страницы, отображённые в процессыВысокая пропускная способность для крупных данныхНужны синхронизация, layout, cleanup и защита от порчи

Границы записи. Для pipe записи размером не более PIPE_BUF атомарны относительно других writers: байты такой записи не перемешиваются. Это не означает, что один read вернёт всю запись. Для SOCK_STREAM такой гарантии границ нет вообще; сериализуйте запись одного соединения или используйте framing.

Конец потока. read возвращает 0 только когда нет данных и закрыты все write-end. Если читателей нет, write в pipe получает EPIPE, а процесс по умолчанию получает SIGPIPE. В Go запись обычно возвращает ошибку broken pipe; не маскируйте её бесконечным retry.

Файловый дескриптор. FD — целое число в таблице процесса, указывающее на open file description/объект ядра. После fork дескрипторы ссылаются на те же объекты. Флаг FD_CLOEXEC предотвращает утечку FD в exec; в Go предпочитайте API, которые создают descriptor с close-on-exec.

Пример: протокол поверх Unix socket в Go

Даже локальный net.UnixConn со stream-сокетом требует границ сообщений. В примере длина проверяется до выделения памяти, а дедлайн не даёт зависнуть при молчащем peer.

package protocol
 
import (
	"encoding/binary"
	"fmt"
	"io"
	"net"
	"time"
)
 
const maxFrame = 1 << 20
 
func readFrame(c net.Conn) ([]byte, error) {
	_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
	var header [4]byte
	if _, err := io.ReadFull(c, header[:]); err != nil {
		return nil, err
	}
	n := binary.BigEndian.Uint32(header[:])
	if n > maxFrame {
		return nil, fmt.Errorf("frame too large: %d", n)
	}
	body := make([]byte, n)
	_, err := io.ReadFull(c, body)
	return body, err
}

Типовые вопросы

  1. Когда pipe лучше Unix socket?
    • Для простого однонаправленного конвейера между родителем и дочерним процессом: меньше протокольной поверхности, EOF выражает завершение. Для запросов и ответов или многих клиентов берите UDS.
  2. Почему read из pipe вернул меньше байтов, чем записал writer?
    • Pipe — поток. read возвращает доступные байты до запрошенного размера; собирайте фрейм циклом или проектируйте потоковый формат.
  3. Что произойдёт, если родитель не закрыл write-end pipe после fork?
    • Читатель не увидит EOF после завершения child, потому что в родителе всё ещё есть writer. Закрывайте неиспользуемые концы сразу в каждой ветви.
  4. Зачем shared memory синхронизация, если страницы общие?
    • Общие байты не задают порядок публикации. Читатель может увидеть частично записанную структуру или устаревшее состояние; нужны атомики/lock и барьеры памяти.
  5. Как передать большой payload без лишней копии?
    • Создать memfd, записать данные, передать FD через SCM_RIGHTS по UDS и передать метаданные/права в control-сообщении. Получатель отображает FD и валидирует размер и версию.
  6. Почему Unix socket не гарантирует безопасность локального API?
    • Путь сокета может быть доступен лишнему пользователю, а любой разрешённый процесс может говорить по протоколу. Нужны права каталога, проверка peer credentials и авторизация операции.

Практика

  • Соберите программу producer | consumer на Go через os/exec и StdoutPipe.
    • Критерии приёмки: parent закрывает неиспользуемые pipe-end; consumer корректно завершает чтение по EOF; при остановке consumer producer получает и логирует ошибку записи; есть тест с payload больше размера буфера.
  • Реализуйте локальный echo-сервис на Unix socket с фреймом «4-байтная длина + payload».
    • Критерии приёмки: socket path удаляется при штатном shutdown; сервер отвергает frame больше лимита; два клиента не смешивают ответы; тест покрывает фрагментированную отправку и тайм-аут.
  • Сделайте односторонний ring buffer в shared memory для фиксированных сообщений.
    • Критерии приёмки: у slot есть состояние empty/ready; producer не перезаписывает непрочитанный slot; consumer проверяет версию layout; нагрузочный тест не даёт повреждённых сообщений; при остановке задокументирован cleanup.
  • Сравните передачу 64 KiB и 8 MiB через pipe, UDS и shared memory на своей машине.
    • Критерии приёмки: зафиксированы команды, число итераций, p50/p99 и CPU; вывод содержит ограничения измерения, а не только «победителя».

Частые ошибки и ловушки

  • Считать SOCK_STREAM сообщениями и разбирать один Read как один запрос. Фрагментация и склейка неизбежны.
  • Оставлять лишние FD после fork/exec; это вызывает зависание EOF и удерживает файлы, сокеты или listener живыми.
  • Передавать в shared memory Go pointers, mutex из другого процесса или структуру без фиксированного ABI. Используйте явные поля фиксированной ширины, offsets и версию layout.
  • Делать неограниченную очередь поверх socket или pipe. Медленный потребитель превращает память процесса либо kernel buffer в источник OOM и tail latency.
  • Открывать FIFO или socket path в общем /tmp без безопасного каталога и проверки владельца: это создаёт race и риск подмены.
  • Путать атомарность PIPE_BUF с транзакционностью протокола: запись может быть атомарной, но обработчик всё равно должен валидировать payload.

Связанные темы

База разработки и Computer Science · Системные вызовы и граница user/kernel · Go: runtime и конкурентность · Linux, сети и cloud · System Design

Источники

  • Linux man-pages: pipe(7), fifo(7), unix(7), socket(7), poll(2), epoll(7), mmap(2), memfd_create(2) и eventfd(2) — первичные спецификации интерфейсов Linux.
  • Linux kernel documentation: filesystems/locking.rst и документация по futex — правила синхронизации и ожидания в ядре.
  • POSIX.1-2024: разделы pipe(), read(), write(), shm_open() и mmap() — переносимые контрактные гарантии.
  • Документация Go standard library: пакеты os/exec, net, syscall и golang.org/x/sys/unix — контракты Go-обёрток над системными интерфейсами.