Публикуя перевод первой статьи из цикла материалов о реализации epoll, мы провели опрос, посвящённый целесообразности перевода продолжения цикла. Более 90% участников опроса высказались за перевод остальных статей. Поэтому сегодня мы публикуем перевод второго материала из этого цикла.



Функция ep_insert()


Функция ep_insert() — это одна из самых важных функций в реализации epoll. Понимание того, как она работает, чрезвычайно важно для того чтобы разобраться в том, как именно epoll получает сведения о новых событиях от файлов, за которыми наблюдает.

Объявление ep_insert() можно найти в строке 1267 файла fs/eventpoll.c. Рассмотрим некоторые фрагменты кода этой функции:

user_watches = atomic_long_read(&ep->user->epoll_watches);
if (unlikely(user_watches >= max_user_watches))
  return -ENOSPC;

В этом фрагменте кода функция ep_insert() сначала проверяет, не превышает ли общее количество файлов, за которым наблюдает текущий пользователь, значения, заданного в /proc/sys/fs/epoll/max_user_watches. Если user_watches >= max_user_watches, то функция немедленно прекращает работу с errno, установленным в ENOSPC.

После этого ep_insert() выделяет память, пользуясь механизмом управления памятью slab ядра Linux:

if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL)))
  return -ENOMEM;

Если функции удалось выделить объём памяти, достаточный для struct epitem, будет выполнен следующий процесс инициализации:

/* Инициализация ... */
INIT_LIST_HEAD(&epi->rdllink);
INIT_LIST_HEAD(&epi->fllink);
INIT_LIST_HEAD(&epi->pwqlist);
epi->ep = ep;
ep_set_ffd(&epi->ffd, tfile, fd);
epi->event = *event;
epi->nwait = 0;
epi->next = EP_UNACTIVE_PTR;

После этого ep_insert() попытается зарегистрировать коллбэк в файловом дескрипторе. Но прежде чем мы сможем об этом поговорить, нам нужно познакомиться с некоторыми важными структурами данных.

Структура poll_table — это важная сущность, используемая реализацией poll() VFS. (Понимаю, что в этом можно запутаться, но тут мне хотелось бы объяснить, что функция poll(), которую я тут упомянул, представляет собой реализацию файловой операции poll(), а не системный вызов poll()). Она объявлена в include/linux/poll.h:

typedef struct poll_table_struct {
  poll_queue_proc _qproc;
  unsigned long _key;
} poll_table;

Сущность poll_queue_proc представляет тип функции-коллбэка, который выглядит так:

typedef void (*poll_queue_proc)(struct file *, wait_queue_head_t *, struct poll_table_struct *);

Член _key таблицы poll_table, на самом деле, является не тем, чем он может показаться на первый взгляд. А именно, несмотря на имя, наводящее на мысль о некоем «ключе», в _key, на самом деле, хранятся маски интересующих нас событий. В реализации epoll _key устанавливается в ~0 (дополнение до 0). Это значит, что epoll стремится получать сведения о событиях любых видов. В этом есть смысл, так как приложения пользовательского пространства могут в любое время менять маску событий с помощью epoll_ctl(), принимая все события от VFS, а затем фильтруя их в реализации epoll, что упрощает работу.

Для того чтобы облегчить восстановление в poll_queue_proc исходной структуры epitem, epoll использует простую структуру, называемую ep_pqueue, которая служит обёрткой для poll_table с указателем на соответствующую структуру epitem (файл fs/eventpoll.c, строка 243):

/* Структура-обёртка, используемая в очереди */
struct ep_pqueue {
  poll_table pt;
  struct epitem *epi;
};

Затем ep_insert() инициализирует struct ep_pqueue. Следующий код сначала записывает в член epi структуры ep_pqueue указатель на структуру epitem, соответствующую файлу, который мы пытаемся добавить, а затем записывает ep_ptable_queue_proc() в член _qproc структуры ep_pqueue, а в _key записывает ~0.

/* Инициализация таблицы с использованием коллбэка */
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);

После этого ep_insert() вызовет ep_item_poll(epi, &epq.pt);, что приведёт к вызову реализации poll(), связанной с файлом.

Давайте рассмотрим пример, в котором используется реализация poll() TCP-стека Linux, и разберёмся с тем, что именно эта реализация делает с poll_table.

Функция tcp_poll() — это реализация poll() для TCP-сокетов. Её код можно найти в файле net/ipv4/tcp.c, в строке 436. Вот фрагмент этого кода:

unsigned int tcp_poll(struct file *file, struct socket *sock, poll_table *wait)
{
  unsigned int mask;
  struct sock *sk = sock->sk;
  const struct tcp_sock *tp = tcp_sk(sk);

  sock_rps_record_flow(sk);

  sock_poll_wait(file, sk_sleep(sk), wait);

  // код опущен
}

Функция tcp_poll() вызывает sock_poll_wait(), передавая, в качестве второго аргумента, sk_sleep(sk), а в качестве третьего — wait (это — ранее переданная функции tcp_poll() таблица poll_table).

А что представляет собой sk_sleep()? Как оказывается, это — всего лишь геттер, предназначенный для доступа к очереди ожидания событий для конкретной структуры sock (файл include/net/sock.h, строка 1685):

static inline wait_queue_head_t *sk_sleep(struct sock *sk)
{
  BUILD_BUG_ON(offsetof(struct socket_wq, wait) != 0);
  return &rcu_dereference_raw(sk->sk_wq)->wait;
}

Что же sock_poll_wait() собирается делать с очередью ожидания событий? Оказывается, что эта функция выполнит некую простую проверку и потом вызовет poll_wait() с передачей тех же самых параметров. Затем функция poll_wait() вызовет заданный нами коллбэк и передаст ему очередь ожидания событий (файл include/linux/poll.h, строка 42):

static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
{
  if (p && p->_qproc && wait_address)
    p->_qproc(filp, wait_address, p);
}

В случае с epoll сущность _qproc будет представлять собой функцию ep_ptable_queue_proc(), объявленную в файле fs/eventpoll.c, в строке 1091.

/*
* Это - коллбэк, используемый для включения нашей очереди ожидания в
* состав списков процессов целевого файла, работу которых надо возобновить.
*/
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,
       poll_table *pt)
{
  struct epitem *epi = ep_item_from_epqueue(pt);
  struct eppoll_entry *pwq;

  if (epi->nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL))) {
    init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
    pwq->whead = whead;
    pwq->base = epi;
    add_wait_queue(whead, &pwq->wait);
    list_add_tail(&pwq->llink, &epi->pwqlist);
    epi->nwait++;
  } else {
    /* Нам нужно сообщить о возникновении ошибки */
    epi->nwait = -1;
  }
}

Сначала ep_ptable_queue_proc() пытается восстановить структуру epitem, которая соответствует файлу из очереди ожидания, с которым мы работаем. Так как epoll использует структуру-обёртку ep_pqueue, восстановление epitem из указателя poll_table представлено простой операцией с указателями.

После этого ep_ptable_queue_proc() просто выделяет столько памяти, сколько нужно для struct eppoll_entry. Эта структура работает как «связующее звено» между очередью ожидания файла, за которым ведётся наблюдение, и соответствующей структурой epitem для этого файла. Для epoll чрезвычайно важно знать о том, где находится голова очереди ожидания для файла, за которым ведётся наблюдение. В противном случае epoll не сможет позже отменить регистрацию в очереди ожидания. Структура eppoll_entry, кроме того, включает в себя очередь ожидания (pwq->wait) с функцией возобновления работы процесса, представленной ep_poll_callback(). Возможно, pwq->wait — это самая важная часть во всей реализации epoll, так как эту сущность используют для решения следующих задач:

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

После этого ep_ptable_queue_proc() присоединит pwq->wait к очереди ожидания целевого файла (whead). Функция, кроме того, добавит struct eppoll_entry в связный список из struct epitem (epi->pwqlist) и инкрементирует значение epi->nwait, представляющее собой длину списка epi->pwqlist.

А вот тут у меня возникает один вопрос. Почему epoll нужно использовать связный список для хранения структуры eppoll_entry внутри структуры epitem одного файла? Не нужен ли epitem лишь один элемент eppoll_entry?

Я, правда, не могу точно ответить на этот вопрос. Насколько я могу судить, если только некто не собирается использовать экземпляры epoll в каких-нибудь безумных циклах, список epi->pwqlist будет содержать лишь один элемент struct eppoll_entry, а epi->nwait для большинства файлов, скорее всего, будет равняться 1.

Хорошо тут то, что неясности вокруг epi->pwqlist никак не отражаются на том, о чём я буду говорить ниже. А именно, речь пойдёт о том, как Linux уведомляет экземпляры epoll о событиях, происходящих с файлами, за которыми осуществляется наблюдение.

Помните то, о чём мы говорили в предыдущем разделе? Речь шла о том, что epoll присоединяет wait_queue_t к списку ожидания целевого файла (к wait_queue_head_t). Несмотря на то, что wait_queue_t чаще всего используется как механизм возобновления работы процессов, это, по сути, просто структура, хранящая указатель на функцию, которая будет вызвана тогда, когда Linux решит возобновить работу процессов из очереди wait_queue_t, прикреплённую к wait_queue_head_t. В этой функции epoll может принять решение о том, что делать с сигналом возобновления работы, но у epoll нет необходимости возобновлять работу какого-либо процесса! Как можно будет увидеть позже, обычно при вызове ep_poll_callback() возобновления работы чего-либо не происходит.

Полагаю, ещё стоит обратить внимание на то, что механизм возобновления работы процессов, применяемый в poll(), полностью зависит от реализации. В случае с файлами TCP-сокетов голова очереди ожидания — это член sk_wq, сохранённый в структуре sock. Это, кроме того, объясняет необходимость использования коллбэка ep_ptable_queue_proc() для работы с очередью ожидания. Так как в реализациях очереди для разных файлов голова очереди может оказаться в совершенно разных местах, у нас нет способа обнаружить нужное нам значение wait_queue_head_t без использования коллбэка.

Когда именно осуществляется возобновление работы sk_wq в структуре sock? Как оказалось, система сокетов Linux следует тем же «OO»-принципам проектирования, что и VFS. Структура sock объявляет следующие хуки в строке 2312 файла net/core/sock.c:

void sock_init_data(struct socket *sock, struct sock *sk)
{
  // код опущен...
  sk->sk_data_ready  =   sock_def_readable;
  sk->sk_write_space =  sock_def_write_space;
  // код опущен...
}

В sock_def_readable() и sock_def_write_space() осуществляется вызов wake_up_interruptible_sync_poll() для (struct sock)->sk_wq с целью выполнения функции-коллбэка, возобновляющей работу процесса.

Когда же будут вызываться sk->sk_data_ready() и sk->sk_write_space()? Это зависит от реализации. Рассмотрим, в качестве примера, TCP-сокеты. Функция sk->sk_data_ready() будет вызвана во второй половине обработчика прерывания в том случае, когда TCP-подключение завершит процедуру трёхстороннего рукопожатия, или когда для некоего TCP-сокета будет получен буфер. Функция sk->sk_write_space() будет вызвана при изменении состояния буфера с full на available. Если помнить об этом при разборе следующих тем, особенно той, что посвящена срабатыванию по фронту, эти темы будут выглядеть интереснее.

Итоги


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

Пользовались ли вы epoll?