Привет!
Если у вас уже есть некоторый опыт работы с веб-серверами, то вам наверняка доводилось попадать в классическую ситуацию «адрес уже используется» (EADDRINUSE).
В этой статье будут подробно разобраны не только предпосылки, позволяющие судить, случится ли в ближайшей перспективе такая ситуация (для этого достаточно просмотреть список открытых сокетов), но и будет рассказано, как можно прослеживать конкретные пути кода в ядре (где происходит такая проверка).
Если вам просто интересно, как именно работает системный вызов socket(2), где именно хранятся все эти сокеты, то обязательно дочитайте эту статью до конца!
❯ В чём суть сокетов?
Сокеты – это конструкции, через которые обеспечивается коммуникация между процессами, работающими на разных машинах, и эта коммуникация происходит по сети, которая для всех этих процессов является базовой. Бывает и так, что сокеты применяются для коммуникации между процессами, работающими на одном и том же хосте (в таком случае речь идёт о сокетах Unix).
Очень точная аналогия, иллюстрирующая суть сокетов и по-настоящему меня впечатлившая, приводится в книге Computer Networking: A top-down approach.
В самом общем виде можно представить компьютер как «дом», в котором есть множество дверей.
Здесь каждая дверь — это сокет, и, как только к ней подойдёт клиент, он может «постучать» в неё.
Сразу после стука в дверь (отправка пакета SYN
) дом автоматически реагирует на это, выдавая ответ (SYN+ACK
), который затем сам заверяет (да, вот такой умный дом с «умной дверью»).
Тем временем, пока сам процесс просто сидит там в доме, сам «умный дом» координирует работу клиентов и выстраивает две очереди: одну для тех, которые всё ещё обмениваются приветствиями с домом, а другую для тех, кто уже справился с этапом приветствия.
Как только те или клиенты оказываются во второй очереди, процесс может впустить их.
Когда соединение считается принятым (клиенту сказано входить), сервер может коммуницировать с клиентом, передавая и получая данные в зависимости от того, что именно требуется.
Здесь стоит отметить, что фактически клиент «не впускают» в дом. Сервер создаёт в доме «приватную дверь» (клиентский сокет) и затем коммуникация с клиентом идёт именно через неё.
Эта статья будет понятнее, если вы пошагово представляете, как реализуется TCP-сервер на C. Если пока эта тема вам не слишком хорошо знакома, то обязательно изучите статью «Реализация TCP-сервера».
❯ Где мне искать список сокетов, имеющихся в моей системе?
Как только у вас сложится представление о том, как именно устанавливается соединение по протоколу TCP, мы сможем «зайти в дом» и исследовать, как машина создаёт эти «двери» (сокеты). Также мы узнаем, сколько дверей у нас в доме, и в каком состоянии каждая из них (закрыта она или открыта).
Для этого давайте возьмём для примера сервер, который просто создаёт сокет (дверь!) и ничего с ним не делает.
// socket.c –создаёт сокет и затем засыпает.
#include <stdio.h>
#include <sys/socket.h>
/**
* Создаёт сокет для работы по TCP IPv4, после чего переходит в
* режим ожидания.
*/
int
main(int argc, char** argv)
{
// Системный вызов `socket(2)` создаёт конечную точку для дальнейшей
// коммуникации, а затем возвращает дескриптор файла, ссылающийся на
// эту конечную точку
// Он принимает три аргумента (последний из них предоставляется лишь
// для большей конкретики):
// - домен (в пределах которого происходит коммуникация)
// AF_INET Интернет-протоколы IPv4
//
// - тип (семантика коммуникации)
// SOCK_STREAM Предоставляет правильно упорядоченные
// надёжные двунаправленные потоки байт,
// основанные на типе соединения
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (err == -1) {
perror("socket");
return err;
}
// Просто ждём ...
sleep(3600);
return 0;
}
Под капотом такой простой системный вызов запускает целую кучу внутренних методов (подробнее о них в следующем разделе), которые в какой-то момент позволят нам искать информацию об активных сокетах, записываемую в трёх разных файлах: /proc/<pid>/net/tcp
, /proc/<pid>/fd
и /proc/<pid>/net/sockstat
.
Тогда как в каталоге fd
представлен список файлов, открытых процессом, в самом файле /proc/<pid>/net/tcp
сообщается, какие в данный момент есть активные TCP-соединения (в различных состояниях), относящиеся к сетевому пространству имён данного процесса. С другой стороны, файл sockstat
можно считать своеобразным резюме.
Начиная с каталога fd
и далее становится заметно, что после вызова socket(2)
дескриптор сокетного файла фигурирует в списке аналогичных дескрипторов:
# Запустить socket.out (gcc -Wall -o socket.out socket.c)
# и оставить его работать в фоновом режиме
./socket.out &
[2] 21113
# Убедиться, что это открытые файлы, используемые процессом.
ls -lah /proc/21113/fd
dr-x------ 2 ubuntu ubuntu 0 Oct 16 12:27 .
dr-xr-xr-x 9 ubuntu ubuntu 0 Oct 16 12:27 ..
lrwx------ 1 ubuntu ubuntu 64 Oct 16 12:27 0 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct 16 12:27 1 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct 16 12:27 2 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct 16 12:27 3 -> 'socket:[301666]'
Учитывая, что при простом вызове socket(2)
никакое TCP-соединение не устанавливается, мы не найдём для себя и не соберём никакой важной информации из /proc/<pid>/net/tcp
.
По резюме (sockstat
) можно догадаться, что количество выделенных TCP-сокетов постепенно увеличивается:
# Ознакомимся с файлом, в котором содержится информация о сокете.
cat /proc/21424/net/sockstat
sockets: used 296
TCP: inuse 3 orphan 0 tw 4 alloc 106 mem 1
UDP: inuse 1 mem 0
UDPLITE: inuse 0
RAW: inuse 0
FRAG: inuse 0 memory 0
Чтобы убедиться, что в процессе нашей работы число alloc
действительно увеличивается, давайте изменим вышеприведённый код и попробуем выделить сразу 100 сокетов:
+ for (int i = 0; i < 100; i++) {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (err == -1) {
perror("socket");
return err;
}
+ }
Теперь, вновь проверив этот параметр, убедимся, что число alloc действительно увеличилось:
cat /proc/21456/net/sockstat
bigger than before!
|
sockets: used 296 .----------.
TCP: inuse 3 orphan 0 tw 4 | alloc 207| mem 1
UDP: inuse 1 mem 0 *----------*
UDPLITE: inuse 0
RAW: inuse 0
FRAG: inuse 0 memory 0
❯ Что именно происходит под капотом, когда выполняется системный вызов socket?
socket(2)
подобен фабрике, производящей базовые структуры, предназначенные для обработки операций над таким сокетом.
Воспользовавшись iovisor/bcc, можно на максимальную глубину отследить все вызовы, происходящие в стеке sys_socket, и, исходя из этой информации, понять каждый шаг.
| socket()
|--------------- (kernel boundary)
| sys_socket
| (socket, type, protocol)
| sock_create
| (family, type, protocol, res)
| __sock_create
| (net, family, type, protocol, res, kern)
| sock_alloc
| ()
˘
Начиная с sys_socket как такового, именно эта обёртка системного вызова — первый слой, затрагиваемый в пространстве ядра. Именно на этом уровне выполняются различные проверки и подготавливаются некоторые флаги, передаваемые для использования при последующих вызовах.
Как только будут выполнены все предварительные проверки, вызов выделяет в собственном стеке указатель на struct socket
— структуру, в которой содержится непротокольная конкретика о сокете:
/**
* Сокет определяется как системный вызов
* со следующими аргументами:
* - int family; - домен, в котором происходит коммуникация
* - int type; and - семантика коммуникации
* - int protocol. – конкретный протокол в рамках
* определённого домена и семантики.
*
*/
SYSCALL_DEFINE3(socket,
int, family,
int, type,
int, protocol)
{
// Указатель, который должен быть направлен на
// `struct sock`, структуру, в которой содержится полное определение
// сокета после того, как он будет должным образом выделен из
// семейства сокетов.
struct socket *sock;
int retval, flags;
// ... проверяется информация, готовятся флаги ...
// Создаются базовые структуры для работы с сокетами.
retval = sock_create(family, type, protocol, &sock);
if (retval < 0)
return retval;
// Для данного процесса выделяется дескриптор файла, так, чтобы
// он мог потреблять конкретный интересующий нас сокет из
// пользовательского пространства
return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}
/**
* Высокоуровневая обёртка сокетных структур
*/
struct socket {
socket_state state;
short type;
unsigned long flags;
struct sock* sk;
const struct proto_ops* ops;
struct file* file;
// ...
};
Учитывая, что в данный момент мы как раз создаём сокет, и мы можем сами выбирать из различных типов и семейств протоколов (например, UDP, UNIX и TCP), именно для этого в struct socket
содержится интерфейс (struct proto_ops*
), определяющий базовые конструкции, реализуемые сокетом. Эти конструкции не зависят ни от типа, ни от семейства протоколов, и данная операция инициируется при вызове метода, который идёт следующим: sock_create
.
/**
* Инициализирует `struct socket`, выделяя необходимую
* для этого память, а также заполняя
* всю необходимую информацию, связанную с
* сокетом
*
* Метод:
* - Проверяет некоторые детали, связанные с аргументами;
* - Выполняет запланированную проверку безопасности для `socket_create`
* - Инициализирует саму операцию выделения памяти для `struct socket`
* (так, чтобы `family` выполняла её в соответствии с теми правилами, что в ней действуют)
*/
int __sock_create(struct net *net,
int family, int type, int protocol,
struct socket **res, int kern)
{
int err;
struct socket *sock;
const struct net_proto_family *pf;
// Проверяет диапазон протокола
if (family < 0 || family >= NPROTO)
return -EAFNOSUPPORT;
if (type < 0 || type >= SOCK_MAX)
return -EINVAL;
// Инициирует собственные проверки безопасности для socket_create.
err = security_socket_create(family, type, protocol, kern);
if (err)
return err;
// Выделяет объект `struct socket` и привязывает его к файлу,
// расположенному в файловой системе `sockfs`.
sock = sock_alloc();
if (!sock) {
net_warn_ratelimited("socket: no more sockets\n");
return -ENFILE; /* Не вполне точное совпадение, но это самый
близкий аналог, имеющийся в posix */
}
sock->type = type;
// Пытается извлечь методы семейства протоколов, чтобы
// создавать сокет по правилам, специфичным для данного семейства.
pf = rcu_dereference(net_families[family]);
err = -EAFNOSUPPORT;
if (!pf)
goto out_release;
// Выполняет метод создания сокетов, специфичный для
// данного семейства протоколов.
//
// Например, если мы работаем с семейством AF_INET (ipv4)
// и при этом мы создаём TCP-сокет (SOCK_STREAM),
// то вызывается конкретный метод для обработки сокета
// именно такого типа.
//
// Если бы мы указывали локальный сокет (UNIX),
// то вызывали бы другой метод (с учётом, что
// такой метод реализовывал бы интерфейс `proto_ops`
// и такой метод был бы загружен).
err = pf->create(net, sock, protocol, kern);
if (err < 0)
goto out_module_put;
// ...
}
Продолжая это подробное исследование, давайте внимательно рассмотрим, как именно выделяется структура struct socket
с использованием метода sock_alloc()
.
❯ Задача этого метода – выделить две сущности: новый индексный дескриптор inode и объект socket.
Они связаны на уровне файловой системы sockfs
, которая не только отвечает за отслеживание информации о сокете в системе, но и предоставляет уровень трансляции, через который взаимодействуют обычные вызовы из файловой системы (например, write(2)
) и сетевой стек (независимо от того, в каком именно базовом домене происходит такая коммуникация).
Отслеживая работу метода sock_alloc_inode
, отвечающего за выделение индексного дескриптора в sockfs
, мы можем наблюдать, как именно организуется весь этот процесс:
trace -K sock_alloc_inode
22384 22384 socket-create.out sock_alloc_inode
sock_alloc_inode+0x1 [kernel]
new_inode_pseudo+0x11 [kernel]
sock_alloc+0x1c [kernel]
__sock_create+0x80 [kernel]
sys_socket+0x55 [kernel]
do_syscall_64+0x73 [kernel]
entry_SYSCALL_64_after_hwframe+0x3d [kernel]
/**
* sock_alloc - выделение сокета
*
* Выделить новые объекты индексного дескриптора и сокета. Система сначала связывает их вместе,
* а затем инициализирует. После этого выделяется сокет. Если мы израсходуем весь запас индексных дескрипторов,
* то возвращается NULL.
*/
struct socket *sock_alloc(void)
{
struct inode *inode;
struct socket *sock;
// При условии, что файловая система находится в памяти,
// выделяем объекты, используя для этого
// память ядра.
inode = new_inode_pseudo(sock_mnt->mnt_sb);
if (!inode)
return NULL;
// Извлекает структуру `socket` из
// `inode`, находящегося в `sockfs`
sock = SOCKET_I(inode);
// Задаёт некоторые аспекты файловой системы, такие, что
inode->i_ino = get_next_ino();
inode->i_mode = S_IFSOCK | S_IRWXUGO;
inode->i_uid = current_fsuid();
inode->i_gid = current_fsgid();
inode->i_op = &sockfs_inode_ops;
// Обновляет счётчик, учитывающий отдельно каждое ядро ЦП,
// который затем может использоваться `sockstat` и другими системами,
// если нужно быстро подсчитать количество сокетов).
this_cpu_add(sockets_in_use, 1);
return sock;
}
static struct inode *sock_alloc_inode(
struct super_block *sb)
{
struct socket_alloc *ei;
struct socket_wq *wq;
// Создаётся запись в кэше ядра и
// берётся необходимая для этого память.
ei = kmem_cache_alloc(sock_inode_cachep, GFP_KERNEL);
if (!ei)
return NULL;
wq = kmalloc(sizeof(*wq), GFP_KERNEL);
if (!wq) {
kmem_cache_free(sock_inode_cachep, ei);
return NULL;
}
// Выполняет простейший возможный
// вариант инициализации
ei->socket.state = SS_UNCONNECTED;
ei->socket.flags = 0;
ei->socket.ops = NULL;
ei->socket.sk = NULL;
ei->socket.file = NULL;
// Возвращает базовый индексный дескриптор vfs.
return &ei->vfs_inode;
}
❯ Сокеты и лимитирование ресурсов
Учитывая, что на индексный дескриптор файловой системы можно ссылаться из пользовательского пространства, используя для этого файловый дескриптор, складывается такая ситуация: после того, как мы настроим все базовые структуры ядра, в дело вступает sys_socket
. Он генерирует файловый дескриптор за пользователя (выполняет все шаги валидации лимитов для ресурсов, как описано в документе Process resource limits under the hood).
Если вы когда-нибудь задумывались, почему при работе с socket(2)
может возникать ошибка «слишком много открытых файлов», то всё дело именно в этих проверках лимитов для ресурсов:
static int
sock_map_fd(struct socket* sock, int flags)
{
struct file* newfile;
// Помните его? Это тот самый метод,
// при помощи которого ядро проверяет
// лимит доступных ресурсов и помогает убедиться,
// что мы этот лимит не превысили!
int fd = get_unused_fd_flags(flags);
if (unlikely(fd < 0)) {
sock_release(sock);
return fd;
}
newfile = sock_alloc_file(sock, flags, NULL);
if (likely(!IS_ERR(newfile))) {
fd_install(fd, newfile);
return fd;
}
put_unused_fd(fd);
return PTR_ERR(newfile);
}
❯ Подсчёт сокетов в системе
Если вы внимательно следили, что делает вызов sock_alloc, то обращу ваше внимание вот на что: именно он увеличивает количество сокетов, которые в настоящий момент находятся «в использовании».
struct socket *sock_alloc(void)
{
struct inode *inode;
struct socket *sock;
// ....
// Обновляет значение счётчика, работающего на каждом ядре процессора
// и после этого используется `sockstat`, чтобы другие системы также
// могли быстро узнавать количество сокетов.
this_cpu_add(sockets_in_use, 1);
return sock;
}
Поскольку this_cpu_add является макросом, можно заглянуть в его определение и выяснить о нём дополнительную информацию:
/*
* this_cpu operations (C) 2008-2013 Christoph Lameter <cl@linux.com>
*
* Оптимизированы манипуляции, связанные с выделением памяти на конкретные ядра процессора,
* или на конкретные ядреса, или на переменные ЦП.
*
* Эти операции гарантируют исключительность доступа для всех других операций
* при работе на *одном и том же* процессоре. При этом предполагается, что в пересчёте на каждое ядро к любым данным одновременно обращается только один экземпляр
* процессора(текущий).
*
* [...]
*/
Теперь, при условии, что мы постоянно прибавляем сокеты к sockets_in_use
, можно, как минимум, предположить, что метод, зарегистрированный для /proc/net/sockstat собирается использовать это значение — в самом деле, именно так и происходит. Это также означает, что мы будем складывать все значения, зарегистрированные на каждом ядре ЦП:
/*
* Сообщить статистику о выделении сокетов [mea@utu.fi]
*/
static int sockstat_seq_show(struct seq_file *seq, void *v)
{
struct net *net = seq->private;
unsigned int frag_mem;
int orphans, sockets;
// Извлечь счётчики, относящиеся к TCP-сокетам.
orphans = percpu_counter_sum_positive(&tcp_orphan_count);
sockets = proto_sockets_allocated_sum_positive(&tcp_prot);
// Показать статистику!
// Как мы уже видели в самом начале статьи,
// `alloc` показывает все те сокеты, которые уже были выделены,
// но в данный момент ещё могут не находиться в состоянии "используется".
socket_seq_show(seq);
seq_printf(seq, "TCP: inuse %d orphan %d tw %d alloc %d mem %ld\n",
sock_prot_inuse_get(net, &tcp_prot), orphans,
atomic_read(&net->ipv4.tcp_death_row.tw_count), sockets,
proto_memory_allocated(&tcp_prot));
// ...
seq_printf(seq, "FRAG: inuse %u memory %u\n", !!frag_mem, frag_mem);
return 0;
}
❯ Что насчёт пространств имён?
Как вы могли заметить, в коде, относящемся к пространствам имён, отсутствует какая-либо логика, которая позволяла бы подсчитывать, сколько сокетов сейчас выделено.
Этот момент поначалу меня очень удивил — ведь я полагал, что именно в сетевом стеке пространства имён задействуются наиболее активно. Но оказалось, что есть и исключения.
интересно - `/proc/<pid>/net/tcp` с пространствами имён, а `/proc/<pid>/net/sockstat` — нет (до сих пор так, патч не приняли) pic.twitter.com/BcaVCAOczY
— Ciro S. Costa (@cirowrc) October 16, 2018
Если хотите сами разобраться в этом вопросе, рекомендую вам сначала изучить статью Using network namespaces and a virtual switch to isolate servers.
Суть в данном случае такова: можно создать набор сокетов, посмотреть sockstat
, затем создать сетевое пространство имён, зайти в него, а затем выясняется: хотя мы и не видим TCP-сокетов сразу из всей системы (именно так действует разграничение по пространствам имён!), мы всё-таки видим общее количество сокетов, выделенных в системе (как будто пространств имён нет).
# Создать набор сокетов, воспользовавшись нашим
# примером на C
./sockets.out
# Убедиться, что у нас есть набор сокетов
cat /proc/net/sockstat
sockets: used 296
TCP: inuse 5 orphan 0 tw 2 alloc 108 mem 3
UDP: inuse 1 mem 0
UDPLITE: inuse 0
RAW: inuse 0
FRAG: inuse 0 memory 0
# Создать сетевое пространство имён
ip netns add namespace1
# Зайти в него
ip netns exec namespace1 /bin/bash
# Убедиться, что `/proc/net/sockstat` показывает столько же
# выделенных сокетов.
TCP: inuse 0 orphan 0 tw 0 alloc 108 mem 3
UDP: inuse 0 mem 0
UDPLITE: inuse 0
RAW: inuse 0
FRAG: inuse 0 memory 0
❯ В качестве заключения
Интересно, оглянуться на то, что у меня получилось. Я углубился в исследование внутреннего устройства ядра, так как мне просто стало любопытно, как работает /proc
. В итоге я нашёл ответы, помогающие понять поведение конкретных функций, с которыми мне приходится сталкиваться при повседневной работе.
❯ Источники
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩
❯ Рекомендуемые статьи
Если из этой статьи вы извлекли для себя что-то новое, то посмотрите и следующие — вероятно, они также пойдут вам на пользу!
clerik_r
Спасибо за статью, познавательно и интересно!