CLR & Concurrency · Lesson 6

Explicit type initialization has a boundary

Observe one static constructor without guessing about beforefieldinit or JIT timing.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

The runtime contract

An explicit static constructor gives a type a recognizable initialization boundary. Counter's static constructor establishes its starting value and writes one initialization line before its first static method invocation proceeds. This is different from guessing when a type marked beforefieldinit happens to initialize under a particular runtime or optimization. The sample intentionally has an explicit constructor and only one bounded state sequence. Its behavior can be checked on the portable runtime and the real desktop project without inferring JIT tier, thread scheduling, or a universal initialization order for unrelated types.

Trace the portable program

The host prints before without accessing Counter. The first call to Next requires Counter's initialization, so init is printed and value becomes two before Next increments it to three. The second call uses the already initialized type and increments the retained value to four. Output is before, init, first:3, and second:4. There is one initialization line for this type in this execution. The sample does not claim that merely inspecting every kind of metadata triggers a constructor, or that changing the declaration to implicit field initialization preserves exactly the same timing guarantees.

Execute the actual desktop lab

The external project repeats the explicit static-constructor sequence on desktop .NET 10. Static initialization is associated with the runtime type, and closed constructions of a generic type can have separate static state. That is different from a single process-wide value shared by every possible generic argument. Type initialization failure also has a specific lifetime consequence: a failed initializer is not an ordinary retryable instance operation. Keep initialization small and avoid blocking or reaching into complicated cyclic dependencies. The sample deliberately initializes one scalar and writes one diagnostic line instead of making startup depend on external resources.

Pitfalls and boundary limits

A type without an explicit static constructor can be marked beforefieldinit, allowing initialization timing choices that differ from this baseline. Do not remove the constructor and then demand identical early-or-late behavior across JIT, interpreter, and AOT environments. Likewise, reading Type metadata and executing a static method are not interchangeable actions. Constants can be embedded without a normal static field read, and code can observe initialization through several different triggers. The lesson intentionally uses a direct static method invocation with an explicit constructor so the expected order follows a meaningful rule rather than one compiler's incidental generated layout.

Practice one: isolate behavior

First call Next a third time and predict five without another init line. Then create a second ordinary instance-bearing type with its own explicit static constructor and observe its independent first-use boundary. Hint: keep the access sequence deliberate so the order comes from actual triggers rather than from assumptions about source-file declaration order. Avoid inserting metadata inspection and claiming it is equivalent to method execution. These experiments distinguish retained per-type state, one-time initialization, and the separate identity of another runtime type.

Practice two: design and verify

For another exercise, create a small generic holder with explicit static initialization and compare two closed type arguments. State which state belongs to each constructed type before running. Hint: a generic definition and its closed runtime types are different metadata identities. In a separate disposable desktop program, inspect how an intentionally failing initializer is reported and why repeatedly calling the type is not a sensible recovery loop. Keep that failure fixture out of shared examples whose later steps require the type to remain usable.

Review the evidence

Review static initialization by identifying the explicit constructor, actual trigger, type identity, mutable state, and failure policy. The baseline proves one clear sequence and does not establish arbitrary timing for beforefieldinit types or unrelated initialization graphs. The desktop run adds a second real runtime environment, but a passing print order still is not a JIT-tier measurement. Use static state when its lifetime and initialization ownership fit the design, and keep complex recovery or external acquisition in explicit operations where failure and disposal can be handled with a contract suited to those responsibilities. Do not block a static constructor on a task whose work may require that same type to initialize; initialization ownership can become part of a deadlock cycle.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

Explicit type initialization has a boundary — desktop runtime lab

Set up the SDK project

  1. Save both files at their exact paths in a fresh owned directory.
  2. Use a .NET 10 SDK, then run dotnet run --project RuntimeLab.csproj. This project intentionally uses C# 13 syntax.
  3. Workers and waits are bounded; a timeout or a violated invariant fails the process. No benchmark or NativeAOT publication is implied by an ordinary run.

Download complete SDK project ↗

Project files

Program.csDownload file ↗
using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

Console.WriteLine("before");
Console.WriteLine($"first:{Counter.Next()}");
Console.WriteLine($"second:{Counter.Next()}");
static class Counter
{
    private static int value;
    static Counter() { Console.WriteLine("init"); value = 2; }
    public static int Next() => ++value;
}
RuntimeLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>13.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PublishAot>false</PublishAot>
  </PropertyGroup>
</Project>

Expected result

Exact behavioral stdout on the stated desktop lab: before init first:3 second:4 Variable diagnostics, where emitted, go to stderr and must not be treated as portable timing or allocation guarantees.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

Console.WriteLine("before");
Console.WriteLine($"first:{Counter.Next()}");
Console.WriteLine($"second:{Counter.Next()}");
static class Counter
{
    private static int value;
    static Counter() { Console.WriteLine("init"); value = 2; }
    public static int Next() => ++value;
}
Open C# practice

C# practice output

before
init
first:3
second:4
Try this
This type has an explicit static constructor. Removing it changes initialization rules and is not a valid way to promise identical timing for every runtime.

Further reading

Interview questions for this lesson