[Memory Management with C++ Pt.2] Stack Memory คืออะไร กินได้ไหมนะ
ในบทความนี้พูดเรื่องของ Stack memory ซึ่งจะต่อจาก part 1 ที่เรากล่าวถึงภาพรวมของของ process memory model และ Memory allocation ใครยังไม่อ่านไปอ่านก่อนนะ
จากบทความที่แล้วที่เราบอกว่าเจ้า Stack เนี่ยทำหน้าที่เก็บ local variable และ funtion parameter โดยในแต่ละครั้งที่เราเรียกใช้ function เจ้า stack มันก็จะโตขึ้น (โตจากบนลงล่าง) และทุกครั้งที่ function return หรือทำงานเสร็จ เจ้า stack ก็จะย่อตัว
เรื่องของ stack สำคัญมากเมื่อเราใช้เขียนโปรแกรมแบบ multiple threads เพราะแต่ละ thread จะมี stack memory ของตัวเอง
คุณสมบัติของ Stack
- Stack เป็น contiguous block of memory พูดง่ายๆคือมันจะไม่ถูกแยกส่วน ไม่ถูกซอยไปใช้นู้นนี่นั่น และ stack มีขนาดสูงสุดที่ถูกกำหนดไว้
- เมื่อขนาดสูงสุดของ Stack ถูกใช้เกิน โปรแกรมบึ้มแน่นอน
- การ Allocating และ deallocating จะเร็วมาก เพราะว่าแค่ย้ายไปมาไม่ได้ไปถูกซอยไปใช้
มาดูตัวอย่างการทำงาน Stack จาก Udacity กัน
ใน main ของเราจะสร้างตัวแปร x ขึ้นมาบน stack ใน scope ของ main จากนั้นก็จะเรียก function ชื่อว่า Add ซึ่งจะดัน a และ b (function parameter) รวมไปถึง return address และค่าของ s ที่ต้องส่งค่ากลับไปตามลำดับ ( ดันของ stack คือจาก บนลงล่าง)
Stack Growth และ Contraction
เราจะมาวิเคราะห์สิ่งที่เกิดขึ้นกับ Stack เมื่อเราประกาศตัวแปรและเรียกใช้ function ว่า stack มันโต (growth)และหด (contraction) อย่างไร
จาก code ข้างบน ใน main เราได้ประกาศตัวแปรไว้สองครั้งคือ i และ j ตามด้วย เรียก function Myfunc() ซึ่ง allocate ตัวแปร local ข้างใน function และหลังจาก function return ค่า เราก็ประกาศตัวแปรใน main อีกตัว j
หากเรารันโปรแกรมด้านบน output จะได้หน้าตาประมาณนี้
สิ่งที่เกิดขึ้นระหว่าง 1 และ 2 :
จะสังเกตว่า stack address ลดลงไป 4 bytes (เท่ากับขนาดของ int j) ซึ่งก็คือ หลังจากประกาศ i ที่ stack address = 0x7ffeffbff688 เราก็ประกาศ j ซึ่งต้องลด stack address ไปอีก 4 เราก็จะได้ 0x7ffeefbff684 ตาม output 1 และ 2
สิ่งที่เกิดขึ้นระหว่าง 2 และ 3 :
รอบนี้เกิดการเรียก function ขึ้น ซึ่ง stack address ของเราลดลงไปถึง 0x25 (40 byte) ทีงี้เราก็ทราบแล้วครับว่าเรียก function นี่ใช้ memory เยอะกว่าอย่างเห็นได้ชัดเพราะว่า ไม่ใช่แค่เก็บ 4 byte จากการประกาศ k ข้างใน แต่ยังรวมไปถึงค่าต่างๆเช่น return address ตามตัวอย่างก่อนหน้านี้
ปล. ถ้าใครงงก็เอา output ด้านบนไปคำนวณ hex to decimal เลยครับ จะเห็นภาพมากขึ้น
สิ่งที่เกิดขึ้นระหว่าง 3และ 4 :
Myfunc ที่เราเรียกไป ถูกใช้เสร็จสิ้นออกจาก scope แล้ว stack address ก็จะหดตัวลงกลับไปที่ 0x7ffeefbff684 หรือก็คือตำแหน่งเดิมก่อนเรียกใช้ หลังจากนั้นเราประกาศ l ต่อเลย ทำให้ต้องลดไปอีก 4 byte จบที่ stack address 0x7ffeefbff680 นั่นเอง
แล้ว Stack size ของเราคือเท่าไหร่กันละ ?
เวลาเราเรียกใช้ function หรือประกาศ local variable เจ้า stack เนี่ยก็จะมี pointer คอยติดตามตัวมันเองครับ ซึ่งถ้ามันมาถึงจุดล่างสุด โปรแกรมเราก็จะบึ้ม นี่แหละครับคือที่มาของ stack overflow (รู้นะว่าเพิ่งรู้กัน อิอิ)
มาสร้าง Stack Overflow กัน
การสร้าง stack over flow ก็คือการเขียนโปรแกรมเล็กที่สร้าง stack เยอะมากๆ ซึ่งเราจะใช้การทำ recursive ในการเรียกตัวมันเอง และทุกครั้งที่มันเรียกตัวเอง โปรแกรมต้องบอกเราด้วยว่าลงไปถึงไหนแล้ว
ใน function main ของเราเนี่ยเราจะสร้าง id เพื่อบอกว่าลำดับเท่าไหร่ และตัวแปรi ขึ้นมาตัวเป็นจุดเริ่มของ stack address ก่อนจะ recursive ลงไปเรื่อยๆ และอัพเดท address ในขณะนั้นผ่าน j ใน recursive ของเรา
ซึ่ง output สุดท้ายจะได้หน้าตาตอบจบประมาณนี้
เราสามารถเช็คขนาดของ stack memory ของเราได้ด้วยคำสั่ง
ทีงี้ผมก็ทราบแล้วครับว่า memory stack คอมผมประมาณ 8Mb
เรามาลองเอา stack ล่างสุด(j ตัวล่าสุด) มาลบ stack บนสุด(i) ซึ่งตามคาดครับได้ใกล้ๆเคียงกันกับที่เรา run บน terminal
0x7ffeef400664–0x7ffeefbff688 = 0xffffffffff800FDC = 8.384.548 bytes
จากการทดลองเราสามารถสรุปได้เลยว่า อย่าหาทำนะครับ ต่อให้ RAM คอมเราเพิ่มแต่ stack ก็ไม่ได้เยอะตามอยู่ดี
Call by value vs Call by reference
เวลาเราส่ง parameter ไปที่ function ใน C++ เราสามารถทำได้หลายแบบครับ สำหรับมือใหม่แบบผมก็คงส่งแบบ Call by value (พวก int, float) ส่งเข้าไปแล้วสั่งให้ return value กลับมาเก็บในอีกตัวแปร
แต่ก่อนอื่นเรามาพูดถึง scope กับก่อนดีกว่าครับ
โดยช่วงเวลาระหว่าง allocation จนถึง deallocation เรียกว่า lifetime of a variable ครับ
เมื่อเราเรียกใช้ MyLocalFunction() ตัวมันเองจะ allocate memory บน stack สำหรับ local variable ชื่อ isBelowThresholdและ deallocate หลังจากออกจาก function (จบ lifetime)
โดยโปรแกรมเมอร์หลายคนอาจจะพลาด เผลอเรียกใช้ตัวแปรหลังจาก life time เช่นสั่งให้ cout << isBelowThreshold ใน main ซึ่งมันไม่สามารถทำได้และทำให้เกิด error
สรุปการ allocate local variable อธิบายได้ดังนี้
- memory จะถูก allocate ให้ local variable (รวมไปถึง parameter ด้วย) เมื่อถูกเรียกใช้ function เท่านั้น
- ตราบใดที่ function ยังทำงาน memory ก็จะยังถูก allocate อยู่
- แต่เมื่อออกจาก function ตัว local variable ของ function นั้นจะถูก deallocate ทันที
Passing Variables by value
จากcode ข้างบนเมื่อเรา เรียก MyLocalFunction() myInt จะเป็นตัว copy ค่าจาก parameter ไปใช้ใน local variable ซึ่งหมายความว่าค่าที่เปลี่ยนไปของ local copy จะไม่เปลี่ยนค่าของตัวที่ให้ copy (caller)
ข้อเสียของสิ่งนี้ก็คือ
- การส่งค่า paramaters โดย value หมายถึงการสร้าง copy ซึ่งใช้ memory แน่นอน
- เมื่อสร้างตัว copy จะไม่สามารถใช้สื่อสาร(หมายถึงใช้ส่งค่าไปมา)กับ caller ได้
มาดูตัวอย่างกัน
เริ่มแรกค่าของตัวแปร val มีค่าเป็น 0 เราเลยจะใส่ค่า val (caller) ไปใน function AddTwo เพื่อเพิ่มค่าให้ val(caller) ไปอีก 2 แต่ว่าค่า val ที่เพิ่มไปคือค่าของตัว copy อีกทีนึง ซึ่งไม่มีผลต่อค่า val ที่ถูกส่งไป (caller) ดังนั้น output ที่เราอยากได้คือ 2 แทนที่จะเป็น 4
Passing Variables by reference
ซึ่งวิธีแก้ด้านบนก็ไม่ยาก ซึ่งเราทำได้สองวิธีคือ
1. Pass By Pointer
เราสามารถทำได้โดยการส่งค่าให้ function รับค่า pointer ไป และส่งค่า reference แทน ซึ่งสิ่งที่ val(copy) จะได้รับจะไม่ใช่ค่า value แต่จะเป็น address ของตัวแปร caller ตัวนั้น
2. Pass by reference
ส่งค่าให้ function รับค่า address ของตัวแปรที่ถูกส่งมา ซึ่งการทำงานคล้ายแบบแรก
ในเมื่อใช้ได้สองแบบ เราจะใช้แบบไหนดีละ
ผมสรุปความต่างได้ดังนี้ครับ
- Pointer สามารถประกาศได้ โดยไม่ต้องกำหนดค่าเริ่มต้น หมายความว่าเราสามารถส่ง pointer เปล่าให้ function ได้ และให้ function จัดการเรื่องค่าให้เราเอง
- Pointer สามารถถูกเปลี่ยนไปใช้ บน heap ได้
- Reference ในหลายๆครั้งใช้งานได้ง่ายกว่า
เปรียบเทียบ Call by value, Call by pointer และ Call by reference
โค้ดข้างบนเราจะวัดกันไปเลยครับ ว่าแบบไหนใช้ memory เยอะหรือน้อยกว่ากัน
ซึ่ง output ที่เราได้ก็คือ
stack bottom: 0x7ffeefbff698
call-by-value: 0x7ffeefbff678
call-by-pointer: 0x7ffeefbff674
call-by-reference: 0x7ffeefbff674
อ้าว ทำไม pointer กับ reference ใช้ memory เยอะกว่าละ ไหนบอกว่า call by value ต้องสร้างตัว copy เปลือง memory ไง
คำตอบก็คือเพราะว่าค่าของขนาด pointer หรือ address ในคอมแต่ละเครื่องอาจจะไม่เท่ากัน เช่น computer 32 bit *int ขนาดเท่า int ปกติเลย แต่ 64 bit ใช้ตั้ง 8 byte (2 เท่าของ int ปกติแหนะ )
โดยการที่เราจะใช้ประโยชร์จาก call by reference เนี่ยเราจะต้องส่งขนาดของ pointer ให้ function ด้วยนั่นเอง
จบกันไปแล้วนะครับสำหรับ Part 2 ตอนต่อไปจะเป็นพวกเกี่ยวกับ Heap ครับ ซึ่งยาวแน่นอนเพราะว่า เราต้องจัดการมันเอง
ถ้าชอบบทความนี้ยังไงก็ฝากแชร์ กดติดตามด้วยนะครับ