DEV Community

Gophernment
Gophernment

Posted on

ทำไม Go ถึง compile ไว — เล่าจากห้องเซิร์ฟเวอร์ Google ที่ร้อนระอุ

ทำไม Go ถึง compile ไว — เล่าจากห้องเซิร์ฟเวอร์ Google ที่ร้อนระอุ

ปี 2007 — ห้องเซิร์ฟเวอร์ของ Google ร้อนจนแทบละลาย

ไม่ใช่เพราะอากาศ — แต่เพราะ คอมไพเลอร์ C++

นักพัฒนาที่ Google สามคน — Robert Griesemer, Rob Pike, และ Ken Thompson — กำลังรอโค้ด compile

Ken Thompson คือใคร? เขาคือคนที่สร้าง Unix, สร้าง ภาษา B (บรรพบุรุษของ C), ร่วมคิดค้น UTF-8, และได้รับ Turing Award — รางวัลโนเบลของวงการคอมพิวเตอร์

สามคนนี้... นั่งรอโค้ด compile เป็นสิบนาที

Pike เล่าในภายหลังว่า: "เราเบื่อที่จะรอ — ก็เลยสร้างภาษาใหม่"


เรื่องเล่าก่อน Go — ทำไม C++ ที่ Google ถึง compile ช้า

ลองนึกภาพ: Google มีโค้ด C++ มหาศาล — หนึ่งไฟล์ #include อีกไฟล์ — #include ซ้อน #include — กลายเป็น chain ยาวเป็นกิโลเมตร

// main.cc
#include "server.h"
// server.h
#include "handler.h"
// handler.h
#include "request.h"
// request.h
#include "database.h"
// database.h — #include อีก 20 ไฟล์...
Enter fullscreen mode Exit fullscreen mode

เปลี่ยนโค้ด 1 บรรทัดใน database.h → ทุกไฟล์ที่ #include chain มาถึงต้อง compile ใหม่หมด → เป็นชั่วโมง

และอย่าลืม — C++ มี template, มี preprocessor, มี header files, มี forward declaration, มี ODR (One Definition Rule) — ทุกอย่างทำให้ compiler ต้องทำงานหนักขึ้น

สามคนนี้ตัดสินใจว่า: "ภาษาใหม่ต้อง compile ไว — มาก"


ข้อที่ 1: ไม่มี Header Files — อ่านตรง ๆ

Go ไม่มี .h ไฟล์ — compiler อ่าน .go โดยตรง:

// Go — import อย่างเดียว ไม่มี include
import (
    "net/http"
    "encoding/json"
)
Enter fullscreen mode Exit fullscreen mode

compiler รู้ว่า package net/http export อะไรบ้าง — อ่านครั้งเดียว — จบ

C++ ต้องทำอะไร? compiler อ่าน header.h → preprocessor ไล่ #include → ต่อ chain → อ่านซ้ำ 20 รอบ — source file เดียวอาจใหญ่เป็นแสนบรรทัดก่อนถึง compiler จริง

Go: 1 ไฟล์ = 1 ไฟล์ — compiler รู้ทันทีว่าต้องอ่านอะไร


ข้อที่ 2: Dependency Graph — ห้ามวกกลับมาที่เดิม

Go ออกแบบ dependency เป็น DAG (Directed Acyclic Graph) — ห้าม circular import

// ✅ ถูก — dependency เป็น tree
package A  import B
package B  import C

// ❌ ผิด — circular — compile ไม่ผ่าน
package A  import B
package B  import A  // compile error!
Enter fullscreen mode Exit fullscreen mode

ทำไมถึงเร็ว? ถ้า dependency เป็น DAG — compiler สามารถ:

  1. เรียงลำดับการ compile ได้ — compile C ก่อน, แล้ว B, แล้ว A
  2. ทำ parallel ได้ — compile C, B, A พร้อมกันถ้าไม่ขึ้นต่อกัน
  3. รู้ว่าไฟล์ไหนต้อง compile ใหม่ — เปลี่ยน C อย่างเดียว → A, B ไม่ต้อง compile ใหม่ถ้า interface ไม่เปลี่ยน

Go ใช้ DAG ตั้งแต่ดีไซน์ — ไม่ใช่ afterthought


ข้อที่ 3: Import ที่ไม่ได้ใช้ = Error

import "fmt"
import "os"  // ❌ compile error: imported and not used

func main() {
    fmt.Println("hello")
}
Enter fullscreen mode Exit fullscreen mode

ฟังดูน่ารำคาญ — แต่มันมีเหตุผล: compiler ไม่ต้องเสียเวลาอ่าน package ที่คุณ import มาแต่เปล่าประโยชน์

ใน C++ — คุณ #include <vector> แล้วไม่ได้ใช้ — compiler ยังต้องอ่านทั้งไฟล์ — สิ้นเปลือง

Go: goimports จัดการให้ — import เอง, ลบเอง — คุณไม่ต้องคิด


ข้อที่ 4: Object Files อัจฉริยะ — metadata อยู่ส่วนหัว

เวลา Go compile package — มันสร้าง .a (object file) ที่ สมบูรณ์ในตัวเอง — และสิ่งที่สำคัญคือการจัดเรียงข้อมูล

math.a:
  ┌─────────────────────────────┐
  │ METADATA (ส่วนหัว)            │
  │  - รายชื่อ symbols ที่ export   │
  │  - รายชื่อ dependencies       │
  │  - type information (สำหรับ  │
  │    reflection)              │
  ├─────────────────────────────┤
  │ MACHINE CODE (ส่วนท้าย)       │
  │  - โค้ดจริงของ package math   │
  └─────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

ประเด็นสำคัญ: Metadata อยู่ที่ ส่วนหัวของไฟล์ — เวลา package B import math — compiler อ่านแค่ metadata (ไม่กี่ KB) — ไม่ต้อง parse source code ทั้งหมด (หลายพันบรรทัด) — ไม่ต้อง re-type-check

เปรียบเทียบกับ C++:

// C++ — ทุกครั้งที่ compile main.cc
#include "math.h"   // ← ต้องเปิด math.h → parse → type-check → expand
                    //    math.h อาจ include math_impl.h → parse → expand
                    //    math_impl.h อาจ include vector → parse → expand
                    //    ทั้งหมดนี้เกิดขึ้นซ้ำในทุก .cc ที่ใช้ math.h
Enter fullscreen mode Exit fullscreen mode

Go: อ่าน metadata ครั้งเดียวต่อ package — C++: parse headers ซ้ำในทุก translation unit

💡 ลองนึกภาพมี 100 ไฟล์ import net/http — C++ parse header ซ้ำ 100 รอบ — Go อ่าน metadata ของ net/http.a 100 รอบ แต่การอ่าน metadata (ไม่กี่ KB) เร็วกว่า parse source (หลายพันบรรทัด) หลายเท่า


ข้อที่ 5: Plan 9 Assembly — "ภาษาเดียว ทุก CPU"

Go ใช้ Plan 9 assembly — assembly language ที่ออกแบบโดยทีมเดียวกับ Unix — Ken Thompson เป็นหนึ่งในนั้น

// Plan 9 assembly ของ Go
TEXT ·Add(SB), NOSPLIT, $0-16
    MOVQ    a+0(FP), AX
    ADDQ    b+8(FP), AX
    MOVQ    AX, ret+16(FP)
    RET
Enter fullscreen mode Exit fullscreen mode

แล้วมันต่างจากภาษาอื่นยังไง?

C/C++: compiler ใช้ assembly ของ CPU นั้นโดยตรง

// x86 assembly → C compiler สร้าง
add eax, ebx     ← ใช้ได้บน x86 เท่านั้น

// ARM assembly → คนละโลก
add r0, r0, r1   ← ใช้ได้บน ARM เท่านั้น
Enter fullscreen mode Exit fullscreen mode

การจะ cross-compile C — คุณต้องมี cross-compiler toolchain:

  • Linux → ARM: ต้องติดตั้ง arm-linux-gnueabihf-gcc
  • macOS → Windows: ต้องติดตั้ง x86_64-w64-mingw32-gcc
  • แต่ละคู่ platform = toolchain ใหม่ — ติดตั้งครั้งละเป็นชั่วโมง

Rust: ใช้ LLVM เป็น backend — LLVM มี IR (Intermediate Representation) ของตัวเอง — ข้อดีคือ LLVM optimize เก่งมาก — แต่ LLVM เองเป็นโค้ด C++ มหาศาล — หนัก ช้า

Go เลือกทางสายกลาง: เขียน compiler backend เองทั้งหมด — ไม่ใช้ LLVM, ไม่ใช้ GCC, ไม่ใช้ toolchain ภายนอกเลยแม้แต่นิดเดียว

Mapping — แผนที่

Go เขียนโค้ด → คอมไพล์เป็น Plan 9 assembly (IR กลาง) → แล้วคอมไพล์ต่อเป็น machine code โดยตรง:

write.go (Go source)
    │
    ▼
[Go compiler frontend] → parse, type-check
    │
    ▼
Plan 9 assembly (IR กลาง — architecture-independent)
    │
    ├──→ [x86-64 backend]  → MOVQ, ADDQ, RET (Intel/AMD)
    ├──→ [ARM64 backend]   → MOV, ADD, RET   (Apple Silicon, Pi)
    ├──→ [ARM backend]     → MOV, ADD, RET   (Pi 32-bit)
    ├──→ [RISC-V backend]  → ...              (SiFive, etc.)
    └──→ [MIPS, s390x, ...]
Enter fullscreen mode Exit fullscreen mode

ทุก backend อยู่ใน Go compiler เรียบร้อย — ไม่ต้องติดตั้งอะไรเพิ่ม

เปรียบเทียบ 3 ภาษา — ใครต้อง setup อะไรบ้าง

Go Rust C/C++
Cross-compile ไป ARM GOARCH=arm go build rustup target add + linker apt install cross-gcc
Backend เขียนเอง (Go) LLVM (C++) GCC/LLVM
ขนาด toolchain ~100MB ~2GB+ ~500MB-2GB
เวลา setup 0 (ติด Go แล้ว cross-compile ได้เลย) 5-10 นาที (add target) 1-3 ชม (หา toolchain, build)

ตัวอย่างจริง — ผมเคย cross-compile Go ให้ Raspberry Pi:

# บน Mac — สร้าง binary สำหรับ Raspberry Pi
GOOS=linux GOARCH=arm64 go build -o myapp-pi .

# copy ไป Pi
scp myapp-pi pi@raspberrypi:~

# รัน — ใช้ได้ทันที
ssh pi@raspberrypi ./myapp-pi
Enter fullscreen mode Exit fullscreen mode

3 บรรทัด — ไม่ต้องหา ARM cross-compiler — ไม่ต้องติดตั้ง sysroot — เพราะ Go compiler มี ARM backend อยู่แล้ว — มันรู้ว่า ARM ใช้ instruction อะไร, register ชื่ออะไร, calling convention เป็นยังไง — ทุกอย่าง built-in

นี่คือเหตุผลที่ Go เป็นราชาแห่ง cross-compilation — ไม่ใช่เพราะมันมีฟีเจอร์วิเศษ — แต่เพราะมันไม่พึ่งพาใครเลย


ข้อที่ 6: Static Linking — ไม่ต้องพึ่งระบบ

Go compile เป็น static binary — รวมทุกอย่างไว้ในไฟล์เดียว:

$ ls -lh myapp
-rwxr-xr-x 8.2M myapp    # ← 8MB — ทั้ง app ในไฟล์เดียว

$ ldd myapp
not a dynamic executable  # ← ไม่พึ่งพา .so ใด ๆ
Enter fullscreen mode Exit fullscreen mode

C/C++ ต้องทำอะไร? compile เป็น binary → ต้องมี libc.so, libstdc++.so, libpthread.so ฯลฯ — เปลี่ยน distro → พัง

Go: binary เดียว — copy ไปเครื่องไหนก็รันได้ — compile บน Mac, deploy บน Linux, รันบน Raspberry Pi — ง่ายมาก


ข้อที่ 7: Compiler เขียนด้วย Go — เร็วขึ้นเรื่อย ๆ

Go compiler ยุคแรกเขียนด้วย C — แต่ตั้งแต่ Go 1.5 (2015) — compiler เขียนด้วย Go เอง

นี่คือ "bootstrapping" — Go compile ตัวเอง — และทุกครั้งที่ Go เวอร์ชันใหม่เร็วขึ้น — compiler ก็เร็วขึ้นตาม

Go 1.0 (2012): compiler เขียนด้วย C
Go 1.5 (2015): compiler เขียนด้วย Go — ช้าลงเล็กน้อย
Go 1.7 (2016): compiler เร็วขึ้น 20-30% ด้วย SSA backend
Go 1.9 (2017): compiler เร็วขึ้นอีก
...
Go 1.22 (2024): compiler แทบจะ instantaneous สำหรับโปรเจกต์ขนาดกลาง
Enter fullscreen mode Exit fullscreen mode

ข้อที่ 8: Grammar Simplicity — อ่านโค้ดรอบเดียวจบ

Go แตกต่างจาก C++ ตรงที่ ไวยากรณ์ถูกออกแบบมาเพื่อ compiler — ไม่ใช่เพื่อโปรแกรมเมอร์ที่ชอบความซับซ้อน

Hand-Written Recursive Descent — parser ที่มนุษย์เขียนเอง

Go ใช้ hand-written recursive descent parser — คือ parser ที่เขียนด้วยมือ ไม่ได้ generate จาก grammar (ต่างจาก yacc/bison ที่ใช้ใน C compiler หลายตัว)

การเขียน parser ด้วยมือมีข้อดี: compiler ควบคุมทุกอย่างได้ — ไม่มี overhead จาก table-driven parsing — และ error message อ่านรู้เรื่องเพราะมนุษย์เขียนเอง

📜 เกร็ดประวัติศาสตร์: Go 1.0-1.4 ใช้ yacc (ซึ่งใช้ LALR(1)) สำหรับบางส่วนของ parser — แต่ตั้งแต่ Go 1.5 ที่ compiler ถูก rewrite ด้วย Go ทั้งหมด — parser เปลี่ยนเป็น hand-written recursive descent เต็มตัว

แต่ไม่ว่าจะใช้ yacc หรือ recursive descent — หัวใจสำคัญคือ Go grammar ถูกออกแบบให้ ง่ายพอที่ parser แบบไหนก็ทำงานได้ใน single pass

// ทุกประโยคใน Go — compiler รู้ทันทีว่าคืออะไร
func Add(a, b int) int {    // ← function declaration — ไม่ใช่อย่างอื่น
    return a + b
}

x := Add(1, 2)              // ← short variable declaration — ชัดเจน
var y int = Add(3, 4)       // ← variable declaration — var นำหน้า
Enter fullscreen mode Exit fullscreen mode

compiler ไม่ต้องถามว่า "นี่คือ function call หรือ variable declaration?" — เพราะ syntax บังคับให้เดาไม่ได้

C++: Most Vexing Parse — compiler เองก็สับสน

// C++ — Most Vexing Parse: compiler ไม่รู้ว่านี่คืออะไร
Widget w();  // ❓ function declaration (คืนค่า Widget) — หรือ variable declaration?
             // คำตอบ: มันคือ function declaration! (Most Vexing Parse)

Widget w{};  // ✅ variable declaration — ใช้ {} แทน
Enter fullscreen mode Exit fullscreen mode

นี่คือตัวอย่างของ context-sensitive grammar ใน C++ — compiler ต้องดูบริบทว่ามี type ชื่อ Widget ไหม — มี constructor แบบไหน — มันคือ function หรือ variable — ทั้งหมดนี้ใช้เวลา CPU มหาศาล

ตารางเทียบ — อะไรทำให้ Go ง่าย C++ ยาก

Go C++
Grammar complexity ออกแบบให้ unambiguous — parser เลือกใช้ได้ (yacc หรือ recursive descent) Context-sensitive — parser ต้องเดาจากบริบท
Parsing Single-pass — รอบเดียวจบ Multiple-pass + template instantiation
Preprocessor ❌ ไม่มี #include, #define, #ifdef
Forward declaration ❌ ไม่ต้อง — order อิสระ ✅ ต้อง — class A;
Template ❌ (generics 1.18+ เป็นคนละเรื่อง) ✅ template metaprogramming
Compile-time computation constexpr, SFINAE

ทำไม Go ถึงไม่มี preprocessor?

Preprocessor ใน C/C++ ทำให้ compiler ต้องทำงาน 2 รอบ:

source.c  [preprocessor: expand #include, #define]  expanded.c  [compiler]
Enter fullscreen mode Exit fullscreen mode

ทุกไฟล์ .c ผ่าน preprocessor → ขยายเป็นไฟล์ใหญ่ยักษ์ → ส่งให้ compiler จริง — นี่คือ 2x งาน

Go: source code → compiler โดยตรง — ไม่มีขั้นกลาง — 1 pass, 1 job

ผลลัพธ์ในโลกจริง

Kubernetes (Go):      ~3 ล้านบรรทัด — compile ทั้งโปรเจกต์ใน ~2 นาที
Chromium (C++):      ~35 ล้านบรรทัด — compile ใช้เวลา 30-60 นาที (แม้ใช้ distributed build!)
Enter fullscreen mode Exit fullscreen mode

สัดส่วน: Kubernetes มีโค้ด 1/10 ของ Chromium — แต่ compile เร็วกว่า 15-30 เท่า — ไม่ใช่แค่เพราะขนาดเล็กกว่า — แต่เพราะ Go compiler ถูกออกแบบมาให้ "เร็ว" ตั้งแต่แรก


📊 สรุป — อะไรทำให้ Go compile ไว

ปัจจัย ทำอะไร ทำไมถึงเร็ว
ไม่มี Header Files อ่าน .go ตรง compiler ไม่ต้องไล่ #include chain
DAG Dependency ห้าม circular import เรียงลำดับ compile ได้, parallel ได้
Unused Import = Error compiler ไม่โหลด package ที่ไม่ใช้ ลดงาน compiler
Object Files อัจฉริยะ .a มีข้อมูลครบ ไม่ต้องเปิด source หา dependency
Plan 9 Assembly IR กลาง cross-compile ได้ทันที โดยไม่ต้อง toolchain ใหม่
Static Linking binary ในไฟล์เดียว deploy ง่าย ไม่พึ่งระบบ
Compiler เขียนด้วย Go bootstrapping compiler เร็วขึ้นทุกเวอร์ชัน
Simple Grammar hand-written parser, ไม่มี preprocessor, single-pass ไม่ต้อง parse ซ้ำ, ไม่คลุมเครือ

🎯 ทำไมเรื่องนี้ถึงสำคัญ

ไม่ใช่แค่ "compile ไว = สะดวก" — แต่มันเปลี่ยน วิธีคิด

C++: compile → รอ 5 นาที → เช็คมือถือ → ลืมว่าทำอะไรอยู่
Go:  compile → 2 วิ → test → แก้ → compile → test → flow
Enter fullscreen mode Exit fullscreen mode

Fast feedback loop คือ superpower — คุณทดลองได้เร็ว, กล้า refactor, ไม่กลัว compile error — เพราะมันใช้เวลาแค่ 2 วิ

นี่คือสิ่งที่ Ken Thompson, Rob Pike, และ Robert Griesemer ตั้งใจ — ไม่ใช่แค่ "สร้างภาษาใหม่" — แต่คือการสร้าง ประสบการณ์ ใหม่ในการเขียนโปรแกรม


📚 อ่านต่อ:

Top comments (0)