August 2, 2022·6 min read·engineeringgoruntimememoryconcurrency

Go 1.19 gives the runtime a memory budget

Go 1.19 adds a soft memory limit and typed atomic values. The memory accounting boundary and sequentially consistent atomic semantics explain what these controls actually promise.

A garbage collector can trade additional memory for less collection work without knowing how much memory a deployment can afford. That missing information is the problem behind Go 1.19's soft memory limit. The runtime now accepts a budget for the memory it manages, which lets it adjust collection and reclamation as that budget approaches.

The 2 August release announcement presents this alongside typed atomic values and a revised memory model. The connection is practical. Both changes give programmers a more explicit way to state a requirement that previously involved lower-level conventions. Neither removes the need to understand the boundary of the resulting guarantee.

I value Go's operational simplicity, but simplicity depends on having controls that match the actual constraint. A memory budget is easier to explain in a deployment than an artificial allocation intended to influence the collector. It also makes the remaining sources of process memory impossible to ignore.

GOGC did not express a deployment budget

Michael Knyszek's soft memory limit proposal, dated September 2021, explains the limitation of tuning through GOGC alone. That control expresses a tradeoff between collection frequency and available memory overhead. A service with transient changes in its live heap can need a different tradeoff near an external memory ceiling.

The proposal describes heap ballasts and periodic forced reclamation as approaches users developed to compensate. Their existence is useful evidence of a missing control. An application should be able to state its runtime memory budget directly, without encoding the budget through a deliberately retained allocation or a separate schedule of forced work.

This changes the information available to the runtime. The collector still has an application to execute and objects that remain live. It also has a stated resource objective. A budget cannot make reachable data disappear, but it can affect when the runtime decides to spend effort collecting and releasing memory.

That distinction matters to the deployment author. Reducing a memory setting does not reduce the amount of live application state required by the workload. If that state approaches the budget, the tradeoff becomes harder. The runtime has less room to let allocations accumulate between collections.

The limit covers runtime-managed memory

The SetMemoryLimit documentation in the Go 1.19 source states the accounting boundary. The limit covers memory mapped and managed by the Go runtime that it has not released. Its expression is MemStats.Sys minus MemStats.HeapReleased. This includes more than live heap objects.

The same documentation explicitly excludes the binary itself and memory outside the runtime's management. Examples include memory allocated by C code, kernel memory held on the process's behalf, and memory mapped directly through syscall.Mmap. A process can therefore consume memory that the runtime limit does not count.

This is why a container's allowance and the Go setting need different interpretations. The container accounts for resources according to its own operating-system rules. GOMEMLIMIT gives the Go runtime a target for the portion it can manage. Assigning the same numeric value to both does not establish that all process memory fits inside the container.

The relevant configuration needs to leave room for the excluded sources that the application actually uses. That room depends on the program. A service calling C libraries has a different accounting problem from a service whose allocations are almost entirely runtime-managed. The new setting makes the distinction clearer without supplying a universal amount of headroom.

I prefer this explicit boundary to a setting named as though it controlled every allocation. It gives an operator something concrete to compare with runtime metrics. A discrepancy between runtime-managed memory and process usage can then lead to the appropriate allocator or mapping behavior.

Configuration is available before application startup

Go 1.19 reads an initial limit from GOMEMLIMIT. The released documentation accepts a byte count with optional binary-unit suffixes such as MiB and GiB. The runtime/debug function provides a programmatic way to change the setting and returns the previous value. Passing a negative argument reads the current setting without changing it.

For example, this shell command supplies a budget to an existing service executable. The number is illustrative and needs to fit the deployment's accounting.

env GOMEMLIMIT=768MiB ./service

The runtime responds by adjusting collection frequency and returning memory to the underlying system more aggressively. The documentation states that the memory limit still applies when ordinary percentage-based collection is disabled through GOGC. Disabling that trigger therefore does not disable the separately configured budget.

Calling the setting soft is consequential. The release notes describe a limit on garbage-collection CPU work intended to avoid pathological behavior under pressure. The runtime can use more memory when necessary to preserve application progress. This is an operating policy for the collector, not a promise to reject every allocation at a fixed byte count.

That policy reflects a real conflict. Collecting continuously cannot recover memory that remains live, and it can prevent useful application work. A runtime needs a way to avoid spending all available execution effort pursuing an unattainable goal. The soft limit makes the budget part of that decision while leaving workload size a responsibility of the application.

Atomic state now has a type of its own

Go 1.19 also adds types such as atomic.Int64 and atomic.Pointer[T]. The released implementations place atomic operations on the value through methods. Int64 provides load and store operations, arithmetic addition, swap and compare-and-swap. Pointer retains the pointed-to type in its method signatures.

This is a useful application of generics introduced in the preceding release. An atomic pointer's declaration can retain the type of the value it publishes. Callers no longer need to express that relationship through repeated unsafe pointer conversions at each operation. The implementation can contain the conversion while the public declaration carries the relationship.

The integer types also address an architectural detail. The package documentation explains that low-level 64-bit atomic functions require callers to arrange alignment on certain 32-bit architectures. Int64 and Uint64 provide automatic alignment. A declaration using the typed value therefore carries a requirement that previously depended on storage arrangement by the caller.

The source includes a noCopy marker recognized by vet's copy-lock analysis. That is consistent with treating the atomic object as synchronization state with an identity in memory. Copying such state is not a way to share the same atomic location between two owners. The declaration should remain stable while callers coordinate through its operations.

The new types also specify useful zero values. Int64 starts at zero, Bool starts at false, and Pointer starts with a nil pointer of the selected type. Those defaults let a containing object describe its initial atomic state without a separate initialization operation for each field.

An atomic operation does not define the whole algorithm

The revised package documentation says that if one atomic operation observes another's effect, the first synchronizes before the second. It also states that atomic operations behave as though executed in a sequentially consistent order. Go supplies that model without offering a menu of weaker memory-order choices through this API.

Those semantics make individual operations easier to describe, but a group of atomic fields does not become a transaction. An algorithm still has to define which combinations of observations are acceptable. Publishing a pointer also does not by itself define the ownership policy for subsequent changes to the pointed-to data.

For example, a counter used only to count events has a narrower contract than a counter used to signal that a separate object is ready. The latter depends on how the object's writes relate to publication and observation. The integer's atomicity alone is not a complete explanation of that protocol.

The package documentation recommends channels or facilities in sync for most synchronization outside specialized low-level work. Typed values improve the expression of an atomic algorithm when one is appropriate. They do not establish that atomics are the easiest mechanism for coordinating every shared resource.

My preference remains to use the smallest synchronization contract that explains the program's behavior. Go 1.19 makes atomic storage more explicit and gives its operations a documented ordering model. A reviewer can spend less effort reconstructing pointer conversions or alignment assumptions, while still examining the relationship between shared state and the algorithm that uses it.