在本站中
本站的 Cargo.toml 里没有 rayon,代码里也搜不到 rayon::、par_iter。Cargo.lock 里的 rayon 1.12.0 和 rayon-core 1.13.0 全是间接依赖,和本卷的版本完全一致。这一页回答四个问题:谁把它带进来、在哪个进程里真的会跑、本站的哪条代码路径会走到它、本卷 09 常见坑 哪些在本站碰得到。
flowchart TB site["本站 app(server 构建)"] --> search["module-search"] search --> tantivy["tantivy 0.27.0"] tantivy --> rayon["rayon 1.12.0"] rayon --> rcore["rayon-core 1.13.0"] rcore --> deque["crossbeam-deque 0.8.6"] tantivy --> meta["私有池 segment_updater:1 线程"] tantivy --> merge["私有池 merge_thread_i:4 线程"] desk["本站 app(desktop feature)"] --> dd["dioxus-desktop 0.7.9"] dd --> image["image 0.25.10(默认 feature 含 rayon)"] image --> rayon image --> ravif["ravif → rav1e → maybe-rayon、av-scenechange"] ravif --> rayon image --> exr["exr 1.74.0"] exr --> rcore
锁定的版本
本站仓库根目录的 Cargo.lock(写作时 main 的 3292b81):
| crate | 本站 lock | 本卷 | 差异 |
|---|---|---|---|
| rayon | 1.12.0 | 1.12.0 | 无 |
| rayon-core | 1.13.0 | 1.13.0 | 无 |
| crossbeam-deque | 0.8.6 | 0.8.8 | 0.8.7 的 #1233 是 Worker::push 等的性能优化,对 rayon 没有行为影响,逐条判断见 crossbeam 卷 版本差异 |
网关 crates/gateway 有自己的 Cargo.lock,里面没有 rayon。
依赖链
在本站目录下用 cargo tree --offline --locked -i rayon -e normal 和 -i rayon-core 反查(只解析 lock,不编译),三种口径:
| 口径 | 命令附加参数 | rayon 从哪来 |
|---|---|---|
| lock 里的全部路径 | --all-features --target all | tantivy 0.27.0 ← module-search;image 0.25.10、ravif 0.13.0、av-scenechange 0.14.1、maybe-rayon 0.1.1(后三个都在 rav1e 0.8.1 下)← dioxus-desktop;rayon-core 另有一条 exr 1.74.0 ← image,直接依赖 rayon-core 不经 rayon |
| 生产服务器 | -p app -F server --target x86_64-unknown-linux-gnu | 只有 tantivy ← module-search 一条 |
| wasm 前端 | -p app --target wasm32-unknown-unknown | 没有:rayon 不在依赖图里 |
| 桌面端 | -p app -F desktop --target aarch64-apple-darwin | image 一族,image 的默认 feature 打开了 rayon,ravif 开 threading,maybe-rayon 开 threads |
所以生产进程里的 rayon 只有 tantivy 一个用户。module-search 的 tantivy 依赖是 optional 的,只在它的 server feature 下打开,app 的 server feature 把 module-search/server 带上。桌面端那一族只在打开 desktop feature 时编进来,生产构建没有;桌面端是否真会执行到 rayon 代码(图标解码之类大概不会进 avif 编码和 exr),本页没有核实,属推测。
tantivy 怎么用 rayon
tantivy 0.27.0 的 src/ 里 rayon 只出现在三个文件:core/executor.rs、error.rs(错误类型转换)、indexer/segment_updater.rs(git grep)。没有一处 par_iter、join,也没有调用全局池的函数;它只自己建 ThreadPool,用 spawn 和 scope。
搜索:Executor,本站走单线程
Executor 有两个变体:SingleThread 和 ThreadPool(Arc<rayon::ThreadPool>)。multi_thread 用 ThreadPoolBuilder 建一个池,线程名是前缀加编号;map 在多线程模式下 pool.scope,给每个段 spawn 一个任务,结果经 crossbeam 无界 channel 发回,scope 返回后按下标放回原位(本卷 scope 家族 末尾拿它做过例子)。
Index 的 executor 默认是单线程:唯一构造 Index 结构的 open_from_metas 写死 Executor::single_thread(),要多线程得显式调 set_multithread_executor / set_default_multithread_executor(线程名前缀 tantivy-search-)。本站全仓库搜不到这两个调用,module-search 的 engine 模块只 Index::open_or_create 加注册 jieba 分词器。所以本站的查询不经过 rayon:/api/search/query 这个 server 函数在 tokio worker 上直接调 searcher.search,各段在调用线程上顺序跑(tantivy 卷 Searcher;换成多线程 executor 时要在建 reader 之前设,见 executor 设晚了)。
写入:SegmentUpdater 的两个池
每个 IndexWriter 在 SegmentUpdater::create 里建两个 rayon 池:
| 池 | 线程数 | 线程名 | panic_handler | 跑什么 |
|---|---|---|---|---|
pool | 1 | segment_updater | 没有 | schedule_task:收新段、提交(清删除、写 meta.json、垃圾回收、consider_merge_options)、合并收尾 |
merge_thread_pool | num_merge_threads,默认 4(index_writer.rs:60-62) | merge_thread_{i} | 有:只记一行 error! | start_merge:合并几个段 |
两者都是 pool.spawn,即本卷 spawn 讲的 HeapJob + terminate_count。结果不经 rayon 传回:schedule_task 和 start_merge 各建一个 FutureResult,底下是 oneshot crate 的 channel,任务结束时 send。单线程的元数据池本身就是一把「串行化所有元数据修改」的锁:所有 schedule_task 的闭包在同一个线程上按 FIFO(池外 spawn 进注入队列)依次执行。
本站 module-search 每次写入(replace_all / upsert_documents / delete_documents)都新开一个 index.writer(50_000_000),用完 commit 后 wait_merging_threads 再丢掉。50 MB 的预算按每线程至少 15 MB(index_writer.rs:29-32)算,最多 3 个索引线程(index.rs:753-763,在至少 3 核的机器上),所以每次写入建起 3 个索引线程(std::thread,名字 thrd-tantivy-index{i})+ 1 个 segment_updater + 4 个 merge_thread_*,写完全部退出(tantivy 卷 IndexWriter 里数过线程)。也就是说本站每写一次就建、拆两个 rayon Registry;rayon 的全局池在生产进程里从未被初始化(tantivy 不调全局函数,别的依赖没有 rayon;由 git grep 和依赖树推出,没有在运行的进程里核对)。
写入时等在哪里
本站的增量重建(main 里 tokio::spawn 的每 10 分钟循环)和管理员的 search_reindex 都在 async 函数里直接调 engine 模块的同步写入函数,没有 spawn_blocking。调用线程是 tokio worker,它在三处阻塞,都不是 rayon 的 latch:
commit()→PreparedCommit::commit→schedule_commit(..).wait():FutureResult::wait是oneshot::Receiver::recv(),等元数据池把提交做完。wait_merging_threads先 join 索引线程(std::thread::JoinHandle)。- 再调
wait_merging_thread:merge_operations.wait_until_empty()。MergeOperationInventory是 census 0.4.2 的Inventory,wait_until_empty在一个Mutex+Condvar上等存活的MergeOperation计数归零。每个合并任务的闭包拥有一个MergeOperation,任务结束时 drop,计数减一并notify。
合并线程那一侧也有一处阻塞:合并写完后 end_merge 把收尾交给元数据池,再 .wait() 等它(第 700 行)。这是一个 rayon worker 阻塞在 oneshot 上、等另一个池的任务。它不会饿死:元数据池的任务(consider_merge_options → start_merge)只往合并池 spawn、把返回的 FutureResult 直接 drop,从不反过来等合并池(读源码得出)。
所以本卷讲的 in_worker_cold / LockLatch 在本站不出现:tantivy 从池外进 rayon 只用 spawn,不用 install、join、scope(多线程 executor 才用 scope,本站没开)。本站等 rayon 任务完成,靠的是 tantivy 自己的 oneshot 和 census 的 Condvar。
常见坑在本站
逐条对照 09 常见坑,依据是上面读过的 tantivy 0.27.0 源码和本站 module-search:
| 坑 | 本站会不会碰到 |
|---|---|
| spawn 里 panic,进程 abort | 合并池设了 panic_handler:合并本身的 panic 被 catch_unwind 接住、以 Err 发回,再以特定消息 resume_unwind 交给 handler,不会 abort。元数据池没设 handler:schedule_task 的闭包里若 panic(例如 RwLock 中毒时的 unwrap、SegmentManager 里的几处 expect),按 spawn 一章的规则会打印「Rayon: detected unexpected panic; aborting」并结束整个服务进程。正常运行时这些 unwrap 不会失败,所以是「能碰到但需要先有别的 panic」(推测,没有构造复现) |
| 持锁调 rayon | 不会:tantivy 的池内任务不再调 join / scope / par_iter,没有「等待时执行偷来的任务」的机会 |
| 在 async 里直接调 par_iter | 不是这个机制,但后果同类:写入函数在 tokio worker 上阻塞于 oneshot::recv 和 census 的 Condvar,提交和合并期间占住一个 tokio worker。本站是 multi_thread 运行时,影响限于这个 worker 上排队的任务(spawn 一章 E5 的 multi_thread 结论)。改法同那一页:包 spawn_blocking |
| 小任务并行反而慢 | 不涉及:没有并行迭代器;每次写入建、拆两个池的线程创建开销,tantivy 卷 IndexWriter 测过很便宜 |
| build_global 返回 GlobalPoolAlreadyInitialized | 不会:没人调 build_global,全局池从不初始化 |
| RAYON_NUM_THREADS | 不起作用:两个池都显式 num_threads(1) / num_threads(4),环境变量只在 num_threads 为 0 时才读(ThreadPoolBuilder) |
| install 期间执行别的任务 | 不会:没有 install |
| 池里读不到调用线程的 thread_local | 没有发现依赖:合并和元数据任务需要的状态都 clone 进闭包(SegmentUpdater 是 Arc);没有逐个函数核对 tantivy 依赖的 TLS,属推测 |
| scope 只传播一个 panic | 不会:scope 只在多线程 executor 里用,本站没开 |
| par_sort 在小数组上更慢、filter 后 collect、par_bridge 乱序、浮点求和不确定 | 不会:tantivy 不用 rayon 的并行迭代器、并行排序;BM25 打分是单线程算的,结果确定 |
| 池内阻塞等待 | 有阻塞,没有饿死:合并线程在 end_merge 里阻塞等元数据池,跨池且被等的一方从不等回来;元数据池只有 1 个线程,它的任务里没有 .wait() 另一个 schedule_task(读源码得出)。如果以后 tantivy 在元数据任务里同步等元数据池自己的任务,1 线程的池会立刻卡死 |
小结
- 本站没有直接依赖 rayon;lock 里是 rayon 1.12.0、rayon-core 1.13.0,与本卷相同;crossbeam-deque 是 0.8.6,比本卷旧两个补丁版本,没有行为差异。
- 生产构建只经 tantivy ←
module-search带进 rayon;wasm 前端没有;image一族只在desktopfeature 下进来。 - 本站搜索走 tantivy 默认的单线程 executor,查询不碰 rayon。用到 rayon 的只有写入:每个
IndexWriter一个 1 线程的segment_updater池和一个 4 线程的merge_thread_*池,都只用spawn,结果经oneshot传回。 - 本站写入时 tokio worker 阻塞在 tantivy 的
oneshot和 census 的Condvar上,不是 rayon 的LockLatch;元数据池没有panic_handler,那里的 panic 会让整个进程 abort。