16 เมษายน 2568

การอ้างอิงแบบวงกลมและหน่วยความจำรั่วไหล: วิธีพอร์ตโค้ด C# ไปยัง C++

หลังจากที่โค้ดได้รับการแปลและคอมไพล์สำเร็จแล้ว เรามักจะพบปัญหาตอนรันไทม์ โดยเฉพาะอย่างยิ่งที่เกี่ยวข้องกับการจัดการหน่วยความจำ ซึ่งไม่ใช่เรื่องปกติสำหรับสภาพแวดล้อม C# ที่มีการ์เบจคอลเลกเตอร์ (garbage collector) ในบทความนี้ เราจะเจาะลึกปัญหาการจัดการหน่วยความจำเฉพาะทาง เช่น การอ้างอิงแบบวงกลม (circular references) และการลบอ็อบเจกต์ก่อนเวลาอันควร และแสดงให้เห็นว่าแนวทางของเราช่วยในการตรวจจับและแก้ไขปัญหาเหล่านั้นได้อย่างไร

ปัญหาการจัดการหน่วยความจำ

1. การอ้างอิงแบบเข้มแบบวงกลม (Circular Strong 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 มันก็จะถูกลบไปด้วย

2. การลบอ็อบเจกต์ระหว่างการสร้าง (Object Deletion During Construction)

ปัญหานี้เกิดขึ้นหากอ็อบเจกต์ถูกส่งผ่าน 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 ขึ้นหนึ่ง และดีสตรัคเตอร์ของมันจะลดจำนวนลงอีกครั้ง โดยไม่ทำให้เกิดการลบอ็อบเจกต์

3. การลบอ็อบเจกต์ซ้ำซ้อนเมื่อคอนสตรัคเตอร์โยน Exception (Double Deletion of an Object When a Constructor Throws an Exception)

พิจารณาสถานการณ์ที่คอนสตรัคเตอร์ของอ็อบเจกต์โยน 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 หลังจากออกจากคอนสตรัคเตอร์ หากพอยเตอร์ถูกทำลายก่อนที่แฟล็กจะถูกตั้งค่า อ็อบเจกต์จะไม่ถูกลบ

4. การลบห่วงโซ่ของอ็อบเจกต์ (Deleting Chains of Objects)

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)

  1. เมื่อฟังก์ชันที่เกี่ยวข้องถูกเรียก กราฟที่สมบูรณ์ของอ็อบเจกต์ที่มีอยู่ในปัจจุบัน รวมถึงข้อมูลเกี่ยวกับประเภท ฟิลด์ และความสัมพันธ์ จะถูกบันทึกลงในไฟล์ กราฟนี้สามารถนำไปแสดงผลด้วยภาพโดยใช้ยูทิลิตี้ graphviz โดยปกติ ไฟล์นี้จะถูกสร้างขึ้นหลังจากการทดสอบแต่ละครั้งเพื่อติดตามการรั่วไหลได้อย่างสะดวก
  2. เมื่อฟังก์ชันที่เกี่ยวข้องถูกเรียก กราฟของอ็อบเจกต์ที่มีอยู่ในปัจจุบันซึ่งมีการพึ่งพาแบบวงกลม – โดยที่การอ้างอิงทั้งหมดที่เกี่ยวข้องเป็นแบบเข้ม – จะถูกบันทึกลงในไฟล์ ดังนั้น กราฟจึงมีเฉพาะข้อมูลที่เกี่ยวข้องเท่านั้น อ็อบเจกต์ที่ได้รับการวิเคราะห์แล้วจะถูกแยกออกจากการวิเคราะห์ในการเรียกฟังก์ชันนี้ครั้งต่อไป ทำให้ง่ายต่อการมองเห็นว่ามีอะไรรั่วไหลจากการทดสอบเฉพาะ
  3. เมื่อฟังก์ชันที่เกี่ยวข้องถูกเรียก ข้อมูลเกี่ยวกับกลุ่มอ็อบเจกต์โดดเดี่ยว (isolation islands) ที่มีอยู่ในปัจจุบัน – ชุดของอ็อบเจกต์ที่การอ้างอิงทั้งหมดไปยังพวกมันถูกถือโดยอ็อบเจกต์อื่นภายในชุดเดียวกัน – จะถูกแสดงผลไปยังคอนโซล อ็อบเจกต์ที่ถูกอ้างอิงโดยตัวแปรสแตติกหรือโลคัลจะไม่รวมอยู่ในการแสดงผลนี้ ข้อมูลเกี่ยวกับแต่ละประเภทของกลุ่มอ็อบเจกต์โดดเดี่ยว เช่น ชุดของคลาสที่สร้างกลุ่มทั่วไป จะถูกแสดงผลเพียงครั้งเดียว
  4. ดีสตรัคเตอร์ของคลาส SharedPtr จะสำรวจการอ้างอิงระหว่างอ็อบเจกต์ โดยเริ่มจากอ็อบเจกต์ที่มันจัดการวงจรชีวิต และแสดงผลข้อมูลเกี่ยวกับวงจรทั้งหมดที่ตรวจพบ – ทุกกรณีที่สามารถเข้าถึงอ็อบเจกต์เริ่มต้นได้อีกครั้งโดยการติดตามการอ้างอิงแบบเข้ม

เครื่องมือดีบักที่มีประโยชน์อีกอย่างคือการตรวจสอบว่าหลังจากคอนสตรัคเตอร์ของอ็อบเจกต์ถูกเรียกโดยฟังก์ชัน MakeObject แล้ว จำนวนการอ้างอิงแบบเข้มสำหรับอ็อบเจกต์นั้นเป็นศูนย์หรือไม่ หากไม่เป็นเช่นนั้น แสดงว่ามีปัญหาที่อาจเกิดขึ้น – วงจรการอ้างอิง พฤติกรรมที่ไม่แน่นอนหากมีการโยน Exception และอื่นๆ

สรุป

แม้ว่าจะมีความไม่เข้ากันพื้นฐานระหว่างระบบประเภทของ C# และ C++ เราก็สามารถสร้างระบบสมาร์ทพอยเตอร์ที่ช่วยให้โค้ดที่แปลงแล้วทำงานได้โดยมีพฤติกรรมใกล้เคียงกับต้นฉบับ ในขณะเดียวกัน งานนี้ก็ไม่สามารถแก้ไขได้ในโหมดอัตโนมัติเต็มรูปแบบ เราได้สร้างเครื่องมือที่ช่วยลดความซับซ้อนในการค้นหาปัญหาที่อาจเกิดขึ้นได้อย่างมาก

ข่าวที่เกี่ยวข้อง

วิดีโอที่เกี่ยวข้อง

บทความที่เกี่ยวข้อง