NAPI в сетевых драйверах Linux

    Привет, Хабр!

    Поговорим о драйверах сетевых устройств Linux, механизме NAPI и его изменениях в ядре 5.12.

    Сетевая подсистема Linux (рисунок) построена по примеру стека BSD, в ней прием и передача данных на транспортном и сетевом уровнях происходит с помощью интерфейса сокетов. В отличие от unix-сокетов для межпроцессного взаимодействия, TCP/IP сокеты используют для работы сетевой протокол и при создании (sys_socket) принимают параметры домен, тип, локальные и удаленные IP-адрес и порт. Буфер сокета (sk_buff) - фактически, пакет. Связный список экземпляров таких структур составляет очередь сетевого интерфейса (tx_queue, rx_queue).

    Упрощенно – некоторые важные поля sk_buff:

    struct sk_buff {
    	union {
    		struct {
    		    /* Двусвязный список */
    			struct sk_buff		*next;
    			struct sk_buff		*prev;
    			
    			struct net_device	*dev;
    		};
    		struct list_head	list;
    	};
    
    	struct sock		*sk;
    
    	unsigned int		len,
    				data_len;
    	__u16			mac_len,
    				hdr_len;
    
    /* Часть NAPI-интерфейса */
    #if defined(CONFIG_NET_RX_BUSY_POLL) || defined(CONFIG_XPS)
    	union {
    		unsigned int	napi_id;
    		unsigned int	sender_cpu;
    	};
    #endif
    
    	__u8		inner_ipproto;
    	__u16			inner_transport_header;
    	__u16			inner_network_header;
    	__u16			inner_mac_header;
    
    	__be16			protocol;
    	__u16			transport_header;
    	__u16			network_header;
    	__u16			mac_header;
    
    	sk_buff_data_t		tail;
    	sk_buff_data_t		end;
    	unsigned char		*head,
    				*data;
    	unsigned int		truesize;
    
    };

    Драйвера отвечают за реализацию канального уровня (разрешение MAC-адресов) и предоставление интерфейса между системными вызовами ядра и сетевой картой. Обработка входящих и исходящих пакетов происходят с помощью функций xmit и rx, от одновременного доступа они защищены спин блокировками, как и обновление статистики stats и изменение параметров передачи. Сам интерфейс определяется структурой net_device, для создания и регистрации вызываются функции alloc_netdev и register_netdev.

    Важные поля net_device:

    struct net_device {
    	char			name[IFNAMSIZ];    // Строка в стиле printf
    
    	unsigned long		mem_end;
    	unsigned long		mem_start;
    	unsigned long		base_addr;
    
    	unsigned long		state;
    
    	struct list_head	dev_list;
    	struct list_head	napi_list;
    
    	unsigned int		flags;
    	unsigned int		priv_flags;
    	const struct net_device_ops *netdev_ops;
    	unsigned short		hard_header_len;
    
    	unsigned int		mtu;
    
    	struct net_device_stats	stats; 
    
    	atomic_long_t		rx_dropped;
    	atomic_long_t		tx_dropped;
    	atomic_long_t		rx_nohandler;
    
    	const struct ethtool_ops *ethtool_ops;
    
    	const struct header_ops *header_ops;
    
    	unsigned char		if_port;
    	unsigned char		dma;
    
    	/* Interface address info. */
    	unsigned char		perm_addr[MAX_ADDR_LEN];
    
    	unsigned short          dev_id;
    	unsigned short          dev_port;
    
    	spinlock_t		addr_list_lock;
    	int			irq;
    	
    	unsigned char		*dev_addr;
    
    	struct netdev_rx_queue	*_rx;
    	unsigned int		num_rx_queues;
    
    	struct netdev_queue	*_tx ____cacheline_aligned_in_smp;
    	unsigned int		num_tx_queues;
    
    	struct timer_list	watchdog_timer;
    	int			watchdog_timeo;
    };

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

    Схематичные действия в обработчике прерываний для очистки очереди входящих пакетов: (драйвер intel Ethernet e1000):

    static bool e1000_clean_rx_irq(struct e1000_adapter *adapter,  // Сетевое устройство
    			       struct e1000_rx_ring *rx_ring, // Очередь входящих пакетов
    			       int *work_done, int work_to_do)
    {
    	while (rx_desc->status & E1000_RXD_STAT_DD) {
    		struct sk_buff *skb;
    		u8 *data;
    		u8 status;
    
            if (netdev->features & NETIF_F_RXALL) {
    		    total_rx_bytes += (length - 4); 
    		    total_rx_packets++;
    
    		    e1000_receive_skb(adapter, status, rx_desc->special, skb);
    		} 
        }
    
    	if (cleaned_count)    // Создание нового буфера
    		adapter->alloc_rx_buf(adapter, rx_ring, cleaned_count);
    		
        // Обновление статистики
    	adapter->total_rx_packets += total_rx_packets;
    	adapter->total_rx_bytes += total_rx_bytes;
    	netdev->stats.rx_bytes += total_rx_bytes;
    	netdev->stats.rx_packets += total_rx_packets;
    	return cleaned;
    }

    До ядер версии 2.3 после самого обработчика прерывания (top half) для выполнения основных задач использовались нижние половины (bottom half) и очереди задач (task queue). Начиная с версии 2.3 на замену интерфейсу BH пришли отложенные прерывания (softirq), тасклеты (tasklet) и очереди отложенных действий (work queue). Преимущество softirq в том, что они могут одновременно выполняться на разных процессорах. Они напрямую используются в сетевой подсистеме.

    Немного о NAPI

    Пока сетевой трафик был умеренным, механизм прерываний при получении пакета эффективно справлялся со своей задачей. С ростом трафика и появлением высоконагруженных систем постоянная обработка прерываний стала приводить к нехватке процессорного времени для пользовательских программ и потере пакетов. Решение проблемы было предложено в 2001 году и появилось в виде интерфейса New API в ядрах серии 2.4. (В оригинальной статье – результаты тестирования для SMP-системы, генератор трафика наподобие pktgen).

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

    В NAPI-совместимых драйверах прерывания отключаются, когда на интерфейс приходит пакет. Обработчик в этом случае только вызывает rx_schedule, гарантирующий, что обработка пакетов произойдет в дальнейшем. Когда приходящие пакеты заполняют буфер (предельное количество – budget), для обработки вызывается метод dev->poll. Метод poll будет вызываться одновременно не более, чем на одном процессоре, что упрощает синхронизацию. Если нагрузка падает, снова разрешаются прерывания. Это позволяет динамически регулировать производительность в зависимости от нагрузки интерфейса. Метод poll может использоваться также и для передачи пакетов.

    Пример poll из драйвера e1000:

    static void e1000_netpoll(struct net_device *netdev)
    {
    	struct e1000_adapter *adapter = netdev_priv(netdev);
    
    	if (disable_hardirq(adapter->pdev->irq))
    		e1000_intr(adapter->pdev->irq, netdev);
    	enable_irq(adapter->pdev->irq);
    }

    При реализации NAPI-совместимого драйвера должны быть выполнены некоторые требования:

    • Возможность хранения входящих пакетов в кольце DMA или буфере в самой карте

    • Возможность отключить прерывания

    • В методе poll должна быть реализована возможность забрать несколько пакетов за раз

    • Так как метод poll работает в контексте softirq и управляется демоном ksoftirqd, в системах с высокой загрузкой нужно менять приоритет поллинга для обеспечения баланса ресурсов между обработчиком прерываний и пользовательскими программами.

    Недостатки NAPI:

    • В некоторых случаях в системе могуть быть задержки, если весь обработчик прерываний помещен в dev->poll

    • Маскировка прерываний может быть медленной

    • Возможно состояние IRQ-гонки, если пакет приходит во время проверки бита наличия новых пакетов и включения прерываний.

    Что нового у NAPI в 5.12?

    В серии патчей в ядре 5.12 метод poll из softirq контекста перенесен в поток ядра.

    Wei Wang в комментарии к патчу рассказывает, что причина такого решения – отсутствие возможности отследить программные прерывания в системе. Планировщик не может измерить время, затрачиваемое на обработку softirq. Поток ядра же видим для планировщика задач CPU, это позволит избежать перегрузки процессора, на котором он работает, и сделать планирование userspace-процессов более детерминированным. Его проще контролировать системному администратору. Kthread можно связать с определенной группой CPU, чтобы явно отделить пользовательские потоки от процессоров, опрашивающих сетевые интерфейсы.

    Изменения затронули в основном net/core/dev.c. Обновлен метод __napi_poll, вызываемый из контекста napi_poll. Появился новый sysfs атрибут в net_device для включения/выключения поточного режима опроса для всех экземпляров napi данного сетевого устройства без необходимости вызова up/down.

    В napi_struct добавлено поле threaded для реализации опроса внутри потока, причем для включения поддержки потоков после создания kthread нужно вызвать napi_set_threaded (флаг NAPI_STATE_THREADED).

    Обновленная структура napi_struct:

    struct napi_struct {
            struct list_head        dev_list;
            struct hlist_node       napi_hash_node;
            unsigned int            napi_id;
            struct task_struct      *thread;
     };

    Создание потока ядра:

    static int napi_kthread_create(struct napi_struct *n)
    {       int err = 0;
    
           /* Create and wake up the kthread once to put it in
            * TASK_INTERRUPTIBLE mode to avoid the blocked task
            * warning and work with loadavg.
            */
           n->thread = kthread_run(napi_threaded_poll, n, "napi/%s-%d",
                                   n->dev->name, n->napi_id);
           if (IS_ERR(n->thread)) {
                   err = PTR_ERR(n->thread);
                   pr_err("kthread_run failed with err %d\n", err);
                   n->thread = NULL;
           }
    
           return err;
    }

    В связи с добавлением поточности появился новый метод napi_thread_wait.

    Wei Wang получил следующие результаты сравнения эффективности softirq, kthread и очередей отложенных действий:

    Основные источники - LDD3 и статьи:

    NAPI polling in kernel threads
    Threadable NAPI polling, softirqs, and proper fixes
    Reworking NAPI
    Driver porting: Network drivers

    Заранее спасибо за уточнения и указания на ошибки!

    Комментарии 10

      0

      Что-то слишком просто. А где тут всякие gso/lro/ntuple?

        0
        amarao, ethtools не поместился, хотелось рассказать максимально кратко. Буду дальше над этой темой работать
          +1

          Где-то я слышал, что из-за всяких хуков на хардварный оффлоадинг, код получился настолько медленным, что тестовый сетевой драйвер без hw offload оказался такой же быстрый, но много проще.

        0

        Есть ли вообще смысл использовать ядро, если мы говорим о высокой производительности? Или надо уходить в bypass. Вот например хороший текст об этом от cloudflare https://blog.cloudflare.com/kernel-bypass/

          0
          acmnu, спасибо, про bypass не слышал, прочитаю
            0
            Байпас мнее безопасный, хотя есть решения и в эту сторону: www.usenix.org/system/files/conference/osdi14/osdi14-paper-belay.pdf.

            Еще с байпасом надо писать/адаптировать/поддерживать свой стек (это, конечно, не RDMA, но все равно дополнительная работа).

            Но байпас хорош, когда надо сильно оптимизировать стек, и иногда выше, чем транспорт. Замечательная статья: www.usenix.org/conference/nsdi19/presentation/kalia на тему запуска RPC стека в байпасе, средняя раунд-трип 2.3 us (по сути, пракический потолок по PCIe и ToR сети)! Но опять же, очень очень много вопросов по безопасности. Этот результат они получили через raw драйвер, т.е. даже байпася DPDK, напярмую кидая пакеты на NIC ;)
              0

              Еще с байпасом надо писать/адаптировать/поддерживать свой стек 

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

                0
                Ага, есть, очень много — в этом и проблема) Кстати, они могут работать на в принципе, любой карточке которая поддерживает их базовый фреймворк (e.g. DPDK, snabb). Тут все зависит от того, что мы хотим поднять на нем. В случае TCP — все не просто. TCP очень сложный и «mature» и его реализация в Linux Kernel шибко нетривиальная и весьма хорошо отлажена со временем. Готовые байпас TCP стеки обычно реализуют определенное подмножество TCP, и не на столько хорошо отлажены [ medium.com/@penberg/on-kernel-bypass-networking-and-programmable-packet-processing-799609b06898 ]. Плюс люди обычно спускаются до байпаса, когда хотят получить высокую (и стабильную) производительность при высокой нагрузке. В таких случаях, не всегда удается этого достичь только с байпасом, нужно и стек подкрутить. В общем, на практике, с байпасом больше возни получается. Фактичски, тут нет стандарта [ medium.com/@penberg/on-kernel-bypass-networking-and-programmable-packet-processing-799609b06898 ], есть много разных реализаций со своими упрощениям и багами, и все это не шибко хорошо поддерживается.
            0
            Метод poll может использоваться также и для передачи пакетов.
            А как именно работает poll на передачу? На передаче, нужно кидать данные в кольцо, doorbell'ить NIC и тогда он сделает DMA и заберет данные с кольца. Если можно, было бы интересно узнать больше как тут работает пулинг, есть ссылочки, плиз?
              0
              Все, понял! Пулинг Completion Queue, видимо, имеется ввиду в статье. Тема неплохо раскрыта тут: arxiv.org/pdf/2002.02563.pdf. Извеняюсь за лишнее беспокойство.

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

            Самое читаемое