การจัดการหน่วยความจำของ C#

สิ่งหนึ่งที่ผมให้ความสำคัญมากในการที่จะเรียนรู้ภาษาทีไม่เคยใช้มาก่อนคือเรื่องของการจัดการกับหน่วยความจำของมันนะครับ สิ่งที่ทำให้การจัดการหน่วยความจำมีความสำคัญเพราะโปรแกรมเมอร์ควรจะสามารถออกแบบโปรแกรมได้โดยการอิงจากหลักหลายๆ อย่างไม่ว่าจะเป็นเพือให้สามารถแก้ไขหรือนำกลับมาใช้ใหม่ได้ง่ายที่สุด หรือ เพือข้อความในบทความนี้ก็คือ เพือให้โปรแกรมมีการใช้หน่วยความจำอย่างมีประสิทธิภาพมากที่สุด ดั้งนั้นอะไรคือการจัดการหน่วยความจำของโปรแกรม ??? การจัดการหน่วยความจำของโปรแกรม คือการที่บริหารและจัดการหน่วยความจำของคอมพิวเตอร์ ซึ่งในทางปัฎิบัติก็คือ การจองหน่วยความจำเมือโปรแกรมต้องการใช้ การปลดปล่อยหน่วยความจำเมือโปรแกรมไม่ได้ใช้แล้ว หรือการนำหน่วยความจำกลับมาใช้ใหม่เมือโปรแกรมต้องการ

ในการจัดการหน่วยความจำในภาษาใดก็ตามมีขั้นตอน 5 ขั้นตอนในการใช้ทรัพยกรเครื่องซึ่งก็คือ
  1. เตรียมจองหน่วยความจำตามประเภทวัตถุที่โปรแกรมต้องการ
  2. กำหนดค่าเริ่มต้นและดำเนินการเบิ้องต้นเพือให้สามารถนำวัตถุมาใช้ในโปรแกรมได้
  3. นำวัตถุไปใช้ในโปรแกรม
  4. เมือไม่ใช้แล้วก็เตรียมทำพื้นที่หน่วยความจำนั้นให้ว่าง
  5. ทำให้หน่วยความจำพื้นที่นั้นว่าง

ในภาษาที่ไม่ได้มีการจัดการหน่วยความจำให้จากตัว runtime (un-managed language)อย่างเช่น C/C++ เราไม่สามารถบอกได้เลยว่า pointer นั้นชี้ไปยังวัตถุชนิดอะไร ขนาดเท่าไร (ก็ pointer มันมีขนาดเท่ากันหมดนิ) จึงเป็นไปไม่ได้เลยที่จะสร้างระบบปลดปล่อยหน่วยความจำที่โปรแกรมไม่ได้ใช้แล้ว (โปรแกรมเมอร์ต้องทำเองหมด) แต่ในภาษาที่อยู่ภายใต้การควบคุมของตัว runtime (managed language) อย่าง Java/ C# reference ทุกตัวมีขนาดที่แน่นอนเพราะต้องซี้ไปยังวัตถุที่ประกาศไว้เท่านั้น ทำให้การจัดการหน่วยความจำ 5 ขั้นข้างบน สามารถให้ตัว runtime จัดการให้ได้

การจัดการหน่วยความจำในภาษา C#

การเตรียมจองพื้นที่หน่วยความจำของภาษา C# จะเกิดขึ้นเมือโปรแกรมเมอร์ ใช้ keyword ว่า "new" ตัว runtime ของ C# หรือ Common Language Runtime (CLR) จะทำการตรวจสอบต่อว่าที่ heap ( คิดว่าเป็นกระบะเก็บวัตถุของคอมพิวเตอร์แล้วกันนะครับ ) มีพื้นที่เพียงพอหรือเปล่า หากว่าพอก็จะดำเนินการสร้างวัตถุชนิดนั้นๆ ที่โปรแกรมร้องขอต่อไป แต่หากว่าไม่พอ ก็.... ต้องใจเย็นๆนะครับเดียวค่อยเป็นค่อยไป เดียวเราจะได้พูดเรื่องนี้ต่อไปนะครับ

ในภาษาอื่นเช่น C ซึ่งเป็นภาษาที่หน่วยความจำไม่ได้ถูกจัดการด้วย runtime เมื่อไรก็ตามที่โปรแกรมเมอร์ เรียกใช้ keyword "new" สิ่งที่เกิดขึ้นคือ runtime ของ C จะพยายามค้นหาหน่วนความจำที่เพียงพอที่ Heap เหมือนกันเช่นเดียวกับ C# และถ้าเจอพื้นที่หน่วยความจำเพียงพอก็จะคืน pointer ที่อยู่ใน link list node ของ runtime ของ C ที่หน่วยความจำถูกจองไว้ การกระทำของ C# กับ C ฟังดูคราวๆอาจจะเหมือนกันนะครับ แต่จริงๆ แล้วต่างกันมากนะครับ เพราะ ในวิธีที่ C ใช้คือเจอที่ไหนพอก็จะจองพื้นที่ตรงนั้นไว้ ทำให้ลักษณะ Heap ของ C หน่วยความจำจะกระจัดกระจายมาก ( มันคงเป็นไปไม่ได้นะครับที่วัตถุทุกตัวจะมีขนาดที่สามารถนำมาเรียงต่อกันได้พอดีนะครับ )

ใน .Net CLR (มีคำแปลอยู่ข้างบนแล้วนะครับ) ตัว runtime จะรักษาพื้นที่หน่วยความจำที่ไม่ได้ใช้หรือยังว่างอยู่ให้มีความต่อเนื้องเสมอ ในทางตรงกันข้ามหมายถึงตัว runtime ก็จะรักษาพื้นที่หน่วยความจำที่ได้มีการใช้แล้วเรี้ยงกันอยู่ต่อเนื้องกันโดยตลอด โดยตัว runtime จะมี pointer อันหนึงซึ่งเราจะเรียกว่า (และฝรั่งอีกหลายล้านคนก็เรียกเหมือนเรา) NextObjPtr pointer หน้าที่ของมันก็คือการชี้ว่าบล๊อคสุดท้ายของหน่วยความจำที่ได้มีการใช้แล้วอยู่ที่ส่วนไหนของ Heap และทุกครั้งที่มีการจองหน่วยความจำใหม่มันจะไปจองที่จุดที่ NextObjPtr pointer ชี้อยู่เนี้ยแหละครับ ทำให้หน่วยความจำที่มีการใช้แล้วสามารถเรียงต่อเนืองกันได้ แต่ปัญหามันก็อาจจะเกิดได้ ถ้า NextObjPtr pointer ชี้ไปยังตำแห่งที่เกินขนาดความจุของ Heap ถ้าหากเป็นเช่นนี้ ตัว CLR ก็จะต้องเรียกให้มีการทำกระบวนการ Garbage Collection หรือเก็บกวาดวัตถุที่ไม่ได้ใช้แล้วใน heap ต่อไป

Garbage Collecting

สำหรับโปรแกรมเมอร์บ้างคนที่เขียน C++ มาก่อนนะครับ อาจจะรู้สึกแปลกๆที่ไม่ต้องทำการปลดปล่อยหน่วนความจำที่ไม่ได้ใช้แล้วเองนะครับ แต่ถ้าใครได้ลองให้ตัว runtime เป็นตัวจัดการหน่วยความจำเมือไรนะครับ รับรองจะทำงานได้เร็วขึ้นมากโขอยู่นะครับ แล้วจะติดใจไม่อยากไปลำบากกับภาษาอื่นแล้วล่ะครับ เดียวเราจะมาลองดูว่าการ Garabage Collecting เนี้ยมีหลักการทำงานคราวๆยังไงนะครับ

Garbage Collector จะเริมงานโดยการดูว่ามีวัตถุใดบ้างใน Heap ที่ไม่มีใครใช้แล้ว (ไม่มี reference อะไรชี้แล้ว) วัตถุเหล่านี้เป็นวัตถุที่สามารถปลดปล่อยจากหน่วยความจำได้ (ก็มันไม่มีใครใช้มันแล้วนี้ครับ อยู่ต่อไปทำไม) วิธีที่ runtime จะรู้ว่าวัตถุใดมี reference ชี้อยู่หรือไม่ก็ จากกระบวนการต่อไปนี้ครับ

ทุกโปรแกรมจะมีที่เราเรียกว่า หมู่ของ roots ซึ่งจะทำหน้าที่ในการบอกตำแหน่งของสิ่งที่อยู่ใน Heap ซึ่ง roots จะประกอบไปด้วย

  • วัตถุ
  • global และ static object
  • Call Stack ของ Thread
  • CPU register ที่มีค่าpointerไปยังวัตถุ
  • ค่า null

ซึ่งทั่งนี้ทั่งนั้น หมู่ roots ถูกจัดการโดย Just In Time compiler และ CLR

เมือ Garbage Collector เริ่มทำงาน มันจะสันนิฐานว่าวัตถุทุกอย่างใน Heap สามารถถูกปลดปล่อยได้หมด หรือ หมู่ roots ของโปรแกรมไม่ได้ชี้ไปยังวัตถุใดเลย แล้ว Garbage Collector จะเริ่มทำงานโดยการตรวจสอบที่ล่ะ root ว่ามีการชี้ไปยัง object ใดหรือไม่ เมือตรวจสอบ roots ขั้นต้น เสร็จแล้วจึงตรวจสอบ roots ขั้นตอ่ไปที่ลึกว่าเดิม แล้วสร้าง graph เหมือนกับต้นไม้เพือบอกความสัมพันธ์ของ roots และเพือหลีกเลี่ยงทีจะตรวจสอบอันเดิมที่ได้ตรวจสอบไปแล้ว ซึ่งก็จพทำอย่างงี้จนตวรจสอบ roots ทุกอันหากมีวัตถุอันไหนที่ไม่มี roots ชี้อยู่แปลว่า วัตถุอันนั้นสมควรตาย (หรือถูกปลดปล่อยจากหน่วยความจำ) จากนั้น Garbage Collector จึงทำการเคลือนย้ายหน่วยความจำที่กำลังถูกใช้อยู่ มาเรียงติดกัน โดยใช้คำสั่ง memcpy อันโด่งดัง

หลังจากสามารถเอาหน่วยความจำที่ยังใช้งานอยู่มาเรียงต่อกันได้แล้ว จึ้งย้าย pointer NextObjPtr ไปชี้ที่หน่วยความจำสุดท้ายที่ถูกใช้งาน ซึ่งแน่นอนครับกระบวนการดังกล่าวย่อมมีผลต่อประสิทธิภาพการทำงานของโปรแกรมนะครับ แต่ก็คุ้มกับสิ่งที่เสียครับ เพราะถ้าเราบริหารมันเป็น มันไม่มีปัญหาหรอกครับ เพราะส่วนใหญ่มันจะทำการ Garbage Collecting ก็ต่อเมือพื้นที่หน่วยความจำเต็มแล้วเท่านั้นครับ และตัว Garbage Collector ยังมีการ implement การ optimizing หลายอย่างนะครับที่จะทำให้การทำงานของมันแทบจะไม่กระทบกับประสิทธิภาพของโปรแกรมเราเลยครับ เดียวเรามาลองดู algorithm ของ Garbage Collector ของ CLR ดูสักหน่อยดีกว่า นะครับ

algorithm ของ Garbage Collection ใน CLR จะใช้ 2 อันนะครับคือ

  1. Mark and Sweep -ไม่มีอะไรมากครับเจออันไหนที่ไม่มี reference ใดอ้างถึงก็ mark ตัวไว้ครับ เพื่อให้ขั้นต่อไปนำไปทำลายและปลดปล่อยหน่วยความจำ(sweep)ได้ครับ จะเก็บ list อันหนึงไว้ตลอดครับ ซึ่งเป็น listที่บอกว่า slot ตรงไหนของหน่วยความจำทียังว่างอยู่ และหากมีการสร้างวัตถุเกิดขึ้นก็จะส่ง slot ที่เล็กที่สุดที่สามารถ สร้างวัตถุนั้นได้ ข้อดี - หลักการนี้ใช้การทำงานเพียงเล็กน้อยเอง เพราะ ต้องสร้างและจัดเก็บแค่ list เดียวเองนี้ ข้อเสีย - ทุกครั้งที่มีการสร้างวัตถุ ต้องค้นหา slot ที่ว่างทั้งหมด, เกิดช่วงของหน่วยความจำที่ไม่ต่อกัน
  2. Mark and Compact - เหมือนกับอันแรกแหละครับเพียงแต่ว่า ทุกครั้งที่ทำการปลดปล่อยหน่วยความจำ จะนำหน่วยความจำที่มีการใช้งานมา(compact)เรียงต่อกันด้วยครับ ข้อดี - ไม่เกิดช่วงของหน่วยความจำ, การจองหน่วยความจำใช้แค่ pointer ตัวเดียวมันเลยเร็วมาก ข้อเสีย - การย้ายวัตถุไปมาใน Heap อาจจะช้า(ถ้าวัตถุมันใหญ่)

สำหรับการจัดการ Heap ของ Garbage Collector จะทำโดยการ (สรุป)

  1. แบ่งขนาดของวัตถุใหญ่และเล็ก : เราจะสามารถแบ่งหมวดหมู่ของ Heap ได้เป็น หมวดหมู่สองอันคราวๆ คือ 1 Heap สำหรับวัตถุที่มีขนาดใหญ่ และ 2 Heap สำหรับวัตถุที่มีขนาดเล็ก ทำไมนะเหรอครับ ผมลองให้ไปดู หัวข้อข้างบนเรื่อง algorithm ของ Garbage Collector นะครับน่าจะพอเดาได้ การที่จะ compact วัตถุขนาดใหญ่มันใช้เวลาเกินไปครับ ดั้งนั้นเราจึงจำเป็นต้องเลือกที่จะ compact เฉพาะวัตถุขนาดเล็ก ซึ่งขนาดของวัตถุว่าอันเล็ก อันใหญ่ก็มีตัวเลขให้คราวๆ ดั้งนี้นะครับ สำหรับวัตถุที่มีขนาดใหญ่คือวัตถุที่ใช้เกิน 85000 bytes(.net ver 1.1)
  2. การค้นหาวัตถุเพือปลดปล่อยหน่วยความจำ (ได้เขียนไปแล้วนะครับในส่วนเรื่องของการ Garbage Collecting)
  3. Sweeping a heap คือการรวามหน่วยความจำที่ว่างอยู่มาเรียงต่อกันนั้นเองครับ แล้วก็เก็บ list ของ free slot ที่ว่างอยู่
  4. Compacting a heap คือการรวมพื้นที่หน่วยความจำของ Heap วัตถุขนาดเล็กให้เรียงต่อกัน

ส่วนเรื่องสุดท้ายนะครับ เป็นเรื่องที่ไม่รู้จะไปแป่ะส่วนไหนดีนะครับแต่มีความสำคัญต่อการทำงานของ CLR มากครับ นั้นก็คือส่วนของ

Generational Garbage Collector

เนื่องจากการศึกษาพฤติกรรมการทำงานของ software (หรือการออกแบบของโปรแกรมเมอร์ส่วนใหญ่) เค้าได้ข้อสรุปรวมกันว่า

  • วัตถุที่เพิ่งสร้างมีโอกาสที่จะมีระยะเวลาชีวิตต่อไปน้อยกว่าวัตถุที่ถูกสร้างมานานแล้ว
  • วัตถุที่สร้างมานานแล้วมีโอกาสที่จะมีระยะเวลาซีวิตได้นาน
  • วัตถุที่เพิงสร้างมา มักจะมีความสัมพันธ์กันและต้องการเรียกใช้ข้อมูลเหมือนกัน

จากข้อสมมุตืฐานดังกล่าวเค้าจึงออกแบบ Garbage Collection ของ CLR ให้เป็น 3 เจอเนอร์เรชัน คือ

  • Gen 0 : วัตถุที่เพิงสร้างมาใหม่ และไม่เคยถูกการทำ Garbage Collecting
  • Gen 1 : วัตถุที่เคยผ่านการทำ Garbage Collecting มาแล้ว 1 ครั้ง
  • Gen 2 : วัตถุที่ผ่านการทำ Garbage Collecting มาแล้วมากกว่าสองครั้ง

ทั้งนี้ Garbage Collector จะพยายาม เก็บ Gen 0 ไว้ที่ L2 Cache ของ CPU เพราะจะเป็น ส่วนที่จะโดนการทำ Garbage Collecting มากที่สุด เพราะเมือไรก็ตามที่ Gen 0 เต็ม ตัว Garbage Collector ก็จะทำการ Garbage Collecting วัตถุ อันไหนที่รอดจุดนี้ไป ก็จะไปอยู่ส่วนของ Gen 1 และ Gen 2 ต่อไป ฉะนั้นถ้าเรียงความสัมพันธ์กัน

  • ความถี่ในการ Garbage Collecting Gen 0 > Gen 1 > Gen 2
  • ถ้ามีความต้องการใช้หน่วยความจำที่สูง คนที่จะโดนลงโทษบ่อย(โดน Garbage Collecting)คือ Gen 0 และรองลงมาคือ Gen 1
  • ถ้าหน่วยความจำขาดแคลน Gen 0 จะโดน Garbage Collecting ถี่ขึ้น

หากต้องการอ่านในเวอร์ชันภาษาอังฤกษสามารถอ่านได้ที่นี้ นะครับ ENGLISH : http://mayansoftware.com/content/view/13/1/

อ่า.... ตอนนี้เหนือยแต๊ๆ แล้วเอาไว้พบกันคราวหน้าในครั้งต่อไป มีข้อเสนอแนะหรือสิ่งที่แนะนำก็สามารถ post ได้เลยนะครับหรือส่งมาที่ ผู้เขียนก็ได้ครับ ตอนนี้ไว้แค่นี้ก่อนคัรบ

4 ความคิดเห็น:

Surapong L. กล่าวว่า...

เยี่ยมไปเลยครับ
keep it coming guys!!!

ไม่ระบุชื่อ กล่าวว่า...

ดีมากเลยครับ เป็นประโยชน์มาก ๆ

ไม่ระบุชื่อ กล่าวว่า...

ขอบคุณครับ

ไม่ระบุชื่อ กล่าวว่า...

ขอบคุณมากคร้าบบบ ^^ ข้อมูลนี้ มีคุณค่ากับผมมากๆ เลย Thanks you very much