บทความ › การเขียนโปรแกรม

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 พร้อมผลวัดจริง

กลาง อ่าน 9 นาที 23 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • ก่อน 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

  1. งบ CPU ต่อช่วง = 2 × 100 ms = 200 ms
  2. ถ้าทั้ง 16 thread ทำงานพร้อมกัน ใช้งบหมดใน 200 ÷ 16 = 12.5 ms
  3. ที่เหลือ 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 และไม่ชนเพดาน

ทุกช่วง 100 mscontainer จำกัด 2 CPU = งบ 200 msGOMAXPROCS=1616 thread ใช้งบหมดใน 12.5 msที่เหลือ 87.5 msโปรแกรมหยุดทั้งหมดGOMAXPROCS=2ไม่ชนเพดาน ทำงานต่อเนื่อง
container จำกัด 2 CPU บนเครื่อง 16 core ก่อน Go 1.25

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) หรือใช้ GODEBUG containermaxprocs=0 / updatemaxprocs=0
  • เปิดเฉพาะเมื่อบรรทัด go ใน go.mod เป็น 1.25 ขึ้นไป

ผลบนเครื่อง 16 CPU:

ตั้งค่า containerGOMAXPROCS (Go 1.25+)เหตุผล
ไม่จำกัด16ไม่มี limit
--cpus=22ตาม limit
--cpus=2.53ปัดขึ้น
--cpus=0.52ขั้นต่ำ 2
--cpu-shares=51216shares เป็นน้ำหนัก ไม่ใช่ limit
--cpuset-cpus=0,12จำนวน CPU ใน affinity mask
env GOMAXPROCS=44ค่าที่ตั้งเองชนะ และไม่อัปเดตอัตโนมัติ

สิ่งที่ต้องเช็กใน 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 รอบ:

GOMAXPROCSGCเวลา GC (CPU)เวลาจริงทั้งโปรแกรม
16เดิม1.21 s229 ms
16Green Tea0.53 s207 ms
2เดิม0.25 s290 ms
2Green Tea0.21 s272 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

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#โค้ดกลาง

เงินห้ามใช้ float ทำไม 19.99 × 100 ถึงไม่ได้ 1999 และวิธีเก็บเงินให้เป๊ะทุกขั้น

float เก็บ 0.1 ได้ไม่เป๊ะ เงินจึงเพี้ยนทีละสตางค์และสะสม เล่าวิธีให้ตัวเลขเงินเป๊ะตั้งแต่ต้นทางจนถึงฐานข้อมูล จากระบบบันทึกผลเทรดที่ใช้จริง พร้อมโค้ด Go SQL และ Python

อ่าน 7 นาที
#โค้ดกลาง

ผ่านในเครื่อง พังบน production: สามเรื่องจริง และวิธีให้เครื่องเรา = เครื่องจริง

library ที่มีแค่ในเครื่องเรา library ที่ติดมาเองแล้วเปลี่ยนผลเงียบ ๆ ค่าอ้างอิงที่บันทึกผิดเครื่อง สามบั๊กจริง และวิธีกันด้วย image เดียว ตรึงเวอร์ชัน และเทสต์สภาพแวดล้อม

อ่าน 6 นาที
#โค้ดกลาง

Python 3.15: lazy import, frozendict และ UTF-8 ค่าเริ่มต้น แก้ไฟล์ไทยบน Windows

Python 3.15 ออกแล้ว lazy import ทำให้ CLI เปิดเร็วขึ้นหลายเท่า frozendict ใช้เป็น key ได้ และ open() อ่านเขียน UTF-8 เสมอ ไฟล์ภาษาไทยบน Windows ไม่เพี้ยนอีก

อ่าน 12 นาที