Go ใน Docker: GOMAXPROCS ตาม CPU limit (Go 1.25) และ Green Tea GC (Go 1.26)
Go 1.25 ตั้ง GOMAXPROCS ตาม CPU limit ของ container เอง Go 1.26 เปิด Green Tea GC เป็นค่าเริ่มต้น สิ่งที่ต้องเช็กใน Dockerfile และ go.mod พร้อมผลวัดจริง
- ก่อน Go 1.25 โปรแกรมใน container ที่จำกัด 2 CPU บนเครื่อง 16 core ตั้ง GOMAXPROCS=16 แล้วโดน throttle เป็นช่วง ๆ
- Go 1.25 อ่าน CPU limit จาก cgroup ปัดขึ้น ไม่ต่ำกว่า 2 และอัปเดตเองเมื่อ limit เปลี่ยน แต่ต้องตั้ง go 1.25 ขึ้นไปใน go.mod
- ตั้ง GOMAXPROCS เองหรือเรียก runtime.GOMAXPROCS จะปิดการปรับอัตโนมัติ ส่วน memory limit Go ไม่อ่านให้ ต้องตั้ง GOMEMLIMIT เอง
- Green Tea GC เป็นค่าเริ่มต้นตั้งแต่ Go 1.26 เอกสารคาด GC overhead ลด 10-40% ในโปรแกรมที่ใช้ GC หนัก
Go เป็นภาษาที่นิยมเขียน service ที่รันใน container แต่นานหลายปีที่ runtime ไม่รู้ว่าตัวเองถูกจำกัด CPU อยู่ ผลคือโปรแกรมโดน kernel หยุดเป็นช่วง ๆ โดยไม่มี error ให้เห็น Go 1.25 แก้เรื่องนี้ และ Go 1.26 เปลี่ยน garbage collector (GC) เป็น Green Tea บทความนี้อธิบายทั้งสองเรื่อง สิ่งที่ต้องเช็กใน Docker และผลวัดจริง
ทดสอบบน Go 1.27.3 (Windows 11, AMD Ryzen 7 5800X 16 logical CPU) สำหรับผลวัด GC ส่วนพฤติกรรม cgroup ตรวจกับเอกสาร Go และ Docker ณ ตุลาคม 2026 (รุ่นที่ยังดูแลอยู่คือ 1.27.3 และ 1.26.10)
ปัญหาเดิม: GOMAXPROCS เท่าจำนวน core ของเครื่อง
GOMAXPROCS คือจำนวน thread สูงสุดที่รันโค้ด Go พร้อมกันได้ ตั้งแต่ Go 1.5 ถึง 1.24 ค่าเริ่มต้นคือจำนวน CPU ของ เครื่อง ไม่ใช่ของ container
Docker จำกัด CPU ด้วย cgroup: --cpus=2 เท่ากับให้ใช้ CPU ได้ 200 ms ในทุกช่วง 100 ms ถ้าใช้หมดก่อน kernel จะ หยุดทั้ง process จนจบช่วงนั้น เรียกว่า throttling
ตัวอย่างตัวเลข: throttling เกิดยังไง
เครื่อง 16 CPU, container --cpus=2, Go 1.24 จึงได้ GOMAXPROCS=16
- งบ CPU ต่อช่วง = 2 × 100 ms = 200 ms
- ถ้าทั้ง 16 thread ทำงานพร้อมกัน ใช้งบหมดใน 200 ÷ 16 = 12.5 ms
- ที่เหลือ 100 − 12.5 = 87.5 ms โปรแกรมถูกหยุดทั้งหมด request ที่ค้างอยู่รอไปด้วย
ช่วงที่ thread ทำงานพร้อมกันเยอะที่สุดมักเป็นตอน GC ผลวัดในหัวข้อท้ายบท GC แบบเดิมที่ GOMAXPROCS=16 ใช้ CPU 1.21 วินาทีในเวลาจริง 0.229 วินาที เฉลี่ย 1.21 ÷ 0.229 ≈ 5.3 core สำหรับ GC อย่างเดียว เกินงบ 2 core ไปไกล ถ้า GOMAXPROCS=2 โปรแกรมจะไม่มีวันรันโค้ด Go เกิน 2 thread และไม่ชนเพดาน
Go 1.25 เปลี่ยนอะไร
บน Linux runtime อ่าน CPU limit จาก cgroup (cpu.max ใน cgroup v2) แล้วใช้เป็นค่าเริ่มต้นถ้าน้อยกว่าจำนวน CPU ของเครื่อง กฎจากเอกสาร runtime:
- limit เป็นเศษ ปัดขึ้น เช่น 2.5 → 3
- ไม่ต่ำกว่า 2 ยกเว้นเครื่องมี CPU หรือ affinity น้อยกว่า 2
- อัปเดตเอง เมื่อ limit หรือจำนวน CPU เปลี่ยน ไม่บ่อยกว่าวินาทีละครั้ง
- ไม่สน CPU request ของ Kubernetes ใช้แต่ limit
- ปิดเองถ้าตั้ง env
GOMAXPROCSหรือเรียกruntime.GOMAXPROCS(n)หรือใช้ GODEBUGcontainermaxprocs=0/updatemaxprocs=0 - เปิดเฉพาะเมื่อบรรทัด
goในgo.modเป็น 1.25 ขึ้นไป
ผลบนเครื่อง 16 CPU:
| ตั้งค่า container | GOMAXPROCS (Go 1.25+) | เหตุผล |
|---|---|---|
| ไม่จำกัด | 16 | ไม่มี limit |
--cpus=2 | 2 | ตาม limit |
--cpus=2.5 | 3 | ปัดขึ้น |
--cpus=0.5 | 2 | ขั้นต่ำ 2 |
--cpu-shares=512 | 16 | shares เป็นน้ำหนัก ไม่ใช่ limit |
--cpuset-cpus=0,1 | 2 | จำนวน CPU ใน affinity mask |
env GOMAXPROCS=4 | 4 | ค่าที่ตั้งเองชนะ และไม่อัปเดตอัตโนมัติ |
สิ่งที่ต้องเช็กใน Docker
1. go.mod บรรทัดนี้สำคัญกว่าเวอร์ชันของ image ที่ใช้ build
module example.com/api
go 1.27
2. ลบค่าที่ตั้งเองจากยุคก่อน เช่น ENV GOMAXPROCS=2 ใน Dockerfile หรือ library ที่เรียก runtime.GOMAXPROCS ตอนเริ่ม (เช่น go.uber.org/automaxprocs) เพราะการตั้งเองปิดการอัปเดตอัตโนมัติ
3. log ค่าตอนเริ่มโปรแกรม จะได้รู้ว่าได้ค่าที่คิดไว้
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Printf("go=%s NumCPU=%d GOMAXPROCS=%d\n",
runtime.Version(), runtime.NumCPU(), runtime.GOMAXPROCS(0))
}
runtime.GOMAXPROCS(0) อ่านค่าโดยไม่เปลี่ยน รันบนเครื่องทดสอบได้ NumCPU=16 GOMAXPROCS=16 และ GOMAXPROCS=2 go run . ได้ 2 ใน container บน Linux ดู limit จริงได้จาก cat /sys/fs/cgroup/cpu.max ซึ่ง --cpus=1.5 จะเป็น 150000 100000 (quota กับ period หน่วยไมโครวินาที)
4. memory limit Go ไม่อ่านให้ ค่าเริ่มต้นของ memory limit คือไม่จำกัด (math.MaxInt64) ถ้า container มี mem_limit ให้ตั้ง GOMEMLIMIT ต่ำกว่าเล็กน้อย คู่มือ GC แนะนำเผื่อ 5-10% สำหรับ memory ที่ runtime มองไม่เห็น
# docker-compose.yml (ส่วนของ service Go)
services:
api:
image: example/api:1.0
cpus: 1.5
mem_limit: 512m
environment:
GOMEMLIMIT: 460MiB # 512 × 0.9 ≈ 460
compose นี้ต่อจากชุดที่อธิบายใน ทำ Docker ให้ปลอดภัยบนเซิร์ฟเวอร์เล็ก ซึ่งจำกัด memory ทุก container อยู่แล้ว
GOMAXPROCS ดูแลฝั่ง CPU ให้เองแล้วตั้งแต่ Go 1.25 แต่ฝั่ง memory ยังเป็นหน้าที่ของเรา:
mem_limitคู่กับGOMEMLIMITเสมอ
Green Tea GC คืออะไร
GC ของ Go ต้องไล่ mark object ที่ยังใช้อยู่ทั้ง heap งานนี้กิน CPU มากที่สุดเมื่อ heap เต็มไปด้วย object เล็กจำนวนมาก Green Tea ออกแบบการ mark และสแกน object เล็กใหม่ ให้เข้าถึง memory ต่อเนื่องกันมากขึ้น (locality ดีขึ้น) และกระจายงานข้าม core ได้ดีขึ้น
- Go 1.25 (สิงหาคม 2025): เป็น experiment เปิดด้วย
GOEXPERIMENT=greenteagc - Go 1.26 (กุมภาพันธ์ 2026): เปิดเป็นค่าเริ่มต้น เอกสารคาด GC overhead ลด 10-40% ในโปรแกรมจริงที่ใช้ GC หนัก และบน CPU amd64 รุ่นใหม่ (Intel Ice Lake หรือ AMD Zen 4 ขึ้นไป) ลดเพิ่มอีกราว 10% จากการใช้คำสั่ง vector
- ปิดได้ด้วย
GOEXPERIMENT=nogreenteagcตอน build เอกสาร 1.26 บอกว่าคาดว่าจะถอดใน 1.27 แต่ go1.27.3 ยัง build ผ่าน (go version -mแสดงGOEXPERIMENT=nogreenteagc)
ไม่ต้องแก้โค้ดใด ๆ แค่ build ใหม่ด้วย Go 1.26 ขึ้นไป
วัดเอง: Green Tea กับ GC แบบเดิม
โปรแกรมทดสอบเก็บต้นไม้ binary ลึก 20 ชั้น (ราว 2 ล้าน object เล็ก) ไว้ตลอด แล้วสร้างต้นไม้ลึก 14 ชั้นทิ้ง 400 รอบ อ่านเวลา CPU ของ GC จาก runtime/metrics
s := []metrics.Sample{{Name: "/cpu/classes/gc/total:cpu-seconds"}}
metrics.Read(s)
fmt.Printf("gcCPU=%.2fs\n", s[0].Value.Float64())
build สองตัวจากโค้ดเดียวกัน:
go build -o gt.exe ./gcb # Green Tea (ค่าเริ่มต้น)
GOEXPERIMENT=nogreenteagc go build -o old.exe ./gcb # GC แบบเดิม
ค่า median จาก 5 รอบ:
| GOMAXPROCS | GC | เวลา GC (CPU) | เวลาจริงทั้งโปรแกรม |
|---|---|---|---|
| 16 | เดิม | 1.21 s | 229 ms |
| 16 | Green Tea | 0.53 s | 207 ms |
| 2 | เดิม | 0.25 s | 290 ms |
| 2 | Green Tea | 0.21 s | 272 ms |
คิดเป็นสัดส่วน: ที่ 16 thread เวลา GC ลด (1.21 − 0.53) ÷ 1.21 ≈ 56% ที่ 2 thread ลด (0.25 − 0.21) ÷ 0.25 = 16% เวลาจริงลด 10% และ 6% ตามลำดับ
อ่านผลให้ถูก: นี่คือ microbenchmark ที่สร้าง object เล็กล้วน ซึ่งเป็นงานที่ Green Tea ถนัดที่สุด และ Ryzen 5800X เป็น Zen 3 จึงไม่ได้ส่วนเร่งจาก vector ตัวเลขของ service จริงให้ยึดช่วง 10-40% ของเอกสาร แล้ววัด p99 latency กับ CPU ของ service ตัวเองก่อนและหลังอัปเกรด และสังเกตว่าผลต่างที่ GOMAXPROCS=2 น้อยกว่ามาก ใน container ที่จำกัด CPU อย่าคาดหวังตัวเลขแบบเครื่อง 16 core
ข้อผิดพลาดที่เจอบ่อย
- อัปเกรด image เป็น golang:1.27 แต่ go.mod ยังเป็น
go 1.24ได้ GOMAXPROCS แบบเดิม ENV GOMAXPROCSค้างใน Dockerfile ค่าคงที่ ไม่ตาม limit ที่เปลี่ยน- ตั้งแต่ CPU request ใน Kubernetes แล้วคิดว่า Go จะจำกัดตัวเอง ต้องมี limit
- ไม่ตั้ง GOMEMLIMIT heap โตจนชน
mem_limitแล้ว container โดน OOM kill ทั้งที่ GC ยังเก็บได้อีก - วัดผล GC บนเครื่อง dev ที่ไม่จำกัด CPU แล้วเอาไปคาดการณ์ production ที่จำกัด 2 core
- ใช้
nogreenteagcถาวร เป็นทางหนีชั่วคราวเท่านั้น
บนเซิร์ฟเวอร์เล็กที่แบ่ง RAM และ CPU ให้หลายระบบ (วางระบบบนเครื่อง 2GB) สองค่านี้คือเส้นแบ่งระหว่าง service ที่ช้าเป็นพัก ๆ แบบหาสาเหตุไม่เจอ กับ service ที่นิ่ง
แหล่งอ้างอิง
คำถามที่พบบ่อย
ต้องแก้โค้ดไหมถึงจะได้ GOMAXPROCS แบบใหม่
ไม่ต้องแก้โค้ด แต่ต้อง build ด้วย Go 1.25 ขึ้นไป และบรรทัด go ใน go.mod ต้องเป็น 1.25 ขึ้นไป ถ้ายังเป็น go 1.24 runtime จะทำตัวแบบเดิมเพื่อความเข้ากันได้ และต้องไม่ตั้ง env GOMAXPROCS ทิ้งไว้
Kubernetes ตั้งแต่ CPU request ไม่ได้ตั้ง limit จะได้อะไร
ได้ GOMAXPROCS เท่าจำนวน CPU ของ node เหมือนเดิม Go ไม่ใช้ค่า request เพราะ container ที่ไม่มี limit ใช้ CPU ว่างของเครื่องเกิน request ได้ ถ้าต้องการให้ Go จำกัดตัวเอง ต้องมี limit
ถ้า Green Tea GC มีปัญหา ปิดได้ไหม
ได้ build ด้วย GOEXPERIMENT=nogreenteagc เอกสาร Go 1.26 บอกว่าตัวเลือกนี้คาดว่าจะถูกถอดใน 1.27 แต่ทดสอบกับ go1.27.3 แล้วยัง build ผ่าน ใช้เป็นทางหนีชั่วคราวและรายงานปัญหาไปที่ทีม Go
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ