DPDK — EAL 环境抽象层

目录

EAL 环境适配层

EAL(Environment Abstraction Layer,环境抽象层)对 DPDK 的运行环境(e.g. Linux 操作系统)进行初始化,并为上层应用(用户态 DPDK App)提供了一个通用接口,隐藏了与底层库与设备打交道的相关细节。EAL 主要实现了 DPDK 运行的初始化工作,包括:HugePage 内存分配、NUMA 亲和性、CPU 绑定、Memory 划分、Buffer 划分、Ring 队列分配、原子性无锁操作等,并通过 UIO 或 VFIO 技术将 PCI/PCIe 设备地址映射到用户空间,方便了用户态的 DPDK App 调用。

  • DPDK App 的加载和启动:将 DPDK App 和 DPDK Lib 链接成一个独立的进程,并以指定的方式加载。
  • NUMA 亲和性与 CPU 绑定:将 DPDK App 的执行单元(进程、线程)绑定到特定的 Core 上。
  • 内存分配:EAL 实现了不同区域的内存分配,例如:为 PCI 设备接口提供了物理内存。
  • PCI 地址(BAR)抽象:EAL 提供了对 PCI 地址空间的访问接口。
  • 跟踪调试功能(Debug):日志信息,堆栈打印、异常挂起等等。
  • 通用功能:提供了标准 libc,不提供的自旋锁、原子计数器等。
  • CPU Feature 标识功能:用于决定 CPU 运行时的一些特殊功能,决定当前 CPU 所支持的特性,以便编译对应的二进制文件。
  • 中断处理:提供接口作为中断注册/解注册的回调函数。
  • 告警功能:提供接口用于设置特定环境下的告警。

Linux 环境下的 EAL

在 Linux 用户空间中,DPDK App 通过 pthread 库作为一个用户态的进程运行。PCI 设备的信息和 BAR 地址空间通过 /sys 内核接口及内核模块,例如:uio_pci_generic 或 igb_uio 来发现并注册的。

正如 Linux 内核文档中对 UIO 的描述,PCI 设备的存储器空间信息是通过 mmap 重新映射到用户态的,相对于传统的 read/write 调用少了一次数据拷贝(内核缓存 => 用户态缓冲)的过程。EAL 通过对 hugetlb 使用 mmap 接口来实现 hugetlbfs 文件系统的空间映射到用户进程的虚拟内存地址空间。这部分内存暴露给 DPDK 服务层,如:Mempool Library。最后,DPDK 通过设置 CPU 绑定和 NUMA 亲和性调用,将每个执行单元(进程、线程)分配给特定的逻辑核,以 User-level 等级运行。

另外,DPDK 的定时器是通过 CPU 的 TSC(时间戳计数器)或者通过 mmap 调用内核的 HPET 系统接口来实现的。

DPDK App 的初始化和运行

DPDK App 的初始化从 glibc 的 start() 开始执行,检查也在初始化过程中被执行,用于保证配置文件所选择的处理器架构宏定义是当前 CPU 所支持的,然后才开始调用 DPDK App 的 main()。Master/logic Core 的初始化和运行时在 rte_eal_init() 上执行的,包括:对 pthread 库的调用。初始化流程如下图所示:

在这里插入图片描述

rte_eal_init() 的初始化,包括:内存区间、Ring、内存池、lPM 表或 HASH 表等,必须作为整个 DPDK App 初始化的一部分,在 Master Core 上完成。创建和初始化这些对象的函数不是多线程安全的,但是,一旦初始化完成后,这些对象本身是线程安全的。

  • 多进程支持:Linux 上运行的 DPDK App 支持多进程运行模式。

  • 内存分配:连续的物理内存分配是通过 hugetlbfs 内核文件系统来实现的。 EAL 提供了相应的接口(函数)用于申请指定名字的、连续的内存空间。 这个接口同时会将这段连续空间的地址返回给用户程序。内存申请是使用 rte_malloc() 接口来完成的,是 hugetlbfs 文件系统支持的调用。

  • PCI 设备访问:EAL 使用了 Linux 内核提供的文件系统 /sys/bus/pci 来扫描 PCI Bus(总线)上的内容。通过 uio_pci_generic(Linux 内核提供的原生 UIO 模块)或 igb_uio(DPDK 提供的 UIO 内核模块)提供的 /dev/uioX 设备文件,以及 /sys 下对应的资源文件(resource0…N)用于访问 PCI 设备。

  • 逻辑核与共享内存(变量):逻辑核就是处理器的逻辑单元,即:线程(Thread),线程之间的交互默认通过共享内存(变量)来完成。共享内存是 Linux 上的一种 IPC(Inter-Process Communication,进程间通信)技术,提供了进程间通信的方法。实际上是通过线程局部存储技术 TLS 来实现的,它提供了每个线程访问本地存储的功能。

  • 日志:EAL 提供了日志信息接口。 默认的,Linux 的应用程序(包括 DPDK App)的日志信息被发送到 syslog 中或打印到 concole 上。DPDK App 也支持用户可以通过使用不同的日志机制来代替上述方式。

  • 跟踪与调试功能(DEBUG):Glibc 提供了一些调试函数用于打印应用程序的堆栈信息。rte_panic() 可以产生一个 SIG_ABORT 信号,这个信号可以触发产生用于 GBD 调试的 core 文件,所以,我们可以通过 gdb 指令来加载并调试 DPDK App。

  • CPU Feature 识别:EAL 提供了 rte_cpu_get_feature() 接口来查询 CPU 的状态信息,包括 CPU 的特征信息,以此来决定 DPDK App 是否可以在该 CPU 上运行。

  • PCI 设备的黑白名单:EAL 的 PCI 设备黑名单功能用于让 DPDK 忽略指定的 NIC 端口,使用 PCI 设备的地址描述符(Domain:Bus:Device:Function)来对端口进行标记。

  • MISC 功能:包括锁和原子操作(x86 架构)。

内存分配

在 Linux 中,所有的物理内存都通过一个内存描述符表进行管理,且每个描述符指向一块连续的物理内存。通常,物理内存区块之间很可能是不连续的,所以 DPDK 内存区块分配器的作用就是保证分配到一块连续的物理内存。内存分配可以从指定的地址开始,也使用使用对齐的方式来进行分配(默认是 Cache Line 大小对齐),对齐一般是以 2 的次幂来进行的,并且不小于 64 字节对齐。

实际上,连续的物理内存分配是通过 hugetlbfs 内核文件系统来实现的。 EAL 提供了相应的接口(函数)用于申请指定名字的、连续的内存空间。 这个接口同时会将这段连续空间的地址返回给用户程序。内存申请是使用 rte_malloc() 接口来完成的,是 hugetlbfs 文件系统支持的调用。所以,内存区块可以是 2M 或是 1G 大小的内存页。

这些内存区块会使用一个名称进行唯一标识,通过名字访问一个内存区块会返回对应内存区块的描述符。rte_memzone 描述符也存在 DPDK 的配置结构体中,通过 rte_eal_get_configuration() 接口来获取。

注意,通常的,rte_malloc() 内存分配不应该在数据面处理逻辑中进行,因为相对于基于池(Mempool 库)的分配速度要慢,并且在分配和释放的过程中也使用了锁操作。所以 rte_malloc() 内存分配通常在控制逻辑的配置代码中使用。

rte_malloc() 可以传入一个对齐参数,数值必须是 2 的次幂,表示分配对齐参数乘以倍数的内存区域。在 NUMA 多处理器系统中,默认的,对 rte_malloc() 的调用会从调用该函数的 Core 所在的 Socket 上分配内存。此外,DPDK 也提供了另一组 API,允许在指定的 NUMA node 上显式的分配内存。

Malloc 库内部使用了两种数据结构类型:

  1. struct malloc_heap:用于在每个 CPU Socket 上跟踪可用内存空间。
  2. struct malloc_elem:Malloc 库内部用于追踪分配和释放空间的基本要素。

Structure: malloc_heap

数据结构 malloc_heap 用于管理每个 Socket 上的可用内存空间

NOTE:malloc_heap 并不会跟踪已使用的内存块。

在 Malloc 库内部,每个 NUMA node 都有一个堆结构,这允许我们在线程运行的 NUMA node 上为线程分配内存,但这也只是一种具有优先级的 “弱限制” 而已。

malloc_heap 结构及其关键字段和功能描述如下:

  • lock:需要使用锁(Lock)来同步对堆的访问。例如:当使用链表来跟踪堆中的可用空间,我们就需要一个锁来防止多个线程同时处理该链表。
  • free_head:指向这个堆的空闲结点链表中的第一个元素。

在这里插入图片描述

Structure: malloc_elem

数据结构 malloc_elem 用作各种内存块的通用头结构。它以三种不同的使用方式:

  1. 作为一个释放/申请内存的头部(正常使用)
  2. 作为内存的内部填充(Pad)头部
  3. 作为内存的结尾标记

结构中重要的字段和使用方法如下所述:

  • heap:这个指针指向了该内存块从哪个堆申请。它被用于正常的内存块,当他们被释放时,将新释放的块添加到堆的空闲列表中。
  • prev:这个指针用于指向紧跟这当前 memseg 的头元素。当释放一个内存块时,该指针用于引用上一个内存块,检查上一个块是否也是空闲。如果空闲,则将两个空闲块合并成一个大块。
  • next_free:这个指针用于将空闲块列表连接在一起。它用于正常的内存块,在 malloc() 接口中用于找到一个合适的空闲块申请出来,在 free() 函数中用于将内存块添加到空闲链表。
  • state:该字段可以有三个可能值:FREE、BUSY 或 PAD。前两个是指示正常内存块的分配状态,后者用于指示元素结构是在块开始填充结束时的虚拟结构,即,由于对齐限制,块内的数据开始的地方不在块本身的开始处。在这种情况下,Pad 头用于定位块的实际 malloc 元素头。对于结尾的结构,这个字段总是 BUSY,它确保没有元素在释放之后搜索超过 memseg 的结尾以供其它块合并到更大的空闲块。
  • pad:这个字段为块开始处的填充长度。在正常块头部情况下,它被添加到头结构的结尾,以给出数据区的开始地址,即在 malloc 上传回的地址。在填充虚拟头部时,存储相同的值,并从虚拟头部的地址中减去实际块头部的地址。
  • size:数据块的大小,包括头部本身。对于结尾结构,这个大小需要指定为 0,虽然从未使用。对于正在释放的正常内存块,使用此大小值替代 next 指针,以标识下一个块的存储位置,在 FREE 情况下,可以合并两个空闲块。

申请内存

在 EAL 初始化时,所有 memseg 都被设置为 malloc_heap 的一部分。此设置包括在 BUSY 状态的末尾放置一个结构体,如果启用了 CONFIG_RTE_MALLOC_DEBUG,则该结构体可能包含一个 sentinel 成员,并为每个 memseg 在开始处放置一个具有 FREE 的 malloc_elem 头部。FREE 元素被添加到 malloc_heap 的空闲列表中。

当 DPDK App 调用 rte_malloc 时,rte_malloc 首先为调用线程索引 lcore_config 结构,并确定该线程的 NUMA node。NUMA node 将作为参数传给 heap_alloc(),用于索引 malloc_heap 数组。参与索引参数还有:大小、类型、对齐方式和边界参数等。

函数 heap_alloc() 将扫描堆的空闲链表,尝试找到一个适用于所请求的大小、对齐方式和边界约束的内存块。当已经识别出合适的空闲元素时,将计算要返回给用户的指针,并且在该指针之前的内存的高速缓存行填充一个 malloc_elem 头部。由于对齐和边界约束,在元素的开头和结尾可能会有空闲的空间,这将导致了下列行为:

  1. 检查尾随空间。如果尾部空间足够大,例如:>128 字节,那么空闲元素将被分割。否则,仅仅忽略它(浪费空间)。
  2. 检查元素开始处的空间。如果起始处的空间很小,例如:<=128 字节,那么使用填充头,这部分空间被浪费。但是,如果空间很大,那么空闲元素将被分割。

从现有元素的末尾分配内存的优点是:不需要调整空闲链表,空闲链表中现有元素仅调整大小指针,并且后面的元素使用 prev 指针重定向到新创建的元素位置。

释放内存

释放内存,将指向数据区开始的指针传递给 free() 函数。从该指针中减去 malloc_elem 结构的大小,以获得内存块的元素头部。如果这个头部类型是 PAD,那么进一步减去 pad 头部的长度,以获得整个块的正确元素头。

从这个元素头中,我们获得指向块所分配的堆的指针及必须被释放的位置,以及指向前一个元素的指针, 并且通过 size 字段,可以计算下一个元素的指针。这意味着我们永远不会有两个相邻的 FREE 内存块,因为他们总是会被合并成一个大的块。

多线程支持

在 DPDK 的术语中,lcore 描述 EAL thread,本质是一个 Linux/FreeBSD pthread。EAL pthreads 由 EAL 创建和管理,用于执行 rte_eal_remote_launch() 回调的任务(Task)函数。EAL pthread 从逻辑上又可以分为 Master、Slave 两种类型,前者做管理相关的,而后者是真正处理业务的线程。

每个 EAL pthread 都有一个 _lcore_id 作为其 TLS(Thread Local Storage,线程本地存储)的唯一标识,并且由于 EAL pthreads 通常和 CPU 是 1:1 的绑定关系,所以 lcore_id 通常就是 CPU 的 ID。但是,当使用多个 pthread 时,EAL pthread 和物理 CPU 之间的绑定关系就未必总是 1:1 的了。EAL pthread 也有可能与一个 CPU 集合关联,这是的 lcore_id 将与 CPU ID 不同。

我们可以通过 DPDK App 的命令行参数 --lcores 进行设定:

-–lcores=’<lcore_set>[@cpu_set][,<lcore_set>[@cpu_set],...]

DPDK 为线程操作提供了两个接口 rte_thread_set_affinity() 和 rte_pthread_get_affinity()。当他们在线程上下文中被调用时,将获取或设置线程 TLS,包括 _cpuset 和 _socket_id:

  • _cpuset:存储了与线程相关联的 CPU 位图(BitMap)。
  • _socket_id:存储了 CPU set 所在的 NUMA node。如果 CPU set 中的 CPU 属于不同的 NUMA 节点,_socket_id 将设置为 SOCKET_ID_ANY。

DPDK App 通常会进行 CPU 绑核以避免切换开销。这显然是有利于性能提升的,但同时也会缺乏灵活性。

从性能的角度出发,我们应该从操作系统层面为 DPDK App 隔离出专用的 CPU,这是基于 Linux 的 cgroup 来实现的。以下是 cgroup 的简单示例:在同一个 CPU 上两个线程 t0、t1 用于执行数据包 I/O,并期望只有 50% 的 CPU 消耗在数据包 IO 操作上。

mkdir /sys/fs/cgroup/cpu/pkt_io
mkdir /sys/fs/cgroup/cpuset/pkt_io

echo $cpu > /sys/fs/cgroup/cpuset/cpuset.cpus

echo $t0 > /sys/fs/cgroup/cpu/pkt_io/tasks
echo $t0 > /sys/fs/cgroup/cpuset/pkt_io/tasks

echo $t1 > /sys/fs/cgroup/cpu/pkt_io/tasks
echo $t1 > /sys/fs/cgroup/cpuset/pkt_io/tasks

cd /sys/fs/cgroup/cpu/pkt_io
echo 100000 > pkt_io/cpu.cfs_period_us
echo  50000 > pkt_io/cpu.cfs_quota_us

同时,也可以为 DPDK App 的每个线程或进程绑定一个核心。甚至还可以通过对 BIOS 的电源管理进行设置,让 CPU 处于高性能的工作效率,不过这也会带来 CPU 的损耗以及电费成本的提高。所以用户应该根据自己的实际需求来对 DPDK App 的性能进行优化。

从灵活性的角度出发,用户可以设置 DPDK App 的线程 CPU 亲和性指向一个 CPU 集合而不是绑定到某个单一的 CPU 了。例如:

--lcores='(0,4,5)@2,1@3,2@4,3@5'

# lcores 0,4,5 绑定在 CPU2
# lcore 1 绑定在 CPU3
# lcore 2 绑定在 CPU4
# lcore 3 绑定在 CPU5

用户态中断处理

  1. 主线程的用户态中断和警告处理:EAL 会创建一个主线程(Master Core)用于轮询 UIO 设备的描述文件以检测中断事件,通过 EAL 提供的函数可以为特定的中断事件注册/解注册一个回调函数(中断处理函数),回调函数在主线程中被异步调用。当然,EAL 也支持像物理网卡中断那样定时的调用回调函数。

需要注意的是,DPDK 实现了基于轮询方式的 PMD(Poll Mode Drivers)网卡驱动,内核态的 UIO Driver(e.g. igb_uio)屏蔽了网卡发出的中断信号,然后由用户态的 PMD Driver 采用主动轮询的方式。所以 DPDK App 的用户态中断处理区别于传统物理网卡的软硬中断。在 DPDK 的 PMD 中,主线程只会对链路状态的改变触发的中断进行处理,例如:网卡的打开和关闭。除了链路状态通知仍必须采用中断方式以外,均使用无中断方式直接操作 PCI 网卡设备的接收和发送队列。这与传统的内核协议栈每接受一个数据包都要触发一次中断完全不同(先抛开 NAPI 不谈)。

  1. RX 中断事件:PMD 提供的报文收发程序并不只限制于单存的轮询机制。为了缓解小吞吐量场景中轮询模式对 CPU 资源的浪费,所以,PMD 还实现了 “暂停轮询并等待唤醒事件” 的设计,即 Interrupt DPDK(中断 DPDK)模式。

Interrupt DPDK 的原理和 NAPI 很像,就是 PMD 在没数据包需要处理时自动进入睡眠,改为中断通知,接收到收包中断信号后,激活主动轮询。这就是所谓的链路状态中断通知。并且 Interrupt DPDK 还可以和其他进程共享一个 CPU Core,但 DPDK 进程仍具有更高的调度优先级。

在这里插入图片描述

EAL 提供了这种事件驱动模式相关的 rte_eth_dev_rx_intr_* 接口,来实现控制、使能、关闭。当 PMD 不支持 RX 中断时,这些 API 会返回失败。Intr_conf.rxq 标识用于打开每个设备的 RX 中断。

以 Linux 上运行的 DPDK App 为例,其实现依赖于 epoll 技术。每个线程可以监控一个 epoll 实例,而在实例中可以添加所有需要的 wake-up 事件的文件描述符。事件文件描述符创建并根据 UIO/VFIO 的规范来映射到指定的中断向量上。EAL 初始化过程中,完成了中断向量和事件文件描述符之间的映射关系,同时为每个 PCI 设备初始化中断向量和队列之间的映射关系。这样一来,EAL 实际上并不知道在指定向量上发生的中断,这是由设备驱动来负责执行后面的映射的。

展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 编程工作室 设计师: CSDN官方博客
应支付0元
点击重新获取
扫码支付

支付成功即可阅读