Rust in Everything
基于 tantivy 0.27.0

每个带 warmer 的 IndexReader 留下一个线程

主线位置:查询主线的第一步。IndexReader 建 searcher 时可以先让 Warmer 预热段级缓存;为了在旧的一代 searcher 死掉后通知 warmer 清理,reader 会起一个后台线程。Warmer 的两个方法和代际(generation)的记账见 IndexReader 与 Searcher。

stateDiagram-v2
  [*] --> NoThread
  NoThread --> Ticking: 第一次预热
  Ticking --> Ticking: 每秒 tick 且 reader 活着 调 gc_maybe
  Ticking --> Idle: reader 被丢掉
  Idle --> Idle: 每秒 tick upgrade 失败 什么也不做

实验在 exp-tantivy/pitb-lab,bin p28_warm(release):一个内存索引、一个单线程的 IndexWriter,提交 20 次;一个什么都不做的 Warmer;线程数用 ps -M 数。M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0。

现象

每次提交后新建一个带 warmer 的 reader、用完丢掉(fresh);对照组不注册 warmer(nowarm):

[fresh] threads at start: 1
[fresh] threads with an IndexWriter: 7
[fresh] after 5 commits: 12 threads
[fresh] after 10 commits: 17 threads
[fresh] after 15 commits: 22 threads
[fresh] after 20 commits: 27 threads
[fresh] readers and warmer dropped, 2.5 s later: 27 threads
[nowarm] threads at start: 1
[nowarm] threads with an IndexWriter: 7
[nowarm] after 5 commits: 7 threads
[nowarm] after 10 commits: 7 threads
[nowarm] after 15 commits: 7 threads
[nowarm] after 20 commits: 7 threads
[nowarm] readers and warmer dropped, 2.5 s later: 7 threads

写入器自己占 6 个线程。之后每建一个带 warmer 的 reader 就多一个线程,20 个 reader 多出 20 个;reader 和 warmer 都丢掉以后,一个也没少。

原因

第一次有 warmer 参与预热时,start_gc_thread_maybe 起一个名叫 tantivy-warm-gc 的线程,只持有 reader 内部状态的 Weak。线程的主体是 gc_loop:

    fn gc_loop(inner: Weak<Mutex<WarmingStateInner>>) {
        for _ in crossbeam_channel::tick(GC_INTERVAL) {
            if let Some(inner) = inner.upgrade() {
                // rely on deterministic gc in tests
                #[cfg(not(test))]
                if let Err(err) = std::panic::catch_unwind(|| inner.lock().unwrap().gc_maybe()) {
                    error!("Panic in Warmer GC {err:?}");
                }
                // ...
            }
        }
    }

tick 返回的通道永远有下一个 tick,for 不会结束;upgrade() 失败只是跳过这一轮,没有 break。reader 丢掉以后,这个线程每秒(GC_INTERVAL)醒一次、发现无事可做、再睡,直到进程退出。没有句柄可以 join,也没有别的退出路径。

这是上游的问题。git log 0.27.0..origin/main -- src/reader 为空,tag 之后 main 上没有改这里。

📝NOTE
冷知识:这个循环从 0.17 起没有变过

gc_loop 是 #1261(99d4b1a17,「Searcher Warming API」,首次发布于 0.17)随 warmer 接口一起加的,提交说明只有一句「Adds an API to register Warmers in the IndexReader.」当时的写法是 crossbeam::channel::tick(GC_INTERVAL),upgrade() 失败时同样什么也不做。到 0.27.0,warming.rs 又被十来个提交改过,gc_loop 只换了 crossbeam_channel 的路径和日志的格式化写法,循环的结构和退出条件都没有动过。

修法

一个索引只建一个 reader,提交后 reload(),不要每次请求新建:

[reuse] threads at start: 1
[reuse] threads with an IndexWriter: 7
[reuse] after 5 commits: 8 threads
[reuse] after 10 commits: 8 threads
[reuse] after 15 commits: 8 threads
[reuse] after 20 commits: 8 threads
[reuse] readers and warmer dropped, 2.5 s later: 8 threads

只多一个 GC 线程,20 次 reload 之后还是一个。这个线程在 reader 丢掉后照样不退出,但只有一个。IndexReader 是 Clone 的,放进应用状态里共享即可;reload 的代价(实测不到 2 ms)和 ReloadPolicy 的选择见 IndexReader 与 Searcher。

不需要 warmer 的就别注册:不注册时既不预热也不起线程,上面的 nowarm 就是。

怎么检查

  • 进程的线程数随请求或时间单调上涨,先看是不是每次都在 reader_builder()...try_into()。macOS 上 ps -M -p <pid> | wc -l;Linux 上数 /proc/<pid>/task 的条目,线程名可以在 /proc/<pid>/task/*/comm 里看到 tantivy-warm-gc。
  • 代码里搜 .warmers(,看它所在的 reader 是不是只建一次。

小结

  • 带 warmer 的 IndexReader 第一次预热时起一个 tantivy-warm-gc 线程,gc_loop 是一个永不结束的 tick 循环,reader 丢掉后它照样每秒醒一次。
  • 每次新建 reader 的写法,每个 reader 留下一个线程:20 个 reader 多出 20 个线程。这是上游问题,0.17 起就是这样,main 上没有改。
  • 修法:一个索引一个长命的 reader,提交后 reload();用不到 warmer 就不注册。

💬 关联讨论

发起讨论
加载中...