Vahid
Vahid
خواندن ۱۲ دقیقه·۴ سال پیش

موشکافی ماژول GC پایتون و بهبود مصرف حافظه!

تو مطلب قبلی garbage collectorهارو به صورت کلی بررسی کردیم، مورادی مثل مزایا و معیایبشون، الگوریتم‌هایی موجود و ... . پس اگر مطلب قبلی را نخوندین حتما اول اون رو مطالعه کنید و بعد بیاین سراغ این مطلب چون گاها به چیزهایی اشاره میشه که اونجا گفته شدند.

https://vrgl.ir/1y8Wd


این مطلب به garbage collector پایتون اختصاص داره و اینکه این ماژول از پایتون چطوری کار می‌کند. اما قبل از اون دوتا نکته مهم رو خدمتتون عرض کنم:

نکته ۱: این مطلب از دو بخش تشکیل شده که بخش اول به نحوه پیاده‌سازی GC تو پایتون و جزئیات اون می‌پردازیم. اینکه اون زیر چه خبره، پایتون چطوری GC رو استفاده می کنه و کلا درباره جزئیات این ماژول حرف می‌زنیم. توی بخش دوم هم به نحوه‌ی نوشتن برنامه‌های بهتر از جهت حافظه می‌پردازیم. پس اگر به نکات مربوط به بخش اول علاقه‌ای ندارین، می‌تونین مستقیم سراغ بخش دوم مطلب بروید. واقعیت اینه که اگر توسعه دهنده خود پایتون نیستید(منظورم خود زبان پایتونه)، بخش اول و دونستن جزئیات در این حد، تقریبا هیچ کاربردی براتون نداره(D:). من کنجکاویم گُل کرد(یا به قولی کِرم درونم) و تا این حد پیش رفتم، از طرف هم گفتم شاید یک نفر یک روزی مثل من به این کنجکاوی گرفتار بشه. :)
نکته ۲: مطلب نوشته شده بر اساس مستندات توسعه دهندگان پایتون و تغییرات اون تا تاریخ ۷ سپتامبر ۲۰۲۰ هستش(تا به امروز که ۲۲ دسامبر ۲۰۲۰ هم تغییری نداشته). پس اگر این مطلب رو تو تاریخ دیگه‌ای مطالعه می‌کنید حتما مستندات رو هم مروری داشته باشید.

بخش اول: ماژول GC پایتون و جزئیاتش

پایتون برای الگوریتم GC خودش از reference count استفاده می‌کنه که این الگوریتم از شمارش تعداد رفرنس‌ها کمک گرفته می‌گیره(که بهش refcount میگن). اگر کنجکاور هستید که بدونید توی پایتون چطوری میشه تعداد refcount هر عنصر رو بدست آورد، می‌تونید از کد زیر کمک بگیرید:

>>> import sys >>> x = object() >>> sys.getrefcount(x) 2 >>> y = x >>> sys.getrefcount(x) 3 >>> del y >>> sys.getrefcount(x) 2

احتمالا براتون سواله که چرا وقتی یک متغیر رو تعریف می‌کنیم مقدار getrefcount برابر ۲ میشه و یک نیست؟ دلیلش اینکه که یک رفرنس هم به خود تابع getrefcount پاس داده میشه.



ویرایش بعد از انتشار پست: یک سوال خیلی جالبی رو فرهاد تو بخش نظرات پرسیده، به نظرم از دستش ندین. اگر هم براش جواب یا نظری دارید، خوشحال میشم که اشتراک دانش کنید و من رو از این بی‌سوادی نجات بدین :)




اما همون که طور که می‌دونیم(از مطلب قبلی) این الگوریتم مشکل cycle رو داره(تشکیل شدن دور در رفرنس‌ها). این مشکل چطوری پیش میاد؟ کد زیر رو در نظر بگیرید:

my_list = list() my_list.append(my_list)

اما این مشکل، یک مشکل اساسی‌ایه که هر زبان برنامه‌نویسی‌ای استفاده کننده این الگوریتم باید اون رو رفعش کنه. پایتون هم از قاعده مستثنی نیست، اگر به طور خلاصه بخوایم بگیم پایتون از بررسی دوره‌ای برای حل این مشکل کمک گرفته، اما اینکه این روش چطوری کار می‌کنه رو در ادامه با هم دیگه بررسی می‌کنیم. اول از همه با ساختار یک شی در حافظه آشنا می‌شویم.

طرح معماری و ساختار شی

پایتون برای ذخیره یک شی در حافظه ساختاری داره که اون ساختار به این شکله:

object ---> +--+--+--+--+--+--+--+--+--+--+--+ \ | ob_refcnt || +--+--+--+--+--+--+--+--+--+--+--+ | PyObject_HEAD | *ob_type || +--+--+--+--+--+--+--+--+--+--+--+ / | ... |

در پایتون به ساختار بالا یک بخش دیگه هم اضافه میشه تا اینکه از Garbage Collector هم به شکل موثرتری بتونیم استفاده کنیم، در نتیجه ساختار به این صورت تغییر پیدا می‌کنه:

+--+--+--+--+---+--+--+--+--+--+--+--+ \ | *_gc_next || +--+--+--+--+---+--+--+---+---+---+--+|| PyGC_Head | *_gc_prev || object ---> +--+--+--+--+---+--+--+--+--+--+--+--+/ | ob_refcnt |\ +--+--+--+--+---+--+--+--+--+--+--+--+|| PyObject_HEAD | *ob_type || +--+--+--+--+---+--+--+--+--+--+--+--+/ | ... |

اینکه چرا از لینک لیست دو طرفه استفاده شده، دلایل مختلفی داره که یکی از اون‌ها بحث اشیا و نسل‌ها(Generation) هستش، که در مطلب قبلی بهش اشاره کردیم(اشیا نسل جوان و نسل پیر). اگر دوست دارین که یکم درمورد این ساختار عمیق‌تر بشین می تونین از این لینک کمک بگیرین. خب با ساختار حافظه آشنا شدیم، اما هنوز نمی‌دونیم که مشکل مربوط به cycle چطوری حل شده؟!

درمورد مشکل cycle گاها گفته میشه که، cycle در شرایط خیلی خاصی پیش میاد و ممکنه اصلا بار حافظه‌ای زیادی رو نداشته باشه و زبان‌های برنامه‌نویسی هم اگر مشکل حادی پیش نیاد می‌تونه بیخیالش باشه. ولی واقعیت امر اینه که این حرف حداقل تو پایتون درست نیست و این ماجرا رو تو پایتون زیاد داریم! به عنوان نمونه:

  • ا، exceptionها شامل traceback می‌شوند که اون هم شامل یک لیست از فریم‌ها میشه که اونا خودشون exception دارند!
  • نمونه‌های کلاس‌ها به کلاس اشاره دارند که کلاس ها هم به ماژول اشاره دارند، از اون طرف هم ماژول به تمام موارد داخلی خودش رفرنس داره!! حتی یک ماژول گاها به ماژول‌های دیگه هم رفرنس داره که در یک پروسه‌ای امکان به وجود اومدن دور از اون طریق هم وجود داره(اگر در اون‌ها به موردی از این ماژول ارتباط داشته باشیم).
  • استفاده از data structureهایی مثل گراف که شامل دور هستند.

خب پس فهمیدیم که مشکل cycle توی پایتون به وجود میاد. بریم سراغ راه‌حل و یه نگاه مختصری هم به اون داشته باشیم.

https://pics.ballmemes.com/garbage-collection-gt-reference-counting-cmv-69836224.png
https://pics.ballmemes.com/garbage-collection-gt-reference-counting-cmv-69836224.png


پایتون برای شناسایی cycle از دوتا linked list دو طرفه استفاده می‌کنه: یکی شامل تمام اشیایی که باید اسکن بشوند(object to scan)، که جنس این اشیا از نوع container objects هستش(اشیایی ممکنه یک یا چند تا به یک یا چندتا شی رفرنس داشته باشند. اخر کار چند نمونه از این نوع رو مثال میزنیم). یکی هم شامل اشیایی به صورت موقت غیرقابل دسترس درنظر گرفته شده‌اند(unreachable یا بهتر بگیم tentatively unreachable).

روش کار اینطوریه که پایتون اول کار، اشیای مدنظر(container objects) رو تو linked list که برای اسکن هستش، قرار میده. همون‌طور که قبلا گفته بودیم، بعدش الگوریتم پیدا کردنِ دور میاد و از مقدار refcount هر عنصر یک دونه کم می‌کنه. توی مرحله بعد، موقع پیمایش میاد به refcountها نگاه می‌کنه، اگر یک شی با refcount صفر پیدا کنه اشیایی که به اون ارتباط دارند رو کاندیدای حذف کردن می‌کنه و به linked list که برای این کار درنظرگرفته انتقال میده(یعنی به tentatively unreachable).

خب حالا پایتون بررسی می‌کنه ببینه که آیا اشیایی که کاندیدای حذف هستند قابل دسترسی‌اند یا نه؟ اگر قابل دسترسی بودند(یعنی میشد با پیمایش بهشون رسید) اون‌ها رو به لیست عادی برمی‌گردونه در غیر این صورت پاک‌شون می‌کنه. این روند کلی ماجرا بود. برای درک بهتر موضوع، یک مثال تصویری هم داشته باشیم.

فرض کنید که همچنین شرایطی داریم:

>>> import gc # از این ماژول بگیرین help یا dir خواستین یه >>> class Link: ... def __init__(self, next_link=None): ... self.next_link = next_link >>> link_3 = Link() >>> link_2 = Link(link_3) >>> link_1 = Link(link_2) >>> link_3.next_link = link_1 >>> A = link_1 >>> del link_1, link_2, link_3 >>> link_4 = Link() >>> link_4.next_link = link_4 >>> del link_4 # Collect the unreachable Link object (and its .__dict__ dict). >>> gc.collect() 2

خب حالا روند کار به ترتیب برابره با:

مرحله اول همه اشیا که قراره اسکن بشوند.
مرحله اول همه اشیا که قراره اسکن بشوند.
مرحله دوم از تعداد رفرنس‌های هر عنصر یک دونه کم می‌کنیم
مرحله دوم از تعداد رفرنس‌های هر عنصر یک دونه کم می‌کنیم
عناصری که با شی‌ای با رفرنس صفر در ارتباط هستند رو کاندید حذف در نظر می‌گیریم
عناصری که با شی‌ای با رفرنس صفر در ارتباط هستند رو کاندید حذف در نظر می‌گیریم
بررسی می‌کنیم ببنیم که از این کاندیداهای حذفی چیزی قابل دسترس هست یا نه؟
بررسی می‌کنیم ببنیم که از این کاندیداهای حذفی چیزی قابل دسترس هست یا نه؟
اشیایی که قابل دسترس نیستند حذف می‌کنیم
اشیایی که قابل دسترس نیستند حذف می‌کنیم


امیدوارم که روش حل cycle خوب توضیح داده باشم. :)

اینکه پایتون چرا این عنصرهارو بین دو linked list جابه‌جا می‌کنه هم دلیل داره، برای اینکه جلوگیری از طولانی شدن مطلب بهش نمی‌پردازیم ولی اگر دوست دارین، می‌تونین دلیلش رو اینجا بخونینش.

نسل‌ها در پایتون

ما توی پایتون ۳ تا نسل داریم. اسمشون نسل۰، نسل۱ و نسل۲ هستش. ترتیب این نسل‌ها به این صورت است که نسل ۰ جوان‌ترین نسل و نسل ۲ هم به عنوان پیرترین نسل به حساب می‌آیند. نسل ۱ رو هم نسل میان‌سال بگیم.

نحوه استفاده پایتون از نسل‌ها

ابتدای امر هر شی‌ای که ایجاد میشه به نسل۰ منتقل میشه. بعد از اجرا شدن GC روی نسل ۰، اون اشیایی که زنده موندن به نسل ۱ منتقل می‌شوند(نسل ۰ بیشترین تعداد دفعات اجرای GC رو داره). همین ماجرا برای نسل ۱ هم اتفاق میوفته، یعنی GC روی نسل ۱ اجرا میشه و اشیایی که پاک نشدن به نسل ۲ منتقل می‌شوند. نکته مهم ماجرا اینکه که اشیا داخل نسل ۲ تا آخر اجرای برنامه زنده می‌مونند(البته واقعیت اینه ممکنه روی این نسل هم GC هم اجرا بشه)!

یک نکته جالبی که توی بحث نسل‌ها داریم به زمان اجرا شدن GC روی اون‌ها مربوط میشه. پایتون برای تشخیص زمان اجرای GC روی نسل‌ها از حد(threshold) استفاده می‌کنه. به این صورت که وقتی تعداد اشیا اون نسل به یک حدی رسید، GC روی اون نسل اجرا می‌شوند. جالبی ماجرا اینجاست ما می‌تونیم این مقدار حد رو تغییر بدیم!

خب اول ببنیم که مقدار این حد چطوری میشه به دست آورد:

>>> import gc >>> gc.get_threshold() (700, 10, 10) اون ۷۰۰ برای نسل صفر هستش چون انتظار داریم توی نسل‌های پیرتر تعداد شی کمتری داشته باشیم در نتیجه حد اون‌ها هم کمه

اگر هم بخوایم تعداد شی‌های موجود در یک نسل رو بدونیم:

>>> gc.get_objects(generation=0) تعداد اشیا نسل صفر میگه و طبیعتا به جای اون صفر می تونیم از ۱ و ۲ هم استفاده کنیم

اما گاها ممکنه دلمون بخواد(یا نیاز داشته باشیم) که gc رو خودمون فراخوانی کنی تا شروع به اجرا بکنه که برای اون هم خواهیم داشت:

>>> gc.collect() برای کل نسل‌ها >>> gc.collect(generation=0) برای نسل ۰ طبیعتا برای نسل‌های ۱ و ۲ هم داریم

شاید بگین که کی آخه gc رو خودش فراخوانی می‌کنه؟ باید بگم که یکی از دوستان تعریف می‌کرد داشته با کتابخونه pandas کار می‌کرده و رَم‌ش کم بوده به خاطر همین میومده و gc رو فراخوانی می‌کرده(کاری نداریم که این کار درست بوده یا نه! هدف اینه که تحت شرایطی ممکنه نیاز به فراخوانی داشته باشیم).

خب برسیم به اینکه چطوری حد نسل‌ها رو عوض کنیم. خیلی راحت:

>>> gc.set_threshold(100) >>> gc.get_threshold() (100, 10, 10) >>> gc.set_threshold(200, 30) >>> gc.get_threshold() (200, 30, 10) >>> gc.set_threshold(200, 40, 12) >>> gc.get_threshold() (200, 40, 12)

اجرای GC روی نسل ۲(پیرترین نسل)

اگر متن‌های مختلف رو بخونین احتمالا گفته میشه که پایتون اشیا نسل ۲ رو تا اخر اجرای برنامه زنده نگه می‌داره. واقعیتی وجود داره و اون هم اینه که این حرف درست نیست و تحت شرایطی GC روی این نسل هم اجرا میشه. شرط اجرای GC برای این نسل برابر است با

long_lived_pending / long_lived_total

و اگر این درصد از ۲۵ بالاتر بزنه GC شروع به اجرا می‌کنه.

منبع: خودم D:
منبع: خودم D:

دسته بندی نوع‌های اشیا

بالاتر گفته بودیم که پایتون برای فقط container objects رو باهاشون کار داره و نوع simple رو کاری بهش نداره. ببنیم که این دو دسته چی هستند؟

  • نوع اتمیک(atomic) یا ساده(simple)
  • نوع غیر اتمیک(شامل container )

از انواع اتمیک می‌تونیم به int, string و tupleها و dictهایی که فقط شامل immutable هستند اشاره کنیم.

از نوع غیر اتمیک میشه به list, class instance و دیکشنری‌هایی که mutable دارند، اشاره کرد.

تکه کد زیر می‌تونه موضوع رو براتون شفاف‌تر کنه:

>>> gc.is_tracked(0) False >>> gc.is_tracked(&quota&quot) False >>> gc.is_tracked({}) False >>> gc.is_tracked({&quota&quot: 1}) False >>> gc.is_tracked([]) True >>> gc.is_tracked({&quota&quot: []}) True

به نظرم کافیه و تا حد خیلی خوبی با GC پایتون آشنا شدیم. در واقع ما الان اکثر چیزهایی که یک توسعه‌دهنده پایتون درمورد GC اون میدونه ما هم می‌دونیم(طبیعتا اون یکم بیشتر چون ما تمام بخش‌هارو پوشش ندادیم).

اما برسیم به یک سری نکات که می‌تونه به ما تو نوشتن برنامه‌های بهتر از لحاظ حافظه کمک کنه.

بخش دوم: برنامه پایتونی بهتری بنویسیم!

https://memegenerator.net/img/instances/62225259/damn-why-cant-i-run-faster.jpg
https://memegenerator.net/img/instances/62225259/damn-why-cant-i-run-faster.jpg


در این بخش سعی میشه نکاتی رو بگیم که باعث می‌شوند که برنامه از لحاط مصرف حافظه عملکرد بهتری داشته باشه. به نظرم توضیح خاصی نیاز نیست و بریم سراغ نکات.

  • اولین نکته اینکه پایتون لزوما هر حافظه‌ای که آزاد می‌کنه رو به سیستم‌عامل برنمی‌گردونه! در واقع پایتون یک memory allocator کوچک داره که از خونه‌هایی که آزاد و به سیستم‌عامل برگردونده نشدند استفاده می‌کنه تا اون‌ها رو به اشیایی که تو آینده قراره ساخته بشوند اختصاص بده. این خودش می‌تونه باعث مشکلاتی بشه اون هم به این صورت که اگر هی تعداد این خونه‌های آزاد نشده بیشتر بشود، مصرف حافظه بالا میره.
  • جمع نکردن رشته‌ها

از اونجایی که رشته‌ها از نوع immutable هستند در نتیجه هر بار که ما + رو استفاده می‌کنیم، پایتون یک آدرس حافظه رو گرفته و قبلی رو بیخیال میشه. راه‌کارش هم اینه که به جای + از f-string اینا استفاده کنید.

این خوب نیست:

part_one = &quothello&quot part_two = &quotpython&quot part_three = &quotworld&quot my_message = part_one + &quot &quot + part_two + &quot &quot + part_three

این خوبه(توجه کنید کد بالا زشت هم هست حتی):

my_message = f'{part_one} {part_two} {part_three}'
  • استفاده از join

اگر یک لیستی داریم که می‌خوایم جمعشون کنیم از join استفاده کنیم.

این خوب نیست:

lines = [&quotline1\n&quot, &quotline2\n&quot, &quotline3\n&quot] content = &quot&quot for line in lines: content += line

این خوبه:

lines = [&quotline1&quot, &quotline2&quot, &quotline3&quot] '\n'.join(lines)
  • استفاده از generatorها:

این عزیزان به ما کمک می‌کنند که در هر بار فقط یک عنصر رو از یک شی iterable برگردونیم(به جای اینکه کل عناصر رو برگردونیم). طبیعتا میزان حافظه‌ای که یک عنصر می‌گیره خیلی کمتر از کل عناصر هستش.

for item in items: yield item
  • از توابع و کتابخانه‌های خود پایتون استفاده کنید.

به طور معمول توابع و کتابخانه‌های خود پایتون استفاده بهتر و بهینه‌تری از حافظه دارند.

کد بد:

my_words = list() for word in words: my_words.append(word.strip())

کد خوب:

my_words = map(str.strip, words)

دقیق مطمئن نیستم ولی فک کنم list comprehensiveها هم موثر هستند.

  • از itertools استفاده کنید.

به جای استفاده از کد زیر:

results = list() for item in [True, False]: for i in range(1, 5): do_some_stuff(item, i)

این رو میتونیم بهترش کنیم:

from itertools import product, chain list(chain.from_iterable(do_some_stuff(item, i) for i, item in product ([True, False], range(1,5))))
  • استفاده از __slot__

اگر کلاسی قراره تعداد زیادی نمونه داشته باشه، برای بهینه کردن مصرف حافظه بهتره که از __slot__ استفاده کنیم. دلیل چرایی اون رو هم می‌تونین از این مطلب بخونین.

https://vrgl.ir/UAJEI

اگر عمری بود و یادم نرفت احتمالا احتمالا به مرور زمان این لیست رو تکمیل‌ترش کنم(یه چیزهایی یاد می‌گیرم).

همین! لبخند بزنین لطفا :)

منابع:

  • https://devguide.python.org/garbage_collector/
  • https://towardsdatascience.com/memory-management-in-python-6bea0c8aecc9


پایتونpythongarbage collectormemory
یه وحید از نوع برنامه نویسش :)
شاید از این پست‌ها خوشتان بیاید