Praneat Blog

Nitirat Saengchaiarun
Lead Developer - Game and Mobile App
20 Apr 2020
garbage_collection_1.jpg

Garbage Collection คืออะไร รวบรวมไปทำไมเจ้าขยะ

Developer หลายๆคนคงเคยได้ยินคำว่า Garbage Collection บางคนลองอ่านดูแล้วก็งงๆ บางทีก็ปล่อยเลยตามเลย บทความชุดนี้จะพยายามอธิบายความหมายของมันแบบกระชับๆ(มั้ง) โดยจะยกตัวกระบวนการทำงานของเจ้า Garbage Collection อย่างจาก game engine สุดฮิตในสมัยนี้อย่าง Unity ว่าตั้งแต่เราเริ่มสร้างตัวแปรไปยันจบ scope ของ function(method) มันมีการบริหารหน่วยความจำยังไง เพราะจริงๆแล้วเรื่องนี้ถือเป็นเรื่องพื้นฐานของการเขียนโปรแกรมที่หลายๆคนยังคงมองข้ามกันไป โดยเฉพาะการทำเกมที่การบริหารทรัพยากรถือเป็นเรื่องค่อนข้างซีเรียสจริงจังฮาร์ดคอร์เกมมิ่งมาก

คำนิยาม

Garbage Collection คือชื่อของ process ที่ทำให้หน่วยความจำว่างสำหรับใช้งานอีกครั้ง

ย้อนความไปเมื่อ 10 ปีที่แล้ว ถ้าเป็นโปรแกรมเมอร์สาย native ที่เคยเขียน c++ หรือ objective-c (ตั้งแต่สมัยก่อน iOS 5) คงจะคุ้นเคยกับการจัดการบริหารหน่วยความจำ (ต่อไปจะเรียก memory) ด้วยตัวเองหรือที่เรียกว่า Manual memory management แอพไหนบริหารไม่ดีก็ดับกันไป สมัยก่อนเคยต้องละเมียดละไมกับการบริหาร memory มาก (แต่ตอนนี้ Objective-C กับ Swift มี ARC มาช่วยแล้วสบายใจ)

พอมาสมัยนี้ภาษาโปรแกรมมิ่งสมัยใหม่อย่าง Java, C# ใหม่เริ่มบริหาร memory ให้อัตโนมัติ (Automatic memory management) ซึ่งก็มีเจ้า Garbage Collection นี่แหละมาช่วยจัดการบริหารเรื่อง memory

ถ้าให้ผมนิยาม Garbage Collector เป็นภาษาไทยผมจะเรียกว่า รถเก็บขยะ ถ้าภาษาไทยครองโลก ชื่อนี้แหละเหมาะสุดๆแล้ว :) ถ้าอ่านไปถึงกระบวนการทำงานด้านล่างแล้วจะรู้ว่ามันเป็นรถเก็บขยะยังไง


ทำความรู้จักกันก่อน

โดยเจ้า automatic memory management นั้นการทำงานของมันเราไม่จำเป็นต้องไปเรียก function อะไรเพื่อบริหาร memory ที่เราจองไปทั้งนั้น code ทุกบรรทัดที่เราเขียนกันอย่างละเมียดละไม จอง memory กันหมดไปกี่ล้านไบท์ก็ตาม ตัวมันจะบริหารจัดการให้อัตโนมัติ สะดวกดี (ภายใต้ความสะดวกมันมักจะมีอะไรซ่อนอยู่ หึๆๆๆ)

โดยก่อนเริ่มอยากจะให้รู้จักกับคำศัพท์เหล่านี้ก่อน

1. Memory Pool

บน Unity เราจะมี memory-pool สำหรับเก็บข้อมูลบน memory เปรียบได้กับสุกี้หม้อรวม โดยแบ่งออกเป็นสองประเภท

  1. Stack
    • สำหรับเก็บข้อมูลเล็กๆ
    • ใช้ในระยะสั้นๆ (คือหมด scope ของตัวแปรก็เลิกใช้แล้ว เช่น สิ้นสุด function)
    • ใช้งานโดยตัวแปร value-type (อันนี้ไม่อธิบายยืดยาวเนอะน่าจะเข้าใจกัน)
    • ทำงานแบบ stack data type data collection แบบเบสิคสุดๆ ถ้าใครเรียน data structure 101 มาน่าจะเข้าใจดี การดำเนินการเป็นแบบ LIFO เข้าก่อนออกทีหลัง และทำงานเร็วมากไม่ซับซ้อน
    • เปรียบได้กับ ถังขยะเล็กๆใต้โต๊ะทำงาน ทิ้งแปปเดียวเต็มแล้ว แต่ทิ้งสะดวกและเร็วและง่าย
  2. Heap
    • สำหรับเก็บข้อมูลขนาดใหญ่
    • ใช้ในระยะยาวๆ (คือจะโดนดองเค็มจนกว่าจะมีคนมาจัดการ)
    • โครงสร้างของมูลไม่แน่นอนขึ้นอยู่กับสิ่งที่มาเก็บ และมีความยืดหยุ่น แต่ก็จะมีข้อเสียเรื่องความเร็วที่น้อยกว่า stack
    • ใช้งานโดยตัวแปร reference-type
    • เปรียบได้กับ ถังขยะใหญ่ในห้องครัว ทิ้งได้เยอะ เสียเวลาเดินไปทิ้งแปปนึง แต่ทำไงได้ขยะเราก้อนใหญ่นิ

2. Allocation & Deallocation

  1. Allocation = การที่ตัวแปรจองตำแหน่งบน memory-poolโดยตราบใดที่เจ้าตัวแปรนั้นๆยังอยู่ใน scope (คือสามารถเข้าถึงได้ด้วย code ของเรา)
    • ถ้าเป็นตัวแปรประเภท value-type จะไปอยู่ใน stack
    • ถ้าเป็นตัวแปรประเภท reference-type จะไปอยู่ใน heap
  2. Deallocation = การคืนพื้นที่ memory กลับไปให้ memory-pool เมื่อไม่ได้ใช้งานแล้ว

กระบวนการบริหารหน่วยความจำ

สมมติว่าเราเป็นพนักงานออฟฟิศที่งานล้นมือต้องปั่นให้ทันกำหนดการเลยโทรสั่งมื้อเที่ยงมากิน แน่นอนว่า fast food คือคำตอบสำหรับช่วงเวลานี้

ก่อนสั่งถัง memory-pool ทั้ง heap กับ stack ยังว่างอยู่

เราเลยโทรหาร้านอาหารจัดการสั่ง hamburger จำนวน 1 ชิ้น

  • ประกาศตัวแปร int ชื่อ hamburger เป็นตัวแปรประเภท value-type
  • เลยต้องลงไปจองที่ อยู่ก้นถัง stack ซึ่งขั้นตอนนี้เราเรียกว่า memory โดน allocated

ไม่รีรอสั่ง nugget อีก 4 ชิ้นไปนอนอยู่ในถัง stack เป็นเพื่อน

จังหวะนี้พอโทรสั่งเสร็จ พ่อครัวประกอบอาหารชุดแฮปปี้มีลพร้อมเสิร์ฟ โดยเจ้าสิ่งที่เก็บใน stack นั้นเป็นแค่ตัวอ้างอิง (reference) ของตัวแปร object ของ HappyMeal เท่านั้น (เปรียบเหมือนใบออเดอร์อาหารที่ระบุว่า HappyMeal ชุดนี้จะไปส่งให้คุณพนักงานออฟฟิศแถวย่านนานา)

ทีนี้เจ้า object HappyMeal ที่แท้จริงมันไปกองอยู่ที่ heap นะ เพราะตัวมันเองเป็นตัวแปรประเภท reference-type เปรียบได้กับอาหารโดนบรรจุหีบห่อเพื่อให้ messenger เอาไปส่งให้ถึงมือลูกค้านั้นเอง

ชุด HappyMeal ของเราส่งถึงมือเรียบร้อย หมด scope ของ function นี้แล้ว ทีนี้พ่อครัวเลยทำการเก็บกวาดของจากออเดอร์ที่พึ่งทำไป จะเห็นว่าตัวแปรทุกอย่างใน stack หายเกลี้ยงทันที แม้แต่ใบออเดอร์จากออฟฟิศย่านนานา ซึ่งขั้นตอนนี้เราเรียกว่า memory โดน deallocated

แต่เดี๋ยวก่อน? ทำไมถัง heap ยังมีขยะอยู่ละ มันไม่ได้โดนล้างทันทีหลังจากจบ scope ของ function หรอ?

คำตอบคือ ขยะใน heap จะไม่โดน deallocated ทันที เพราะมันต้องรอพระเอกของงานนี้มาเก็บ ซึ่งแน่นอนคือเข้า Garbage Collector รถขยะของเรานั้นเอง โดยเจ้า Garbage Collector จะรู้ว่า object HappyMeal เป็นขยะก็จากการที่ reference ของมันอย่างใบออเดอร์ที่เคยเก็บอยู่ใน stack โดนขยำทิ้งไปแล้วเรียบร้อยนั้นเอง


Garbage Collection

พระเอกของเรามาแล้ว งั้นมาดูขั้นตอนการทำงานของ Garbage Collector กันก่อนดีกว่า (ผมจะเรียกตัว worker process ที่ทำงานว่า Garbage Collector, ส่วน Garbage Collection คือชื่อกระบวนการ)

  1. ตรวจดู object ทุกอันใน heap
  2. พิจารณาว่า object ใน heap นั้นใช้งานอยู่หรือเปล่า โดยการเช็คว่าตัวอ้างอิง object ยังอยู่ใน stack หรือไม่
  3. object ตัวไหนที่ไม่ได้ใช้งานแล้วจะถูกหมายหัวไว้ว่าจะโดนทิ้ง
  4. ล้างบาง object ที่โดนหมายหัว

Garbage Collector ทำงานตอนไหน

  1. เมื่อการ allocation บน heap ไม่สามารถทำได้ เช่น heap เต็ม ไม่เหลือที่ว่างให้ object ใหม่เข้าไป
  2. ทำงานตามช่วงเวลาแบบอัตโนมัติ โดยความถี่จะแล้วแต่ platform เร็วช้าไม่เท่ากัน
  3. สามารถบังคับให้ทำงานแบบ manual (ส่วนนี้จะพูดถึงในบทความหน้า)

เกิดอะไรขึ้นระหว่าง Heap Allocation

ต่อเนื่องจากข้อ 1.) ของหัวข้อก่อนหน้า การ allocated memory บน heap จะมีความซับซ้อนกว่า stack เนื่องจากตัวแปรที่ตัวมันเก็บความความยืดหยุ่น มีขนาด memory ที่เล็กบ้างใหญ่บ้าง เลยอยากจะขยายความเพิ่มเติม โดยเรามาดูขั้นตอนกันว่าเมื่อเราสร้างตัวแปรประเภท reference-type จะมีการขอ allocated memory บน heap กันยังไงบ้าง

สังเกตได้ว่าจังหวะที่ช้าและใช้ทรัพยากรเยอะจะมี 2 จังหวะคือ

  1. Garbage Collector ทำการคืน memory ให้ heap เมื่อที่ไม่พอ
  2. เพิ่มขนาดความจุของ heap เมื่อ Garbage Collector ก็ช่วยหาที่ว่างให้ไม่ได้แล้ว

เพราะฉะนั้นอธิบายได้ว่า ยิ่ง heap allocation ถี่แค่ไหน ก็มีความเสี่ยงที่จะทำให้เกิดการ Garbage Collection ได้ถี่มากขึ้นเท่านั้น และยิ่ง Garbage Collection ทำงานถี่แค่ไหนก็จะยิ่งมีโอกาศทำให้เกมเราใช้ทรัพยากรได้มากขึ้นเท่านั้น

ปัญหาของ Garbage Collection

  1. ยิ่งมี object ใน heap เยอะเท่าไหร่ จังหวะที่ Garbage Collector ทำงาน จะยิ่งช้ามากขึ้นเท่านั้น เพราะมีสิ่งที่ต้องพิจารณามากขึ้น ส่งผลให้เกมกระตุกหรือประมวลผลได้ช้าลง
  2. Garbage Collector อาจทำงานผิดจังหวะ ในขณะที่ cpu กำลังประมวลผลงานหนักๆ ในจังหวะสำคัญของเกม ส่งผลให้ framerate ลดลงได้
  3. heap fragmentation

Heap Fragmentation

การ deallocation นั้นก่อให้เกิดการคืน memory กลับไปให้ heap แต่พื้นที่ว่างที่คืนให้นั้นขนาดเล็กใหญ่ไม่เท่ากันตามโครงสร้างของข้อมูล เลยทำให้เกิดสิ่งที่เรียกว่า heap fragmentation ขึ้น

allocation memory กันอย่างเมามัน

Garbage Collector เก็บกวาดเรียบร้อย

มีการสร้างตัวแปรใหม่มาและทำการขอพื้นที่บน heap แต่พื้นที่ว่างที่มีดันเล็กไป

เมื่อเกมถูกดำเนินไปเรื่อยๆและมี heap allocation/deallocation เกิดขึ้นซ้ำไปซ้ำมาจะก่อให้เกิดพื้นที่ว่างของ memory จำนวนมาก และถ้าเกิดเหตุการณ์ตอน allocation แล้วที่ว่างเหลือไม่พอจนต้องทำการขยาย heap ออกไปเรื่อยๆ จนสุดท้ายแล้วตัวอุปกรณ์ก็ไม่มีพื้นที่เหลือให้กู้ยืมแล้วละก็ มันก็จะเกิดโศกนาฏกรรมกับเกมของเรา หรือที่เห็นกันบ่อยว่าเกมเด้ง เกมระเบิด ผู้ใช้งานก็จะมาแจก 1 ดาวบน store ให้เรากันรัวๆ T_T

จบแล้วครับสำหรับ part 1 กับการทำความรู้จักกับ Garbage Collection, heap, stack, memory allocation/deallocation

บทความหน้าจะพาทัวร์เรื่องการ optimization เราจะมาดูการ coding กันว่าแบบไหนที่ทำให้เกิด heap allocation และทำยังไงให้ลดผลกระทบจาก Garbage Collection ได้มากที่สุด รับรองว่าหนักหัวกว่านี้แน่นอน!!!

อ้างอิง