A 40ms Go garbage collector pause caused by swap
🇬🇧 English
I’m writing this so you don’t slap your forehead like I almost did when I decided to run swap in production to absorb memory spikes. I had a cgroup with two processes: one is a Go process that calls io. ReadAll and then proto.
Unmarshal, creating a blob and then a graph struct (which is marked as scan by Go’s allocator). The other process is an HTTP server that mostly stays quiet. Whenever the collector runs, it reads those scan spans, pointer by pointer, and decides what to do with them.
So I thought: ok, under memory pressure, the kernel is going to evict pages to the swap device, but since the eviction is per cgroup, and not per process, both processes’ pages are going to be evicted - so there is only a small chance that this will turn into a sad dance of swap-in and swap-out between the kernel and the garbage collector. I was wrong. While experimenting with this, I found a problem that could’ve hurt me: Go’s garbage collector reads its metadata (outside the heap, in a region that is not freed) in a stop-the-world pause, and that metadata can be in swap.
I did a mock run on a Hetzner box using kernel 6.8 with MGLRU enabled. The median pause was around 51 us. With the metadata on the NVMe, the worst pause was 40ms.
To check where those 40ms went, I wrote a small bpf script that counts page faults while the world is stopped. This was the worst one: 39902 us, faults during it 228, 39013 us in faults. 39 of those 40ms were spent in 228 page faults. Those faults happened inside the GC’s bookkeeping.
That’s a potential failure mode. Go’s GC has to stop the world at two points: when it performs a sweep termination, and when it performs a mark termination. We had 312 of those pauses in 30 minutes.
So here is why this happens: the runtime allocates those pages. They are not freed, but reused. Those pages are read in GC cycles.
Because the kernel evicts pages by age, it sends the least recently accessed pages to swap. The GC runs, stops the world, tries to read those pages, but now we have a major page fault. The kernel needs to read PT Es, and then call do_swap_page, find a new frame, charge it to the cgroup, read the pages, submit a bio, wait for the disk, and put them back in memory - just to keep it short.
Those 40ms seem harmless at first. But we are talking about a stop-the-world pause. Those 40ms mean everything has stopped - in Go’s terminology, every P has stopped, so, for example, if a goroutine was waiting for I/O, during that pause the I/O might return and there would be no one to handle it. 40ms is 800 times the median pause.
It happens two or three times per memory spike during the test. It is a lot.
🇸🇦 العربية
انقطاع مؤقت لـ Go GC بسبب إخلاء النطاق المبادل
أكتب هذا لأنك لن تضرب جبينك مثلي تقريبًا عندما قررت تشغيل النطاق المبادل في الإنتاج لامتصاص ذروات الذاكرة. كان لدي cgroup يحتوي على عمليةين: الأولى هي عملية Go تستدعي io. ReadAll ثم proto. Unmarshal، وتنشئ blob ثم هيكل بيانات رسم بياني (وهو مُعلم ككائن فحص بواسطة مخصّص Go). العملية الأخرى هي خادم HTTP يظل هادئًا في معظم الأحيان. في كل مرة يعمل فيها المجمع، يقرأ تلك النطاقات القابلة للفحص، مؤشرًا بمؤشر، ويقرر ماذا يفعل بها. لذا فكرت: حسنًا، تحت ضغط الذاكرة، سيقوم النواة بإخلاء الصفحات إلى جهاز النطاق المبادل، لكن بما أن الإخلاء هو لكل cgroup، وليس لكل عملية، فإن صفحات كلا العمليتين سيتم إخلاؤها، لذا فرصة حدوث رقصة حزينة من التبادل بين النواة ومجمع النفايات ضعيفة. كنت مخطئًا. أثناء تجريبي لهذا الأمر، وجدت مشكلة قد تكون أذرتني: مجمع نفايات Go يقرأ بياناته الوصفية (خارج الكومة، في منطقة غير محررة) خلال إيقاف عالمي كامل، وهذه البيانات الوصفية قد تكون في النطاق المبادل. قمت بتشغيل تجريبي على جهاز Hetzner باستخدام نواة 6.8 مع تفعيل MGLRU. كانت الإيقافة المتوسطة حوالي 51 مايكروثانية. مع البيانات الوصفية على الذاكرة غير القابلة للبرمجة NVMe، كانت أسوأ إيقافة 40 مليثانية. لفحص أين ذهبت تلك 40 مليثانية، كتبت برنامج bpf صغير يحسب أخطاء الصفحات أثناء توقف العالم. هذه كانت الأسوأ: 39902 مايكروثانية، أخطاء خلالها 228، 39013 مايكروثانية في الأخطاء. 39 من تلك 40 مليثانية استغرقت في 228 خطأ صفحة. حدثت تلك الأخطاء داخل كتابة GC. هذه نمط فشل محتمل.
GC في Go يجب أن يوقف العالم في نقطتين: عندما ينهي المسح، وعندما ينهي التمثيل. كنا لدينا 312 من هذه الإيقافات في غضون 30 دقيقة. إليك لماذا يحدث ذلك: يخصص وقت التشغيل تلك الصفحات. لا تُحرر، لكن تُعاد استخدامها. تُقرأ هذه الصفحات في دورات GC. لأن النواة تُخلّي الصفحات حسب العمر، فإنها ترسل الصفحات الأقل حديثًا إلى النطاق المبادل. يعمل GC، يوقف العالم، يحاول قراءة تلك الصفحات، لكن الآن لدينا خطأ صفحة رئيسي. النواة بحاجة إلى قراءة الـ PTE، ثم استدعاء do_swap_page، العثور على إطار جديد، تحميله على cgroup، قراءة الصفحات، إرسال bio، الانتظار للقرص، ووضعها عائدة في الذاكرة — فقط لتلخيص الأمر. تلك 40 مليثانية تبدو خالية الخطر في البداية. لكننا نتحدث عن إيقاف عالمي كامل. تلك 40 مليثانية تعني أن كل شيء توقف — بمصطلحات Go، كل P توقف، لذا على سبيل المثال، إذا كانت goroutine تنتظر إدخال/إخراج، أثناء تلك الإيقافة قد يعود الإدخال/الإخراج ولن يكون هناك أحد لمعالجته. 40 مليثانية هي 800 مرة الإيقافة المتوسطة. يحدث ذان أو ثلاث مرات في كل ذروة ذاكرة أثناء الاختبار. إنه كثير.
لماذا يواجه مجمع نفايظ Go إيقافات طويلة عند تمكيم النطاق المبادل؟
يقرأ مجمع نفايظ Go البيانات الوصفية خارج الكومة خلال إيقافات عالمية كاملة؛ إذا تم إخلاء تلك البيانات الوصفية إلى النطاق المبادل، فإن كل وصول يؤدي إلى خطأ صفحة رئيسي، مما يتسبب في تأخير أثناء قراءة النواة للصفحات من القرص، مما يؤدي إلى إيقافات تصل إلى 40 مليثانية على الرغم من أن الإيقافات المتوسطة تكون حوالي 51 مايكروثانية.
🇧🇩 বাংলা
স্ব্যাপ এভিকশন থেকে Go GC বিশ্রাম স্পাইক
আমি এটি লিখছি কারণ আপনি আমার মতো করে না মাথা বাদাম দেবেন যখন আমি মেমরি স্পাইক শোষণ করতে স্ব্যাপ চালানোর সিদ্ধান্ত নিয়েছিলাম। আমার একটি cgroup ছিল যাতে দুটি প্রক্রিয়া ছিল: একটি Go প্রক্রিয়া যা io.ReadAll কল করে এবং তারপর proto.Unmarshal করে, একটি blob তৈরি করে এবং তারপর একটি গ্রাফ স্ট্রাক্টার তৈরি করে (যা Go-এর আবেদনকারী দ্বারা স্ক্যান হিসাবে চিহ্নিত)। অন্য প্রক্রিয়াটি একটি HTTP সার্ভার যা বেশিরভাগ সময় নীরব থাকে। প্রতিবার যখন কালেক্টর চালায়, তখন এটি স্ক্যান স্প্যান পড়ে, পয়েন্টার দিয়ে পয়েন্টার, এবং সিদ্ধান্ত নেয় কী করতে হয়। তাই আমি ভাবলাম: ঠিক আছে, মেমরি চাপের নিচে, কার্নেল পৃষ্ঠাগুলিকে স্ব্যাপ ডিভাইসে রিমুভ করবে, কিন্তু যেহেতু রিমুভটি প্রতি cgroup এবং প্রতি প্রক্রিয়া নয়, তাই উভয় প্রক্রিয়ার পৃষ্ঠাগুলিও রিমুভ করা হবে — তাই এটি কার্নেল এবং গ্যারবেজ কালেক্টরের মধ্যে স্ব্যাপ-ইন এবং স্ব্যাপ-আউট-এর একটি বেদনাদায়ক নাচে রূপান্তরিত হওয়ার সম্ভাবনা খুবই কম। আমি ভুল ছিলাম। এটি পরীক্ষা করার সময় আমি এমন একটি সমস্যার সম্মুখীন হয়েছিলাম যা আমাকে ক্ষতিগ্রস্ত করতে পারত: Go-এর গ্যারবেজ কালেক্টর তার মেটাডেটা (হিপের বাইরে, একটি অঞ্চলে যা মুক্ত করা হয়নি) স্টপ-দ্য-ওয়ার্ল্ড বিশ্রামে পড়ে, এবং সেই মেটাডেটা স্ব্যাপে থাকতে পারে। আমি MGLRU সক্ষম করে কার্নেল 6.8 ব্যবহার করে Hetzner-এ একটি সিমুলেশন চালিয়েছিলাম। মাঝারি বিশ্রামটি ছিল প্রায় 51 মাইক্রোসেকেন্ড। NVMe-এ মেটাডেটা থাকলে, সবচেয়ে খারাপ বিশ্রামটি 40 মিলিসেকেন্ড ছিল। এই 40 মিলিসেকেন্ডটি কোথায় গেছে তা জানতে আমি একটি ছোট bpf স্ক্রিপ্ট লিখেছিলাম যা স্টপ-দ্য-ওয়ার্ল্ড অবসান পর্যন্ত পৃষ্ঠা ফল্টের সংখ্যা গণনা করে। এটিই ছিল সবচেয়ে খারাপ: 39902 মাইক্রোসেকেন্ড, এর মধ্যে 228টি ফল্ট, 39013 মাই্রোসেকেন্ড ফল্টে। সেই 40 মিলিসেকেন্ডের 39 মিলিসেকেন্ডটি 228টি পৃষ্ঠা ফল্টে ব্যয় করা হয়েছিল। সেই ফল্টগুলি GC-এর বইয়ের মধ্যে ঘটেছিল। এটি একটি সম্ভাব্য ব্যর্থতা মোড। Go-এর GC-এর দুটি পয়েন্টে পৃবী বন্ধ করতে হয়: যখন এটি সোয়াপ শেষ করে, এবং যখন এটি মার্ক শেষ করে। আমরা 30 মিনিটে 312টি এমন বিশ্রাম পেয়েছিলাম। তাই এখানে কেন এটি ঘটে: রানটাইম এই পৃষ্ঠাগুলিকে বরাদ্দ করে। এগুলি মুক্ত করা হয়নি, কিন্তু পুনরায় ব্যবহার করা হয়। এই পৃষ্ঠাগুলি GC চক্রে পড়ে। কারণ কার্নেল পৃষ্ঠাগুলিকে বয়স অনুসারে রিমুভ করে, তাই এটি সবচেয়ে কম আগে অ্যাক্সেস করা পৃষ্ঠাগুলিকে স্ব্যাপে পাঠায়। GC চালু হয়, পৃবী বন্ধ করে, এই পৃষ্ঠাগুলিকে পড়ার চেষ্টা করে, কিন্তু এখন আমাদের একটি প্রধান পৃষ্ঠা ফল্ট আছে। কার্নেলকে PTE পড়তে হবে, তারপর do_swap_page কল করতে হবে, একটি নতুন ফ্রেম খুঁজে বের করতে হবে, এটিকে cgroup-এ চার্জ করতে হবে, পৃষ্ঠাগুলি পড়তে হবে, bio জমা দিতে হবে, ডিস্ক অপেক্ষা করতে হবে এবং তাদের মেমরিতে ফেরত দিতে হবে — শুধুমাত্র সংক্ষেপ করার জন্য। এই 40 মিলিসেকেন্ডটি প্রথমে অপরাধী মনে হয়। কিন্তু আমরা স্টপ-দ্য-ওয়ার্ল্ড বিশ্রামের কথা বলছি। এই 40 মিলিসেকেন্ডটি মানে আপনি যে কোনও জিনিস থেমে গেছে — Go-এর শব্দভাণ্ডারে, প্রতিটি P বন্ধ করা হয়েছে, তাই উদাহরণস্বরূপ, যদি একটি goroutine I/O অপেক্ষা করছিল, তবে সেই বিশ্রামের সময় I/O ফিরে আসতে পারে এবং কাউন তা হ্যান্ডেল করতে পারে না। 40 মিলিসেকেন্ড মাঝারি বিশ্রামের 800 গুণ। এটি পরীক্ষার সময় প্রতি মেমরি স্পাইকের সাথে দুটো বা তিন বার ঘটে। এটা বেশি।
কেন Go-এর গ্যারবেজ কালেক্টর স্ব্যাপ সক্ষম করলে দীর্ঘ বিশ্রামের সমস্যায় জড়িত?
Go-এর গ্যারবেজ কালেক্টর হিপের বাইরের মেটাডেটা স্টপ-দ্য-ওয়ার্ল্ড বিশ্রামে পড়ে; যদি সেই মেটাডেটা স্ব্যাপে যায়, তবে প্রতিটি অ্যাক্সেস একটি প্রধান পৃষ্ঠা ফল্ট ট্রিগার করে, যা কার্নেল যখন ডিস্ক থেকে পৃষ্ঠাগুলি পুনরুদ্ধৃত করে তখন দেরি করে, যা ~51 মাইক্রোসেকেন্ড মাঝারি বিশ্রাম থাকলেও, অতিরিক্ত 40 মিলিসেকেন্ড পর্যন্ত বিশ্রাম ঘটাতে পারে।
🇩🇪 Deutsch
Go GC-Pausen-Spitze durch Swap-Auslagerung
Ich schreibe das hier, damit Sie nicht wie ich fast mitten auf die Stirn hämmern, als ich beschlossen habe, Swap in der Produktion für die Pufferung von Speicherspitzen zu aktivieren. Ich hatte einen cgroup mit zwei Prozessen: ein Prozess ist ein Go-Prozess, der io. ReadAll aufruft und dann proto.
Unmarshal, wodurch ein Blob und anschließend eine Grafikstruktur erstellt wird (die als Scan vom Go-Allocator markiert ist). Der andere Prozess ist ein HTTP-Server, der meistens still bleibt. Jedes Mal, wenn der Collector läuft, liest er diese Scan-Spans, Zeiger für Zeiger, und entscheidet, was damit zu tun ist.
Also dachte ich: Gut, unter Speicherdruck wird der Kernel Seiten auf das Swap-Gerät auslagern, aber da die Auslagerung pro cgroup und nicht pro Prozess erfolgt, werden die Seiten beider Prozesse ausgelagert — also ist die Chance, dass dies zu einem traurigen Swap-In- und Swap-Out-Tanz zwischen Kernel und Garbage Collector wird, nur gering. Ich lag falsch. Während ich das experimentell testete, fand ich ein Problem, das mich hätte treffen können: Der Go-Garbage-Collector liest seine Metadaten (außerhalb des Heaps, in einem Bereich, der nicht freigegeben wird) während einer Stop-the-World-Pause, und diese Metadaten können im Swap sein.
Ich führte einen Simulationslauf auf einem Hetzner-Server mit Kernel 6.8 und aktiviertem MGLRU durch. Die mittlere Pause betrug etwa 51 µs. Mit den Metadaten auf dem NVMe, betrug die schlimmste Pause 40 ms.
Um zu überprüfen, wo diese 40 ms geblieben sind, schrieb ich ein kleines bpf-Skript, das Seitenfehler während der Welt gestoppt zählt. Das war der schlimmste Fall: 39902 µs, 228 Fehler währenddessen, 39013 µs in Fehlern. 39 dieser 40 ms wurden in 228 Seitenfehlern aufgewendet. Diese Fehler trugen innerhalb der GC-Buchführung statt.
Das ist ein potenzielles Fehlerszenario. Der Go-GC muss die Welt an zwei Stellen stoppen: wenn er eine Sweep-Terminierung durchführt, und wenn er eine Mark-Terminierung durchführt. Wir hatten 312 dieser Pausen in 30 Minuten.
Hier ist also, warum das passiert: Die Laufzeit weist diese Seiten zu. Sie werden nicht freigegeben, sonder.
🇪🇸 Español
Pico de pausa de GC de Go causado por la evicción de intercambio
Estoy escribiendo esto para que no te des un golpe en la frente como casi me pasó a mí cuando decidí ejecutar swap en producción para absorber picos de memoria. Tenía un cgroup con dos procesos: uno es un proceso de Go que llama a io. ReadAll y luego proto.
Unmarshal, creando un blob y luego una estructura de gráfico (que está marcada como escaneo por el asignador de Go). El otro proceso es un servidor HTTP que normalmente permanece en silencio. Cada vez que el recolector se ejecuta, lee esos spans de escaneo, puntero por puntero, y decide qué hacer con ellos.
Así que pensé: bien, bajo presión de memoria, el kernel va a expulsar páginas al dispositivo de intercambio, pero como la expulsión es por cgroup y no por proceso, las páginas de ambos procesos van a ser expulsadas, así que solo hay una pequeña probabilidad de que esto se convierta en una triste danza de intercambio de entrada y salida entre el kernel y el recolector de basura. Me equivoqué. Mientras experimentaba con esto, encontré un problema que podría haberme causado daño: el recolector de basura de Go lee sus metadatos (fuera del montón, en una región que no se libera) durante una pausa de detener-el-mundo, y esos metadatos pueden estar en el intercambio.
Hice una simulación en una máquina de Hetzner usando el kernel 6.8 con MGLRU habilitado. La pausa mediana fue de alrededor de 51 us. Con los metadatos en el NVMe, la peor pausa fue de 40 ms.
Para ver dónde se iban esos 40 ms, escribí un pequeño script de bpf que cuenta los fallos de página mientras el mundo está detenido. Esta fue la peor: 39902 us, fallos durante ese tiempo 228, 39013 us en fallos. 39 de esos 40 ms se gastaron en 228 fallos de página. Esos fallos ocurrieron dentro de la contabilidad del GC.
Ese es un modo de fallo potencial. El GC de Go tiene que detener el mundo en dos puntos: cuando realiza una terminación de barrido, y cuando realiza una terminación de marcaje. Tuvimos 312 de esas pausas en 30 minutos.
Así que aquí está por qué sucede esto: el entorno de ejecución asigna esas páginas. No se liberan, pero se reutilizan. Esas páginas se leen en ciclos de GC.
Porque el kernel expulsa páginas por antigüedad, envía las páginas menos recientemente accedidas al intercambio. El GC se ejecuta, detiene el mundo, intenta leer esas páginas, pero ahora tenemos un fallo de página importante. El kernel necesita leer las PTE, luego llamar a do_swap_page, encontrar un nuevo marco, cargarlo al cgroup, leer las páginas, enviar un bio, esperar al disco y devolverlas a la memoria, solo para resumir.
Esos 40 ms parecen inofensivos al principio. Pero estamos hablando de una pausa de detener-el-mundo. Esos 40 ms significan que todo se ha detenido, en términos de Go, cada P se ha detenido, así que, por ejemplo, si una goroutine estaba esperando una operación de E/S, durante esa pausa la E/S podría devolverse y no habría nadie para manejarla. 40 ms es 800 veces la pausa mediana.
Ocurre dos o tres veces por pico de memoria durante la prueba. Es mucho.
¿Por qué el recolector de basura de Go experimenta largas pausas cuando el intercambio está habilitado?
El recolector de basura de Go lee metadatos fuera del montón durante pausas de detener-el-mundo; si esos metadatos están en el intercambio, cada acceso desencadena un fallo de página importante, causando retrasos mientras el kernel recupera páginas desde el disco, lo que lleva a pausas de hasta 40 ms a pesar de que las pausas medianas sean de ~51 us.
🇫🇷 Français
Pic de pause GC de Go dû à l'éviction de pagination
J’écris cela pour que vous ne vous gifliez pas le front comme moi, jusqu’à ce que je décide d’exécuter le swap en production pour absorber les pics de mémoire. J’avais un cgroup avec deux processus : un processus Go qui appelle io. ReadAll puis proto.
Unmarshal, créant un blob puis une structure de graphe (qui est marquée comme scan par l’allocateur de Go). L’autre processus est un serveur HTTP qui reste généralement silencieux. Chaque fois que le ramasse-miettes s’exécute, il lit ces spans de scan, pointeur par pointeur, et décide quoi en faire.
Alors j’ai pensé : ok, sous pression mémoire, le noyau va évaporer des pages vers le périphérique de swap, mais comme l’éviction est par cgroup et non par processus, les pages des deux processus vont être évaporées — il n’y a donc qu’une petite chance pour que cela devienne une triste danse de swap-in et swap-out entre le noyau et le ramasse-miettes. Je me suis trompé. En expérimentant cela, j’ai trouvé un problème qui aurait pu me blesser : le ramasse-miettes de Go lit ses métadonnées (en dehors du tas, dans une région non libérée) pendant une pause stop-the-world, et ces métadonnées peuvent être dans le swap.
J’ai fait un test sur une machine Hetzner avec le noyau 6.8 et MGLRU activé. La pause médiane était d’environ 51 µs. Avec les métadonnées sur le NVMe, la pire pause était de 40 ms.
Pour vérifier où étaient passées ces 40 ms, j’ai écrit un petit script bpf qui compte les fautes de page pendant que le monde est arrêté. Voici la pire : 39902 µs, 228 fautes pendant cela, 39013 µs en fautes. 39 de ces 40 ms ont été passées dans 228 fautes de page. Ces fautes se sont produites à l’intérieur de la comptabilité du GC.
C’est un mode de défaillance potentiel. Le GC de Go doit arrêter le monde en deux points : lorsqu’il effectue une terminaison de balayage, et lorsqu’il effectue une terminaison de marquage. Nous avons eu 312 de ces pauses en 30 minutes.
Voici donc pourquoi cela arrive : le runtime alloue ces pages. Elles ne sont pas libérées, mais réutilisées. Ces pages sont lues lors des cycles GC.
Parce que le noyau évite les pages par âge, il envoie les pages les moins récemment accédées vers le swap. Le GC s’exécute, arrête le monde, tente de lire ces pages, mais maintenant nous avons une faute de page majeure. Le noyau doit lire les PTE, appeler do_swap_page, trouver un nouveau cadre, le charger sur le cgroup, lire les pages, soumettre un bio, attendre le disque, et les remettre en mémoire — pour résumer.
Ces 40 ms semblent inoffensives au début. Mais nous parlons d’une pause stop-the-world. Ces 40 ms signifient que tout s’est arrêté — en termes de Go, chaque P s’est arrêté, donc par exemple, si une goroutine attendait une entrée/sortie, pendant cette pause l’E/S pourrait retourner et il n’y aurait personne pour la gérer. 40 ms est 800 fois la pause médiane.
Cela se produit deux ou trois fois par pic de mémoire pendant le test. C’est beaucoup.
Pourquoi le ramasse-miettes de Go subit-il de longues pauses lorsque le swap est activé ?
Le ramasse-miettes de Go lit des métadonnées en dehors du tas pendant les pauses stop-the-world ; si ces métadonnées sont dans le swap, chaque accès déclenche une faute de page majeure, causant des retards lorsque le noyau récupère des pages depuis le disque, entraînant des pauses pouvant atteindre 40 ms malgré des pauses médianes d’environ 51 µs.
🇮🇳 हिन्दी
स्वैप एविक्शन से Go GC पीज़ पस स्पाइक
मैं इसे लिख रहा हूँ ताकि आप मुझे जैसा लगभग कर न पाएं जब मैंने मेमरी स्पाइक को अवशोषित करने के लिए प्रोडक्शन में स्वैप चलाने का फैसला किया। मेरे पास एक cgroup था जिसमें दो प्रक्रियाएँ थीं: एक Go प्रक्रिया है जो io.ReadAll को कॉल करती है और फिर proto.Unmarshal करती है, एक blob बनाती है और फिर एक ग्राफ स्ट्रक्चर बनाती है (जो Go के आवंटकर्ता द्वारा स्कैन के रूप में चिह्नित है)। दूसरी प्रक्रिया एक HTTP सर्वर है जो अधिकांश समय चुप रहता है। हर बार जब संग्राहक चलता है, तो वह इन स्कैन स्पैन को पढ़ता है, पॉइंटर के माध्यम से, और फिर उनका निर्णय लेता है। इसलिए मैंने सोचा: ठीक है, मेमरी दबाव के तहत, कर्नल पृष्ठों को स्वैप डिवाइस पर निकाल देगा, लेकिन चूंकि निकासी प्रति cgroup है और प्रति प्रक्रिया नहीं, इसलिए दोनों प्रक्रियाओं के पृष्ठ निकाल लिए जाएंगे - इसलिए इसे कर्नल और गार्बेज कलेक्टर के बीच स्वैप-इन और स्वैप-आउट के एक दुखद नृत्य में बदलने की संभावना बहुत छोटी है। मैं गलत था। इसके साथ प्रयोग करते समय, मुझे एक समस्या मिली जो मुझे नुकसान पहुँचा सकती थी: Go का गार्बेज कलेक्टर अपने मेटाडेटा (हीप के बाहर, एक क्षेत्र में जो मुक्त नहीं है) को स्टॉप-द-वर्ल्ड रोकथाम के दौरान पढ़ता है, और उन मेटाडेटा को स्वैप में हो सकता है। मैंने MGLRU सक्षम करके कर्नल 6.8 का उपयोग करके Hetzner पर एक सिमुलेशन चलाया। मीडियन रोकथाम लगभग 51 माइक्रोसेकंड थी। NVMe पर मेटाडेटा के साथ, सबसे खराब रोकथाम 40 मिलीसेकंड थी। यह जाँचने के लिए कि वह 40 मिलीसेकंड कहाँ गई, मैंने एक छोटा bpf स्क्रिप्ट लिखा जो स्टॉप-द-वर्ल्ड के दौरान पेज फॉल्ट की गिनती करता है। यह सबसे खराब था: 39902 माइक्रोसेकंड, उसके दौरान 228 फॉल्ट, 39013 माइक्रोसेकंड फॉल्ट में। उन 40 मिलीसेकंड में से 39 मिलीसेकंड 228 पेज फॉल्ट में खर्च हुईं। ये फॉल्ट GC के बुककीपिंग के भीतर हुए। यह एक संभावित विफलता मोड है। Go के GC को दो बिंदुओं पर दुनिया रोकनी पड़ती है: जब वह स्वीप समाप्ति करता है, और जब वह मार्क समाप्ति करता है। हमने 30 मिनट में 312 ऐसी रोकथाम देखीं। इसलिए यहीं कारण है कि यह होता है: रनटाइम उन पृष्ठों को आवंटित करता है। वे मुक्त नहीं होते, बल्कि पुन: उपयोग किए जाते हैं। इन पृष्ठों को GC चक्र में पढ़ा जाता है। क्योंकि कर्नल पृष्ठों को उम्र के आधार पर निकालता है, इसलिए वह सबसे कम हाल में एक्सेस किए गए पृष्ठों को स्वैप भेज देता है। GC चलता है, दुनिया रोकता है, उन पृष्ठों को पढ़न की कोशिश करता है, लेकिन अब हमें एक प्रमुख पेज फॉल्ट मिलता है। कर्नल को PTE पढ़ने की आवश्यकता है, फिर do_swap_page को कॉल करना है, एक नई फ्रेम ढूँढनी है, उसे cgroup में चार्ज करना है, पृष्ठों को पढ़ना है, bio जमा करना है, डिस्क का इंतजार करना है और फिर उन्हें मेमरी में वापस रखना है - बस इतना ही। शुरुआती दिखावट में वह 40 मिलीसेकंड बेमार नहीं लगते। लेकिन हम स्टॉप-द-वर्ल्ड रोकथाम की बात कर रहे हैं। उन 40 मिलीसेकंड का मतलब है कि सब कुछ रुक गया है - Go के शब्दों में, हर P रुक गया है, इसलिए उदाहरण के लिए, अगर एक goroutine I/O का इंतजार कर रहा था, तो उस रोकथाम के दौरान I/O वापस आ सकता है और कोई उसे संभालने वाला नहीं होगा। 40 मिलीसेकंड मीडियन रोकथाम का 800 गुना है। यह परीक्षण के दौरान हर मेमरी स्पाइक के साथ दो या तीन बार होता है। यह बहुत अधिक है।
क्यों Go का गार्बेज कलेक्टर स्वैप सक्षम होने पर लंबी रोकथाम के अनुभव को रखता है?
Go का गार्बेज कलेक्टर हीप के बाहर के मेटाडेटा को स्टॉप-द-वर्ल्ड रोकथाम के दौरान पढ़ता है; अगर उन मेटाडेटा को स्वैप में हो जाता है, तो प्रत्येक एक्सेस प्रमुख पेज फॉल्ट का कारण बनता है, जिससे डिस्क से पृष्ठों को पुनः प्राप्त करने में कर्नल द्वारा देरी होती है, जिसकी वजह से ~51 माइक्रोसेकंड की मीडियन रोकथाम के बावजूद, अधिकतम 40 मिलीसेकंड तक की रोकथाम हो सकती है।
🇯🇵 日本語
スワップ排出によるGo GC一時停止スパイク
これを書いているのは、メモリスパイクを吸収するためにプロダクションでスワップを実行することにしたとき、私のようにほぼ額に手を叩かないようにするためです。私は2つのプロセスを持つcgroupを持っていました:1つはGoプロセスで、io.ReadAllを呼び出した後にproto.Unmarshalを呼び出し、blobを作成し、次にグラフ構造を作成します(これはGoのアロケータによってスキャンとしてマークされています)。もう1つは、ほとんどの時間静かにしているHTTPサーバーです。コレクタが実行されるたびに、それらのスキャンスパンをポインタごとに読み取り、それらをどうするかを決定します。だから私は思いました:OK、メモリの圧力の下で、カーネルはページをスワップデバイスに退避させますが、退避はcgroupごとに行われ、プロセスごとには行われないため、両方のプロセスのページが退避されるため、これがカーネルとガベージコレクタの間のスワップインとスワップアウトの悲しいダンスになる可能性は非常に小さいです。私は間違っていました。これを実験している間、私は自分を傷つける可能性のある問題を見つけました:Goのガベージコレクタは、stop-the-worldの一時停止中にメタデータ(ヒープ外、解放されていない領域)を読み取り、そのメタデータはスワップに存在する可能性があります。私はMGLRUを有効にしたカーネル6.8を使用してHetznerのマシンでシミュレーションを行いました。中央値の一時停止は約51μsでした。メタデータをNVMeに配置した場合、最悪の一時停止は40msでした。その40msがどこに行ったのかを確認するために、世界が停止している間にページフォルトをカウントする小さなbpfスクリプトを書きました。これが最悪でした:39902μs、その間228回のフォルト、39013μsがフォルトに費やされました。40msのうち39msは228回のページフォルトに費やされました。これらのフォルトはGCの簿記の中で発生しました。これは潜在的な障害モードです。GoのGCは2つのポイントで世界を停止する必要があります:スイープ終了を実行するとき、およびマーク終了を実行するとき。私たちは30分間で312回の一時停止を経験しました。だからこれが起こる理由は次のとおりです:ランタイムはこれらのページを割り当てます。それらは解放されませんが、再利用されます。これらのページはGCサイクルで読み取られます。カーネルは年齢に基づいてページを退避させるため、最も最近アクセスされていないページをスワップに送信します。GCが実行され、世界を停止し、それらのページを読み取ろうとしますが、今度はメジャーページフォルトが発生します。カーネルはPTEを読み取り、次にdo_swap_pageを呼び出し、新しいフレームを見つけ、それをcgroupに課金し、ページを読み取り、bioを送信し、ディスクを待ち、それらをメモリに戻します —簡潔に言えば。最初にこれらの40msは無害に見えます。しかし、私たちはstop-the-worldの一時停止について話しています。これらの40msはすべてが停止したことを意味します —Goの用語では、すべてのPが停止しているため、たとえば、goroutineがI/Oを待っていた場合、その一時停止中にI/Oが戻ってくる可能性があり、それを処理する人はいません。40msは中央値の800倍です。テスト中のメモリスパイクごとに2〜3回発生します。それはたくさんです。
なぜGoのガベージコレクタはスワップが有効な場合に長い一時停止を経験しますか?
Goのガベージコレクタは、stop-the-worldの一時停止中にヒープ外のメタデータを読み取ります。このメタデータがスワップにある場合、各アクセスはメジャーページフォルトをトリガーし、カーネルがディスクからページを取得する際に遅延が発生し、中央値の一時停止が約51μsであるにもかかわらず、最大40msの一時停止が発生する可能性があります。
🇧🇷 Português
Pico de pausa do GC do Go causado pela evicção de paginação
Estou escrevendo isso para que você não se dê um tapa na testa como eu quase fiz quando decidi executar swap em produção para absorver picos de memória. Eu tinha um cgroup com dois processos: um é um processo Go que chama io. ReadAll e depois proto.
Unmarshal, criando um blob e depois uma estrutura de gráfico (que é marcada como scan pelo alocador de Go). O outro processo é um servidor HTTP que normalmente fica em silêncio. Sempre que o coletor é executado, ele lê esses spans de scan, ponteiro por ponteiro, e decide o que fazer com eles.
Então eu pensei: ok, sob pressão de memória, o kernel vai expulsar páginas para o dispositivo de swap, mas como a expulsão é por cgroup e não por processo, as páginas de ambos os processos vão ser expulsas — então há apenas uma pequena chance de isso se tornar uma triste dança de swap-in e swap-out entre o kernel e o coletor de lixo. Eu estava errado. Enquanto experimentava isso, eu encontrei um problema que poderia ter me machucado: o coletor de lixo do Go lê seus metadados (fora do heap, em uma região que não é liberada) em uma pausa stop-the-world, e esses metadados podem estar no swap.
Eu fiz uma simulação em uma máquina Hetzner usando o kernel 6.8 com MGLRU ativado. A pausa mediana foi de cerca de 51 us. Com os metadados no NVMe, a pior pausa foi de 40 ms.
Para verificar onde esses 40 ms foram, eu escrevi um pequeno script bpf que conta falhas de página enquanto o mundo está parado. Esta foi a pior: 39902 us, falhas durante isso 228, 39013 us em falhas. 39 desses 40 ms foram gastos em 228 falhas de página. Essas falhas ocorreram dentro da contabilidade do GC.
Isso é um modo de falha potencial. O GC do Go precisa parar o mundo em dois pontos: quando ele realiza uma terminação de varredura, e quando ele realiza uma terminação de marcação. Tivemos 312 dessas pausas em 30 minutos.
Então aqui está por que isso acontece: o runtime aloca essas páginas. Elas não são liberadas, mas reutilizadas. Essas páginas são lidas em ciclos de GC.
Porque o kernel expulsa páginas por idade, ele envia as páginas menos recentemente acessadas para o swap. O GC roda, para o mundo, tenta ler essas páginas, mas agora temos uma falha de página maior. O kernel precisa ler as PTE, depois chamar do_swap_page, encontrar um novo quadro, carregá-lo no cgroup, ler as páginas, enviar um bio, esperar pelo disco e colocá-las de volta na memória — apenas para resumir.
Esses 40 ms parecem inofensivos no início. Mas estamos falando de uma pausa stop-the-world. Esses 40 ms significam que tudo parou — em termos de Go, cada P parou, então, por exemplo, se uma goroutine estivesse esperando por E/S, durante essa pausa a E/S poderia retornar e não haveria ninguém para tratá-la. 40 ms é 800 vezes a pausa mediana.
Ele acontece duas ou três vezes por pico de memória durante o teste. É muito.
Por que o coletor de lixo do Go enfrenta longas pausas quando o swap está ativado?
O coletor de lixo do Go lê metadados fora do heap durante pausas stop-the-world; se esses metadados estiverem no swap, cada acesso dispara uma falha de página maior, causando atrasos enquanto o kernel busca páginas no disco, resultando em pausas de até 40 ms apesar de as pausas medianas serem de ~51 us.
🇷🇺 Русский
Скачок паузы GC Go из-за вытеснения страниц подкачки
Я пишу это, чтобы вы не хлопали себя по лбу, как я почти сделал, когда решил запустить swap в производстве, чтобы поглощать скачки памяти. У меня был cgroup с двумя процессами: один — это процесс Go, который вызывает io. ReadAll, а затем proto. Unmarshal, создаёт blob, а затем структуру графа (которая помечена как сканируемая распределителем Go). Другой процесс — это HTTP-сервер, который обычно молчалив. Каждый раз, когда запускается сборщик, он читает эти сканируемые промежутки, указатель за указателем, и решает, что с ними делать. Поэтому я подумал: хорошо, при давлении памяти ядро будет выгружать страницы на устройство подкачки, но поскольку выгрузка происходит по cgroup, а не по процессу, страницы обоих процессов будут выгружены, так что шанс того, что это превратится в печальный танец swap-in и swap-out между ядром и сборщиком мусора, весьма мал. Я ошибался. Пока экспериментируя с этим, я нашёл проблему, которая могла бы меня ранить: сборщик мусора Go читает свои метаданные (вне кучи, в области, которая не освобождается) во время паузы stop-the-world, и эти метаданные могут быть в подкачке. Я провёл тест на сервере Hetzner с ядром 6.8 и включённым MGLRU. Медианная пауза составила около 51 мкс. С метаданными на NVMe, худшая пауза составила 40 мс. Чтобы проверить, куда ушли эти 40 мс, я написал небольшой скрипт bpf, который считает ошибки страниц, пока мир остановлен. Вот самая плохая: 39902 мкс, за это время 228 ошибок, 39013 мкс в ошибках. 39 из этих 40 мс были потрачены на 228 ошибок страниц. Эти ошибки произошли внутри учёта GC. Это потенциальный сценарий сбоя.
GC в Go должен останавливать мир в двух точках: когда он завершает очистку, и когда он завершает маркировку. У нас было 312 таких пауз за 30 минут. Вот почему это происходит: среда выполнения выделяет эти страницы. Они не освобождаются, а переиспользуются. Эти страницы читаются в циклах GC. Поскольку ядро выгружает страницы по возрасту, оно отправляет наименее недавно используемые страницы в подкачку. GC запускается, останавливает мир, пытается прочитать эти страницы, но теперь у нас есть серьёзный сбой страницы. Ядру нужно прочитать PTE, затем вызвать do_swap_page, найти новый кадр, начислить его на cgroup, прочитать страницы, отправить bio, подождать диск и вернуть их обратно в память — чтобы кратко сказать. Эти 40 мс кажутся безобидными на первый взгляд. Но мы говорим о паузе stop-the-world. Эти 40 мс означают, что всё остановилось — по терминологии Go, каждый P остановился, поэтому, например, если goroutine ждала ввода-вывода, во время этой паузы ввод-вывод мог вернуться, и никто не обрабатывал бы его. 40 мс — это в 800 раз больше медианной паузы. Он происходит два или три раза на каждый скачок памяти во время теста. Это много.
Почему сборщик мусора Go сталкивается с длительными паузами, когда включена подкачка?
Сборщик мусора Go читает метаданные вне кучи во время пауз stop-the-world; если эти метаданные выгружены в подкачку, каждый доступ вызывает серьёзный сбой страницы, вызывая задержки при извлечении страниц с диска ядром, что приводит к паузам до 40 мс, несмотря на то, что медианные паузы составляют ~51 мкс.
🇨🇳 简体中文
Go GC暂停峰值:来自交换驱逐
我之所以写这篇文章,是因为当我决定在生产环境中运行交换空间以吸收内存峰值时,我几乎像你一样想要打自己额头。我有一个cgroup中有两个进程:一个是Go进程,它调用io.ReadAll然后proto.Unmarshal,创建一个blob然后是一个图结构(这个结构被Go的分配器标记为扫描对象)。另一个进程是一个HTTP服务器,大部分时间都保持安静。每当垃圾回收器运行时,它会逐个指针读取这些扫描跨度,并决定如何处理它们。所以我想:好的,在内存压力下,内核会将页面驱逐到交换设备,但由于驱逐是按cgroup而不是按进程进行的,因此两个进程的页面都会被驱逐——所以这变成内核和垃圾回收器之间的交换进出“悲伤舞蹈”的可能性很小。我错了。在实验过程中,我发现了一个可能会伤害到我的问题:Go的垃圾回收器在停止世界暂停期间读取其元数据(位于堆外,在一个未被释放的区域),而这些元数据可能被换出到交换空间。我在Hetzner的一台机器上使用内核6.8和MGLRU启用的环境下做了一个模拟运行。中位数暂停时间约为51微秒。而将元数据放在NVMe上时,最坏的暂停时间达到了40毫秒。为了查清那40毫秒去了哪里,我编写了一个小型bpf脚本来统计停止世界期间的页面错误。这是最坏的情况:39902微秒,期间发生228次页面错误,其中39013微秒花在页面错误上。那40毫秒中的39毫秒花在了228次页面错误上。这些页面错误发生在GC的簿记工作中。这是一个潜在的故障模式。Go的GC在两个点上必须停止世界:当它执行清扫终止时,以及当它执行标记终止时。我们在30分钟内经历了312次这样的暂停。所以这就是为什么会发生这种情况:运行时分配了这些页面。它们没有被释放,但被重用。这些页面在GC周期中被读取。因为内核按年龄驱逐页面,它会将最近最少访问的页面发送到交换空间。GC运行,停止世界,尝试读取这些页面,但现在我们遇到了一次主要页面错误。内核需要读取页表项,然后调用do_swap_page,找到一个新的帧,将其计入cgroup,读取页面,提交bio,等待磁盘完成,然后将它们放回内存——仅此而已。起初那40毫秒似乎无害。但我们正在谈论一次停止世界暂停。那40毫秒意味着所有事情都停止了——在Go的术语中,每个P都停止了,因此,例如,如果一个goroutine正在等待I/O,在那个暂停期间I/O可能会返回,但没有人来处理它。40毫秒是中位数暂停的800倍。在测试过程中,每次内存峰值都会发生两到三次。这真的很多。
为什么Go的垃圾回收器在启用交换空间时会出现长时间暂停?
Go的垃圾回收器在停止世界暂停期间读取堆外的元数据;如果这些元数据被换出到交换空间,则每次访问都会触发一次主要页面错误,导致内核从磁盘获取页面时出现延迟,从而即使中位数暂停时间仅为~51微秒,仍可能出现高达40毫秒的暂停。