Привет!

Если у вас уже есть некоторый опыт работы с веб-серверами, то вам наверняка доводилось попадать в классическую ситуацию «адрес уже используется»‬ (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-канале

Перейти ↩

❯ Рекомендуемые статьи

Если из этой статьи вы извлекли для себя что-то новое, то посмотрите и следующие — вероятно, они также пойдут вам на пользу!

Комментарии (0)