写在前面
本系列记录了作者在项目过程中由于好奇心驱使而了解到的部分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
进程可以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
,在一个运行在多个lcore
的DPDK
应用中,启动线程运行的lcore
是master lcore
,其余都是slave lcore
,master 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_ring
和rte_mempool
。
先将一般情况下,三者之间的关系画出来
rte_memzone
rte_memzone
在DPDK
的内存资源管理中起到的是其他资源管家的作用,默认情况下,在DPDK
初始化时会创建RTE_MAX_MEMZONE
个rte_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
每个 core 将 ring->proc_head 拷贝到本地 proc_head ,再将 proc_next 设置为下一个位置
Step2
尝试修改 ring->proc_head 为 proc_next 的值,这一步用到了Compare And Swap指令来保证原子性, 这里,只有当 ring->proc_head 与 proc_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_ring
和 rte_mempool
是两个独立的数据结构,但如同上面的关系图中描述的,一般的 rte_mempool
会内置一个rte_ring
用来管理 rte_mempool
中的元素,我认为这正是rte_ring
中存储的是指针的原因,它指向的内容就是rte_mempool
种内容。
Local Cache
多核场景下,如果两个线程向同一个rte_mempool
申请或释放内存,势必引起对rte_ring
的CAS
操作失败,因此DPDK
允许用户在创建rte_mempool
时为每个lcore
创建缓存,缓存同rte_ring
一样存储的是指针。
所以对于有缓存的的rte_mempool
,它在内存中的布局如下:
官方文档中,带 Cache 的rte_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_ring
中 ring->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);
}