linux源码解读(三十二):dpdk原理概述(一)

    1、操作系统、计算机网络诞生已经几十年了,部分功能不再能满足现在的业务需求。如果对操作系统做更改,成本非常高,所以部分问题是在应用层想办法解决的,比如前面介绍的协程、quic等,都是在应用层重新开发的框架,简单回顾如下:

  • 协程:server多线程通信时,如果每连接一个客户端就要生成一个线程去处理,对server硬件资源消耗极大!为了解决多线程以及互相切换带来的性能损耗,应用层发明了协程框架:单线程人为控制跳转到不同的代码块执行,避免了cpu浪费、线程锁/切换等一系列耗时的问题!
  • quic协议:tcp协议已经深度嵌入了操作系统,更改起来难度很大,所以同样也是在应用层基于udp协议实现了tls、拥塞控制等,彻底让协议和操作系统松耦合!

   除了上述问题,操作还有另一个比较严重的问题:基于os内核的网络数据IO!传统做网络开发时,接收和发送数据用的是操作系统提供的receive和send函数,用户配置一下网络参数、传入应用层的数据即可!操作系统由于集成了协议栈,会在用户传输的应用层数据前面加上协议不同层级的包头,然后通过网卡发送数据;接收到的数据处理方式类似,按照协议类型一层一层拨开,直到获取到应用层的数据!整个流程大致如下:

  网卡接受数据----->发出硬件中断通知cpu来取数据----->os把数据复制到内存并启动内核线程
         --->软件中断--->内核线程在协议栈中处理包--->处理完毕通知用户层

  大家有没有觉得这个链条忒长啊?这么长的处理流程带来的问题:

  • “中间商”多,整个流程耗时;数据进入下一个环节时容易cache miss
  • 同一份数据在内存不同的地方存储(缓存内存、内核内存、用户空间的内存),浪费内存
  • 网卡通过中断通知cpu,每次硬中断大约消耗100微秒,这还不算因为终止上下文所带来的Cache Miss(L1、L2、TLB等cpu的cache可能都会更新)
  • 用户到内核态的上下文切换耗时
  • 数据在内核态用户态之间切换拷贝带来大量CPU消耗,全局锁竞争
  • 内核工作在多核上,为保障全局一致,即使采用Lock Free,也避免不了锁总线、内存屏障带来的性能损耗

     这一系列的问题都是内核处理网卡接收到的数据导致的。大胆一点想象:如果不让内核处理网卡数据了?能不能避免上述各个环节的损耗了?能不能让3环的应用直接控制网卡收发数据了?

       2、如果真的通过3环应用层直接读写网卡,面临的问题:

  •    用户空间的内存要映射到网卡,才能直接读写网卡
  •    驱动要运行在用户空间

     (1)这两个问题是怎么解决的了?这一切都得益于linux提供的UIO机制! UIO 能够拦截中断,并重设中断回调行为(相当于hook了,这个功能还是要在内核实现的,因为硬件中断只能在内核处理),从而绕过内核协议栈后续的处理流程。这里借用别人的一张图:

         

   UIO 设备的实现机制其实是对用户空间暴露文件接口,比如当注册一个 UIO 设备 uioX,就会出现文件 /dev/uioX(用于读取中断,底层还是要在内核处理,因为硬件中断只能发生在内核),对该文件的读写就是对设备内存的读写(通过mmap实现)。除此之外,对设备的控制还可以通过 /sys/class/uio 下的各个文件的读写来完成。所以UIO的本质:

  •  让用户空间的程序拦截内核的中断,更改中断的handler处理函数,让用户空间的程序第一时间拿到刚从网卡接收到的“一手、热乎”数据,减少内核的数据处理流程!由于应用程序拿到的是网络链路层(也就是第二层)的数据,这就需要应用程序自己按照协议解析数据了!说个额外的:这个功能可以用来抓包

  简化后的示意图如下:原本网卡是由操作系统内核接管的,现在直接由3环的dpdk应用控制了!

     

   这就是dpdk的第一个优点;除了这个,还有以下几个:

   (2)Huge Page 大页:传统页面大小是4Kb,如果进程要使用64G内存,则64G/4KB=16000000(一千六百万)页,所有在页表项中占用16000000 * 4B=62MB;但是TLB缓存的空间是有限的,不可能存储这么多页面的地址映射关系,所以可能导致TLB miss;如果改成2MB的huge Page,所需页面减少到64G/2MB=2000个。在TLB容量有限的情况下尽可能地多在TLB存放地址映射,极大减少了TLB miss!下图是采用不同大小页面时TLB能覆盖的内存对比!

        

   (3)mempool 内存池:任何网络协议都要处理报文,这些报文肯定是存放在内存的!申请和释放内存就需要调用malloc和free函数了!这两个是系统调用,涉及到上下文切换;同时还要用buddy或slab算法查找空闲内存块,效率较低!dpdk 在用户空间实现了一套精巧的内存池技术,内核空间和用户空间的内存交互不进行拷贝,只做控制权转移。当收发数据包时,就减少了内存拷贝的开销!

 (4)Ring 无锁环:多线程/多进程之间互斥,传统的方式就是上锁!但是dpdk基于 Linux 内核的无锁环形缓冲 kfifo 实现了自己的一套无锁机制,支持多消费者或单消费者出队、多生产者或单生产者入队;

   (5)PMD poll-mode网卡驱动:网络IO监听有两种方式,分别是

  • 事件驱动,比如epoll:这种方式进程让出cpu后等数据;一旦有了数据,网卡通过中断通知操作系统,然后唤醒进程继续执行!这种方式适合于接收的数据量不大,但实时性要求高的场景;
  • 轮询,比如poll:本质就是用死循环不停的检查内存有没有数据到来!这种方式适合于接收大块数据,实时性要求不高的场景;

  总的来说说:中断是外界强加给的信号,必须被动应对,而轮询则是应用程序主动地处理事情。前者最大的影响就是打断系统当前工作的连续性,而后者则不会,事务的安排自在掌握!

  dpdk采用第二种轮询方式:直接用死循环不停的地检查网卡内存,带来了零拷贝、无系统调用的好处,同时避免了网卡硬件中断带来的上下文切换(理论上会消耗300个时钟周期)、cache miss、硬中断执行等损耗

     (6)NUMA:dpdk 内存分配上通过 proc 提供的内存信息,使 CPU 核心尽量使用靠近其所在节点的内存,避免了跨 NUMA 节点远程访问内存的性能问题;其软件架构去中心化,尽量避免全局共享,带来全局竞争,失去横向扩展的能力

  (7)CPU 亲和性: dpdk 利用 CPU 的亲和性将一个线程或多个线程绑定到一个或多个 CPU 上,这样在线程执行过程中,就不会被随意调度,一方面减少了线程间的频繁切换带来的开销,另一方面避免了 CPU L1、L2、TLB等缓存的局部失效性,增加了 CPU cache的命中率。

   3、一个简单的数据接收demo,主要是对网络中的数据进行一层一层解包:

int main(int argc, char *argv[])
{
    
    // 初始化环境,检查内存、CPU相关的设置,主要是巨页、端口的设置
    if (rte_eal_init(argc, argv) < 0)
    {
    
        rte_exit(EXIT_FAILURE, "Error with EAL init\n");
    }

    // 内存池初始化,发送和接收的数据都在内存池里
    struct rte_mempool *mbuf_pool = rte_pktmbuf_pool_create(
        "mbuf pool", NUM_MBUFS, 0, 0, RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id());
    if (NULL == mbuf_pool)
    {
    
        rte_exit(EXIT_FAILURE, "Could not create mbuf pool\n");
    }

    // 启动dpdk
    ng_init_port(mbuf_pool);

    while (1)
    {
    
        // 接收数据
        struct rte_mbuf *mbufs[BURST_SIZE];
        unsigned num_recvd = rte_eth_rx_burst(gDpdkPortId, 0, mbufs, BURST_SIZE);
        if (num_recvd > BURST_SIZE)
        {
    
            // 溢出
            rte_exit(EXIT_FAILURE, "Error receiving from eth\n");
        }

        // 对mbuf中的数据进行处理
        unsigned i = 0;
        for (i = 0; i < num_recvd; i++)
        {
    
            // 得到以太网中的数据
            struct rte_ether_hdr *ehdr = rte_pktmbuf_mtod(mbufs[i], struct rte_ether_hdr *);
            // 如果不是ip协议
            if (ehdr->ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4))
            {
    
                continue;
            }
            struct rte_ipv4_hdr *iphdr =
                rte_pktmbuf_mtod_offset(mbufs[i], struct rte_ipv4_hdr *, sizeof(struct rte_ether_hdr));

            // 接收udp的数据帧
            if (iphdr->next_proto_id == IPPROTO_UDP)
            {
    
                struct rte_udp_hdr *udphdr = (struct rte_udp_hdr *)(iphdr + 1);

                uint16_t length = ntohs(udphdr->dgram_len);
                // udp data copy to buff
                uint16_t udp_data_len = length - sizeof(struct rte_udp_hdr) + 1;
                char buff[udp_data_len];
                memset(buff, 0, udp_data_len);
                --udp_data_len;
                memcpy(buff, (udphdr + 1), udp_data_len);

                //源地址
                struct in_addr addr;
                addr.s_addr = iphdr->src_addr;
                printf("src: %s:%d, ", inet_ntoa(addr), ntohs(udphdr->src_port));

                //目的地址+数据长度+数据内容
                addr.s_addr = iphdr->dst_addr;
                printf("dst: %s:%d, %s\n",
                       inet_ntoa(addr), ntohs(udphdr->dst_port), buff);

                // 用完放回内存池
                rte_pktmbuf_free(mbufs[i]);
            }
        }
    }
}

 

 

 

 

参考:

1、https://cloud.tencent.com/developer/article/1198333  一文看懂dpdk

4、https://lwn.net/Articles/232575/  uio机制
5、https://chowdera.com/2021/12/202112162035343569.html dpdk示例
6、https://cloud.tencent.com/developer/article/1736535 dpdk内存池

posted @ 2022-03-21 21:52  第七子007  阅读(1457)  评论(0编辑  收藏  举报