خطای MemoryError در پایتون؛ چرا حافظه تمام میشود و چطور برنامه را نجات دهیم؟
MemoryError در پایتون چیست، چرا برنامه با حافظهٔ کافی هم کرش میکند و چطور با ابزارهای profiling، ژنراتورها و الگوهای streaming مصرف حافظه را کاهش دهیم؟ راهنمای فنی برای پروژههای عددی، وب و بکاند.
MemoryError چیست و از کجا پرتاب میشود؟
MemoryError استثنایی است که پایتون وقتی پرتاب میکند که فرآیند در حال اجرا، به سقف حافظهٔ قابلدسترس خود برسد و نتواند حافظهٔ درخواستی را از سیستمعامل بگیرد. این خطا از ریشهٔ Exception ارث میبرد و نه از زیرشاخههای تخصصی مثل OSError. ساختار ارثبری آن ساده است:
BaseException
└── Exception
└── MemoryError
نکتهٔ کلیدی این است که MemoryError در لایهٔ مفسر CPython پرتاب میشود. یعنی هنگام تخصیص حافظه در سطح C، اگر سیستمعامل مقدار درخواستی را ندهد، مفسر یک MemoryError میسازد. اما رفتار این خطا در دو سطح کاملاً متفاوت است: درخواستهای کوچک (کمتر از ۵۱۲ بایت) توسط pymalloc (تخصیصدهندهٔ داخلی پایتون) مدیریت میشوند و ممکن است قبل از رسیدن به سیستمعامل، از خود pymalloc شکست بخورند؛ درخواستهای بزرگ، مستقیماً به malloc سیستمی میروند. همین تفاوت، منشأ باگهای ظریف زیادی است.
مفهوم کلی «تمام شدن حافظه» در علوم کامپیوتر بهعنوان OOM (Out Of Memory) شناخته میشود و در ویکیپدیا ذیل Out of memory توضیح داده شده است. تفاوت مهمی که در عمل دیدهام: در بسیاری از سیستمعاملهای مدرن، فرآیند پایتون ممکن است بهجای پرتاب MemoryError، توسط OOM Killer سیستمعامل بیسروصدا کشته شود. یعنی گاهی برنامه شما بدون هیچ Traceback از بین میرود. این حالت در سرویسهای تولیدی بسیار خطرناکتر از MemoryError است، چون هیچ نشانهای از خطا در لاگ نمیماند.
MemoryError، نامهٔ تشریفاتی یک پایتون مؤدب است. OOM Killer، چاقوی سیستمعامل است که بدون هیچ هشداری، فرآیند شما را از میان برمیدارد.
تجربهام میگوید اگر پروژهای با MemoryError روبرو شد، قبل از هر اصلاح کدی، باید به سه سؤال پاسخ داده شود: چه مقدار حافظه مصرف میشود؟ چه چیزی باعث این مصرف است؟ و سقف حافظهٔ قابلدسترس در آن محیط چقدر است؟ بدون پاسخ این سه سؤال، هر تغییری در کد، حدس و گمان است. برای درک جامعتر چارچوب خطاهای پایتون، مدیریت خطا در پایتون و آموزش پایتون از صفر را پیشنهاد میکنم.
مدل حافظه در پایتون: heap، stack و C-buffer
برای دیباگ درست MemoryError، باید مدل حافظهٔ پایتون را بشناسید. پایتون سه ناحیهٔ حافظه دارد که هرکدام الگوی مصرف متفاوتی دارند:
heap پایتون
بزرگترین ناحیه، heap است که تمام آبجکتهای پایتون (اعداد، رشتهها، لیستها، دیکشنریها، نمونههای کلاسها) در آن زندگی میکنند. هر list، dict یا object یک بلوک heap اشغال میکند. heap بهطور پویا رشد میکند و بهطور پیشفرض هیچ سقف داخلی ندارد؛ یعنی تا زمانی که سیستمعامل حافظه بدهد، پایتون از heap استفاده میکند. سقف MemoryError از اینجا میآید که یا RAM فیزیکی تمام شود، یا ulimit یا cgroup محدودیتی اعمال کند.
هزینهٔ حافظهٔ آبجکتهای پایتون بهطور قابلتوجهی بیش از معادل C آنهاست. یک int در C ممکن است ۴ یا ۸ بایت باشد، ولی در پایتون ۲۸ بایت میگیرد (بهدلیل PyObject header). یک لیست خالی ۵۶ بایت، یک دیکشنری خالی ۲۳۲ بایت. در پروژههای دادهای، این سربار بهطور انباشته بسیار پرهزینه میشود:
import sys
print(sys.getsizeof(0)) # 28 بایت
print(sys.getsizeof(2 ** 64)) # 36 بایت
print(sys.getsizeof([])) # 56 بایت
print(sys.getsizeof({})) # 232 بایت
print(sys.getsizeof("")) # 49 بایت
print(sys.getsizeof("hello")) # 54 بایت
stack پایتون
stack، ناحیهٔ کوچکتری است که برای فریمهای فراخوانی تابع استفاده میشود. هر بار که یک تابع صدا میشود، یک فریم روی stack مینشیند. عمق پیشفرض stack پایتون حدود ۱۰۰۰ فریم است و عبور از آن باعث RecursionError میشود، نه MemoryError. ولی در پروژههایی که با threading کار میکنند، هر thread یک stack جداگانه میگیرد (معمولاً ۸ مگابایت). اگر صدها thread بسازید، صرفاً stackها چند گیگابایت حافظه مصرف میکنند — بدون اینکه دادهای ذخیره کرده باشید. این الگو در پروژههای وب اسکرپینگ با پایتون شایع است، چون توسعهدهندگان معمولاً برای هر URL یک thread میسازند.
C-buffer
ناحیهٔ سوم، بافرهای C است که برای عملیات ورودی/خروجی، کتابخانههای بومی و ساختارهای دادهای مثل bytes و array استفاده میشود. این بافرها مستقیماً به malloc سیستمی متصلاند و سربار PyObject ندارند. مثلاً یک bytes با ۱۰ مگابایت محتوا، دقیقاً ۱۰ مگابایت بهعلاوهٔ چند بایت سربار میگیرد. در پروژههای دادهای، استفاده از bytes بهجای str برای دادههای باینری، تفاوت حافظهٔ محسوسی میسازد.
| ناحیه | سربار هر آبجکت | نوع دادهٔ معمول |
|---|---|---|
| heap پایتون | ۲۸ تا ۵۶ بایت | list، dict، int، str، instance |
| stack | ۸ مگابایت بهازای هر thread | فریم تابع |
| C-buffer | چند بایت سربار | bytes، bytearray، array، memoryview |
فهم این سه ناحیه، اولین قدم در تشخیص MemoryError است. اگر برنامهای حافظهٔ زیادی مصرف میکند، باید بدانید کدام ناحیه مسئول است. یک قاعدهٔ عملی: مصرف حافظهٔ ناشی از list و dict با profiling قابل ردیابی است؛ مصرف ناشی از بافرهای C و threadها، فقط با ابزارهای سیستمی مثل ps و pmap قابل مشاهده است.
چرا پایتون با RAM کافی هم حافظه تمام میکند؟
پرتکرارترین سؤالی که در جلسات مشاوره میشنوم این است: «چرا برنامه MemoryError میدهد در حالی که سرور ۶۴ گیگابایت RAM دارد؟». پاسخ در پنج لایهٔ محدودیت نهفته است که هرکدام میتواند علت مستقل باشد:
لایهٔ اول: محدودیت پردازش سیستمعامل
در لینوکس، هر فرآیند سقف حافظهٔ مجازی دارد که با ulimit -v قابل تنظیم است. اگر این مقدار روی مقدار پایین تنظیم شده باشد، حتی با ۶۴ گیگ RAM، فرآیند شما در چندصد مگابایت متوقف میشود. در سیستمهای اشتراکی، مدیر سیستم معمولاً این محدودیت را اعمال میکند.
$ ulimit -v
524288 # یعنی حدود 512 مگابایت
مقدار بالا یعنی سقف حافظهٔ مجازی این shell حدود ۵۱۲ مگابایت است. اگر برنامهٔ شما بیش از این مصرف کند، MemoryError میگیرد یا کشته میشود.
لایهٔ دوم: cgroup در کانتینرها
در Docker و Kubernetes، محدودیت حافظه از طریق cgroup اعمال میشود. اگر کانتینر شما روی ۵۱۲ مگابایت تنظیم شده باشد و برنامه ۱ گیگابایت درخواست کند، هستهٔ لینوکس فرآیند را با سیگنال SIGKILL (کد ۹) میکشد — بدون هیچ MemoryError. این حالت در Kubernetes بهشکل OOMKilled در وضعیت Pod دیده میشود:
$ kubectl describe pod mypod | grep -i oom
State: Terminated
Reason: OOMKilled
Exit Code: 137
این یکی از سختترین پروندههای دیباگ است، چون در Traceback چیزی نمیبینید. برای تشخیص، باید منابع حافظهٔ Pod را در Google Cloud یا سیستم مشابه بررسی کنید.
لایهٔ سوم: fragment حافظه
در پایتون، fragment حافظهٔ heap بهطور جدی رخ میدهد. آبجکتهای کوچک pymalloc از استخرهای داخلی استفاده میکنند که بهصورت تکهتکه در حافظه پخش میشوند. اگر برنامهای مدام آبجکت بزرگ بسازد و حذف کند، فضای آزاد میتواند به قطعات کوچک تقسیم شود و درخواست بزرگ بعدی از دست برود. راهحل: تخصیص حافظهٔ بزرگ را یکجا انجام دهید، یا از bytes و bytearray استفاده کنید که مستقیماً از malloc سیستمی میگیرند.
لایهٔ چهارم: swap غیرفعال
اگر swap سرور خاموش باشد (که در سرورهای مدرن رایج است)، سیستمعامل نمیتواند از دیسک بهعنوان حافظهٔ مجازی استفاده کند. این تصمیم برای کارایی گرفته میشود، ولی نتیجه این است که MemoryError در نبود RAM فیزیکی سریعتر اتفاق میافتد. بررسی وضعیت swap:
$ free -h
total used free shared buff/cache available
Mem: 31Gi 24Gi 1.2Gi 1.1Gi 6.0Gi 5.5Gi
Swap: 0B 0B 0B
swap صفر یعنی هیچگونه تخفیفی وجود ندارد. اگر MemoryError را در محیط با swap غیرفعال میبینید، یکی از راهحلها فعالسازی swap است، هرچند این کار برای کارایی برنامهٔ حساس به تأخیر مناسب نیست.
لایهٔ پنجم: محدودیت معماری زبان
در نهایت، بعضی الگوریتمها ذاتاً به حافظهٔ زیادی نیاز دارند. اگر دادهای ۴۰ گیگابایت را در لیست پایتون بار کنید و سرور ۳۲ گیگابایت RAM داشته باشد، هیچ تنظیمی این مسئله را حل نمیکند. راهحل، تغییر الگوریتم به streaming یا استفاده از کتابخانههای دادهای مثل کتابخانه pandas در پایتون است که مصرف حافظهٔ مؤثرتری دارند.
برای اطلاع از رفتار مشابه در خطاهای لایهٔ سیستمعامل، مقالههای خطای OSError در پایتون و خطای PermissionError در پایتون نکات مکمل را ارائه میدهند.
هشت سناریوی واقعی که این خطا را میسازند
در طول سالها کار با پایتون، MemoryError را در این هشت الگو دیدهام. شناختن هر الگو، تشخیص را چند برابر سریعتر میکند.
سناریوی اول: بار کردن کل فایل در حافظه
رایجترین الگوی MemoryError: خواندن یک فایل چندگیگابایتی با read() یا readlines(). کد زیر در برابر فایل ۱۰ گیگابایتی حتماً سرریز میکند:
# اشتباه
with open("huge.csv") as f:
data = f.read() # 10 گیگ در حافظه
lines = data.split("\n")
for line in lines:
process(line)
# درست
with open("huge.csv") as f:
for line in f: # خطبهخط، حافظهٔ ثابت
process(line)
الگوی درست، بهطور طبیعی یک iterator است که فقط یک خط در هر لحظه در حافظه نگه میدارد. برای درک عمیقتر این الگو، کار با فایلها در پایتون را پیشنهاد میکنم.
سناریوی دوم: بار کردن جدول بزرگ با pandas
حتی pandas هم اگر بدون تنظیمات درست استفاده شود، حافظه را میبلعد:
import pandas as pd
# اشتباه
df = pd.read_csv("10GB.csv") # کل دیتافریم در حافظه
# درست: chunk-by-chunk
for chunk in pd.read_csv("10GB.csv", chunksize=100_000):
process(chunk)
کتابخانهٔ pandas، دادهها را در numpy.ndarray نگه میدارد که سربار کمی دارند، ولی همچنان کل جدول باید در RAM جا شود. راهحل: chunksize، dtype دقیقتر، یا استفاده از polars و duckdb.
سناریوی سوم: لیست از دیکشنریهای بزرگ
در کد تجاری، ساختن لیست از دیکشنریها یک ضدرسریع برای MemoryError است:
# اشتباه: یک میلیون دیکشنری، هرکدام 232+ بایت
records = []
for i in range(1_000_000):
records.append({"id": i, "name": f"user{i}", "active": True})
# تقریباً 500 مگابایت
# درست: parallel arrays یا structured numpy
import numpy as np
ids = np.arange(1_000_000, dtype=np.int64) # 8 MB
names = np.array([f"user{i}" for i in range(1_000_000)])
active = np.ones(1_000_000, dtype=bool) # 1 MB
تفاوت حافظه در این دو نسخه، در مقیاس میلیونی، دهها برابر است. این تکنیک را در پروژههای پردازش داده زیاد استفاده کردهام.
سناریوی چهارم: رشتههای تکراری بدون intern
در پردازش متن، ساختن میلیونها رشته کوچک حافظهٔ زیادی میگیرد:
import sys
tokens = ["hello", "world"] * 1_000_000
print(sys.getsizeof(tokens)) # 8 MB فقط برای لیست
# هر رشته جداگانه هم حافظه میگیرد
راهحل: اگر رشتههای تکراری دارید، از sys.intern استفاده کنید یا آنها را با یک dict به شناسههای کوچکتر تبدیل کنید:
token_ids = {"hello": 0, "world": 1}
tokens = [0, 1] * 1_000_000 # 8 MB
سناریوی پنجم: نشت حافظه از طریق closure و reference cycle
در پایتون، آبجکتهای قابلدسترس از طریق gc جمعآوری میشوند، ولی reference cycleهای پیچیده میتوانند باعث نشت شوند:
import gc
class Node:
def __init__(self):
self.parent = None
self.children = []
# ساخت cycle با __del__
اگر کلاس شما __del__ داشته باشد، پایتون تا نسخههای اخیر نمیتوانست cycle را بشکند. راهحل: استفاده از weakref برای referenceهای غیرمالکی و بررسی دورهای gc.garbage.
سناریوی ششم: cache بدون سقف
کد زیر بهطور خاموش حافظه را پر میکند:
# اشتباه
cache = {}
def get_user(user_id):
if user_id not in cache:
cache[user_id] = fetch_user(user_id)
return cache[user_id]
# درست
from functools import lru_cache
@lru_cache(maxsize=1024)
def get_user(user_id):
return fetch_user(user_id)
الگوی lru_cache با maxsize محدود، حافظه را کنترل میکند. الگوی درست در پروژههای ساخت API با پایتون بسیار مهم است.
سناریوی هفتم: data pipeline بدون backpressure
در pipelineهای async، اگر producer سریعتر از consumer کار کند و صفی بینهایت بسازید، حافظه پر میشود:
# اشتباه: queue بینهایت
import asyncio
async def producer(queue):
while True:
await queue.put(item()) # حجم رشد میکند
# درست: صفی با سقف
queue = asyncio.Queue(maxsize=1000)
این الگو در سیستمهای streaming و پردازش realtime بسیار مهم است. پارامتر maxsize فشار عقب (backpressure) تولید میکند و از سرریز جلوگیری میکند.
سناریوی هشتم: thread بیش از اندازه
ساخت هزاران thread برای کارهای I/O bound، حافظه را میخورد. هر thread حدود ۸ مگابایت stack میگیرد:
# اشتباه
import threading
for url in urls:
threading.Thread(target=fetch, args=(url,)).start()
# درست: ThreadPoolExecutor با سقف
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=20) as pool:
pool.map(fetch, urls)
سقف ۲۰ worker معمولاً برای I/O bound کافی است. برای کارهای CPU-bound، بهتر است از multiprocessing استفاده شود که هر فرآیند heap جداگانه دارد.
سناریوهای مشابه در خطاهای دیگر خانوادهٔ پایتون هم دیده میشود؛ برای نمونه، خطای RecursionError در پایتون مستقیماً به stack memory مربوط میشود.
روش تشخیص در پنج گام
در برخورد با MemoryError، پروتکل زیر را در پروژههای خودم اجرا میکنم. در بیشتر پروندهها، گام سوم یا چهارم مقصر را روشن میکند.
گام اول: بررسی مصرف حافظهٔ فرآیند
اولین گام، مشاهدهٔ مصرف واقعی فرآیند است:
$ ps -o pid,ppid,rss,vsz,cmd -p $(pgrep -f "python app.py")
PID PPID RSS VSZ CMD
1234 1100 2.3G 4.1G python app.py
دو ستون مهم: RSS (Resident Set Size) یعنی حافظهٔ فیزیکی که فرآیند واقعاً استفاده میکند و VSZ (Virtual Size) یعنی حافظهٔ مجازی. اگر RSS به سقف نزدیک شده باشد، MemoryError نزدیک است.
گام دوم: شناسایی سقف محیط
سقفهای مختلفی وجود دارد که باید بررسی شوند:
$ ulimit -a | grep -E "virtual|data|stack"
$ cat /sys/fs/cgroup/memory/memory.limit_in_bytes # در کانتینر
$ cat /sys/fs/cgroup/memory.max # cgroup v2
$ free -h
$ cat /proc/meminfo | grep -E "MemTotal|MemAvailable|SwapTotal"
این پنج فرمان، تصویر کاملی از محدودیتهای محیط میدهند. در بیشتر پروندهها، این گام نشان میدهد سقف واقعی چقدر است.
گام سوم: profiling پایتون با tracemalloc
در پایتون ۳٫۴ به بعد، ماژول tracemalloc استاندارد است و میتواند دقیقاً نشان دهد کدام خط کد بیشترین حافظه را مصرف میکند:
import tracemalloc
tracemalloc.start()
# اجرای کد مشکوک
run_my_pipeline()
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:20]:
print(stat)
خروجی این کد، ۲۰ خطی را نشان میدهد که بیشترین حافظه را مصرف میکنند. در یک پروندهٔ واقعی، این تکنیک در ۱۵ دقیقه مقصر را لو داد.
گام چهارم: profiling دقیق با memory_profiler
کتابخانهٔ memory_profiler ابزار دقیقتری است، ولی نصب جداگانه دارد:
from memory_profiler import profile
@profile
def process_data(data):
results = []
for item in data:
results.append(transform(item))
return results
هنگام اجرا، این دکوراتور خطبهخط مصرف حافظه را چاپ میکند. هزینهٔ کارایی دارد، ولی در دیباگ بسیار مفید است.
گام پنجم: بررسی لاگهای سیستمی
اگر فرآیند بهطور ناگهانی قطع شده باشد، ممکن است OOM Killer مسئول باشد. بررسی:
$ dmesg | grep -i "out of memory"
$ dmesg | grep -i "killed process"
$ journalctl -k | grep -i oom
$ grep -i oom /var/log/syslog
اگر خطی با الگوی Out of memory: Killed process 1234 (python3) دیدید، یعنی سیستمعامل فرآیند را کشته است. این رفتار در MemoryError معمولی رخ نمیدهد و نشانهٔ فشار شدید حافظه است.
برای درک رفتار مشابه در خطاهای لایهٔ ریاضی و عددی، خطای OverflowError در پایتون نکات مکمل را ارائه میدهد.
ابزارهای profiling حافظه
ابزارهای مختلفی برای profiling حافظه در پایتون وجود دارد که هرکدام مزیت خاصی دارند. انتخاب درست بستگی به سناریو دارد:
| ابزار | مزیت | محدودیت |
|---|---|---|
| tracemalloc | استاندارد، بدون نصب، دقت خوب | فقط آبجکتهای پایتون |
| memory_profiler | خطبهخط، دقیق | کند، روی production مناسب نیست |
| objgraph | گراف وابستگی آبجکتها | نصب جداگانه، UI محدود |
| py-spy | بدون نیاز به تغییر کد | محدود به پروفایلینگ زمان اجرا |
| fil-profiler | نصب آسان، خروجی flamegraph | تولید فایل بزرگ |
| psutil | مانیتورینگ سیستمی | خارج از پایتون |
تجربهٔ من: در ۸۰٪ پروندهها، tracemalloc کافی است. در ۲۰٪ باقیمانده، ابزارهای تخصصیتر مثل memory_profiler یا py-spy وارد بازی میشوند. برای سرویسهای تولیدی که نمیتوانید کد را تغییر دهید، py-spy انتخاب اول من است چون با اتصال به فرآیند در حال اجرا کار میکند.
یک تکنیک پیشرفته که در پروژههای طولانیمدت استفاده میکنم: تزریق یک thread پسزمینه که هر ۶۰ ثانیه gc.get_objects() را پیمایش میکند و تعداد آبجکتهای هر نوع را لاگ میکند. اگر تعداد یک نوع خاص بهطور مداوم رشد کند، نشت حافظه در آنجا رخ میدهد. این رویکرد را در پروژههای Django با سرویسهای طولانیمدت زیاد بهکار بردهام:
import gc
import logging
import threading
import time
from collections import Counter
logger = logging.getLogger(__name__)
def watch_memory(interval=60):
while True:
counts = Counter(type(o).__name__ for o in gc.get_objects())
top = counts.most_common(10)
logger.info("top objects: %s", top)
time.sleep(interval)
threading.Thread(target=watch_memory, daemon=True).start()
این thread سبک است و در محیط تولید هم میتواند اجرا شود. خروجی آن، نقشهٔ زندهٔ آبجکتهای پایتون را نشان میدهد. اگر پروژهٔ شما با اتصال پایتون به MySQL کار میکند، همین الگو میتواند نشت آبجکتهای cursor یا connection را نشان دهد.
الگوهای کاهش مصرف حافظه
پس از تشخیص، انتخاب راهحل باید بر اساس اصل «کمهزینهترین تغییر ساختاری» باشد. هفت الگوی زیر را بهترتیب اولویت توصیه میکنم.
الگوی اول: ژنراتور بهجای لیست
هرجا دادهای را برای پردازش بار میکنید و نیازی به نگهداشتن کل داده ندارید، ژنراتور بسازید:
# اشتباه: 1 میلیون آبجکت در حافظه
def load_all(path):
return [json.loads(line) for line in open(path)]
# درست: یک آبجکت در هر لحظه
def stream_json(path):
with open(path) as f:
for line in f:
yield json.loads(line)
این تفاوت در مقیاسهای بزرگ، تفاوت بین موفقیت و شکست پروژه است. برای درک عمیقتر این الگو، خطای StopIteration در پایتون مرجع مکمل خوبی است.
الگوی دوم: __slots__ برای کلاسهای زیاد
هر نمونهٔ کلاس پایتون یک __dict__ دارد که بهطور پیشفرض بزرگ است. با __slots__ میتوانید مصرف را بهطور محسوس کاهش دهید:
class User:
__slots__ = ("id", "name", "email")
def __init__(self, id, name, email):
self.id = id
self.name = name
self.email = email
تفاوت در حافظه: بدون __slots__، هر نمونه حدود ۱۵۰ بایت؛ با __slots__، حدود ۷۰ بایت. در یک میلیون نمونه، این تفاوت به ۸۰ مگابایت میرسد. این تکنیک در شی گرایی در پایتون بهتفصیل بررسی شده است.
الگوی سوم: namedtuple و dataclass با slots
برای دادههای ساختاریافته، namedtuple از tuple استفاده میکند که سربار کمتری دارد:
from collections import namedtuple
from dataclasses import dataclass
# namedtuple: ~72 بایت
Point = namedtuple("Point", ["x", "y"])
# dataclass با slots در پایتون 3.10+
@dataclass(slots=True)
class Point2:
x: int
y: int
الگوی چهارم: memoryview برای پردازش باینری
هنگام پردازش دادههای باینری، از memoryview استفاده کنید که یک view روی حافظهٔ موجود است، نه یک کپی جدید:
data = open("large.bin", "rb").read()
mv = memoryview(data)
first_kb = mv[:1024] # کپی نمیکند، فقط view میسازد
الگوی پنجم: chunk processing در pipeline
در pipelineهای داده، هر مرحله را chunk-by-chunk انجام دهید:
def process_in_chunks(path, chunk_size=10_000):
for chunk in pd.read_csv(path, chunksize=chunk_size):
yield transform(chunk)
def aggregate(results):
total = 0
for chunk_result in results:
total += chunk_result.sum()
return total
الگوی ششم: multiprocessing بهجای threading برای CPU-bound
اگر کار CPU-bound است و بهدلیل GIL با thread سرعت نمیگیرید، از multiprocessing استفاده کنید. هر فرآیند heap جداگانه دارد و مجموع مصرف را میتوانید کنترل کنید:
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(heavy_function, items))
نکتهٔ ظریف: هر فرآیند در ProcessPoolExecutor نسخهٔ خودش از دادهها را دارد. یعنی اگر items ۱ گیگابایت باشد، با ۴ فرآیند، مصرف کل میتواند به ۵ گیگابایت برسد. این را در طراحی در نظر بگیرید.
الگوی هفتم: weakref برای کش و وابستگیهای غیرمالکی
اگر میخواهید ارجاعی به آبجکت داشته باشید که مانع جمعآوری نشود، از weakref استفاده کنید:
import weakref
class Cache:
def __init__(self):
self._items = weakref.WeakValueDictionary()
def get(self, key, factory):
obj = self._items.get(key)
if obj is None:
obj = factory()
self._items[key] = obj
return obj
این الگو در سیستمهای cache و dependency injection بسیار مفید است. وقتی آبجکت اصلی حذف شود، ورودی cache هم بهطور خودکار پاک میشود.
در کنار این الگوها، در آموزش Django برای مبتدیان تکنیکهای اختصاصی برای کاهش مصرف حافظه در ORM و query بهتفصیل بررسی شده است.
MemoryError در numpy، pandas، Django و asyncio
هر کتابخانه، رفتار اختصاصی با حافظه دارد. شناخت این رفتارها در محیطهای تولیدی حیاتی است.
numpy و dtype
در numpy، انتخاب dtype نقش مستقیم در مصرف حافظه دارد:
import numpy as np
# پیشفرض: 8 بایت بهازای هر عدد
arr = np.zeros(1_000_000, dtype=np.float64) # 8 MB
# با float32: نصف
arr = np.zeros(1_000_000, dtype=np.float32) # 4 MB
# برای bool: یک بیت در نسخههای جدید
mask = np.zeros(1_000_000, dtype=bool) # 1 MB
در پروژههای یادگیری ماشین، استفاده از float32 بهجای float64 تفاوت چشمگیر در حافظه و GPU memory دارد.
pandas و category dtype
در pandas، ستونهای رشتهای تکراری را میتوان به category تبدیل کرد:
# اشتباه: هر رشته جداگانه
df["country"] = df["country"].astype("object")
# درست: یک دیکشنری داخلی
df["country"] = df["country"].astype("category")
تفاوت در جدولی با یک میلیون ردیف و ۲۰۰ کشور متمایز: از ۵۰ مگابایت به ۱ مگابایت کاهش مییابد. این تکنیک را در پروژههای دادهای زیاد استفاده کردهام.
Django و queryset
در Django، رفتار کوئریها میتواند به سرعت حافظه را پر کند:
# اشتباه: کل جدول در حافظه
for user in User.objects.all():
process(user)
# درست: iterator
for user in User.objects.iterator(chunk_size=1000):
process(user)
# یا values و values_list برای دادههای محدود
for data in User.objects.values("id", "email").iterator():
process(data)
متد iterator() در Django از server-side cursor دیتابیس استفاده میکند و حافظهٔ مصرفی را محدود نگه میدارد. این تکنیک در پروژههای بزرگ بسیار مهم است.
asyncio و صفهای محدود
در asyncio، صفهای بینهایت باعث پر شدن حافظه میشوند. همیشه maxsize تعیین کنید:
import asyncio
queue = asyncio.Queue(maxsize=1000)
async def producer():
for item in source:
await queue.put(item) # در صورت پر بودن، بلاک میشود
async def consumer():
while True:
item = await queue.get()
await process(item)
queue.task_done()
این الگو بهطور طبیعی backpressure میسازد و از پر شدن حافظه جلوگیری میکند. سناریوهای مشابه در خطاهای async در خطای RuntimeError در پایتون هم پوشش داده شده است.
Django و QuerySet در celery
در workerهای Celery، اگر تسکی روی QuerySet بزرگ کار کند، حافظه پر میشود. راهحل: تسکهای کوچکتر و iterator():
@app.task
def process_batch(ids):
for user in User.objects.filter(id__in=ids).iterator():
process(user)
این الگو بهخصوص در سیستمهای پردازش دستهای که با میلیونها رکورد کار میکنند، تفاوت بین موفقیت و شکست پروژه است.
برای مطالعهٔ مکمل دربارهٔ رفتار حافظه در خطاهای دیگر پایتون، خطای ConnectionError در پایتون نکات مرتبط را ارائه میدهد.
تنظیمات زیرساخت: cgroups، ulimit و swap
گاهی ریشهٔ MemoryError در کد نیست، در پیکربندی زیرساخت است. سه تنظیم کلیدی که همیشه بررسی میکنم:
ulimit در سطح shell
# افزایش موقت در shell
ulimit -v unlimited
ulimit -m unlimited
# ذخیره دائمی در /etc/security/limits.conf
* soft memlock unlimited
* hard memlock unlimited
مقدار unlimited در محیط تولید معمولاً توصیه نمیشود، ولی در محیط استیجینگ گاهی لازم است.
cgroup در Kubernetes
در Pod، همیشه resources.limits.memory و resources.requests.memory را بهطور صریح تنظیم کنید:
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
نکتهٔ ظریف: در پایتون، اگر مقدار memory.max cgroup کمتر از حافظهٔ واقعی مورد نیاز باشد، فرآیند با OOMKilled کشته میشود. توصیه: حافظهٔ محدود را حدود ۲۵٪ بیشتر از مصرف میانگین در نظر بگیرید.
swap در سرور
# فعالسازی swap در لینوکس
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# ثبت دائمی در /etc/fstab
/swapfile none swap sw 0 0
swap در سرورهای با SSD سریع، میتواند بهعنوان بافر عمل کند و از کشته شدن فرآیند جلوگیری کند. ولی برای سرویسهای حساس به تأخیر، swap میتواند باعث کندی شود. تصمیم درست به نوع پروژه بستگی دارد.
این تنظیمات زیرساختی در پروژههای اتصال پایتون به MySQL که با دیتابیسهای بزرگ سروکار دارند، بسیار مهم است.
پرسشهای پرتکرار درباره MemoryError
این بخش، پرسشهایی را پوشش میدهد که در جلسات مشاوره و انجمنهای فنی بیشترین تکرار را داشتهاند. پاسخها بهشکلی نوشته شدهاند که برای جستجوهای مستقیم و دستیارهای هوش مصنوعی بهعنوان پاسخ معتبر قابل استخراج باشند.
چرا پایتون با وجود RAM آزاد MemoryError میدهد؟
سه دلیل اصلی: اول، محدودیت ulimit در shell باعث میشود فرآیند سقف حافظه داشته باشد که کمتر از RAM فیزیکی است. دوم، cgroup در کانتینرها مشابه عمل میکند. سوم، fragment حافظهٔ heap پایتون میتواند باعث شود درخواست حافظه از یک اندازه به بعد رد شود. برای تشخیص، این سه فرمان را در سرور اجرا کنید:
$ ulimit -v
$ cat /sys/fs/cgroup/memory.max
$ free -h
تفاوت MemoryError با OOMKilled چیست؟
MemoryError یک استثنای پایتون است که وقتی heap نتواند حافظه بگیرد رخ میدهد و Traceback دارد. OOMKilled وضعیتی است که سیستمعامل فرآیند را با سیگنال SIGKILL میکشد و هیچ Tracebackی نمیماند. در Kubernetes، این وضعیت با Exit Code 137 و دلیل OOMKilled نمایش داده میشود. در لینوکس، در dmesg میتوانید خطوط Out of memory: Killed process را ببینید.
چگونه میتوان مصرف حافظه را در پایتون اندازه گرفت؟
چند روش عملی: اول، sys.getsizeof(obj) که اندازهٔ یک آبجکت را میدهد. دوم، tracemalloc که خطبهخط مصرف را نشان میدهد. سوم، resource.getrusage برای اندازهگیری کل حافظهٔ فرآیند:
import resource
peak_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
print(f"peak memory: {peak_kb / 1024:.1f} MB")
چهارم، psutil برای پایش دورهای:
import psutil
p = psutil.Process()
print(p.memory_info().rss / 1024 / 1024, "MB")
آیا پایتون حافظه را بعد از حذف آبجکت آزاد میکند؟
بله، ولی با یک تأخیر. پایتون از reference counting و garbage collection دورهای استفاده میکند. آبجکتهای ساده بلافاصله آزاد میشوند، ولی cycleها نیاز به gc.collect() دارند. علاوه بر این، حتی بعد از آزاد شدن آبجکت، حافظه ممکن است به سیستمعامل برگردانده نشود و در heap پایتون نگه داشته شود. برای بازگرداندن اجباری، میتوانید gc.collect() را فراخوانی کنید:
import gc
gc.collect()
چرا لیست پایتون اینقدر حافظه میگیرد؟
هر آبجکت پایتون، یک PyObject header دارد که شامل reference count و type pointer است (۱۶ بایت روی سیستمهای ۶۴ بیتی). یک int کوچک ۲۸ بایت، یک رشتهٔ کوچک حدود ۵۰ بایت. علاوه بر این، لیست پایتون برای هر عنصر، یک pointer اضافه (۸ بایت) نگه میدارد. یعنی یک لیست یک میلیونی از اعداد کوچک، حدود ۳۶ مگابایت حافظه میگیرد، در حالی که معادل آن در numpy فقط ۸ مگابایت است.
چگونه میتوان حافظهٔ numpy array را کاهش داد؟
سه تکنیک: اول، انتخاب dtype مناسب (float32 بهجای float64 و int8 برای دادههای کوچک). دوم، استفاده از numpy.memmap برای آرایههای روی دیسک:
arr = np.memmap("large.bin", dtype=np.float32, mode="r")
# فقط بخشهای لازمی که میخوانید در RAM بار میشوند
سوم، استفاده از sparse arrayها در scipy.sparse وقتی بیشتر عناصر صفر هستند.
آیا میتوان MemoryError را catch و برنامه را ادامه داد؟
بهطور کلی نه. MemoryError معمولاً نشانهٔ فشار شدید حافظه است و حتی if شما آن را catch کنید، سایر عملیات برنامه ممکن است با مشکل مواجه شوند. بهترین رویکرد: کشتن فرآیند بهطور تمیز و راهاندازی مجدد:
import sys
import logging
try:
result = heavy_operation()
except MemoryError:
logging.critical("out of memory, restarting process")
sys.exit(1)
در محیطهای Kubernetes، این الگو باعث میشود pod بهطور خودکار بازراهاندازی شود.
چگونه حافظهٔ مصرفی در asyncio را کنترل کنیم؟
سه تکنیک کلیدی: اول، استفاده از asyncio.Queue(maxsize=N) برای backpressure. دوم، استفاده از asyncio.Semaphore برای محدود کردن تعداد تسکهای همزمان. سوم، پاک کردن reference به تسکهای تمامشده:
task = asyncio.create_task(coro())
# ...
await task
del task # آزادسازی حافظه
آیا استفاده از fork در multiprocessing حافظه را دو برابر میکند؟
در لینوکس، fork از copy-on-write استفاده میکند: یعنی حافظه فقط وقتی کپی میشود که نوشته شود. بنابراین در شروع، مصرف کل ممکن است نزدیک به تکفرآیند باشد. ولی بهمحض اینکه هر فرآیند دادهای را تغییر دهد، آن صفحه از حافظه کپی میشود. در عمل، مصرف معمولاً بین ۱ تا ۳ برابر تکفرآیند است، بسته به الگوی دسترسی. برای صرفهجویی، از fork بهجای spawn (که در macOS و Windows پیشفرض است) استفاده کنید.
چرا MemoryError در Docker بیشتر از سیستم میزبان رخ میدهد؟
چون کانتینرها در cgroup با محدودیت صریح اجرا میشوند. اگر در Dockerfile دستور --memory=512m ست شده باشد، فرآیند پایتون فقط میتواند ۵۱۲ مگابایت استفاده کند، صرفنظر از RAM فیزیکی میزبان. راهحل: بررسی cgroup و تنظیم دقیق. اگر سرویس نیاز به حافظهٔ بیشتری دارد، محدودیت را افزایش دهید:
$ docker run --memory=2g myapp
آیا تنظیمات swap در سرور میتواند به MemoryError کمک کند؟
در حالت کوتاهمدت، بله. اگر swap فعال باشد، فرآیند میتواند از دیسک بهعنوان حافظهٔ کمکی استفاده کند و بهجای کشته شدن، کندتر اجرا شود. ولی برای سرویسهای حساس به تأخیر، swap میتواند تجربهٔ کاربری را بهشدت خراب کند. تصمیم درست به پروژه بستگی دارد: batch jobs — swap فعال؛ سرویسهای realtime — swap غیرفعال و بهجایش بهینهسازی کد.
چگونه برای سرویسهای تولیدی، سقف حافظهٔ هر worker را تعیین کنیم؟
سه گام: اول، مصرف peak هر worker را با memory_profiler روی ترافیک واقعی اندازه بگیرید. دوم، ضریب امنیت ۲۵٪ اضافه کنید. سوم، حاصلضرب را روی تعداد worker ضرب کنید و با RAM کل مقایسه کنید. برای نمونه، اگر هر worker peak ۳۰۰ مگابایت دارد و ۴ worker دارید، سقف کل ۱.۵ گیگابایت خواهد بود. این عدد را بهعنوان --max-memory در Gunicorn تنظیم کنید:
gunicorn --workers 4 --max-requests 1000 --max-requests-jitter 100 app:app
نکتهٔ ظریف: --max-requests باعث میشود هر worker بعد از N درخواست بازراهاندازی شود. این تکنیک از نشت تدریجی حافظه جلوگیری میکند و در پروژههای Django با سرویسهای طولانیمدت بسیار مفید است.
برای مطالعات مکمل در خانوادهٔ خطاهای پایتون، خطای FileNotFoundError در پایتون و خطای ImportError در پایتون نکات مرتبط را پوشش میدهند.
درسهایی که مدیریت حافظه به معماری کد من اضافه کرد
MemoryError بیش از آنکه یک خطای فنی باشد، یک «درس طراحی» است. سه اصلی که پس از سالها دیباگ، در معماری کد خودم رعایت میکنم:
نخست، پیش از نوشتن کد، حجم داده را تخمین بزنید. اگر میدانید دادهای ۱۰ گیگابایت است، هرگز آن را در لیست پایتون بار نکنید. طراحی streaming از ابتدا هزینهای ندارد؛ ولی تغییر آن در محیط تولید ممکن است هفتهها وقت بگیرد. هر بار که کدی مینویسید، یک سؤال کوچک بپرسید: «اگر این داده ده برابر شود، برنامه چه میکند؟».
دوم، حافظه را مثل زمان CPU، یک منبع قابل اندازهگیری ببینید. بسیاری از تیمها برای زمان اجرا بنچمارک دارند ولی برای حافظه خیر. در پروژههای خودم، همیشه دو عدد را در CI ذخیره میکنم: زمان اجرا و peak memory. اگر هر یک از این دو عدد در PR جدید بیش از ۲۰٪ رشد کند، آن PR بررسی دقیقتری لازم دارد. این عادت کوچک، جلوگیری از نشت تدریجی حافظه در بلندمدت را تضمین میکند.
سوم، خطای حافظه را جدی بگیرید، نه سرکوب. اگر MemoryError را catch میکنید و برنامه را ادامه میدهید، در واقع خطر بزرگتری ساختهاید: سایر بخشهای برنامه ممکن است با دادههای ناقص کار کنند و نتایج نادرست تولید کنند. راه درست، شکست سریع (fail-fast) و راهاندازی مجدد تمیز است. برای درک این فلسفه، مقالهٔ خطای AssertionError در پایتون نمونههای مکمل را ارائه میدهد.
در پایان، اگر در پروژهای با حالت خاصی از MemoryError برخورد کردید که اینجا پوشش داده نشده — مثلاً در ترکیب با PyTorch روی GPU، Dask، Ray، یا در سرورهای Windows با محدودیتهای خاص — تجربهتان را در دیدگاهها بنویسید. بهویژه اگر راهحلی پیدا کردهاید که با رویکردهای معمول متفاوت است و میتواند برای خوانندهٔ بعدی ارزشمند باشد. 🧠