Everything on this page is unconditional -- naming a global in a write frame, the read-only-outside-the-module rule,
^atomic, and^thread-localneed no--enableflag.--enable=global-statewas a warning-only no-op through 0.37.0 and is a hardTUR-E0310from 0.38.0; delete it. The design of record isdocs/upcoming/mutable-globals-plan.md.
^mut on a top-level def gives a global with static storage that set! may
write:
(def ^mut hits 0)
(defn hit [] : void
(set! hits (+ hits 1)))
Without ^mut a global is immutable and set! on it is an error. ^mut is the
only thing that grants write permission -- no other annotation confers it as a
side effect.
Initialization runs in source order before main, so a def may read an
earlier global. A forward reference is TUR-E0003, not a zero-initialized
surprise.
There is no restriction on the type of a global. In particular, a value whose
type comes from defopaque -- including one declared :linear -- is an
ordinary global:
(def hub-mutex (mutex-new)) ; Mutex is (defopaque Mutex :ptr<void> :linear)
(defn broadcast [msg : cstr] : nil
(mutex-lock hub-mutex)
...
(mutex-unlock hub-mutex))
A process-lifetime mutex, connection pool, or shared registry behind a
module-level def is the intended spelling. Linearity is satisfied by the
initializer: the value is produced once, at static-init, and lives for the
process -- one production, one lifetime.
You may see older code holding the carrier and casting back at each borrow
((def hub-mutex (:: (mutex-new) :int)), then (:: hub-mutex Mutex)). That was
a workaround for a codegen bug that emitted no storage for such a global while
still emitting references to it; it was fixed 2026-08-29. It costs an unchecked
cast at every use, so replace it with the direct spelling.
A mutable global is a name every function in the program can read and, inside its module, write. That is occasionally what you want and usually not.
| If you want | Prefer |
|---|---|
| a value a call tree should see, that callers can rebind | a dynamic variable -- defdynamic / binding, see dynamic-vars-guide.md |
| state owned by one subsystem | a struct threaded through its functions, or a defopaque handle |
| a constant | a plain def -- no ^mut |
| one scratch value per thread | ^thread-local (below), not a shared global plus a lock |
| a genuinely process-wide counter, cache, or registry | a mutable global |
The dynvar case is the one most often mistaken for a global: it is the same per-thread machinery with a scope attached, and it does not leave a name any function can write.
#fx{}An effect row tracks algebraic effects. It infers nothing from set!, from a
mutable global, or from inline C, so a function that writes a global can
truthfully declare #fx{}. That is not a gap to be fixed in #fx{} -- an
empty row is a veto, not evidence -- and it is why the write-frame vocabulary
below exists instead.
#writes frame may name a globalA #writes frame names parameters and mutable globals, so a body that
maintains global state can say so and be checked, instead of being declined
outright for writing something the frame had no way to name:
(def ^mut hits 0)
(defn bump! [] #writes [hits] : void
(set! hits (+ hits 1)))
| Body | Verdict |
|---|---|
| writes only globals the frame names | VERIFIED |
| writes a global the frame does not name | TUR-E0382, naming the global |
| the walk cannot tell | UNVERIFIED (silent) |
Declared-but-never-written is fine -- a frame is an upper bound. Frames may
mix parameters and globals: #writes [a hits].
Frames are checked (WF2's three verdicts) as of 0.37.0, when the write-frames
experiment graduated -- before that a frame parsed and imposed nothing unless
you passed --enable=write-frames.
#reads is deliberately not part of this and still rejects a non-parameter
name. It is the annotation that grants congruence, so a global there would let
a promise about mutable global state pay out in proofs. See
stateful-refinements-guide.md.
A module that exports a counter for reading does not thereby export it for writing:
(defmodule ctr
(export hit peek hits)
(def ^mut hits 0)
(defn hit [] : void (set! hits (+ hits 1)))
(defn peek [] : int hits))
An importer may read hits and call hit. (set! hits 99) from outside is an
error naming the owning module. To permit it, the module says so at the
definition site:
(export hit peek (mut hits)) ;; writable from outside
The permission belongs in the export list because it is a statement about the
module's interface. (mut ...) on a function or an immutable global is rejected
by name.
This only applies across a real module boundary -- a single-file program has no owning module and is untouched.
A plain ^mut global has no synchronization of any kind. Two threads
writing one is a data race, and nothing diagnoses it. Turmeric has real OS
threads (thread-spawn-fn, stdlib/threadpool.tur, stdlib/future.tur), so
this is reachable from ordinary code.
Two annotations help, each with a narrow job.
^atomic -- one shared value, indivisible accesses(def ^atomic ^mut ready 0)
Every read becomes a sequentially-consistent load and every set! a
sequentially-consistent store. That buys three things: no torn access, no
hoisting (a bare global read in a loop may be cached in a register, so a
spinning reader would otherwise never see another thread's store), and
sequentially-consistent ordering.
It does not make (set! c (+ c 1)) safe. That is a load then a store, not
an atomic read-modify-write; two threads still lose updates. ^atomic makes
each half indivisible, it does not fuse them. For a counter use
stdlib/atomic.tur's CAS or fetch-add; for anything wider use
stdlib/mutex.tur.
Eight-byte scalars only -- :int, :float, :cstr, :ptr. A narrower or
wider type is rejected with a reason. ^atomic does not imply ^mut.
^thread-local -- one copy per thread(def ^thread-local scratch (make-buffer))
Each thread gets its own copy, materialized on first access and initialized by running the initializer on that thread -- so each thread gets its own buffer, not a share of one. The copy is freed when the thread exits.
It does not combine with ^atomic: a per-thread copy is unshared, so atomic
accesses would suggest a synchronization that is not happening. Its initializer
may not reference another ^thread-local, because per-thread initialization
order would otherwise become observable.
Under tur --interpret it is a plain global. turi has no user-reachable thread
spawn, so there is no second thread for it to differ on.
Said plainly, because the annotations above can read as more than they are:
^atomic and
^thread-local are opt-in tools for two specific shapes. A ^mut global
without either is unsynchronized, and no diagnostic will say so.stdlib/atomic.tur and
stdlib/mutex.tur.^atomic gives you
sequential consistency at the access; it does not let the compiler prove
anything about what another thread observed.If you need more than "one indivisible value" or "one copy per thread", you need a lock and the discipline that goes with it. The compiler will not check that discipline for you.
| Annotation | Position | Effect | Gate |
|---|---|---|---|
^mut |
top level | set! is allowed |
none |
^atomic |
top level | accesses are sequentially consistent; needs ^mut; 8-byte scalars |
none |
^thread-local |
top level | one copy per thread, initialized per thread | none |
#writes [g] |
on a defn |
the frame may name a global, and is checked | none |
(export (mut g)) |
in defmodule |
importers may write g |
none |
def in both positions, and the full annotation table#reads / #writes frames