When is a Swift global or static property initialised, what is swift_once, and when does the optimiser make the initialiser disappear?

5.8.3 · L5 · Interview

Predict first

What does this print?

let eager: Int = { print("top-level let"); return 1 }()
struct C { static let lazy: Int = { print("static let"); return 2 }() }
print("start")
_ = C.lazy; _ = C.lazy
print("end")
  • top-level let / start / static let / end
  • start / top-level let / static let / end
  • top-level let / start / static let / static let / end
  • start / static let / top-level let / end

Predict first

At -Onone, do limit and table have lazy-initialisation tokens in SIL?

import Swift
public let table = [1, 2, 3]
public let limit = 100
public let shared = Cfg()
public final class Cfg: Sendable { public init() {} }
public func use() -> Int { limit + table.count + (shared === shared ? 1 : 0) }
  • Yes: private _Wz tokens for each global
  • No: they are plain data
  • Only for the class instance
  • Only in -O

Predict first

At -O, do limit and table still have tokens?

import Swift
public let table = [1, 2, 3]
public let limit = 100
public let shared = Cfg()
public final class Cfg: Sendable { public init() {} }
public func use() -> Int { limit + table.count + (shared === shared ? 1 : 0) }
  • No: they become statically initialised; the class instance keeps its token
  • Yes, all three
  • No tokens at all, including the class instance
  • Only table keeps one

Mental model. A lazy global is a kettle filled when somebody first wants tea, with a note taped on it so nobody boils it twice. If the “tea” is a fixed packet that comes already poured (a constant), the optimiser delivers it ready-made and removes the note.

Globals and static let properties in a normal file are lazy: the initialiser runs the first time the value is read, exactly once, even if several threads race. The compiler generates an accessor (an addressor) that checks a one-word token and calls swift_once on the first use; later reads pay a load and a compare. The exception is the top-level code of main.swift: its variables are initialised eagerly, in order, as script statements. The run below shows the difference: the top-level let prints before start, while the static let prints only when first touched (and only once).

In -Onone every global gets a once token (sil_global private @…_Wz in the SIL). With -O the optimiser can turn an initialiser that is a compile-time constant, such as an integer or an array of integer literals, into statically initialised data, and the token and the call disappear: in my build limit and table had a static value and no token, while an instance of a class (shared) still kept its token. Practical consequences: do not put side effects in global initialisers, because you cannot tell when they run; an expensive global is paid for at the first access, which may be on the main thread during a scroll; and “thread-safe lazy singleton” is the default behaviour, not something to add a lock for.

Script-level variables initialise in order before later statements; the static let initialises once, on first use.

Every global gets a token in -Onone.

Constant initialisers are folded into static data; the class instance still goes through a once token in this build.

Common traps

  • Relying on a global’s initialiser to run at launch. It runs at first use, not at app start; only main.swift top-level code is eager.
  • Assuming initialisation order between globals in different files. There is none; each runs when first read, which can create hidden dependency chains.

They might follow up with

  • Is a static let thread-safe to read? The initialisation is; the value’s own mutability is separate (Swift 6 checks Sendable).
  • How do I run something at launch then? Do it explicitly from the app entry point, not through a global.

Related questions

  • 5.3.2 How does an unspecialised generic function know the concrete type, and what does that cost?
  • 5.2.4 What is exclusivity enforcement, and what does it cost at runtime?
  • 5.2.3 How does the inliner decide, and what do @inline(never), @inline(__always) and @inlinable do?
  • 10.4.2 How do @MainActor, global actors, isolated and nonisolated work, and how does isolation propagate?
  • 10.9.5 What does the Swift 6.2 default actor isolation setting change, and how does it affect a global variable or…

← How do access levels become linkage in the object file, and why can…   What reflection metadata does the compiler emit for every type, what… →