By Arnon Puitrakul - 09 สิงหาคม 2024
หากเราเรียนลงลึกไปในภาษาใหม่ ๆ อย่าง Python และ Java โดยเฉพาะในเรื่องของการจัดการ Memory ว่าเขาใช้ Garbage Collection นะ ว่าแต่มันทำงานยังไง วันนี้เราจะมาเล่าให้อ่านกันว่า จริง ๆ แล้วมันทำงานอย่างไร และมันมีเคสใดที่อาจจะหลุดจนเราต้องเข้ามาจัดการเองบ้าง
ปล. หลักการทั่ว ๆ ไปของแต่ละภาษามันจะเหมือนกัน แต่ไส้ข้างในวิธีการบางอย่างอาจมีความแตกต่างกันนิดหน่อย ในบทความนี้ เราจะเน้นไปที่ฝั่ง Python เป็นหลัก แต่ถ้าอยากไปสุดจริง ๆ รวมมิตร 108 กระบวนท่า GC ไปดู Java อันนั้นดุดันไม่เกรงใจใคร
ในภาษาสมัยใหม่ ๆ เขามักจะมีการ Implement กลไกบางอย่างเข้ามาจัดการ Memory เพื่อป้องกันความผิดพลาดที่อาจจะเกิดขึ้นจาก Programmer ที่อาจจะลืมอะไรบางอย่างไป
ส่วนใหญ่ ปัญหาที่เรามักเจอ เกิดได้ 3 ประเด็นใหญ่ ๆ อย่างแรกคือ เราเคยใช้งานไปแล้ว แต่ลืมว่า เราคืนไปแล้ว พอเราเรียกไปเท่านั้นแหละ End Game เราเรียกอาการทำนองนี้ว่า Dangling pointers คือ เรามี Pointer ที่เคย Reference ไปหาสักจุดแล้ว จุดนั้นโดนคืนไปแล้ว แต่เรายังเก็บ Pointer อยู่ พอเรา Dereference ไปหาก็แตกเฉย หากเป็นภาษาที่ Compile ส่วนใหญ่ Compiler และ Linter มักจะเจอและแจ้งเตือนกับเรา แต่บางครั้ง หากเราเรียกแบบ Dynamic นั่นก็อีกเรื่องเลยนะ หรือในภาษาที่ใช้ Interpretator อย่าง Python นั่นก็อีกเรื่องเลยเช่นกัน
ประเด็นที่ 2 เรียกว่า Double Free Bugs เกิดเมื่อ เราพยายามที่จะ Free หรือคืน Memory ในส่วนที่เราคืนไปแล้ว ซึ่งถ้ามันว่างอยู่มันไม่ใช่ประเด็น แต่ถ้ามันโดน Allocate โดยคนอื่น (จุดนี้ Modern OS เขา Handle เป็น Safety Net ให้เราชั้นนึงแล้ว) หรือภายในโปรแกรมเราเองแต่เป็นจุดอื่นละ ก็.... เย้ ยินดีด้วย คุณแตกหนึ่ง
ประเด็นที่ 3 เราคุ้นเคยกันดีแน่นอน คือ Memory Leak คือ เราไม่สามารถที่จะคืน Memory บางส่วนได้เนื่องจาก เราไม่สามารถเข้าถึงมันได้อีกแล้วทำให้สุดท้าย เราจะเจอกับอาการ Memory เต็ม หรือ Memory Exhasustion
ทำให้ภาษาสมัยใหม่ เริ่มมีการ Implement กลไกในการจัดการเข้าไป ทำให้ Programmer ไม่จำเป็นต้อง Deallocate Memory ด้วยตัวเองอีก เราเห็นคนชอบเรียกภาษาพวกนี้ว่าเป็น Memory-Safe Programming Lanaguage เช่น Python, Java และ Rust เป็นต้น ซึ่งในแต่ละภาษาก็อาจจะเลือกใช้งานกลไกสักตัว หรือมากกว่า 1 ตัวในการจัดการ แต่วันนี้เราจะมาโฟกัสกันที่วิธีการยอดนิยม และเข้าใจได้ง่าย อย่าง Garbage Collection
แต่ก่อนที่เราจะไปพูดถึง Garbage Collection ใน Python เราจะต้องมาเข้าใจวิธีการที่ Python จัดการกับ ตัวแปร กันก่อน
a = 10
print(id(a))
> 4335567376
เราต้องเข้าใจก่อนว่า ใน Python ทุกอย่างเป็น Object ทั้งหมด เวลามันจัดการคือ ทุก ๆ ตัวแปรมันจะมีเลข ID เป็นของตัวเองเสมอ เราสามารถใช้ id() เพื่อเรียก ID ของ Object ตัวนั้น ๆ ขึ้นมาดูได้
def check_id (a) :
print(id(a))
a = 10
print(id(a))
check_id(a)
และหากเราลอง Pass Argument เข้าไปใน Function และลองเช็ค ID ของ a กันดู เราจะพบว่า ID ของ a ทั้งในและนอก Function มันจะเป็นตัวเดียวกัน นั่นเป็นเพราะเวลาเรา Pass Argument ใน Python มันไม่ได้ Copy ค่า หรือที่เราเรียก Call by Value แต่มันแค่คัดลอก Reference ID นั้นไปแปะลงไปนั่นเอง
def check_change_id (a) :
print(f"2. {id(a)}")
a = 20
print(f"3. {id(a)}")
a = 10
print(f"1. {id(a)}")
print(f"The value of a before call function is {a}")
check_change_id(a)
print(f"The value of a after call function is {a}")
ถ้าเราบอกว่า เราส่ง Object Reference มันจะต้องคล้ายกับการทำ Call By Reference สิ ถ้าเราเปลี่ยนแปลงค่าใน Function เมื่อเรา Pop Stack กลับมาที่คนเรียกคนก่อนหน้า ค่ามันน่าจะเปลี่ยนไปด้วย งั้นเราลองทำการทดลองด้วย Snippet ด้านบนกัน เราเริ่มจาก เรา Assign a ขึ้นมาให้เท่ากับ 1 จากนั้น เราขอ ID และค่าออกมา จากนั้น เราจะเรียก Function โดย เราจะขอ ID ก่อนและหลังที่เราจะเปลี่ยนค่า สุดท้าย เมื่อ Function ทำงานจบ Pop Stack กลับมาที่ Main เราก็จะสั่งให้แสดงค่าใน A ออกมา
> python ref_tester.py
1. 4368040464
The value of a before call function is 10
2. 4368040464
3. 4368040784
The value of a after call function is 10
เมื่อเรารัน ผลที่ได้ ขอแยกออกมาเป็น 2 ส่วนด้วยกัน ส่วนแรกที่เห็นชัด ๆ คือ ค่าของ a ทั้งก่อนและหลังเรียก check_change_id() นั้น ได้เท่าเดิมเลย ไม่ได้เปลี่ยนเป็น 20 อย่างที่เราคิด และ ID ที่ได้ ในจุดที่ 1 และ 2 นั้นได้ ID เดียวกันเลย จะต่างแค่ ID ที่ 3 เท่านั้น ซึ่งเป็น ID ที่อยู่หลังจากเราเปลี่ยนค่า a ภายใน Function แล้ว
นั่นแปลว่า จริง ๆ แล้ว Python มันโยน Reference ข้าม Function กันจริง แต่เมื่อมันมีการเปลี่ยนแปลงนอก Scope ของมัน มันจะจองพื้นที่ และชี้ไปที่ Memory ก้อนใหม่ ทำให้เมื่อเราขอค่า a หลังจากเราเรียก Function แล้ว มันเลยไม่ได้กลายเป็น 20 นั่นเอง เราเรียกสมบัตินี้ว่า Immutable
def check_change_id (a) :
print(f"2. {id(a)}")
a.append(10)
print(f"3. {id(a)}")
a = [1,2,3]
print(f"1. {id(a)}")
print(f"The value of a before call function is {a}")
check_change_id(a)
print(f"The value of a after call function is {a}")
แต่เราอยากให้ลองดูอีกสัก Snippet นึง เราจะเห็นว่า มันคล้ายกับตัวก่อน สิ่งที่เปลี่ยนแปลง มีเพียงแค่ a จากเดิมที่เราใช้ Data Type เป็น Int รอบนี้ เราลองเปลี่ยนเป็น List ดู และภายใน Function เราเปลี่ยนจากการ Assign ค่าใหม่ เป็นการ เพิ่ม Item ลงไปใน List แล้วมาดูกันว่าผลจะเป็นอย่างไร
> python ref_list_tester.py
1. 4378293824
The value of a before call function is [1, 2, 3]
2. 4378293824
3. 4378293824
The value of a after call function is [1, 2, 3, 10]
ผลที่ได้น่าจะทำให้ทุกคนเกิดความเอ๊ะ ด้วย 2 เหตุผลคือ เลข ID ทั้ง 3 จุดที่เราช้อนขอมานั้น จากเดิมตัวที่ 2 มันควรจะแตกต่างตัวเดียว กลายเป็นว่า ทุกตัวเหมือนกันทั้งหมด และเหตุผลที่สองคือ List ที่เราโยนเข้าไป ขากลับมาเราไม่ได้ Return อะไรเลย แต่ค่ามันดันเปลี่ยน จากสิ่งที่สั่งภายใน Function แปลก ๆ นะ นั่นเป็นเพราะ ตัวแปรประเภท List,Set และ Dict มันออกแบบมาให้อยู่ในกลุ่มของ Mutable จึงทำให้เกิดปรากฏการณ์ดั่งที่ได้ทดลองให้ดูขึ้น
ถามต่อว่า แล้วทำไม Python เลือกที่จะใช้ Design ลักษณะนี้ในการทำงาน ทำไมไม่เลือก Call by refernece หรือ Call by value แบบที่ C ทำได้ไปเลยละ จากความเห็นส่วนตัวของเรามองว่า การที่เราทำได้แบบ C มันเหมือนกับการพลักภาระในการจัดการ Memory ให้กับ Programmer แต่การทำแบบ Python นี้ มันเหมือนการเอาข้อดีของทั้ง 2 วิธีเข้ามาหาด้วยกัน คือโดนพื้นฐานหากเราโยนค่าเข้าไป มันแค่เหมือนโยน Pointer เป็น Reference ID เข้าไป ทำให้เราไม่เปลือง Memory และทำงานได้เร็ว เหมือนตอนเรา Call by Value แต่เมื่อมันเกิดการเปลี่ยนแปลง เราค่อยชี้ไปหาค่าใหม่เท่านั้น มันก็จะเข้ามาแก้เรื่อง Variable Scope ได้อีก สุดท้าย มันเลยทำให้เราไม่ต้องมานั่งเลือก หรือกำหนดลงไปใน Code ว่า เราจะ Pass Argument อย่างไร เป็นการ Abstract Layer นี้ไปได้เลย แต่กลับกันข้อดีกลับกลายเป็นข้อเสีย ที่มันทำให้ Programmer ขาดความสามารถในการควบคุมเช่นกัน ขึ้นกับการใช้งานของเราแล้วละว่า เราต้องการลักษณะไหน
กระบวนการสำหรับการจัดการ Memory สั้น ๆ ง่าย ๆ ถูกแบ่งออกเป็น 2 ขั้นตอนด้วยกันคือ การค้นหา Memory ส่วนที่ไม่ใช้แล้ว และการ Deallocate Memory ส่วนที่ไม่ใช้แล้วคืนออกไป เหมือนกับรถขยะที่ เมื่อมันขับผ่านมา เห็นขยะก็จะเอาเก็บทิ้งไป
ทำให้เกิดคำถามต่อว่า แล้ว Python รู้ได้ยังไงว่า อะไรคือขยะ อะไรคือสิ่งที่ยังใช้อยู่ หากอยู่ ๆ มันไล่เก็บของที่เรายังใช้อยู่ ย่อมไม่ดีแน่นอน เพราะจะทำให้โปรแกรมของเราแตกได้แน่นอน ใน Python เราจะใช้งานอยู่ 2 กลไกคือ Reference Counting และ Generational Garbage Collection
Reference Counting มันทำตรงตามชื่อของมันคือ การนับว่า มันมีตัวที่ Reference เข้าหามันอยู่ทั้งหมดกี่ตัว หากไม่มีใคร Reference มันเลย นั่นแปล่า มันน่าจะไม่ได้ใช้งานแล้ว มันก็ควรจะเป็นขยะ รอการ Deallocate ออกไป
import sys
a = 10
print(f"a has {sys.getrefcount(a)} referenced")
b = a
print(f"a has {sys.getrefcount(a)} referenced")
b = None
print(f"a has {sys.getrefcount(a)} referenced")
ใน Python เอง ก็มี API สำหรับการตรวจสอบได้ด้วยว่า Memory นั้น ๆ ถูก Reference ทั้งหมดกี่ครั้ง จาก Snippet ด้านบน เราทดลองสร้างตัวแปรขึ้นมาตัวนึง คือ a ให้ค่าเป็น 10 จากนั้น เราขอ Reference Count ออกมา แล้วเราลองเพิ่มตัวเลขนั้นด้วยการกำหนดให้ b เป็น a และสุดท้าย เราลอง เปลี่ยน Reference ของ b ไปเป็นแบบอื่นแทน ตัวเลข Refernece Count ของ a ก็ควรจะน้อยลง เพราะ b ไปมีค่าอื่นแล้ว
1
2
1
ผลการทดลอง เป็นอย่างที่เราคาดหวัง เริ่มจาก Reference Count ของ a สด ๆ หลังจากเรา Assign 10 ให้แล้ว มันก็ควรจะเป็น 1 แน่นอน เพราะคนเดียวที่ Reference ไปหาค่า 10 คือ a แต่หลังจากบอกว่า b = a นั่นแปลว่า ค่าของ b คือ Reference ไปที่ 10 เช่นเดียวกับ a ทำให้เมื่อเราเช็ค Reference Count ไป ทำให้ มันกลายเป็น 2 และสุดท้าย เมื่อเราชี้ b ไปที่ None ไม่ใช่ a อีกต่อไป ค่า Reference Count เลยกลับไปเป็น 1 เหมือนเดิม
ถามว่า แล้วถ้า เราเปลี่ยน a = 20 แทน คิดว่ามันจะเกิดอะไรขึ้น สั้น ๆ ก็คือ มันจะจอง Memory ใหม่เป็น Int แล้วเอา 20 ไปใส่ แล้วเปลี่ยน ID จากอันเดิมที่ชี้ไปหา 10 เป็น อันที่ชี้ไปหา 20 แทน นั่นแปลว่า หาก 10 ไม่มีใครชี้หาอยู่ จะทำให้ Reference Count เป็น 0 จึงทำให้โดนจัดเป็นขยะรอเก็บนั่นเอง
def test_f () :
c = 20
a = 10
b = a
for i in range(20):
test_f()
แล้วถ้าเราทำแบบ Snippet ด้านบนบ้างละ ภายใน test_f() เรา Assign ตัวแปร c เป็น 20 แล้วใน Main เราวนเรียก test_f() ไปเรื่อย ๆ สิ่งที่มันเกิดขึ้นคือ เมื่อ test_f โดนเรียก c=20 ก็ทำงานเป็นคำสั่งแรก มันจะเข้าไปจอง Memory ใส่ 20 เข้าไป และเอา c ชี้ไปหาค่านั้นทำให้ Refernece Count เป็น 1 และเมื่อ Function ทำงานจบ Stack Pop ออก นั่นแปลว่า c ที่ Reference ไปหา 20 ก็จะโดนเด้งออกไป ทำให้ Reference Count หายไปจนเหลือ 0 ก็จะโดนเก็บไปนั่นเอง นี่คือการทำงานของ Refernece Counting เป็นวิธีการที่เข้าใจ และ Implement ได้ง่าย Overhead ต่ำ แค่เรา Keep Track การ Reference ของตัวแปรแต่ละตัวไปเรื่อย ๆ เท่านั้น
อ่านมาแล้ว การทำ Reference Counting ดูเป็นวิธีที่ Make Sense มาก ๆ เราจะมี GC ทำไม ดังนั้นก่อนที่เราจะไปดู GC เราขอยกเคสตัวอย่างอันนึงที่ทำให้ Refernece Counting แตก
a = [1,2,3]
b = [4,5,6]
a.append(b)
b.append(a)
a = None
b = None
หากเราลองดูที่ Snippet ด้านบนนี้ เราจะเห็นว่า เรามี List a แล้วเรา Append ด้วย b นั่นแปลว่า ค่าที่ชี้ไปมันจะมี ค่าเดิมของ a อยู่แล้ว และมี b เข้ามาแจม ทำให้ b มี Reference Count เป็น 2 จาก การที่ b ชี้ไปหา และ การที่ Element สุดท้ายใน a ชี้ไปหา และในบรรทัดต่อไป เราเพิ่ม Element ใน b ด้วย a ทั้งตัว ทำให้ Reference Count ของ a ก็จะเพิ่มขึ้นอีก 1 เป็น 2 ด้วยเช่นกัน
สุดท้าย เมื่อเรา Reference a และ b ไปหา None จากที่มันควรจะทำให้ Reference Count ของ List ที่ a และ b เคยชี้ไปหามันกลายเป็น 0 แล้วโดนเก็บไป แต่อย่าลืมว่า มันเป็น 2 หายไป 1 มันก็เหลือ 1 จากการที่มันทั้งคู่ชี้หากันเองอยู่ เราเรียกการชี้กันเป็นวงกลม แบบนี้ว่า Cyclic Reference หรือ Cycle Reference ก็ได้
สิ่งที่เกิดขึ้นคือ เมื่อ Reference Count มันเป็น 1 แน่นอนว่า มันก็จะคาอยู่ใน Memory เราแบบนั้นเป็นขยะที่เราไม่ได้ใช้งานไป เป็น Memory ส่วนที่ Leak นั่นเอง
จากปัญหา Cyclic Reference ทำให้ Python เลือก Implement อีกวิธีการมาเพื่อจัดการกับขยะที่ Reference Count มันเก็บไม่ได้คือ Garbage Collection (GC) นั่นเอง
การทำงานของ GC นั้นง่ายมาก ๆ คือ เมื่อมันถูกเรียกขึ้นมา มันจะสแกนเข้าไปใน Heap เทียบกับ Stack ว่า มีจุดไหนใน Heap ที่ไม่มี Reference จาก Stack เรียกไป มันจะ Mark จุดนั้นแล้ว Deallocate ออกไป ทำให้มันสามารถแก้ปัญหา Cyclic Reference ได้ แต่.... การที่มันจะต้องถูกปลุกขึ้นมานั่งเทียบข้อมูลบน Heap และ Stack เรื่อย ๆ มันมี Overhead เยอะมาก ๆ ส่งผลให้โปรแกรมของเราทำงานได้ช้ากว่าที่ควรจะเป็น แต่เราก็เอาวิธีนี้ออกไปไม่ได้อีก สิ่งที่เราทำได้คือ เราจะต้องลดจำนวนครั้งที่ GC จะถูกเรียกขึ้นมา หรือ GC เข้าไปเทียบเช็ค ให้น้อยที่สุด วิธีการที่ Python เลือกใช้คือ Generational Garbage Collector
Generational Garbage Collector มันจะแบ่งตัวแปรออกเป็น Generation อ้างอิงจาก อายุและความถี่ในการเปลี่ยนแปลง เพราะไอเดียของมันคือ Object ส่วนใหญ่มักจะเกิดมาแล้วตายจากไปอย่างรวดเร็ว แต่ตัวที่อยู่ยาว มันก็อยู่ยาวจริง ๆ
ถามต่อว่า แล้ว Python เลือกใช้กี่ Generation ในการจัดการละ คำตอบคือ 3 Generation ตั้งแต่ 0-2 เริ่มต้นจาก 0 คือ ตัวที่พึ่งสร้างสด ๆ ร้อน ๆ เมื่อ GC รันขึ้นมา มันเห็นว่ายังโดน Reference อยู่ มันจะโดนย้ายไปที่ Generation 1 และเมื่อ GC รันอีกรอบ มันยังมีการ Reference อยู่มันก็จะโดนย้ายไปใน Generation 2 หากมันรอดหลังจากนั้นอีก มันก็จะคงอยู่ใน Generation 2 ไปเรื่อย ๆ
import gc
print(gc.get_threshold())
(700, 10, 10)
ข้อดีของการทำแบบนี้คือ ใน Python GC เขาจะตั้ง Threshold ไว้ว่า หากจำนวน Object ถึงค่าที่กำหนดไว้ มันจะเรียก GC ขึ้นมา แต่การที่เราใช้ Generational GC เราสามารถกำหนด Threshold ในแต่ละ Generation ได้เลย เช่น ถ้าเราบอกว่า Object ส่วนใหญ่มักเกิดแล้วตายจากไปอย่างรวดเร็วมีจำนวนเยอะ ๆ หากเรากำหนด Threshold น้อยเกินไป ก็จะทำให้ GC ออกมาวิ่งบ่อย ย่อมทำให้โปรแกรมเรา Performance ต่ำลงแน่นอน ดังนั้นในค่าพื้นฐาน Python เขาเลยตั้งเอาไว้ที่ 700 สำหรับ Generation 0, 10 สำหรับ Generation 1 และ 2
import gc
print(gc.get_count())
(404,7,1)
โดยเราสามารถเช็คได้ว่า ณ ตอนนี้ Object Count ในแต่ละ Generation เป็นเท่าไหร่ผ่านคำสั่งที่ชื่อว่า get_count() อยู่ใน gc Module เมื่อสัก Generation ถึง Threshold ที่ตั้งไว้แล้ว มันถึงจะเรียก GC ออกมา เริ่มจากเจี๊ยนตัวที่ Reference Count = 0 ออกไปก่อนแล้วถึงจะทำ Generational Garbage Collector ไล่ตั้งแต่การทำ Garbage Collection ใน Generation 0, 1 และ 2 ตามชั้นไปเรื่อย ๆ เลย
เราสามารถ Interact กับ GC ได้ผ่าน Built-in Module อย่าง gc ภายในนั้นมีคำสั่งหลายตัวมาก ๆ เราสามารถเข้าไปอ่านใน Document ของ Python ได้
import gc
gc.enable() // Enable GC
gc.disable() // Disable GC
gc.collect() // Manually start GC
หลัก ๆ ที่เราน่าจะได้เจอกันบ่อย ๆ ก็น่าจะเป็นคำสั่งสำหรับการ เปิดปิด GC และ เรียก GC ออกมาทำงานนี่แหละ เหตุที่เราจำเป็นต้องสั่งปิด GC ส่วนใหญ่ เราจะใช้เวลา เราจะต้อง Debug โปรแกรมในหลาย ๆ กรณี เพื่อเช็คค่าบางอย่างในตัวแปรอะไรเทือก ๆ นั้น แต่มันไม่ใช่ของที่เราจะสั่งปิดเล่น ๆ ใน Production แน่นอน
อ่านมาถึงตรงนี้ เราอาจจะสงสัยต่อว่า แล้วการที่เรารู้เรื่องบ้าพวกนี้ทำให้เราเขียนโปรแกรมได้ดีขึ้นจริง ๆ เหรอ คำตอบคือ ใช่ การที่เรามี GC มันป้องกันเราจากความผิดพลาดที่อาจจะเกิดขึ้นจาก Memory ได้ แต่นั่นแลกมากับ Overhead ในการทำงาน ดังนั้น Goal ของเรื่องนี้คือ เราควรจะต้องทำยังไงก็ได้ให้ GC มันไม่จำเป็นต้องรันบ่อย ๆ
a = [1,2,3]
// Don't Do
b = []
for i in range(len(a)) :
b.append(a[i]+1)
// Do This Instead
b = [current+1 for current in a ]
จากที่เล่ามาคือมัน Trigger ด้วย Threshold ในการทำงาน ทำให้สิ่งแรกที่เราทำได้คือ การเลี่ยงพวกการสร้าง Object ที่ไม่จำเป็น วิธีการเลี่ยงก็น่าจะเป็นการใช้พวก Iterators, Comprehension และ Generation เท่าที่เราจะทำได้
ถัดไปคือ การเลี่ยงพวก Global Object ที่อายุยืน ๆ หน่อย เราจะเห็นได้จาก Threshold ใน Generation 1 และ 2 มันต่ำมาก ๆ 10 เท่านั้นเอง ดังนั้น ถ้าเรามี Global Object ที่อายุยืนยาว มันก็จะเลื่อนไปอยู่ Generation 1 และ 2 เยอะดังนั้น มันจะทำให้ GC โดนเรียกบ่อยขึ้นโดยไม่จำเป็น วิธีการเลี่ยงที่แนะนำ แทนที่เราจะใช้ Global Variable เราใช้เป็น Local Variable แล้วโยนมันผ่าน Function Argument แทนไปเลย หรือ กระทั่งการที่เราสั่ง del() เมื่อเรารู้ว่า เราไม่ได้ใช้แล้วมันก็ช่วยได้เยอะเลย
สุดท้ายคือ กราบละ อย่าสร้าง Cyclic Reference เลยนะ อย่างที่เราเล่าไปว่า Overhead ของการใช้ Reference Counting มันน้อยกว่าการใช้ GC เยอะมาก ๆ ดังนั้น ถ้าเราไม่มีพวก Cyclic Reference มาก GC ที่มี Overhead สูงก็จะทำงานน้อยลง หากเราจำเป็นจริง ๆ เมื่อเราใช้งานเสร็จแล้ว เราจะต้อง Break Cyclic นั้นเองได้ เพื่อให้มันกลับมาสู่สภาวะปกติที่ Reference Counter จัดการได้ หรือจะใช้ Weak Reference ก็ได้เหมือนกัน
Filename: memprofiler_demo.py
Line # Mem usage Increment Occurrences Line Contents
=============================================================
9 48.1 MiB 48.1 MiB 1 @profile
10 def main () :
11 48.4 MiB 0.0 MiB 21 for i in range(20) :
12 48.4 MiB 0.0 MiB 220 for j in range(10) :
13 48.4 MiB 0.4 MiB 200 test_func(i,j)
แต่หากเราเขียนออกมาแล้ว เรายังรู้สึกว่า มันใช้ Memory มากกว่าที่ควรจะเป็น สิ่งที่เราควรทำคือ การทำ Memory Profiling ใน Python ก็จะมีเครื่องมือหลากหลายตัวให้เราเลือกใช้ เช่น memory_profiler และ tracemalloc ไว้ในโอกาสหน้าจะมาเล่าวิธีการทำ Memory Profiling ผ่านเครื่องมือที่บอกไปบน Python ให้อ่านกัน
ในบทความนี้ เราได้เรียนรู้เกี่ยวกับ วิธีการที่ Python จัดการกับ Memory ตั้งแต่ การที่เราสร้างตัวแปร จนไปถึงกระบวนการที่ Python พยายามจะ Deallocate Memory ให้กับเราผ่าน 2 วิธีที่ใช้คือ Reference Counter และ Garbage Collection ที่ใน Python เลือกใช้ Generational Garbage Collection ซึ่งจะแบ่ง Object ออกเป็น Generation และจะเรียก GC ขึ้นมาทำงานก็ต่อเมื่อ Object ใน Generation ถึง Threshold ก็จะทำให้ เราเรียก GC บ่อยน้อยลง เป็นการลด Overhead ของ GC นั่นเอง สุดท้าย คือ วิธีการที่เราจะลดโอกาสที่ GC จะโดนเรียกขึ้นมา ทำให้สุดท้าย เราสามารถเอาวิธีการและหลักการเหล่านี้ไปลด Overhead ของ GC ได้ และเพิ่มประสิทธิภาพให้กับโปรแกรมของเราได้เยอะมาก ๆ
Obsidian เป็นโปรแกรมสำหรับการจด Note ที่เรียกว่า สารพัดประโยชน์มาก ๆ เราสามารถเอามาทำอะไรได้เยอะมาก ๆ หนึ่งในสิ่งที่เราเอามาทำคือ นำมาใช้เป็นระบบสำหรับการจัดการ Todo List ในแต่ละวันของเรา ทำอะไรบ้าง วันนี้เราจะมาเล่าให้อ่านกันว่า เราจัดการะบบอย่างไร...
อะ อะจ๊ะเอ๋ตัวเอง เป็นยังไงบ้างละ เมื่อหลายเดือนก่อน เราไปเล่าเรื่องกันขำ ๆ ว่า ๆ จริง ๆ แล้วพวก Loop ที่เราใช้เขียนโปรแกรมกันอยู่ มันไม่มีอยู่จริง สิ่งที่เราใช้งานกันมันพยายาม Abstract บางอย่างออกไป วันนี้เราจะมาถอดการทำงานของ Loop จริง ๆ กันว่า มันทำงานอย่างไรกันแน่ ผ่านภาษา Assembly...
นอกจากการทำให้ Application รันได้แล้ว อีกเรื่องที่สำคัญไม่แพ้กันคือการวางระบบ Monitoring ที่ดี วันนี้เราจะมาแนะนำวิธีการ Monitor การทำงานของ MySQL ผ่านการสร้าง Dashboard บน Grafana กัน...
จากตอนที่แล้ว เราเล่าในเรื่องของการ Harden Security ของ SSH Service ของเราด้วยการปรับการตั้งค่าบางอย่างเพื่อลด Attack Surface ที่อาจจะเกิดขึ้นได้ หากใครยังไม่ได้อ่านก็ย้อนกลับไปอ่านกันก่อนเด้อ วันนี้เรามาเล่าวิธีการที่มัน Advance มากขึ้น อย่างการใช้ fail2ban...