[Из песочницы] SO_TIMESTAMPING в картинках. Прием пакета

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


Наивным решением будет запоминать в приложении время сразу после получения пакета от ядра (или перед отправкой ядру):


  recv(sock, buffer, length, flags);
  clock_gettime(CLOCK_REALTIME, timespec);

Ясно, что полученное таким образом время может заметно отличаться от момента, когда пакет был получен Сетевым Устройством. Для получения более точного времени нужна поддержка от операционной системы, драйвера и/или Сетевого Устройства.


Начиная с версии 2.6.30 Линукс поддерживает опцию сокета SO_TIMESTAMPING. Она позволяет пользовательскому сокету получать временные метки для отправляемых и принимаемых пакетов. Временные метки могут быть сняты самим ядром, драйвером или сетевым устройством (см. список поддерживающих устройств и драйверов). О том, что это вообще такое и как этим пользоваться, стоит почитать в Documentation/networking/timestamping.txt


В этой статье я расскажу о том, как пакеты доставляются от сетевого устройства пользователю, когда при этом снимаются временные метки, как они доставляются пользователю и насколько они точны. Приведенные примеры кода ядра взяты из версии 4.1.


Начальные знания


struct sk_buff


Все сетевые пакеты в ядре представляются структурой struct sk_buff, которая объявлена в файле include/linux/skbuff.h. Рассмотрим некоторые из ее полей:


struct sk_buff {
    /* временная метка, обычно снятая программно */
    ktime_t     tstamp;

    /* указатель на сокет, который отправил или получил этот пакет */
    struct sock     *sk;

    /* указатель на устройство, с которого получен или которому будет отправлен пакет */
    struct net_device   *dev;

    /* L3 протокол */
    __be16          protocol;

    /* смещения заголовков относительно head */
    __u16           transport_header;
    __u16           network_header;
    __u16           mac_header;

    /* sk_buff_data_t - может быть или указателем, или смещением относительно head
     * tail - указатель на конец данных
     * end  - указатель на конец буфера выделенного под данные
     */
    sk_buff_data_t      tail;
    sk_buff_data_t      end;

    /* head - указатель на начало буфера выделенного под данные
     * data - указатель на начало данных. Т.к для разных
     *        протоколов данными могут называться разные вещи,
     *        этот указатель меняется при движении пакета по сетевому стеку.
     *        (Например: для IP данные начинаются с заголовка TCP/UDP,
     *        для Ethernet - с IP заголовка)
     */
    unsigned char       *head,
                *data;

    /* счетчик ссылок */
    atomic_t        users;
};

Экземпляры этой структуры я буду коротко называть skb.


Вместе с каждой struct sk_buff выделяется буфер под заголовки с полезными данными (тот самый, в который указывают skb->head, skb->tail) и следующую сразу за ними структуру struct skb_shared_info. (см. include/linux/skbuff.h).


4aa4ee3c336a4842875b3324e2a99bb0.png

Причем несколько разных skb могут ссылаться на один буфер и соответствующую ему struct skb_shared_info. Это бывает удобно при доставке одного skb нескольким пользователям, которым позволено менять поля struct sk_buff, но не данные в буфере.


Самые интересные для нас поля struct skb_shared_info:


struct skb_shared_hwtstamps {
    ktime_t hwtstamp;
};

/* ... */

struct skb_shared_info {
    /* флаги отправляемого пакета */
    __u8        tx_flags;

    /* и снова временная метка, обычно снятая аппаратно */
    struct skb_shared_hwtstamps hwtstamps;
};

Для доступа к skb_shared_info есть макрос skb_shinfo(skb), а для доступа к полю hwtstamps — функция skb_hwtstamps(skb). См. include/linux/skbuff.h


struct net_device и struct sock


  • `struct net_device` выделяется для каждого зарегистрированного в системе сетевого устройства. Она хранит конфигурацию устройства, статистику и кучу других данных. Кроме того, при регистрации устройства, драйвер записывает в эту структуру указатели на свои функции, которые потом вызываются ядром. См. include/linux/netdevice.h
  • `struct sock` выделяется для каждого созданного пользователем сокета и инициализируется функциями указанного им семейства адресов (AF_INET, AF_PACKET …). Здесь хранятся указатели на функции, реализующие системные вызовы, очереди
    принятых сокетом, но еще не доставленных пользователю skb, флаги и многое другое. См. include/net/sock.h.

Мы не станем подробно рассматривать эти структуры. Сейчас достаточно понять, что первая позволяет ядру общаться с устройством, а вторая — с пользовательским сокетом. Для принятого пакета: skb->dev указывает на устройство, которым он был получен; skb->sk на сокет, которому будет доставлен. Для отправляемого — наоборот.


SOFTIRQ


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


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


SOFTIRQ — Механизм ядра, позволяющий запланировать отложенный вызов функции. Часто используется для реализации Bottom Half. Всего в ядре v4.1 десять разных SOFTIRQ (См. список), обработчики для которых определяются при компиляции ядра. Во время своего выполнения обработчик может быть прерван только аппаратным прерыванием. Для каждого процессора своя маска запланированных SOFTIRQ, т.е обработчик будет вызван на том же процессоре, с которого был запланирован. (На самом деле, можно ухитриться и указать на каком конкретно процессоре запланировать SOFTIRQ.) Одно и то же SOFTIRQ может быть запланировано и
выполнено независимо на двух разных процессорах. При отправке и получении пакетов используются два из них: NET_RX_SOFTIRQ с обработчиком net_rx_action и NET_TX_SOFTIRQ с обработчиком net_tx_action. (см. net_rx_action и net_tx_action)


Для выполнения запланированных SOFTIRQ служит функция do_softirq() (см kernel/softirq.c). Она поочередно вызывает обработчики SOFTIRQ, начиная с самых приоритетных (меньший номер — более высокий приоритет). Она вызывается после каждого обработчика аппаратного прерывания. Кроме того, на каждом процессоре крутится процесс ядра ksoftirqd, который периодически (как часто — зависит от нагрузки на процессор) вызывает do_softirq().


Прием пакета


Для общения с Сетевыми Устройствами Линукс использует смесь прерываний и поллинга (polling). (см. NAPI)


Вот как это выглядит:


Top Half


56efb1a2d49d44a58bea0046a9d54a9f.png
  1. При получении пакета, устройство посылает одному из процессоров прерывание.
  2. Происходит вызов обработчика в коде драйвера. Задача обработчика — оповестить ядро о наличии у его устройства пакетов, для этого он вызывает функцию `napi_schedule ()`, которая:
    • добавляет устройство в `poll_list`*
    • планирует для выполнения NET_RX_SOFTIRQ

    Заметим, что первая операция — добавление элемента в двусвязный список, и вторая — установка бита в маске SOFTIRQ очень быстрые. Т.к ядро уже в курсе, что у Устройства есть пакеты, скорее всего драйвер захочет на время выключить на нем прерывания.
  3. Выход из обработчика прерывания, сразу за которым последует вызов `do_softirq ()`.

poll_list* — список, создаваемый ядром для каждого ядра процессора (как одно из полей struct softnet_data). Он хранит устройства, с которых NET_RX_SOFTIRQ предстоит получить пакеты.


Bottom Half


9d9b5a58aee04dd19fff0864af3d09b8.png

Произошел вызов do_softirq(). После выполнения более приоритетных SOFTIRQ, будет вызван обработчик NET_RX_SOFTIRQ — net_rx_action().


Эта функция проходит по списку poll_list и для каждого устройства dev, пока на нем есть пакеты, вызывает виртуальную функцию драйвера napi→poll (См. include/linux/netdevice.h.), которая:


  1. Получает от Устройства пакет, и формирует skb
  2. Через вызов `netif_receive_skb (skb)` пердает их по одному на обработку ядру
  3. Если на устройстве больше нет пакетов, включает для него прерывание и сообщает об этом ядру функцией `napi_complete ()`

Стоит отметить, что net_rx_action() позволяет обрабатывать не более netdev_budget(экспортируется в /proc/sys/net/core/netdev_budget) пакетов за раз и ограничивает время своего выполнения 2/HZ секундами (на x86 по умолчанию HZ = 1000, т.е ограничение времени = 2 мс).


netif_receive_skb ()


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


#define net_timestamp_check(COND, SKB)          \
    if (static_key_false(&netstamp_needed)) {   \
        if ((COND) && !(SKB)->tstamp.tv64)  \
            __net_timestamp(SKB);       \
    }                       \

static int netif_receive_skb_internal(struct sk_buff *skb)
{
    net_timestamp_check(netdev_tstamp_prequeue, skb);

    /* ... */

    return __netif_receive_skb(skb);
}

Мы видим, что первым делом после получения пакета, функция вызывает макрос net_timestamp_check. Теперь по порядку:


  • `static_key_false (&netstamp_needed)` — проверка просил ли кто-то из пользователей снимать временную метку сразу после получения пакета ядром. Она реализована с помощью static keys, этот механизм позволяет эффективно включать/выключать редко используемые функции ядра (см. Documentation/static-keys.txt). Мы не станем подробно его рассматривать.
  • `netdev_tstamp_prequeue` — переменная, экспортируемая в `/proc/sys/net/core/netdev_tstamp_prequeue`. По умолчанию равна 1. Если установить ее в 0, таймстамп будет сниматься в функции `__netif_receive_skb_core ()`(Мы еще вернемся к ней.)
  • Макрос `__net_timestamp (skb)` записывает в `skb→tstamp` текущее время.

Обычно пакет обрабатывается тем процессором, на котором был запланирован SOFTIRQ, а это тот процессор, на который пришло прерывание. Некоторые сетевые карты шлют только одно прерывание только одному процессору, не позволяя распараллелить обработку пакетов на многопроцессорных системах. Для решения этой проблемы был придуман Receive Packet Steering (RPS).


Если в ядре включен RPS, netif_receive_skb_internal() может поставить пакет в очередь (backlog) другого процессора и запланировать на нем NET_RX_SOFTIRQ. Через какое-то время, другой процессор начнет обработку этого пакета с функции __netif_receive_skb(), которая вызывает __netif_receive_skb_core(). Помните переменную netdev_tstamp_prequeue? В случае с RPS она позволяет выбрать когда снимать временную метку: до отправки пакета в чужую очередь или уже после извлечения его оттуда.


RPS настраивается через /sys/class/net//queues/. Подробнее см. Документацию на redhat.com.


__netif_receive_skb_core ()


Прежде всего эта функция снимает временную метку, если она не была снята в netif_receive_skb_internal():


    net_timestamp_check(!netdev_tstamp_prequeue, skb);

Теперь мы имеем skb, который содержит:


  • данные по указателю `skb→data`
  • в `skb→tstamp` время получения ядром пакета
  • в `skb_hwtstamps (skb)` время получения пакета Сетевым Устройством

Остается доставить его всем желающим получателям.


В зависимости от требуемого L3 протокола и устройства получатели могут регистрироваться в нескольких местах:


  • `ptype_all` — список желающих получать все пакеты со всех устройств. Обычно это AF_PACKET сокет какого-нибудь `tcpdump`.
  • `ptype_base[PTYPE_HASH_SIZE]` — хеш таблица, где в качестве ключей выступают номера L3 протоколов.
    Здесь регистрируются те, кто хочет получать пакеты только определенного L3 протокола со всех устройств.
    Например для всех AF_INET сокетов здесь числится один обработчик — функция `ip_recv`.
  • `skb→dev→ptype_all` — такой же список, как `ptype_all`. В котором регистрируются те,
    кто хочет получать все пакеты с устройства `skb→dev`.
  • `skb→dev→ptype_specific` — в этом списке регистрируются те, кто хочет получать пакеты определенного протокола
    с устройства `skb→dev`.

Частые пользователи всех 4-х списков — AF_PACKET сокеты, каждый из них при системном вызове bind регистрирует в соответствующем списке обработчик. (Обработчика зовут packet_rcv) На самом деле есть еще куча получателей, но мы ограничимся только теми, которые доставляют пакет сокетам в пространстве пользователя.


UDP или TCP пакет будет принят функцией ip_recv. Через эту функцию пакеты попадают в обработку стеком протоколов TCP/IP. Обработка включает в себя проверки контрольных сумм, прохождение через таблицы iptables, поиск сокета-получателя по ip адресу и номеру порта, удаление заловков L2, L3, L4(которые не должны попасть в userspace).


Когда стало ясно, какой сокет должен получить этот skb, skb помещается в очередь приема сокета. Когда пользователь вызовет recvmsg на этом сокете, в указанный им буфер в userspace будут скопированы данные пакета (те, что идут после tcp/udp заголовка), а в контрольном сообщении (см. man 3 cmsg и man 2 recvmsg) будут лежать обе временных метки в формате struct timespec. (См Documentation/networking/timestamping.txt.)


Временные метки кладутся в контрольное сообщение функцией __sock_recv_timestamp. См. net/socket.c.


Важное различие: ip_recv зарегистрирована как обработчик всего один раз в ptype_base сколько бы AF_INET сокетов вы не создали, а packet_rcv регистрируется для каждого AF_PACKET сокета по разу в каком-нибудь из списков.


Тестирование Задержек


Мы видели, что для принимаемых пакетов дважды снимаются временные метки: сетевой картой (Thard), ядром сразу при получении (Tsoft). Подводя итог, посмотрим чем вызываются задержки между ними и моментом доставки пакета пользователю (Tuser):


  • Tsoft — Thard = [ожидание NET_RX_SOFTIRQ] + [обработка ядром других пакетов (включая пакеты от других устройств)]

    Если за один вызов NET_RX_SOFTIRQ не удалось обработать наш пакет (был превышен netdev_budget или ограничение по времени 2/HZ), то ядро позволит выполняться другим процессам, пока аппаратное прерывание или ksoftirqd снова не вызовут `do_softirq ()`. Также не стоит забывать о том, что есть и другие SOFTIRQ, на выполнение которых тоже уходит время.

    Даже при выполнении NET_RX_SOFTIRQ некоторые пакеты будут обработаны раньше нашего.

  • Tuser — Tsoft = [доставка другим сокетам] + [доставка нашему сокету]

    В зависимости от того, что это за сокеты, доставка может занимать разное время.


Чтобы примерно оценить, насколько велики эти задержки и насколько они предсказуемы, воспользуемся программой rxtest. Эта программа:


  1. создает сокет (пакетный или UDP)
  2. настраивает на нем программные и аппаратные временные метки для принимаемых пакетов через SO_TIMESTAMPING
  3. принимает с сокета указанное количество пакетов вместе с врменными метками
  4. считает для задержек среднее значение и среднеквадратическое отклонение

Проверка проводилась на Core i7 с Linux 4.0 с сетевой картой Intel 82599ES под управлением драйвера ixgbe.


В моем случае сетевая карта имеет свои аппаратные часы и снимает временные метки по ним. И нет никакой гарантии, что эти часы как-то синхронизированы с jiffies ядра. Для того, чтобы это исправить, запустим программу phc2sys из linuxptp:


    # тестируемый сетевой интерфейс называется eth5
    phc2sys -s CLOCK_REALTIME -c eth5 -m

Её нужно держать открытой на протяжении всей проверки. Она будет заниматься подстройкой часов сетевой карты под системное время и выводить текущее расхождение часов. В моем случае абсолютное значение расхождения не превышало 10нс.


Кроме установки настроек SO_TIMESTAMPING на сокете, нам нужно попросить сетевую карту запоминать временные метки. Воспользуемся для этого утилитой hwstamp_ctl из того же linuxptp.


    hwstamp_ctl -i eth5 -r 13

Это приведет к тому, что сетевая карта будет снимать временные метки для всех пакетов типа Sync протокола PTP. Почему именно Sync PTP? Потому что наша сетевая карта не умеет снимать временные метки для всех пакетов. Ей обязательно нужно указать какой-нибудь тип пакетов протокола PTP. (Это свзязано с тем, что поддержка аппаратных временных меток в Linux была введена для работы протокола PTP, позволяющего синхронизировать время с точностью до наносекунд.)


Стартуем rxtest:


    rxtest packet eth5 1000

Тем временем шлем с другого конца кабеля по 10 PTP Sync пакетов в секунду. (Я брал примеры PTP пакетов с https://wiki.wireshark.org/Protocols/ptp и слал их с помощью tcpreplay.)


Результат выполнения rxtest:


    hard->soft delay: packets 1000: 18.1603 +- 1.18737 microseconds
    soft->user delay: packets 1000: 5.54756 +- 1.88607 microseconds

При этом тестируемому сетевому интерфейсу не был присвоен ip-адрес и наши пакеты не попадали на обработку протоколом IP. Этим можно объяснить совсем небольшую задержку Tuser — Tsoft.


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


На этом все. Буду рад любым отзывам и критике.


Ссылки


  • Christian Benvenuti. Understanding Linux Network Internals
  • Jonathan Corbet, Alessandro Rubini, Greg Kroah-Hartman. Linux Device Drivers
  • http://lxr.free-electrons.com/
  • http://www.linuxfoundation.org/collaborate/workgroups/networking

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

© Habrahabr.ru