Skip to content

Commit 25d4f1b

Browse files
authored
Merge pull request #16 from phaethix/refactor/async-runtime
Refactor/async runtime
2 parents 2526411 + 538ea08 commit 25d4f1b

12 files changed

Lines changed: 491 additions & 111 deletions

File tree

Cargo.lock

Lines changed: 61 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ signal-hook = "0.4.4"
88
log = "0.4"
99
env_logger = "0.11"
1010
indexmap = "2"
11+
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "macros", "signal", "sync", "time"] }
1112

1213
[dev-dependencies]
1314
criterion = "0.8.2"

benches/redis-benchmark.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,61 @@ redis-benchmark -h 127.0.0.1 -p 6399 -n 100000 -c 50 -q -t set,get,incr
105105
redis-benchmark -h 127.0.0.1 -p 6399 -n 100000 -c 50 -P 16 -q -t set,get,incr
106106
kill "$SERVER"
107107
```
108+
109+
---
110+
111+
## v0.4.0 — tokio async runtime (Week 8)
112+
113+
Re-ran on the same Apple M5 / macOS 26.4.1 box after the `feat/async-runtime`
114+
branch replaced the thread-per-connection model with a multi-threaded tokio
115+
runtime. The server binary, the benchmark client and the command mix are
116+
identical to the v0.3.0 runs above so the deltas are apples-to-apples.
117+
118+
### Scenario 1 — no memory cap, `c=50`
119+
120+
| Command | v0.3.0 QPS | v0.4.0 QPS | Δ |
121+
| ------- | ---------- | ---------- | ------ |
122+
| SET | 58 207 | 62 189 | +6.8% |
123+
| GET | 61 349 | 65 231 | +6.3% |
124+
| INCR | 61 728 | 63 492 | +2.9% |
125+
126+
### Scenario 2 — pipelined (`-P 16`, `c=50`)
127+
128+
| Command | v0.3.0 QPS | v0.4.0 QPS | Δ |
129+
| ------- | ---------- | ---------- | ------ |
130+
| SET | 366 300 | 350 877 | -4.2% |
131+
| GET | 373 134 | 378 787 | +1.5% |
132+
| INCR | 375 939 | 381 679 | +1.5% |
133+
134+
> Pipelined throughput is dominated by in-memory command execution rather
135+
> than socket I/O, so the delta is inside run-to-run noise. What matters
136+
> is that the async rewrite did **not** regress the hot path.
137+
138+
### Scenario 5 — high client fan-out (`c=500`)
139+
140+
```
141+
./target/release/ferrum-kv --addr 127.0.0.1:6399
142+
redis-benchmark -h 127.0.0.1 -p 6399 -n 100000 -c 500 -q -t set,get
143+
```
144+
145+
| Command | v0.4.0 QPS | p50 |
146+
| ------- | ---------- | -------- |
147+
| SET | 50 301 | 4.567 ms |
148+
| GET | 43 440 | 5.871 ms |
149+
150+
> The pre-tokio build would have tried to spawn 500 OS threads here — one
151+
> per accepted connection — and spent most of its time context switching.
152+
> v0.4.0 handles the whole fan-out on a handful of worker threads with
153+
> single-digit-millisecond p50, the clearest win of Phase 8.
154+
155+
### Observations
156+
157+
- Single-client-per-thread throughput nudged up ~5% because tokio replaces
158+
two blocking `read`/`write` syscalls per round-trip with `epoll` /
159+
`kqueue`-backed readiness checks that batch more efficiently under load.
160+
- The thread-per-connection floor is gone: scaling `-c` past the CPU count
161+
no longer multiplies kernel threads; the runtime just multiplexes more
162+
sockets onto the same workers.
163+
- The integration suite's 500-client PING fan-out finishes in under 300 ms
164+
on the same box, confirming the smoke result is not a microbenchmark
165+
artefact.

ferrum.conf.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,10 @@ loglevel info
5353

5454
# How many random candidates to inspect per eviction round (Redis default: 5).
5555
# maxmemory-samples 5
56+
57+
# ---- Runtime -------------------------------------------------------
58+
# Number of tokio worker threads driving accept/read/write loops.
59+
# 0 (or unset) means "one per logical CPU", which is the right default
60+
# for most deployments. Bump it higher only if you are pinning other
61+
# workloads to specific cores and want to cap FerrumKV's share.
62+
# io-threads 0

src/cli.rs

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub const USAGE: &str = concat!(
2424
" [--appendfsync always|everysec|no]\n",
2525
" [--client-timeout SECONDS] [--maxclients N]\n",
2626
" [--maxmemory BYTES] [--maxmemory-policy POLICY]\n",
27-
" [--maxmemory-samples N]\n",
27+
" [--maxmemory-samples N] [--io-threads N]\n",
2828
" [--loglevel off|error|warn|info|debug|trace]"
2929
);
3030

@@ -60,6 +60,9 @@ pub struct CliArgs {
6060
max_memory_policy: Option<EvictionPolicy>,
6161
/// How many random candidates each eviction round considers.
6262
max_memory_samples: Option<usize>,
63+
/// Number of tokio worker threads; `0` (unset) asks tokio for the
64+
/// default (usually one per logical CPU).
65+
io_threads: Option<usize>,
6366
}
6467

6568
/// Raw, un-merged values taken verbatim from the command line.
@@ -81,6 +84,7 @@ struct RawFlags {
8184
max_memory: Option<u64>,
8285
max_memory_policy: Option<EvictionPolicy>,
8386
max_memory_samples: Option<usize>,
87+
io_threads: Option<usize>,
8488
/// Whether AOF was explicitly enabled via the config file's `appendonly yes`.
8589
/// CLI `--aof-path` implies enabled; this field only carries the file's
8690
/// intent so that a later merge step can decide.
@@ -92,7 +96,7 @@ impl CliArgs {
9296
pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Invocation, String> {
9397
let raw = match scan_argv(args)? {
9498
ScanOutcome::Help => return Ok(Invocation::Help),
95-
ScanOutcome::Flags(f) => f,
99+
ScanOutcome::Flags(f) => *f,
96100
};
97101

98102
let file_cfg = if let Some(path) = raw.config_path.as_deref() {
@@ -127,6 +131,12 @@ impl CliArgs {
127131
self.loglevel.as_deref()
128132
}
129133

134+
/// Returns the explicit tokio worker thread count. `None` means
135+
/// "let tokio pick", which is what most deployments want.
136+
pub fn io_threads(&self) -> Option<usize> {
137+
self.io_threads
138+
}
139+
130140
/// Returns the resolved eviction configuration. Defaults (unlimited
131141
/// memory, `noeviction`, 5 samples) apply when neither CLI nor config
132142
/// file specifies a value.
@@ -142,7 +152,7 @@ impl CliArgs {
142152

143153
enum ScanOutcome {
144154
Help,
145-
Flags(RawFlags),
155+
Flags(Box<RawFlags>),
146156
}
147157

148158
/// First pass: walk `argv` and record every flag we recognise without
@@ -210,12 +220,18 @@ fn scan_argv<I: IntoIterator<Item = String>>(args: I) -> Result<ScanOutcome, Str
210220
format!("invalid --maxmemory-samples: '{value}' is not a non-negative integer")
211221
})?);
212222
}
223+
"--io-threads" => {
224+
let value = take_value(&mut iter, "--io-threads")?;
225+
raw.io_threads = Some(value.parse().map_err(|_| {
226+
format!("invalid --io-threads: '{value}' is not a non-negative integer")
227+
})?);
228+
}
213229
"-h" | "--help" => return Ok(ScanOutcome::Help),
214230
other => return Err(format!("unrecognised argument: '{other}'")),
215231
}
216232
}
217233

218-
Ok(ScanOutcome::Flags(raw))
234+
Ok(ScanOutcome::Flags(Box::new(raw)))
219235
}
220236

221237
/// Merges `raw` flags with an optional [`FileConfig`], applying defaults for
@@ -278,6 +294,7 @@ fn merge(raw: RawFlags, file: Option<&FileConfig>) -> Result<CliArgs, String> {
278294
let max_memory_samples = raw
279295
.max_memory_samples
280296
.or_else(|| file.and_then(|f| f.max_memory_samples));
297+
let io_threads = raw.io_threads.or_else(|| file.and_then(|f| f.io_threads));
281298

282299
Ok(CliArgs {
283300
addr,
@@ -289,6 +306,7 @@ fn merge(raw: RawFlags, file: Option<&FileConfig>) -> Result<CliArgs, String> {
289306
max_memory,
290307
max_memory_policy,
291308
max_memory_samples,
309+
io_threads,
292310
})
293311
}
294312

@@ -649,4 +667,35 @@ mod tests {
649667
assert_eq!(cfg.policy, EvictionPolicy::AllKeysLru);
650668
assert_eq!(cfg.samples, 3);
651669
}
670+
671+
#[test]
672+
fn io_threads_defaults_to_none() {
673+
assert!(parse_run(&[]).io_threads().is_none());
674+
}
675+
676+
#[test]
677+
fn io_threads_flag_is_parsed() {
678+
assert_eq!(parse_run(&["--io-threads", "4"]).io_threads(), Some(4));
679+
assert_eq!(parse_run(&["--io-threads", "0"]).io_threads(), Some(0));
680+
}
681+
682+
#[test]
683+
fn io_threads_rejects_non_integer() {
684+
let err = parse(&["--io-threads", "lots"]).unwrap_err();
685+
assert!(err.contains("--io-threads"));
686+
}
687+
688+
#[test]
689+
fn io_threads_from_config_file() {
690+
let conf = TempConf::new("io-threads", "io-threads 8\n");
691+
let args = parse_run(&["--config", conf.path.to_str().unwrap()]);
692+
assert_eq!(args.io_threads(), Some(8));
693+
}
694+
695+
#[test]
696+
fn cli_io_threads_overrides_config_file() {
697+
let conf = TempConf::new("io-threads-override", "io-threads 8\n");
698+
let args = parse_run(&["--config", conf.path.to_str().unwrap(), "--io-threads", "2"]);
699+
assert_eq!(args.io_threads(), Some(2));
700+
}
652701
}

src/config/file.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ pub struct FileConfig {
4242
pub max_memory_policy: Option<EvictionPolicy>,
4343
/// Number of keys inspected per eviction round.
4444
pub max_memory_samples: Option<usize>,
45+
/// Number of tokio worker threads. `0` (or unset) lets the runtime
46+
/// pick a sensible default (usually the logical CPU count).
47+
pub io_threads: Option<usize>,
4548
}
4649

4750
/// Error type for configuration file parsing.
@@ -180,6 +183,9 @@ fn apply_directive(cfg: &mut FileConfig, key: &str, value: &str) -> Result<(), S
180183
"maxmemory-samples" => {
181184
cfg.max_memory_samples = Some(parse_usize(value, "maxmemory-samples")?);
182185
}
186+
"io-threads" => {
187+
cfg.io_threads = Some(parse_usize(value, "io-threads")?);
188+
}
183189
other => return Err(format!("unknown directive '{other}'")),
184190
}
185191
Ok(())
@@ -389,4 +395,11 @@ mod tests {
389395
Some(12),
390396
);
391397
}
398+
399+
#[test]
400+
fn io_threads_is_parsed() {
401+
assert_eq!(parse("io-threads 4\n").unwrap().io_threads, Some(4));
402+
assert_eq!(parse("io-threads 0\n").unwrap().io_threads, Some(0));
403+
assert!(parse("io-threads nope\n").is_err());
404+
}
392405
}

src/main.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ fn main() -> ExitCode {
8989
max_clients: args
9090
.max_clients()
9191
.unwrap_or_else(|| ServerConfig::default().max_clients),
92+
worker_threads: args.io_threads().unwrap_or(0),
9293
};
9394
match server_config.client_timeout {
9495
Some(d) => info!("client idle timeout: {}s", d.as_secs()),
@@ -99,6 +100,11 @@ fn main() -> ExitCode {
99100
} else {
100101
info!("maxclients: {}", server_config.max_clients);
101102
}
103+
if server_config.worker_threads == 0 {
104+
info!("io-threads: auto (one per logical CPU)");
105+
} else {
106+
info!("io-threads: {}", server_config.worker_threads);
107+
}
102108

103109
let expire_handle = expire::spawn(engine.clone(), shutdown.clone());
104110

@@ -176,9 +182,11 @@ fn build_engine(args: &CliArgs) -> Result<KvEngine, ExitCode> {
176182
}
177183
}
178184

179-
/// Installs SIGINT/SIGTERM handlers that flip the shared shutdown flag and
180-
/// self-connect to the listener so the blocked `accept` returns immediately.
181-
fn install_signal_handlers(shutdown: Shutdown, wake_addr: SocketAddr) -> std::io::Result<()> {
185+
/// Installs SIGINT/SIGTERM handlers that flip the shared shutdown flag. The
186+
/// async accept loop observes the flag via `Shutdown::notified` and exits on
187+
/// the next scheduler poll, so we no longer need the self-connect wake-up
188+
/// trick that the blocking listener required.
189+
fn install_signal_handlers(shutdown: Shutdown, _wake_addr: SocketAddr) -> std::io::Result<()> {
182190
use signal_hook::consts::{SIGINT, SIGTERM};
183191
use signal_hook::iterator::Signals;
184192

@@ -197,7 +205,6 @@ fn install_signal_handlers(shutdown: Shutdown, wake_addr: SocketAddr) -> std::io
197205
};
198206
warn!("received {name}, initiating graceful shutdown");
199207
shutdown.trigger();
200-
Shutdown::wake_listener(wake_addr);
201208
}
202209
})?;
203210
Ok(())

0 commit comments

Comments
 (0)