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

Локальные сокеты, или Unix domain sockets, отбрасывают этот балласт. Они дают тот же знакомый интерфейс сокетов, но общение идёт целиком внутри ядра, минуя сетевой стек. Результат измерим: на локальном обмене они стабильно быстрее петлевого TCP. Но выигрыш не безусловен, и понимание того, где он есть, а где испаряется, отличает осознанный выбор от карго-культа.

Откуда берётся выигрыш в скорости при общении внутри одной машины

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

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

#include <sys/socket.h>
#include <sys/un.h>

int fd = socket(AF_UNIX, SOCK_STREAM, 0);   /* AF_UNIX вместо AF_INET */

struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/tmp/myapp.sock", sizeof(addr.sun_path) - 1);

bind(fd, (struct sockaddr *)&addr, sizeof(addr));
listen(fd, 16);
/* дальше всё как в обычном TCP-сервере: accept, read, write */

Цифры из независимых замеров рисуют убедительную картину. На пинг-понге из ста тысяч обменов локальный сокет показал среднюю задержку около 4546 наносекунд против 14746 наносекунд у TCP, то есть выигрыш более чем втрое. Замеры на других стеках дают около пятидесяти процентов прироста пропускной способности по сравнению с петлевым TCP, а на коротких сообщениях разрыв доходит до пятикратного. В тесте под Node.js задержка локального сокета составила около 130 микросекунд против 334 микросекунд у петлевого TCP. Именно поэтому базы данных и менеджеры процессов по умолчанию предпочитают локальные сокеты для соединений в пределах машины.

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

Почему на больших сообщениях преимущество может обернуться отставанием

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

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

Объяснение кроется в том, как разные транспорты управляют буферизацией и нарезкой больших объёмов данных. На крупных передачах в дело вступают особенности размеров буферов сокета, и характеристики, оптимальные для петлевого TCP с его отлаженной за десятилетия машинерией потокового обмена, перестают совпадать с поведением локального сокета. Практический вывод трезвый: преимущество локальных сокетов наиболее выражено на частых мелких сообщениях, а на крупных потоковых передачах его нужно проверять замером на своей нагрузке, а не принимать на веру. Кстати, выигрыш сокращается и при активной конвейеризации запросов, когда в канал набивают длинные цепочки команд подряд.

Какое уникальное умение локальных сокетов недоступно сетевым

У локальных сокетов есть способность, которой у TCP нет в принципе и которая делает их незаменимыми в определённых архитектурах. Через соединение локального сокета процессы могут передавать друг другу не только данные, но и сами файловые дескрипторы. Один процесс открывает файл, сокет или иной ресурс и передаёт открытый дескриптор другому процессу, который начинает работать с ним как со своим собственным.

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

Дополнительный бонус локальных сокетов это естественная привязка к правам файловой системы. Раз сокет представлен файлом, доступ к нему регулируется обычными правами на этот файл. Можно разрешить подключение только определённому пользователю или группе средствами файловой системы, без всякой дополнительной аутентификации в самом приложении. TCP-порт же открыт всем, кто способен достучаться до интерфейса, и ограничивать доступ приходится отдельными механизмами.

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

Чем абстрактное пространство имён выручает при очистке файла сокета

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

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

Выбор между файловым и абстрактным вариантом сводится к компромиссу. Файловый сокет переносим и позволяет управлять доступом правами файловой системы, но требует следить за уборкой. Абстрактный избавляет от уборки и гонок при перезапуске, но привязывает к Linux и не даёт привычного контроля прав через файл. Для долгоживущего демона, который должен надёжно переживать перезапуски без ручного вмешательства, абстрактный вариант часто оказывается удобнее.

Где у локальных сокетов заканчивается зона применимости

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

С распространением микросервисов, контейнеров и шаблонов вроде коляски, когда вспомогательный контейнер живёт рядом с основным, эффективность локального обмена снова стала важной для производительности системы. В пределах одного узла или одного пода локальный сокет даёт ту самую экономию, которую TCP при общении внутри машины просто проедает на ненужной надёжности. Но стоит компонентам разъехаться по разным узлам, и выбора уже нет, нужен сетевой транспорт со всей его машинерией.

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

Есть и тонкость переносимости. Возможность следить за дескриптором локального сокета теми же средствами событийного мультиплексирования, что и за сетевым, на Linux работает, но не гарантирована стандартом на всех системах. Код, рассчитывающий на единое поведение, стоит проверять на целевой платформе.

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