16 เมษายน 2568
หลังจากที่โค้ดได้รับการแปลและคอมไพล์สำเร็จแล้ว เรามักจะพบปัญหาตอนรันไทม์ โดยเฉพาะอย่างยิ่งที่เกี่ยวข้องกับการจัดการหน่วยความจำ ซึ่งไม่ใช่เรื่องปกติสำหรับสภาพแวดล้อม C# ที่มีการ์เบจคอลเลกเตอร์ (garbage collector) ในบทความนี้ เราจะเจาะลึกปัญหาการจัดการหน่วยความจำเฉพาะทาง เช่น การอ้างอิงแบบวงกลม (circular references) และการลบอ็อบเจกต์ก่อนเวลาอันควร และแสดงให้เห็นว่าแนวทางของเราช่วยในการตรวจจับและแก้ไขปัญหาเหล่านั้นได้อย่างไร
ใน C# การ์เบจคอลเลกเตอร์สามารถจัดการกับการอ้างอิงแบบวงกลมได้อย่างถูกต้อง โดยการตรวจจับและลบกลุ่มของอ็อบเจกต์ที่ไม่สามารถเข้าถึงได้ อย่างไรก็ตาม ใน C++ สมาร์ทพอยเตอร์ (smart pointers) ใช้การนับจำนวนการอ้างอิง (reference counting) หากอ็อบเจกต์สองตัวอ้างอิงถึงกันด้วยการอ้างอิงแบบเข้ม (SharedPtr
) จำนวนการอ้างอิงของพวกมันจะไม่มีทางลดลงถึงศูนย์ แม้ว่าจะไม่มีการอ้างอิงภายนอกจากส่วนอื่นของโปรแกรมมายังพวกมันอีกต่อไป สิ่งนี้นำไปสู่ภาวะหน่วยความจำรั่วไหล (memory leak) เนื่องจากทรัพยากรที่อ็อบเจกต์เหล่านี้ใช้งานอยู่จะไม่ถูกปลดปล่อยเลย
พิจารณาตัวอย่างทั่วไป:
class Document {
private Element root;
public Document()
{
root = new Element(this); // Document อ้างอิงถึง Element
}
}
class Element {
private Document owner;
public Element(Document doc)
{
owner = doc; // Element อ้างอิงกลับไปยัง Document
}
}
โค้ดนี้จะถูกแปลงเป็นดังนี้:
class Document : public Object {
SharedPtr<Element> root;
public:
Document()
{
root = MakeObject<Element>(this);
}
}
class Element {
SharedPtr<Document> owner; // การอ้างอิงแบบเข้ม
public:
Element(SharedPtr<Document> doc)
{
owner = doc;
}
}
ในที่นี้ อ็อบเจกต์ Document
มี SharedPtr
ที่ชี้ไปยัง Element
และอ็อบเจกต์ Element
มี SharedPtr
ที่ชี้ไปยัง Document
ทำให้เกิดวงจรของการอ้างอิงแบบเข้มขึ้น แม้ว่าตัวแปรที่เดิมทีถือพอยเตอร์ไปยัง Document
จะหลุดออกจากขอบเขต (scope) ไปแล้ว จำนวนการอ้างอิงของทั้งสองอ็อบเจกต์จะยังคงเป็น 1 เนื่องจากการอ้างอิงซึ่งกันและกัน อ็อบเจกต์เหล่านี้จะไม่มีวันถูกลบ
ปัญหานี้แก้ไขได้โดยการตั้งค่าแอททริบิวต์ CppWeakPtr
บนฟิลด์ใดฟิลด์หนึ่งที่เกี่ยวข้องในวงจร ตัวอย่างเช่น บนฟิลด์ Element.owner
แอททริบิวต์นี้จะสั่งให้ตัวแปลภาษาใช้การอ้างอิงแบบอ่อน WeakPtr
สำหรับฟิลด์นี้ ซึ่งจะไม่เพิ่มจำนวนการอ้างอิงแบบเข้ม
class Document {
private Element root;
public Document()
{
root = new Element(this);
}
}
class Element {
[CppWeakPtr] private Document owner;
public Element(Document doc)
{
owner = doc;
}
}
โค้ด C++ ที่ได้:
class Document : public Object {
SharedPtr<Element> root; // การอ้างอิงแบบเข้ม
public:
Document()
{
root = MakeObject<Element>(this);
}
}
class Element {
WeakPtr<Document> owner; // ตอนนี้เป็นการอ้างอิงแบบอ่อน
public:
Element(SharedPtr<Document> doc)
{
owner = doc;
}
}
ตอนนี้ Element
ถือการอ้างอิงแบบอ่อนไปยัง Document
ซึ่งเป็นการทำลายวงจร เมื่อ SharedPtr<Document>
ภายนอกตัวสุดท้ายหายไป อ็อบเจกต์ Document
จะถูกลบ การดำเนินการนี้จะกระตุ้นการลบฟิลด์ root
(SharedPtr<Element>
) ซึ่งจะลดจำนวนการอ้างอิงของ Element
หากไม่มีการอ้างอิงแบบเข้มอื่น ๆ ไปยัง Element
มันก็จะถูกลบไปด้วย
ปัญหานี้เกิดขึ้นหากอ็อบเจกต์ถูกส่งผ่าน SharedPtr
ไปยังอ็อบเจกต์หรือเมธอดอื่น ระหว่าง การสร้าง ก่อนที่การอ้างอิงแบบเข้ม “ถาวร” ไปยังมันจะถูกสร้างขึ้น ในกรณีนี้ SharedPtr
ชั่วคราวที่สร้างขึ้นระหว่างการเรียกคอนสตรัคเตอร์ (constructor) อาจเป็นการอ้างอิงเพียงอย่างเดียว หากมันถูกทำลายหลังจากการเรียกเสร็จสิ้น จำนวนการอ้างอิงจะลดลงถึงศูนย์ นำไปสู่การเรียกดีสตรัคเตอร์ (destructor) ทันทีและการลบอ็อบเจกต์ที่ยังสร้างไม่เสร็จสมบูรณ์
พิจารณาตัวอย่าง:
class Document {
private Element root;
public Document()
{
root = new Element(this);
}
public void Prepare(Element elm)
{
...
}
}
class Element {
public Element(Document doc)
{
doc.Prepare(this);
}
}
ตัวแปลภาษาจะสร้างผลลัพธ์ดังนี้:
class Document : public Object {
SharedPtr<Element> root;
public:
Document()
{
ThisProtector guard(this); // ป้องกันการลบก่อนเวลาอันควร
root = MakeObject<Element>(this);
}
void Prepare(SharedPtr<Element> elm)
{
...
}
}
class Element {
public:
Element(SharedPtr<Document> doc)
{
ThisProtector guard(this); // ป้องกันการลบก่อนเวลาอันควร
doc->Prepare(this);
}
}
เมื่อเข้าสู่เมธอด Document::Prepare
อ็อบเจกต์ SharedPtr
ชั่วคราวจะถูกสร้างขึ้น ซึ่งอาจลบอ็อบเจกต์ Element
ที่ยังสร้างไม่เสร็จสมบูรณ์ได้ เนื่องจากไม่มีการอ้างอิงแบบเข้มเหลืออยู่ ดังที่แสดงในบทความก่อนหน้านี้ ปัญหานี้แก้ไขได้โดยการเพิ่มตัวแปรโลคัล ThisProtector guard
เข้าไปในโค้ดคอนสตรัคเตอร์ของ Element
ตัวแปลภาษาจะทำสิ่งนี้โดยอัตโนมัติ คอนสตรัคเตอร์ของอ็อบเจกต์ guard
จะเพิ่มจำนวนการอ้างอิงแบบเข้มสำหรับ this
ขึ้นหนึ่ง และดีสตรัคเตอร์ของมันจะลดจำนวนลงอีกครั้ง โดยไม่ทำให้เกิดการลบอ็อบเจกต์
พิจารณาสถานการณ์ที่คอนสตรัคเตอร์ของอ็อบเจกต์โยน Exception หลังจากที่ฟิลด์บางส่วนของมันถูกสร้างและกำหนดค่าเริ่มต้นแล้ว ซึ่งฟิลด์เหล่านั้นอาจมีการอ้างอิงแบบเข้มกลับไปยังอ็อบเจกต์ที่กำลังถูกสร้างอยู่
class Document {
private Element root;
public Document()
{
root = new Element(this);
throw new Exception("Failed to construct Document object");
}
}
class Element {
private Document owner;
public Element(Document doc)
{
owner = doc;
}
}
หลังจากการแปลง เราจะได้:
class Document : public Object {
SharedPtr<Element> root;
public:
Document()
{
ThisProtector guard(this);
root = MakeObject<Element>(this);
throw Exception(u"Failed to construct Document object");
}
}
class Element {
SharedPtr<Document> owner;
public:
Element(SharedPtr<Document> doc)
{
ThisProtector guard(this);
owner = doc;
}
}
หลังจากที่ Exception ถูกโยนในคอนสตรัคเตอร์ของ document และการทำงานออกจากโค้ดคอนสตรัคเตอร์ การคลายสแต็ก (stack unwinding) จะเริ่มขึ้น รวมถึงการลบฟิลด์ของอ็อบเจกต์ Document
ที่สร้างไม่เสร็จสมบูรณ์ ซึ่งจะนำไปสู่การลบฟิลด์ Element::owner
ที่มีการอ้างอิงแบบเข้มไปยังอ็อบเจกต์ที่กำลังถูกลบอยู่ ส่งผลให้เกิดการลบอ็อบเจกต์ที่กำลังอยู่ในกระบวนการทำลายโครงสร้าง (deconstruction) อยู่แล้ว นำไปสู่ข้อผิดพลาดรันไทม์ต่างๆ
การตั้งค่าแอททริบิวต์ CppWeakPtr
บนฟิลด์ Element.owner
จะช่วยแก้ปัญหานี้ได้ อย่างไรก็ตาม จนกว่าจะมีการวางแอททริบิวต์ การดีบักแอปพลิเคชันดังกล่าวทำได้ยากเนื่องจากการยุติการทำงานที่ไม่คาดคิด เพื่อให้การแก้ไขปัญหาง่ายขึ้น มีโหมดบิลด์สำหรับดีบัก (debug build mode) แบบพิเศษที่ตัวนับการอ้างอิงภายในของอ็อบเจกต์จะถูกย้ายไปยังฮีป (heap) และเสริมด้วยแฟล็ก (flag) แฟล็กนี้จะถูกตั้งค่าก็ต่อเมื่ออ็อบเจกต์ถูกสร้างขึ้นอย่างสมบูรณ์แล้ว – ที่ระดับของฟังก์ชัน MakeObject
หลังจากออกจากคอนสตรัคเตอร์ หากพอยเตอร์ถูกทำลายก่อนที่แฟล็กจะถูกตั้งค่า อ็อบเจกต์จะไม่ถูกลบ
class Node {
public Node next;
}
class Node : public Object {
public:
SharedPtr<Node> next;
}
การลบห่วงโซ่ของอ็อบเจกต์จะทำแบบเรียกซ้ำ (recursively) ซึ่งอาจนำไปสู่ภาวะสแต็กโอเวอร์โฟลว์ (stack overflow) หากห่วงโซ่นั้นยาวมาก – หลายพันอ็อบเจกต์ขึ้นไป ปัญหานี้แก้ไขได้โดยการเพิ่มไฟนาไลเซอร์ (finalizer) ซึ่งแปลเป็นดีสตรัคเตอร์ ที่จะลบห่วงโซ่ผ่านการวนซ้ำ (iteration)
การแก้ไขปัญหาการอ้างอิงแบบวงกลมนั้นตรงไปตรงมา – เพิ่มแอททริบิวต์ในโค้ด C# ข่าวร้ายคือ นักพัฒนาที่รับผิดชอบในการรีลีสผลิตภัณฑ์สำหรับ C++ โดยทั่วไปจะไม่ทราบว่าการอ้างอิงใดควรเป็นแบบอ่อน หรือแม้แต่วงจรนั้นมีอยู่จริงหรือไม่
เพื่ออำนวยความสะดวกในการค้นหาวงจร เราได้พัฒนาชุดเครื่องมือที่ทำงานคล้ายกัน พวกมันอาศัยกลไกภายในสองอย่าง: รีจิสทรีอ็อบเจกต์ส่วนกลาง (global object registry) และการดึงข้อมูลเกี่ยวกับฟิลด์การอ้างอิงของอ็อบเจกต์
รีจิสทรีส่วนกลางประกอบด้วยรายการของอ็อบเจกต์ที่มีอยู่ในปัจจุบัน คอนสตรัคเตอร์ของคลาส System::Object
จะวางการอ้างอิงไปยังอ็อบเจกต์ปัจจุบันลงในรีจิสทรีนี้ และดีสตรัคเตอร์จะลบออก โดยปกติแล้ว รีจิสทรีนี้จะมีอยู่เฉพาะในโหมดบิลด์สำหรับดีบักแบบพิเศษเท่านั้น เพื่อไม่ให้ส่งผลกระทบต่อประสิทธิภาพของโค้ดที่แปลงแล้วในโหมดรีลีส
ข้อมูลเกี่ยวกับฟิลด์การอ้างอิงของอ็อบเจกต์สามารถดึงออกมาได้โดยการเรียกฟังก์ชันเสมือน (virtual function) GetSharedMembers()
ซึ่งประกาศไว้ที่ระดับ System::Object
ฟังก์ชันนี้จะส่งคืนรายการที่สมบูรณ์ของสมาร์ทพอยเตอร์ที่เก็บอยู่ในฟิลด์ของอ็อบเจกต์และอ็อบเจกต์เป้าหมายของพวกมัน ในโค้ดไลบรารี ฟังก์ชันนี้จะถูกเขียนด้วยตนเอง ในขณะที่ในโค้ดที่สร้างขึ้น มันจะถูกฝังโดยตัวแปลภาษา
มีหลายวิธีในการประมวลผลข้อมูลที่ได้จากกลไกเหล่านี้ การสลับระหว่างวิธีต่างๆ ทำได้โดยใช้ตัวเลือกของตัวแปลภาษาที่เหมาะสมและ/หรือค่าคงที่ของพรีโปรเซสเซอร์ (preprocessor constants)
SharedPtr
จะสำรวจการอ้างอิงระหว่างอ็อบเจกต์ โดยเริ่มจากอ็อบเจกต์ที่มันจัดการวงจรชีวิต และแสดงผลข้อมูลเกี่ยวกับวงจรทั้งหมดที่ตรวจพบ – ทุกกรณีที่สามารถเข้าถึงอ็อบเจกต์เริ่มต้นได้อีกครั้งโดยการติดตามการอ้างอิงแบบเข้มเครื่องมือดีบักที่มีประโยชน์อีกอย่างคือการตรวจสอบว่าหลังจากคอนสตรัคเตอร์ของอ็อบเจกต์ถูกเรียกโดยฟังก์ชัน MakeObject
แล้ว จำนวนการอ้างอิงแบบเข้มสำหรับอ็อบเจกต์นั้นเป็นศูนย์หรือไม่ หากไม่เป็นเช่นนั้น แสดงว่ามีปัญหาที่อาจเกิดขึ้น – วงจรการอ้างอิง พฤติกรรมที่ไม่แน่นอนหากมีการโยน Exception และอื่นๆ
แม้ว่าจะมีความไม่เข้ากันพื้นฐานระหว่างระบบประเภทของ C# และ C++ เราก็สามารถสร้างระบบสมาร์ทพอยเตอร์ที่ช่วยให้โค้ดที่แปลงแล้วทำงานได้โดยมีพฤติกรรมใกล้เคียงกับต้นฉบับ ในขณะเดียวกัน งานนี้ก็ไม่สามารถแก้ไขได้ในโหมดอัตโนมัติเต็มรูปแบบ เราได้สร้างเครื่องมือที่ช่วยลดความซับซ้อนในการค้นหาปัญหาที่อาจเกิดขึ้นได้อย่างมาก