في حين أنه من السهل استخدام بايثون لتحويل فكرة إلى برنامج، إلا أن المرء سيواجه بسرعة اختناقات تجعل الكود الخاص به أقل أداءً مما قد يرغب فيه. أحد هذه الاختناقات هو الذاكرة، والتي يستهلكها بايثون كثيرًا مقارنة باللغات المكتوبة بشكل ثابت. في الواقع، من المحتمل أن يتلقى الشخص الذي يطلب النصيحة حول كيفية تحسين تطبيق بايثون عبر الإنترنت النصيحة التالية: “أعد كتابته في Rust”. لأسباب واضحة، هذه ليست نصيحة عملية جدًا في معظم الأحيان. وبالتالي، يجب أن نكتفي بما لدينا: بايثون والمكتبات المكتوبة لـبايثون.
فيما يلي عرض لنموذج الذاكرة وراء تطبيق بايثون: كيفية تخصيص الكائنات، وأين يتم تخزينها، وكيف يتم تنظيفها في النهاية. وفي حين أن مركز الثقل في المناقشة هو على الجانب النظري، فسوف يكون هناك مع ذلك العديد من الاعتبارات العملية التي يمكنك استخدامها لتحسين استخدام الذاكرة في الكود الخاص بك.
نموذج ذاكرة بايثون
يمكن شرح نموذج الذاكرة باستخدام ثلاثة مفاهيم: stack وheap، وهما المنطقتان الرئيسيتان للذاكرة في تطبيق بايثون، و”المؤشرات”، وهي متغيرات تحمل عناوين الذاكرة التي تشير إلى كائن. سيتم شرح كل من هذه المفاهيم أدناه.
Stack
stack هو منطقة الذاكرة التي تحمل تدفق تنفيذ التطبيق الخاص بك. كلما استدعت دالة دالة أخرى، يتم دفع stack frame جديد إلى أعلى stack. عندما تعود دالة، يتم تدمير stack frame هذا، ويتم تمرير التحكم إلى الإطار الموجود في المستوى الأدنى مباشرة. ينتهي تنفيذ البرنامج عندما يعود الإطار الأدنى في stack.
يتم تسمية “stack trace”، وهي رسالة الخطأ التي تظهر عند حدوث استثناء، باسم stack، وتمثل حالة التنفيذ في اللحظة التي حدث فيها الخطأ.
تُعد stack أيضًا المكان الذي توجد فيه المتغيرات المحلية. وهذه هي الطريقة التي يتم بها تنظيف المتغيرات المحلية في نهاية تنفيذ الدالة، ولماذا لا تستطيع الدوال خارج نطاق الدالة الوصول إلى المتغيرات المحلية ضمن النطاق المذكور.
Heap
heap هي أكبر مساحة ذاكرة للتطبيق وهي المكان الذي يترك فيه بايثون أي كائنات ينشئها. وعلى عكس stack، لا يتم تنظيم اheap بأي طريقة معينة. يتم توفير مساحة لإنشاء الكائنات حسب الحاجة. وعلى هذا النحو، فإن عمر الكائنات في heap غير محدد مسبقًا. تظل الكائنات موجودة طالما يُعتقد أنها مطلوبة.
بالطبع، من الضروري أن يكون من الممكن الوصول إلى الكائنات الموجودة على heap من خلال stack. وبالمثل، سيكون من الضروري أيضًا الوصول إلى الكائنات من كائنات أخرى. يتم تسهيل إمكانية الوصول هذه من خلال المؤشرات.
Pointers
سيعرف كل من عمل بلغة C وC++ ما هي المؤشرات. وسيفهمون أيضًا سبب محاولة معظم لغات البرمجة الأخرى جاهدة تجريد مفهوم المؤشرات بعيدًا عن المطور تمامًا. المؤشرات هي عناوين ذاكرة. يتم تسميتها بهذا الاسم لأنها “تشير” إلى عنوان في الذاكرة يُفترض أنه يحمل بيانات تتوافق مع نوع معين من الكائنات.
في بايثون، عندما يتم إنشاء كائن، فإن ما يحدث فعليًا هو إنشاء كائن على heap، مع إتاحة مؤشر داخل النطاق المحلي الذي طلب تهيئة الكائن. وهذا يعني أن العبارة السابقة “المتغيرات المحلية تعيش على إطار stack الخاص بها” ليست دقيقة تمامًا: الكائن الفعلي يعيش في مكان ما على heap، بينما المؤشر الوحيد المتاح (في البداية) للكائن يعيش داخل إطار stack.
النتيجة المترتبة على هذا التمييز هي أن تدمير إطار stack يؤدي فقط إلى تنظيف المؤشر، ولكن ليس الكائن الفعلي. وذلك لأنه من الممكن أن يكون قد تم إنشاء مرجع آخر لنفس الكائن منذ إنشائه، وأن هذا المرجع لا يزال نشطًا. سنرى كيف يتعامل بايثون مع مسح الكائن المرتبط في القسم الخاص بجمع القمامة.
حجم المؤشر يساوي حجم الكلمة في بنية أنظمتك، وبالتالي فهو 8 بايت على أنظمة 64 بت، والتي هي في معظم أجهزة الكمبيوتر الحديثة.
كيفية تعامل بايثون مع الكائنات في الذاكرة
المصفوفات نمط C مقابل قوائم بايثون
في لغة C، يكون نوع المصفوفة في الواقع مؤشرًا إلى العنصر الأول في تلك المصفوفة. ولا يتم تقديم أي معلومات إضافية، ولا حتى الحجم المفترض للمصفوفة. ولتيسير هذا النوع من بنية البيانات، يلزم أن تكون عناصر المصفوفة متجاورة مع بعضها البعض في الذاكرة. وعلاوة على ذلك، يجب معرفة نوع البيانات مسبقًا، حتى نتمكن من توقع مقدار مساحة الذاكرة التي يخصصها كل كائن. والنتيجة هي تخطيط مثالي لبيانات المصفوفة في الذاكرة، مع انخفاض استخدام الذاكرة وزيادة الأداء.
تمنع الطبيعة الديناميكية لبايثون، وخاصة الكتابة الديناميكية، هذا النوع من تخزين البيانات بكفاءة. باستثناءات قليلة، تشير جميع الكائنات إلى بعضها البعض بشكل غير مباشر، من خلال وجود مؤشر. تمتد هذه الممارسة إلى مجموعات بايثون والقائمة والمجموعات والعناصر: يتم تمثيل جميع هياكل البيانات القياسية في الذاكرة بواسطة مصفوفات أساسية من المؤشرات.
إن حقيقة أن جميع الكائنات تقريبًا في بايثون يتم تمثيلها بواسطة مؤشرات هي مساهمة كبيرة في مرونة بايثون. على سبيل المثال، هذا هو السبب في أن هياكل البيانات list
وtuple
في بايثون يمكنها الاحتفاظ بكائنات من أنواع مختلفة. ولكن هذا هو أيضًا سبب كون مجموعات بايثون أقل كفاءة في استخدام المساحة من اللغات ذات النوع الثابت. تتغلب بعض فئات مكتبة بايثون القياسية، وأبرزها مكتبة array
، على هذا القيد، مما يسمح للمستخدم بتحديد تسلسلات من نوع بيانات بحجم ثابت للتخزين الأمثل. يمكن أن يكون هذا حلاً جيدًا إذا كانت لديك حاجة لتخزين تسلسلات كبيرة من الأنواع الرقمية في الذاكرة. ولكن بالطبع، تفضل معظم التطبيقات في العالم الحقيقي استخدام مكتبة خارجية خاصة باحتياجاتها، ويعد numpy المثال الأكثر شهرة.
العد المرجعي وجامع البيانات المهملة
لاحظ أن هذه المفاهيم تنطبق على تنفيذ CPython. لا تستخدم التطبيقات البديلة مثل PyPy أداة تجميع البيانات المهملة التي تعتمد على العد المرجعي.
يستخدم بايثون مزيجًا من عد المراجع وإمكانية الوصول. يتتبع عد المراجع عدد المرات التي تم فيها تعيين كائن إلى متغير، مطروحًا منه عدد المرات التي لم يتم تعيينه فيها. الكائن الذي لا يشير إليه أي كائن آخر له عدد مراجع يساوي صفرًا، وبالتالي يمكن تنظيفه. كما يستخدم فحوصات إمكانية الوصول لمعرفة ما إذا كانت هناك أي سلسلة من المراجع موجودة لجعل الكائن قابلاً للوصول من المكدس. وبذلك، يمكنه تنظيف الرسوم البيانية الدورية ولكن غير المتصلة للكائنات، والتي لا يمكن اكتشافها من خلال عد المراجع وحده. مثل عندما يحمل كائنان مراجع لبعضهما البعض، ولكن لا يتم الرجوع إليهما في أي مكان آخر.
جامع البيانات المهملة CPython الخاص بـبايثون هو جيل. يتم إنشاء الكائنات أولاً وتعيينها إلى الجيل 0، وسيقوم جامع القمامة بفحص كل جيل على حدة. الكائن الذي ينجو من جولة جمع من جيله يتم ترقيته إلى جيل أعلى. يتم فحص الأجيال الأعلى بحثًا عن كائنات منتهية الصلاحية بشكل أقل تكرارًا، التزامًا بالملاحظة العالمية التي مفادها أنه كلما طالت مدة وجود شيء ما، زادت احتمالية بقائه لفترة أطول. في المجموع، هناك ثلاثة أجيال.
يمكن ممارسة تحكم يدوي محدود في جمع البيانات المهملة من خلال مكتبة gc، والتي تعد جزءًا من stdlib. على سبيل المثال، يمكن تشغيل جمع البيانات المهملة يدويًا من خلال التابع gc.collect()
. يمكن أيضًا تعليق جمع البيانات المهملة تمامًا من خلال استخدام gc.disable()
و gc.enable()
. يمكن أن يكون هذا مفيدًا كلما كان التوقف غير المتوقع للبرنامج غير مرغوب فيه (مؤقتًا) أثناء فترات التنفيذ.
نظرًا لأن جميع أنواع Python عبارة عن كائنات ولأن جميع الكائنات يتم إدارتها بواسطة جمع البيانات المهملة، فإن الأنواع في CPython تحتوي بالضرورة على حقلين إضافيين: ob_refcnt
، المستخدم لحساب المرجع، وob_type
، الذي يشير إلى فئة الكائن. معًا، تمثل هذه العناصر 16 بايتًا إضافية غير قابلة للاختزال من تكلفة الذاكرة لكل كائن. وهذا مهم للغاية، نظرًا لأن نوع int المكافئ في اللغات المكتوبة بشكل ثابت يبلغ 4 بايت فقط.
بصمة الذاكرة لأنواع بايثون الشائعة
يمكننا استخدام وحدة sys لفحص استخدام الذاكرة للأنواع في الذاكرة. في CPython، تبلغ قيمة العدد العائم 24 بايتًا، كما قد نتوقع: 8 بايتات مشغولة برقم فاصلة عائمة بدقة مزدوجة، و16 بايتًا إضافيًا من النفقات العامة
>>> sys.getsizeof(1.0)
24
الأعداد الصحيحة في بايثون غريبة بعض الشيء، لأنها تمتد إلى ما لا نهاية. في حين أن عددًا صحيحًا مكونًا من 4 بايتات يمكنه فقط تخزين ما يقرب من 4 مليارات قيمة، فإن الأعداد الصحيحة في بايثون تستمر إلى الأبد. وهذا يؤدي إلى قدر ضئيل من النفقات الإضافية. 28 بايتًا للأعداد الصحيحة ذات القيمة الصغيرة. ومع ذلك، سيزداد استخدام الذاكرة مع زيادة قيمة العدد الصحيح.
>>> sys.getsizeof(0)
28
>>> sys.getsizeof(2**256)
60
يستخدم بايثون ترميز يونيكود للسلاسل. من المفضل أن يتم تمثيل أحرف آسكي ببايت واحد، بالإضافة إلى تكلفة إضافية غير قابلة للاختزال لا تقل عن 49 بايت. إذا كانت السلسلة تحتوي على أحرف غير آسكي، فسيستخدم بايثون تنفيذًا بديلًا للسلسلة له تكلفة إضافية غير قابلة للاختزال لا تقل عن 73 بايت، وحجم لكل حرف يعتمد على قيمة يونيكود لأكبر حرف مستخدم (ما يصل إلى 4 بايت لكل حرف).
>>> sys.getsizeof('abc')
52
>>> sys.getsizeof('🐍')
80
من بين أنواع المجموعات القياسية، فإن المجموعة الثنائية أكثر كفاءة في استخدام الذاكرة من القوائم. ومع ذلك، فإن كليهما أقل استهلاكًا للذاكرة من المجموعات، والتي لها تكلفة إضافية أكبر بكثير بسبب التجزئة المطلوبة لضمان عمليات التحقق السريعة من التفرد. تتناسب هذه التكلفة الإضافية مع طول المجموعة. لذلك، قد يكون من المفيد تفضيل القوائم للمجموعات الكبيرة وضمان التفرد بطريقة مختلفة. (إذا كان التحقق السريع من الوجود مطلوبًا، فقد تضطر إلى إنشاء كائن مجموعة شبيه بالشجرة، أو استيراد مكتبة تابعة لجهة خارجية. سيسمح هذا بإجراء عمليات التحقق في O(log n)
مع استهلاك قدر أقل من الذاكرة.) علاوة على ذلك، يمتد نفس عدم كفاءة الذاكرة للمجموعات إلى مفاتيح القاموس.
my_list = [random.randint(0, 1000) for _ in range(100)]
>>> sys.getsizeof(my_list)
920
>>> sys.getsizeof(tuple(my_list))
840
>>> sys.getsizeof(set(my_list))
8408
بصمة الذاكرة لكائنات بايثون
في حين أن ما سبق يغطي أكثر مكونات بايثون المضمنة شيوعًا، إلا أنه لا يتطرق بعد إلى التمثيل الداخلي للكائنات في الذاكرة. أهم شيء يجب معرفته حول فئات بايثون هو أن قيم حقولها مخزنة افتراضيًا داخل قاموس. على وجه التحديد، يتم تضمينها داخل الحقل المسمى __dict__
.
هذا ما يجعل كائنات بايثون مرنة بشكل لا يصدق، حيث يمكن إضافة الحقول إلى كائن حسب الرغبة، حتى بعد التهيئة.
على سبيل المثال، يعمل الكود مثل هذا بشكل جيد للغاية في بايثون:
class Foo:
def __init__(self):
self.bar = 2
foo = Foo()
foo.xyz = 3 # valid
ومع ذلك، بالإضافة إلى كونها غير مرغوب فيها على الأرجح (ومحبطة، لأن الخطأ المطبعي في أسماء الحقول لن يؤدي إلى خطأ)، فإن هذه الميزة في بايثون تتسبب أيضًا في زيادة كبيرة في مساحة ذاكرة الكائنات الخاصة بك:
>>> sys.getsizeof(foo.__dict__)
296
ومع ذلك، فإن ما يدركه معظم الناس هو أن هذه المرونة لا تمتد إلى أنواع بايثون المضمنة. على وجه التحديد، سيؤدي كلا سطري التعليمات البرمجية هذين إلى AttributeError
int(0).xyz = 3
object().xyz = 3
إذا نظرنا بشكل أعمق إلى هذا الموضوع، فسوف ندرك سريعًا أن السمة __dict__
غير موجودة لهذه الأنواع:
int(0).__dict__ # Also an AttributeError
تحتوي لغة بايثون على ميزة تسمى الفئات المقسمة، وهي طريقة بديلة لتخزين الكائنات لحقولها، وهي أقل مرونة، ولكنها أكثر كفاءة في استخدام الذاكرة. والتركيب النحوي لهذه الميزة واضح للغاية، وإن كان مطولًا بعض الشيء:
class Foo:
__slots__ = ('bar', 'baz') # specify exactly which fields the class has
def __init__(self, bar, baz):
self.bar = bar
self.baz = baz
foo = Foo('bar', 'baz')
foo.xyz = 3 # This is now an AttributeError
يحدد هذا النحو مسبقًا الحقول التي من المتوقع أن يحتوي عليها الكائن، على الرغم من أنه لا يتطلب تهيئة هذه الحقول بواسطة __init__
(أو في أي وقت). وكإضافة إضافية، يكون الوصول إلى سمة محددة أسرع من الطريقة التي تستخدم __dict__
تجدر الإشارة إلى أن معظم مبرمجي بايثون يفضلون استخدام فئات البيانات أينما أمكن ذلك. وكما يحدث، يمكن تحديد الفتحات بسهولة أكبر لفئة البيانات دون الحاجة إلى القوالب الإضافية التي نراها أعلاه:
@dataclass(slots=True)
class Foo:
bar: str
baz: str
foo = Foo('bar', 'baz')
لذا فمن المرجح أن تكون هذه هي طريقتك المفضلة لإنشاء الفئات المحددة.
إن فهم نموذج الذاكرة الأساسي لتطبيق بايثون يعد دليلاً مفيدًا لفهم مخاطر الأداء التي تفرضها عليك طبيعة بايثون المرنة. ورغم أنه من المعروف عمومًا أن بايثون “أقل كفاءة” من اللغات المكتوبة بشكل ثابت، فإن العوامل المساهمة في هذا الانخفاض في الكفاءة ليست مفهومة عالميًا. ولكن مع القليل من الشرح، آمل أن أكون قد ساعدتك على فهم كيفية استخدام بايثون لذاكرة الكمبيوتر لديك، وأريتك كيف يمكن لبعض التعديلات البسيطة نسبيًا أن تقدم تحسينات كبيرة في استخدام الذاكرة في تطبيقك.
كملاحظة ختامية، يجب ملاحظة أن أي تطبيق تجاري تقريبًا لـبايثون لا يستخدم بايثون “الخالص” لإنجاز مهامه. هناك دائمًا تقريبًا اعتماد على مكتبات أو أطر عمل تابعة لجهات خارجية مكتوبة جزئيًا على الأقل بلغة مجمعة. عند كتابة كود تنافسي، عندما تصبح كفاءة الذاكرة حقًا موضوعًا مثيرًا للقلق، فإن المعرفة المذكورة أعلاه لن توصلك إلى أبعد من ذلك، ويصبح من الضروري الانغماس في خيارات كفاءة الذاكرة المقدمة كمكتبات بايثون ضمن مجال عملك.
اكتشاف المزيد من بايثون العربي
اشترك للحصول على أحدث التدوينات المرسلة إلى بريدك الإلكتروني.