Men-debug Kebocoran Memori di Aplikasi Node.js
Diterbitkan: 2022-03-11Saya pernah mengendarai Audi dengan mesin V8 twin-turbo di dalamnya, dan performanya luar biasa. Saya mengemudi dengan kecepatan sekitar 140MPH di jalan raya IL-80 dekat Chicago pada pukul 3 pagi ketika tidak ada orang di jalan. Sejak saat itu, istilah "V8" telah dikaitkan dengan kinerja tinggi bagi saya.
Meskipun Audi V8 sangat bertenaga, Anda masih terbatas dengan kapasitas tangki bensin Anda. Hal yang sama berlaku untuk V8 Google - mesin JavaScript di belakang Node.js. Performanya luar biasa dan ada banyak alasan mengapa Node.js berfungsi dengan baik untuk banyak kasus penggunaan, tetapi Anda selalu dibatasi oleh ukuran tumpukan. Saat Anda perlu memproses lebih banyak permintaan di aplikasi Node.js Anda, Anda memiliki dua pilihan: skala vertikal atau skala horizontal. Penskalaan horizontal berarti Anda harus menjalankan lebih banyak instance aplikasi secara bersamaan. Ketika dilakukan dengan benar, Anda akhirnya dapat melayani lebih banyak permintaan. Penskalaan vertikal berarti Anda harus meningkatkan penggunaan dan kinerja memori aplikasi Anda atau meningkatkan sumber daya yang tersedia untuk instans aplikasi Anda.
Baru-baru ini saya diminta untuk mengerjakan aplikasi Node.js untuk salah satu klien Toptal saya untuk memperbaiki masalah kebocoran memori. Aplikasi, sebuah server API, dimaksudkan untuk dapat memproses ratusan ribu permintaan setiap menit. Aplikasi asli menempati hampir 600MB RAM dan oleh karena itu kami memutuskan untuk mengambil titik akhir API panas dan mengimplementasikannya kembali. Overhead menjadi sangat mahal ketika Anda harus melayani banyak permintaan.
Untuk API baru kami memilih restify dengan driver MongoDB asli dan Kue untuk pekerjaan latar belakang. Kedengarannya seperti tumpukan yang sangat ringan, bukan? Tidak terlalu. Selama beban puncak, instans aplikasi baru dapat menghabiskan hingga 270MB RAM. Oleh karena itu, impian saya untuk memiliki dua instance aplikasi per 1X Heroku Dyno sirna.
Kebocoran Memori Node.js Debugging Arsenal
jam tangan
Jika Anda mencari "cara menemukan kebocoran di node", alat pertama yang mungkin Anda temukan adalah memwatch . Paket aslinya sudah lama ditinggalkan dan tidak terawat lagi. Namun Anda dapat dengan mudah menemukan versi yang lebih baru di daftar garpu GitHub untuk repositori. Modul ini berguna karena dapat mengeluarkan peristiwa kebocoran jika melihat tumpukan tumbuh lebih dari 5 pengumpulan sampah berturut-turut.
tumpukan sampah
Alat hebat yang memungkinkan pengembang Node.js mengambil cuplikan heap dan memeriksanya nanti dengan Alat Pengembang Chrome.
Inspektur simpul
Bahkan alternatif yang lebih berguna untuk heapdump, karena memungkinkan Anda untuk terhubung ke aplikasi yang sedang berjalan, mengambil heap dump dan bahkan men-debug dan mengkompilasi ulang dengan cepat.
Mengambil "inspektur simpul" untuk Spin
Sayangnya, Anda tidak akan dapat terhubung ke aplikasi produksi yang berjalan di Heroku, karena tidak mengizinkan sinyal untuk dikirim ke proses yang sedang berjalan. Namun, Heroku bukan satu-satunya platform hosting.
Untuk merasakan node-inspector beraksi, kami akan menulis aplikasi Node.js sederhana menggunakan restify dan menempatkan sedikit sumber kebocoran memori di dalamnya. Semua eksperimen di sini dibuat dengan Node.js v0.12.7, yang telah dikompilasi dengan V8 v3.28.71.19.
var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });Aplikasi di sini sangat sederhana dan memiliki kebocoran yang sangat jelas. Tugas array akan bertambah selama masa pakai aplikasi yang menyebabkannya melambat dan akhirnya macet. Masalahnya adalah kita tidak hanya membocorkan penutupan tetapi juga seluruh objek permintaan.
GC di V8 menggunakan strategi stop-the-world, oleh karena itu, semakin banyak objek yang Anda miliki di memori, semakin lama waktu yang dibutuhkan untuk mengumpulkan sampah. Pada log di bawah ini Anda dapat dengan jelas melihat bahwa di awal kehidupan aplikasi, dibutuhkan rata-rata 20 ms untuk mengumpulkan sampah, tetapi beberapa ratus ribu permintaan kemudian membutuhkan sekitar 230 md. Orang yang mencoba mengakses aplikasi kami sekarang harus menunggu 230 md lebih lama karena GC. Anda juga dapat melihat bahwa GC dipanggil setiap beberapa detik yang berarti bahwa setiap beberapa detik pengguna akan mengalami masalah saat mengakses aplikasi kami. Dan penundaan akan bertambah hingga aplikasi macet.
[28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].Baris log ini dicetak ketika aplikasi Node.js dimulai dengan flag –trace_gc :
node --trace_gc app.jsMari kita asumsikan bahwa kita telah memulai aplikasi Node.js kita dengan flag ini. Sebelum menghubungkan aplikasi dengan node-inspector, kita perlu mengirimkan sinyal SIGUSR1 ke proses yang sedang berjalan. Jika Anda menjalankan Node.js di cluster, pastikan Anda terhubung ke salah satu proses slave.
kill -SIGUSR1 $pid # Replace $pid with the actual process IDDengan melakukan ini, kita membuat aplikasi Node.js (tepatnya V8) masuk ke mode debugging. Dalam mode ini, aplikasi secara otomatis membuka port 5858 dengan V8 Debugging Protocol.
Langkah kita selanjutnya adalah menjalankan node-inspector yang akan terhubung ke antarmuka debugging dari aplikasi yang sedang berjalan dan membuka antarmuka web lain pada port 8080.
$ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.Jika aplikasi berjalan pada produksi dan Anda memiliki firewall, kami dapat melakukan tunnel port 8080 jarak jauh ke localhost:
ssh -L 8080:localhost:8080 [email protected]Sekarang Anda dapat membuka browser web Chrome dan mendapatkan akses penuh ke Alat Pengembangan Chrome yang dilampirkan ke aplikasi produksi jarak jauh Anda. Sayangnya, Alat Pengembang Chrome tidak akan berfungsi di browser lain.
Mari Cari Kebocoran!
Kebocoran memori di V8 bukanlah kebocoran memori yang sebenarnya seperti yang kita ketahui dari aplikasi C/C++. Dalam variabel JavaScript tidak menghilang ke dalam kekosongan, mereka hanya "dilupakan". Tujuan kami adalah menemukan variabel yang terlupakan ini dan mengingatkan mereka bahwa Dobby gratis.
Di dalam Alat Pengembang Chrome, kami memiliki akses ke beberapa profiler. Kami sangat tertarik dengan Rekam Alokasi Heap yang berjalan dan mengambil beberapa snapshot heap dari waktu ke waktu. Ini memberi kita gambaran yang jelas tentang objek mana yang bocor.
Mulai merekam alokasi heap dan mari simulasikan 50 pengguna bersamaan di halaman beranda menggunakan Apache Benchmark.
ab -c 50 -n 1000000 -k http://example.com/Sebelum mengambil snapshot baru, V8 akan melakukan pengumpulan sampah mark-sweep, jadi kita pasti tahu bahwa tidak ada sampah lama di snapshot.
Memperbaiki Kebocoran dengan Cepat
Setelah mengumpulkan snapshot alokasi tumpukan selama 3 menit , kami berakhir dengan sesuatu seperti berikut:
Kita dapat dengan jelas melihat bahwa ada beberapa array raksasa, banyak objek IncomingMessage, ReadableState, ServerResponse dan Domain juga di heap. Mari kita coba menganalisis sumber kebocorannya.
Saat memilih perbedaan heap pada bagan dari 20-an hingga 40-an, kami hanya akan melihat objek yang ditambahkan setelah 20-an sejak Anda memulai profiler. Dengan cara ini Anda dapat mengecualikan semua data normal.
Dengan memperhatikan berapa banyak objek dari setiap jenis dalam sistem, kami memperluas filter dari 20 detik menjadi 1 menit. Kita dapat melihat bahwa array, yang sudah cukup besar, terus bertambah. Di bawah "(array)" kita dapat melihat bahwa ada banyak objek "(properti objek)" dengan jarak yang sama. Benda-benda itu adalah sumber kebocoran memori kita.

Juga kita dapat melihat bahwa objek "(penutupan)" tumbuh dengan cepat juga.
Mungkin berguna untuk melihat senar juga. Di bawah daftar senar ada banyak frasa "Hai Leaky Master". Itu mungkin memberi kita petunjuk juga.
Dalam kasus kami, kami tahu bahwa string "Hai Leaky Master" hanya dapat dirakit di bawah rute "GET /".
Jika Anda membuka jalur pengikut, Anda akan melihat string ini entah bagaimana direferensikan melalui req , lalu ada konteks yang dibuat dan semua ini ditambahkan ke beberapa array penutupan raksasa.
Jadi pada titik ini kita tahu bahwa kita memiliki semacam susunan penutupan yang sangat besar. Mari kita benar-benar pergi dan memberi nama untuk semua penutupan kita secara real-time di bawah tab sumber.
Setelah kita selesai mengedit kode, kita dapat menekan CTRL+S untuk menyimpan dan mengkompilasi ulang kode dengan cepat!
Sekarang mari kita rekam Snapshot Alokasi Heap lainnya dan lihat penutupan mana yang menempati memori.
Jelas bahwa SomeKindOfClojure() adalah penjahat kami. Sekarang kita dapat melihat bahwa penutupan SomeKindOfClojure() sedang ditambahkan ke beberapa tugas bernama array di ruang global.
Sangat mudah untuk melihat bahwa array ini tidak berguna. Kita bisa mengomentarinya. Tapi bagaimana kita membebaskan memori memori yang sudah ditempati? Sangat mudah, kami hanya menetapkan array kosong ke tugas dan dengan permintaan berikutnya itu akan ditimpa dan memori akan dibebaskan setelah acara GC berikutnya.
Dobby gratis!
Kehidupan Sampah di V8
Heap V8 dibagi menjadi beberapa ruang berbeda:
- Ruang Baru : Ruang ini relatif kecil dan berukuran antara 1 MB hingga 8 MB. Sebagian besar objek dialokasikan di sini.
- Old Pointer Space : Memiliki objek yang mungkin memiliki pointer ke objek lain. Jika objek bertahan cukup lama di Ruang Baru, ia akan dipromosikan ke Ruang Pointer Lama.
- Ruang Data Lama : Hanya berisi data mentah seperti string, nomor kotak, dan larik ganda tanpa kotak. Objek yang bertahan GC di Ruang Baru cukup lama dipindahkan ke sini juga.
- Ruang Objek Besar : Objek yang terlalu besar untuk muat di ruang lain dibuat di ruang ini. Setiap objek memiliki wilayah
mmap'ed sendiri di memori - Ruang kode : Berisi kode rakitan yang dihasilkan oleh kompiler JIT.
- Ruang sel, ruang sel properti, ruang peta : Ruang ini berisi
Cells,PropertyCells, danMaps. Ini digunakan untuk menyederhanakan pengumpulan sampah.
Setiap ruang terdiri dari halaman. Halaman adalah wilayah memori yang dialokasikan dari sistem operasi dengan mmap. Setiap halaman selalu berukuran 1MB kecuali halaman dalam ruang objek yang besar.
V8 memiliki dua mekanisme pengumpulan sampah bawaan: Scavenge, Mark-Sweep dan Mark-Compact.
Scavenge adalah teknik pengumpulan sampah yang sangat cepat dan beroperasi dengan objek di New Space . Scavenge adalah implementasi dari Algoritma Cheney. Idenya sangat sederhana, New Space dibagi menjadi dua semi-ruang yang sama: To-Space dan From-Space. Scavenge GC terjadi ketika To-Space penuh. Itu hanya menukar ruang Ke dan Dari dan menyalin semua objek hidup ke To-Space atau mempromosikannya ke salah satu ruang lama jika mereka selamat dari dua pemulungan, dan kemudian sepenuhnya dihapus dari ruang. Scavenge sangat cepat namun mereka memiliki overhead menjaga tumpukan berukuran ganda dan terus-menerus menyalin objek dalam memori. Alasan menggunakan pemulung adalah karena kebanyakan benda mati muda.
Mark-Sweep & Mark-Compact adalah jenis pengumpul sampah lain yang digunakan di V8. Nama lainnya adalah pengumpul sampah penuh. Ini menandai semua node hidup, lalu menyapu semua node mati dan mendefrag memori.
Kiat Kinerja dan Debug GC
Sementara untuk aplikasi web kinerja tinggi mungkin bukan masalah besar, Anda tetap ingin menghindari kebocoran dengan cara apa pun. Selama fase tanda di GC penuh, aplikasi sebenarnya dijeda hingga pengumpulan sampah selesai. Ini berarti semakin banyak objek yang Anda miliki di heap, semakin lama waktu yang dibutuhkan untuk melakukan GC dan semakin lama pengguna harus menunggu.
Selalu beri nama untuk penutupan dan fungsi
Jauh lebih mudah untuk memeriksa jejak dan tumpukan tumpukan ketika semua penutupan dan fungsi Anda memiliki nama.
db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })Hindari benda besar dalam fungsi panas
Idealnya Anda ingin menghindari objek besar di dalam fungsi panas sehingga semua data masuk ke dalam Ruang Baru . Semua operasi terikat CPU dan memori harus dijalankan di latar belakang. Hindari juga pemicu deoptimisasi untuk fungsi panas, fungsi panas yang dioptimalkan menggunakan lebih sedikit memori daripada yang tidak dioptimalkan.
Fungsi panas harus dioptimalkan
Fungsi panas yang berjalan lebih cepat tetapi juga mengkonsumsi lebih sedikit memori menyebabkan GC berjalan lebih jarang. V8 menyediakan beberapa alat debugging yang berguna untuk menemukan fungsi yang tidak dioptimalkan atau fungsi yang tidak dioptimalkan.
Hindari polimorfisme untuk IC dalam fungsi panas
Inline Cache (IC) digunakan untuk mempercepat eksekusi beberapa potongan kode, baik dengan caching akses properti objek obj.key atau beberapa fungsi sederhana.
function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3 Ketika x(a,b) dijalankan untuk pertama kalinya, V8 menciptakan IC monomorfik. Saat Anda memanggil x untuk kedua kalinya, V8 menghapus IC lama dan membuat IC polimorfik baru yang mendukung kedua jenis operan integer dan string. Saat Anda memanggil IC untuk ketiga kalinya, V8 mengulangi prosedur yang sama dan membuat IC polimorfik level 3.
Namun, ada batasannya. Setelah level IC mencapai 5 (dapat diubah dengan flag –max_inlining_levels ) fungsinya menjadi megamorfik dan tidak lagi dianggap dapat dioptimalkan.
Secara intuitif dapat dimengerti bahwa fungsi monomorfik berjalan paling cepat dan juga memiliki jejak memori yang lebih kecil.
Jangan tambahkan file besar ke memori
Yang satu ini jelas dan terkenal. Jika Anda memiliki file besar untuk diproses, misalnya file CSV besar, bacalah baris demi baris dan proses dalam potongan kecil alih-alih memuat seluruh file ke memori. Ada kasus yang agak jarang di mana satu baris csv akan lebih besar dari 1mb, sehingga memungkinkan Anda untuk memasukkannya ke dalam New Space .
Jangan blokir utas server utama
Jika Anda memiliki beberapa API panas yang memerlukan beberapa waktu untuk diproses, seperti API untuk mengubah ukuran gambar, pindahkan ke utas terpisah atau ubah menjadi pekerjaan latar belakang. Operasi intensif CPU akan memblokir utas utama yang memaksa semua pelanggan lain untuk menunggu dan terus mengirim permintaan. Data permintaan yang tidak diproses akan menumpuk di memori, sehingga memaksa GC penuh untuk menyelesaikannya lebih lama.
Jangan membuat data yang tidak perlu
Saya pernah punya pengalaman aneh dengan restify. Jika Anda mengirim beberapa ratus ribu permintaan ke URL yang tidak valid maka memori aplikasi akan bertambah dengan cepat hingga ratusan megabita hingga GC penuh muncul dalam beberapa detik kemudian, saat itulah semuanya akan kembali normal. Ternyata untuk setiap URL yang tidak valid, restify menghasilkan objek kesalahan baru yang menyertakan jejak tumpukan panjang. Ini memaksa objek yang baru dibuat untuk dialokasikan di Large Object Space daripada di New Space .
Memiliki akses ke data tersebut bisa sangat membantu selama pengembangan, tetapi jelas tidak diperlukan pada produksi. Oleh karena itu aturannya sederhana - jangan membuat data kecuali Anda benar-benar membutuhkannya.
Ketahui alat Anda
Terakhir, tetapi tentu tidak sedikit, adalah mengetahui alat Anda. Ada berbagai debugger, penangkap kebocoran, dan generator grafik penggunaan. Semua alat tersebut dapat membantu Anda membuat perangkat lunak Anda lebih cepat dan lebih efisien.
Kesimpulan
Memahami cara kerja pengumpulan sampah dan pengoptimal kode V8 adalah kunci kinerja aplikasi. V8 mengkompilasi JavaScript ke perakitan asli dan dalam beberapa kasus kode yang ditulis dengan baik dapat mencapai kinerja yang sebanding dengan aplikasi yang dikompilasi GCC.
Dan jika Anda bertanya-tanya, aplikasi API baru untuk klien Toptal saya, meskipun ada ruang untuk perbaikan, bekerja dengan sangat baik!
Joyent baru-baru ini merilis versi baru Node.js yang menggunakan salah satu versi terbaru dari V8. Beberapa aplikasi yang ditulis untuk Node.js v0.12.x mungkin tidak kompatibel dengan rilis v4.x yang baru. Namun, aplikasi akan mengalami peningkatan kinerja dan penggunaan memori yang luar biasa dalam versi baru Node.js.
