每个带 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 上没有改这里。
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 就不注册。