كشفت الشعبية المتزايدة للحاويات واستخدامها مع مجموعات التحكم عن وجود مشكلة خطيرة في قابلية التوسع ، مما يؤدي إلى انخفاض كبير في الأداء على الأجهزة الكبيرة. المشكلة هي أن وقت التجاوز لذاكرة التخزين المؤقت لـ SLAB يعتمد من الناحية التربيعية على عدد الحاويات ، وأن الاستهلاك النشط لكميات كبيرة من الذاكرة في فترة قصيرة يمكن أن يتسبب في دخول النظام في حلقة مشغول ، ويستهلك 100٪ من وقت المعالج. اليوم أود أن أخبركم كيف تم حل هذه المشكلة عن طريق تغيير خوارزمية المحاسبة لاستخدام مجموعة التحكم memcg لاستخدام كائنات ذاكرة التخزين المؤقت لـ SLAB وتحسين وظيفة shrink_slab ().

لماذا أثير سؤال تحسين العمليات في النواة؟ بدأ كل شيء بحقيقة أن أحد عملائنا ، باستخدام الحاويات ومجموعات التحكم في الذاكرة (memcg) بنشاط ، لفت الانتباه إلى القمم الغريبة لاستهلاك موارد المعالج التي تحدث من وقت لآخر. كان الحمل العادي للنظام حوالي 50٪ ، وفي أوقات الذروة ، تم الاستغناء عن 100٪ من وقت المعالج ، واستهلك النواة جميعها تقريبًا.
كانت العقدة نفسها متعددة المستخدمين ، وتم إطلاق حوالي 200 حاوية OpenVZ عليها. أظهر التحليل أن عددًا كبيرًا من المستخدمين قاموا بإنشاء حاويات Docker متداخلة وتسلسلات هرمية متعددة المستويات لمجموعات التحكم في الذاكرة. تحتوي كل حاوية مخصصة من المستوى الأعلى على حوالي 20 نقطة تحميل و 20 مجموعة ذاكرة للتحكم (memcg) تم إنشاؤها بواسطة systemd. بالإضافة إلى ذلك ، كانت هناك نقاط تحميل ومجموعات تحكم تم إنشاؤها بواسطة عامل الميناء المذكور أعلاه. ببساطة ، تم تحميل العقدة بكثافة ، وكان الحمل عليها أقوى بكثير من المتوسط لجميع عملائنا الآخرين. لقد كنا مهتمين بإيجاد سبب ظهور هذه القمم ، حيث يمكن أن تظهر نفس المشكلة على الأجهزة الأقل ازدحامًا ، حيث كان من الصعب ملاحظتها (على سبيل المثال ، أعط القمم في وقت + 5٪ sys ، مما يؤدي إلى انخفاض الأداء).
من خلال التلاعب بالكمال ، تمكنت من اللحاق بالركب وإزالة الممر. لقد اتضح أن معظم وقت المعالج يقضي في محو ذاكرة التخزين المؤقت لـ SLAB ، أي ذاكرات التخزين الكبيرة الفائقة:
- 100,00% 0,00% kswapd0 [kernel.vmlinux] [k] kthread - 99,31% balance_pgdat - 82,11% shrink_zone - 61,69% shrink_slab - 58,29% super_cache_count + 54,56% list_lru_count_one
هنا يستحق تقديم شرح والتفصيل حول هذه المسألة بمزيد من التفاصيل. يعلم الجميع أن النواة تخزن البيانات غير المستخدمة لفترة من الوقت قبل تحرير الذاكرة في النهاية. تستفيد النواة من هذا المبدأ على نطاق واسع. على سبيل المثال ، تحتوي ذاكرة التخزين المؤقت للصفحة على صفحات من البيانات المتعلقة بالملف ، مما يؤدي إلى زيادة سرعة الوصول إليها بشكل متكرر عند القراءة (لأنك لا تحتاج إلى الوصول إلى القرص مرة أخرى). في حالتنا ، نشأت المشكلة مع ذاكرة التخزين المؤقتة للبيانات الوصفية الفائقة الموجودة في قائمتين LRU: s_dentry_lru و s_inode_lru.
LRU (الأقل استخدامًا مؤخرًا)
يشير الهيكل lru_list إلى مجموعة من القوائم المرتبطة ، وكل عنصر memcg نشط يتوافق مع عنصر واحد (list_lru_one) في هذه المجموعة. عندما لا يتم استخدام كائن SLAB معيّن بواسطة kernel ، يضيفه kernel إلى إحدى قوائم الصفيف المرتبطة (بناءً على memcg الذي ينتمي إليه الكائن ، أو بمعنى تقريبًا ، أي memcg العملية المستخدمة عند إنشاء هذا الكائن). يتم وصف الصفيف نفسه كما يلي (lru_list :: node :: memcg_lrus):
struct list_lru_memcg { struct rcu_head rcu; struct list_lru_one *lru[0]; }; struct list_lru_one { struct list_head list; long nr_items; };
يشير lru [0] إلى قائمة الكائنات المتعلقة memcg بالكود 0 ؛
يشير lru [1] إلى قائمة الكائنات المتعلقة memcg بالكود 1 ؛
...
يشير lru [n] إلى قائمة الكائنات المتعلقة بـ memcg ذات المعرف n ؛
يسرد LRU قائمة s_dentry_lru و s_inode_lru تظهر في مشكلتنا ، وكما يوحي الاسم ، فهي تحتوي على كائنات غير مستعملة في نظام الملفات و inode.
في المستقبل ، إذا لم يكن هناك ذاكرة كافية في النظام أو memcg معين ، يتم تحرير بعض عناصر القائمة في النهاية ، وتقوم آلية خاصة تسمى shrinker بهذا.
انكماش
عندما تحتاج النواة إلى تخصيص صفحات الذاكرة ، ولكن لا توجد ذاكرة خالية على عقدة NUMA أو في النظام ، تبدأ آلية تنظيفها. إنه يحاول رمي أو تجاهل مقدار معين من القرص: 1) صفحات من محتويات الملفات من ذاكرة التخزين المؤقت للصفحة ؛ 2) الصفحات المتعلقة بالذاكرة المجهولة في المبادلة ، و 3) كائنات SLAB المخزنة مؤقتًا (المشكلة التي واجهناها مرتبطة بها).
لا يؤثر التخلص من جزء من كائنات SLAB المخزنة مؤقتًا بشكل مباشر على إصدار الصفحات: حجمها ، كقاعدة عامة ، أصغر بكثير من حجم الصفحة ، وتحتوي صفحة واحدة على مئات الكائنات. عندما يتم تحرير جزء من الكائنات ، تظهر فجوات ذاكرة حرة في صفحات SLAB ، والتي يمكن استخدامها لإنشاء كائنات SLAB أخرى. يتم قبول هذه الخوارزمية في النواة عن قصد: إنها بسيطة وفعالة للغاية. يمكن للقارئ المهتم رؤية الصيغة الخاصة بتحديد جزء من الكائنات لتنظيفها في وظيفة do_shrink_slab ().
تقوم هذه الوظيفة بالتنظيف الفعلي لجزء من الكائنات ، مسترشدة بالوصف الذي تم تمريره إليها في تقلص الهيكل:
static unsigned long do_shrink_slab(struct shrink_control *shrinkctl, struct shrinker *shrinker, int priority) { … freeable = shrinker->count_objects(shrinker, shrinkctl); if (freeable == 0) return 0; total_scan = _(freeable); while (total_scan >= batch_size) { ret = shrinker->scan_objects(shrinker, shrinkctl); total_scan -= shrinkctl->nr_scanned; } ... }
فيما يتعلق بالكتلة الفائقة المتقلصة ، يتم تنفيذ هذه الوظائف على النحو التالي. يحتفظ كل كتلة رئيسية بقائمتها s_dentry_lru و s_inode_lru الخاصتين بالكائنات غير المستخدمة المرتبطة به:
struct super_block { ... struct shrinker s_shrink; ... struct list_lru s_dentry_lru; struct list_lru s_inode_lru; … };
إرجاع الأسلوب .count_objects عدد الكائنات:
static unsigned long super_cache_count(struct shrinker *shrink, struct shrink_control *sc) { total_objects += list_lru_shrink_count(&sb->s_dentry_lru, sc); total_objects += list_lru_shrink_count(&sb->s_inode_lru, sc); total_objects = vfs_pressure_ratio(total_objects); return total_objects; }
الأسلوب .scan_objects فعليًا يحرر الكائنات:
static unsigned long super_cache_scan(struct shrinker *shrink, struct shrink_control *sc) { prune_dcache_sb(sb, sc); prune_icache_sb(sb, sc); }
يتم تمرير عدد الكائنات التي سيتم تحريرها في المعلمة sc. أيضًا ، يتم الإشارة إلى memcg هناك ، والتي يجب إخراج الكائنات منها من LRU:
struct shrink_control { int nid; unsigned long nr_to_scan; struct mem_cgroup *memcg; };
وبالتالي ، يحدد prune_dcache_sb () قائمة مرتبطة من مجموعة الصفيف list_lru_memcg :: lru [] ويعمل معها. Prune_icache_sb () يفعل نفس الشيء.
الخوارزمية الالتفافية القديمة الالتفافية
باستخدام الطريقة القياسية ، "إخراج" الكائنات من SLAB مع نفاد الذاكرة في
يحدث sc-> target_mem_cgroup على النحو التالي:
shrink_node() { … struct mem_cgroup *root = sc->target_mem_cgroup; memcg = mem_cgroup_iter(root, NULL, &reclaim); do { … shrink_slab(memcg, ...); … } while ((memcg = mem_cgroup_iter(root, memcg, &reclaim))); ... }
نذهب من خلال جميع memcg الطفل وندعو shrink_slab () لكل منهم. بعد ذلك ، في دالة shrink_slab () ، نذهب إلى جميع المتقلصات ولكل منهم استدعاء do_shrink_slab ():
static unsigned long shrink_slab(gfp_t gfp_mask, int nid, struct mem_cgroup *memcg, int priority) { list_for_each_entry(shrinker, &shrinker_list, list) { struct shrink_control sc = { .nid = nid, .memcg = memcg, }; ret = do_shrink_slab(&sc, shrinker, ...); } }
تذكر أنه لكل كتلة عظمى ، تتم إضافة انكماشها الخاص إلى هذه القائمة. دعنا نحسب عدد المرات التي سيتم فيها استدعاء do_shrink_slab () للحالة مع 200 حاوية من 20 memcg و 20 نقطة تحميل في كل منها. في المجموع ، لدينا 200 * 20 نقطة تحميل و 200 * 20 مجموعات التحكم. إذا لم تكن هناك ذاكرة كافية في أعلى memcg ، فسنضطر لتجاوز كل memcg التابعة لها (أي ، كل شيء بشكل عام) ، ولكل منهم استدعاء كل من المتقلص من قائمة shrinker_list. وبالتالي ، ستقوم النواة بإجراء 200 * 20 * 200 * 20 = 16000000 مكالمات إلى الدالة do_shrink_slab ().
علاوة على ذلك ، سيكون العدد الهائل من المكالمات إلى هذه الوظيفة عديم الفائدة: عادةً ما تكون الحاويات معزولة فيما بينها ، واحتمال استخدام CT1 super_block2 الذي تم إنشاؤه في CT2 منخفض بشكل عام. أو ، ما هو نفسه ، إذا كانت memcg1 عبارة عن مجموعة تحكم من CT1 ، فسيكون العنصر المقابل في super_block2-> s_dentry_lru-> node-> memcg_lrus-> lru [memcg1_id] صفيفًا فارغًا ولا توجد نقطة في استدعاء do_shrink_slab ().
يمكن نمذجة هذه المشكلة باستخدام برنامج نصي بسيط للباش (يتم استخدام البيانات من patchset ، والتي تم نقلها فيما بعد إلى kernel ، هنا):
$echo 1 > /sys/fs/cgroup/memory/memory.use_hierarchy $mkdir /sys/fs/cgroup/memory/ct $echo 4000M > /sys/fs/cgroup/memory/ct/memory.kmem.limit_in_bytes $for i in `seq 0 4000`; do mkdir /sys/fs/cgroup/memory/ct/$i; echo $$ > /sys/fs/cgroup/memory/ct/$i/cgroup.procs; mkdir -ps/$i; mount -t tmpfs $is/$i; touch s/$i/file; done
دعونا نرى ما يحدث إذا قمت باستدعاء إجراء إعادة تعيين ذاكرة التخزين المؤقت 5 مرات على التوالي:
$time echo 3 > /proc/sys/vm/drop_caches
يستمر التكرار الأول 14 ثانية ، لأن الكائنات المخزنة مؤقتًا موجودة فعليًا في الذاكرة:
0.00 user 13.78 system 0: 13.78 المنقضي 99٪ CPU.يستغرق التكرار الثاني 5 ثوانٍ ، على الرغم من عدم وجود المزيد من الكائنات:
0.00user 5.59system 0: 05.60eleled 99٪ CPU.يستغرق التكرار الثالث 5 ثوانٍ:
0.00user 5.48system 0: 05.48elapsed 99٪ CPUيستغرق التكرار الرابع 8 ثوانٍ:
0.00user 8.35system 0: 08.35elapsed 99٪ CPUيستغرق التكرار الخامس 8 ثوانٍ:
0.00user 8.34system 0: 08.35elapsed 99٪ CPUأصبح من الواضح أن خوارزمية الالتفافية المتقلصة المستخدمة من قِبل الفانيليا ليست مثالية ، ونحن بحاجة إلى تغييرها للأفضل من حيث قابلية التوسع.
خوارزمية الالتفافية الجديدة المتقلبة
من الخوارزمية الجديدة أردت تحقيق ما يلي:
- تحريره من عيوب القديم و
- لا تضيف أقفال جديدة. استدعاء do_shrink_slab () فقط عندما يكون ذلك منطقيًا (أي ، القائمة المرتبطة المقابلة من صفيف s_dentry_lru أو من صفيف s_inode_lru ليست فارغة) ، لكن لا تصل مباشرة إلى ذاكرة القائمة المرتبطة.
كان من الواضح أنه لا يمكن توفير ذلك إلا من خلال بنية بيانات جديدة أعلى من تقلص غير المتجانسة (هناك تقلص ليس فقط من superblock ، ولكن أيضا كائنات البيانات الأخرى غير الموصوفة في هذه المقالة. يمكن للقارئ التعرف عليها من خلال البحث عن الكلمة الرئيسية prealloc_shrinker () في رمز النواة). يجب أن تسمح بنية البيانات الجديدة بترميز حالتين: "من المنطقي استدعاء do_shrink_slab ()" و "من غير المنطقي استدعاء do_shrink_slab ()".
تم رفض بنية بيانات نوع IDA بسبب يستخدمون الأقفال داخل أنفسهم. هيكل البيانات لحقل البتات مناسب تمامًا لهذا الدور: فهو يتيح التعديل الذري للبتات الفردية ، وفي تركيبة مع حواجز الذاكرة تسمح لك ببناء خوارزمية فعالة دون استخدام الأقفال.
يحصل كل تقليص على المعرف الفريد الخاص به (shrinker :: id) ، ويحصل كل memcg على صورة نقطية يمكنها استيعاب أكبر معرّف مسجل حاليًا. عند إضافة العنصر الأول إلى s_dentry_lru-> العقدة-> memcg_lrus-> lru [memcg_id] ، يتم تعيين الصورة النقطية memcg المقابلة إلى 1 بت مع معرف تقلص الأرقام>. نفس الشيء مع s_inode_id.
الآن يمكن تحسين حلقة في shrink_slab () لتجاوز فقط shrinkers الضرورية:
unsigned long shrink_slab() { … for_each_set_bit(i, map, shrinker_nr_max) { … shrinker = idr_find(&shrinker_idr, i); … do_shrink_slab(&sc, shrinker, priority); … } }
(يتم تنفيذ تنظيف البت أيضًا عند دخول المتقلص الحالة "ليس من المنطقي استدعاء do_shrink_slab (). راجع
الالتزام على جيثب للحصول على التفاصيل.
إذا كررت اختبار إعادة تعيين ذاكرة التخزين المؤقت ، فإن استخدام الخوارزمية الجديدة يُظهر نتائج أفضل بكثير:
$time echo 3 > /proc/sys/vm/drop_caches
التكرار الأول:
0.00user 1.10system 0: 01.10 المنقضي 99 ٪ وحدة المعالجة المركزية
التكرار الثاني:
0.00user 0.00system 0: 00.01elapsed 64٪ CPU
التكرار الثالث:
0.00user 0.01system 0: 00.01elapsed 82٪ CPU
التكرار الرابع:
0.00user 0.00system 0: 00.01elapsed 64٪ CPU
التكرار الخامس:
0.00user 0.01system 0: 00.01elapsed 82٪ CPUمدة التكرار من الثاني إلى الخامس هي 0.01 ثانية ،
أسرع 548 مرة من قبل.نظرًا لإجراءات مماثلة لإعادة تعيين ذاكرة التخزين المؤقت تحدث مع كل نقص في الذاكرة على الجهاز ، فإن هذا التحسين يحسن بشكل كبير من تشغيل الآلات مع عدد كبير من الحاويات ومجموعات التحكم في الذاكرة. تم قبول
مجموعة من التصحيحات (17 قطعة) في قلب الفانيليا ، ويمكنك العثور عليها هناك بدءًا من الإصدار 4.19.
أثناء مراجعة التصحيحات ، ظهر أحد موظفي Google ، واتضح أن لديهم نفس المشكلة. لذلك ، تم اختبار بقع أخرى على نوع مختلف من الحمل.
ونتيجة لذلك ، تم اعتماد patchset من التكرار التاسع. ودخولها إلى الفانيليا الأساسية استغرق حوالي 4 أشهر. اليوم أيضًا ، يتم تضمين patchset في Virtuozzo 7 kernel الخاصة بنا ، بدءًا من الإصدار vz7.71.9