สองบรรทัดที่เหมือนกันทุกตัวอักษร บรรทัดหนึ่งจอง อีกบรรทัดไม่จอง และคอมไพเลอร์บอกว่าทั้งคู่ไม่จอง
คำถามที่โปรแกรมเมอร์ Go ถามบ่อยที่สุดข้อหนึ่งคือ “บรรทัดนี้จองบน heap ไหม”
เครื่องมือที่ใช้ตอบคือ go build -gcflags=-m · หน้านี้เอาคำตอบของมันไปเทียบกับสิ่งที่
ตัวจัดสรรทำจริง
เพดานไม่ได้มีตัวเดียว
หัวข้อที่มีชื่อว่า “เพดานไม่ได้มีตัวเดียว”| เขียนแบบไหน | พอดีเพดาน | เกินไปหนึ่งไบต์ |
|---|---|---|
var x [N]byte | 131,072 ไบต์ → 0 alloc | 131,073 ไบต์ → 1 alloc |
new([N]byte) | 65,536 ไบต์ → 0 alloc | 65,537 ไบต์ → 1 alloc |
make([]byte, n) | 32 ไบต์ → 0 alloc | 33 ไบต์ → 1 alloc |
สามแถวนี้คือเพดานสามตัวที่แยกกันเด็ดขาด ไม่ใช่ตัวเดียวที่มองจากคนละมุม
var กับ new ต่างกัน 65,536 ไบต์ ทั้งที่
ทั้งคู่คือ “ขอที่เก็บของขนาดคงที่” เหมือนกัน · ส่วน make ที่ความยาวเป็นตัวแปร
ใช้เพดานที่เล็กกว่าอีกสองบานราวสองพันเท่า
ไม่มีบานไหนกลม ๆ แบบที่คนจำได้ และไม่มีบานไหนอยู่ในข้อความ error ตอนคุณเขียนเกิน
ถ้าคุณเขียน keep(&x) โดยที่ keep รับ any ทุกแถวจะจองหมด เพราะการส่ง ไม่ใช่เพราะขนาด
· ตารางทั้งใบจะกลายเป็นเลข 1 เรียงกัน แล้วไม่ได้ตอบคำถามอะไรเลย
ผู้เขียนหน้านี้ตกกับดักนั้นในรอบวัดแรกจริง ๆ · ตัววัดที่ใช้อยู่ตอนนี้จึงไม่ปล่อย pointer ตัวไหนออกนอกฟังก์ชัน ยกเว้นในช่องควบคุมที่ตั้งใจให้ปล่อย
บานที่สาม กับสิ่งที่คอมไพเลอร์พูด
หัวข้อที่มีชื่อว่า “บานที่สาม กับสิ่งที่คอมไพเลอร์พูด”ทีนี้ส่วนที่หน้านี้อยากให้ดูจริง ๆ · นี่คือสองฟังก์ชันในตัววัด
| ซอร์ส | คอมไพเลอร์พูดว่า | ตัวจัดสรรถูกเรียก |
|---|---|---|
func mkAt(n int) { s := make([]byte, n); s[0] = 1; sink = s[0] } | make([]byte, n) does not escape | 0 |
func mkOver(n int) { s := make([]byte, n); s[0] = 1; sink = s[0] } | make([]byte, n) does not escape | 1 |
บอดี้ของสองฟังก์ชันนี้เหมือนกันทุกตัวอักษร ต่างกันแค่ชื่อ · สิ่งที่ต่างจริงอยู่ที่ ผู้เรียก ตัวหนึ่งส่ง 32 อีกตัวส่ง 33
คอมไพเลอร์พูดประโยคเดียวกันกับทั้งคู่ · และประโยคนั้นผิดสำหรับแถวล่าง
does not escape ไม่ได้แปลว่า “ไม่จอง”
หัวข้อที่มีชื่อว่า “does not escape ไม่ได้แปลว่า “ไม่จอง””คำว่า escape ในคอมไพเลอร์ของ Go มีความหมายแคบกว่าที่คนอ่านคิด มันแปลว่า “อ้างอิงถึงของชิ้นนี้ ยังมีชีวิตอยู่หลังฟังก์ชันจบหรือเปล่า”
แถวล่างตอบว่าไม่ · slice ตัวนั้นตายพร้อมฟังก์ชันจริง คอมไพเลอร์จึงพูดถูกตามนิยามของมัน
แต่คำถามที่คุณถามไม่ใช่คำถามนั้น คุณถามว่า “บรรทัดนี้จองไหม” และคำตอบคือจอง เพราะความยาวเป็นตัวแปรที่มีค่าเกิน 32 ไบต์ คอมไพเลอร์จึงกันที่บน stack ไว้ล่วงหน้าไม่ได้ ต้องเรียกตัวจัดสรรตอนรัน
สองคำถามนี้ตอบไม่เหมือนกัน และเครื่องมือตอบให้แค่คำถามเดียว
อีกสองบานถูกรายงานด้วยคำคนละคำ
หัวข้อที่มีชื่อว่า “อีกสองบานถูกรายงานด้วยคำคนละคำ”ข้อที่ทำให้บานที่สามน่าสับสนเป็นพิเศษ คือมันไม่ได้เงียบ · มันพูด และพูดคนละคำกับพี่น้องของมัน
| ประตู | คำที่คอมไพเลอร์ใช้ | อ่านแล้วเข้าใจว่าจอง |
|---|---|---|
var เกินเพดาน | moved to heap | ใช่ |
new เกินเพดาน | escapes to heap | ใช่ |
make เกินเพดาน | does not escape | ไม่ |
สองบานแรกใช้คนละคำแต่สื่อความเดียวกัน · บานที่สามใช้คำที่คนอ่านแล้วสรุปตรงกันข้าม
-gcflags=-m เป็นผลของ escape analysis ซึ่งเป็นการวิเคราะห์อายุของการอ้างอิง
ไม่ใช่รายงานการจัดสรร · การจองที่เกิดจาก make ที่ความยาวเป็นตัวแปร ไม่ได้ผ่านการวิเคราะห์นั้น
มันถูกตัดสินตอนแปลงเป็น code เครื่อง หลังจาก escape analysis จบไปแล้ว
ผลก็คือ ถ้าคุณใช้ -m เป็นคำตอบของคำถาม “code นี้จองไหม” คุณจะได้คำตอบที่ถูก
สำหรับสองในสามกรณี · และไม่มีอะไรบอกคุณว่าคุณอยู่ในกรณีที่สาม
เอาไปใช้ยังไง
หัวข้อที่มีชื่อว่า “เอาไปใช้ยังไง”ถ้าต้องรู้จริงว่าบรรทัดไหนจอง ให้นับด้วย testing.B แล้วอ่าน allocs/op
· นั่นคือจำนวนครั้งที่ตัวจัดสรรถูกเรียกจริง ไม่ใช่คำอธิบายว่าคอมไพเลอร์คิดยังไง
ใช้ -m ตอนที่อยากรู้ ว่าทำไม ของถึงหนีไป heap หลังจากรู้แล้วว่ามันหนี
· มันเก่งเรื่องอธิบายเส้นทางการอ้างอิง และนั่นคือสิ่งที่มันถูกสร้างมาเพื่อทำ
ส่วนความยาวของ make ถ้าคุณรู้ค่าตอนแปล ให้เขียนเป็นค่าคงที่ · คอมไพเลอร์จะกันที่บน
stack ให้ได้ถึงเพดานของ new ซึ่งใหญ่กว่าเพดานของความยาวแบบตัวแปรมาก
สิ่งที่หน้านี้ไม่ได้วัด
หัวข้อที่มีชื่อว่า “สิ่งที่หน้านี้ไม่ได้วัด”-
ไม่ได้วัดเวลา หน่วยของหน้านี้คือจำนวนครั้งที่ตัวจัดสรรถูกเรียก ซึ่งข้ามเครื่องได้ ส่วนวินาทีข้ามไม่ได้
-
ไม่ได้ตรึง
B/opตัววัดรัน benchmark เดียวกัน 6 รอบ คร่อมหลาย benchtime แล้วเทียบสองช่อง ·allocs/opได้ 1 เท่ากันทุกรอบ ส่วนB/opได้ค่าต่างกัน 4 ค่า ต่ำสุด 139,264 สูงสุด 139,290 บนเครื่องที่เขียนบทความแต่เหตุผลที่ดีกว่านั้นมาจากเครื่องที่สอง รันชุดเดียวกันบน ubuntu amd64 กับ go1.25.14 บน CI ของรีโปนี้เมื่อ 2026-08-28 แล้ว
B/opได้ ค่าเดียวเท่ากันทั้งหกรอบ ซึ่งตรงกับค่าต่ำสุดของฝั่ง arm64 พอดี · ช่องเดียวกัน สองสถาปัตยกรรม คนละพฤติกรรม · ส่วนallocs/opได้เลขเดียวกันทั้งสองเครื่องประวัติของด่านนี้ยาวกว่าย่อหน้าที่มันค้ำ · ร่างแรกเทียบแค่สองรอบ แล้วแดงจริงหนึ่งครั้ง หลังบทความขึ้นเว็บ เพราะสองรอบบังเอิญตกค่าเดียวกัน — ด่านที่ตัดสินด้วยตัวอย่าง น้อยเกินไป คือด่านที่มี race อยู่ข้างใน · ร่างที่สองบังคับว่าหกรอบต้องเห็นค่าต่างกัน แล้วมันแดงบน CI ทันที เพราะบน amd64 มันไม่ต่าง — ด่านที่เอาคุณสมบัติของเครื่องหนึ่ง ไปบังคับกับทุกเครื่อง ก็แดงได้โดยที่ข้ออ้างยังจริง · ตอนนี้ด่านบังคับเฉพาะ
allocs/opแล้วรายงานการกระจายของB/opออกมาเฉย ๆ -
ตัวเลขผูกกับ version ของ Go เพดานของ
varเพิ่งลงมาจาก 10 MiB เป็น 131,072 ไบต์ที่ Go 1.24 และเพดานของmakeเพิ่งมีที่ Go 1.25 · วัดบนgo version go1.27.0 darwin/arm64 -
ไม่ได้วัดสถาปัตยกรรมอื่น ตัวเลขทั้งหมดมาจาก arm64 เครื่องเดียว
ตัววัดอยู่ที่ scripts/go-escape-doors/ รันด้วย npm run verify:go-escape
· มันอยู่ในเกตของ CI จึงมีคนรันทุกครั้งที่มีคน push
รหัส 1 แปลว่า ข้ออ้างของบทความผิดแล้ว เช่นคอมไพเลอร์เปลี่ยนคำที่ใช้รายงาน หรือเพดานบานใดบานหนึ่งขยับ
รหัส 2 แปลว่า วัดไม่ได้ เช่นเครื่องไม่มี Go หรือมี Go ที่เก่ากว่า 1.25 ซึ่งยังไม่มีบานที่สาม · ถ้าสองอย่างนี้ปนกัน runner ที่ไม่มี Go จะอ่านเหมือนบทความเขียนผิด
ตัววัดยังมีช่องควบคุมสองขั้ว: ตัวที่ปล่อย pointer ออกไปจริงต้องได้ 1 alloc และตัวที่เล็กและไม่หนีไปไหนต้องได้ 0 · ถ้าขั้วใดขั้วหนึ่งพลาด ด่านจะบอกว่า การวัดพัง ไม่ใช่ว่าข้ออ้างผิด · วัดด้วย benchtime 500x