Lesson 06 / 12
The WebAssembly Target
The same source needing a different body depending on the target it runs on: a body that relies on a part of the standard library (the file system) and one that does not are separated with conditional compilation, both are run on the local target, and both give the same result.
Published Updated
In this lesson
- Shows by running code on the local target how conditional compilation separates a target-dependent decision in the source
- Proves by comparison that a body relying on the file system and one that does not produce the same result
- Explains that source looking portable does not mean it is portable, and that target dependency must be read from the source
Contents
The previous lesson measured the case where the boundary sat between two languages: as long as the declaration was correct, it did not matter which processor or operating system it ran on. This lesson reverses that same independence. Here the language stays the same — the entire source is Rust — but the target it runs on changes, and some bodies cannot stay indifferent to that change. A target running in a browser does not always have access to a file system, an operating system thread, or a clock; this lesson measures how that access is made visible in the source.
The boundary in the previous lesson rested on a declaration’s correctness; the boundary here rests on the environment the target itself provides — both rest on an assumption the compiler cannot test directly. This constraint does not belong to Rust; it comes from the browser’s own security model. A browser tab keeps the code it runs in a space isolated from disk, network, and operating system; a page opening a file on its own, creating a thread, or establishing a socket runs against the purpose of that isolation. A program compiled into this space is limited to the capabilities that space offers — and these capabilities are narrower than what the same program’s version running on a server or a desktop has. Rust’s standard library does not hide this difference: part of modules such as the file system, threads, and time either do not exist at all in this isolated target, or behave differently.
In this lesson, only the local target is compiled. No real compilation is done for another
target; the target-dependent decision is shown on the local target with conditional
compilation — which of the two bodies in the source is selected on this machine is seen by
running code, while the other body sits in the source without ever being compiled. This
selection is not made at run time: the decision managed by cfg is made during
compilation, before the program starts running. It is not possible to pick one of the two
bodies inside the same binary and switch to the other at run time — whichever target it
was compiled for, that target’s body enters the binary, the other never does.
A Body Split by Target
#[cfg(...)] is not a run-time branch; it should not be confused with an if expression.
if tests a condition at run time, in a structure where both branches sit in the binary
and only one is executed. cfg, by contrast, decides at compile time: the item that does
not satisfy the condition never enters the rest of compilation at all, as if it had been
cut from the source tree entirely. The result of this difference is seen in the run below.
The run below defines two bodies that read a sensor record. One reads from the file
system; the other stands in for a body that would be compiled on the browser target, and
it is never compiled on this machine at all. Which body gets compiled is decided by the
#[cfg(...)] attribute.
LL11. The sensor record is a four-line text: 12, 7, 5, 21 — summing to 45.
// wasm1.rs — a body split by target, the branch selected on the local target
use std::fs;
#[cfg(not(target_family = "wasm"))]
fn read_records() -> String {
// Body relying on the file system: compiled only on targets that have one.
fs::read_to_string("sensor.txt").expect("could not read file")
}
#[cfg(target_family = "wasm")]
fn read_records() -> String {
// This body is compiled on the browser target; it is never compiled on the
// local target, so an undefined call carried here breaks nothing here.
body_reads_from_another_source_on_the_wasm_target()
}
fn total(text: &str) -> i32 {
text.lines().filter_map(|s| s.trim().parse::<i32>().ok()).sum()
}
fn main() {
fs::write("sensor.txt", "12\n7\n5\n21\n").expect("could not write file");
let content = read_records();
println!("file-system-backed body total: {}", total(&content));
}
file-system-backed body total: 45
There are two read_records definitions in the source, but the compiled program contains
only one. The body marked #[cfg(not(target_family = "wasm"))] entered compilation
because this machine is not part of the wasm family; the body marked
#[cfg(target_family = "wasm")] was never compiled at all — even though the call inside
it is defined nowhere, the compiler never noticed, because it never saw that body.
LL12. This shows that a body left out by cfg passes through no semantic checking at
all, as long as its syntax is valid; “not compiled” and “wrong” are not the same thing —
the compiler passes no judgment at all about a body that is not compiled.
This may look similar to the foreign function declaration in the previous lesson, but it
carries a different guarantee. An extern "C" declaration described a body the compiler
never saw but which genuinely existed and had to be linked; if the linker could not find
that name, compilation broke. A body marked #[cfg(target_family = "wasm")], by contrast,
is never looked for on this machine at all — it does not matter that the name
body_reads_from_another_source_on_the_wasm_target inside it is defined nowhere in this
program, because that name never reaches the linker. In the foreign function interface,
what was delegated was a declaration’s correctness; here nothing is delegated, because
that body is not even part of this compilation.
The .expect(...) call inside read_records is also a deliberate choice: the read is
known not to fail, because the file was written one line earlier inside the same main
function, but Rust cannot know this at compile time — read_to_string always returns a
Result, and this value has to be handled. .expect(...) unwraps that value, choosing to
stop with a panic on failure; because this lesson never triggers a panic, this path is only
ever followed in the success case.
The run above chose the form target_family = "wasm"; one of the attribute names the
language recognizes is also target_arch = "wasm32", and the two do not ask the same
question. target_family asks about the broad family a target belongs to (the wasm
family, the unix family); target_arch asks about the processor architecture’s name
(wasm32, x86_64). This lesson asked about the family, because the distinction being
measured rested not on architecture but on the class of environment the target provides —
a target without a file system is not limited to a single architecture. Which attribute
name is chosen is a decision readable from the source, and it says which question the body
is split by.
The Counterpart That Does Not Rely on the File System
A body being split by cfg does not mean that body has to keep existing. The same sensor
record can also be obtained without ever touching the file system — for this lesson, the
value is embedded inside the source. The difference between these two options is not an
implementation detail: the first assumes the data comes from a source at run time, the
second assumes the data is already known at compile time. Which assumption is valid
depends on the problem being solved itself — if the sensor record is genuinely changing
data, a file (or another source standing in for it) is needed; if it is a fixed
configuration value, an embedded constant is enough.
// wasm2.rs — a body that never relies on the file system, independent of target
const EMBEDDED_RECORDS: &str = "12\n7\n5\n21\n";
fn total(text: &str) -> i32 {
text.lines().filter_map(|s| s.trim().parse::<i32>().ok()).sum()
}
fn main() {
let total_value = total(EMBEDDED_RECORDS);
println!("embedded body total: {}", total_value);
}
embedded body total: 45
Both bodies gave the same result (45), but by different routes. For the
file-system-backed body to work, the environment had to provide a file system — it does on
this machine, but on a browser target this assumption generally does not hold, because a
browser page cannot access a file system directly. The embedded body, by contrast, depends
on no external source at all; its data is fixed inside the source itself, at compile time.
LL13. These two forms, producing the same result, show a target-independent task
(summing four numbers) being fed from a target-dependent source (a file) and a
target-independent source (an embedded constant).
Embedding the data into the source here was done with a const declaration; the value
becomes part of the binary during compilation and requires no external source at run time.
This form is not a solution limited to the browser target alone — the same approach is
used for environments where a file system does not exist at all, where only a fixed memory
layout exists. The next lesson will take up that environment directly; the constraint
there is not the browser’s isolated space but the absence of the standard library itself.
A Second Split: Threads
The file system is not the only example. The same pattern also applies to the capability
of opening multiple threads — code a browser page runs does not always have access to the
operating system’s ordinary thread-creation call. Opening a thread asks the operating
system to allocate a new unit of execution and schedule it; this is a different service
from opening a file but a request from the same class — neither can be carried out from
inside the source alone, both wait for the environment to provide something. The run below
sets up the same cfg pattern once more: the body compiled on the local target opens a
thread and collects its result, while the body written for the wasm family is again never
compiled on this machine at all.
LL14. The computed value is 6 * 7, that is 42; the value the thread returns is
collected into the main body with a join call.
// wasm3.rs — a body relying on a thread, the branch selected on the local target
use std::thread;
#[cfg(not(target_family = "wasm"))]
fn produce_value() -> i32 {
// Body that opens a thread: not compiled on targets without multithreading support.
let worker = thread::spawn(|| 6 * 7);
worker.join().expect("the thread cannot have crashed")
}
#[cfg(target_family = "wasm")]
fn produce_value() -> i32 {
computed_by_a_separate_method_on_the_wasm_target()
}
fn main() {
println!("thread-backed body: {}", produce_value());
}
thread-backed body: 42
In this run too, the difference sits in the same place: the thread::spawn call asks the
operating system to open a separate thread, and this request cannot be satisfied on every
target. The #[cfg(not(target_family = "wasm"))] label marks the body carrying this
dependency; a body performing the same calculation directly, without opening any thread,
could also have been written (6 * 7 computed on a single line), and that body would work
identically on every target. The point here is the same as the previous measurement: what
depends on the target is not the calculation itself, but how the calculation is
carried out.
There is also a difference between these two examples (file, thread): the absence of the
file system usually shows itself with an error value — the call returns a Result, and if
it fails, this is understood through that value. The state of the thread-opening
capability changes by target; on some targets the function does not exist at all, and the
source, written using that function, does not compile; on other targets it may work under
a different restriction (for instance, limited to a single thread). What is common in both
cases is that this difference has to be visible somewhere in the source — either through a
cfg label, or through how the returned value is handled.
The Limiting Measurement
Both runs above gave the same result on this machine — but that does not mean the
file-system-backed body will work on every target. The source itself already says which
part of it is target-dependent: the #[cfg(not(target_family = "wasm"))] label declares
that the body under it will be compiled on targets outside the wasm family, and the
#[cfg(target_family = "wasm")] label declares that the opposite body will be compiled
only on the wasm target. Reaching this information does not require running a program on
that target; the label itself is a declaration that can be read without looking at any
run.
The reverse also holds: a body carrying no cfg label does not automatically mean it will
work on every target. The total function carries no cfg in this lesson and is shared
by both bodies — because it only parses and sums text, it touches no external source. But
if a function carried a call that opened a thread, read the real clock, or established a
socket while carrying no cfg, this dependency would not be visible from the source — the
compiler does not reject such a call, it either gives a compile error when compiling for
that target, or produces an unexpected result at run time. Source looking portable does
not show that it is portable; which assumption is target-dependent can only be read from
the cfg labels in the source and the standard library parts used, not from a single run.
The two examples measured in this lesson (file system, thread) are not an exhaustive list,
they are two faces of the same boundary. Reading the real clock falls into a similar
class: a target may not have access to the system clock, or that access may be provided by
a different route. What the three examples share is that all of them ask a service from
the operating system or the hardware — the file system asks for a storage service, the
thread asks for a scheduling service, the clock asks for a hardware counter service. An
isolated target either does not offer some of these services at all, or offers them
through a different interface; the source stays readable to the extent that it marks which
function asks for which service, with cfg labels or with the module itself that is used.
All three are not run in this lesson; only the file system and thread examples were run,
the clock example is only mentioned because it belongs to the same class.
This readability is also why real multi-target source (libraries expected to compile
across several operating systems and architectures) tends to look full of cfg labels;
the two examples built throughout this lesson are nothing more than a small-scale model of
the patterns in that kind of source. A reliable method before porting source to a target
is to scan the source for cfg labels and calls that ask for an external service — that is
exactly what is done in this lesson, only across a single pair of targets (the local
target and the wasm family) and a single body.
Summary
- A target-dependent decision can be split into two separate bodies in the source with
#[cfg(...)]attributes; only one is compiled on the local target, the other sits in the source without ever being compiled —cfgdoes not branch at run time like anif, it decides at compile time - No real compilation was done for the browser target; the target-dependent decision was modelled through the source
- The body relying on the file system and the body that does not gave the same sensor
total (
45); the difference is not in the result but in which environment assumption they rest on - A browser target not offering some platform capabilities, such as a file system, shows that this target’s guarantee comes not from the language itself but from the target — the class is outside the language
- The file system and opening a thread are two separate instances of the same boundary: both ask a service from the operating system, and that service is not offered the same way on every target
- Source looking portable does not mean it is portable; target dependency is read from the
cfglabels and the standard library parts used, from the source — not from a run
Next Step
In this lesson, the boundary sat between two targets of the same language; the difference was which capabilities the environment offered — both the file system and threads rested on the assumption that an operating system exists, only whether that assumption was met changed from target to target. The next lesson carries this boundary to an extreme: an environment where the standard library does not exist at all, where the operating system itself is absent. There, even a target-independent form like an embedded constant will run into the question of whether memory allocation itself exists at all — in this lesson, “target-dependent” meant only a few functions; there, “target-dependent” is a constraint that determines how the program’s entire body is written.
To keep your progress and take notes, Log in
My notes
Log in to take notes.