如何实现兼顾无锁与一致性的配置更新

1. 背景

  在 DPDK 多核架构中,Worker(数据面线程)处理报文时,需要访问路由、策略、地址转换等配置。配置修改时,Worker 仍在运行。如果直接改正在使用的对象,就可能读到更新了一半的数据;如果每次访问都加锁,又会增加报文处理的同步开销。

  这里把配置更新分成准备和切换两个过程。配置面先构建新对象,准备期间 Worker 继续使用旧配置。等新配置准备好,再发布对应的版本号,由 Worker 在下一批报文处理前切换配置指针。旧对象先保留,等所有相关 Worker 完成切换后再回收。

为避免混淆,本文统一使用下面三个概念:

名称 含义
配置内容 实际使用的路由表、策略表等配置对象,通过指针表访问
版本号 标识一轮配置更新的 uint64_t 数字,例如 1、2、3,每提交一轮更新就加一
配置版本,简称“版本” 一个版本号及其对应的一组配置内容

配置更新架构

2. 指针注册

  每个 Worker 对应一个 thread_config 和一个 thread_ctx。thread_config 保存配置面发布的版本,thread_ctx 保存 Worker 已应用的版本,以及各模块配置指针变量的地址。

struct thread_config {
    uint64_t version;   /* 配置面发布的版本号 */
    void *table[CONFIG_MODULE_MAX];   /* 对应的配置对象指针 */
} ALIGNED(CACHE_LINE);

struct thread_ctx {
    uint64_t version;   /* Worker 已切换到的版本号 */
    void **pp_tables[CONFIG_MODULE_MAX];  /* 模块配置指针变量的地址 */
} ALIGNED(CACHE_LINE);

  模块启动时,把自己的配置指针变量地址注册到 pp_tables 中。例如,策略模块通过 policy_cfg 访问配置,那么注册的就是 &policy_cfg。更新时只需要修改这个变量,策略模块仍然按原来的方式使用 policy_cfg,不需要另外实现一套配置切换逻辑。

复制指针表并替换变化的对象

  这两个数组存的东西不同:tc->table[i] 是配置对象地址,ctx->pp_tables[i] 是模块配置指针变量的地址。因此,*ctx->pp_tables[i] = tc->table[i] 改的是模块正在使用的指针,没有复制对象内容。注册的变量必须在 Worker 运行期间一直有效,不能使用初始化函数返回后就失效的局部变量;没有注册的索引,切换时直接跳过。

  Worker 进入正常处理循环前,需要完成注册,并设置好各模块的初始配置指针和版本。只把两个版本号设成相同,并不代表初始配置已经应用。

3. 准备副本

  配置面先复制当前配置指针表,再替换本次发生变化的指针。例如,更新策略时,单独构建新策略对象,路由和地址转换对象没有变化,就继续使用原来的地址。

模块配置指针注册

  这里复制的是指针,不是指针指向的所有对象。副本中的旧指针仍然指向 Worker 正在使用的数据,不能沿着这些指针原地修改。需要变化的对象要重新构建,未变化的对象可以共享。如果几个对象存在依赖,先准备被引用的对象,再建立关联;需要一起生效的修改,放在同一个版本中发布。

  准备副本时还要保证当前指针表稳定。本文按一次只进行一轮更新来处理:上一轮发布完成、所有相关 Worker 切换后,才开始下一轮。复制时只处理配置指针部分,版本号按发布顺序单独访问和更新。

  多 NUMA 架构下,可以为每个 NUMA 节点分别构建一份配置对象,并从本节点内存分配。同一节点内的 Worker 共享这份对象,各自保留 thread_config 和 thread_ctx 。这样既能减少跨 NUMA 访问,也不用给每个 Worker 重复保存完整配置。发生更新时,各节点分别准备新对象,再让节点内的 Worker 切换到对应实例。

4. 发布与切换

  新对象和私有副本准备好后,配置面将指针表写入各 Worker 的 tc->table,最后通过 release 原子操作发布新版本号。在版本号变化之前,Worker 继续使用模块本地的旧指针,不读取正在准备的 tc->table。

  Worker 在每批报文处理前检查版本号。版本号没变就继续处理报文;版本号变了,就通过启动时注册的变量地址,替换各模块的配置指针,最后写入版本号。

static inline void
dp_config_update(struct dataplane *dp)
{
    struct thread_ctx *ctx = dp->ctx;
    struct thread_config *tc = dp->tc;

    /* 读取配置面发布的版本号。 */
    uint64_t version = __atomic_load_n(&tc->version, __ATOMIC_ACQUIRE);

    /* 版本号相同,表示本 Worker 已使用这组配置。 */
    if (ctx->version == version)
        return;

    for (unsigned int i = 0; i < CONFIG_MODULE_MAX; i++) {
        /* 未注册的索引不参与切换。 */
        if (ctx->pp_tables[i] != NULL)
            *ctx->pp_tables[i] = tc->table[i];
    }

    /* 所有配置指针切换完成,记录已切换到的版本号。 */
    __atomic_store_n(&ctx->version, version, __ATOMIC_RELEASE);
}

  循环中的指针是逐个替换的。之所以不会让一批报文用到更新了一半的配置,是因为切换期间不执行报文处理,也不允许其他线程或回调读取这些模块指针。所有指针替换完,再开始下一批报文处理。没有报文时,Worker 也需要检查版本,否则配置面可能一直等不到空闲 Worker 的切换。

  配置面先向所有参与更新的 Worker 发布版本,再检查各自的版本。下面用轮询表示这个过程:

static inline void
dp_config_synchronize(struct dataplane *dp[], unsigned int count, uint64_t new_version)
{
    /* 调用前:新配置对象及所有发布区的指针表均已准备完成。 */
    for (unsigned int i = 0; i < count; i++) {
        __atomic_store_n(&dp[i]->tc->version, new_version, __ATOMIC_RELEASE);
    }

    /* 等所有相关 Worker 都切换到新版本。 */
    for (unsigned int i = 0; i < count; i++) {
        while (__atomic_load_n(&dp[i]->ctx->version, __ATOMIC_ACQUIRE) != new_version) {
            PAUSE();
        }
    }
}

配置发布与 Worker 版本切换

  这里有两次同步:配置面先写好数据,再 release 发布版本,Worker 通过 acquire 读到新版本后使用这些数据;Worker 完成切换后,再 release 写入版本号,配置面通过 acquire 检查。在全部相关 Worker 切换之前,发布区的指针表必须保持不变,不能被下一次更新覆盖。

  各 Worker 到达检查位置的时间不同,所以切换期间可能有的使用旧版本,有的已经使用新版本。这里保证的是每个 Worker 在处理一批报文时使用完整的一组配置,以及所有相关 Worker 都已完成切换,并不保证它们在同一时刻切换。

5. 回收旧配置

  能否回收旧对象,取决于 Worker 切换时是否已经停止使用它。代码需要保证:配置对象只在报文处理逻辑内使用,每批处理结束后,不再保留或使用指向该批配置对象的临时指针。这条要求一直成立,与本次是否发生配置更新无关。各模块保存的当前配置指针,则在检查到新版本时替换。

  满足这个条件后,Worker 完成指针切换,配置面就能知道它已经退出旧配置。等所有相关 Worker 都切换,再回收本次被替换的对象。没有变化、仍被新版本引用的对象不能释放;如果其他模块也持有旧对象,还需要等待这些使用者退出。对于 NUMA 节点内共享的对象,要检查该节点全部相关 Worker,而不是只看其中一个。

  这套做法把主要更新工作放在配置面。没有更新时,Worker 每次读取并比较一次版本;有更新时,再替换各模块指针。开销仍然包括模块指针复制、原子访问、缓存行传递和新配置的缓存预热。

6. 批量提交

  启动时加载配置,或者用户通过脚本连续修改多个模块时,不必每修改一项就发布一个版本。可以先开启事务,把修改写入待发布副本,全部准备完成后再提交:版本号只增加一次,Worker 也只切换一次。这样,同一批需要一起生效的配置不会以中间状态被使用。

  提交前失败或取消,清理本次新建且尚未发布的对象即可,不能释放副本中复用的旧对象。提交后仍按前面的顺序处理:发布版本、等待 Worker 切换,再回收被替换且已无人使用的旧对象。

参考资料

留言