Rust Send/Sync Lab

What does the compiler know about threads? Two captured refusals, the program it accepts, and the same shape in Go — where the compiler knows nothing and a runtime sampler finds the race instead.

The finding. Rust knows two facts and enforces them at every boundary: one type may be moved to another thread, another may be shared between two at once. Neither is written by hand in any program below — they are properties of the types, so the refusals arrive as ordinary trait-bound errors. Go has no such fact to know. Its compiler is silent, its static analyzer is silent, and what finds the race is a detector that runs alongside the program and reports what it happens to observe.

Nothing on this page touches a chain.

Captured output, not a compiler or a program running here

No compiler runs when you open this page, nothing is executed and nothing is fetched. A committed harness ran the Rust build, built the Go program with the race detector linked in, ran go vet over it, ran the instrumented binary, and wrote all of it to JSON — which is compiled into this binary.

There is no thread diagram on this page, and that is deliberate. You will not find a timeline, a swimlane or an arrow between two goroutines — not because such a picture would be hard to draw, but because the capture does not contain one. Drawing it would mean this page teaching you something it did not measure.

The harness is pigfox/rust-send-sync-lab, and the fixtures below live at commit 39ea922.

2 refused, 1 accepted

Those two numbers are read out of the capture rather than counted from the walkthrough below. cargo emits a diagnostic for a target it refuses and an artifact record for one it builds; the harness indexes both, per target, because an acceptance is otherwise silent — a program that compiled says nothing, which is exactly what a program that was never built also says.

Targets rejected
2
Targets accepted
1
Rust command
cargo build --message-format=json --keep-going

Refused, refused, accepted

Both refusals carry the same error code, and it is not a code about threads. It is the ordinary trait-bound error — the one that reports a missing Display — pointed at a marker nobody writes an implementation for. That is the point rather than a detail: thread safety here is not a special case in the compiler.

Step 1 Move it to another thread

E0277 Send src/main.rs

An `Rc` is a reference count kept with ordinary non-atomic arithmetic — correct and fast on one thread, and a data race on two. Nothing in the program mentions thread safety: the constraint is carried by the TYPE and checked wherever the type goes. Read the error code. It is the ordinary trait-bound error, the same one that reports a missing `Display`, pointed at a marker trait nobody writes an impl for.

error[E0277]: `Rc<i32>` cannot be sent between threads safely

31 |     let handle = thread::spawn(move || {
  |                                ^^^^^^^^^ `Rc<i32>` cannot be sent between threads safely
32 |         println!("{moved}");
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `Rc<i32>` cannot be sent between threads safely
33 |     });
  | ^^^^^ `Rc<i32>` cannot be sent between threads safely

The error points at 2 further places. A trait bound is not violated at one line; it is required at a call and unsatisfied by a type, and both ends have to be named.

31 |     let handle = thread::spawn(move || {
  |                                ------- within this `{closure@src/main.rs:31:32: 31:39}`
31 |     let handle = thread::spawn(move || {
  |                  ------------- required by a bound introduced by this call

rustc offers no edit for this one, and there is nothing evasive about that: no change to any single line makes the type satisfy the bound. The fix is to use a type that does, which is what the last step shows.

Step 2 Share it between two threads

E0277 Sync src/bin/not_sync.rs

The other half of the pair, and the half people forget. `Send` is about moving a value TO another thread; `Sync` is about two threads holding a reference to the same value AT ONCE. A `Cell` can be moved safely and cannot be shared, because mutation through a shared reference with no synchronization is its whole purpose. The scoped thread is what makes this the only objection left: it may borrow from the stack, so the lifetime complaint that would otherwise mask this one is gone.

error[E0277]: `Cell<i32>` cannot be shared between threads safely

27 |         s.spawn(|| {
  |                 ^^^^ `Cell<i32>` cannot be shared between threads safely
28 |             counter.set(counter.get() + 1);
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `Cell<i32>` cannot be shared between threads safely
29 |         });
  | ^^^^^^^^^ `Cell<i32>` cannot be shared between threads safely

The error points at 1 further places. A trait bound is not violated at one line; it is required at a call and unsatisfied by a type, and both ends have to be named.

27 |         s.spawn(|| {
  |           ----- required by a bound introduced by this call

rustc offers no edit for this one, and there is nothing evasive about that: no change to any single line makes the type satisfy the bound. The fix is to use a type that does, which is what the last step shows.

Step 3 The same program, accepted

no error Send + Sync src/bin/arc_mutex_ok.rs

`Arc` is `Rc` with atomic reference counting, so it is `Send`. `Mutex` makes its contents reachable only through a lock, so `Arc<Mutex<T>>` is `Sync`. Both are properties of the types rather than of this program, and the compiler checks them at the same boundary that refused the other two. The fix for both refusals was to name the right type, not to restructure anything.

The compiler accepted this one. There is no diagnostic to show, because a program that compiles produces none — so what is below is the source itself, read off disk by the harness and recorded beside the two refusals.

29 | fn main() {
30 |     let counter = Arc::new(Mutex::new(0_i32));
31 |     let mut handles = Vec::new();
32 | 
33 |     for _ in 0..2 {
34 |         let mine = Arc::clone(&counter);
35 |         handles.push(thread::spawn(move || {
36 |             let mut guard = mine.lock().expect("the mutex is not poisoned");
37 |             *guard += 1;
38 |         }));
39 |     }
40 | 
41 |     for h in handles {
42 |         h.join().expect("the thread did not panic");
43 |     }
44 | 
45 |     println!("{}", counter.lock().expect("the mutex is not poisoned"));
46 | }

That is the function, not the whole file — the recorded source starts at src/bin/arc_mutex_ok.rs line 1 with a comment header the harness keeps and this page does not print. Follow the link above to read it entire.

What this does not claim: the accepted program is free of the class of bug the two markers are about. It is not thereby free of deadlocks, of lock-ordering mistakes, or of logic that is wrong while being perfectly synchronized. The compiler knows about one class and nothing about the rest.

What Go's static tools said about the same shape

Nothing, and both of them were asked. Go has no marker trait and no bound to violate: a value that must not cross a goroutine boundary is indistinguishable, to the type system, from one that may. So there is no second set of diagnostics to put beside the ones above.

Build
go build -race -o <tmp> . — exit 0
Static analysis
go vet ./... — exit 0
Bytes the two printed
0
Recorded as
staticToolsSaidNothing = true

That last line is a control, not a claim. The harness computes it from both output streams of both tools and refuses the whole capture if it is ever false, so a page carrying this section cannot have been built from a capture where either of them spoke.

What found it instead

A detector, linked into the binary at build time and running alongside the program. It is not an analysis: it watches memory accesses as they happen and reports the ones it can prove were unordered. The Go program below has two goroutines writing to one variable with nothing between them, and this is what came out.

==================
WARNING: DATA RACE
Write at 0x<addr> by goroutine <id>:
  main.write()
      go/main.go:51 +0x<offset>
  main.main.gowrap1()
      go/main.go:71 +0x<offset>

Previous write at 0x<addr> by goroutine <id>:
  main.write()
      go/main.go:51 +0x<offset>
  main.main.gowrap1()
      go/main.go:71 +0x<offset>

Goroutine <id> (<state>) created at:
  main.main()
      go/main.go:71 +0x<offset>

Goroutine <id> (<state>) created at:
  main.main()
      go/main.go:71 +0x<offset>
==================
Found 1 data race(s)

The bracketed tokens above were put there by the capture, not printed by the detector. Each one stood for something that names this machine and this run rather than the program, and would be different the next time:

<id>
a goroutine id, which the scheduler assigns and which differs on every run
<addr>
the address of the racing variable in this process
<offset>
a byte offset into a compiled function
<state>
whether that goroutine was still running when the report was written

The verbatim report is kept in the fixture beside this one — 497 bytes of it — so that “only the machine detail was removed” can be checked rather than taken on trust. It is not printed here because an address and two goroutine ids mean nothing to a reader and would be wrong by the next run.

Run
<tmp> — exit 66
Recorded as
raceDetected = true

And this is the part that does not compare like for like. The refusals above are a guarantee: those programs cannot be built, on any machine, on any run. This is an observation — the detector reports what it happened to see while it was watching, so a race in a path that did not execute, or two accesses that happened not to interleave this time, produce exactly the output a correct program produces. The capture requires both the detector's banner and its exit code before it will record a detection, because a run where it saw nothing is the sampler's weakness rather than a clean program.

Where all of this came from

rustc
rustc 1.93.0 (254b59607 2026-01-19)
cargo
cargo 1.93.0 (083ac5135 2025-12-15)
Toolchain in force
1.93.0-x86_64-unknown-linux-gnu
Rust command
cargo build --message-format=json --keep-going — exit 101
Go
go version go1.26.5 linux/amd64
Captured
2026-08-16T22:49:48Z
Captured from
34a844931bdf856a2b539c6158f76ad2ae6ac3d8
Committed at
39ea922

The Rust build exited 101 and the Go run exited 66, and both are the intended outcome rather than a failure of the capture. cargo reports a non-zero exit when any target fails, so its code alone cannot say which of the three did what — the target index above is what can. The Go code is the detector's own: an instrumented binary exits with it when it reports a race, so a clean exit there would mean the detector watched this program and saw nothing.

Two Go versions appear in that capture, and only one built anything. The capture machine's default toolchain is go version go1.26.3 linux/amd64; the harness module asks for a newer one, and with GOTOOLCHAIN=auto Go switches to it silently for any command run inside that module.

The two commit identifiers above differ, and they always will. A capture reads the working tree, writes the fixture, and only then is the fixture committed — so the sources that produced these bytes are one commit earlier than the commit carrying them.

What was dropped on the way here. The Rust fixture is 21485 bytes in this repository and 41944 in the harness. Two fields were removed: the text of rustc --explain for the error code, once per diagnostic, which this page does not render; and the verbatim cargo output, which duplicates the parsed diagnostics beside it. The target index and the accepted target's source were kept — they are the only record that one of the three programs compiled. The detector capture is committed whole, raw report included, for the reason given above it.

Neither of these fixtures is byte-reproducible, and they are irreproducible for different reasons. cargo compiles the three binaries in parallel, so its diagnostics arrive in whatever order they finish; the harness compares two Rust captures through a projection that sorts those streams. The detector's report varies in its content rather than its order, so it is compared after normalization instead. Both rules are tested in both directions in the harness — a reordering or a changed id must not read as a difference, and a moved line number or a renamed function must.