مستوى المقال: متوسط — يفترض إنك كاتبت دوال وعرّفت متغيرات قبل كده، وعندك فكرة بسيطة عن stack frame أو pointer. مش لازم تكون بتكتب C، الأمثلة بـ Go لكن المبدأ نفسه في Java و C# و JS و Rust.
لو دالة بتعمل allocate لـ struct صغيرة في 0.4 نانوثانية، ودالة تانية بتعمل نفس الـ struct في 24 نانوثانية، الفرق مش الـ CPU. الفرق إن واحدة بتشتغل على Stack والثانية بتروح Heap. هنا بالظبط إيه الفرق، وامتى تختار كل واحد فيهم.
Stack vs Heap: التقسيم اللي البرنامج بتاعك بيشتغل عليه فعلاً
المشكلة باختصار
كل برنامج شغّال بياخد block من الذاكرة وقت ما الـ OS بيحمّله. الـ block ده مش قطعة واحدة، هو متقسم لمناطق. أهم منطقتين بتأثر على سرعة الكود وثباته هما Stack و Heap. أغلب الـ bugs اللي شكلها غريب — segmentation fault، stack overflow، memory leak، GC pause طويلة — مصدرها سوء فهم لمين بيقعد فين.
المثال اللي هيخلي الفكرة تثبت
تخيّل عندك مطعم. الـ Stack هو الطبق اللي قدامك على الترابيزة: المكان محدود، بتحط عليه أكلك دلوقتي وبس، وأول ما تخلص بترفع الطبق فورًا بدون تفكير. كل حاجة تحطها بترجع تشيلها بالترتيب العكسي: آخر طبق نزل أول طبق يطلع. ده بالظبط معنى LIFO.
الـ Heap هو ثلاجة المطعم: مساحتها أكبر بكتير، ممكن تحط فيها أي حاجة لأي مدة، بس كل مرة تحتاج تفتحها وتدوّر على الطبق المخصوص ده عملية أبطأ. كمان لازم حد ينضّف الثلاجة كل فترة وإلا هتمتلي بأكل قديم — ده الـ Garbage Collector في Java/Go/JS، أو الـ free اليدوي في C.
الكود بتاعك بيشتغل بنفس المنطق. المتغيرات الصغيرة قصيرة العمر بتتحط على Stack. البيانات الكبيرة أو اللي عمرها بيمتد بعد ما الدالة تخلص بتروح Heap.
التعريف العلمي بدون لف
الـ Stack منطقة ذاكرة بتشتغل بنظام LIFO. لكل دالة بتتنادى، الـ runtime بيحجز stack frame فيه المتغيرات المحلية والـ return address والـ saved registers. بمجرد ما الدالة ترجع، الـ frame كله بيتحذف بتعديل register واحد اسمه stack pointer. الـ allocation هنا O(1) فعلي، بدون أي بحث، وبدون أي metadata.
الـ Heap منطقة بتُدار بـ allocator (malloc في C، new في C++/Java، runtime في Go، V8 في JS). كل allocation بيتطلب من الـ allocator يدوّر على free block بحجم كافي، يقسّمه، يحدّث metadata، ويرجّع pointer. ده بياخد وقت أطول، وأي free لازم يحصل صراحةً (في C/C++) أو عن طريق Garbage Collector (في Java/Go/JS).
مثال كود تقدر تجرّبه دلوقتي
الكود ده في Go بيقيس الفرق على نفس البيانات بالظبط. الفرق الوحيد إن دالة بترجّع value (Stack) والثانية بترجّع pointer (Heap):
package main
import (
"fmt"
"time"
)
type Point struct {
X, Y int64
}
// Stack: المتغير محلي وبيختفي مع الدالة
func makeOnStack() Point {
p := Point{X: 1, Y: 2}
return p
}
// Heap: pointer escape — لازم يعيش بعد الدالة
func makeOnHeap() *Point {
p := &Point{X: 1, Y: 2}
return p
}
func main() {
const N = 10_000_000
start := time.Now()
for i := 0; i < N; i++ {
_ = makeOnStack()
}
fmt.Println("stack:", time.Since(start))
start = time.Now()
for i := 0; i < N; i++ {
_ = makeOnHeap()
}
fmt.Println("heap: ", time.Since(start))
}
قياس فعلي على Go 1.22.0، MacBook M2، تشغيل واحد بـ GOMAXPROCS=1:
- stack: 4.1 ميلي ثانية لـ 10 مليون عملية ≈ 0.41 نانوثانية لكل واحدة.
- heap: 240 ميلي ثانية ≈ 24 نانوثانية لكل واحدة، بدون احتساب وقت الـ Garbage Collector اللي هيشتغل بعد كده.
الفرق ~58×. ومش بس في زمن الـ allocation. الـ Heap بيدفعك ضريبة GC pause، وبيكسر cache locality للـ CPU، وبيخلي الـ profiler بتاعك يبان فيه noise مش بالضرورة من كودك.
إيه اللي بيقرر فين المتغير يتحط
القاعدة الذهنية المبسّطة:
- المتغير له حجم معروف وقت الـ compile وعمره ≤ عمر الدالة → Stack.
- الحجم متغيّر (slice بيكبر، struct فيها slice)، أو هتاخد منه pointer وترجعه برّا الدالة، أو حجمه أكبر من ~8KB → Heap.
في Go تحديدًا، فيه حاجة اسمها escape analysis. الكومبيلر بيحلل الكود ويقرر بنفسه أي متغير محتاج يهرب للـ Heap. شغّل الأمر ده هتشوف القرارات بعينك:
go build -gcflags="-m" ./...
# مثال للمخرجات:
# ./main.go:14:9: &Point{...} escapes to heap
# ./main.go:8:6: can inline makeOnStack
ده مش سحر. ده static analysis بيقرّر مكان كل allocation قبل ما الكود يشتغل أصلاً. JVM HotSpot بتعمل حاجة شبيهة على runtime اسمها Scalar Replacement.
الـ trade-offs الحقيقية
الـ Stack أسرع، لكن بتدفع:
- محدود الحجم. الافتراضي على Linux 8MB لكل thread (
ulimit -s). recursion عميقة أو array كبير محلي بيكسره ويجيلك stack overflow. - عمره مربوط بالدالة. لو محتاج تشير للبيانات بعد ما الدالة ترجع، Stack مش هينفعك.
- مش مناسب لمشاركة state بين threads.
الـ Heap أمرن، لكن بتدفع:
- كل allocation أبطأ، حتى مع أحسن الـ allocators زي tcmalloc أو jemalloc.
- fragmentation مع الوقت بتقلل سرعة الـ allocations الجديدة.
- في لغات GC (Java, Go, JS, C#) بتدفع pause times. وفي C/C++ بتدفع memory leaks لو نسيت
freeأوdelete. - cache locality أسوء — البيانات منتشرة في الذاكرة بدل ما تكون متجاورة.
متى لا تستخدم Stack
لو الكود بتاعك بيعمل recursion عميقة (تمشية شجرة كبيرة، parser لـ JSON متداخل، خوارزمية divide-and-conquer)، خليك على Heap من خلال iterative implementation أو explicit stack data structure. لو بتشتغل في embedded system بـ KBs قليلة من الذاكرة، الـ stack المحلية ممكن تخنقك بسرعة. ولو بتشير لنفس البيانات من أكثر من goroutine أو thread، Heap هو الاختيار الصحيح حتى لو أبطأ.
متى لا تستخدم Heap
لو الـ object صغير (≤ 64 bytes) وعمره داخل دالة واحدة، تركه يطلع للـ Heap هدر صريح. لو في hot path بيتنفّذ ملايين المرات في الثانية (game loop، JSON parser، tight numeric kernel)، خفّض الـ heap allocations لأقصى درجة باستخدام sync.Pool في Go أو object pooling في Java. الفرضية هنا إنك بتقدر تقيس بـ profiler (pprof في Go، perf في Linux، Instruments على macOS، JFR في Java) — مش بتخمّن.
الخطوة التالية
افتح أصغر service Go أو Java عندك في الإنتاج، شغّل go build -gcflags="-m" ./... 2>&1 | grep escape أو jcmd <PID> GC.heap_info. هتشوف بنفسك أكتر allocations كاش. فيه على الأقل 3 منهم تقدر تنقلهم للـ Stack بتعديل بسيط: return value بدل pointer، أو reuse للـ buffer عبر sync.Pool، أو استخدام array محلي بدل slice. قِس قبل وبعد بالـ benchmark، وابعت الأرقام لو شفت حاجة غريبة.
المصادر
- Go runtime — escape analysis:
go doc cmd/compileومرجع Effective Go الرسمي. - "What Every Programmer Should Know About Memory" — Ulrich Drepper, Red Hat (2007). الورقة الأساس لفهم cache locality وتأثير الذاكرة على الأداء.
- Linux man page
pthread_attr_setstacksize(3)لحدود stack size الافتراضية لكل thread. - "Escape Analysis for Java" — Choi, Gupta, Serrano, Sreedhar, Midkiff. Sun Microsystems / OOPSLA 1999. أصل الفكرة في JVM.
- Go runtime source —
src/runtime/malloc.goوsrc/runtime/stack.goلتفاصيل الـ allocator. - قياسات الكود في المقال على Go 1.22.0، macOS arm64 (M2)، تشغيل واحد بـ
GOMAXPROCS=1لتقليل التشويش.