Как Linux создаёт и подсчитывает сокеты

в 8:05, , рубрики: linux, tcp, timeweb_статьи_перевод, udp, UNIX, веб-сервер, Сетевые технологии, сокеты, ядро

Привет!

Если у вас уже есть некоторый опыт работы с веб-серверами, то вам наверняка доводилось попадать в классическую ситуацию «адрес уже используется»‬ (EADDRINUSE).

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

Если вам просто интересно, как именно работает системный вызов socket(2), где именно хранятся все эти сокеты, то обязательно дочитайте эту статью до конца!

Как Linux создаёт и подсчитывает сокеты - 1

❯ В чём суть сокетов?

Сокеты – это конструкции, через которые обеспечивается коммуникация между процессами, работающими на разных машинах, и эта коммуникация происходит по сети, которая для всех этих процессов является базовой. Бывает и так, что сокеты применяются для коммуникации между процессами, работающими на одном и том же хосте (в таком случае речь идёт о сокетах Unix).

Очень точная аналогия, иллюстрирующая суть сокетов и по-настоящему меня впечатлившая, приводится в книге Computer Networking: A top-down approach.

В самом общем виде можно представить компьютер как «дом», в котором есть множество дверей.

Как Linux создаёт и подсчитывает сокеты - 2

Здесь каждая дверь — это сокет, и, как только к ней подойдёт клиент, он может «постучать» в неё.

Сразу после стука в дверь (отправка пакета SYN) дом автоматически реагирует на это, выдавая ответ (SYN+ACK), который затем сам заверяет (да, вот такой умный дом с «умной дверью»).

Как Linux создаёт и подсчитывает сокеты - 3

Тем временем, пока сам процесс просто сидит там в доме, сам «умный дом» координирует работу клиентов и выстраивает две очереди: одну для тех, которые всё ещё обмениваются приветствиями с домом, а другую для тех, кто уже справился с этапом приветствия.

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

Как Linux создаёт и подсчитывает сокеты - 4

Когда соединение считается принятым (клиенту сказано входить), сервер может коммуницировать с клиентом, передавая и получая данные в зависимости от того, что именно требуется.

Здесь стоит отметить, что фактически клиент «не впускают» в дом. Сервер создаёт в доме «приватную дверь» (клиентский сокет) и затем коммуникация с клиентом идёт именно через неё.

Эта статья будет понятнее, если вы пошагово представляете, как реализуется 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 socketsn");
		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().

Как Linux создаёт и подсчитывает сокеты - 5

❯ Задача этого метода – выделить две сущности: новый индексный дескриптор 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 %ldn",
		   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 %un", !!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. В итоге я нашёл ответы, помогающие понять поведение конкретных функций, с которыми мне приходится сталкиваться при повседневной работе.

❯ Источники

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

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

Автор: Albert_Wesker

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js