【DPDK】【Multiprocess】一个dpdk多进程场景的坑


【前言】

  理论上只要用到DPDK multiprocess场景的都会遇到这个问题,具体出不出问题只能说是看运气,即使不出问题也仍然是一个风险。

  patch地址:https://patches.dpdk.org/patch/64819/

  讨论的patch地址:https://patches.dpdk.org/patch/64526/

【场景】

  我先描述一下这个问题我是怎么撞到的吧。

  我司不同的产品线都不同程度的使用了DPDK作为网络IO加速的手段,我相信这也是所有使用DPDK人的初衷,并且我司不同的产品线在设计上有使用DPDK multiprocess场景实现业务逻辑。

  在我这边的情况是这样,用过DPDK的话都知道,DPDK会利用自己的igb_uio/vfio驱动来接管传统内核驱动,这样往往会导致一些问题,就是我们一些传统的类unix工具,诸如ifconfig、ip、ethtool等工具无法再查看被DPDK驱动接管的网卡状态。

  举个例子:

  在传统linux场景下,我向看一下网卡丢包原因、网卡寄存器状态、网卡的feature,通过一个ethtool就可以搞定,但是到了DPDK这里就行不通了,因为上述传统工具实际上都是去内核拿数据,ethtool底层就是用ioctl去读的内核数据,但是现在网卡驱动已经被DPDK驱动接管了,用ethtool再也拿不到信息了。

  因此我重新写了一个ethtool-dpdk,用来专门解决在dpdk场景下的查看网卡驱动状态。这个工具是以secondary进程实现的,每次运行,都会attach到primary进程中,去获取primary进程和secondary进程之间的share memory。其中就包括struct rte_eth_dev_data这个处在share memory的数据结构,通过获取这个结构中的pci bar,我就可以通过“基地址 + 寄存器偏移量”的手段去拿到寄存器状态。

/**
 * @internal
 * The data part, with no function pointers, associated with each ethernet device.
 *
 * This structure is safe to place in shared memory to be common among different (这个结构处于共享内存中)
 * processes in a multi-process configuration.
 */
struct rte_eth_dev_data {
    char name[RTE_ETH_NAME_MAX_LEN]; /**< Unique identifier name */

    void **rx_queues; /**< Array of pointers to RX queues. */
    void **tx_queues; /**< Array of pointers to TX queues. */
    uint16_t nb_rx_queues; /**< Number of RX queues. */
    uint16_t nb_tx_queues; /**< Number of TX queues. */

    struct rte_eth_dev_sriov sriov;    /**< SRIOV data */

    void *dev_private;              /**< PMD-specific private data */ //这个里面存着pci bar

    struct rte_eth_link dev_link;
    /**< Link-level information & status */

    struct rte_eth_conf dev_conf;   /**< Configuration applied to device. */
    uint16_t mtu;                   /**< Maximum Transmission Unit. */

    uint32_t min_rx_buf_size;
    /**< Common rx buffer size handled by all queues */

    uint64_t rx_mbuf_alloc_failed; /**< RX ring mbuf allocation failures. */
    struct ether_addr* mac_addrs;/**< Device Ethernet Link address. */
    uint64_t mac_pool_sel[ETH_NUM_RECEIVE_MAC_ADDR];
    /** bitmap array of associating Ethernet MAC addresses to pools */
    struct ether_addr* hash_mac_addrs;
    /** Device Ethernet MAC addresses of hash filtering. */
    uint8_t port_id;           /**< Device [external] port identifier. */
    __extension__
    uint8_t promiscuous   : 1, /**< RX promiscuous mode ON(1) / OFF(0). */
        scattered_rx : 1,  /**< RX of scattered packets is ON(1) / OFF(0) */
        all_multicast : 1, /**< RX all multicast mode ON(1) / OFF(0). */
        dev_started : 1,   /**< Device state: STARTED(1) / STOPPED(0). */
        lro         : 1;   /**< RX LRO is ON(1) / OFF(0) */
    uint8_t rx_queue_state[RTE_MAX_QUEUES_PER_PORT];
    /** Queues state: STARTED(1) / STOPPED(0) */
    uint8_t tx_queue_state[RTE_MAX_QUEUES_PER_PORT];
    /** Queues state: STARTED(1) / STOPPED(0) */
    uint32_t dev_flags; /**< Capabilities */ //请注意这个标记
    enum rte_kernel_driver kdrv;    /**< Kernel driver passthrough */
    int numa_node;  /**< NUMA node connection */
    struct rte_vlan_filter_conf vlan_filter_conf;
    /**< VLAN filter configuration. */
};

代码版本:

  代码来源于DPDK 17.08版本,但是此问题不局限于17.08版本,一直到19.11版本都存在,只是我在这个版本的dpdk代码踩到了这个坑,或者换句话说,这版本比较容易踩到这个坑。下列介绍凡是不特别提及,都为dpdk-17.08版本。

代码位置:

  DPDK 根目录/lib/librte_ether/rte_ethdev.h

【问题描述】

  但是偶然一次测试发现了问题。我们的设备原本是支持网卡热插拔的,但是在启动这个ethtool-dpdk工具后发现网卡的热插拔竟然失效了,primary去检查网卡热插拔的标记时,发现标记“消失了””

  标记所在的代码位置:

  DPDK 根目录/lib/librte_ether/rte_ethdev.h

/** Device supports hotplug detach */
#define RTE_ETH_DEV_DETACHABLE   0x0001 //网卡热插拔标记
/** Device supports link state interrupt */
#define RTE_ETH_DEV_INTR_LSC     0x0002 //网卡LSC中断标记
/** Device is a bonded slave */
#define RTE_ETH_DEV_BONDED_SLAVE 0x0004
/** Device supports device removal interrupt */
#define RTE_ETH_DEV_INTR_RMV     0x0008

  这些标记时用于给struct rte_eth_dev_data->dev_flags准备的,刚才我们说过,rte_eth_dev_data这个数据结构处于共享内存中,由primary进程掌控。

  原本struct rte_eth_dev_data->dev_flags的值应该是 RTE_ETH_DEV_DETACHABLE | RTE_ETH_DEV_INTR_LSC,也就是0x0001 | 0x0002 = 0x0003。

  但是在使用ethtool-dpdk工具后,这个值变为了0x0002,也就是说,网卡热插拔标记RTE_ETH_DEV_DETACHABLE消失了...根据我刚才所说rte_eth_dev_data处于共享内存中,因此一定是secondary进程,也就是ethtool-dpdk工具改变了共享内存中的内容导致的。

  注意:如果已经知晓struct rte_eth_dev_data数据处于共享内存中,以下的分析应该扫一眼就知道是怎么回事了

【分析】

  在primary/secondary进程初始化过程中,也就是调用rte_eal_init()函数进行初始化的过程中,会去扫描pci设备,获取pci设备的状态信息。这里不了解的话,可以见我另外一篇文章《DPDK初始化之PCI》,并且实际上不耽误了解此篇文章中的内容。

  在初始化的过程中,primary进程和secondary进程都会进入rte_eth_dev_pci_allocate函数去获取struct rte_eth_dev结构。

  先介绍下struct rte_eth_dev结构:

struct rte_eth_dev {
    eth_rx_burst_t rx_pkt_burst; /**< Pointer to PMD receive function. */
    eth_tx_burst_t tx_pkt_burst; /**< Pointer to PMD transmit function. */
    eth_tx_prep_t tx_pkt_prepare; /**< Pointer to PMD transmit prepare function. */
    struct rte_eth_dev_data *data;  /**< Pointer to device data */ //注意这个指针
    const struct eth_dev_ops *dev_ops; /**< Functions exported by PMD */
    struct rte_device *device; /**< Backing device */
    struct rte_intr_handle *intr_handle; /**< Device interrupt handle */
    /** User application callbacks for NIC interrupts */
    struct rte_eth_dev_cb_list link_intr_cbs;
    /**
     * User-supplied functions called from rx_burst to post-process
     * received packets before passing them to the user
     */
    struct rte_eth_rxtx_callback *post_rx_burst_cbs[RTE_MAX_QUEUES_PER_PORT];
    /**
     * User-supplied functions called from tx_burst to pre-process
     * received packets before passing them to the driver for transmission.
     */
    struct rte_eth_rxtx_callback *pre_tx_burst_cbs[RTE_MAX_QUEUES_PER_PORT];
    enum rte_eth_dev_state state; /**< Flag indicating the port state */
} __rte_cache_aligned;

  不了解这里的实现的话,我在这里就直接告诉大家, 这个struct rte_eth_dev数据结构描述的是“设备”,在我们的场景下可以理解为描述某一个网卡设备,说白了就是一个管理性质的数据结构,网卡设备的抽象。

  先上数据结构:

  (这张图如果看一眼就知道什么意思的基本接下来的分析大概看一看就能明白到底是什么问题)

  rte_eth_dev_pci_allocate函数的作用实际上就是去获得这个struct rte_eth_dev数据,这里为什么视角从关键的rte_eth_dev_data结构转到rte_eth_dev_pci_allocate函数,我先按下不表,跟着思路走即可,因为这里我更倾向于还原整个问题现场与顺序,如果直接从问题出现的上下文出发,反而不好分析。

static inline struct rte_eth_dev *
rte_eth_dev_pci_allocate(struct rte_pci_device *dev, size_t private_data_size)
{
    struct rte_eth_dev *eth_dev;
    const char *name;

    if (!dev)
        return NULL;
    //step 1.先获取设备名
    name = dev->device.name;

    //step 2.如果是primary进程就去调用rte_eth_dev_allocate函数去“申请”rte_eth_dev结构
    //反之如果是secondary进程,就去调用rte_eth_dev_attach_secondary函数去“获取”rte_eth_dev结构
    if (rte_eal_process_type() == RTE_PROC_PRIMARY) {
        eth_dev = rte_eth_dev_allocate(name);
        if (!eth_dev)
            return NULL;

        if (private_data_size) {
            eth_dev->data->dev_private = rte_zmalloc_socket(name,
                private_data_size, RTE_CACHE_LINE_SIZE,
                dev->device.numa_node);
            if (!eth_dev->data->dev_private) {
                rte_eth_dev_release_port(eth_dev);
                return NULL;
            }
        }
    } else {
        eth_dev = rte_eth_dev_attach_secondary(name);
        if (!eth_dev)
            return NULL;
    }

    eth_dev->device = &dev->device;
    //step 3.调用rte_eth_copy_pci_info去根据pci设备数据结构拷贝pci信息
    rte_eth_copy_pci_info(eth_dev, dev);
    return eth_dev;
}

  对应的流程图为:

  根据rte_eth_dev_pci_allocate函数的逻辑我们可以看到有两处关键的地方,即:

  1. 要获取rte_eth_dev数据结构,只不过primary和secondary获取的方式不同。
  2. 调用rte_eth_copy_pci_info函数,去从描述pci设备的数据结构中拷贝信息至rte_eth_dev这个描述设备的结构。

  先将视角聚焦在第一处关键位置,即获取rte_eth_dev数据结构,我们这里的场景是secondary进程,因此primary进程执行的代码就不做分析,有兴趣的可以自己了解。

  接下来以secondary进程的视角进入rte_eth_dev_attach_secondary函数,观察secondary是怎么获取的struct rte_eth_dev结构,随之做了什么。

struct rte_eth_dev *
rte_eth_dev_attach_secondary(const char *name)
{
    uint8_t i;
    struct rte_eth_dev *eth_dev;

    //step 1.判断全局数据指针rte_eth_dev_data是否为NULL,如果为NULL,则申请。
    if (rte_eth_dev_data == NULL)
        rte_eth_dev_data_alloc();

    //step 2.找到与设备名字对应的rte_eth_dev_data结构所在的下标id
    for (i = 0; i < RTE_MAX_ETHPORTS; i++) {
        if (strcmp(rte_eth_dev_data[i].name, name) == 0)
            break;
    }
    if (i == RTE_MAX_ETHPORTS) {
        RTE_PMD_DEBUG_TRACE(
            "device %s is not driven by the primary process\n",
            name);
        return NULL;
    }
    //step 3.根据上一步获取的下标id来调用eth_dev_get函数来获取struct rte_eth_dev数据结构
    eth_dev = eth_dev_get(i);
    RTE_ASSERT(eth_dev->data->port_id == i);

    return eth_dev;
}

  对应的流程图为:

  这个函数中同样有两个重要的点,即:

  1. 要调用rte_eth_dev_data_alloc()函数去“获得”rte_eth_dev_data这个数据结构
  2. 调用eth_dev_get函数拿到对应的struct rte_eth_dev结构

  我们暂且跳过rte_eth_dev_data_alloc()函数,回头再来看,先看rte_dev_get函数是怎么拿到的这个struct rte_eth_dev结构。

static struct rte_eth_dev *
eth_dev_get(uint8_t port_id)
{
    struct rte_eth_dev *eth_dev = &rte_eth_devices[port_id];

    eth_dev->data = &rte_eth_dev_data[port_id];    //rte_eth_dev中的data指针来自于rte_eth_dev_data结构
    eth_dev->state = RTE_ETH_DEV_ATTACHED;
    TAILQ_INIT(&(eth_dev->link_intr_cbs));

    eth_dev_last_created_port = port_id;

    return eth_dev;
}

  对应的流程图为:

  rte_eth_dev结构来自于全局数组rte_eth_devices,说明rte_eth_dev数据为local数据,并不是shared memory中的数据,但是。关键在于上述代码注释的那一行,rte_eth_dev中的data指针指向了rte_eth_dev_data数据。而rte_eth_dev_data我们刚才也说过是在rte_eth_dev_attach_secondary中调用rte_eth_dev_data_alloc函数“获得的”,怎么获得的呢,让我们接下来回过头来看rte_eth_dev_data_alloc函数是怎么获得的rte_eth_dev_data数据。

static void
rte_eth_dev_data_alloc(void)
{
    const unsigned flags = 0;
    const struct rte_memzone *mz;
    //step 1.如果是primary进程则向memzone中申请一块空间作为rte_eth_dev_data数据所在
    //如果是secondary进程,则直接lookup收共享内存中的rte_eth_dev_data数据
    if (rte_eal_process_type() == RTE_PROC_PRIMARY) {
        mz = rte_memzone_reserve(MZ_RTE_ETH_DEV_DATA,
                RTE_MAX_ETHPORTS * sizeof(*rte_eth_dev_data),
                rte_socket_id(), flags);
    } else
        mz = rte_memzone_lookup(MZ_RTE_ETH_DEV_DATA);
    if (mz == NULL)
        rte_panic("Cannot allocate memzone for ethernet port data\n");

    rte_eth_dev_data = mz->addr;
    if (rte_eal_process_type() == RTE_PROC_PRIMARY)
        memset(rte_eth_dev_data, 0,
                RTE_MAX_ETHPORTS * sizeof(*rte_eth_dev_data));
}

  对应是流程图:

  通过这段代码,我们可以了解到一个信息,struct rte_eth_dev_data数据是处于共享内存中的,实际secondary进程去读网卡寄存器就是通过这个数据结构索引拿到pci bar,在根据基地址 + 寄存器偏移,拿到的具体的某一个寄存器地址,所以secondary进程才可以去读网卡的寄存器信息。

  经过上述的分析我们起码知道以下几个线索,可以梳理一下:

  1. dpdk的primary/secondary进程初始化过程中都会调用rte_eth_dev_pci_allocate函数去拿到struct rte_eth_dev结构。
  2. 在初始化过程中,struct rte_eth_dev结构来自于全局数组struct rte_eth_devices,也就意味着rte_eth_dev结构为进程的local变量。
  3. 在初始化过程中,还会给struct rte_eth_dev结构中的data指针初始化指向struct rte_eth_dev_data结构。
  4. struct rte_eth_dev_data结构在secondary进程中,其初始化时通过获取共享内存中的地址得到的,以为这struct rte_eth_dev_data结构在共享内存中。

  其实上述4点梳理的线索只是为了让大家明白:secondary中握着和primary进程的共享内存结构,这个结果是struct rte_eth_dev_data结构,既然握着共享内存,就容易犯错。

  而犯错的代码就位于rte_eth_dev_pci_allocate函数中第二处关键的位置,即rte_eth_dev_pci_copy_info函数中。

static inline void
rte_eth_copy_pci_info(struct rte_eth_dev *eth_dev,
    struct rte_pci_device *pci_dev)
{
    if ((eth_dev == NULL) || (pci_dev == NULL)) {
        RTE_PMD_DEBUG_TRACE("NULL pointer eth_dev=%p pci_dev=%p\n",
                eth_dev, pci_dev);
        return;
    }

    eth_dev->intr_handle = &pci_dev->intr_handle;
    //问题代码:将data指针的dev_flags进行reset操作
    eth_dev->data->dev_flags = 0;
    if (pci_dev->driver->drv_flags & RTE_PCI_DRV_INTR_LSC)
        eth_dev->data->dev_flags |= RTE_ETH_DEV_INTR_LSC;
    if (pci_dev->driver->drv_flags & RTE_PCI_DRV_INTR_RMV)
        eth_dev->data->dev_flags |= RTE_ETH_DEV_INTR_RMV;

    eth_dev->data->kdrv = pci_dev->kdrv;
    eth_dev->data->numa_node = pci_dev->device.numa_node;
}

对应的流程图为:

  可以看到,在rte_eth_dev_pci_copy_info函数中,对struct rte_eth_dev中的data指针中的数据进行了写操作,而这个数据正式来自于shared memory中的struct rte_eth_dev_data结构,并且经过前面对rte_eth_dev_pci_allocate函数的分析我们知道无论是secondary进程还是primary进程,都会进入rte_eth_dev_pci_copy_info函数中,那么就会出现这种情况:

  secondary进程在获得struct rte_eth_dev结构后大摇大摆的进入rte_eth_dev_pci_copy_info中去拷贝pci信息,然后顺手就将struct rte_eth_dev中的data指针中的数据重置了,这个数据就是rte_eth_dev_data.dev_flags,而重置时的条件判断却不充分,导致重置后的dev_flags只有两种可能,要么为0x0000,就是什么都没有,要么为0x0002,RTE_ETH_DEV_INTR_LS,或者是RTE_ETH_DEV_INTR_RMV,要么就是RTE_ETH_DEV_INTR_LSC | RTE_ETH_DEV_INTR_RMV,但是除了两者以外的其他值永远回不来了....

  那么回到我们的场景,在dpdk 17.08版本,struct rte_eth_dev_data.dev_flags原本为RTE_ETH_DEV_DETACHABLE | RTE_ETH_DEV_INTR_LSC,值为0x0003,经过rte_eth_dev_pci_copy_info函数中的逻辑重置后,就只剩下RTE_ETH_DEV_INTR_LSC了,就是由好好的0x0003变为了0x0002,从而导致primary中的网卡热插拔特性被莫名其妙的重置掉了。

【dpdk 19.11版本】

  在dpdk 19.11版本,此问题仍然存在,函数名都没有变,只不过就是函数所在的文件位置发生了变化,dev_flags的值发生了变化,RTE_ETH_DEV_DETACHABLE已经被废弃,但是问题真的只是RTE_ETH_DEV_DETACHABLE标志消失导致网卡热插拔出问题么?相信经过上述的分析大家心里自然有答案。

【结论】

   这个问题的本质实际上是在secondary函数初始化时进入rte_eth_pci_copy_info函数私自改变了共享内存struct rte_eth_dev_data中的值。关于这个问题我个人有两种角度来看:

  1. 从语义编程的角度讲,secondary进程的确需要进入rte_eth_pci_copy_info函数去重置(如果真的是这样的话,我个人表示不理解),只不过在重置时没有考虑全所有的情况,导致重置后的状态和充值前的状态出现了差异。
  2. 从我个人的想法来看,我个人坚持secondary进程不需要进入rte_eth_dev_pci_copy_info函数去重新设置rte_eth_dev_data->dev_flags,我觉得这个问题本质上与标志位无关,与是否网卡支持热插拔无关,与标志位的值,与标志位是否被废弃也无关,因为我个人认为dpdk的secondary进程就不应该有权利去触碰共享内存的数据,只能读不能写,更何况是与驱动相关的struct rte_eth_dev_data中的数据。secondary进程就不应该就如rte_eth_dev_pci_copy_info函数。并且为什么需要重新设置rte_eth_dev_data->dev_flags呢?数据来自于共享内存,primary已经把值设置完成了,举个例子就是primary进程已经把菜做好了端在面前了,secondary进程为什么还要讲菜倒掉重新做一份呢?并且从最终的结果来看,secondary进程初始化后的驱动状态和primary进程是统一的,既然希望统一,对secondary进程来讲,不设置struct rte_eth_dev_data中的数据岂不是最安全的。

【后续】

  关于这个问题有两种改动方法:

方法一: 最方便改法

static inline void
rte_eth_copy_pci_info(struct rte_eth_dev *eth_dev,
    struct rte_pci_device *pci_dev)
{
    if ((eth_dev == NULL) || (pci_dev == NULL)) {
        RTE_PMD_DEBUG_TRACE("NULL pointer eth_dev=%p pci_dev=%p\n",
                eth_dev, pci_dev);
        return;
    }

    eth_dev->intr_handle = &pci_dev->intr_handle;

    //加一层if判断,只有primary进程有权利对struct rte_eth_dev_data中的数据进行写操作
    if (rte_eal_process_type() == RTE_PROC_PRIMARY) {
        eth_dev->data->dev_flags = 0;
        if (pci_dev->driver->drv_flags & RTE_PCI_DRV_INTR_LSC)
            eth_dev->data->dev_flags |= RTE_ETH_DEV_INTR_LSC;
        if (pci_dev->driver->drv_flags & RTE_PCI_DRV_INTR_RMV)
            eth_dev->data->dev_flags |= RTE_ETH_DEV_INTR_RMV;

        eth_dev->data->kdrv = pci_dev->kdrv;
        eth_dev->data->numa_node = pci_dev->device.numa_node;
    }
}

方法二:

  坚持我个人的想法,不应该让secondary进程进入rte_eth_dev_pci_copy_info函数,但是这种改法改动巨大,风险也大,因为在dpdk的逻辑中不只有在初始化时会调用rte_eth_dev_pci_copy_info函数,有兴趣的可以自行研究,这里不多赘述。

  最后,这个问题已经提交了patch到dpdk社区,目前已经被采纳。

  P.S.给dpdk提patch还挺费劲的....比在我公司内部提一个patch麻烦的多...dpdk有一个专门提patch的引导,https://doc.dpdk.org/guides/contributing/patches.html,第一次看的时候脑袋都有点大...

这个框我也不知道是啥东西,写博客的时候冒出来的...