ข้าม​ไป​ยัง​เนื้อหา

สอง​บรรทัด​ที่​เหมือน​กัน​ทุก​ตัว​อักษร บรรทัด​หนึ่ง​จอง อีก​บรรทัด​ไม่​จอง และ​คอม​ไพ​เลอ​ร์​บอก​ว่า​ทั้ง​คู่​ไม่​จอง

คำถาม​ที่​โปรแกรมเมอร์ Go ถาม​บ่อย​ที่สุด​ข้อ​หนึ่ง​คือ “บรรทัด​นี้​จอง​บน heap ไหม”

เครื่อง​มือ​ที่​ใช้​ตอบ​คือ go build -gcflags=-m · หน้า​นี้​เอา​คำ​ตอบ​ของ​มัน​ไป​เทียบ​กับ​สิ่ง​ที่ ตัว​จัดสรร​ทำ​จริง

เขียน​แบบ​ไหนพอดี​เพดานเกิน​ไป​หนึ่ง​ไบต์
var x [N]byte131,072 ไบต์ → 0 alloc131,073 ไบต์ → 1 alloc
new([N]byte)65,536 ไบต์ → 0 alloc65,537 ไบต์ → 1 alloc
make([]byte, n)32 ไบต์ → 0 alloc33 ไบต์ → 1 alloc

สาม​แถว​นี้​คือ​เพดาน​สาม​ตัว​ที่​แยก​กัน​เด็ด​ขาด ไม่ใช่​ตัว​เดียว​ที่​มอง​จาก​คนละ​มุม

var กับ new ต่าง​กัน 65,536 ไบต์ ทั้ง​ที่ ทั้ง​คู่​คือ “ขอ​ที่​เก็บ​ของ​ขนาด​คงที่” เหมือน​กัน · ส่วน make ที่​ความ​ยาว​เป็น​ตัวแปร ใช้​เพดาน​ที่​เล็ก​กว่า​อีก​สอง​บาน​ราว​สอง​พัน​เท่า

ไม่มี​บาน​ไหน​กลม ๆ แบบ​ที่​คน​จำ​ได้ และ​ไม่มี​บาน​ไหน​อยู่​ใน​ข้อความ error ตอน​คุณ​เขียน​เกิน

ทำไม​ทุก​ตัวอย่าง​ใน​หน้า​นี้​ไม่​ส่ง pointer ออก​ไป​ไหน​เลย

ถ้า​คุณ​เขียน 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 escape0
func mkOver(n int) { s := make([]byte, n); s[0] = 1; sink = s[0] }make([]byte, n) does not escape1

บอดี้​ของ​สอง​ฟังก์ชัน​นี้​เหมือน​กัน​ทุก​ตัว​อักษร ต่าง​กัน​แค่​ชื่อ · สิ่ง​ที่​ต่าง​จริง​อยู่​ที่ ผู้​เรียก ตัว​หนึ่ง​ส่ง 32 อีก​ตัว​ส่ง 33

คอม​ไพ​เลอ​ร์​พูด​ประโยค​เดียวกัน​กับ​ทั้ง​คู่ · และ​ประโยค​นั้น​ผิด​สำหรับ​แถว​ล่าง

คำ​ว่า 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