The compiler can manage heap memory in three ways, selected with -mm=:
| flag | what it does | when to use it |
|---|---|---|
-mm=gc |
Garbage collection (Boehm). The default. | Almost always. Nothing to think about, and nothing leaks. |
-mm=rc |
Reference counting. Objects are freed the moment the last reference to them goes away. No collector, no pauses, no runtime dependency on libgc. | Predictable latency, WebAssembly, or shipping without libgc — provided you read the cycles section below. |
-mm=none |
Nothing is ever freed. | Short-lived programs where the process exits before memory matters. |
-mm=gc is the default and stays the default. Everything below is about what changes if you
choose -mm=rc.
tslang --emit=exe -mm=rc hello.tsMemory is reclaimed deterministically, at the point the last reference is dropped, rather than whenever a collector next runs. On allocation-heavy programs it holds close to the working set rather than to the total allocated:
| program | -mm=gc |
-mm=rc |
-mm=none |
|---|---|---|---|
| a ray tracer, 256x256 | 5.8 MB | 4.1 MB | 114.5 MB |
| the same, 512x512 | 5.5 MB | 4.1 MB | 445.0 MB |
| an n-body simulation | 5.6 MB | 4.1 MB | 4.1 MB |
Peak working set, ahead-of-time build, --opt --opt_level=3. The ray tracer's -mm=rc figure is
flat across a 64-fold change in image size, because nothing accumulates.
There is no collector thread, no pause, and no libgc to ship.
This is the one thing to know before choosing -mm=rc. If two objects refer to each other,
directly or through a chain, neither one's count ever reaches zero and neither is ever freed:
class Node {
parent: Node;
name: string;
constructor(name: string) { this.name = name; }
}
const a = new Node("a");
const b = new Node("b");
a.parent = b;
b.parent = a; // a cycle: neither a nor b will ever be freed under -mm=rcMeasured, that costs everything: a loop building one such pair per iteration holds 22.6 MB
under -mm=rc, exactly what -mm=none holds — reference counting reclaims none of it. Break
the cycle by removing one of the two assignments and the same loop holds 4.1 MB.
This is a defined property of the mode, not a bug, and it is the same trade Swift makes with
ARC. The difference is that here it is opt-in: -mm=gc is the default, handles cycles without
you thinking about it, and is one flag away.
Anything that refers back to itself, however indirectly. Each of these was measured, and each
holds exactly as much as -mm=none — that is, reference counting reclaims none of it:
- Parent/child links —
class Node { parent: Node; children: Node[] }, the example above. - Mutually referencing objects —
a.peer = b; b.peer = a. - Doubly linked lists — every node holds its neighbour and is held by it.
- An object holding a callback that captures the object — the closure's capture box holds the object, and the object holds the closure.
- Trees and lists with no back-references — the common case.
- Strings, and arrays of them. A string never points at another heap object, so no cycle involving one can exist.
- Self-recursive functions. A named recursive function is not a cycle — it holds no reference to itself at run time. (A self-referential arrow function would be, but the compiler does not currently accept one.)
- Use
-mm=gc— the default — if your data has cycles and you do not want to think about them. This is the right answer for most programs. - Break the cycle by hand where you know about it: null out the back-reference when you are done with the structure, or store a key/index instead of a pointer back to the owner.
- If neither fits,
-mm=rcis not the right mode for that program.
A WeakRef<T> that lets you declare a back-reference as non-owning is designed but not
implemented; see tslang/docs/reference-counting-evaluation.md §9.8. When it lands it will be
the fourth option here, and it does not change anything above.
The standard library is built once per memory model, and your program links the build matching
its own -mm=. Nothing to configure — the compiler picks it.
It has to work that way: the library allocates the way the model it was built for allocates.
The -mm=gc build calls into Boehm and brings libgc with it; the -mm=rc build maintains
reference counts and brings no collector at all. A hello-world is 335 KB under -mm=gc and
145 KB under -mm=rc for exactly that reason.
If the build for your model is missing, the compiler says so and names the directory rather
than falling back to another model's copy — that would link and then misbehave at run time.
Build them with the default library's build.bat (build.sh on Linux), which produces all
three.
- Objects crossing between differently-managed modules are never freed. If you link a
module built
-mm=rcagainst one built-mm=gc, anything allocated on the other side leaks rather than being freed twice. The compiler warns when it can see the mismatch. Building everything with the same-mm=avoids it. (The standard library is not affected — see above.) - Counts are not atomic.
-mm=rcis single-threaded today.
A program that loads a tslang shared library must link Boehm as a DLL, not statically.
The same rule is why, on Windows, TypeScriptRuntime.dll (the JIT's runtime) and the default
library's TypeScriptDefaultLib.dll take the collector from gc.dll: under the JIT, or beside a
user's shared library, they share a process with other gc code, and one static collector
among them frees what the others hold. gc.dll ships beside tslang.exe.
If the executable and the library each link gc.lib statically, each gets its own collector,
with its own heap and its own idea of what the roots are. The library's collector does not scan
the executable's roots, so it frees objects the executable is still holding. The symptom is not
a crash: the freed memory is reallocated and the program reads a plausible wrong value, which
only shows up when what was written over it differs from what was there.
On Windows tslang makes the choice itself:
--emit=dll, and--emit=exefor a program that imports a tslang shared library, link the shared collector and copygc.dllbeside the output.--emit=exefor a program that imports none keeps the staticgc.lib, and ships as one file.
It finds the shared collector through --gc-shared-lib-path (or GC_SHARED_LIB_PATH), else the
gcdll folder inside --gc-lib-path — which is where the Windows release package ships it, beside
the static gc.lib at its root; both files are named gc.lib, so the folder is what tells them
apart — else --gc-lib-path itself when that already names a shared build. If none of those has
one, the build stops with an error rather than linking a collector of its own.
From a source build, the shared collector comes from scripts/build_gc_release_shared_vs.bat:
--gc-shared-lib-path=3rdParty/gcdll/x64/release/lib (with gc.dll in its ../bin).
Linking by hand (--emit=obj and your own linker) makes no choice for you: link the executable
and every shared library against the shared gc.lib, and ship gc.dll beside the executable.
What to ship with a Windows program that loads a tslang shared library, all in one folder:
- the program and its shared libraries,
gc.dll(fromgcdll/in the release package),TypeScriptDefaultLib.dll(fromdefaultlib/dll/<release|debug>/gc), which every shared library built with the default library imports.
A shared library that was linked against the static gc.lib anyway — by an older tslang, or by
hand — is refused when you import it, under --emit=exe and --emit=jit alike:
error: shared library 'foo.dll' links its own garbage collector (the static gc.lib). Objects
crossing between it and this module can be freed while still in use. Rebuild it with tslang
--emit=dll, which links gc.dll.
The compiler reads this from the library itself, so it holds however the library was linked. The JIT checks the default library's DLL the same way before loading it.
On Linux nothing needs to ship. A program that imports a tslang shared object exports its own
collector (--whole-archive plus --export-dynamic-symbol=GC_*), and the dynamic loader binds
the shared object's GC_* calls to it, so the copy linked into the shared object is never used.
The default library's .so links no collector at all. Under the JIT, libTypeScriptRuntime.so
exports the collector the same way. If you link a program by hand, pass those two options.
Statically linked programs are unaffected and keep the static gc.lib — one binary already
means one collector. -mm=rc and -mm=none are unaffected either way: neither has a collector.
A shared library records the model it was built under, and the compiler warns when you import one built differently:
warning: shared library 'foo.dll' was built with -mm=gc, this module with -mm=rc.
Objects crossing between them are never reclaimed.
The link is allowed and the program is correct — objects that cross simply leak, rather than
being freed by one side while the other still holds them. Build every module with the same
-mm= to avoid it.