Skip to content
Select theme

Handling concurrent connections

Step 4 of 10. Step 3 ended at the tag mini-ch03 with a server that decodes frames off a buffered connection and answers PING and ECHO — one client at a time, because serve runs inside the accept loop. This step starts from that tag and ends at mini-ch04: an accept loop that spawns a task per client, one Arc<Db> shared between them, and SET, GET, DEL, LPUSH and LRANGE on top. It is the first step to reach outside the standard library, and the first with real shared mutable state.

The gap is worth seeing before it is fixed. The mini-ch03 source was rebuilt on a scratch port — the machine’s real redis-server owns 6379, the same reason step 3’s cargo run stopped before it listened — and two clients were pointed at it. Client A opens a connection, sends half a PING, and thinks about the rest. Client B sends one complete, valid command:

A: sent half a PING, then thought about it
B: ECHO hello -> nothing within a second
A: sent the rest -> b'+PONG\r\n'
B: still waiting -> nothing within a second
A: hung up
B: now -> b'$5\r\nhello\r\n'

B’s command was not merely delayed. It was not accepted until A’s connection closed, because the accept loop was busy inside serve(A), and serve does not return until its client hangs up. A client that opens a connection and sits on a half-written frame holds the whole server. One task per connection is the fix, and Tokio is how this track gets it.

Tokio lands as one dependency, and five features of it:

Cargo.toml
[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util", "time"] }
  • macros — #[tokio::main], which wraps main’s body in a runtime, and #[tokio::test], which does the same for a test.
  • rt-multi-thread — the default runtime, a pool of worker threads. On the twelve-core machine this was written on it starts twelve of them; flavor = "current_thread" on #[tokio::main] picks the single-threaded runtime instead.
  • net — TcpListener and TcpStream, the async pair.
  • io-util — the async read and write_all methods the connection calls.
  • time — one sleep in the tests, which makes a split read real.

tokio resolved to 1.54.1 here. With that in place, main becomes five lines of new shape:

serve is stubbed there — the real one is at the end of this step — because the point is the loop. listener.accept().await hands back one connection; tokio::spawn starts a task that owns it; the loop goes straight back to accepting. The spawned future has to be 'static and Send, so it cannot borrow the store: it takes a clone of the Arc, which is one atomic increment on the reference count, not a copy of the map.

Forgetting that clone is the mistake this loop invites, and the compiler catches it before the binary exists:

error[E0382]: the type `Arc` does not implement `Copy`
--> src/main.rs:29:22
|
26 | let db = new_db();
| -- this move could be avoided by cloning the original `Arc`, which is inexpensive
27 | loop {
| ---- inside of this loop
28 | let (stream, _) = listener.accept().await?;
29 | tokio::spawn(async move { serve(stream, db).await });
| ^^^^^^^^^^ -- use occurs due to use in coroutine
| |
| value moved here, in previous iteration of loop
|
= note: consider using `Arc::clone`

The loop is the giveaway: the move happens on the first pass and the error surfaces on the second. Arc::clone is the fix, and the error’s own note says so.

Connection gains a runtime and loses nothing. The step-3 loop is the step-4 loop with one .await added to the read — let n = self.stream.read(&mut chunk).await?; — and nothing else moved. Incomplete still falls through to another read, Invalid still becomes an io::Error, a zero-byte read is still a hangup. None of that depended on the socket blocking, because all of it is about the bytes in the buffer. What changes is that read now parks the task instead of the thread, and a parked task costs nothing while it waits. The full loop is in the file at the end of this step.

Every task now touches the same map, so the map needs a lock, and the lock is where this kind of server falls over:

use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
/// What a key holds: a string, or a list of strings.
enum Value {
String(Vec<u8>),
List(VecDeque<Vec<u8>>),
}
/// The store, shared by every connection's task. A standard-library
/// `Mutex`, not a Tokio one: every critical section in `execute` is a
/// couple of hash-map operations with no `.await` inside, so a
/// synchronous lock is both the cheaper choice and the one that cannot be
/// held across a suspension point.
type Db = Arc<Mutex<HashMap<Vec<u8>, Value>>>;
fn new_db() -> Db {
Arc::new(Mutex::new(HashMap::new()))
}

Keys are Vec<u8>, not String, for the same reason Frame::Bulk holds bytes: the codec already treats a payload as bytes, and the real server’s keys are byte strings too. Nothing downstream needs them to be text.

The lock choice deserves a sentence, because both kinds exist. A tokio::sync::Mutex makes acquiring the lock an await point of its own, which only pays off when the guard has to live across another await. Here no critical section contains an await, so the standard-library lock is the right one — and the dangerous version fails loudly: hold a MutexGuard across an .await and the future stops being Send, so tokio::spawn refuses to take it. The remaining hazard of a synchronous lock is a long critical section, which blocks the worker thread and every task on it; the sections here are a couple of hash-map operations long.

lock().unwrap() also deserves its honesty: a panic while a guard is alive poisons the mutex, and every later lock().unwrap() panics with it. On this machine the poison message reads poisoned lock: another task failed inside. Keeping the sections short is what keeps that theoretical.

Six commands now, and the command table stops being a chain of if name == b"…". Parsing moves into Command::from_frame, which only looks at the frame; execute is the sole place that touches the store, and it stays synchronous — the lock is taken and dropped inside it, never across an .await. Value is the two shapes a key can hold, SET replaces whatever was there (a list included), LPUSH prepends each value in order through a VecDeque, and LRANGE clamps its two indices the way the real server does. All of it is in the block at the end of this step, tests included; the capture below is what each reply was matched against.

Every reply the command layer produces was captured off the real redis-server 7.2.4 first, over a raw socket, before the Rust was written:

Request Real server replies
SET greeting hello +OK
GET greeting $5\r\nhello\r\n
GET nope $-1\r\n
LPUSH queue job1 job2 job3 :3\r\n
LRANGE queue 0 -1 *3\r\n$4\r\njob3\r\n$4\r\njob2\r\n$4\r\njob1\r\n
LRANGE queue -2 -1 *2\r\n$4\r\njob2\r\n$4\r\njob1\r\n
LRANGE queue 5 10 *0\r\n
DEL greeting :1\r\n
DEL greeting, again :0\r\n
GET queue (a list) -WRONGTYPE Operation against a key holding the wrong kind of value
SET queue x (a list) +OK
SET k -ERR wrong number of arguments for 'set' command
LRANGE k a b -ERR value is not an integer or out of range
FOO bar baz -ERR unknown command 'FOO', with args beginning with: 'bar' 'baz'

Three of those are not obvious. SET on a key that holds a list answers +OK, not WRONGTYPE — it replaces the value. LRANGE on a missing key answers an empty array, not a nil. And the wrong-arity error quotes the command name as the client sent it: SET uppercase is answered 'SET', because the real server echoes the bytes rather than its own lowercased lookup key.

The argument checks came from the same captures. A null bulk anywhere in the request array answers -ERR Protocol error: invalid bulk length; any other non-bulk type answers -ERR Protocol error: expected '$', got '<byte>', where the byte is the one that arrived — and that check runs before the command is looked up, which is why FOO :1 answers a protocol error and not an unknown-command error. parse_int follows the same discipline: str::parse accepts +5, 007 and -0, and the real server refuses all three, along with anything outside i64.

The unknown-command reply lists its arguments, quoted and space-separated, capped at 128 bytes so a typo cannot become a large error. Thirty short arguments stop after the twenty-third on the real server; the cap in the clone is the same one, and a test pins the exact reply.

The block below is the tag’s file with two additions and a new main: the port moves to an ephemeral one, and two clients are driven from main instead of a command line. show prints a frame the way the server wrote it, and accept_loop is the tag’s accept loop lifted into a function so the demo can spawn it. Everything else is the tag’s code byte for byte, tests aside. Client A opens a connection, sends half a PING, and waits; client B runs a whole script while A’s frame is still open.

B: SET greeting hello -> +OK
B: GET greeting -> $5 hello
B: LPUSH queue job1 job2 job3 -> :3
B: LRANGE queue 0 -1 -> *3 [$4 job3, $4 job2, $4 job1]
B: LRANGE queue -2 -1 -> *2 [$4 job2, $4 job1]
B: DEL greeting -> :1
B: GET greeting, deleted -> $-1
B: done, while A's PING is still half-sent
A: PING -> +PONG

Seven replies, all of them while A’s PING is still half-sent — the thing the step-3 server could not do. The last line is the proof in the other direction: A’s PING was never lost, only waiting, and it completes once B is done.

The tag mini-ch04 is one file, and this is it byte for byte. The codec is unchanged from step 2, Connection is the step-3 reader with one .await added, and everything from Value down is new.

Thirty-two tests, up from thirteen: the step 3 set carried over — the socket ones rewritten as #[tokio::test] against the same spawn_server helper — plus nineteen for the new behaviour. Two of them are the step’s whole claim in test form: one client writes, a second connection reads it back; and a half-sent command on one connection does not stop another connection’s script from running to completion.

$ cargo test
Compiling mini-redis v0.1.0
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.32s
Running unittests src/main.rs (/Volumes/RustBuild/tracks/debug/deps/mini_redis-be061b86284cc959)
running 32 tests
test tests::a_frame_in_two_pieces_is_incomplete_not_invalid ... ok
test tests::bytes_that_are_not_resp_are_invalid ... ok
test tests::an_empty_array_is_not_a_command ... ok
test tests::an_unknown_command_caps_its_argument_list_at_128_bytes ... ok
test tests::an_unknown_command_lists_its_arguments ... ok
test tests::echo_with_the_wrong_number_of_arguments_is_refused ... ok
test tests::echo_round_trips_through_the_codec ... ok
test tests::del_counts_only_the_keys_that_existed ... ok
test tests::error_messages_quote_the_command_name_as_it_was_sent ... ok
test tests::every_argument_must_be_a_bulk_string ... ok
test tests::every_frame_type_round_trips ... ok
test tests::get_on_a_list_is_a_wrongtype_error ... ok
test tests::get_on_a_missing_key_answers_null ... ok
test tests::lpush_needs_at_least_one_value ... ok
test tests::lpush_on_a_string_key_is_a_wrongtype_error ... ok
test tests::lpush_prepends_each_value_in_order ... ok
test tests::lrange_on_a_missing_key_answers_an_empty_array ... ok
test tests::lrange_on_a_string_key_is_a_wrongtype_error ... ok
test tests::lrange_reads_a_slice_the_way_the_real_server_clamps_it ... ok
test tests::lrange_with_a_non_integer_index_is_refused ... ok
test tests::one_decode_consumes_exactly_one_frame ... ok
test tests::ping_with_an_argument_echoes_it ... ok
test tests::a_buffered_second_frame_needs_no_read ... ok
test tests::set_replaces_a_list_without_an_error ... ok
test tests::set_then_get_round_trips ... ok
test tests::the_exact_bytes_the_real_redis_cli_sends_for_ping ... ok
test tests::ping_over_a_real_socket ... ok
test tests::pipelined_commands_are_all_answered ... ok
test tests::a_half_sent_command_does_not_block_another_connection ... ok
test tests::a_protocol_error_ends_the_connection_without_a_reply ... ok
test tests::two_connections_share_one_store ... ok
test tests::a_frame_split_across_two_reads_is_reassembled ... ok
test result: ok. 32 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s

The order the tests print in varies between runs; the set does not. The Running line names this machine’s shared build cache; elsewhere it reads target/debug/deps/mini_redis-….

cargo run stops before it listens, because a real redis-server already owns 127.0.0.1:6379:

$ cargo run
Running `/Volumes/RustBuild/tracks/debug/mini-redis`
Error: Os { code: 48, kind: AddrInUse, message: "Address already in use" }

To check the clone without disturbing the server that owns the port, the same binary was rebuilt on a scratch port and the real redis-cli pointed at it:

$ redis-cli -p 7777 ping
PONG
$ redis-cli -p 7777 set greeting hello
OK
$ redis-cli -p 7777 get greeting
"hello"
$ redis-cli -p 7777 get nope
(nil)
$ redis-cli -p 7777 lpush queue job1 job2 job3
(integer) 3
$ redis-cli -p 7777 lrange queue 0 -1
1) "job3"
2) "job2"
3) "job1"
$ redis-cli -p 7777 lrange queue -2 -1
1) "job2"
2) "job1"
$ redis-cli -p 7777 lrange queue 5 10
(empty array)
$ redis-cli -p 7777 del greeting
(integer) 1
$ redis-cli -p 7777 del greeting
(integer) 0
$ redis-cli -p 7777 get greeting
(nil)
$ redis-cli -p 7777 set greeting
(error) ERR wrong number of arguments for 'set' command
$ redis-cli -p 7777 foo bar baz
(error) ERR unknown command 'foo', with args beginning with: 'bar' 'baz'
$ redis-cli -p 7777 get queue
(error) WRONGTYPE Operation against a key holding the wrong kind of value
$ redis-cli -p 7777 set queue replaced
OK
$ redis-cli -p 7777 get queue
"replaced"
$ redis-cli -p 7777 del queue
(integer) 1

One more check, and it is the one that matters: fifty-six request frames — every command, every arity error, the protocol-error matrix, the integer-format battery and the 128-byte cap — were sent to the real server and to the clone, and the replies compared byte for byte. Fifty-five matched. The one that did not is SET k v extra: the real server answers -ERR syntax error, because its SET has optional flags after the value, and the clone answers the wrong-arity error, because its SET takes exactly two arguments. That is a real difference, not a formatting one, and it is listed below rather than papered over.

  • Expiry. EXPIRE, TTL and the sweeper are step 5, and they are why Value has no time-to-live field yet.
  • SET has no flags. No NX, XX, GET, EX; extra arguments are the wrong-arity error, where the real server answers a syntax error.
  • The buffer has no cap. Unchanged from step 3. The real server caps a single bulk string at proto-max-bulk-len — 536870912 on this machine — and keeps per-client query buffers besides.
  • Inline commands are still not spoken. A bare PING\r\n with no array around it is answered by the real server; the clone accepts arrays only.
  • A non-array request diverges. The real server falls back to inline parsing, so :5\r\n becomes the unknown command :5; the clone answers ERR expected an array of bulk strings.
  • An empty array diverges too. *0\r\n gets no reply at all from the real server; the clone answers an error.
  • A panic in a task is swallowed. The JoinHandle from tokio::spawn is dropped, so a panicking task reports nothing, and the accept loop keeps going.
  • An accept error is fatal. listener.accept().await? propagates out of main, where the real server logs and continues.
  • One global lock. Every command serialises on it, which is fine at this size and is what step 10 replaces with a sharded store.

Step 5, Storing values with expiry, attaches a time to live to each key, answers EXPIRE and TTL, and adds the sweeper that reclaims expired keys without blocking the server. It is not written yet, so it gets no link — the track overview carries the full list as articles land.