Мониторинг и настройка сетевого стека Linux: получение данных

в 9:43, , рубрики: linux kernel, Блог компании Mail.Ru Group, давайте кратко пробежимся, Настройка Linux, никто не читает теги, оптимизация, Сетевые технологии, системное администрирование

Мониторинг и настройка сетевого стека Linux: получение данных - 1

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

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



1. Общий совет по мониторингу и настройке сетевого стека в Linux

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

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

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

Опасно применять сетевые настройки, подключившись к машине удалённо. Можно легко заблокировать себе доступ или вообще уронить систему работы с сетью. Не применяйте настройки на рабочих машинах, сначала обкатайте их, по мере возможности, на новых, а затем применяйте в production.


2. Обзор проблематики

Вы можете захотеть иметь под рукой копию спецификации (data sheet) устройства. В этой статье будет рассмотрен контроллер Intel I350, управляемый драйвером igb. Скачать спецификацию можно отсюда.
Высокоуровневый путь, по которому проходит пакет от прибытия до приёмного буфера сокета выглядит так:

  1. Драйвер загружается и инициализируется.
  2. Пакет прибывает из сети в сетевую карту.
  3. Пакет копируется (посредством DMA) в кольцевой буфер памяти ядра.
  4. Генерируется аппаратное прерывание, чтобы система узнала о появлении пакета в памяти.
  5. Драйвер вызывает NAPI, чтобы начать цикл опроса (poll loop), если он ещё не начат.
  6. На каждом CPU системы работают процессы ksoftirqd. Они регистрируются во время загрузки. Эти процессы вытаскивают пакеты из кольцевого буфера с помощью вызова NAPI-функции poll, зарегистрированной драйвером устройства во время инициализации.
  7. Очищаются (unmapped) те области памяти в кольцевом буфере, в которые были записаны сетевые данные.
  8. Данные, отправленные напрямую в память (DMA), передаются для дальнейшей обработки на сетевой уровень в виде ‘skb’.
  9. Если включено управление пакетами, или если в сетевой карте есть несколько очередей приёма, то фреймы входящих сетевых данных распределяются по нескольким CPU системы.
  10. Фреймы сетевых данных передаются из очереди на уровни протоколов.
  11. Уровни протоколов обрабатывают данные.
  12. Данные добавляются в буферы приёма, прикреплённые к сокетам уровнями протоколов.

Далее мы подробно рассмотрим весь этот поток. В качестве уровне протоколов будут рассмотрены уровни IP и UDP. Большая часть информации верна и для других уровней протоколов.


3. Подробный разбор

Мы будем рассматривать ядро Linux версии 3.13.0. Также по всей статье используются примеры кода и ссылки на GitHub.

Очень важно разобраться, как именно пакеты принимаются ядром. Нам придётся внимательно ознакомиться и понять работу сетевого драйвера, чтобы потом было легче вникнуть в описание работы сетевого стека.

В качестве сетевого драйвера будет рассмотрен igb. Он используется в довольно распространённой серверной сетевой карте, Intel I350. Так что давайте начнём с разбора работы этого драйвера.


3.1. Драйвер сетевого устройства

Инициализация

Драйвер регистрирует функцию инициализации, вызванную ядром при загрузке драйвера. Регистрация выполняется с помощью макроса module_init.
Вы можете найти функцию инициализации igb (igb_init_module) и её регистрацию с помощью module_init в drivers/net/ethernet/intel/igb/igb_main.c. Всё довольно просто:

/**
 *  igb_init_module – подпрограмма (routine) регистрации драйвера
 *
 *  igb_init_module — это первая подпрограмма, вызываемая при загрузке драйвера.
 *  Она выполняет регистрацию с помощью подсистемы PCI.
 **/
static int __init igb_init_module(void)
{
  int ret;
  pr_info("%s - version %sn", igb_driver_string, igb_driver_version);
  pr_info("%sn", igb_copyright);

  /* ... */

  ret = pci_register_driver(&igb_driver);
  return ret;
}

module_init(igb_init_module);

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

Инициализация PCI

Сетевая карта Intel I350 — это устройство с интерфейсом PCI express.

PCI-устройства идентифицируют себя с помощью серии регистров в конфигурационном пространстве PCI.

Когда драйвер устройства скомпилирован, то для экспорта таблицы идентификаторов PCI-устройств, которыми может управлять драйвер, используется макрос MODULE_DEVICE_TABLE (из include/module.h). Ниже мы увидим, что таблица также регистрируется как часть структуры.

Эта таблица используется ядром для определения, какой нужно загрузить драйвер для управления устройством. Таким образом операционная система понимает, какое устройство подключено и какой драйвер позволяет с ним взаимодействовать.

Вы можете найти таблицу и идентификаторы PCI-устройств для драйвера igb, соответственно, здесь drivers/net/ethernet/intel/igb/igb_main.c и здесь drivers/net/ethernet/intel/igb/e1000_hw.h:

static DEFINE_PCI_DEVICE_TABLE(igb_pci_tbl) = {
  { PCI_VDEVICE(INTEL, E1000_DEV_ID_I354_BACKPLANE_1GBPS) },
  { PCI_VDEVICE(INTEL, E1000_DEV_ID_I354_SGMII) },
  { PCI_VDEVICE(INTEL, E1000_DEV_ID_I354_BACKPLANE_2_5GBPS) },
  { PCI_VDEVICE(INTEL, E1000_DEV_ID_I211_COPPER), board_82575 },
  { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_COPPER), board_82575 },
  { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_FIBER), board_82575 },
  { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_SERDES), board_82575 },
  { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_SGMII), board_82575 },
  { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_COPPER_FLASHLESS), board_82575 },
  { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_SERDES_FLASHLESS), board_82575 },

  /* ... */
};
MODULE_DEVICE_TABLE(pci, igb_pci_tbl);

Как мы видели выше, pci_register_driver вызывается драйверной функцией инициализации.

Эта функция регистрирует структуру указателей. Большинство из них являются указателями функций, но таблица идентификаторов PC-устройства тоже регистрируется. Ядро использует регистрируемые драйвером функции для запуска PCI-устройства.

Из drivers/net/ethernet/intel/igb/igb_main.c:

static struct pci_driver igb_driver = {
  .name     = igb_driver_name,
  .id_table = igb_pci_tbl,
  .probe    = igb_probe,
  .remove   = igb_remove,

  /* ... */
};

Probe-функция PCI

Когда устройство опознано по его PCI ID, ядро может выбрать подходящий драйвер. Каждый драйвер регистрирует probe-функцию в PCI-системе ядра. Ядро вызывает эту функцию для тех устройств, на которые ещё не претендовали драйверы. Когда один из драйверов претендует на устройство, то другие уже не опрашиваются. Большинство драйверов содержат много кода, которые выполняется для подготовки устройства к использованию. Выполняемые процедуры сильно варьируются в зависимости от драйвера.

Вот некоторые типичные процедуры:

  1. Включение PCI-устройства.
  2. Запрашивание областей памяти и портов ввода-вывода.
  3. Настройка маски DMA.
  4. Регистрируются поддерживаемые драйвером функции ethtool (будут описаны ниже).
  5. Выполняются сторожевые таймеры (например, у e1000e есть таймер, проверяющий, не зависло ли железо).
  6. Другие процедуры, характерные для данного устройства. Например, обход или разрешение аппаратных выкрутасов, и тому подобное.
  7. Создание, инициализация и регистрация структуры struct net_device_ops. Она содержит указатели на разные функции, нужные для открытия устройства, отправки данных в сеть, настройки MAC-адреса и так далее.
  8. Создание, инициализация и регистрация высокоуровневой структуры struct net_device, представляющей сетевое устройство.

Давайте пробежимся по некоторым из этих процедур применительно к драйверу igb и функции igb_probe.

Беглый взгляд на инициализацию PCI

Приведённый ниже код из функции igb_probe выполняет базовое конфигурирование PCI. Взято из drivers/net/ethernet/intel/igb/igb_main.c:

err = pci_enable_device_mem(pdev);

/* ... */

err = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64));

/* ... */

err = pci_request_selected_regions(pdev, pci_select_bars(pdev,
           IORESOURCE_MEM),
           igb_driver_name);

pci_enable_pcie_error_reporting(pdev);

pci_set_master(pdev);
pci_save_state(pdev);

Сначала устройство инициализируется с помощью pci_enable_device_mem. Если устройство находится в спящем режиме, то оно пробуждается, активируются источники памяти и так далее.

Затем настраивается маска DMA. Наше устройство может читать и писать в адреса 64-битной памяти, поэтому с помощью DMA_BIT_MASK(64) вызывается dma_set_mask_and_coherent.

С помощью вызова pci_request_selected_regions резервируются области памяти. Запускается служба расширенной регистрации ошибок (PCI Express Advanced Error Reporting), если загружен её драйвер. С помощью вызова pci_set_master активируется DMA, а конфигурационное пространство PCI сохраняется с помощью вызова pci_save_state.

Фух.

Дополнительная информация о драйвере PCI для Linux

Полный разбор работы PCI-устройства выходит за рамки этой статьи, но вы можете почитать эти материалы:

Инициализация сетевого устройства

Функция igb_probe выполняет важную работу по инициализации сетевого устройства. В дополнение к процедурам, характерным для PCI, она выполняет и более общие операции для работы с сетью и функционирования сетевого устройства:

  1. Регистрирует struct net_device_ops.
  2. Регистрирует операции ethtool.
  3. Получает от сетевой карты MAC-адрес по умолчанию.
  4. Настраивает флаги свойств net_device.
  5. И делает многое другое.

Всё это нам понадобится позднее, так что давайте кратко пробежимся.

struct net_device_ops

struct net_device_ops содержит указатели функций на многие важные операции, необходимые сетевой подсистеме для управления устройством. Эту структуру мы ещё не раз упомянем в статье.

Структура net_device_ops прикреплена к struct net_device в igb_probe. Взято из drivers/net/ethernet/intel/igb/igb_main.c:

static int igb_probe(struct pci_dev *pdev, const struct pci_device_id *ent)
{
  /* ... */

  netdev->netdev_ops = &igb_netdev_ops;

В том же файле настраиваются указатели функций, хранящиеся в структуре net_device_ops. Взято из drivers/net/ethernet/intel/igb/igb_main.c:

static const struct net_device_ops igb_netdev_ops = {
  .ndo_open               = igb_open,
  .ndo_stop               = igb_close,
  .ndo_start_xmit         = igb_xmit_frame,
  .ndo_get_stats64        = igb_get_stats64,
  .ndo_set_rx_mode        = igb_set_rx_mode,
  .ndo_set_mac_address    = igb_set_mac,
  .ndo_change_mtu         = igb_change_mtu,
  .ndo_do_ioctl           = igb_ioctl,

  /* ... */

Как видите, в struct есть несколько интересных полей, например, ndo_open, ndo_stop, ndo_start_xmit и ndo_get_stats64, которые содержат адреса функций, реализованных драйвером igb. Некоторые из них мы далее рассмотрим подробнее.

Регистрация ethtool

ethtool — это программа, управляемая из командной строки. С её помощью вы можете получать и настраивать различные драйверы и опции оборудования. Под Ubuntu эту программу можно установить так: apt-get install ethtool.

Обычно ethtool применяется для сбора с сетевых устройств детальной статистики. Другие способы применения будут описаны ниже.

Программа общается с драйверами с помощью системного вызова ioctl. Драйвер устройства регистрирует серию функций, выполняемых для операций ethtool, а ядро обеспечивает glue.

Когда ethtool вызывает ioctl, ядро находит структуру ethtool, зарегистрированную соответствующим драйвером, и выполняет зарегистрированные функции. Реализация драйверной функции ethtool может делать что угодно — от изменения простого программного флага в драйвере до регулирования работы сетевой карты путём записи в память устройства значений из реестра.

Драйвер igb с помощью вызова igb_set_ethtool_ops регистрирует в igb_probe операции ethtool:

static int igb_probe(struct pci_dev *pdev, const struct pci_device_id *ent)
{
  /* ... */

  igb_set_ethtool_ops(netdev);

Весь ethtool-код драйвера igb вместе с функцией igb_set_ethtool_ops можно найти в файле drivers/net/ethernet/intel/igb/igb_ethtool.c.

Взято из drivers/net/ethernet/intel/igb/igb_ethtool.c:

void igb_set_ethtool_ops(struct net_device *netdev)
{
  SET_ETHTOOL_OPS(netdev, &igb_ethtool_ops);
}

Помимо этого, вы можете найти структуру igb_ethtool_ops с поддерживаемыми драйвером igb функциями ethtool, настроенными в соответствующих полях.

Взято из drivers/net/ethernet/intel/igb/igb_ethtool.c:

static const struct ethtool_ops igb_ethtool_ops = {
  .get_settings           = igb_get_settings,
  .set_settings           = igb_set_settings,
  .get_drvinfo            = igb_get_drvinfo,
  .get_regs_len           = igb_get_regs_len,
  .get_regs               = igb_get_regs,
  /* ... */

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

Довольна интересна функция get_ethtool_stats, которая (если она реализована) создаёт подробные статистические счётчики, которые отслеживаются либо программно драйвером, либо самим устройством.

В посвящённой мониторингу части мы рассмотрим, как использовать ethtool для получения этой статистики.

IRQ

Когда фрейм данных с помощью DMA записывается в память, как сетевая карта сообщает системе о том, что данные готовы к обработке?

Обычно карта генерирует прерывание, сигнализирующее о прибытии данных. Есть три распространённых типа прерываний: MSI-X, MSI и легаси-IRQ. Вскоре мы их рассмотрим. Генерируемое при записи данных в память прерывание достаточно простое, но если приходит много фреймов, то генерируется и большое количество IRQ. Чем больше прерываний, тем меньше времени работы CPU доступно для обслуживания более высокоуровневых задач, например, пользовательских процессов.

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

NAPI

По ряду важных признаков NAPI отличается от легаси-метода сбора данных. Он позволяет драйверу устройства регистрировать функцию poll, вызываемую подсистемой NAPI для сбора фрейма данных.

Алгоритм использования NAPI драйверами сетевых устройств выглядит так:

  1. Драйвер включает NAPI, но изначально тот находится в неактивном состоянии.
  2. Прибывает пакет, и сетевая карта напрямую отправляет его в память.
  3. Сетевая карта генерирует IRQ посредством запуска обработчика прерываний в драйвере.
  4. Драйвер будит подсистему NAPI с помощью SoftIRQ (подробнее об этом — ниже). Та начинает собирать пакеты, вызывая в отдельном треде исполнения (thread of execution) зарегистрированную драйвером функцию poll.
  5. Драйвер должен отключить последующие генерирования прерываний сетевой картой. Это нужно для того, чтобы позволить подсистеме NAPI обрабатывать пакеты без помех со стороны устройства.
  6. Когда вся работа выполнена, подсистема NAPI отключается, а генерирование прерываний устройством включается снова.
  7. Цикл повторяется, начиная с пункта 2.

Этот метод сбора фреймов данных позволил уменьшить нагрузку по сравнению с легаси-методом, поскольку многие фреймы могут одновременно приниматься без необходимости одновременного генерирования IRQ для каждого из них.

Драйвер устройства реализует функцию poll и регистрирует её с помощью NAPI, вызывая netif_napi_add. При этом драйвер также задаёт weight. Большинство драйверов хардкодят значение 64. Почему именно его, мы увидим дальше.

Обычно драйверы регистрируют свои NAPI-функции poll в процессе инициализации драйвера.

Инициализация NAPI в драйвере igb

Драйвер igb делает это с помощью длинной цепочки вызовов:

  1. igb_probe вызывает igb_sw_init.
  2. igb_sw_init вызывает igb_init_interrupt_scheme.
  3. igb_init_interrupt_scheme вызывает igb_alloc_q_vectors.
  4. igb_alloc_q_vectors вызывает igb_alloc_q_vector.
  5. igb_alloc_q_vector вызывает netif_napi_add.

В результате выполняется ряд высокоуровневых операций:

  1. Если поддерживается MSI-X, то она включается с помощью вызова pci_enable_msix.
  2. Высчитываются и инициализируются различные настройки; например, количество очередей передачи и приёма, которые будут использоваться устройством и драйвером для отправки и получения пакетов.
  3. igb_alloc_q_vector вызывается однократно для каждой создаваемой очереди передачи и приёма.
  4. При каждом вызове igb_alloc_q_vector также вызывается netif_napi_add для регистрации функции poll для конкретной очереди. Когда функция poll будет вызвана для сбора пакетов, ей будет передан экземпляр struct napi_struct.

Давайте взглянем на igb_alloc_q_vector чтобы понять, как регистрируется callback poll и её личные данные (private data).

Взято из drivers/net/ethernet/intel/igb/igb_main.c:

static int igb_alloc_q_vector(struct igb_adapter *adapter,
                              int v_count, int v_idx,
                              int txr_count, int txr_idx,
                              int rxr_count, int rxr_idx)
{
  /* ... */

  /* размещает в памяти q_vector и кольца (rings) */
  q_vector = kzalloc(size, GFP_KERNEL);
  if (!q_vector)
          return -ENOMEM;

  /* инициализирует NAPI */
  netif_napi_add(adapter->netdev, &q_vector->napi, igb_poll, 64);

  /* ... */

Выше приведён код размещения в памяти очереди приёма и регистрации функции igb_poll с помощью подсистемы NAPI. Мы получаем ссылку на struct napi_struct, ассоциированную с этой новой созданной очередью приёма (&q_vector->napi). Когда придёт время сбора пакетов из очереди и подсистемой NAPI будет вызвана igb_poll, ей передадут эту ссылку.

Важность описанного алгоритма мы поймём, когда изучим поток данных из драйвера в сетевой стек.

Загрузка (bring up) сетевого устройства

Помните структуру net_device_ops, которая регистрировала набор функций для загрузки сетевого устройства, передачи пакетов, настройки MAC-адреса и так далее?

Когда сетевое устройство загружено (например, с помощью ifconfig eth0 up), вызывается функция, прикреплённая к полю ndo_open структуры net_device_ops.

Функция ndo_open обычно делает следующее:

  1. Выделяет память для очередей приёма и передачи.
  2. Включает NAPI.
  3. Регистрирует обработчика прерываний.
  4. Включает аппаратные прерывания.
  5. И многое другое.

В случае с драйвером igb, igb_open вызывает функцию, прикреплённая к полю ndo_open структуры net_device_ops.

Подготовка к получению данных из сети

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

Сначала драйвер устройства должен совместно с ОС зарезервировать в памяти область, которая будет использоваться сетевой картой. Далее карта информируется о выделении памяти, куда позднее будут записываться входящие данные, которые можно брать и обрабатывать с помощью сетевой подсистемы.

Выглядит просто, но что если частота пакетов так высока, что один CPU не успевает их обрабатывать? Структура данных базируется на области памяти фиксированного размера, поэтому пакеты будут отбрасываться.

В этом случае может помочь механизм Receive Side Scaling (RSS), система с несколькими очередями.

Некоторые устройства могут одновременно писать входящие пакеты в несколько разных областей памяти. Каждая область обслуживает отдельную очередь. Это позволяет ОС использовать несколько CPU для параллельной обработки входящих данных на аппаратном уровне. Но такое умеют делать не все сетевые карты.

Intel I350 — умеет. Свидетельства этого умения мы видим в драйвере igb. Одной из первых вещей, выполняемых им после загрузки, является вызов функции igb_setup_all_rx_resources. Эта функция вызывает однократно для каждой очереди приёма другую функцию — igb_setup_rx_resources, упорядочивающая DMA-память, в которую сетевая карта будет писать входящие данные.

Если вас интересуют подробности, почитайте github.com/torvalds/linux/blob/v3.13/Documentation/DMA-API-HOWTO.txt.

С помощью ethtool можно настраивать количество и размер очередей приёма. Изменение этих параметров позволяет существенно повлиять на отношение обработанных и отброшенных фреймов.

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

Некоторые сетевые карты позволяют настраивать вес очередей приёма, так что вы можете направлять больше трафика в конкретные очереди.
Реже встречается возможность настройки самой хэш-функции. Если вы можете её настраивать, то можете направлять конкретный поток в конкретную очередь, или даже отбрасывать пакеты на аппаратном уровне.
Ниже мы рассмотрим, как настраивается хэш-функция.

Включение NAPI

Когда сетевое устройство загружено, драйвер обычно включает NAPI. Мы уже видели, как драйверы с помощью NAPI регистрируют функции poll. Обычно NAPI не включается, пока устройство не загружено.

Включить его довольно просто. Вызов napi_enable сигнализирует struct napi_struct, что NAPI включена. Как отмечалось выше, после включения NAPI находится в неактивном состоянии.

В случае с драйвером igb, NAPI включается для каждого q_vector, инициализируемого после загрузки драйвера, или когда счётчик или размер очереди изменяется с помощью ethtool.

Взято из drivers/net/ethernet/intel/igb/igb_main.c:

for (i = 0; i < adapter->num_q_vectors; i++)
  napi_enable(&(adapter->q_vector[i]->napi));

Регистрация обработчика прерываний

После включения NAPI нужно зарегистрировать обработчика прерываний. Устройство может генерировать прерывания разными способами: MSI-X, MSI и легаси-прерывания. Поэтому код может быть различным, в зависимости от поддерживаемых способов.

Драйвер должен определить, какой способ поддерживается данным устройством, и зарегистрировать соответствующую функцию-обработчика, которая выполняется при получении прерывания.

Некоторые драйверы, в том числе и igb, пытаются зарегистрировать обработчика для каждого способа, в случае неудачи переходя к следующему неопробованному.

Предпочтительнее использовать прерывания MSI-X, особенно для сетевых карт, поддерживающих несколько очередей приёма. Причина в том, что каждой очереди присвоено собственное аппаратное прерывание, которая может быть обработано конкретным CPU (с помощью irqbalance или модифицирования /proc/irq/IRQ_NUMBER/smp_affinity). Как мы скоро увидим, прерывание и пакет обрабатывает один и тот же CPU. Таким образом, входящие пакеты будут обрабатываться разными CPU в рамках всего сетевого стека, начиная с уровня аппаратных прерываний.

Если MSI-X недоступна, то драйвер использует MSI (если поддерживается), которая всё ещё имеет преимущества по сравнению с легаси-прерываниями. Подробнее об этом читайте в английской Википедии.

В драйвере igb в качестве обработчиков прерываний MSI-X, MSI и легаси выступают соответственно функции igb_msix_ring, igb_intr_msi, igb_intr.

Код драйвера, пробующий каждый способ, можно найти в drivers/net/ethernet/intel/igb/igb_main.c:

static int igb_request_irq(struct igb_adapter *adapter)
{
  struct net_device *netdev = adapter->netdev;
  struct pci_dev *pdev = adapter->pdev;
  int err = 0;

  if (adapter->msix_entries) {
    err = igb_request_msix(adapter);
    if (!err)
      goto request_done;
    /* переход к MSI */

    /* ... */
  }

  /* ... */

  if (adapter->flags & IGB_FLAG_HAS_MSI) {
    err = request_irq(pdev->irq, igb_intr_msi, 0,
          netdev->name, adapter);
    if (!err)
      goto request_done;

    /* переход к легаси */

    /* ... */
  }

  err = request_irq(pdev->irq, igb_intr, IRQF_SHARED,
        netdev->name, adapter);

  if (err)
    dev_err(&pdev->dev, "Error %d getting interruptn", err);

request_done:
  return err;
}

Как видите, драйвер сначала пытается использовать обработчика igb_request_msix для прерываний MSI-X, если не получается, то переходит к MSI. Для регистрации MSI-обработчика igb_intr_msi используется request_irq. Если и это не срабатывает, драйвер переходит к легаси-прерываниям. Для регистрации igb_intr снова используется request_irq.

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

Включение прерываний

К данному моменту почти всё уже настроено. Осталось только включить прерывания и ожидать прихода данных. Процедура включения зависит от конкретного оборудования, то драйвер igb делает это в __igb_open, с помощью вызова вспомогательной функции igb_irq_enable.

С помощью записи регистров включаются прерывания для данного устройства:

static void igb_irq_enable(struct igb_adapter *adapter)
{

  /* ... */

    wr32(E1000_IMS, IMS_ENABLE_MASK | E1000_IMS_DRSTA);
    wr32(E1000_IAM, IMS_ENABLE_MASK | E1000_IMS_DRSTA);

  /* ... */
}

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

Давайте теперь рассмотрим вопросы мониторинга и настройки опций драйверов сетевых устройств.

Мониторинг сетевых устройств

Есть несколько разных способов мониторинга, отличающихся подробностью статистики и сложностью. Начнём с самого детального.

Использование ethtool -S

Установить ethtool под Ubuntu можно так: sudo apt-get install ethtool.
Теперь можно просмотреть статистику, передав флаг -S с именем сетевого устройства, чья статистка вас интересует.

Мониторьте подробную статистику (например, отбрасывание пакетов) с помощью `ethtool -S`.

$ sudo ethtool -S eth0
NIC statistics:
     rx_packets: 597028087
     tx_packets: 5924278060
     rx_bytes: 112643393747
     tx_bytes: 990080156714
     rx_broadcast: 96
     tx_broadcast: 116
     rx_multicast: 20294528
     ....

Мониторинг может быть трудной задачей. Данные легко получить, но не существует стандартного представления значений полей. Разные драйверы, даже разные версии одного драйвера могут генерировать разные имена полей, имеющие одинаковое значение.

Ищите значения, в названиях которых есть “drop”, “buffer”, “miss” и так далее. Дальше нужно считать данные из источника. Вы сможете определить, какие значения относятся только к ПО (например, инкрементируются при отсутствии памяти), а какие поступают напрямую из оборудования посредством чтения регистров. В случае со значениями регистров, сверьтесь со спецификацией сетевой карты чтобы узнать, что означают конкретные счётчики. Многие названия, присваиваемые ethtool, могут быть ошибочны.

Использование sysfs

sysfs тоже предоставляют много статистики, но она чуть более высокоуровневая чем та, что предоставляется напрямую сетевой картой.

Вы сможете узнать количество отброшенных входящих фреймов для, например, eth0, применив cat к файлу.

Мониторинг более высокоуровневой статистки сетевой карты с помощью sysfs:

$ cat /sys/class/net/eth0/statistics/rx_dropped
2

Значения счётчиков будут раскиданы по файлам: collisions, rx_dropped, rx_errors, rx_missed_errors и так далее.

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

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

Использование /proc/net/dev

Ещё более высокоуровневый файл /proc/net/dev, предоставляющий выжимку по каждому сетевому адаптеру в системе.

Читаем /proc/net/dev, чтобы мониторить высокоуровневую статистику сетевых карт:

$ cat /proc/net/dev
Inter-|   Receive                                                |  Transmit
 face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
  eth0: 110346752214 597737500    0    2    0     0          0  20963860 990024805984 6066582604    0    0    0     0       0          0
    lo: 428349463836 1579868535    0    0    0     0          0         0 428349463836 1579868535    0    0    0     0       0          0

Этот файл содержит набор некоторых значений, которые вы можете найти в упомянутых файлах файлах sysfs. Его можно использоваться как общий источник информации.

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

Настройка сетевых устройств

Проверка количества используемых очередей приёма

Если загруженные в вашей системе сетевая карта и драйвер устройства поддерживают RSS (множественные очереди), то обычно с помощью ethtool можно настраивать количество очередей приёма (каналов приёма, RX-channels).

Проверка количества очередей приёма сетевой карты:

$ sudo ethtool -l eth0
Channel parameters for eth0:
Pre-set maximums:
RX:   0
TX:   0
Other:    0
Combined: 8
Current hardware settings:
RX:   0
TX:   0
Other:    0
Combined: 4

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

Примечание: не все драйверы устройств поддерживают эту операцию.

Ошибка, возникающая, если ваша сетевая карта не поддерживает операцию:

$ sudo ethtool -l eth0
Channel parameters for eth0:
Cannot get device channel parameters
: Operation not supported

Это означает, что драйвер не реализовало операцию ethtool get_channels. Причина может быть в отсутствии поддержки настройки количества очередей со стороны сетевой карты, в отсутствии поддержки RSS, или у вас слишком старая версия драйвера.

Настройка количества очередей приёма

После того, как вы нашли счётчики текущего и максимального количества очередей, вы можете настроить их значения с помощью sudo ethtool -L.

Примечание: некоторые устройства и их драйверы поддерживают только комбинированные очереди, — на приём и передачу — как в примере в предыдущей главе.

С помощью ethtool -L назначим 8 комбинированных очередей:

$ sudo ethtool -L eth0 combined 8

Если ваше устройство и драйвер позволяют отдельно настраивать количество очередей приёма и передачи, то можно отдельно задать 8 очередей приёма:

$ sudo ethtool -L eth0 rx 8

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

Настройка размера очередей приёма

Некоторые сетевые карты и их драйверы поддерживают настройку размера очереди приёма. Конкретная реализация зависит от оборудования, но, к счастью, ethtool обеспечивает стандартный метод настройки. Увеличение размера позволяет предотвратить отбрасывание сетевых данных при большом количестве входящих фреймов. Правда, данные ещё могут быть отброшены на уровне ПО, так что для полного исключения или снижения отбрасывания необходимо будет провести дополнительную настройку.

Проверка текущего размера очереди сетевой карты с помощью ethtool –g:

$ sudo ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:
RX:   4096
RX Mini:  0
RX Jumbo: 0
TX:   4096
Current hardware settings:
RX:   512
RX Mini:  0
RX Jumbo: 0
TX:   512

Выходные данные показывают, что оборудование поддерживает 4096 дескрипторов приёма и передачи, но в данный момент используется 512.

Увеличим размер каждой очереди до 4096:

$ sudo ethtool -G eth0 rx 4096

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

Настройка веса обработки очередей приёма

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

Это можно сделать, если:

  • Сетевая карта поддерживает косвенную адресацию потока (flow indirection).
  • Ваш драйвер реализует функции get_rxfh_indir_size и get_rxfh_indir из ethtool.
  • У вас работает достаточно новая версия ethtool, поддерживающая опции командной строки -x и -X, соответственно отображающие и настраивающие таблицу косвенной адресации (indirection table).

Проверка таблицы косвенной адресации потока приёма:

$ sudo ethtool -x eth0
RX flow hash indirection table for eth3 with 2 RX ring(s):
0: 0 1 0 1 0 1 0 1
8: 0 1 0 1 0 1 0 1
16: 0 1 0 1 0 1 0 1
24: 0 1 0 1 0 1 0 1

Здесь слева отображены значения хэшей пакетов и очереди приёма — 0 и 1. Пакет с хэшем 2 будет адресован в очередь 0, а пакет с хэшем 3 — в очередь 1.

Пример: равномерно распределим обработку между первыми двумя очередями приёма:

$ sudo ethtool -X eth0 equal 2

Если вам нужно настроить кастомные веса, чтобы количество пакетов, адресуемых в конкретные очереди (а следовательно, и CPU), то вы можете сделать это в командной строке с помощью ethtool –X:

$ sudo ethtool -X eth0 weight 6 2

Здесь очереди 0 присваивается вес 6, а очереди 1 — вес 2. Таким образом, большая часть данных будет обрабатываться очередью 0.

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

Настройка полей хэшей приёма для сетевых потоков

С помощью ethtool можно настраивать поля, которые будут участвовать в вычислении хэшей, используемых в RSS.

C помощью ethtool -n проверим, какие поля используются для хэша потока приёма UPD:

$ sudo ethtool -n eth0 rx-flow-hash udp4
UDP over IPV4 flows use these fields for computing Hash flow key:
IP SA
IP DA

В случае с eth0, для вычисления хэша UDP-потока используется источник IPv4 и адреса назначения. Давайте добавим ещё входящий и исходящий порты:

$ sudo ethtool -N eth0 rx-flow-hash udp4 sdfn

Значение sdfn выглядит непонятно. Объяснение каждой буквы можно найти на странице автора ethtool man.

Настройка полей хэша довольно полезна, но фильтрование ntuple ещё полезнее и обеспечивает более тонкое управление распределением потоков по очередям приёма.

Фильтрование ntuple для управления сетевыми потоками

Некоторые сетевые карты поддерживают функцию «фильтрование ntuple» (ntuple filtering). Она позволяет указывать (посредством ethtool) набор параметров, используемых для фильтрования входных данных на уровне железа и адресации в конкретную очередь приёма. Например, можно прописать, чтобы TCP-пакеты, пришедшие на определённый порт, передавались в очередь 1.

В сетевых картах Intel эта функция обычно называется Intel Ethernet Flow Director. Другие производители могут давать другие названия.

Как мы увидим дальше, фильтрование ntuple — критически важный компонент другой функции, Accelerated Receive Flow Steering (aRFS). Это сильно облегчает использование ntuple, если ваша сетевая карта поддерживает его. aRFS мы рассмотрим позднее.

Эта функция может быть полезна, если операционные требования системы подразумевают максимизацию локальности данных (data locality) ради увеличения частоты успешных обращений (hit rates) к кэшу CPU при обработке сетевых данных. Например, так выглядит конфигурирование веб-сервера, работающего на порте 80:

  • Сервер закрепляется за CPU 2.
  • Обработка IRQ для очереди приёма закрепляется за тем же CPU.
  • TCP-трафик на порт 80 «фильтруется» с помощью ntuple и отправляется на CPU 2.
  • Весь входящий трафик на порт 80 обрабатывается этим CPU, начиная с получения данных и вплоть до программ пространства пользователя.
  • Для определения эффективности требуется аккуратный мониторинг системы, включая частоту успешных обращений к кэшу и задержку сетевого стека.

Как упоминалось выше, фильтрование ntuple можно настроить с помощью ethtool, но для начала вам нужно удостовериться, что эта функция включена на вашем устройстве. Проверить это можно так:

$ sudo ethtool -k eth0
Offload parameters for eth0:
...
ntuple-filters: off
receive-hashing: on

Как видите, ntuple-filters в состоянии off.

Включим ntuple-фильтры:

$ sudo ethtool -K eth0 ntuple on

После их включения или проверки, что фильтры работают, можно проверить настроенные правила:

$ sudo ethtool -u eth0
40 RX rings available
Total 0 rules

Здесь фильтры не имеют настроенных правил. Вы можете добавить их через командную строку с помощью ethtool. Давайте пропишем, чтобы весь TCP-трафик, приходящий на порт 80, переадресовывался очередь приёма 2:

$ sudo ethtool -U eth0 flow-type tcp4 dst-port 80 action 2

Фильтрование ntuple можно использовать и для отбрасывания пакетов определённых потоков на аппаратном уровне. Это бывает удобно для снижения нагрузки по трафику от каких-то IP-адресов. Подробнее о конфигурации правил фильтрации читайте man ethtool.

Получать статистику о степени успешности своих ntuple-правил можно с помощью проверки выходных данных ethtool -S [device name]. Например, на сетевых картах Intel fdir_match и fdir_miss выдают количество совпадений и промахов правил фильтрования. По вопросам отслеживания статистики своего устройства сверьтесь с исходниками драйвера и спецификацией.


3.2. SoftIRQ

Прежде чем переходить к рассмотрению сетевого стека, нам нужно кратко пройтись по системе SoftIRQ из ядра Linux.

Что такое SoftIRQ?

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

Существуют механизмы, которые могут использоваться для откладывания работы в ядре ради нужд сетевого стека. Мы рассмотрим SoftIRQ.

Систему SoftIRQ можно представить в виде серии тредов ядра (по одному на CPU), в которых работают функции-обработчики, зарегистрированные для разных SoftIRQ-событий. Если вы когда-нибудь поднимались наверх и встречали ksoftirqd/0 в списке тредов ядра, то это как раз тред SoftIRQ, выполняющийся на CPU 0.

Подсистемы ядра (например, по работе с сетью) могут регистрировать обработчика SoftIRQ посредством выполнения функции open_softirq. Дальше мы увидим, как система по работе с сетью регистрирует своих SoftIRQ-обработчиков. А теперь давайте немного поговорим о том, как работает SoftIRQ.

ksoftirqd

Поскольку SoftIRQ очень важны для откладывания работы драйверов устройств, то процесс ksoftirqd создаётся в рамках жизненного цикла ядра довольно рано.

Взгляните на код из kernel/softirq.c, показывающий, как инициализируется система ksoftirqd:

static struct smp_hotplug_thread softirq_threads = {
  .store              = &ksoftirqd,
  .thread_should_run  = ksoftirqd_should_run,
  .thread_fn          = run_ksoftirqd,
  .thread_comm        = "ksoftirqd/%u",
};

static __init int spawn_ksoftirqd(void)
{
  register_cpu_notifier(&cpu_nfb);

  BUG_ON(smpboot_register_percpu_thread(&softirq_threads));

  return 0;
}
early_initcall(spawn_ksoftirqd);

Как следует из определения struct smp_hotplug_thread, регистрируются два указателя функций: ksoftirqd_should_run и run_ksoftirqd.

Обе функции вызываются kernel/smpboot.c как часть чего-то, напоминающего цикл события (event loop).

Код в kernel/smpboot.c сначала вызывает ksoftirqd_should_run, который определяет, есть ли ожидающие SoftIRQ. Если есть, то выполняется run_ksoftirqd, которая выполняет некоторые второстепенные вычисления, прежде чем вызвать __do_softirq.

__do_softirq

Функция __do_softirq делает несколько интересных вещей:

  • Определяет ожидающий SoftIRQ.
  • Учитывает для статистики время SoftIRQ.
  • Инкрементирует статистику выполнения SoftIRQ.
  • Выполняет обработчика для ожидающего SoftIRQ (который был зарегистрирован с помощью вызова open_softirq).

Так что когда вы смотрите на графики использования CPU и видите softirq или si, то это выполняется измерение объёма ресурсов CPU, которые используются отложенным рабочим контекстом.

Мониторинг

/proc/softirqs

Система softirq инкрементирует статистические счётчики, которые можно считывать из /proc/softirqs. Мониторинг этой статистики даст вам понимание того, с какой частотой генерируются SoftIRQ для разных событий.

Читаем /proc/softirqs, чтобы мониторить статистику SoftIRQ:

$ cat /proc/softirqs
                    CPU0       CPU1       CPU2       CPU3
          HI:          0          0          0          0
       TIMER: 2831512516 1337085411 1103326083 1423923272
      NET_TX:   15774435     779806     733217     749512
      NET_RX: 1671622615 1257853535 2088429526 2674732223
       BLOCK: 1800253852    1466177    1791366     634534
BLOCK_IOPOLL:          0          0          0          0
     TASKLET:         25          0          0          0
       SCHED: 2642378225 1711756029  629040543  682215771
     HRTIMER:    2547911    2046898    1558136    1521176
         RCU: 2056528783 4231862865 3545088730  844379888

Благодаря этому файлу вы поймёте, как на данный момент распределена между процессами обработка входящих сетевых данных (NET_RX). Если неравномерно, счётчики одних CPU будут иметь значения выше, чем у других. Это показатель того, что вы можете извлечь выгоду из описанных ниже Receive Packet Steering / Receive Flow Steering. Будьте осторожны, полагаясь при мониторинге производительности лишь на этот файл: на протяжении периода высокой сетевой активности вы можете ожидать увеличения NET_RX, но этого может не произойти. На частоту SoftIRQ NET_RX могут повлиять дополнительные настройки в сетевом стеке, мы дальше этого коснёмся.

Так вот, будьте осторожны, но если вы измените упомянутые настройки, то изменения можно будет увидеть в /proc/softirqs.

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


3.3. Подсистема сетевого устройства в Linux

Мы познакомились с работой сетевых драйверов и SoftIRQ, теперь займёмся инициализацией подсистемы сетевого устройства. Затем мы проследим путь следования пакета начиная с его прихода.

Инициализация подсистемы сетевого устройства

Подсистема сетевого устройства (netdev) инициализируется в функции net_dev_init. В ней вообще происходит много интересного.

Инициализация структур struct softnet_data

net_dev_init создаёт набор структур struct softnet_data для каждого CPU в системе. Эти структуры содержат указатели на несколько важных для обработки сетевых данных вещей:

  • Список структур NAPI, которые нужно зарегистрировать для CPU.
  • Backlog для обработки данных.
  • Вес обработки (processing weight).
  • Список структуры receive offload.
  • Настройки управления принимаемыми пакетами (Receive packet steering).
  • И многое другое.

Инициализация обработчиков SoftIRQ

net_dev_init регистрирует обработчика SoftIRQ приёма и передачи, который будет использоваться для обработки входящих или исходящих сетевых данных. Код довольно простой:

static int __init net_dev_init(void)
{
  /* ... */

  open_softirq(NET_TX_SOFTIRQ, net_tx_action);
  open_softirq(NET_RX_SOFTIRQ, net_rx_action);

 /* ... */
}

Теперь посмотрим, как драйверный обработчик прерываний «поднимет» (или запустит) функцию net_rx_action, зарегистрированную на SoftIRQ NET_RX_SOFTIRQ.

Прибытие данных

Наконец-то прибыли сетевые данные!

Будем считать, что очередь приёма имеет достаточно доступных дескрипторов, так что пакет записывается в память посредством DMA. Затем устройство генерирует приписанное к пакету прерывание (или, в случае с MSI-X, прерывание привязано к очереди приёма, в которую адресуется прибывший пакет).

Обработчик прерываний

Выполняемый после генерирования прерывания обработчик, в целом должен пытаться вынести как можно больший объём работы за пределы контекста прерывания. Это критически важно, потому что в ходе обработки одного прерывания другие могут быть заблокированы.

Давайте посмотрим на исходный код обработчика прерываний MSI-X. Это поможет проиллюстрировать идею, что обработчик выполняет минимальный объём работы.

Взято из drivers/net/ethernet/intel/igb/igb_main.c:

static irqreturn_t igb_msix_ring(int irq, void *data)
{
  struct igb_q_vector *q_vector = data;

  /* Пишет значение ITR, вычисленное из предыдущего прерывания. */
  igb_write_itr(q_vector);

  napi_schedule(&q_vector->napi);

  return IRQ_HANDLED;
}

Это очень короткий обработчик, и перед возвратом он выполняет две очень быстрые операции.

  1. Функция igb_write_itr просто обновляет определённый аппаратный регистр. Он используется для отслеживания частоты появления аппаратных прерываний. Регистр используется совместно с аппаратной функцией “Interrupt Throttling” (ещё её называют «объединением прерываний», Interrupt Coalescing), позволяющей подстраивать доставку прерываний CPU. Скоро мы увидим, как ethtool обеспечивает механизм настройки частоты генерирования IRQ.
  2. Вызывается napi_schedule, пробуждающая цикл обработки NAPI, если он ещё не активен. Обратите внимание, что этот цикл исполняется в SoftIRQ, а не обработчика прерываний. Обработчик просто запускает его выполнение, если это ещё не сделано.

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

NAPI и napi_schedule

Давайте посмотрим, как работает вызов napi_schedule из обработчика аппаратных прерываний.

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

Цикл poll запускается, когда драйверный обработчик прерываний вызывает napi_schedule. Это просто функция-обёртка, заданная в заголовочном файле, которая в свою очередь вызывает __napi_schedule.

Взято из net/core/dev.c:

/**
 * __napi_schedule – расписание получения
 * @n: запись в расписании
 *
 * Будет задан старт функции получения записи
 */
void __napi_schedule(struct napi_struct *n)
{
  unsigned long flags;

  local_irq_save(flags);
  ____napi_schedule(&__get_cpu_var(softnet_data), n);
  local_irq_restore(flags);
}
EXPORT_SYMBOL(__napi_schedule);

В этом коде для получения структуры softnet_data, зарегистрированной на текущий CPU, используется __get_cpu_var. Данная структура передаётся в ____napi_schedule вместе со взятой из драйвера структурой struct napi_struct. Как много нижних подчёркиваний.

Давайте посмотрим на ____napi_schedule, взято из net/core/dev.c:

/* Вызывается с отключённым IRQ */
static inline void ____napi_schedule(struct softnet_data *sd,
                                     struct napi_struct *napi)
{
  list_add_tail(&napi->poll_list, &sd->poll_list);
  __raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

Этот код делает две важные вещи:

  1. Структура struct napi_struct, взятая из кода драйверного обработчика прерываний, добавляется в poll_list, прикреплённый к структуре softnet_data, ассоциированной с текущим CPU.
  2. __raise_softirq_irqoff используется для запуска SoftIRQ NET_RX_SOFTIRQ. В результате начинает выполняться net_rx_action, зарегистрированная во время инициализации подсистемы сетевого устройства, если это ещё не сделано.

Как мы скоро увидим, SoftIRQ функция-обработчик net_rx_action для сбора пакетов вызовет функцию NAPI poll.

Примечание о CPU и обработке сетевых данных

Обратите внимание, что весь код до этого, который переносил работу из обработчика аппаратных прерываний в SoftIRQ, использовал структуры, ассоциированные с текущим CPU.

Поскольку сам драйверный IRQ-обработчик мало что делает, то SoftIRQ-обработчик будет выполняться на том же CPU, что и IRQ-обработчик. Поэтому важно настраивать, каким CPU будет обрабатываться конкретный IRQ: ведь этот CPU будет использован не только для выполнения драйверного обработчика прерываний, но и для сбора пакетов в SoftIRQ посредством NAPI.

Позднее мы увидим, что механизмы наподобие управления принимаемыми пакетами (Receive Packet Steering) могут распределять часть работы по другим CPU в рамках сетевого стека.

Мониторинг поступления сетевых данных

Запросы аппаратных прерываний

Примечание: мониторинг аппаратных прерываний не даёт полной картины корректности обработки пакетов. Многие драйверы отключают аппаратные прерывания во время работы NAPI. Но всё же это важная часть общего решения мониторинга.

Читаем /proc/interrupts, чтобы мониторить статистику аппаратных прерываний:

$ cat /proc/interrupts
            CPU0       CPU1       CPU2       CPU3
   0:         46          0          0          0 IR-IO-APIC-edge      timer
   1:          3          0          0          0 IR-IO-APIC-edge      i8042
  30: 3361234770          0          0          0 IR-IO-APIC-fasteoi   aacraid
  64:          0          0          0          0 DMAR_MSI-edge      dmar0
  65:          1          0          0          0 IR-PCI-MSI-edge      eth0
  66:  863649703          0          0          0 IR-PCI-MSI-edge      eth0-TxRx-0
  67:  986285573          0          0          0 IR-PCI-MSI-edge      eth0-TxRx-1
  68:         45          0          0          0 IR-PCI-MSI-edge      eth0-TxRx-2
  69:        394          0          0          0 IR-PCI-MSI-edge      eth0-TxRx-3
 NMI:    9729927    4008190    3068645    3375402  Non-maskable interrupts
 LOC: 2913290785 1585321306 1495872829 1803524526  Local timer interrupts

Мониторя статистику в /proc/interrupts, можно увидеть изменение количества и частоты аппаратных прерываний по мере прибытия пакетов. Также можно удостовериться, что каждая очередь приёма вашей сетевой карты обрабатывается соответствующим CPU. Количество говорит нам лишь о том, сколько было прерываний, но эта метрика не обязательно поможет понять, сколько данных было получено или обработано, потому что многие драйверы отключают прерывания сетевой карты в качестве части договора с подсистемой NAPI. Более того, использование объединения прерываний (interrupt coalescing) также влияет на статистику, получаемую из этого файла. Его мониторинг позволяет определить, действительно ли работают выбранные вами настройки объединения прерываний.

Для получения более полного представления о корректности обработки сетевых данных, вам нужно мониторить /proc/softirqs и ещё ряд файлов в /proc. Об этом мы поговорим ниже.

Настройка поступления сетевых данных

Объединение прерываний

Так называется метод предотвращения передачи прерываний из устройства в CPU, пока не наберётся определённое количество событий или объём конкретной работы.

Это помогает избегать «штормов прерываний» и увеличивать пропускную способность или задержку, в зависимости от настроек. Уменьшение количества генерируемых прерываний приводит к повышению пропускной способности и задержки, снижению нагрузки на CPU. С ростом количества прерываний происходит обратное: снижается задержка и пропускная способность, но увеличивается нагрузка на CPU.

Исторически сложилось так, что ранние версии igb, e1000 и ряда других драйверов поддерживали только параметр InterruptThrottleRate. В более свежих версиях его заменили generic функцией из ethtool.

Получение текущих настроек объединения IRQ:

$ sudo ethtool -c eth0
Coalesce parameters for eth0:
Adaptive RX: off  TX: off
stats-block-usecs: 0
sample-interval: 0
pkt-rate-low: 0
pkt-rate-high: 0
...

ethtool предоставляет generic-интерфейс для оперирования настройками объединения. Однако помните, что не каждое устройство или драйвер поддерживают все настройки. Сверьтесь с документацией или исходным кодом, чтобы узнать, что поддерживается в вашем случае. Как сказано в документации к ethtool: «Всё, что не реализовано драйвером, будет тихо игнорироваться».

Некоторые драйверы поддерживают любопытную опцию «адаптивного объединения прерываний приёма/передачи» (adaptive RX/TX IRQ coalescing). Обычно она реализуется в оборудовании. Драйверу нужно проделать некоторую работу, чтобы сообщить сетевой карте о включении этой опции, а также кое-какие вычисления (bookkeeping) (как мы видели ранее в коде драйвера igb).

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

Включение адаптивного объединения прерываний приёма:

$ sudo ethtool -C eth0 adaptive-rx on

Можно использовать ethtool -C для настройки нескольких опций. Некоторые из наиболее часто используемых:

  • rx-usecs: длительность задержки между прибытием пакета и генерированием прерывания приёма, в миллисекундах.
  • rx-frames: максимальное количество фреймов, получаемых до генерирования прерывания приёма.
  • rx-usecs-irq: длительность задержки прерывания приёма, пока оно обслуживается хостом, в миллисекундах.
  • rx-frames-irq: максимальное количество фреймов, получаемых до генерирования прерывания приёма, пока система обслуживает прерывание.

И есть ещё очень много других.

Напоминаю, что ваше оборудование и драйвер могут поддерживать лишь часть опций. Изучите исходный код драйвера и спецификацию сетевой карты.

К сожалению, доступные для настраивания опции плохо документированы везде, кроме заголовочного файла. Обратитесь к исходнику include/uapi/linux/ethtool.h и найдите объяснение для каждой опции, поддерживаемой ethtool (но не факт, что они поддерживаются вашим драйвером и сетевой картой).

Примечание: на первый взгляд объединение прерываний выглядит очень полезной оптимизацией. В некоторых случаях это действительно так, но вам нужно удостовериться, что остальная часть сетевого стека тоже настроена правильно. Одно лишь изменение параметров объединения наверняка принесёт мало пользы.

Настройка привязок IRQ

Если ваша карта поддерживает RSS, или если вы пытаетесь оптимизировать локальность данных, ты вам может понадобиться использовать специфический набор CPU для обработки генерируемых картой прерываний.

Это позволяет сегментировать использование CPU. Подобные изменения могут влиять на работу верхних уровней, как мы видели на примере сетевого стека.

Если вы решили настроить привязки IRQ, то сначала проверьте, работает ли демон irqbalance. Он пытается автоматически сбалансировать прерывания и CPU, и потому может перезаписать ваши настройки. Отключите irqbalance, или используйте --banirq вместе с IRQBALANCE_BANNED_CPUS, чтобы irqbalance знал, что ему не следует трогать настраиваемый вами набор прерываний и CPU.

Затем проверьте файл /proc/interrupts на наличие списка номеров прерываний для каждой очереди приёма вашей сетевой карты. Наконец, внесите изменения в /proc/irq/IRQ_NUMBER/smp_affinity, прописав для каждого номера, какой CPU должен обрабатывать данное прерывание. Просто укажите для этого файла шестнадцатеричную битовую маску, чтобы ядро знало, какие CPU использовать для обработки прерываний.

Пример: настроим привязку IRQ 8 к CPU 0:

$ sudo bash -c 'echo 1 > /proc/irq/8/smp_affinity'

Начало обработки сетевых данных

Когда SoftIRQ-код определяет, какие из SoftIRQ находятся в состоянии ожидания, исполняется net_rx_action и начинается процесс обработки сетевых данных.

Давайте на части цикла обработки net_rx_action, чтобы понять, как это работает, что можно настроить и мониторить.

Цикл обработки net_rx_action

net_rx_action начинает обработку пакетов из памяти, которые были отправлены туда устройством посредством DMA.

Функция итерирует по списку структур NAPI, стоящих в очереди текущего CPU, поочерёдно извлекает каждую структуру работает с ней.

Цикл обработки ограничивает объём работы и время исполнения зарегистрированных NAPI-функций poll. Он делает это двумя способами:

  1. отслеживая рабочий бюджет (work budget) (который можно настраивать),
  2. а также проверяет затраченное время.

Взято из net/core/dev.c:

while (!list_empty(&sd->poll_list)) {
    struct napi_struct *n;
    int work, weight;

    /* Если исчерпалось окно SoftIRQ - отфутболиваем
     * Выполняйте это в течение двух тиков, это позволит сделать
     * среднюю задержку на уровне 1.5/Гц.
     */
    if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit)))
      goto softnet_break;

Таким образом ядро не позволяет обработке пакетов занять все ресурсы CPU. budget — это весь доступный бюджет, который будет разделён на все доступные NAPI-структуры, зарегистрированные на этот CPU.

Это ещё одна причина, почему сетевые карты с несколькими очередями требуют аккуратной настройки привязки IRQ. Вспомните, что на CPU, который обрабатывает прерывания от устройства, будет также выполняться и обработчик SoftIRQ. И в результате на том же CPU работают вышеприведённый цикл и выполняются вычисления бюджета.

Системы с несколькими сетевыми картами, каждая из которых поддерживает несколько очередей, могут оказаться в ситуации, когда на один CPU зарегистрировано несколько NAPI-структур. Обработка данных всех структур на одном CPU «оплачивается» из одного и того же бюджета.

Если вам не хватает CPU для распределения прерываний, то можно увеличить net_rx_action budget, чтобы каждый CPU обрабатывал больше пакетов. Увеличение бюджета повлечёт увеличение нагрузки на CPU (в особенности sitime или si в top или других программах), но должно снизить задержку, потому что данные будут обрабатываться быстрее.

Примечание: CPU будет ограничен по времени двумя jiffies, вне зависимости от присвоенного бюджета.

NAPI-функция poll и weight

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

/* инициализация NAPI */
  netif_napi_add(adapter->netdev, &q_vector->napi, igb_poll, 64);

Здесь регистрируется NAPI-структура, которой в коде прописан вес 64. Давайте посмотрим, как этот вес используется в цикле обработки net_rx_action.

Взято из net/core/dev.c:

weight = n->weight;

work = 0;
if (test_bit(NAPI_STATE_SCHED, &n->state)) {
        work = n->poll(n, weight);
        trace_napi_poll(n);
}

WARN_ON_ONCE(work > weight);

budget -= work;

Мы получаем вес, зарегистрированный на структуру NAPI, и передаём его в функцию poll, тоже зарегистрированную на структуру NAPI (в приведённом примере igb_poll).

Функция poll возвращает количество обработанных фреймов. Оно сохраняется в виде work, и потом вычитается из общего budget.

Допустим:

  1. вы используете из своего драйвера вес 64 (у всех драйверов в Linux 3.13.0 жёстко прописано это значение),
  2. и размер вашего budget по умолчанию 300.

Ваша система прекратит обрабатывать данные, если:

  1. Функция igb_poll была вызвана самое большее 5 раз (может быть и меньше, если не обрабатывалось никаких данных, как мы увидим дальше),
  2. ИЛИ прошло как минимум 2 jiffies.

Договор между NAPI и драйвером сетевого устройства

Пока ещё не упоминалась важная информация, касающаяся договора между подсистемой NAPI и драйверами устройств. Речь об условиях остановки NAPI.

  • Если драйверная функция poll расходует весь свой вес (64), она не должна изменять состояние NAPI. Вступит в работу цикл net_rx_action.
  • Если драйверная функция poll НЕ расходует весь свой вес, она должна отключить NAPI. NAPI будет снова включён при получении следующего IRQ, когда драйверный обработчик прерываний вызовет napi_schedule.

Теперь посмотрим, как net_rx_action работает с первой частью договора. Затем, после рассмотрения функции poll, выясним, как выполняется вторая часть договора.

Завершение цикла net_rx_action

Цикл обработки net_rx_action завершается секцией кода, которая выполняет первую часть договора с NAPI. Взято из net/core/dev.c:

/* Драйверы не должны изменять состояние NAPI, если они
 * расходуют весь свой вес. В таких случаях код всё ещё
 * «владеет» экземпляром NAPI, и, следовательно, может
 * перемещать его по списку по своему желанию.
 */
if (unlikely(work == weight)) {
  if (unlikely(napi_disable_pending(n))) {
    local_irq_enable();
    napi_complete(n);
    local_irq_disable();
  } else {
    if (n->gro_list) {
      /* сбрасываем слишком старые пакеты
       * Если HZ < 1000, сбрасываем все пакеты.
       */
      local_irq_enable();
      napi_gro_flush(n, HZ >= 1000);
      local_irq_disable();
    }
    list_move_tail(&n->poll_list, &sd->poll_list);
  }
}

Если расходуется весь объём работы, то net_rx_action обрабатывает две ситуации:

  1. Сетевое устройство должно быть выключено (например, потому что пользователь выполнил ifconfig eth0 down).
  2. Если устройство не выключается, то проверьте, есть ли список generic receive offload (GRO). Если частота таймера (timer tick rate) >= 1000, то все сетевые потоки с GRO, которые недавно обновлялись, будут сброшены. Позднее мы подробнее рассмотрим GRO. NAPI-структура перемещается в конец списка данного CPU, так что следующая итерация цикла получит следующую зарегистрированную NAPI-структуру.

Таким образом цикл обработки пакетов вызывает зарегистрированную драйверную функцию poll, которая займётся обработкой. Дальше мы увидим, что эта функция собирает сетевые данные и отправляет их в стек для дальнейшей обработки.

Выход из цикла по достижении ограничений

Выход из цикла net_rx_action будет совершён, если:

  • список poll, зарегистрированный для данного CPU, больше не содержит NAPI-структур (!list_empty(&sd->poll_list)),
  • или остаток бюджета <= 0,
  • или был достигнут временной предел в два jiffies.

Вот один из вышеприведённых примеров кода

    /* Если исчерпалось окно SoftIRQ - отфутболиваем.
     * Выполняйте это в течение двух тиков, это позволит сделать
     * среднюю задержку на уровне 1.5/Гц.
     */
if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit)))
  goto softnet_break;

Если вы отследите label softnet_break, наткнётесь на кое-что интересное. Взято из net/core/dev.c:

softnet_break:
  sd->time_squeeze++;
  __raise_softirq_irqoff(NET_RX_SOFTIRQ);
  goto out;

Инкрементируется некоторая статистика структуры struct softnet_data и закрывается SoftIRQ NET_RX_SOFTIRQ. Поле time_squeeze — это количество раз, когда у net_rx_action была работа, но бюджета не хватало либо было достигнуто ограничение по времени, прежде чем работа была завершена. Этот счётчик крайне полезен для понимания узких мест в сетевой обработке. Скоро мы этого коснёмся. NET_RX_SOFTIRQ отключено, чтобы освободить время обработки для других задач. В этом есть смысл, поскольку этот маленький кусок кода выполняется только тогда, когда можно сделать больше работы, но нам не нужно монополизировать CPU.

Затем исполнение передаётся ярлыку (label) out. Оно может быть передано out и в том случае, если больше не осталось NAPI-структур для обработки, то есть бюджета больше, чем сетевой активности, все драйверы закрыли NAPI, а net_rx_action нечем заняться.

Прежде чем выполнить возврат из net_rx_action, в секции out делается важная вещь: вызывается net_rps_action_and_irq_enable. Если включено управление принимаемыми пакетами (Receive Packet Steering), то эта функция пробуждает удалённые CPU, чтобы они начали обрабатывать сетевые данные.

Ниже мы разберём работу RPS. А пока посмотрим, как мониторить корректность цикла обработки net_rx_action, и перейдём к «внутренностям» NAPI-функций poll, продвигаясь по сетевому стеку.

NAPI-функция poll

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

Как драйвер igb всё это делает?

igb_poll

Наконец-то мы можем проанализировать работу igb_poll. Её код обманчиво прост. Взято из drivers/net/ethernet/intel/igb/igb_main.c:

/**
 *  igb_poll – NAPI Rx polling callback
 *  @napi: структура опроса (polling) NAPI
 *  @budget: счётчик количества пакетов, которые нужно обработать
 **/
static int igb_poll(struct napi_struct *napi, int budget)
{
        struct igb_q_vector *q_vector = container_of(napi,
                                                     struct igb_q_vector,
                                                     napi);
        bool clean_complete = true;

#ifdef CONFIG_IGB_DCA
        if (q_vector->adapter->flags & IGB_FLAG_DCA_ENABLED)
                igb_update_dca(q_vector);
#endif

        /* ... */

        if (q_vector->rx.ring)
                clean_complete &= igb_clean_rx_irq(q_vector, budget);

        /* Если вся работа не завершена, бюджета возвращается и продолжается опрос */
        if (!clean_complete)
                return budget;

        /* Если выполнено недостаточно работы по приёму, выходит из режима опроса */
        napi_complete(napi);
        igb_ring_irq_enable(q_vector);

        return 0;
}

Здесь есть ряд интересных вещей:

  • Если в ядре включена поддержка прямого доступа к кэшу (Direct Cache Access (DCA)), то кэш CPU «прогревается», чтобы в него попадали обращения к RX-кольцу. Подробнее об этом можно почитать в главе с доп. материалами в конце статьи.
  • Затем вызывается функция igb_clean_rx_irq, выполняющая нелёгкую задачу. Об этом ниже.
  • Далее проверяется clean_complete, чтобы определить, осталась ли ещё работа, которую можно выполнить. Если да, то бюджет возвращается (жёстко прописано значение 64). net_rx_action перемещает NAPI-структуру в конец списка poll.
  • В противном случае драйвер выключит NAPI посредством вызова napi_complete, и с помощью igb_ring_irq_enable снова включит прерывания. Следующее пришедшее прерывание опять включит NAPI.

Давайте посмотрим, как igb_clean_rx_irq отправляет сетевые данные вверх по стеку.

igb_clean_rx_irq

Функция igb_clean_rx_irq — это цикл, обрабатывающая по одному пакету за раз, пока не кончится budget или данные для обработки.

В этом цикле выполняются следующие вещи:

  1. В памяти размещаются дополнительные буферы приёма данных, на случай очистки используемых буферов. Они добавляются по IGB_RX_BUFFER_WRITE (16) за раз.
  2. Из очереди приёма извлекается буфер и сохраняется в структуре skb.
  3. Проверяется, является буфер “End of Packet”. Если да, то обработка продолжается. В противном случае продолжается извлечение дополнительных буферов из очереди приёма и добавление в skb. Это необходимо на тот случай, если полученный фрейм превышает размер буфера.
  4. Проверяется корректность схемы (layout) и заголовков данных.
  5. С помощью skb->len увеличивается счётчик количества обработанных байтов.
  6. В skb настраиваются хэш, контрольная сумма, временная метка, VLAN id и поля протокола. Первые четыре позиции предоставляются сетевой картой. Если она сообщает об ошибке контрольной суммы, то инкрементируется csum_error. Если с контрольной суммой всё в порядке, а данные получены по UDP или TCP, то skb помечается как CHECKSUM_UNNECESSARY. Если возникает сбой контрольной суммы, то с пакетом разбираются стеки протоколов. Нужный протокол вычисляется с помощью вызова eth_type_trans и сохраняется в структуре skb.
  7. Сделанный skb передаётся вверх по сетевому стеку с помощью вызова napi_gro_receive.
  8. Инкрементируется счётчик количества обработанных пакетов.
  9. Цикл продолжает выполняться до тех пор, пока количество обработанных пакетов не исчерпает бюджет.

Когда цикл прерывается, функция записывает счётчики обработанных пакетов и байтов.

Прежде чем продолжить рассмотрение сетевого стека, сделаем пару отступлений. Во-первых, посмотрим, как мониторить и настраиваться SoftIRQ сетевой подсистемы. Во-вторых, поговорим о Generic Receive Offloading (GRO). После того, как мы вникнем в napi_gro_receive, нам будет понятнее работа остальной части сетевого стека.

Мониторинг обработки сетевых данных

/proc/net/softnet_stat

Как мы видели в предыдущей главе, статистические счётчики инкрементируются при выходе из цикла net_rx_action, либо если осталась работа, которая могла быть выполнена, но мы упёрлись в ограничение бюджета или времени для SoftIRQ. Эта статистика отслеживается как часть структуры struct softnet_data, ассоциированной с CPU. Она сбрасывается в файл /proc/net/softnet_stat, по которому, к сожалению, очень мало документации. Поля не проименованы и могут меняться в зависимости от версии ядра.

В Linux 3.13.0 можно выяснить, какие значения соответствуют каким полям в /proc/net/softnet_stat посредством чтения исходников ядра. Взято из net/core/net-procfs.c:

seq_printf(seq,
       "%08x %08x %08x %08x %08x %08x %08x %08x %08x %08x %08xn",
       sd->processed, sd->dropped, sd->time_squeeze, 0,
       0, 0, 0, 0, /* was fastroute */
       sd->cpu_collision, sd->received_rps, flow_limit_count);

Многие из этих счётчиков имеют странные имена и инкрементируются в неожиданных местах. Только изучив сетевой стек можно понять, когда и где инкрементируется каждый из них. Поскольку статистика squeeze_time встречалась в net_rx_action, мне кажется, есть смысла задокументировать сейчас этот файл.

Читаем /proc/net/softnet_stat, чтобы мониторить статистику обработки сетевых данных:

$ cat /proc/net/softnet_stat
6dcad223 00000000 00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000
6f0e1565 00000000 00000002 00000000 00000000 00000000 00000000 00000000 00000000 00000000
660774ec 00000000 00000003 00000000 00000000 00000000 00000000 00000000 00000000 00000000
61c99331 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
6794b1b3 00000000 00000005 00000000 00000000 00000000 00000000 00000000 00000000 00000000
6488cb92 00000000 00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000

Важные подробности относительно /proc/net/softnet_stat:

  • Каждая строка /proc/net/softnet_stat соответствует структуре struct softnet_data, по одной на каждый CPU.
  • Значения разделены одиночными пробелами и отображаются в шестнадцатеричной форме.
  • Первое значение, sd->processed, — это количество обработанных сетевых фреймов. Оно может превышать общее количество полученных фреймов, если вы используете связывание Ethernet (Ethernet bonding). Драйвер связывания может стать причиной повторной обработки данных, и тогда счётчик sd->processed для одного и того же пакета будет инкрементироваться больше одного раза.
  • Второе значение, sd->dropped, — это количество отброшенных сетевых фреймов по причине нехватки места в очереди обработки. Об этом поговорим ниже.
  • Третье значение, sd->time_squeeze, — это количество раз, когда цикл net_rx_action прерывался из-за истощения бюджета или достижения ограничения по времени, хотя работа ещё оставалась. Как объяснялось выше, помочь тут может увеличение budget.
  • Следующие пять значений всегда равны 0.
  • Девятое значение, sd->cpu_collision, — это количество коллизий, возникавших при попытках получения блокировки устройства в ходе передачи пакетов. Поскольку эта статья посвящена приёму пакетов, то мы эту статистику рассматривать не будем.
  • Десятое значение, sd->received_rps, — это количество раз, когда посредством межпроцессорного прерывания будили CPU для обработки пакетов.
  • Последнее значение, flow_limit_count, — это количество раз, когда было достигнуто ограничение потока (flow limit). Ограничение потока — это опциональная возможность управления принимаемыми пакетами (Receive Packet Steering), которую мы скоро рассмотрим.

Если вы решили мониторить этот файл и отображать результаты графически, то будьте крайне осторожны, чтобы не поменять порядок полей и сохранить их смысл. Для этого ознакомьтесь с исходниками ядра.

Настройка обработки сетевых данных

Настройка бюджета net_rx_action

Настройка бюджета net_rx_action позволяет определять, сколько можно потратить на обработку пакетов для всех NAPI-структур, зарегистрированных на CPU. Для этого нужно с помощью sysctl настроить значение net.core.netdev_budget.

Пример: присвоим общему бюджету обработки пакетов значение 600.

$ sudo sysctl -w net.core.netdev_budget=600

Ещё эту настройку можно записать в файл /etc/sysctl.conf так, чтобы изменение сохранялось после перезагрузки. В Linux 3.13.0 значение по умолчанию 300.

Механизм Generic Receive Offloading (GRO)

Generic Receive Offloading (GRO) — это программная реализация аппаратной оптимизации, известной как Large Receive Offloading (LRO).

Суть обоих механизмов в том, чтобы уменьшить количество пакетов, передаваемых по сетевому стеку, за счёт комбинирования «достаточно похожих» пакетов. Это позволяет снизить нагрузку на CPU. Представим, что у нас передаётся большой файл, и большинство пакетов содержат чанки данных из этого файла. Вместо отправки по стеку маленьких пакетов по одному, входящие пакеты можно комбинировать в один, с огромной полезной нагрузкой. А затем уже передавать его по стеку. Таким образом уровни протоколов обрабатывают заголовки одного пакета, при этом передавая пользовательской программе более крупные чанки.

Но этой оптимизации присуща проблема потери информации. Если какой-то пакет имеет настроенную важную опцию или флаг, то эта опция или флаг могут быть потеряны при объединении с другими пакетами. В целом реализации LRO имеют очень нестрогие правила объединения пакетов.
GRO является программной реализацией LRO, но с более строгими правилами объединения.

Кстати: если вы когда-то использовали tcpdump и встречали слишком большие размеры входящих пакетов, то это наверняка было связано с включённой GRO в вашей системе. Как мы скоро увидим, tap'ы захвата пакетов вставлены дальше по стеку, уже после GRO.

Настройка параметров GRO с помощью ethtool

ethtool можно использовать для проверки, включена ли GRO, а также для её настройки.

Проверка настроек:

$ ethtool -k eth0 | grep generic-receive-offload
generic-receive-offload: on

В данном случае включена generic-receive-offload. Включаем GRO:

$ sudo ethtool -K eth0 gro on

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

napi_gro_receive

Функция napi_gro_receive занимается обработкой сетевых данных для GRO (если GRO включена) и отправкой их по стеку на уровни протоколов. Большая часть логики находится в функции dev_gro_receive.

dev_gro_receive

Эта функция сначала проверяет, включена ли GRO. Если да, то готовится к её применению: проходит по списку offload-фильтров, чтобы высокоуровневые стеки протоколов могли работать с данными, предназначенными для GRO. Это нужно для того, чтобы уровни протоколов могли сообщать уровню сетевого устройства, является ли пакет частью сетевого потока, который в данный момент свободен, а также могли обрабатывать всё относящееся к протоколу, что должно произойти в рамках GRO. Например, TCP-протоколу нужно решить, можно ли/когда подтверждать объединение пакета с уже имеющимся.

Вот пример кода, который это делает, взятый из net/core/dev.c:

list_for_each_entry_rcu(ptype, head, list) {
  if (ptype->type != type || !ptype->callbacks.gro_receive)
    continue;

  skb_set_network_header(skb, skb_gro_offset(skb));
  skb_reset_mac_len(skb);
  NAPI_GRO_CB(skb)->same_flow = 0;
  NAPI_GRO_CB(skb)->flush = 0;
  NAPI_GRO_CB(skb)->free = 0;

  pp = ptype->callbacks.gro_receive(&napi->gro_list, skb);
  break;
}

Если уровни протоколов сообщают, что пришло время сбросить GRO-пакет, то далее выполняется эта процедура. В этом случае вызывается napi_gro_complete, которая вызывает callback gro_complete для уровней протоколов, а затем передаёт пакет по стеку посредством вызова netif_receive_skb.

Пример кода из net/core/dev.c:

if (pp) {
  struct sk_buff *nskb = *pp;

  *pp = nskb->next;
  nskb->next = NULL;
  napi_gro_complete(nskb);
  napi->gro_count--;
}

Если уровни протоколов объединили этот пакет с имеющимся потоком, то napi_gro_receive посто возвращается.

Если пакет не было объединён и в системе меньше MAX_GRO_SKBS (8) GRO-потоков, то в список gro_list NAPI-структуры данного CPU добавляется новая запись.

Пример кода из net/core/dev.c:

if (NAPI_GRO_CB(skb)->flush || napi->gro_count >= MAX_GRO_SKBS)
  goto normal;

napi->gro_count++;
NAPI_GRO_CB(skb)->count = 1;
NAPI_GRO_CB(skb)->age = jiffies;
skb_shinfo(skb)->gso_size = skb_gro_len(skb);
skb->next = napi->gro_list;
napi->gro_list = skb;
ret = GRO_HELD;

Так работает система GRO в сетевом стеке Linux.

napi_skb_finish

По завершении dev_gro_receive вызывается napi_skb_finish, которая освобождает структуры данных, невостребованные по причине слияния пакета, либо для передачи данных по сетевому стеку вызывается netif_receive_skb (потому что GRO уже применена к потокам MAX_GRO_SKBS).

Теперь пришло время рассмотреть механизм управления принимаемыми пакетами (Receive Packet Steering (RPS)).


3.4. Механизм управления принимаемыми пакетами Receive Packet Steering (RPS)

Помните, выше мы обсуждали, как драйверы сетевых устройств регистрируют NAPI-функцию poll? Каждый экземпляр поллера NAPI исполняется в контексте SoftIRQ, по одному на каждый CPU. Теперь вспомним, что CPU, на котором выполняется драйверный обработчик прерываний, для обработки пакетов активирует SoftIRQ-цикл обработки.

Иными словами, в рамках обработки входящих данных одиночный CPU обрабатывает для пакетов прерывания и функции poll.

Некоторые сетевые карты (вроде Intel I350) на аппаратном уровне поддерживают несколько очередей. Это означает, что входящие пакеты могут напрямую отправляться в разные области памяти, выделенные для каждого очереди. При этом поллинг каждой области выполняется с помощью отдельных NAPI-структур. Так что прерывания и пакеты будут обрабатываться несколькими CPU.

Этот механизм называется Receive Side Scaling (RSS).

Receive Packet Steering (RPS) — это программная реализация RSS. А раз реализовано в коде, то может быть применено для любой сетевой карты, даже если она имеет лишь одну очередь приёма. С другой стороны, программная природа приводит к тому, что RPS может входить в поток только после пакета, извлечённого из DMA-области памяти.

Это означает, что CPU не будет тратить меньше времени на обработку прерываний или цикла poll, но зато вы сможете распределять нагрузку по обработке пакетов после того, как они были собраны, и снижать продолжительность работы CPU в сетевом стеке.

RPS генерирует для входящих данных хэш, чтобы определить, какой CPU должен их обработать. Затем данные помещаются во входящую очередь (backlog) этого процессора в ожидании последующей обработки. В процессор с backlog передаётся межпроцессорное прерывание (IPI), инициирующее обработку очереди, если это ещё не делается. /proc/net/softnet_stat содержит счётчик количества раз, когда каждая структура softnet_data получала IPI (поле received_rps).

Следовательно, netif_receive_skb продолжит отправлять данные по сетевому стеку или передаст в RPS для обработки другим CPU.

Включение RPS

Для начала нужно включить механизм RPS в конфигурации ядра (на Ubuntu верно для ядра 3.13.0), а также создать битовую маску, описывающую, какие CPU должны обрабатывать пакеты для конкретного интерфейса или очереди приёма.

Информацию об этих битовых масках можно найти в документации к ядру. Если вкратце, взять и модифицировать маски можно отсюда:

/sys/class/net/DEVICE_NAME/queues/QUEUE/rps_cpus

Например, для eth0 и очереди приёма 0 нужно внести в файл /sys/class/net/eth0/queues/rx-0/rps_cpus шестнадцатеричное число, обозначающее, какой CPU должен обрабатывать пакеты из очереди 0 eth0. Как указано в документации, на определённых конфигурациях в RPS нет нужды.

Примечание: включение RPS для распределения обработки пакетов по CPU, которые раньше этого не делали, для каждого из этих CPU приведёт к увеличению количества SoftIRQ `NET_RX`, а также `si` или `sitime` на графике потребления ресурсов CPU. Можете сравнить показатели «до» и «после», чтобы выяснить, соответствует ли конфигурация RPS вашим пожеланиям.


3.5. Механизм управления принимаемыми потоками (Receive Flow Steering (RFS))

Receive flow steering (RFS) используется совместно с RPS. RPS пытается распределять входящие пакеты среди нескольких CPU, но не принимает во внимание вопросы локальности данных для увеличения частоты попадания в кэш CPU. Если вам нужно увеличить эту частоту, то в этом поможет механизм RFS, переадресующий пакеты одного потока на один и тот же CPU.

Включение RFS

Чтобы RFS заработал, нужно его включить и сконфигурировать. RFS отслеживает глобальную хэш-таблицу всех потоков. Размер таблицы настраивается через sysctl параметром net.core.rps_sock_flow_entries.

Увеличим размер хэша потоков в сокете RFS:

$ sudo sysctl -w net.core.rps_sock_flow_entries=32768

Теперь можно настроить количество потоков для каждой очереди приёма. Это делается в файле rps_flow_cnt для каждой очереди.

Пример: увеличим количество потоков до 2048 в eth0 для очереди 0:

$ sudo bash -c 'echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt'


3.6. Аппаратно ускоренное управление принимаемыми потоками (Accelerated Receive Flow Steering (aRFS))

Работу RFS можно аппаратно ускорять. Сетевая карта и ядро могут совместно определять, какой поток на каком CPU нужно обрабатывать. Эта функция должна поддерживаться картой и драйвером, сверьтесь со спецификацией. Если драйвер вашей карты предоставляет функцию ndo_rx_flow_steer, значит он поддерживает aRFS.

Включение aRFS

Допустим, ваш драйвер поддерживает этот механизм. Порядок его включения и настройки:

  1. Включаем и настраиваем RPS.
  2. Включаем и настраиваем RFS.
  3. В ходе компиляции ядра активируется CONFIG_RFS_ACCEL. В частности, в ядре Ubuntu 3.13.0.
  4. Включаем для нашего устройства поддержку ntuple, как описано выше. Проверить, включена ли она, можно с помощью ethtool.
  5. Настраиваем параметры IRQ чтобы удостовериться, что каждая очередь приёма обрабатывается одним из ваши CPU, занимающихся обработкой сетевых данных.

Когда всё это будет сделано, aRFS автоматически задействуется для перемещения данных в очередь приёма, закреплённую за ядром CPU, обрабатывающим данные из этого потока. Вам не нужно вручную прописывать правила фильтрации ntuple для каждого потока.


3.7. Повышение (moving up) сетевого стека с помощью netif_receive_skb

Вернёмся к тому месту, где мы оставили netif_receive_skb, вызываемую из нескольких мест. Чаще всего из двух (мы их уже рассмотрели):

  • napi_skb_finish — если пакет не будет объединён с имеющимся GRO-потоком, ЛИБО
  • napi_gro_complete — если уровни протоколов сигнализируют о том, что пора сбрасывать поток, ЛИБО

Напоминаю: netif_receive_skb и его потомки оперируют в контексте цикла обработки SoftIRQ. Инструменты вроде top учитывают затраченное здесь время как sitime или si.

netif_receive_skb сначала проверят значение sysctl чтобы определить, включено ли у пользователя присвоение временных меток при получении до или после того, как пакет попадает в backlog-очередь. Если эта настройка включена, то данным теперь присваиваются временные метки до того, как они подвергаются работе механизма RPS (и backlog-очереди, ассоциированной с CPU). Если эта настройка выключена, то данным присваиваются временные метки после попадания в очередь. При включённом RPS это можно использовать для распределения нагрузки по присваиванию меток среди нескольких CPU, но даст некоторую задержку.

Настройка: присваивание временных меток принимаемым пакетам

Порядок присваивания меток можно настраивать в sysctl посредством net.core.netdev_tstamp_prequeue.

Отключим присваивание меток принимаемым пакетам:

$ sudo sysctl -w net.core.netdev_tstamp_prequeue=0

По умолчанию значение равно 1. Значение этой настройки объяснено в предыдущей главе.


3.8. netif_receive_skb

Разобравшись с временными метками, далее netif_receive_skb действует по разному, в зависимости от того, включён ли RPS. Начнём с более простого случая, когда RPS выключен.

Без RPS (по умолчанию)

В этом случае вызывается __netif_receive_skb, которая регистрирует какие-то данные (bookkeeping), а затем вызывает __netif_receive_skb_core, чтобы та перенесла данные поближе к стекам протоколов.

Потом мы рассмотрим, как работает __netif_receive_skb_core, но сначала разберём работу кода со включённым RPS, поскольку в этом случае также вызывается __netif_receive_skb_core.

С включённым RPS

После работы с опциями присваивания временных меток, netif_receive_skb выполняет ряд вычислений чтобы определить, backlog-очередь какого CPU нужно использовать. Это делается с помощью функции get_rps_cpu. Взято из net/core/dev.c:

cpu = get_rps_cpu(skb->dev, skb, &rflow);

if (cpu >= 0) {
  ret = enqueue_to_backlog(skb, cpu, &rflow->last_qtail);
  rcu_read_unlock();
  return ret;
}

get_rps_cpu также принимает во внимание настройки RFS и aRFS, чтобы удостовериться, что посредством вызова enqueue_to_backlog данные попадают в backlog нужного CPU.

enqueue_to_backlog

Эта функция сначала получает указатель на структуру softnet_data удалённого CPU, содержащую указатель на input_pkt_queue. Затем проверяется длина очереди input_pkt_queue этого удалённого CPU. Взято из net/core/dev.c:

qlen = skb_queue_len(&sd->input_pkt_queue);
if (qlen <= netdev_max_backlog && !skb_flow_limit(skb, qlen)) {

Сначала длина очереди input_pkt_queue сравнивается с netdev_max_backlog. Если длина большего этого значения, то данные отбрасываются. Аналогично проверяется ограничение потока, и если оно достигнуто, то данные отбрасываются. В обоих случаях инкрементируется счётчик отбрасываний структуры softnet_data. Обратите внимание, что это структура того CPU, в чью очередь должны были быть помещены данные. Мониторинг счётчика отбрасываний описан в главе о /proc/net/softnet_stat.

enqueue_to_backlog вызывается при обработке пакетов с включённым RPS, а также из netif_rx. Большинству драйверов следует использовать не netif_rx, а netif_receive_skb. Если вы не применяете RPS и ваш драйвер не использует netif_rx, то увеличение backlog’а не окажет на систему заметного влияния, поскольку он и не используется.

Примечание: проверьте используемый драйвер. Если он вызывает netif_receive_skb и вы не используете RPS, то увеличение netdev_max_backlog никак не повысит производительность, потому что никакие данные не доберутся до input_pkt_queue.

Допустим, что input_pkt_queue достаточно мала и ограничение потока не достигнуто (или отключено), а данные могут быть помещены в очередь. Логика здесь немного смешная, её можно обобщить так:

  • Если очередь пустая: проверяется, запущен ли NAPI на удалённом CPU. Если нет, проверяется, находится ли в очереди на отправку IPI. Если нет, то IPI помещается в очередь, а посредством вызова ____napi_schedule запускается цикл обработки NAPI. Переходим к передаче данных в очередь.
  • Если очередь не пустая, либо если завершена предыдущая операция, данные передаются в очередь.

Из-за использования goto код довольно хитрый, так что читайте внимательно. Взято из net/core/dev.c:

if (skb_queue_len(&sd->input_pkt_queue)) {
enqueue:
         __skb_queue_tail(&sd->input_pkt_queue, skb);
         input_queue_tail_incr_save(sd, qtail);
         rps_unlock(sd);
         local_irq_restore(flags);
         return NET_RX_SUCCESS;
 }

 /* Schedule NAPI for backlog device
  * We can use non atomic operation since we own the queue lock
  */
 if (!__test_and_set_bit(NAPI_STATE_SCHED, &sd->backlog.state)) {
         if (!rps_ipi_queued(sd))
                 ____napi_schedule(sd, &sd->backlog);
 }
 goto enqueue;

Ограничения потоков

RPS распределяет нагрузку по обработке пакетов между несколькими CPU, но один большой поток способен монополизировать время обработки и ущемлять более мелкие потоки. Ограничения потоков позволяют лимитировать количество пакетов от одного потока, помещаемых в backlog. В результате маленькие потоки могут обрабатываться параллельно с гораздо более крупными.

Выражение if из net/core/dev.c посредством вызова skb_flow_limit проверяет ограничение потока:

if (qlen <= netdev_max_backlog && !skb_flow_limit(skb, qlen)) {

Здесь проверяется, есть ли ещё место в очереди и не достигнуто ли ограничение. По умолчанию ограничения отключены. Для их включения нужно задать битовую маску (аналогично RPS).

Мониторинг отбрасываний по причине заполнения input_pkt_queue или ограничения потока

См. главу о мониторинге /proc/net/softnet_stat. Поле dropped — это счётчик, инкрементируемый при каждом отбрасывании данных вместо их помещения в очередь input_pkt_queue CPU.

Настройка

Настройка netdev_max_backlog для предотвращения отбрасываний

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

Если вы используете RPS или ваш драйвер вызывает netif_rx, то можно помочь предотвращать отбрасывания в enqueue_to_backlog с помощью увеличения netdev_max_backlog.

Пример: увеличим backlog до 3000:

$ sudo sysctl -w net.core.netdev_max_backlog=3000

По умолчанию значение равно 1000.

Настройка веса NAPI в backlog цикла poll

Настроить вес поллера NAPI в backlog’е можно с помощью net.core.dev_weight sysctl. Это значение определяет, какую часть общего бюджета может потратить цикл poll backlog'а (см. главу о настройке net.core.netdev_budget).

Пример: увеличим в backlog’е цикл обработки poll:

$ sudo sysctl -w net.core.dev_weight=600

По умолчанию значение равно 64.

Помните, что обработка backlog’а выполняется в контексте SoftIRQ аналогично зарегистрированной на драйвер устройства функции poll. Она будет ограничена общим бюджетом и временным ограничением, как описано в предыдущей главе.

Включение ограничений потоков и настройка размера хэш-таблицы ограничений потоков

Настроим размер таблицы ограничений потоков:

$ sudo sysctl -w net.core.flow_limit_table_len=8192

По умолчанию значение равно 4096.

Это изменение влияет только на заново размещённые таблицы. Так что сначала настройте размер таблицы, а потом включайте ограничения потоков.

Для включения ограничений нужно задать битовую маску в /proc/sys/net/core/flow_limit_cpu_bitmap, аналогично маске RPS, которая показывает, на каких CPU включены ограничения потоков.

Поллер NAPI backlog-очереди

Backlog-очереди каждого CPU используют NAPI так же, как и драйвер устройства. Предоставляется функция poll, используемая для обработки пакетов из контекста SoftIRQ. Как и в случае с драйвером, здесь тоже применяется weight.

Структура NAPI предоставляется в ходе инициализации сетевой подсистемы.

Взято из net_dev_init в net/core/dev.c:

sd->backlog.poll = process_backlog;
sd->backlog.weight = weight_p;
sd->backlog.gro_list = NULL;
sd->backlog.gro_count = 0;

NAPI-cтруктура backlog'а отличается от структуры драйвера возможностью настройки параметра weight: у драйвера его значение жёстко закодировано. Дальше мы рассмотрим процесс настройки веса.

process_backlog

Функция process_backlog — это цикл, выполняемый до тех пор, пока его вес (как описано в предыдущей главе) не будет израсходован или пока в backlog’е не останется больше данных.

Данные вынимаются по частям из backlog-очереди и передаются в __netif_receive_skb. Ветвь кода будет такой же, как и в случае с отключённым RPS. А именно, __netif_receive_skb выполняет те же процедуры перед вызовом __netif_receive_skb_core, чтобы передать данные на уровни протоколов.

process_backlog соблюдает тот же договор с NAPI, что и драйверы устройства: NAPI отключается, если не расходуется весь вес. Поллер перезапускается посредством вызова ____napi_schedule из enqueue_to_backlog, как описано выше.

Функция возвращает выполненную работу, которую затем net_rx_action (описано выше) вычтет из бюджета (настраивается с помощью net.core.netdev_budget, описано выше).

__netif_receive_skb_core передаёт данные в packet taps и на уровни протоколов

__netif_receive_skb_core выполняет трудную рабту по передаче данных в стеки протоколов. Перед этим она сначала проверяет, установлены ли какие-нибудь packet taps, которые ловят все входящие пакеты. Пример – семейство адресов AF_PACKET, обычно используемое посредством библиотеки libpcap.

Если имеется такой tap, до данные передаются сначала туда, а затем на уровни протоколов.

Передача в packet tap

Передачу пакетов выполняет следующий код. Взято из net/core/dev.c:

list_for_each_entry_rcu(ptype, &ptype_all, list) {
  if (!ptype->dev || ptype->dev == skb->dev) {
    if (pt_prev)
      ret = deliver_skb(skb, pt_prev, orig_dev);
    pt_prev = ptype;
  }
}

Если вам интересно, как данные проходят через pcap, читайте net/packet/af_packet.c.

Передача на уровни протоколов

После того, как тапы будут удовлетворены, __netif_receive_skb_core передаёт данные на уровни протоколов. Для этого из данных извлекается поле протокола и выполняется итерирование по списку передающих функций (deliver functions), зарегистрированных для этого типа протокола.

Это можно посмотреть в __netif_receive_skb_core в net/core/dev.c:

type = skb->protocol;
list_for_each_entry_rcu(ptype,
                &ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {
        if (ptype->type == type &&
            (ptype->dev == null_or_dev || ptype->dev == skb->dev ||
             ptype->dev == orig_dev)) {
                if (pt_prev)
                        ret = deliver_skb(skb, pt_prev, orig_dev);
                pt_prev = ptype;
        }
}

Здесь идентификатор ptype_base определён как хэш-таблица списка в net/core/dev.c:

struct list_head ptype_base[PTYPE_HASH_SIZE] __read_mostly;

Каждый уровень протокола добавляет фильтр в список заданного слота хэш-таблицы, вычисляемой вспомогательной функцией ptype_head:

static inline struct list_head *ptype_head(const struct packet_type *pt)
{
        if (pt->type == htons(ETH_P_ALL))
                return &ptype_all;
        else
                return &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];
}

Добавление фильтра в список завершается вызовом dev_add_pack. Таким способом уровни протоколов регистрируют себя для передачи им данных.
Теперь вы знаете, как сетевые данные попадают из сетевой карты на уровень протокола.


3.9. Регистрация уровня протокола

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

Уровень протокола IP

Уровень протокола IP сам включает себя в хэш-таблицу ptype_base, и в предыдущих главах мы уже рассматривали данные, которые предаются на этот уровень с уровня сетевого устройства.

Это происходит в функции inet_init, взято из net/ipv4/af_inet.c:

dev_add_pack(&ip_packet_type);
Здесь регистрируется структура типа IP-пакета, определённая в <a href="https://github.com/torvalds/linux/blob/v3.13/net/ipv4/af_inet.c#L1673-L1676">net/ipv4/af_inet.c</a>:
static struct packet_type ip_packet_type __read_mostly = {
        .type = cpu_to_be16(ETH_P_IP),
        .func = ip_rcv,
};

__netif_receive_skb_core вызывает deliver_skb (как мы видели в предыдущей главе), которая вызывает func (в данном случае – ip_rcv).

ip_rcv

На высоком уровне функция ip_rcv весьма незатейлива. В ней есть несколько проверок целостности, чтобы удостовериться в валидности данных, а также дёргаются счётчики статистики.

ip_rcv завершается передачей пакета в ip_rcv_finish посредством netfilter. Это делается так, чтобы любое правило iptables, которое должно быть соблюдено на уровне протокола IP, могло проверить пакет, прежде чем он отправится дальше.

Код, передающий данные через netfilter в конце ip_rcv в net/ipv4/ip_input.c:

return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL, ip_rcv_finish);

netfilter и iptables

Ради краткости изложения я решил пропустить подробное рассмотрение netfilter, iptables и conntrack.

Короткая версия: NF_HOOK_THRESH проверяет, установлены ли какие-нибудь фильтры и пытается вернуть исполнение обратно на уровень протокола IP, чтобы не углубляться в netfilter и всё, что под ним, вроде iptables и conntrack.

Помните: если у вас много правил netfilter или iptables, или они слишком сложны, то эти правила будут исполняться в контексте SoftIRQ, а это риск возникновения задержек в сетевом стеке. Возможно, вы этого и не избежите, если вам нужен конкретный набор установленных правил.

ip_rcv_finish

После того, как netfilter получает возможность взглянуть на данные и решить, что с ними делать, вызывается ip_rcv_finish. Конечно, если только данные не отбрасываются netfilter'ом.

ip_rcv_finish начинается с оптимизации. Чтобы доставить пакет в нужное место, должна быть готова структура dst_entry от системы маршрутизации. Для её получения код сначала пытается вызвать функцию early_demux из протокола более высокого уровня, чем тот, для которого предназначены эти данные.

early_demux — это оптимизация, в ходе которой мы пытаемся найти необходимую для доставки пакета структуру dst_entry. Для этого проверяется, не закэширована ли dst_entry в структуре сокета.

Вот как это выглядит, взято из net/ipv4/ip_input.c:

if (sysctl_ip_early_demux && !skb_dst(skb) && skb->sk == NULL) {
  const struct net_protocol *ipprot;
  int protocol = iph->protocol;

  ipprot = rcu_dereference(inet_protos[protocol]);
  if (ipprot && ipprot->early_demux) {
    ipprot->early_demux(skb);
    /* нужно перезагрузить iph, skb->head могла измениться */
    iph = ip_hdr(skb);
  }
}

Как видите, этот код защищается sysctl_ip_early_demux. По умолчанию включена early_demux. Из следующей главы вы узнаете о том, как её отключить и почему вы можете захотеть это сделать.

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

После завершение слоя маршрутизации обновляются счётчики статистики, а функция завершается вызовом dst_input(skb). Та, в свою очередь, вызывает указатель функции ввода на структуру пакета dst_entry, приписанную системой маршрутизации.

Если пункт назначения пакета — локальная система, то система маршрутизации прикрепит функцию ip_local_deliver к указателю функции ввода в структуре пакета dst_entry.

Настройка early demux протокола IP

Отключаем оптимизацию early_demux:

$ sudo sysctl -w net.ipv4.ip_early_demux=0

Значение по умолчанию равно 1; early_demux включена.

В ряде случаев эта sysctl примерно на 5% ухудшает пропускную способность с оптимизацией early_demux.

ip_local_deliver

Вспоминаем следующий шаблон на уровне протокола IP:

  1. При вызове ip_rcv выполняется ряд процедур (bookkeeping).
  2. Пакет передаётся netfilter для последующей обработки, с указателем на исполнение callback’а, когда обработка будет завершена.
  3. ip_rcv_finish — это callback, завершающий обработку и продолжающий продвижение пакета по сетевому стеку.

В случае с ip_local_deliver используется тот же шаблон. Взято из net/ipv4/ip_input.c:

/*
 *      Доставляет IP-пакеты на более высокие уровни протоколов.
 */
int ip_local_deliver(struct sk_buff *skb)
{
        /*
         *      Пересобирает IP-пакеты.
         */

        if (ip_is_fragment(ip_hdr(skb))) {
                if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))
                        return 0;
        }

        return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL,
                       ip_local_deliver_finish);
}

После того, как netfilter получает возможность взглянуть на данные, вызывается ip_local_deliver_finish. Конечно, если только данные не отброшены netfilter'ом.

ip_local_deliver_finish

ip_local_deliver_finish извлекает из пакета протокол, ищет зарегистрированную на этот протокол структуру net_protocol и вызывает функцию, на которую указывает handler в этой структуре.

Таким образом пакет передаётся на более высокий уровень протокола.

Мониторинг статистики уровня протокола IP

Читаем /proc/net/snmp, чтобы мониторить подробную статистику протокола IP:

$ cat /proc/net/snmp
Ip: Forwarding DefaultTTL InReceives InHdrErrors InAddrErrors ForwDatagrams InUnknownProtos InDiscards InDelivers OutRequests OutDiscards OutNoRoutes ReasmTimeout ReasmReqds ReasmOKs ReasmFails FragOKs FragFails FragCreates
Ip: 1 64 25922988125 0 0 15771700 0 0 25898327616 22789396404 12987882 51 1 10129840 2196520 1 0 0 0
...

Этот файл содержит статистику по нескольким уровням протоколов. Первым идёт уровень IP. В первой строке представлены разделённые пробелами имена соответствующих значений из второй строки.

На уровне протокола IP можно найти ряд счётчиков статистики, которые используются в С-перечислении. Все валидные enum-значения и имена полей в /proc/net/snmp, которым они соответствуют, можно найти в include/uapi/linux/snmp.h:

enum
{
  IPSTATS_MIB_NUM = 0,
/* часто записываемые напрямую поля, хранятся в той же кэш-строке */
  IPSTATS_MIB_INPKTS,     /* InReceives */
  IPSTATS_MIB_INOCTETS,     /* InOctets */
  IPSTATS_MIB_INDELIVERS,     /* InDelivers */
  IPSTATS_MIB_OUTFORWDATAGRAMS,   /* OutForwDatagrams */
  IPSTATS_MIB_OUTPKTS,      /* OutRequests */
  IPSTATS_MIB_OUTOCTETS,      /* OutOctets */

  /* ... */

Читаем /proc/net/netstat, чтобы мониторить расширенную статистику протокола IP:

$ cat /proc/net/netstat | grep IpExt
IpExt: InNoRoutes InTruncatedPkts InMcastPkts OutMcastPkts InBcastPkts OutBcastPkts InOctets OutOctets InMcastOctets OutMcastOctets InBcastOctets OutBcastOctets InCsumErrors InNoECTPkts InECT0Pktsu InCEPkts
IpExt: 0 0 0 0 277959 0 14568040307695 32991309088496 0 0 58649349 0 0 0 0 0

Формат аналогичен /proc/net/snmp, за исключением строк с префиксом IpExt.

Некоторые любопытные метрики:

  • InReceives: общее количество IP-пакетов, достигших ip_rcv до всех проверок целостности.
  • InHdrErrors: общее количество IP-пакетов с повреждёнными заголовками. Заголовок был слишком маленьким или длинным, вообще не существует, содержал неправильный номер версии протокола IP и так далее.
  • InAddrErrors: общее количество IP-пакетов, когда хост был недоступен.
  • ForwDatagrams: общее количество IP-пакетов, которые были переадресованы (forwarded).
  • InUnknownProtos: общее количество IP-пакетов с неизвестным или неподдерживаемым протоколом, указанным в заголовке.
  • InDiscards: общее количество IP-пакетов, отклонённых в результате сбоя выделения памяти, или в результате сбоя контрольной суммы при обрезании пакета.
  • InDelivers: общее количество IP-пакетов, успешно доставленных на более высокие уровни протоколов. Помните, что эти уровни могут отбрасывать данные, даже если этого не сделал уровень IP.
  • InCsumErrors: общее количество IP-пакетов с ошибками контрольной суммы.

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

Регистрация протокола более высокого уровня

Здесь мы рассмотрим UDP, но обработчик протокола TCP регистрируется так же и в то же время, что и обработчик протокола UDP.

В net/ipv4/af_inet.c можно найти определения структур, которые содержат функции-обработчики для подключения протоколов UDP, TCP и ICMP к уровню протокола IP. Взято из net/ipv4/af_inet.c:

static const struct net_protocol tcp_protocol = {
        .early_demux    =       tcp_v4_early_demux,
        .handler        =       tcp_v4_rcv,
        .err_handler    =       tcp_v4_err,
        .no_policy      =       1,
        .netns_ok       =       1,
};

static const struct net_protocol udp_protocol = {
        .early_demux =  udp_v4_early_demux,
        .handler =      udp_rcv,
        .err_handler =  udp_err,
        .no_policy =    1,
        .netns_ok =     1,
};

static const struct net_protocol icmp_protocol = {
        .handler =      icmp_rcv,
        .err_handler =  icmp_err,
        .no_policy =    1,
        .netns_ok =     1,
};

Эти структуры регистрируются в коде инициализации семейства адресов inet. Взято из net/ipv4/af_inet.c:

/*
  *      Добавляем все базовые протоколы.
  */

 if (inet_add_protocol(&icmp_protocol, IPPROTO_ICMP) < 0)
         pr_crit("%s: Cannot add ICMP protocoln", __func__);
 if (inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0)
         pr_crit("%s: Cannot add UDP protocoln", __func__);
 if (inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0)
         pr_crit("%s: Cannot add TCP protocoln", __func__);

Рассмотрим уровень протокола UDP. Как мы видели выше, функция handler для UDP вызывается udp_rcv. Это точка входа на уровень UPD, куда передаются данные с уровня IP.

Уровень протокола UDP

Код уровня протокола UDP можно найти здесь: net/ipv4/udp.c.

udp_rcv

Код функции udp_rcv состоит из одной строки, в которой напрямую вызывается функция __udp4_lib_rcv для обработки принимаемых датаграмм.

__udp4_lib_rcv

Функция __udp4_lib_rcv удостоверяется в валидности пакета и извлекает заголовок UDP, длину UDP-датаграммы, адрес источник и адрес пункта назначения. Далее выполняются проверки целостности и контрольной суммы.

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

Если найден сокет и соответствующая dst_entry, то функция __udp4_lib_rcv помещает пакет в очередь сокета:

sk = skb_steal_sock(skb);
if (sk) {
  struct dst_entry *dst = skb_dst(skb);
  int ret;

  if (unlikely(sk->sk_rx_dst != dst))
    udp_sk_rx_dst_set(sk, dst);

  ret = udp_queue_rcv_skb(sk, skb);
  sock_put(sk);
  /* возвращаемое значение > 0 означает повторный ввод данных,
   * но он хочет вернуть –protocol или 0
   */
  if (ret > 0)
    return -ret;
  return 0;
} else {

Если от операции early_demux не осталось прикреплённого принимающего сокета, то он ищется путём вызова __udp4_lib_lookup_skb.

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

ret = udp_queue_rcv_skb(sk, skb);
sock_put(sk);

Если сокет так и не находится, то датаграмма будет отброшена:

/* Сокета нет. Пакет тихо отбрасывается, если контрольная сумма ошибочна */
if (udp_lib_checksum_complete(skb))
        goto csum_error;

UDP_INC_STATS_BH(net, UDP_MIB_NOPORTS, proto == IPPROTO_UDPLITE);
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);

/*
 * Хм.  Мы получили UDP-пакет на порт, который 
 * не хотим прослушивать.  Игнорируем его.
 */
kfree_skb(skb);
return 0;

udp_queue_rcv_skb

Начальная часть функции:

  1. Определяет, является ли сокет, ассоциированный с датаграммой, инкапсулирующим сокетом. Если да, то перед обработкой пакет передаётся функции-обработчику этого уровня.
  2. Определяет, относится ли датаграмма к UDP-Lite и выполняется проверки целостности.
  3. Проверяет UDP-контрольную сумму датаграммы и отбрасывает последнюю в случае сбоя.

Наконец-то мы добрались до логики очереди приёма. Начинается она с проверки заполненности очереди для сокета. Взято из net/ipv4/udp.c:

if (sk_rcvqueues_full(sk, skb, sk->sk_rcvbuf))
  goto drop;

sk_rcvqueues_full

Функция sk_rcvqueues_full проверяет длину backlog’а и sk_rmem_alloc сокета, чтобы определить, не превышает ли сумма их длин размер sk_rcvbuf для сокета (sk->sk_rcvbuf в вышеприведённом примере):

/*
 * Учитывает размер очереди приёма и backlog-очереди.
 * Не учитывает этот skb truesize,
 * чтобы мог прибыть даже одиночный большой пакет.
 */
static inline bool sk_rcvqueues_full(const struct sock *sk, const struct sk_buff *skb,
                                     unsigned int limit)
{
        unsigned int qsize = sk->sk_backlog.len + atomic_read(&sk->sk_rmem_alloc);

        return qsize > limit;
}

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

Настройка: память очереди приёма сокета

Значение sk->sk_rcvbuf (вызываемое ограничение в sk_rcvqueues_full) можно увеличить до любого уровня в пределах sysctl net.core.rmem_max.

Увеличим максимальный размер буфера приёма:

$ sudo sysctl -w net.core.rmem_max=8388608

sk->sk_rcvbuf начинается со значения net.core.rmem_default, которое тоже можно настроить с помощью sysctl.

Настроим исходный размер буфера приёма по умолчанию:

$ sudo sysctl -w net.core.rmem_default=8388608

Также можно настроить размер sk->sk_rcvbuf, посредством вызова setsockopt из вашего приложения и передачи SO_RCVBUF. Максимальное значение setsockopt не превышает net.core.rmem_max.

Зато можно превысить ограничение net.core.rmem_max, вызвав setsockopt и передав SO_RCVBUFFORCE. Но пользователю, у которого выполняется приложение, потребуется возможность CAP_NET_ADMIN.

Значение sk->sk_rmem_alloc инкрементируется с помощью вызовов skb_set_owner_r, которые задают владеющий датаграммой сокет. Позднее мы ещё столкнёмся с этим на уровне UDP.

Значение sk->sk_backlog.len инкрементируется с помощью вызовов sk_add_backlog.

udp_queue_rcv_skb

После проверки заполненности очереди продолжается процедура помещения датаграммы в очередь. Взято из net/ipv4/udp.c:

bh_lock_sock(sk);
if (!sock_owned_by_user(sk))
  rc = __udp_queue_rcv_skb(sk, skb);
else if (sk_add_backlog(sk, skb, sk->sk_rcvbuf)) {
  bh_unlock_sock(sk);
  goto drop;
}
bh_unlock_sock(sk);

return rc;

Сначала выясняется, есть ли в данный момент системные вызовы к сокету из программы пользовательского пространства. Если нет, то датаграмма может быть добавлена в очередь приёма с помощью вызова __udp_queue_rcv_skb. Если есть, то датаграмма помещается в backlog-очередь с помощью вызова sk_add_backlog.

Датаграммы добавляются в очередь приёма из backlog’а, когда системные вызовы освобождают сокет с помощью вызова release_sock в ядре.

__udp_queue_rcv_skb

Функция __udp_queue_rcv_skb добавляет датаграммы в очередь приёма с помощью вызова sock_queue_rcv_skb. А если датаграмму нельзя добавить в очередь приёма сокета, то __udp_queue_rcv_skb дёргает счётчики статистики.

Взято из net/ipv4/udp.c:

rc = sock_queue_rcv_skb(sk, skb);
if (rc < 0) {
  int is_udplite = IS_UDPLITE(sk);

  /* Обратите внимание, что ошибка ENOMEM выдаётся дважды */
  if (rc == -ENOMEM)
    UDP_INC_STATS_BH(sock_net(sk), UDP_MIB_RCVBUFERRORS,is_udplite);

  UDP_INC_STATS_BH(sock_net(sk), UDP_MIB_INERRORS, is_udplite);
  kfree_skb(skb);
  trace_udp_fail_queue_rcv_skb(rc, sk);
  return -1;
}

Мониторинг статистики уровня протокола UDP

Два очень полезных файла для получения статистики по протоколу UDP:

  • /proc/net/snmp
  • /proc/net/udp

/proc/net/snmp

Читаем /proc/net/snmp, чтобы мониторить подробную статистику протокола UDP.

$ cat /proc/net/snmp | grep Udp:
Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors
Udp: 16314 0 0 17161 0 0

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

InDatagrams: инкрементируется, когда:

  • recvmsg используется программой пользовательского пространства для чтения датаграммы.
  • UDP-пакет инкапсулируется и возвращается для обработки.

NoPorts: инкрементируется, когда UDP-пакеты прибывают на определённый порт, который не слушает ни одна программа.

InErrors: инкрементируется, если:

  • закончилась память очереди приёма,
  • ошибочная контрольная сумма,
  • у sk_add_backlog не получается добавить датаграмму.

OutDatagrams: инкрементируется, когда UDP-пакет безошибочно передаётся вниз, на уровень протокола IP для последующей отправки.

RcvbufErrors: инкрементируется, когда sock_queue_rcv_skb сообщает об отсутствии доступной памяти; такое случается, если sk->sk_rmem_alloc больше или равно sk->sk_rcvbuf.

SndbufErrors: инкрементируется, если:

  • уровень протокола IP сообщил об ошибке при попытке отправки пакета,
  • ядру не хватает памяти,
  • закончилось место в буфере отправки.

InCsumErrors: инкрементируется, когда обнаруживается сбой контрольной суммы UDP. Обратите внимание, что во всех случаях, с которыми я сталкивался, InCsumErrors инкрементируется одновременно с InErrors. Следовательно, связка InErrors — InCsumErros должна отражать количество ошибок памяти.

/proc/net/udp

Читаем /proc/net/udp, чтобы мониторить подробную статистику сокета UDP.

$ cat /proc/net/udp
  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode ref pointer drops
  515: 00000000:B346 00000000:0000 07 00000000:00000000 00:00000000 00000000   104        0 7518 2 0000000000000000 0
  558: 00000000:0371 00000000:0000 07 00000000:00000000 00:00000000 00000000     0        0 7408 2 0000000000000000 0
  588: 0100007F:038F 00000000:0000 07 00000000:00000000 00:00000000 00000000     0        0 7511 2 0000000000000000 0
  769: 00000000:0044 00000000:0000 07 00000000:00000000 00:00000000 00000000     0        0 7673 2 0000000000000000 0
  812: 00000000:006F 00000000:0000 07 00000000:00000000 00:00000000 00000000     0        0 7407 2 0000000000000000 0

Первая строка описывает каждое из полей из последующих строк:

  • sl: хэш-слот ядра для сокета.
  • local_address: шестнадцатеричный локальный адрес сокета и номер порта, разделённые :.
  • rem_address: шестнадцатеричный удалённый адрес сокета и номер порта, разделённые :.
  • st: состояние сокета. Довольно странно, но уровень протокола UDP, судя по всему, использует состояния сокета TCP. В приведённом примере, 7 — это TCP_CLOSE.
  • tx_queue: количество выделенной в ядре памяти для исходящих датаграмм UDP.
  • rx_queue: количество выделенной в ядре памяти для входящих датаграмм UDP.
  • tr, tm->when, retrnsmt: эти поля не используются уровнем протокола UDP.
  • uid: действительный идентификатор пользователя, создавшего этот сокет.
  • timeout: не используется уровнем протокола UDP.
  • inode: номер индексного дескриптора (inode number), соответствующего этому сокету. Его можно использовать для определения, какой пользовательский процесс открыл этот сокет. Посмотрите /proc/[pid]/fd, она содержи symlink’и на socket[:inode].
  • ref: текущий счётчик ссылок на этот сокет.
  • pointer: адрес struct sock в памяти ядра.
  • drops: количество отброшенных датаграмм, связанных с этим сокетом.

Отображающий всё это код можно найти в net/ipv4/udp.c.

Помещение данных в очередь сокета

Сетевые данные помещаются в очередь сокета с помощью вызова sock_queue_rcv. Прежде чем добавить датаграмму в очередь, эта функция делает несколько вещей:

  1. Проверяется выделенная для сокета память чтобы определить, достигнут ли предел размера буфера приёма. Если да, то для этого сокета инкрементируется счётчик отбрасываний.
  2. sk_filter используется для обработки фильтров Berkeley Packet Filter, применённых к сокету.
  3. sk_rmem_schedule позволяет удостовериться, что в буфере приёма есть достаточно места, чтобы принять датаграмму.
  4. Затем с помощью вызова skb_set_owner_r размер датаграммы заносится в сокет. Инкрементируется sk->sk_rmem_alloc.
  5. С помощью вызова __skb_queue_tail в очередь добавляются данные.
  6. Наконец, с помощью вызова функции обработки уведомлений sk_data_ready уведомляются все процессы, ожидающие прибытия данных в сокет.

Таким образом данные прибывают в систему и проходят по сетевому стеку, пока не достигают сокета. Теперь они готовы к использованию пользовательской программой.


3.10. Дополнительная информация

Нужно упомянуть о нескольких дополнительных вещах, о которых не было повода рассказать выше.

Присваивание временных меток

Я уже писал, что сетевой стек может собирать временные метки входящих данных. В sysctl есть значения, совместно с RPS позволяющие контролировать момент и способ сбора меток. Подробнее об этом читайте в главах, посвящённых RPS и временным меткам. Некоторые сетевые карты даже поддерживают метки аппаратно.

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

В документации ядра прекрасно освещён вопрос присваивания временных меток, туда даже включена программа-образец и сборочный файл!

Определяем, какие режимы временных меток поддерживают ваши драйвер и устройство:

$ sudo ethtool -T eth0
Time stamping parameters for eth0:
Capabilities:
  software-transmit     (SOF_TIMESTAMPING_TX_SOFTWARE)
  software-receive      (SOF_TIMESTAMPING_RX_SOFTWARE)
  software-system-clock (SOF_TIMESTAMPING_SOFTWARE)
PTP Hardware Clock: none
Hardware Transmit Timestamp Modes: none
Hardware Receive Filter Modes: none

К сожалению, эта сетевая карта не поддерживает временные метки аппаратно, но ещё можно использовать их программно. Они помогут определять, какую задержку ядро добавляет к моему пути прохождения пакетов.

Нагруженный поллинг для сокетов с малой задержкой

Можно использовать опцию сокета, называющуюся SO_BUSY_POLL. Она заставляет ядро применять к новым данным нагруженный поллинг, когда завершается блокирующий приём и нет данных.

ВАЖНОЕ ЗАМЕЧАНИЕ: чтобы эта опция работала, она должна поддерживаться вашим драйвером устройства. Драйвер igb ядра 3.13.0 её не поддерживает. А ixgbe — поддерживает. Если ваш драйвер имеет функцию, настроенную в поле ndo_busy_poll структуры struct net_device_ops (упоминалась выше), то он поддерживает SO_BUSY_POLL.

У Intel прекрасно описано, как это работает и как это использовать.

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

Также можно настроить в sysctl значение net.core.busy_poll — как долго вызовы с poll или select должны ожидать прибытия новых данных в условиях нагруженного поллинга(в микросекундах).

Эта опция позволяет снизить задержку, но увеличивает нагрузку на CPU и потребление энергии.

Netpoll: поддержка работы с сетью в рамках критических контекстов

Ядро Linux предоставляет способ использовать драйверы устройств для отправки и приёма данных на сетевой карте, если ядро падает. API для этой функциональности называется Netpoll. Он используется разными системами, но особенно kgdb и netconsole.

Netpoll поддерживается большинство драйверов. Ваш драйвер должен реализовать функцию ndo_poll_controller и прикрепить её к структуре struct net_device_ops, регистрируемой во время probe.

Когда подсистема сетевого устройства выполняет операции со входящими и исходящими данными, то сначала проверяется система Netpoll, чтобы определить, не предназначен ли пакет для неё.

Следующий код можно найти в __netif_receive_skb_core из net/dev/core.c:

static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc)
{

  /* ... */

  /* если мы попали сюда через NAPI, проверяем netpoll */
  if (netpoll_receive_skb(skb))
    goto out;

  /* ... */
}

Проверки Netpoll довольно рано выполняются в большинстве подсистем сетевых устройств Linux, работающих с передачей или приёмом сетевых данных.

Потребители Netpoll API могут зарегистрировать структуры struct netpoll с помощью вызова netpoll_setup. Эти структуры содержат указатели функций для прикрепления хуков, а API экспортирует функцию для отправки данных.

Если вас интересует использование Netpoll API, то изучите драйвер netconsole, заголовочный файл Netpoll API, ‘include/linux/netpoll.h` и этот прекрасный текст.

SO_INCOMING_CPU

Флаг SO_INCOMING_CPU отсутствовал в Linux вплоть до версии 3.19, но он достаточно полезен, поэтому я его упомяну.

Для определения, какой CPU обрабатывает сетевые пакеты для конкретного сокета, можно использовать getsockopt и опцию SO_INCOMING_CPU. Тогда ваше приложение сможет использовать эту информацию для передачи сокетов тредам, выполняющимся на нужном CPU. Это поможет улучшить локальность данных и частоту попадания в кэш CPU.

Короткий пример архитектуры, когда эта опция полезна, приведён здесь: patchwork.ozlabs.org/patch/408257.

Движки DMA

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

Ядро Linux содержит обощённый интерфейс движка DMA, который может использоваться авторами драйверов движка. Подробнее об этом интерфейсе можно почитать в документации к ядру.

Ядро поддерживает несколько движков DMA, но мы будем говорить об одном из самых распространённых — Intel IOAT DMA engine.

Технология ускорения I/O Intel (Intel’s I/O Acceleration Technology (IOAT))

Многие серверы содержат пакет Intel I/O AT, который вносит в производительность ряд изменений. Одно из них — использование аппаратного движка DMA. Проверьте выходные данные своего dmesg для ioatdma чтобы определить, загружен ли модуль и нашёл ли он поддерживаемое оборудование. Движок DMA используется в ряде мест, но особенно активно — в стеке TCP.

Поддержка движка Intel IOAT была включена в Linux 2.6.18, но в 3.13.11.10 от неё отказались из-за неожиданных багов, повреждавших данные. Пользователи ядер до версии 3.13.11.10 могут по умолчанию использовать модуль ioatdma. Возможно, в будущих релизах его пофиксят.

Прямой доступ к кэшу (Direct cache access (DCA))

Другая интересная функция, идущая в пакете Intel I/O AT — Direct Cache Access (DCA).

Она позволяет сетевым устройствам (через их драйверы) помещать сетевые данные напрямую в кэш CPU. Конкретная реализация зависит от драйвера. В случае с igb можете посмотреть код функции igb_update_dca, а также igb_update_rx_dca. Драйвер igb использует DCA при записи значений регистров в сетевую карту.

Чтобы использовать DCA, вам нужно включить её в BIOS, проверить, загружен ли модуль dca и поддерживают ли её ваша сетевая карта и драйвер.

Мониторинг движка IOAT DMA

Если, несмотря на риск повреждения данных, вы используете модуль ioatdma, то можете мониторить его через некоторые записи в sysfs.
Мониторим общее количество разгруженных операций memcpy в DMA-канале:

$ cat /sys/class/dma/dma0chan0/memcpy_count
123205655

Мониторим общее количество байтов, переданных через DMA-канал:

$ cat /sys/class/dma/dma0chan0/bytes_transferred
131791916307

Настройка движка IOAT DMA

Движок IOAT DMA используется только тогда, когда размер пакета превышает определённый порог — copybreak. Эта проверка нужна потому, что для маленьких копий накладные расходы при установке и использовании движка DMA не покрывают выгоды от ускорения.

Настроим copybreak для движка DMA:

$ sudo sysctl -w net.ipv4.tcp_dma_copybreak=2048

Значение по умолчанию равно 4096.


4. Заключение

Сетевой стек Linux довольно сложный. Без глубокого понимания происходящих процессов вы не сможете мониторить или настроить его (как и любое другое сложное ПО). На просторах интернета вы можете встретить примеры sysctl.conf, содержащие наборы значений, которые предлагается скопировать и вставить на вашем компьютере. Это не лучший способ оптимизации своего сетевого стека.

Мониторинг сетевого стека требует аккуратного управления сетевыми данными на каждом уровне, начиная с драйверов. Тогда вы сможете определять, где происходят отбрасывания и возникают ошибки, после чего производить настройку, уменьшая эти вредные эффекты.

К сожалению, лёгкого пути тут нет.

Автор: Mail.Ru Group

Источник

Поделиться новостью

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