Stack กับ Heap มันคืออะไรกันนะ ?

เราน่าจะเคยได้ยินคุ้นหูกันมาบ้างกับคำว่า stack กับ heap หรือคำเหล่านี้

  1. Stack Overflow
  2. Pass by Value
  3. Pass by Reference
  4. Pointer
  5. Memory Leak
  6. Garbage Collection
  7. Segmentation Fault
  8. Dangling Pointer
  9. Use After Free

เราจะมาดูกันว่า stack, heap, และคำที่คุ้นหูพวกนี้มันคืออะไร


ก่อนอื่น ต้องบอกก่อนว่า บทความนี้เขียนมาจากความจำล้วน ๆ อาจจะมีบางส่วนที่ผิด (หรือผิดหมดเลย 😳) อย่าเชื่อและไปหาข้อมูลเพิ่มเติมเอานะครับ 🤪

เริ่มจากเรามาทำความรู้จักกับ Stack กันก่อน
Stack เป็น data type ตัวนึง ที่เอาไว้เก็บสิ่งของคล้าย ๆ กับ array แต่เราสามารถทำได้แค่ 2 อย่าง คือ ใส่ (push) กับ หยิบออก (pop) โดยที่สิ่งที่เราใส่เข้าไปอันสุดท้าย จะต้องถูกหยิบออกมาก่อนเป็นอันแรก (LIFO; Last In First Out)

https://upload.wikimedia.org/wikipedia/commons/b/b4/Lifo_stack.png

ทีนี้ในโปรแกรมคอมพิวเตอร์จะมีสิ่งที่เรียกว่า Call Stack อยู่
เวลาที่ function ถูกเรียก คนที่เรียกจะต้อง push parameter ของ function ลงไปใน stack ก่อน แล้วจึงกระโดดเข้าไปในทำงานใน function ที่เรียก

เช่น ถ้าเรามี function หน้าตาแบบนี้

int add(int a, int b) {
int c = a + b;
return c;
}

แล้วเราเรียก function add นี้ใน function main เช่น add(4, 5); 
สิ่งที่จะเกิดขึ้นคือ function main จะ
1. push ค่าเปล่า ๆ เอาไว้เป็นค่า return เข้าไปใน call stack
2. push ค่า a เข้าไปใน call stack
3. push ค่า b เข้าไปใน call stack
ตอนนี้ call stack เราจะหน้าตาประมาณนี้ (ให้ดูว่ามันคือ stack ที่กลับด้าน, ข้างบนคือก้น stack)

ทีนี้ใน function add เราประกาศตัวแปร 1 ตัว ชื่อว่า c แล้วให้ c = a + b เราจะได้ stack หน้าตาแบบนี้

หลังจากนั้นเราก็ return c ออกไป ค่าใน c ก็จะถูก copy ไปแทนที่ตำแหน่งที่จะ return แล้ว stack ของ function add จะถูก pop ออก จะได้ stack เป็นแบบนี้

ตัวโปรแกรมจะกระโดดกลับไปทำงานใน function main ต่อจากที่เราเรียก add(4, 5);

ถ้าเราเรียก function ซ้อน ๆ กัน มันก็จะทำแบบนี้ไปเรื่อย ๆ

https://upload.wikimedia.org/wikipedia/commons/8/8a/ProgramCallStack2_en.png

เราจะเห็นว่าตัวแปร c ถูกสร้างไว้ใน stack เมื่อ function return ตัวแปรใน function ที่อยู่ใน stack จะถูก pop ออกไป ทำให้ไม่มีตัวแปรค้างไว้ในหน่วยความจำ

เนื่องจากว่า stack ไม่สามารถขยายได้เรื่อย ๆ มันจะมีขนาดจำกัดอยู่ ถ้าเราเรียก function ซ้อนกันเยอะมาก ๆ จน stack เต็ม ทำให้ function ใหม่ ไม่สามารถถูกเรียกได้ จะเกิด error ที่เรียกว่า stack overflow ขึ้น

ส่วนใหญ่ stack overflow จะเกิดจาก recursive function ที่เรียกตัวเองหลาย ๆ รอบจน stack เต็ม

ทีนี้ถ้าเราเรียก function add แบบนี้ ใน function main

int main() {
int x = 3;
int y = 4;
add(x, y);
return 0;
}

สิ่งที่จะเกิดขึ้นคือ function main จะ copy ค่า x และ y ลงไปใน stack เราเรียกวิธีส่งค่าแบบนี้ว่า Pass by Value


คราวนี้ถ้าเราจะส่งตัวแปรให้ function อื่นแก้ค่า เราก็สามารถส่งเป็น pointer ไปได้

pointer คือตัวแปรที่เก็บตำแหน่ง (address) ในหน่วยความจำของตัวแปรตัวอื่น

int main() {
int a = 10;
int *ptr = &a;
printf("%p %d\n", ptr, *ptr);
return 0;
}

ถ้าเราลองรันดูจะได้ผลลัพท์ประมาณนี้

0x7ffee22d468c 10

ใน call stack เราจะหน้าตาประมาณนี้

คำสั่ง &a จะได้ตำแหน่งในหน่วยความจำของตัวแปร a ในที่นี้คือ 0x7ffee22d468c ซึ่งคำสั่ง *ptr หมายถึงให้เอาค่าจากหน่วยความจำที่เก็บอยู่ในตัวแปร ptr ออกมา ซึ่งก็คือค่า a

เราสามารถแก้ค่า a ผ่านตัวแปร ptr ได้

int main() {
int a = 10;
int *ptr = &a;
*ptr = 20;
printf("%d\n", a);
return 0;
}

เราจะได้ผลลัพท์เป็น 20

ปัญหาอย่างนึงของการใช้ pointer คือการที่ pointer ชี้ไปที่หน่วยความจำที่อยู่นอกโปรแกรมของเรา แล้วเราพยายามไปแก้ค่ามันจะเกิด Segmentation Fault เช่น code นี้

#include <stdio.h>
int main() {
int *ptr = 0;
*ptr = 10;
return 0;
}

พอรันแล้วจะได้

Segmentation fault: 11

คราวนี้ถ้าเราจะสร้าง function ที่สามารถแก้ค่าตัวแปรได้ เราก็สามารถรับตัวแปรเป็น pointer ได้

#include <stdio.h>
void mul2(int *x) {
*x = *x * 2;
}
int main() {
int a = 10;
mul2(&a);
printf("%d\n", a);
return 0;
}

จะได้ผลลัพท์เป็น 20

นอกจากส่งตัวแปรแบบ pointer แล้ว เรายังสามารถส่งตัวแปรแบบ Pass by Reference ได้อีกด้วย

#include <stdio.h>
void mul2(int &x) {
x = x * 2;
}
int main() {
int a = 10;
mul2(a);
printf("%d\n", a);
return 0;
}

ได้ผลลัพท์เป็น 20

สิ่งที่แตกต่างระหว่าง pointer กับ reference คือ pointer จะสร้างตัวแปรใหม่มาเก็บ address ของอีกตัวแปร แต่ reference จะไม่ใช่การสร้างตัวแปรใหม่ แต่เป็น alias ของตัวแปรเดิม

เราจะเห็นว่าถ้าเป็น pointer &a จะไม่เท่ากับ &x แต่ถ้าเป็น reference &a กับ &x จะเป็น address เดียวกัน


เราลองมาดู code นี้กัน ว่าเกิดอะไรขึ้น…

#include <stdio.h>
struct point {
int x;
int y;
};
point * create_point(int x, int y) {
point p;
p.x = x;
p.y = y;
return &p;
}
int main() {
point *p1 = create_point(1, 2);
point *p2 = create_point(6, 7);
printf("p1: %p (%d, %d)\n", p1, p1->x, p1->y);
printf("p2: %p (%d, %d)\n", p2, p2->x, p2->y);
printf("p1: %p (%d, %d)\n", p1, p1->x, p1->y);
return 0;
}

ลองทายดูว่าถ้ารันแล้วจะได้ผลลัพท์เป็นยังไง ?

p1: 0x7ffee5b92650 (6, 7)
p2: 0x7ffee5b92650 (0, 0)
p1: 0x7ffee5b92650 (0, 0)

จะเห็นว่าใน function create_point เราสร้าง point เป็นตัวแปรใน stack แล้วเรา return address นั้นออกมา แต่พอจบ function แล้ว ค่า p ที่สร้างจะถูก pop ออก พอเรารัน create_point อีกรอบนึง มันไปสร้าง point ที่ตำแหน่งเดียวกันกับที่สร้างตอนแรก ทำให้ค่าที่ p1 กับ p2 นั้นชี้ไปที่เดียวกัน
พอเรารัน printf ครั้งที่นึง เราดึงค่า x, y ออกมาจาก p1 ก่อน แล้วจึง call printf พอ printf ถูกรัน มันก็อาจจะไปสร้างตัวแปรทับที่ตำแหน่ง p1 ชี้อยู่ ทำให้พอ printf อีกครั้งได้เป็นค่าอื่น เราเรียกปัญหานี้ว่า Dangling pointer


แล้วถ้าเราอยาก return pointer ออกมาแบบนี้จะทำยังไงหล่ะ ?

เราก็สร้างตัวแปร p นอก stack ซะสิ

#include <stdio.h>
#include <stdlib.h>
struct point {
int x;
int y;
};
point * create_point(int x, int y) {
point *p = (point *) malloc(sizeof(point));
p->x = x;
p->y = y;
return p;
}
int main() {
point *p1 = create_point(1, 2);
point *p2 = create_point(6, 7);
printf("p1: %p (%d, %d)\n", p1, p1->x, p1->y);
printf("p2: %p (%d, %d)\n", p2, p2->x, p2->y);
printf("p1: %p (%d, %d)\n", p1, p1->x, p1->y);
return 0;
}

ได้ผลลัพท์เป็น

p1: 0x7fb627c00340 (1, 2)
p2: 0x7fb627c00350 (6, 7)
p1: 0x7fb627c00340 (1, 2)

วิธีนี้คือการสร้างตัวแปรไว้ใน Heap

ข้อควรระวังคือ ถ้าเราสร้างตัวแปรไว้ใน heap แล้ว เราจะต้องลบค่านั้นออกด้วยเมื่อไม่ได้ใช้ ไม่อย่างนั้นจะเกิด Memory Leak คือเราไปสร้างตัวแปรไว้ใน heap แต่เราไม่ได้ลบมันออก อาจจะทำให้เครื่องคอมพิวเตอร์หน่วยความจำเต็มและ hang ได้

โดยที่เราสามารถคืนหน่วยความจำได้ด้วยคำสั่ง free

int main() {
point *p1 = create_point(1, 2);
printf("p1: %p (%d, %d)\n", p1, p1->x, p1->y);
free(p1);
point *p2 = create_point(6, 7);
printf("p2: %p (%d, %d)\n", p2, p2->x, p2->y);
printf("p1: %p (%d, %d)\n", p1, p1->x, p1->y);
}

ลองทายดูว่า code ข้างบนนี้จะได้ผลลัพท์อะไรออกมา

p1: 0x7f8f90400340 (1, 2)
p2: 0x7f8f90400340 (6, 7)
p1: 0x7f8f90400340 (6, 7)

จะเห็นว่าเราคืนหน่วยความจำของตัวแปร p1 ไปแล้ว แต่เรากลับมาใช้ p1 ต่อ ซึ่งหน่วยความจำตรงนั้นถูกใช้ใน p2 แล้ว เราเรียกปัญหานี้ว่า Use After Free คือการที่เราไปใช้ตัวแปรที่ free ไปแล้ว


เนื่องจากว่าถ้าเราต้องมา manage memory เองนั้น จะทำให้มีโอกาสเกิด memory leak และ use after free มาก ภาษาใหม่ ๆ หลาย ๆ ภาษาจึงพัฒนาสิ่งที่เรียนว่า Garbage Collector (GC) ขึ้นมา มาทำหน้าที่ค่อย free ตัวแปรใน heap ที่เราไม่ได้ใช้แล้วให้

โดยที่ garbage collector (GC) มีหลักการแบบง่าย ๆ ว่า data ที่อยู่ใน heap ตัวไหนไม่มีตัวแปรไหนชี้อยู่ มันจะ free memory ตำแหน่งนั้นให้เลย

แสดงว่าถ้าเราไปสร้างตัวแปรใน heap เยอะ ๆ จะทำให้ GC ทำงานหนัก เวลาที่ GC ทำงาน โปรแกรมของเราจะหยุดทำงาน (ค้าง) จนกระทั่ง GC ทำงานเสร็จ โปรแกรมเราจึงจะกลับมาทำงานต่อ เราเรียกสิ่งนี้ว่า GC Pause


ดังนั้น เวลาที่เราจะสร้างตัวแปร ต้องดูว่าเราจะสร้างใน stack หรือใน heap

ถ้าตัวแปรเราเล็ก copy เร็ว ก็สร้างใน stack

แต่ถ้าตัวแปรเราใหญ่ เราอาจจะไปสร้างใน heap แทน


บอกอีกครั้งว่าบทความนี้เขียนมาจากความจำล้วน ๆ มีโอกาสผิดสูงนะครับ 😦

Like what you read? Give acoshift a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.