【Bug已解决】Fast (Rust-backed) tokenizers aren‘t thread-safe anymore 解决方案 【Bug已解决】Fast (Rust-backed) tokenizers arent thread-safe anymore 解决方案一、现象长什么样在多线程不是多进程环境下复用同一个 HuggingFacetokenizers的 fast tokenizer 实例时出现一类很诡异的问题不同线程得到的编码结果不一致同一句文本线程 A 编码出[1, 345, 789]线程 B 却得到[1, 346, 790]而单线程跑就稳定。偶发崩溃或RuntimeErrorRust 端报Already borrowed或thread ... panicked at ...。训练数据喂进去后 loss 震荡、样本错乱排查半天才发现是 tokenizer 在 DataLoader 的num_workers0或自定义多线程预处理里被并发调用内部缓存被踩。最迷惑的是这曾经没问题某个tokenizers版本升级后突然开始出错——也就是标题里说的 arent thread-safeanymore。很多项目在升级依赖后训练静默变差根子就在 tokenizer 的线程安全回归。它的本质fast tokenizer 的内部状态如 truncation/offset 缓存、特殊 token 处理上下文、或 Rust 端的RefCell可变借用在并发访问时没有正确同步多线程共享同一实例导致数据竞争。二、背景HuggingFace 的 fast tokenizer 底层是 Rust 的tokenizers库通过 PyO3 暴露给 Python。Rust 的借用检查器保证了单线程内的内存安全但线程安全需要显式Send/Sync或内部锁。fast tokenizer 在encode时并非纯函数它会读写一些内部可变状态例如offset/attention 缓存记录每个 token 对应的原始字符区间特殊 token 的合并缓存处理add_special_tokens时的临时状态overflowing与 truncation 的复用 buffer上一次调用的残留可能被下一次复用。当多个 Python 线程共享同一个 tokenizer 对象并同时调用encode/__call__这些内部可变状态会被并发读写。在 Rust 端若这些状态用RefCell非线程安全、单线程借用而非Mutex保护并发借用就会 panicAlready borrowed即便不 panic缓存被踩也会导致编码结果错乱——这是一个 silent wrong比崩溃更危险。典型触发场景DataLoader(num_workers0)且每个 worker 没独立建 tokenizer而是继承了主进程的同一实例fork 后共享自己写ThreadPool/threading做并行预处理所有线程共用一个全局 tokenizerdatasets的map用num_proc1但 tokenizer 在进程间共享不当。下面用可运行代码复现多线程共享带可变缓存的对象导致结果错乱的机制。三、根因根因一句话fast tokenizer 在encode时读写内部可变状态缓存/特殊 token 上下文而这些状态在 Rust 端未被线程安全原语Mutex/RwLock保护多线程共享同一实例并发调用时产生数据竞争表现为结果错乱或 Rust 端 panic。三个具体失配内部可变状态无锁保护缓存/复用 buffer 用非线程安全的借用并发访问踩踏。Python 层共享同一实例多线程共用一个全局 tokenizer而非每线程独立。版本回归某tokenizers版本去掉/改变了线程安全保证导致以前安全、现在不安全。四、最小可运行复现用纯 Python 模拟带可变缓存的 tokenizer多线程共享同一实例导致编码错乱import threading from dataclasses import dataclass, field dataclass class FakeFastTokenizer: vocab: dict _cache: dict field(default_factorydict) # 模拟 Rust 端的内部可变缓存 def encode(self, text: str): # 模拟 encode 会读写内部缓存非线程安全 self._cache[last] text ids [self.vocab.get(c, 0) for c in text] # 错误点缓存被并发踩下面错误地引用了别的线程写的值 if self._cache.get(last) ! text: ids ids[::-1] # 模拟被踩后的错乱结果 return ids def worker(tok, text, results, idx): results[idx] tok.encode(text) def main(): tok FakeFastTokenizer(vocab{c: ord(c) % 97 for c in hello}) texts [hello, world, hello, world] results [None] * 4 threads [threading.Thread(targetworker, args(tok, texts[i], results, i)) for i in range(4)] for t in threads: t.start() for t in threads: t.join() # 单线程基准 base [tok.encode(hello) for _ in range(2)] print(单线程 hello 结果:, base[0]) print(多线程结果:, results) if results[0] ! base[0]: print(复现到线程不安全相同输入多线程结果不一致) if __name__ __main__: main()运行可能看到多线程结果与单线程基准不一致缓存被并发踩即复现了同一输入编码结果因线程而变的 silent wrong。五、解决方案第一层最小直接修复最立竿见影的修复不要让多线程共享同一个 tokenizer 实例。每个线程/每个 worker 用自己独立的 tokenizer 副本。这是官方推荐做法——tokenizer 创建成本低独立实例互不干扰。import threading from functools import partial def make_tokenizer(): # 每个调用返回独立实例真实里是 AutoTokenizer.from_pretrained(...) return FakeFastTokenizer(vocab{c: ord(c) % 97 for c in hello world}) def worker_threadsafe(results, idx, text): # 关键修复线程内自己建 tokenizer不共享 local_tok make_tokenizer() results[idx] local_tok.encode(text) def main(): texts [hello, world, hello, world] results [None] * 4 threads [threading.Thread(targetworker_threadsafe, args(results, i, texts[i])) for i in range(4)] for t in threads: t.start() for t in threads: t.join() print(线程安全结果:, results) # 每个线程独立实例结果稳定一致 if __name__ __main__: main()第一层修复让每个线程持有独立 tokenizer彻底消除共享可变状态的竞争。六、解决方案第二层结构性改进把tokenizer 必须线程局部thread-local或加锁收口成一个TokenizerPool统一提供每线程一个实例的语义调用方不再需要手动在每个 worker 里建。import threading from dataclasses import dataclass, field from typing import Callable, Dict dataclass class TokenizerPool: factory: Callable # 返回新 tokenizer 实例的工厂 _local: threading.local field(default_factorythreading.local) def get(self): # 每个线程首次访问时建一个之后复用本线程实例 if not hasattr(self._local, tok): self._local.tok self.factory() return self._local.tok def encode(self, text): return self.get().encode(text) def make_tokenizer(): return FakeFastTokenizer(vocab{c: ord(c) % 97 for c in hello world}) def main(): pool TokenizerPool(factorymake_tokenizer) results [None] * 4 texts [hello, world, hello, world] def worker(i, t): results[i] pool.encode(t) # 自动拿到线程局部实例 threads [threading.Thread(targetworker, args(i, texts[i])) for i in range(4)] for th in threads: th.start() for th in threads: th.join() print(TokenizerPool 结果线程安全:, results) if __name__ __main__: main()第二层的关键是TokenizerPool把线程局部单例封装好所有调用方都走pool.encode既避免共享又不用每个 worker 手写建实例逻辑。七、解决方案第三层断言 / CI 守护加 pytest 守护(1) 多线程并发调用TokenizerPool.encode结果应与单线程一致(2) 共享单实例时结果应不稳定警告危险(3) thread-local 保证每线程实例唯一。import threading import pytest class FakeTok: def __init__(self): self.cache {} def encode(self, text): self.cache[last] text return list(text) class Pool: def __init__(self, factory): self.factory factory self._local threading.local() def get(self): if not hasattr(self._local, tok): self._local.tok self.factory() return self._local.tok def encode(self, text): return self.get().encode(text) def test_threadlocal_consistent(): pool Pool(FakeTok) texts [abc, def, abc, def] out [None] * 4 def w(i, t): out[i] pool.encode(t) ts [threading.Thread(targetw, args(i, texts[i])) for i in range(4)] for t in ts: t.start() for t in ts: t.join() # 单线程基准 base [FakeTok().encode(t) for t in texts] assert out base def test_threadlocal_unique_per_thread(): pool Pool(FakeTok) seen [] def w(): seen.append(id(pool.get())) ts [threading.Thread(targetw) for _ in range(3)] for t in ts: t.start() for t in ts: t.join() assert len(set(seen)) 3 # 每线程独立实例 if __name__ __main__: pytest.main([__file__, -q])CI 里test_threadlocal_consistent通过就能保证多线程编码结果与单线程一致从根上防住 fast tokenizer 的线程安全回归。八、排查清单怀疑 tokenizer 线程不安全时按此顺序查先看是否多线程共享同一实例grep 代码里 tokenizer 是不是一个全局/模块级单例被多线程/多 worker 共用。检查 DataLoadernum_workersnum_workers0时每个 worker 应独立建 tokenizer若 tokenizer 在__init__之前就建好并被 fork 继承可能共享状态。对比单线程 vs 多线程结果同一批文本单线程编一次、多线程编一次结果不一致即中招。降级 tokenizers 版本验证若升级后突然出问题回退tokenizers版本确认是回归。每线程独立实例用TokenizerPool或threading.local让每个 worker 自己建 tokenizer。必要时加锁若必须共享实例创建极贵用threading.Lock串行化encode但性能差优先独立实例。datasets.map(num_proc1)确认load_from_cache_file与 tokenizer 在子进程独立加载不要跨进程共享同一对象。九、小结Fast (Rust-backed) tokenizers arent thread-safe anymore 这个 bug根因不在编码算法而在fast tokenizer 在encode时会读写内部可变状态缓存/特殊 token 上下文而这些状态在 Rust 端未被线程安全原语保护多线程共享同一实例并发调用就产生数据竞争表现为相同输入编码结果因线程而变silent wrong或 Rust 端Already borrowedpanic。它常是tokenizers版本升级后的回归多线程预处理/DataLoader 里悄悄把训练数据搞错。修复三层第一层不让多线程共享实例每线程/每 worker 建独立 tokenizer第二层用TokenizerPoolthreading.local把线程局部单例封装好调用方统一走pool.encode第三层用 pytest 断言多线程编码结果与单线程一致、每线程实例唯一。记住tokenizer 不是线程安全的全局变量多线程下要么每线程一份要么加锁——共享实例必踩。