Switch-Router

DPDK 实现的不完全笔记

Published at 2018-08-24 | Last Update 2018-08-24

写在前面

本系列记录了作者在项目过程中由于好奇心驱使而了解到的部分DPDK实现细节。比较适合有同样好奇心的DPDK初学者,通过本文

您可以学习到

  • DPDK的整体工作原理以及部分实现细节

您不能学习到

  • 应用DPDK进行性能调优

如果对DPDK的起源不是很清楚的话,可以先浏览下 绝对干货!初学者也能看懂的DPDK解析,重点就是Linux + x86网络IO瓶颈 这部分,总结一句话就是Linux内核协议栈太慢了,为了突破这种性能瓶颈,DPDK的方案是绕过(bypass)内核,直接从网卡把数据抓到用户空间。

一些基本的概念

EAL

首先必须明白的一点就是,DPDK是以若干个lib的形式提供给应用链接使用,其中最终要的一个lib就是EAL了,EAL的全称是(Environment Abstraction Layer, 环境抽象层),它负责为应用间接访问底层的资源,比如内存空间、线程、设备、定时器等。如果把我们的应用比作一个豪宅的主人的话,EAL就是这个豪宅的管家。

lcore & socket

这两个概念在 DPDK的代码中随处可见,注意这里的 socket 不是网络编程里面的那一套东西,而是CPU相关的东西。具体的概念可以参看Differences between physical CPU vs logical CPU vs Core vs Thread vs Socket 或者其翻译版本physical CPU vs logical CPU vs Core vs Thread vs Socket(翻译)

对我们来说,只要知道可以DPDK可以运行在多个lcore上就足够了.

DPDK 如何知道有多少个lcore呢 ? 在启动时解析文件系统中的特定文件就可以了, 参考函数eal_cpu_detected

DPDK的运行形式

大部分DPDK的代码是以lib的形式运行在用户应用的进程上下文.为了达到更高的性能。应用通常都会多进程或者多线程的形式运行在不同的lcore

多线程的场景:

多进程的场景:

多进程的场景下,多个应用实例如何保证关键信息(比如内存资源)的一致性呢? 答案是不同进程将公共的数据mmap同一个文件,这样任何一个进程对数据的修改都可以影响到其他进程。

Primary & Secondary

多进程场景下,进程有两种角色Primary或者Secondary,正如其名字,Primary进程可以create 资源,而Secondary进程只能 attach已存在的资源。一山不容二虎,一个多进程的应用,有且只有一个Primary进程,其余都是Secondary进程。应用可以通过命令行参数 –proc-type 来指定应用类型。

DPDK的入口

如同main函数在应用程序中的地位,rte_eal_init函数便是DPDK梦开始的地方(其实前面的图已经画出来了!),我们来看看它做了什么事。

/* Launch threads, called at application init(). */
int
rte_eal_init(int argc, char **argv)
{
	thread_id = pthread_self();

	rte_eal_cpu_init();

	eal_parse_args(argc, argv);

	rte_config_init();

    rte_mp_channel_init();
    
	rte_eal_intr_init();
	
	rte_eal_memzone_init();
	
	rte_eal_memory_init();

	rte_eal_malloc_heap_init()
	
	eal_thread_init_master(rte_config.master_lcore);

	RTE_LCORE_FOREACH_SLAVE(i) {
         
        pipe(lcore_config[i].pipe_master2slave);
        pipe(lcore_config[i].pipe_slave2pipe);
        
		/* create a thread for each lcore */
		ret = pthread_create(&lcore_config[i].thread_id, NULL,
				     eal_thread_loop, NULL);
	
		.....
	}

	/*
	 * Launch a dummy function on all slave lcores, so that master lcore
	 * knows they are all ready when this function returns.
	 */
	rte_eal_mp_remote_launch(sync_func, NULL, SKIP_MASTER);
	
	rte_eal_mp_wait_lcore();

	......
}  

rte_eal_init总结起来干的工作就是

  • 检测哪些lcore是可以使用的
  • 解析用户的命令行参数
  • 各个子模块初始化
  • 在所有slave lcore上启动线程

上面提到了一个概念是slave lcore,与之对应的是master lcore,在一个运行在多个lcoreDPDK应用中,启动线程运行的lcoremaster lcore,其余都是slave lcoremaster lcore和所有slave lcore之间通过pipe进行通信,拓扑上组成一个星型网络。

每个lcore的状态和配置记录在全局变量 lcore_config 中,这是一个数组,每个lcore只会访问自己的那一份

struct lcore_config lcore_config[RTE_MAX_LCORE]

多进程的情况稍微复杂一些,除了线程间的通信外,还要完成primary进程和其他secondary进程的通信。这是通过在 刚才那一堆子模块初始化中的下面函数完成的(mp表示multiple process),其内部会单独创建一个线程用来接收来自其他进程的消息。

int rte_mp_channel_init(void)

内存框架

DPDK要高速处理网络报文,报文需要内存来承载,所以DPDK自然免不了就是频繁的内存申请释放。显然,如果在需要内存时 malloc, 不需要时 free ,那么这个效率太低了。因此DPDK使用内存池来负责内存申请释放,相关的数据结构主要有rte_memzone rte_ringrte_mempool

先将一般情况下,三者之间的关系画出来

rte_memzone

rte_memzoneDPDK的内存资源管理中起到的是其他资源管家的作用,默认情况下,在DPDK初始化时会创建RTE_MAX_MEMZONErte_memzone,每一个都可以记录一个rte_ring或者rte_mempool的内存位置。从上面的图中也可以看到每一个rte_ring或者rte_mempool都有一个指针回指到它关联的rte_memzone

rte_ring

rte_ring描述了一个循环队列,它有以下特点

  • FIFO 先入先出
  • 队列的容量在创建之后是固定的,且一定是 2 的整数次幂
  • 队列中存储的是指针 (void*)
  • 支持单消费者和多消费者模型
  • 支持单生产者和多生产者模型
  • 支持批量存取

如上图所示,每个rte_ring内部包含了两对游标用以记录当前rte_ring的的存储状态,之所以用两对而不是两个的原因是一是为了支持多消费者模型和多生产者模型,二是为了支持批量存取。

这里仅以多生产者竞争下入队列的场景说明rte_ring是如何工作的,其中上面的方框表示两个 core 上的本地游标,下面的方框表示这个rte_ring内部记录的游标

注意:这里的每个 core 既适用于多线程也适用于多进程

Step1

每个 corering->proc_head 拷贝到本地 proc_head ,再将 proc_next 设置为下一个位置

Step2

尝试修改 ring->proc_headproc_next 的值,这一步用到了Compare And Swap指令来保证原子性, 这里,只有当 ring->proc_headproc_head 相等时这个操作才会成功,否则重新进行 Step1 。在本例子中,假设在 Core 1 上的操作成功了。在 Core 2 上操作时,由于 ring->proc_head 已经与本地的 proc_head 的不相等的了,所以不会成功,而是重新进行 Step1 的拷贝。

Step3

Core 2 上的操作成功,将内容(一个指针)写入 rte_ring

Step4

接下来就是要尝试更新 ring->proc_tail ,这一步同样用到了Compare And Swap,只有当 ring->proc_tail 与本地的 proc_tail 相同时才能成功,更新为本地的 proc_head 在本例中,显然只有在 Core 1 上才能成功。

Step5

最后, 再将 ring->proc_tail 更新为 Core 2 上的 proc_head

其他场景,如 单生产者 单消费者 多消费者的场景请参考

使用 rte_ring

对应用者来说,知道如何使用可能比知道其内部工作原理更有用。rte_ring主要接口有下面两个:

创建 rte_ring

struct rte_ring*
rte_ring_create(const char* name, unsigned count, int socket_id, unsigned  flags);

根据名字,查找已经创建的 rte_ring

struct rte_ring*
rte_ring_lookup(const char* name);

一般来说,可以在 master lcore 或者 primary process 上创建,在 slave lcore 或者 secondary process 上查找。

rte_ring存入一个指针(生产者)

int
rte_ring_enqueue(struct rte_ring* r, void* obj);

rte_ring取出一个指针(消费者)

int
rte_ring_dequeue(struct rte_ring* r, void **obj_p);

rte_mempool

rte_ring 只能存储一个指针,而 rte_ring 可以存储一定容量的其他大小元素的数据,但有一点要注意,这个元素大小同样在创建的时候就要指定,同样指定的还有容量。

虽然 rte_ringrte_mempool 是两个独立的数据结构,但如同上面的关系图中描述的,一般的 rte_mempool会内置一个rte_ring用来管理 rte_mempool中的元素,我认为这正是rte_ring中存储的是指针的原因,它指向的内容就是rte_mempool种内容。

Local Cache

多核场景下,如果两个线程向同一个rte_mempool申请或释放内存,势必引起对rte_ringCAS操作失败,因此DPDK允许用户在创建rte_mempool时为每个lcore创建缓存,缓存同rte_ring一样存储的是指针。

所以对于有缓存的的rte_mempool,它在内存中的布局如下:

官方文档中,带 Cacherte_mempool表示如下:

当一个应用想从rte_mempool申请内存时,他会首先尝试从 Cache 中看有没有为当前 lcore 预留的内存,如果有就直接使用就好了(这样不会有竞争),如果没有再去从rte_ring获取。

使用 rte_mempool

对应用程序来说,rte_mempool主要提供的接口有以下几个

创建一个标准的 rte_mempool

struct rte_mempool*
rte_mempool_create(const char *name, unsigned n, unsigned elt_size,
		   unsigned cache_size, unsigned private_data_size,
		   rte_mempool_ctor_t *mp_init, void *mp_init_arg,
		   rte_mempool_obj_cb_t *obj_init, void *obj_init_arg,
		   int socket_id, unsigned flags);

根据名字 查找一个rte_mempool.

struct rte_mempool*
rte_mempool_lookup(const char *name);

从内存池中获取一个对象(消费者)

int 
rte_mempool_get(struct rte_mempool* mp, void **obj_p);

向内存池归还一个对象

void
rte_mempool_put(struct rte_mempool* mpu, void* obj);

创建一个空的rte_mempool

struct rte_mempool*
rte_mempool_create_empty(const char *name, unsigned n, unsigned elt_size,
	unsigned cache_size, unsigned private_data_size,
	int socket_id, unsigned flags);

空的rte_mempool是指大部分数据结构的关系已经设置好,但这个rte_mempool还没有分配池中元素的内存,即用户是不能从空的rte_mempool得到内存,如果用GDB调试的话,可以看到当创建空的rte_mempool后,其内置的rte_ringring->proc_head = ring->proc_tail ,这时我们还需要使用下 rte_mempool_populate_*() 这类函数真正为内存池分配内存(这个过程称为 populate )。默认的接口如下:

int rte_mempool_populate_default(struct rte_mempool *mp);

所以其实创建非空的rte_mempool的大致实现是,先创建空的内存池,再为其中的元素向系统申请内存

struct rte_mempool *
rte_mempool_create(const char *name, unsigned n, unsigned elt_size,
	unsigned cache_size, unsigned private_data_size,
	rte_mempool_ctor_t *mp_init, void *mp_init_arg,
	rte_mempool_obj_cb_t *obj_init, void *obj_init_arg,
	int socket_id, unsigned flags)
{
    mp = rte_mempool_create_empty(name, n, elt_size, cache_size,
		 private_data_size, socket_id, flags);

   ...
   rte_mempool_populate_default(mp);
}