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/seqpacket | Duplex, несколько клиентов, права и передача 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
}Типовые вопросы
- Когда pipe лучше Unix socket?
- Для простого однонаправленного конвейера между родителем и дочерним процессом: меньше протокольной поверхности, EOF выражает завершение. Для запросов и ответов или многих клиентов берите UDS.
- Почему
readиз pipe вернул меньше байтов, чем записал writer?- Pipe — поток.
readвозвращает доступные байты до запрошенного размера; собирайте фрейм циклом или проектируйте потоковый формат.
- Pipe — поток.
- Что произойдёт, если родитель не закрыл write-end pipe после
fork?- Читатель не увидит EOF после завершения child, потому что в родителе всё ещё есть writer. Закрывайте неиспользуемые концы сразу в каждой ветви.
- Зачем shared memory синхронизация, если страницы общие?
- Общие байты не задают порядок публикации. Читатель может увидеть частично записанную структуру или устаревшее состояние; нужны атомики/lock и барьеры памяти.
- Как передать большой payload без лишней копии?
- Создать
memfd, записать данные, передать FD черезSCM_RIGHTSпо UDS и передать метаданные/права в control-сообщении. Получатель отображает FD и валидирует размер и версию.
- Создать
- Почему 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.
- Критерии приёмки: у slot есть состояние
- Сравните передачу 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-обёрток над системными интерфейсами.