كيف تتسبب في حدوث تسرب للذاكرة في تطبيق Angular؟

الأداء هو مفتاح نجاح تطبيق الويب. لذلك ، يحتاج المطورون إلى معرفة كيفية حدوث تسرب للذاكرة وكيفية التعامل معها.

هذه المعرفة مهمة بشكل خاص عندما يصل التطبيق الذي يتعامل معه المطور إلى حجم معين. إذا كنت لا تولي اهتمامًا كافيًا لتسربات الذاكرة ، فقد ينتهي الأمر بكل شيء مع دخول المطور إلى "فريق القضاء على تسرب الذاكرة" (كان علي أن أكون جزءًا من هذا الفريق). يمكن أن يحدث تسرب للذاكرة لأسباب مختلفة. ومع ذلك ، أعتقد أنه عند استخدام Angular ، قد تواجه نمطًا يتطابق مع السبب الأكثر شيوعًا لتسربات الذاكرة. هناك طريقة للتعامل مع مثل هذه التسريبات في الذاكرة. وأفضل شيء ، بالطبع ، ليس محاربة المشاكل ، ولكن تجنبها.





ما هي إدارة الذاكرة؟


تستخدم جافا سكريبت نظام إدارة ذاكرة تلقائي. تتكون دورة حياة الذاكرة عادة من ثلاث خطوات:

  1. تخصيص الذاكرة اللازمة.
  2. العمل مع الذاكرة المخصصة ، وإجراء عمليات القراءة والكتابة.
  3. تحرير الذاكرة بعد عدم الحاجة إليها.

على MDN يقول أن إدارة الذاكرة التلقائية - هو مصدر محتمل للالتباس. هذا يمكن أن يعطي المطورين شعورًا خاطئًا بأنهم لا يحتاجون إلى القلق بشأن إدارة الذاكرة.

إذا كنت لا تهتم بإدارة الذاكرة على الإطلاق ، فهذا يعني أنه بعد أن ينمو تطبيقك إلى حجم معين ، فقد تواجه تسربًا للذاكرة.

بشكل عام ، يمكن اعتبار تسريبات الذاكرة بمثابة الذاكرة المخصصة للتطبيق ، والتي لم تعد بحاجة إليها ، ولكن لم يتم تحريرها. بمعنى آخر ، هذه الكائنات فشلت في الخضوع لعمليات جمع القمامة.

كيف يعمل جمع القمامة؟


أثناء إجراء جمع القمامة ، وهو أمر منطقي تمامًا ، يتم تنظيف كل ما يمكن اعتباره "قمامة". يقوم جامع القمامة بتنظيف الذاكرة التي لم يعد يحتاجها التطبيق. من أجل معرفة مناطق الذاكرة التي لا يزال التطبيق بحاجة إليها ، يستخدم جامع البيانات المهملة خوارزمية "وضع علامة واكتساح" (خوارزمية وضع العلامات). كما يوحي الاسم ، تتكون هذه الخوارزمية من مرحلتين - مرحلة وضع العلامات ومرحلة المسح.

▍ مرحلة العلم


يتم عرض الأشياء والروابط إليها في شكل شجرة. جذر الشجرة ، في الشكل التالي ، عقدة root. في JavaScript ، هذا كائن window. كل كائن له علامة خاصة. دعونا نسمي هذا العلم marked. في مرحلة الإبلاغ ، أولاً وقبل كل شيء ، markedيتم تعيين جميع الأعلام إلى قيمة false.


في البداية ، يتم تعيين أعلام الكائنات التي تم وضع علامة عليها على false.

ثم يتم اجتياز شجرة الكائن. كل الأعلام منmarkedالأشياء يمكن الوصول إليها من العقدةrootيتم تعيين لtrue. وتبقى أعلام تلك الأشياء التي لا يمكن الوصول إليها في القيمةfalse.

يعتبر الكائن غير قابل للوصول إذا كان لا يمكن الوصول إليه من الكائن الجذر.


يتم تمييز الكائنات التي يمكن الوصول إليها على أنها مميزة = صحيح ، الكائنات التي لا يمكن الوصول إليها على أنها مميزة = false

ونتيجة لذلك ،markedتظلجميع علاماتالكائنات التي لا يمكن الوصول إليها في القيمةfalse. لم يتم تحرير الذاكرة بعد ، ولكن بعد الانتهاء من مرحلة وضع العلامات ، أصبح كل شيء جاهزًا لمرحلة التنظيف.

▍ مرحلة التنظيف


يتم مسح الذاكرة بدقة في هذه المرحلة من الخوارزمية. هنا ، يتم تدمير جميع الكائنات التي markedلا يمكن الوصول إليها (تلك التي يبقى علمها في القيمة false) بواسطة جامع القمامة.


شجرة الكائن بعد جمع القمامة. يتم إتلاف كل الكائنات التي تم تعيين علمها على false إلى جامع البيانات المهملة.

يتم تنفيذ مجموعة البيانات المهملة بشكل دوري أثناء تشغيل برنامج JavaScript. خلال هذا الإجراء ، يتم تحرير الذاكرة التي يمكن تحريرها.

ربما ينشأ السؤال التالي هنا: "إذا قام جامع القمامة بإزالة جميع الكائنات التي تم وضع علامة عليها على أنها غير قابلة للوصول - كيف يتم إنشاء تسرب للذاكرة؟".

النقطة هنا هي أن الكائن لن يقوم بمعالجة الكائن إذا لم يكن التطبيق بحاجة إليه ، ولكن لا يزال بإمكانك الوصول إليه من العقدة الجذرية لشجرة الكائن.

لا تستطيع الخوارزمية معرفة ما إذا كان التطبيق سيستخدم جزءًا من الذاكرة يمكنه الوصول إليه أم لا. مبرمج فقط لديه مثل هذه المعرفة.

تسرب الذاكرة الزاوي


في معظم الأحيان ، يحدث تسرب للذاكرة بمرور الوقت عندما يتم إعادة عرض المكون بشكل متكرر. على سبيل المثال - من خلال التوجيه ، أو نتيجة لاستخدام التوجيه *ngIf. لنفترض في حالة يعمل فيها بعض المستخدمين المتقدمين مع التطبيق طوال اليوم دون تحديث صفحة التطبيق في المتصفح.

من أجل إعادة إنتاج هذا السيناريو ، سنقوم بإنشاء مكونين. ستكون هذه المكونات AppComponentو SubComponent.

@Component({
  selector: 'app-root',
  template: `<app-sub *ngIf="hide"></app-sub>`
})
export class AppComponent {
  hide = false;

  constructor() {
    setInterval(() => this.hide = !this.hide, 50);
  }
}

AppComponentيستخدم قالب المكون المكون app-sub. الشيء الأكثر إثارة للاهتمام هنا هو أن مكوننا يستخدم وظيفة setIntervalتقوم بتبديل العلم hideكل 50 مللي ثانية. ينتج عن هذا إعادة عرض مكون كل 50 مللي ثانية app-sub. أي ، يتم إنشاء إنشاء أمثلة جديدة للفئة SubComponent. يحاكي هذا الرمز سلوك مستخدم يعمل طوال اليوم مع تطبيق ويب دون تحديث صفحة في متصفح.

قمنا ، في SubComponent، بتنفيذ سيناريوهات مختلفة ، يبدأ استخدامها في الظهور ، بمرور الوقت ، التغييرات في حجم الذاكرة التي يستخدمها التطبيق. لاحظ أن المكونAppComponentدائما هو نفسه. في كل سيناريو ، سنكتشف ما إذا كان ما نتعامل معه هو تسرب للذاكرة من خلال تحليل استهلاك الذاكرة لعملية المتصفح.

إذا زاد استهلاك الذاكرة للعملية بمرور الوقت ، فهذا يعني أننا نواجه تسربًا للذاكرة. إذا كانت العملية تستخدم مقدارًا ثابتًا تقريبًا من الذاكرة ، فهذا يعني إما عدم وجود تسرب للذاكرة ، أو أن التسرب ، على الرغم من وجوده ، لا يظهر بشكل واضح إلى حد ما.

▍ السيناريو رقم 1: ضخم للحلقة


يمثل السيناريو الأول لدينا حلقة تعمل 100000 مرة. في الحلقة ، تتم إضافة قيم عشوائية إلى الصفيف. دعونا لا ننسى أن المكون يُعاد تقديمه كل 50 مللي ثانية. ألق نظرة على الكود وفكر فيما إذا قمنا بإنشاء تسرب للذاكرة أم لا.

@Component({
  selector:'app-sub',
  // ...
})
export class SubComponent {
  arr = [];

  constructor() {
    for (let i = 0; i < 100000; ++i) {
      this.arr.push(Math.random());
    }
  }
}

على الرغم من أنه لا يجب إرسال هذا الرمز إلى الإنتاج ، إلا أنه لا يؤدي إلى حدوث تسرب للذاكرة. أي أن استهلاك الذاكرة لا يتجاوز النطاق المحدود بقيمة 15 ميغا بايت. نتيجة لذلك ، لا يوجد تسرب للذاكرة. أدناه سنتحدث عن سبب ذلك.

▍ السيناريو 2: اشتراك BehaviorSubject


في هذا السيناريو ، نشترك في BehaviorSubjectقيمة ثابتة ونعينها. هل يوجد تسرب للذاكرة في هذا الرمز؟ كما كان من قبل ، لا تنس أن يتم تقديم المكون كل 50 مللي ثانية.

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  subject = new BehaviorSubject(42);
  
  constructor() {
    this.subject.subscribe(value => {
        const foo = value;
    });
  }
}

هنا ، كما في المثال السابق ، لا يوجد تسرب للذاكرة.

▍ السيناريو 3: تعيين قيمة لحقل فئة داخل اشتراك


هنا ، يتم تقديم نفس الرمز تقريبًا كما في المثال السابق. والفرق الرئيسي هو أن القيمة لا يتم تعيينها إلى ثابت ، ولكن إلى حقل فئة. والآن ، هل تعتقد أن هناك تسرب في الكود؟

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  subject = new BehaviorSubject(42);
  randomValue = 0;
  
  constructor() {
    this.subject.subscribe(value => {
        this.randomValue = value;
    });
  }
}

إذا كنت تعتقد أنه لا يوجد تسرب هنا - فأنت على حق تمامًا.

في السيناريو رقم 1 لا يوجد اشتراك. في السيناريوهين رقم 2 و 3 ، اشتركنا في دفق الكائن المرصود الذي تمت تهيئته في المكون الخاص بنا. يبدو أننا آمنون من خلال الاشتراك في تدفقات المكونات.

ولكن ماذا لو أضفنا خدمة إلى مخططنا؟

السيناريوهات التي تستخدم الخدمة


في السيناريوهات التالية ، سنقوم بمراجعة الأمثلة المذكورة أعلاه ، ولكن هذه المرة سنشترك في الدفق الذي توفره الخدمة DummyService. هنا هو رمز الخدمة.

@Injectable({
  providedIn: 'root'
})
export class DummyService {

   some$ = new BehaviorSubject<number>(42);
}

أمامنا خدمة بسيطة. هذه مجرد خدمة توفر دفق ( some$) في شكل حقل فئة عامة.

▍ السيناريو 4: الاشتراك في دفق وتعيين قيمة إلى ثابت محلي


سنعيد إنشاء نفس المخطط الذي تم وصفه سابقًا هنا. ولكن هذه المرة ، نشترك في البث some$من DummyService، وليس إلى حقل المكون.

هل هناك تسرب للذاكرة؟ مرة أخرى ، عند الإجابة على هذا السؤال ، تذكر أنه تم استخدام المكون AppComponentوعرضه عدة مرات.

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  
  constructor(private dummyService: DummyService) {
    this.dummyService.some$.subscribe(value => {
        const foo = value;
    });
  }
}

والآن قمنا أخيرًا بتسريب ذاكرة. لكن هذا تسرب صغير. أعني بـ "تسرب صغير" واحدًا يؤدي بمرور الوقت إلى زيادة بطيئة في مقدار الذاكرة المستهلكة. هذه الزيادة بالكاد ملحوظة ، ولكن فحص سريع لقطعة الكومة أظهر وجود العديد من الحالات غير المحذوفة Subscriber.

▍ السيناريو 5: الاشتراك في خدمة وتعيين قيمة لحقل فئة


هنا نشترك مرة أخرى في dummyService. لكن هذه المرة نعين القيمة الناتجة لحقل الفئة ، وليس ثابتًا محليًا.

@Component({
    selector:'app-sub',
    // ...
})
export class SubComponent {
  randomValue = 0;
  
  constructor(private dummyService: DummyService) {
    this.dummyService.some$.subscribe(value => {
        this.randomValue = value;
    });
  }
}

وهنا أنشأنا أخيرًا تسربًا كبيرًا للذاكرة. تجاوز استهلاك الذاكرة بسرعة ، خلال دقيقة ، 1 غيغابايت. لنتحدث عن سبب ذلك.

henمتى حدث تسرب للذاكرة؟


ربما لاحظت أنه في السيناريوهات الثلاثة الأولى لم نتمكن من إحداث تسرب للذاكرة. هذه السيناريوهات الثلاثة لها شيء مشترك: كل الروابط محلية للمكون.

عندما نشترك في كائن يمكن ملاحظته ، فإنه يخزن قائمة المشتركين. رد الاتصال الخاص بنا موجود أيضًا في هذه القائمة ، ويمكن أن يشير رد الاتصال إلى المكون الخاص بنا.


لا يوجد تسرب للذاكرة

عندما يتم تدمير أحد المكونات ، أي عندما لا يكون Angular مرتبطًا به ، مما يعني أنه لا يمكن الوصول إلى المكون من العقدة الجذرية ، لا يمكن الوصول إلى الكائن المرصود وقائمة المشتركين فيه من العقدة الجذرية أيضًا. نتيجة لذلك ، يتم تجميع كائن المكون بأكمله القمامة.

طالما أننا مشتركون في كائن يمكن ملاحظته ، ولا توجد روابط إليه إلا داخل المكون ، فلا تنشأ مشاكل. ولكن عندما يبدأ تشغيل الخدمة ، يتغير الوضع.


تسرب الذاكرة

بمجرد الاشتراك في كائن يمكن ملاحظته من قبل خدمة أو فئة أخرى ، أنشأنا تسرب للذاكرة. ويرجع ذلك إلى الكائن المرصود بسبب قائمة المشتركين فيه. وبسبب هذا ، يمكن الوصول إلى رد الاتصال ، وبالتالي المكون ، من العقدة الجذرية ، على الرغم من أن Angular لا تحتوي على إشارة مباشرة إلى المكون. نتيجة لذلك ، لا يلمس جامع القمامة الكائن المطابق.

سأوضح: يمكنك استخدام مثل هذه الإنشاءات ، ولكن عليك العمل معها بشكل صحيح ، وليس كما نفعل.

عمل الاشتراك الصحيح


لتجنب تسرب الذاكرة ، من المهم إلغاء الاشتراك بشكل صحيح من الكائن الذي تمت ملاحظته ، وذلك من خلال القيام بذلك عندما لا تكون هناك حاجة إلى الاشتراك. على سبيل المثال ، عندما يتم إتلاف أحد المكونات. هناك طرق عديدة لإلغاء الاشتراك من كائن ملاحظ.

تشير تجربة تقديم المشورة لأصحاب المشاريع الكبيرة إلى أنه من الأفضل في هذه الحالة استخدام الكيان الذي destroy$أنشأه الفريق new Subject<void>()بالاشتراك مع المشغل takeUntil.

@Component({
  selector:'app-sub',
  // ...
})
export class SubComponent implements OnDestroy {

  private destroy$: Subject<void> = new Subject<void>();
  randomNumber = 0;

  constructor(private dummyService: DummyService) {
      dummyService.some$.pipe(
          takeUntil(this.destroy$)
      ).subscribe(value => this.randomNumber = value);
  }

  ngOnDestroy(): void {
      this.destroy$.next();
      this.destroy$.complete();
  }
}

هنا نقوم بإلغاء الاشتراك من الاشتراك باستخدام destroy$و عامل التشغيل takeUntilبعد تدمير المكون.

قمنا بتطبيق خطاف دورة حياة في المكون ngOnDestroy. في كل مرة يتم تدمير عنصر، فإننا ندعو destroy$وسائل nextو complete.

المكالمة completeمهمة جدًا لأن هذه المكالمة تمسح الاشتراك من destroy$.

ثم نستخدم عامل التشغيل takeUntilونمرره destroy$. يضمن ذلك إلغاء الاشتراك (أي أننا قمنا بإلغاء الاشتراك من الاشتراك) بعد إتلاف المكون.

كيف تتذكر مسح الاشتراكات؟


من السهل نسيان إضافة المكون destroy$ونسيان الاتصال next، وفي completeدورة حياة Hook ngOnDestroy. على الرغم من حقيقة أنني علمت ذلك للفرق التي تعمل في المشاريع ، إلا أنني غالبًا ما نسيت ذلك بنفسي.

لحسن الحظ ، هناك قاعدة رائعة من اللنتر ، وهي جزء من مجموعة من القواعد التي تسمح لك بضمان إلغاء الاشتراك الصحيح من الاشتراكات. يمكنك تعيين مجموعة قواعد مثل هذا:

npm install @angular-extensions/lint-rules --save-dev

ثم يجب أن يكون متصلاً بـ tslint.json:

{
  "extends": [
    "tslint:recommended",
    "@angular-extensions/lint-rules"
  ]
}

أوصي بشدة باستخدام هذه المجموعة من القواعد في مشاريعك. سيوفر لك هذا العديد من ساعات تصحيح الأخطاء في البحث عن مصادر تسرب الذاكرة.

ملخص


في Angular ، من السهل جدًا إنشاء موقف يؤدي إلى تسرب الذاكرة. حتى التغييرات الصغيرة في التعليمات البرمجية في الأماكن ، التي لا يجب أن تكون مرتبطة بتسربات الذاكرة ، يمكن أن تؤدي إلى عواقب وخيمة.

أفضل طريقة لتجنب تسرب الذاكرة هي إدارة اشتراكاتك بشكل صحيح. لسوء الحظ ، يتطلب تشغيل اشتراكات التنظيف دقة كبيرة من المطور. هذا سهل النسيان. لذلك ، يوصى بتطبيق القواعد @angular-extensions/lint-rulesالتي تساعدك على تنظيم العمل الصحيح مع اشتراكاتك.

هذا هو المستودع مع الكود الكامن وراء هذه المواد.

هل واجهت تسرب للذاكرة في تطبيقات Angular؟


All Articles