控制面与数据面双侧更新共享状态的无锁设计

1. 背景

  ARP/NDP 邻居表有两个更新来源:用户配置,以及 Worker(数据面线程)收到的邻居报文。用户可以添加静态邻居,Worker 收到 ARP/NDP 报文后也可能触发学习或刷新。路由变化和记录老化,同样会修改这些共享状态。

  《如何实现兼顾无锁与一致性的配置更新》介绍了 Worker 如何切换配置,但还需要解决谁来修改配置的问题。例如,两个线程从同一份旧指针表准备副本,一个修改邻居表,另一个修改路由表,后发布的副本就可能覆盖前一次更新。即使版本使用原子操作,也不能避免这种覆盖。

  这里把实际写入收拢到一个配置面更新线程。用户侧提交配置操作,Worker 将相关报文或事件放入队列,路由变化和老化也交给这个线程处理。它顺序准备新配置,再复用前面的版本发布和确认机制。

2. 线程分工

配置面更新线程与 Worker 的分工

  配置面更新线程负责修改共享状态、构建配置副本、发布版本和回收旧对象。它是 struct thread_config 的唯一写入方。配置面可以有其他线程,但涉及配置变更时,都需要向它提交请求。

  Worker 负责查表和报文处理。收到需要更新邻居状态的 ARP/NDP 报文后,通过队列异步上报,不直接修改共享表,也不等待更新结果。之后继续处理报文,并在报文批次之间检查配置版本。

  双方都可以触发变更,但写表只由配置面更新线程完成。这里的“无锁”指 Worker 读取配置时不需要获取读写锁,消息队列和版本同步仍然需要正确的原子操作与内存顺序。

3. 处理变更

  用户侧提交的配置请求包含操作类型、模块标识和必要参数。例如,添加邻居时提交地址、接口及相关属性,由配置面更新线程根据当前状态决定如何修改。请求需要返回结果时,再通过请求编号关联完成通知。入队成功只表示请求已经收到,不能当作配置已经生效。

  Worker 上报的 ARP/NDP 数据,以及路由模块、接口状态变化产生的事件,也由这个线程处理。它根据协议规则和当前记录,决定创建、刷新还是删除。用户配置与动态学习可能修改同一条记录,因此还要明确静态配置能否被学习结果覆盖、用户删除后是否允许重新学习。这些业务规则不会因为改成单线程就自动解决。

  确定修改内容后,基于最新配置准备指针表副本,为变化部分构建新对象,未变化的对象继续复用。外部提交的是操作和参数,不应拿提前复制的整份 thread_config 覆盖当前配置。已经发布给 Worker 的对象保持不变,修改通过下一版本生效。

4. 老化处理

  ARP/NDP 以及需要老化的路由记录,由配置面更新线程维护状态和过期时间,并定期检查。这样,记录刷新和老化删除在同一个线程中执行,不需要另一个线程先判断过期,再把删除命令发过来。

  扫描时仍要使用最新状态。如果记录在等待处理期间已经刷新,就不能按之前的过期判断删除。静态记录是否参与老化、动态记录何时刷新或失效,分别按配置属性和协议规则处理。老化产生的修改也先作用于私有副本,再发布新版本,不能直接删除 Worker 正在访问的对象。

  消息消费和老化扫描都需要限制单次工作量。一个槽位里记录很多,或者某个 Worker 连续上报大量事件时,应分批处理,让其他任务也能得到执行。重复刷新可以在不改变业务含义的前提下合并;只用于内部计时、不影响 Worker 查表结果的数据,也不必每次都触发版本发布。

5. 发布与回收

配置发布与事件队列处理

  配置面更新线程准备好本轮对象和指针表后,发布新版本,记录目标版本及被替换的旧对象。Worker 完成当前报文批次,在下一批处理前检查版本,切换各模块的配置指针,再更新自己的版本。

  等待期间,配置面更新线程检查切换进度、记录到期任务,后续配置和 Worker 事件继续入队。本轮尚未完成时,不再改写已发布的指针表,也不启动下一轮发布。Worker 上报后必须继续运行,否则它等待更新结果,配置面又等待它切换版本,就会互相等待。

  配置面检查到全部相关 Worker 已切换后,再回收满足条件的旧对象。这里仍依赖前一篇的使用约定:每批报文处理结束后,Worker 不再保留或使用该批配置对象的临时引用。新版本仍在引用的对象,以及其他使用者尚未退出的对象,都不能释放。

  本轮更新完成后,向关联的用户请求返回结果,再读取队列中的后续事件。有新的变更,就基于最新数据构建下一份配置;没有则继续等待或轮询。

留言