C# Compiler dan Just-in-Time (JIT) Compilation

Ketika kita menulis kode C#, kode tidak langsung di-compile menjadi machine instruction untuk prosesor atau operating system spesifik. Kode C# tersebut harus melewati beberapa tahap , disebut compilation pipeline, yang…

Ketika kita menulis kode C#, kode tidak langsung di-compile menjadi machine instruction untuk prosesor atau operating system spesifik. Kode C# tersebut harus melewati beberapa tahap , disebut compilation pipeline, yang didesain untuk mendukung tiga hal: portabilitas (bisa jalan di banyak OS), keamanan, dan optimisasi.

Sebelum masuk ke detail, ini gambaran besarnya:

  1. Source Code (.cs)
    • ⇒ Rosyln (Compiler) – Cek syntax, type safety, language rules
  2. Assembly (.dll/.exe), berisi: IL + metadata + manifest + resources + (PDB terpisah)
  3. CLR (.NET Runtime) .NET Runtime: memuat assembly, JIT-compile IL, garbage collection, exception handling, dll
  4. Native Machine Code (spesifik OS + arsitektur prosesor)
  5. Program benar-benar berjalan

1. Roslyn, C# Compiler

Langkah pertama dalam pipeline adalah C# compiler, yang dikenal sebagai Roslyn. Roslyn menganalisis source code untuk ketepatan, mengecek syntax, type safety, dan language rules sebelum menghasilkan compiled output.

Roslyn juga memberdayai code analysis features pada tools seperti Visual Studio dan VS Code, termasuk IntelliSense, code navigation, dan real-time error detection. Selain itu, Roslyn memberdayai static code analysis tools yang mengecek kualitas kode, style issues, dan potensi masalah keamanan.

Jika kodenya valid, compiler (Roslyn) menerjemahkannya ke Intermediate Language (IL).

Contoh: kode C# sederhana ini…

public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}

…akan gagal di-compile oleh Roslyn kalau ada type mismatch, misalnya:

public int Add(int a, int b)
{
    return a + "b"; // Compile-time error: CS0029
                    // Cannot implicitly convert type 'string' to 'int'
}

Error ini ditangkap sebelum kode sempat jadi IL sama sekali, ini bagian dari “ketepatan” yang dicek Roslyn.

Roslyn juga dapat dipakai secara programatik (Roslyn API) untuk analisis kode, contohnya untuk menghitung jumlah method dalam sebuah class:

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

var code = "public class Foo { void A() {} void B() {} }";
var tree = CSharpSyntaxTree.ParseText(code);
var root = tree.GetRoot();

var methodCount = root.DescendantNodes()
                       .OfType<MethodDeclarationSyntax>()
                       .Count();

Console.WriteLine($"Jumlah method: {methodCount}"); // 2

Ini contoh konkret dari “code analysis features” yang disebut di atas — analyzer di VS Code/Visual Studio pada dasarnya melakukan hal serupa di background.

Apa saja yang sebenarnya dihasilkan Roslyn?

Penting untuk dipahami: Roslyn tidak cuma menghasilkan “kode IL”. Outputnya adalah satu paket lengkap bernama assembly (.dll/.exe), yang di dalamnya berisi beberapa komponen berbeda:

  1. IL Code, instruksi bytecode untuk tiap method.
  2. Metadata, tabel data yang mendeskripsikan struktur program: semua class, method, field, property beserta signature-nya. Metadata inilah yang membuat reflection (typeof(), GetType()) bisa bekerja tanpa perlu source code asli.
  3. Assembly Manifest, deskripsi assembly itu sendiri: nama, versi, dan daftar referensi ke assembly lain yang dipakai.
  4. Resources (opsional), file embedded seperti string resource (.resx) atau gambar.
  5. Attributes, metadata dari attribute yang ditulis di kode, seperti [Serializable] atau [Obsolete].
  6. Debug Symbols (.pdb), file terpisah, dihasilkan saat build Debug, memetakan tiap instruksi IL kembali ke baris source code asli. File inilah yang membuat breakpoint dan stack trace bisa menunjuk ke baris kode yang tepat.
Calculator.dll
├── Assembly Manifest   (nama: Calculator, versi 1.0.0.0, referensi: System.Runtime, ...)
├── Metadata             (struktur: class Calculator, method Add(int, int): int)
├── IL Code              (instruksi ldarg/add/ret untuk method Add)
└── Resources (kalau ada)

Calculator.pdb            ← file terpisah, khusus debugging

Karena assembly bersifat self-describing (metadata-nya lengkap), CLR nanti bisa tahu seluruh struktur program hanya dari file ini, tidak perlu file header terpisah seperti di C/C++.

dotnet build = memanggil Roslyn (tapi bukan cuma itu)

dotnet build sebenarnya menjalankan proses MSBuild, dan Roslyn adalah salah satu langkah di dalamnya (langkah paling penting). Urutan kasarnya:

  1. dotnet build
  2. Restore dependencies → pastikan semua NuGet package tersedia
  3. MSBuild baca .csproj, tentukan file mana yang perlu di-compile
  4. Roslyn dipanggil → hasilkan IL + metadata + manifest → MyApp.dll
  5. Copy semua dependency .dll ke folder output
  6. Generate file pendukung → MyApp.deps.json, MyApp.runtimeconfig.json
  7. (kalau Debug) generate MyApp.pdb

Begitu dotnet build selesai, MyApp.dll yang dihasilkan sudah 100% output Roslyn , tidak ada compile tambahan lagi setelah ini. Perlu diketahui juga: dotnet run sebenarnya melakukan dotnet build dulu secara implisit (kecuali dipakai --no-build), baru menjalankan hasilnya.

2. Intermediate Language (IL)

IL adalah representasi low-level platform independent dari sebuah program. Dan karena IL tidak terikat pada spesifik OS atau arsitektur prossesor, aplikasi yang telah di-compiled bisa berjalan pada envrionment yang berbeda tanpa harus direcompiled dari source code.

Kalau Calculator.cs di atas di-compile dan IL-nya dilihat (misalnya lewat ildasm atau sharplab.io), method Add akan terlihat kira-kira seperti ini:

.method public hidebysig instance int32 Add (
    int32 a,
    int32 b
) cil managed
{
    .maxstack 8
    IL_0000: ldarg.1   // load a
    IL_0001: ldarg.2   // load b
    IL_0002: add       // a + b
    IL_0003: ret
}

Perhatikan: instruksi seperti ldarg dan add ini bukan instruksi native CPU — ini bytecode CIL (Common Intermediate Language) yang masih perlu diterjemahkan lagi ke native code oleh runtime. Inilah yang membuat file .dll/.exe hasil build C# bisa jalan di Windows, Linux, maupun macOS selama ada .NET runtime yang sesuai.

Poin penting: IL yang identik di Windows dan Linux

Untuk source code yang sama, Roslyn menghasilkan IL yang byte-identik, baik di-compile di Windows maupun Linux , karena Roslyn cuma menerjemahkan syntax C# ke instruksi IL yang platform-independent, tidak peduli OS tempat dia dijalankan.

Windows:  dotnet build  →  Calculator.dll (IL) , bytes identik
Linux:    dotnet build  →  Calculator.dll (IL) , bytes identik

Ini artinya Calculator.dll hasil compile di Windows bisa disalin ke Linux dan tetap bisa dijalankan (selama ada CLR di Linux) , dibahas lebih detail di bagian akhir catatan ini.

3. .NET Runtime (CLR), Eksekusi

CLR (Common Language Runtime) adalah runtime environment milik .NET yang bertanggung jawab mengeksekusi aplikasi setelah proses compile Roslyn selesai. Tugas utamanya:

  1. Memuat assembly , membaca file .dll/.exe (IL + metadata).
  2. JIT compiler , menerjemahkan IL ke native machine code saat method pertama kali dipanggil.
  3. Memory management , termasuk garbage collector (GC), sehingga tidak perlu manual malloc/free seperti di C/C++.
  4. Type safety & security enforcement , memastikan operasi sesuai type yang dideklarasikan.
  5. Exception handling , mekanisme try/catch/finally yang konsisten lintas bahasa .NET.
  6. Thread management , dasar dari Task, async/await, lock, dll.

CLR bukan satu file , dan bukan dibuat oleh Roslyn

CLR sebenarnya kumpulan beberapa file (library/binary) yang bersama-sama membentuk runtime, bukan satu file tunggal:

Linux (.NET 8, contoh path):
/usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.x/
├── libcoreclr.so                  ← inti CLR (engine eksekusi)
├── libclrjit.so                   ← JIT compiler
├── System.Private.CoreLib.dll     ← base class library
└── ... (puluhan file lain)

Windows (path serupa):
C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.x\
├── coreclr.dll
├── clrjit.dll
├── System.Private.CoreLib.dll
└── ...

CLR dan Roslyn adalah dua proyek yang sepenuhnya terpisah, dibuat tim berbeda, bahasa implementasi berbeda:

RoslynCLR
PeranCompiler (front-end)Runtime engine (back-end)
Ditulis dalamC#C++ (sebagian kecil assembly)
TugasSource code → IL + metadataMuat assembly, JIT ke native code, GC, dll
Kapan jalanSekali, saat buildBerulang, setiap kali app dieksekusi
Repodotnet/roslyndotnet/runtime

Hubungan keduanya bukan “membentuk”, tapi input-output berurutan: Roslyn menghasilkan produk (assembly IL), CLR adalah mesin yang mengonsumsi produk itu saat runtime. Roslyn sudah selesai bekerja jauh sebelum CLR bahkan dimuat.

Analogi: IL adalah “resep masakan” , Roslyn adalah “penulis resep”. CLR adalah “dapur beserta kokinya” yang membaca resep itu dan memasaknya jadi makanan (native code). Dapur tidak dibangun oleh penulis resep; keduanya diproduksi terpisah, cuma “bertemu” lewat resep sebagai perantara.

4. JIT vs AOT vs ReadyToRun

Setelah assembly dimuat CLR, ada tiga cara IL diterjemahkan ke native code:

a. Just-In-Time (JIT) , paling umum, default

  • Kompilasi terjadi saat runtime, di mesin end-user, bukan di mesin developer.
  • Method di-compile ke native code ketika pertama kali dieksekusi, bukan semuanya di awal.
  • Compiled code disimpan di memory dan dipakai ulang selama lifetime aplikasi.
  • Ada tiered compilation: JIT awalnya hasilkan kode “cepat tapi kurang optimal”, lalu method yang sering dipanggil (hot path) di-compile ulang dengan optimisasi lebih agresif.

Konsekuensinya: ada sedikit startup delay (warm-up) , makanya request pertama ke endpoint ASP.NET Core biasanya lebih lambat dibanding request berikutnya.

JIT bukan terikat pada dotnet run saja. JIT adalah perilaku level CLR, dan tetap terjadi di skenario mana pun selama IL dieksekusi tanpa precompilation:

# Semua skenario ini tetap memakai JIT di baliknya:
dotnet run
dotnet bin/Release/net8.0/MyApp.dll     # hasil publish framework-dependent
dotnet test                              # saat test runner mengeksekusi kode
# + aplikasi ASP.NET Core yang di-hosting lewat IIS, Kestrel, atau container

Catatan penting soal file: JIT tidak pernah membuat file .dll baru. JIT terjadi setelah .dll (IL) dibaca, hasilnya (native machine code) cuma disimpan di memory, tidak pernah ditulis balik jadi file. Jadi tidak ada istilah “.dll hasil JIT” , yang ada cuma “.dll berisi IL” (dibuat Roslyn) yang nanti di-JIT saat dijalankan.

b. Ahead-Of-Time (AOT)

  • IL dikompilasi ke native machine code sebelum aplikasi berjalan , saat proses publish, bukan saat runtime.
  • Mengurangi startup time (tidak ada JIT saat app pertama jalan) dan meningkatkan predictability (tidak ada variasi performa dari warm-up).
  • Cocok untuk container/serverless (cold start harus cepat), CLI tools, device resource terbatas.
  • Trade-off: binary lebih besar, dan fitur reflection dinamis terbatas.
# Publish sebagai Native AOT (tersedia mulai .NET 7+)

# Linux
dotnet publish -c Release -r linux-x64 -p:PublishAot=true

# Windows
dotnet publish -c Release -r win-x64 -p:PublishAot=true

Catatan: proses ini tidak cross-compile secara default , publish win-x64 idealnya dilakukan di mesin Windows, publish linux-x64 di mesin Linux, karena Native AOT butuh native toolchain platform tersebut (Windows: Visual Studio Build Tools/C++ toolset; Linux: clang).

c. ReadyToRun (R2R) , hybrid

Assembly di-precompile sebagian ke native code saat publish (mempercepat startup), tapi JIT tetap jalan di background untuk optimisasi lanjutan. Beda dari Native AOT yang menghilangkan JIT sepenuhnya.

# Linux
dotnet publish -c Release -r linux-x64 --self-contained -p:PublishReadyToRun=true

# Windows
dotnet publish -c Release -r win-x64 --self-contained -p:PublishReadyToRun=true

5. Self-Contained vs Framework-Dependent

Ini bukan soal build vs publish , yang menentukan adalah flag --self-contained, dan ini bisa dipakai di kedua konteks (meski biasanya dipakai saat publish untuk deployment).

# Framework-dependent (default, TANPA flag)
dotnet publish -c Release
# → Hasilnya MASIH butuh .NET runtime terinstal di mesin tujuan

# Self-contained (WITH --self-contained -r <RID>)
dotnet publish -c Release --self-contained -r linux-x64   # Linux
dotnet publish -c Release --self-contained -r win-x64     # Windows

Kalau self-contained, seluruh runtime CLR (termasuk JIT compiler) ikut disalin ke folder output publish:

publish/linux-x64/          publish/win-x64/
├── MyApp                   ├── MyApp.exe
├── MyApp.dll                ├── MyApp.dll
├── libcoreclr.so   ←baru    ├── coreclr.dll   ←baru
├── libclrjit.so    ←baru    ├── clrjit.dll    ←baru
└── ...                      └── ...

Cara menjalankan hasilnya:

# Linux , perlu prefix ./ karena current directory tidak ada di $PATH
./MyApp

# Windows (cmd/PowerShell) , bisa langsung panggil nama file
MyApp.exe

Ini bukan berarti JIT hilang , CLR yang dibundle tetap men-JIT-compile IL ke native code saat method pertama kali dieksekusi, cuma native code-nya dihasilkan spesifik untuk OS masing-masing. Self-contained menyelesaikan masalah distribusi (tidak perlu install runtime terpisah), bukan menghilangkan JIT.

Framework-dependent (build/run/publish tanpa flag)Self-contained (--self-contained)
CLR ikut dibundle di folder output?TidakYa
Butuh .NET terinstal di mesin tujuan?YaTidak
Ukuran outputKecilBesar (puluhan MB)

6. Apphost, apa itu MyApp.exe / MyApp?

File MyApp.exe (Windows) atau MyApp (Linux) yang muncul di folder bin//publish/ bukan CLR, dia disebut apphost: executable native kecil yang fungsinya jadi “pintu masuk” untuk memanggil CLR, bukan CLR itu sendiri.

publish/
├── MyApp / MyApp.exe        ← APPHOST (native, kecil, bukan IL, bukan CLR)
├── MyApp.dll                 ← IL aplikasi (hasil Roslyn)
├── MyApp.deps.json
├── MyApp.runtimeconfig.json
├── libcoreclr.so / coreclr.dll   ← CLR (kalau self-contained)
└── ...

Apphost sendiri tidak menjalankan aplikasi , dia cuma bootstrapper yang: cari MyApp.dll di folder yang sama → cari CLR (di folder sendiri kalau self-contained, atau di instalasi sistem kalau framework-dependent) → serahkan eksekusi ke CLR.

Analogi: kalau CLR itu “dapur” dan IL itu “resep”, maka apphost itu seperti tombol power yang menyalakan dapur dan menyuruhnya mulai memasak , bukan dapurnya, bukan juga makanannya, cuma pemicu paling depan.

7. Dua Direktori yang Berbeda

Penting untuk tidak tertukar antara dua lokasi ini:

a. Direktori output project (bin/Debug/net8.0/)

Isinya cuma milik project ini saja, dihasilkan dotnet build:

Windows: C:\Users\<user>\ProjekmU\bin\Debug\net8.0\
Linux:   ~/ProjekmU/bin/Debug/net8.0/

├── Calculator.dll
├── Calculator.pdb
├── Calculator.deps.json
├── Calculator.runtimeconfig.json
└── Calculator / Calculator.exe   ← apphost

hostfxr, coreclr, clrjit tidak ada di sini (kecuali publish self-contained).

b. Direktori instalasi .NET (shared runtime)

Dipakai bersama oleh semua aplikasi .NET di mesin itu, terpisah dari project manapun:

Windows: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.x\
Linux:   /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.x/
├── (lib)coreclr / coreclr.dll
├── (lib)clrjit / clrjit.dll
└── ...

Windows: C:\Program Files\dotnet\host\fxr\8.0.x\
Linux:   /usr/share/dotnet/host/fxr/8.0.x/
└── (lib)hostfxr , komponen yang mencari & memuat versi CLR yang tepat

Saat dotnet run, apphost/muxer di folder project mencari keluar ke direktori instalasi ini untuk memuat CLR , CLR tidak datang ke folder project (kecuali self-contained, di mana CLR disalin masuk ke folder output).


8. Alur Lengkap: Dua Jalur Eksekusi

Ada dua “pintu masuk” berbeda untuk menjalankan aplikasi yang sama , hasil akhirnya sama, tapi mekanismenya beda:

Jalur 1 , dotnet run / dotnet MyApp.dll (lewat muxer)

dotnet sendiri (yang disebut muxer) bertindak sebagai generic host untuk .dll apa saja yang diberikan sebagai argumen , apphost di folder bin/ sama sekali tidak disentuh, dilewati sepenuhnya:

Windows:
  dotnet run
    → dotnet (muxer) baca argumen, panggil hostfxr.dll secara internal
    → hostfxr.dll mencari CLR terinstal di C:\Program Files\dotnet\shared\...
    → coreclr.dll dimuat
    → coreclr.dll membaca Calculator.dll (IL)
    → clrjit.dll JIT-compile method Add() → native x64 Windows machine code
    → dieksekusi

Linux:
  dotnet run
    → dotnet (muxer) baca argumen, panggil libhostfxr.so secara internal
    → libhostfxr.so mencari CLR terinstal di /usr/share/dotnet/shared/...
    → libcoreclr.so dimuat
    → libcoreclr.so membaca Calculator.dll (IL) , file yang SAMA persis
    → libclrjit.so JIT-compile method Add() → native x64 Linux machine code
    → dieksekusi

Jalur 2 , ./MyApp / MyApp.exe (lewat apphost)

./MyApp dieksekusi (apphost, native code kecil, hostfxr ter-embed di dalamnya)
   → apphost cari CLR (folder sendiri kalau self-contained, sistem kalau framework-dependent)
   → CLR dimuat, baca MyApp.dll (dicari otomatis di folder yang sama), JIT, eksekusi

Perbandingan kedua jalur

dotnet run (muxer)./MyApp (apphost)
Yang dieksekusi pertamadotnet (terinstal sistem)MyApp (di folder project/publish)
Perlu tahu path .dllYaTidak, otomatis cari di folder sendiri
Apphost terpakai?TidakYa
Hasil akhirSama (CLR dimuat → JIT → eksekusi)Sama

Ini menjelaskan kenapa dotnet MyApp.dll tetap bisa jalan meski file apphost (MyApp.exe/MyApp) dihapus , jalur dotnet tidak pernah membutuhkan apphost sama sekali.


9. Apakah .dll Hasil Compile di Windows Bisa Dibaca di Linux?

Ya, bisa , dengan syarat yang dipindah adalah file .dll berisi IL (hasil Roslyn), bukan native code:

bash

# Di Windows
dotnet build
# → Calculator.dll (IL, belum di-JIT)

# Salin Calculator.dll ke mesin Linux (scp, USB, apa saja)

# Di Linux
dotnet Calculator.dll
# → BISA jalan, karena CLR di Linux membaca IL yang sama,
#   lalu JIT-compile sendiri ke native code Linux

Yang tidak bisa dipindah lintas OS:

Yang dipindahBisa jalan di Linux?
Calculator.dll (hasil dotnet build, berisi IL)Ya , CLR Linux baca IL, JIT sendiri secara lokal
Native AOT output dari WindowsTidak , sudah native code Windows murni (Exec format error di Linux)
Apphost MyApp.exe dari self-contained WindowsTidak (tapi MyApp.dll di sebelahnya tetap bisa dipakai lewat dotnet MyApp.dll)

Jadi: yang portable itu file IL sebelum di-JIT, bukan hasil dari proses JIT , karena hasil JIT memang sengaja spesifik-platform dan cuma hidup di memory selama proses berjalan, tidak pernah jadi file.


10. Ringkasan Besar

TahapApa yang terjadiKapanPlatform-dependent?
Roslyn compileSource code → assembly (IL + metadata + manifest)Development time (dotnet build)Tidak , IL identik di semua OS
CLR load & JITAssembly dibaca, IL diterjemahkan ke native codeRuntime, saat app dijalankanYa , CLR & hasil JIT spesifik per OS
AOT compile (opsional)IL → native code langsung, tanpa CLR/JIT saat runtimePublish timeYa , hasilnya spesifik OS, tidak cross-compile
AspekJITAOT (Native)
Kapan compile ke native codeSaat runtime, per-method, saat pertama dipanggilSaat publish, sebelum aplikasi jalan
Startup timeLebih lambat (ada warm-up)Lebih cepat
Performa puncak (steady-state)Bisa lebih optimal (tiered compilation)Konsisten, tanpa optimisasi berbasis profil runtime
Ukuran outputLebih kecilLebih besar
Reflection dinamisDidukung penuhTerbatas
Butuh .NET runtime di mesin targetYa (kecuali self-contained)Tidak

Poin paling penting untuk diingat sebagai pemula:

  1. Roslyn = compiler, jalan sekali saat build, hasilkan assembly (IL + metadata) yang portable.
  2. CLR = runtime, kumpulan banyak file (bukan satu file, bukan dibuat Roslyn), jalan setiap kali app dieksekusi, hasilnya (native code lewat JIT) spesifik per OS dan cuma hidup di memory.
  3. Apphost (MyApp.exe/MyApp) cuma “tombol power” pemanggil CLR , bukan CLR itu sendiri.
  4. dotnet run lewat jalur muxer (dotnet), ./MyApp lewat jalur apphost , dua pintu berbeda, hasil akhir sama.
  5. File .dll berisi IL itu portable lintas OS; yang tidak portable adalah hasil AOT atau apphost (karena itu sudah native code/executable OS-spesifik).

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *