برنامهنویسی موازی با multiprocessing، قدرت استفاده از چندین هستهی پردازنده را به ما میدهد و محدودیت GIL را دور میزند. اما این قدرت، هزینهای هم دارد: مدیریت حافظه. برخلاف threading که تردها حافظهی مشترک دارند، در multiprocessing هر پردازش حافظهی خودش را دارد. این تفاوت بنیادین، چالشهای خاصی را در مدیریت حافظه ایجاد میکند که اگر بهدرستی با آنها برخورد نشود، میتواند به سرریز حافظه (Memory Overflow) و خرابی برنامه منجر شود.
تفاوت اساسی در مدل حافظه
در multiprocessing، هر پردازش یک مفسر پایتون مجزا و فضای حافظهی اختصاصی خود را دارد. این یعنی دادهها بین پردازشها بهطور خودکار به اشتراک گذاشته نمیشوند. وقتی دادهای را از یک پردازش به پردازش دیگر میفرستید، بهطور پیشفرض، آن داده سریالایز (Pickle) و کپی میشود. این کپی شدن، دو مشکل جدی ایجاد میکند: مصرف بالای حافظه و سربار پردازشی.
سناریوی واقعی: وقتی حافظهی نهفته، فاجعه میآفریند
یک مثال واقعی و بسیار آموزنده را در نظر بگیرید. فرض کنید یک آرایهی NumPy به اندازهی ۸ گیگابایت در پردازش اصلی ایجاد کردهاید و میخواهید با استفاده از joblib و backend multiprocessing، ۱۶۰ وظیفه را روی آن اجرا کنید. برنامهای که از نظر منطقی درست به نظر میرسد، اما با خطای OSError: [Errno 28] No space left on device از کار میافتد.
علت چیست؟ وقتی joblib با backend multiprocessing کار میکند، برای هر آرایهای که به پردازش کارگر (Worker) ارسال میشود، یک فایل باینری در /dev/shm ایجاد میکند. /dev/shm یک فایلسیستم موقت است که دادهها را در RAM ذخیره میکند، نه روی دیسک. هر پردازش کارگر، از این فایل بهعنوان یک numpy.memmap استفاده میکند تا به داده دسترسی داشته باشد. ایدهی اولیه این است که یک فایل برای هر قطعهی داده ایجاد شود و همهی پردازشها از آن استفاده کنند.
اما در عمل، joblib برای هر وظیفه، یک فایل جدید در /dev/shm ایجاد میکند، حتی اگر همان قطعهی داده را پردازش کند. در مثال ۱۶۰ وظیفهی ما، این یعنی ۱۶۰ فایل ۸ گیگابایتی! با وجود اینکه ۲۵۲ گیگابایت RAM در سیستم وجود دارد، /dev/shm معمولاً به نصف RAM محدود میشود (مثلاً ۱۲۶ گیگابایت). بهمحض اینکه فضای /dev/shm پر شود، برنامه با خطای “No space left on device” از کار میافتد، حتی اگر هنوز RAM خالی وجود داشته باشد.
استراتژیهای مدیریت حافظه
استفاده از حافظهی مشترک (Shared Memory)
بهترین راه برای کاهش مصرف حافظه، استفاده از حافظهی مشترک است. با استفاده از multiprocessing.shared_memory (پایتون ۳.۸+) میتوانید یک قطعه حافظه را بین چندین پردازش به اشتراک بگذارید و از کپی کردن دادهها جلوگیری کنید. کتابخانههایی مثل torch.multiprocessing نیز از همین مکانیزم استفاده میکنند تا تنسورهای PyTorch را بدون کپی بین پردازشها به اشتراک بگذارند.
from multiprocessing import shared_memory
import numpy as np
# ایجاد حافظهی مشترک
shm = shared_memory.SharedMemory(create=True, size=array.nbytes)
shared_array = np.ndarray(array.shape, dtype=array.dtype, buffer=shm.buf)
shared_array[:] = array[:] # کپی کردن داده به حافظهی مشترک
# پردازش فرزند میتواند به این حافظه دسترسی داشته باشدتوجه داشته باشید که حافظهی مشترک باید بهصورت دستی آزاد شود (shm.close() و shm.unlink()) و اگر پردازش بهطور غیرمنتظره از کار بیفتد، ممکن است حافظهی مشترک تا زمان راهاندازی مجدد سیستم اشغال باقی بماند.
انتخاب استراتژی اشتراکگذاری مناسب
در برخی کتابخانهها مثل PyTorch، میتوانید استراتژی اشتراکگذاری را انتخاب کنید:
file_descriptor(استراتژی پیشفرض در لینوکس): ازshm_openبرای ایجاد فایلهای حافظهی مشترک استفاده میکند و توصیفکنندههای فایل را بین پردازشها منتقل میکند. اگر تعداد زیادی تنسور به اشتراک گذاشته شود، ممکن است تعداد زیادیfile descriptorباز بماند.file_system: از نام فایلها برای شناسایی مناطق حافظهی مشترک استفاده میکند. نیازی به کش کردنfile descriptorندارد، اما در صورت خرابی پردازشها، فایلها ممکن است در سیستم باقی بمانند و حافظه را اشغال کنند.
روش spawn در مقابل fork
روش شروع (start method) پردازشها تأثیر مستقیمی بر مصرف حافظه دارد:
fork(پیشفرض در لینوکس): از Copy-on-Write استفاده میکند. پردازش فرزند، حافظهی پردازش والد را بهاشتراک میگذارد و فقط زمانی که دادهها تغییر میکنند، کپی میشوند. این روش بسیار بهینه است و مصرف حافظه را بهشدت کاهش میدهد.spawn(پیشفرض در ویندوز و macOS): یک مفسر پایتون کاملاً جدید راهاندازی میکند و همهی ماژولها و دادهها را از ابتدا بارگذاری میکند. حافظه بین والد و فرزند به اشتراک گذاشته نمیشود و مصرف حافظه بهصورت خطی با تعداد پردازشها افزایش مییابد.
یک مثال واقعی از این تفاوت: در یک پروژه، تغییر از fork به spawn برای رفع یک مشکل deadlock، باعث شد هر پردازش کارگر حدود ۴۳۰ مگابایت حافظه مصرف کند. با ۳۵ پردازش همزمان، مصرف حافظه از ۱۲ گیگابایت گذشت و برنامه با خطای OOM (Out of Memory) از کار افتاد.
import multiprocessing
# تنظیم روش شروع
multiprocessing.set_start_method('fork') # یا 'spawn' یا 'forkserver'مدیریت CPU Oversubscription
CPU Oversubscription وضعیتی است که تعداد کل vCPUهای درخواستی، از تعداد vCPUهای موجود در سختافزار بیشتر میشود. این وضعیت منجر به رقابت شدید برای منابع CPU، افزایش Context Switching و کاهش کارایی کلی سیستم میشود.
فرض کنید سیستمی با N هستهی پردازنده دارید و M پردازش موازی راهاندازی میکنید. اگر هر پردازش از همهی هستهها استفاده کند (مثلاً با تنظیم torch.set_num_threads(N))، مجموع درخواستهای CPU برابر با M * N میشود که بسیار بیشتر از N موجود است. راهحل این است که تعداد تردهای هر پردازش را به floor(N / M) محدود کنید.
import os
import torch
def worker_process(rank, world_size):
# محدود کردن تعداد تردهای هر پردازش
n_cores = os.cpu_count()
n_threads = max(1, n_cores // world_size)
torch.set_num_threads(n_threads)
# ... ادامهی پردازشآزادسازی بهموقع حافظه
وقتی از Queue برای انتقال دادههای حجیم بین پردازشها استفاده میکنید، حافظهی اشغالشده تا زمانی که داده در Queue یا در پردازش گیرنده وجود دارد، آزاد نمیشود. برای جلوگیری از انباشت حافظه:
- دادهها را بلافاصله پس از استفاده با
delحذف کنید. - اگر از حافظهی مشترک استفاده میکنید، حتماً پس از اتمام کار، آن را
unlinkکنید. - پردازش تولیدکننده (Producer) باید تا زمانی که پردازش مصرفکننده (Consumer) داده را دریافت کرده و استفاده کرده است، زنده بماند، در غیر این صورت ممکن است دادهها بهدرستی آزاد نشوند.
تشخیص و رفع نشتی حافظه
نشتی حافظه در multiprocessing میتواند ناشی از عوامل مختلفی باشد:
باز نشدن فایلهای حافظهی مشترک: اگر پردازشها بهطور غیرمنتظره از کار بیفتند، فایلهای /dev/shm ممکن است پاک نشوند. ابزارهایی مثل torch_shm_manager میتوانند به شناسایی و پاکسازی این فایلها کمک کنند.
مشکل Resource Tracker در پایتون ۳.۱۳: در برخی نسخهها، Resource Tracker ممکن است بهدرستی کار نکند و حافظهی مشترک را آزاد نکند. در این موارد، میتوانید از پارامتر track=False در SharedMemory استفاده کنید یا استثناهای مربوط به ChildProcessError را نادیده بگیرید.
اشتراکگذاری تنسورهای CUDA: در PyTorch، اگر تنسورهای CUDA را بین پردازشها به اشتراک بگذارید، پردازش فرستنده باید تا زمانی که گیرنده به تنسور نیاز دارد، زنده بماند. اگر پردازش گیرنده با سیگنال کشنده از کار بیفتد، تنسور ممکن است برای همیشه در حافظه باقی بماند.
جمعبندی
مدیریت حافظه در multiprocessing یکی از چالشهای اصلی برنامهنویسی موازی است. با درک تفاوتهای مدل حافظه، انتخاب روش شروع مناسب (fork در برابر spawn)، استفاده از حافظهی مشترک و مدیریت CPU Oversubscription، میتوانید برنامههایی بنویسید که از حداکثر توان پردازشی سیستم استفاده کنند بدون اینکه دچار سرریز حافظه شوند.
به خاطر داشته باشید که هر بار دادهای بین پردازشها ارسال میشود، یا کپی میشود یا به حافظهی مشترک منتقل میشود. استفاده از fork در سیستمهای لینوکس، به دلیل Copy-on-Write، بهمراتب بهینهتر از spawn است و مصرف حافظه را بهشدت کاهش میدهد. همچنین با محدود کردن تعداد تردهای هر پردازش، از رقابت بینتیجه بر سر منابع CPU جلوگیری کنید.