Replacing my revm ForkDB background thread with SharedBackend
I have been refactoring the RPC client that backs my
revm ForkDB. The old version, called
Client, was built around a background worker thread. It handled batching,
caching, deduplication, rate limiting, and retries. It worked, but it was hard
to reason about and kept growing more complex every time I fixed a bug.
I replaced it with SharedBackend. The new version deletes four modules and
roughly 1,100 lines of code. More importantly, it needs no background thread at
all. The fuzzer threads themselves coordinate batches through a single mutex and
a condition variable. Here is how I got there and why it is simpler.
The old architecture
The old Client spawned a background Batcher thread at startup. The batcher
sat in a loop reading requests from a crossbeam channel, grouping them into
JSON-RPC batches, and dispatching responses back through one-shot channels.
pub struct ClientInner {
pub request_tx: RwLock<Sender<PendingRequest>>,
pub cache: Option<Arc<Cache>>,
pub dedup: Arc<DedupTable>,
}When a fuzzer thread needed data, it called Client::request. That method had
to:
- Check the in-memory cache (
DashMap). - Check the deduplication table (
DashMap+ morecrossbeamchannels) to see if another thread was already fetching the same key. - Send the request to the batcher via a bounded
crossbeamchannel. - Block on a one-shot
crossbeamchannel for the response. - Handle self-deduplication if the same key appeared twice in one request slice.
On top of that, there was a supervisor thread wrapped around the batcher in
catch_unwind. If the batcher panicked (for example, because ureq threw on
malformed JSON), the supervisor had to respawn a fresh worker and swap out the
request_tx channel so that pending requests did not get lost. This meant the
ClientInner stored the sender inside an RwLock so it could be replaced at
runtime.
The code was spread across four files:
client.rs: the public API and the supervisor logic.batcher.rs: the background worker loop.cache.rs: the two-layer cache usingDashMap.dedup.rs: the deduplication table usingDashMapandcrossbeamchannels.
That is almost 2,000 lines just to batch and cache RPC requests. And the
crossbeam channels were everywhere: one bounded channel from clients to the
batcher, one bounded channel per request from the batcher back to the client,
and one bounded channel per in-flight dedup entry for waiters.
Because the batcher could panic and restart, ForkDB (the revm DatabaseRef
implementation) had to wrap every call in a with_retry helper that retried
BatcherRestarted errors up to three times. That retry logic lived in the
database layer, which felt wrong. The database should not know that the RPC
client has a background thread that might crash.
The new architecture
SharedBackend removes the background thread entirely. Instead, the fuzzer
threads themselves collect pending requests into batches. The core idea is:
- A lock-free
papaya::HashMapserves as the global cache. Every thread sees the same map, so a value cached by one thread is instantly visible to all others. There is no separateCachemodule. - A single
parking_lot::Mutex<BatchState>+Condvarcoordinates pending requests. If a thread’s requests are all in the cache, it returns immediately without ever locking. - If a request is missing, the thread acquires the mutex, adds its requests to the pending set, and either becomes the fetcher or sleeps on the condvar until a fetcher finishes.
Here is the new SharedBackend inner state:
struct SharedBackendInner {
global_cache: PapayaMap<String, Value>,
batch_state: Mutex<BatchState>,
batch_condvar: Condvar,
batch_size: usize,
batch_timeout: Duration,
transport: Arc<dyn Transport>,
url: String,
retries: u32,
backoff: Duration,
limiter: Option<Arc<RateLimiter>>,
cache_dir: Option<PathBuf>,
}The BatchState is a simple struct:
struct BatchState {
pending: Vec<Request>,
keys: HashSet<String>,
deadline: Option<Instant>,
errors: HashMap<String, Arc<Error>>,
fetcher_in_flight: bool,
}The fetch_or_wait method has two paths.
- Fast path: check
global_cachewithout locking. If every request is cached, return immediately. - Slow path: acquire the mutex, re-check the cache under the lock (in case another thread filled it while we were waiting), add missing requests to the pending batch, and decide whether to become the fetcher or wait.
If the pending batch reaches batch_size or the deadline expires, the current
thread becomes the fetcher. It takes ownership of the pending requests, drops
the mutex while performing the HTTP call, then re-acquires the lock to publish
results and wake all waiters with notify_all.
If another thread is already fetching, the current thread simply waits on the condvar. When it wakes up, it loops back and re-checks the cache.
Because there is no background thread that can panic and restart, there is no
BatcherRestarted error. That let me delete the with_retry wrapper from
ForkDB entirely. The database layer is now a thin mapping from revm operations
to typed RPC requests:
impl DatabaseRef for ForkDB {
fn basic_ref(&self, address: Address) -> Result<Option<AccountInfo>, Self::Error> {
let responses = self.backend.fetch_or_wait(&[
Request::GetBalance { /* ... */ },
Request::GetTransactionCount { /* ... */ },
Request::GetCode { /* ... */ },
])?;
self.parse_basic_responses(responses)
}
}No retries, no channel juggling, no supervisor. Just fetch_or_wait.
What I learned
Background threads are seductive. They feel like the “right” way to handle I/O in a multithreaded system. But every thread boundary introduces channels, serialization, restart logic, and subtle race conditions. In this case, the batcher was not doing anything that the caller threads could not do themselves. It was just a middleman.
Lock-free data structures like papaya::HashMap are a great fit for read-heavy
shared caches. The fast path never touches a lock, which means the common case
(cached data) is essentially free.
Finally, I learned that shrinking the system is often better than optimizing it.
The old Client had DashMap for the cache and another DashMap for dedup, plus
all those crossbeam channels, because each piece was trying to solve a problem
created by the background thread. Once I removed the thread, I realized I did
not need most of the infrastructure. A plain Mutex and a HashMap were
enough.
The fuzzer does not care how the RPCs are batched. It just wants the data.
SharedBackend gives it that without the complex stuff.
| Tags | rust , revm , fuzzing |
|---|