Kami di
Phusion memiliki proxy HTTP multi-utas sederhana di Ruby (mendistribusikan paket DEB dan RPM). Saya melihat di atasnya konsumsi memori 1,3 GB. Tapi ini gila untuk proses kewarganegaraan ...
Pertanyaan: Apa itu? Jawab: Penggunaan memori oleh proses Ruby dari waktu ke waktu!Ternyata saya tidak sendirian dalam masalah ini. Aplikasi Ruby dapat menggunakan banyak memori. Tapi mengapa? Menurut
Heroku dan
Nate Burkopek ,
kembung terutama disebabkan oleh fragmentasi memori dan distribusi
tumpukan yang berlebihan.
Berkopek menyimpulkan bahwa ada dua solusi:
- Baik menggunakan pengalokasi memori yang sama sekali berbeda dari glibc - biasanya jemalloc , atau:
- Setel variabel lingkungan ajaib
MALLOC_ARENA_MAX=2
.
Saya khawatir tentang deskripsi masalah dan solusi yang diajukan. Ada sesuatu yang salah di sini ... Saya tidak yakin bahwa masalahnya dijelaskan dengan benar atau ini adalah satu-satunya solusi yang tersedia. Ini juga mengganggu saya bahwa banyak orang menyebut jemalloc sebagai kolam perak ajaib.
Sihir hanyalah ilmu yang belum kita pahami . Jadi saya melakukan perjalanan penelitian untuk mengetahui seluruh kebenaran. Artikel ini akan membahas topik-topik berikut:
- Bagaimana alokasi memori bekerja.
- Apa ini "fragmentasi" dan "distribusi berlebihan" memori yang semua orang bicarakan?
- Apa yang menyebabkan konsumsi memori besar? Apakah situasinya konsisten dengan apa yang orang katakan, atau ada sesuatu yang lain? (spoiler: ya, ada sesuatu yang lain).
- Apakah ada solusi alternatif? (spoiler: Saya menemukan satu).
Catatan: artikel ini hanya relevan untuk Linux, dan hanya untuk aplikasi Ruby multi-threaded.Isi
Alokasi Memori Ruby: Pendahuluan
Ruby mengalokasikan memori pada tiga level, dari atas ke bawah:
- Penerjemah Ruby yang mengelola objek Ruby.
- Perpustakaan pengalokasi memori sistem operasi.
- Intinya.
Mari kita melewati setiap level.
Ruby
Di sisinya, Ruby mengatur objek di area memori yang disebut
halaman tumpukan Ruby . Halaman tumpukan seperti itu dibagi menjadi beberapa slot dengan ukuran yang sama, di mana satu objek menempati satu slot. Apakah itu string, tabel hash, array, kelas, atau yang lainnya, ia menempati satu slot.
Slot pada halaman tumpukan mungkin sibuk atau gratis. Ketika Ruby memilih objek baru, ia segera mencoba menempati slot gratis. Jika tidak ada slot gratis, halaman tumpukan baru akan disorot.
Slotnya kecil, sekitar 40 byte. Jelas, beberapa objek tidak akan cocok di dalamnya, misalnya, 1 MB baris. Kemudian Ruby menyimpan informasi di tempat lain di luar halaman tumpukan, dan menempatkan penunjuk ke area memori eksternal ini di slot.
Data yang tidak sesuai dengan slot disimpan di luar halaman tumpukan. Ruby menempatkan pointer ke data eksternal ini di slotBaik halaman tumpukan Ruby dan area memori eksternal dialokasikan menggunakan pengalokasi memori sistem.
Pengalokasi memori sistem
Alokasi memori sistem operasi adalah bagian dari glibc (C runtime). Ini digunakan oleh hampir semua aplikasi, bukan hanya Ruby. Ini memiliki API sederhana:
- Memori dialokasikan dengan memanggil
malloc(size)
. Anda memberinya jumlah byte yang ingin Anda alokasikan, dan itu mengembalikan alamat alokasi atau kesalahan. - Memori yang dialokasikan dibebaskan dengan memanggil
free(address)
.
Tidak seperti Ruby, di mana slot dengan ukuran yang sama dialokasikan, pengalokasi memori menangani permintaan untuk mengalokasikan memori dalam ukuran apa pun. Seperti yang akan Anda pelajari nanti, fakta ini menyebabkan beberapa komplikasi.
Pada gilirannya, pengalokasi memori mengakses API kernel. Dibutuhkan potongan memori yang jauh lebih besar dari kernel daripada permintaan pelanggannya sendiri, karena panggilan kernel itu mahal dan API kernel memiliki batasan: itu hanya dapat mengalokasikan memori dalam kelipatan 4 KB.
Pengalokasi memori mengalokasikan potongan besar - mereka disebut tumpukan sistem - dan membagikan kontennya untuk memenuhi permintaan dari aplikasiArea memori yang dialokasikan oleh pengalokasi memori dari kernel disebut heap. Perhatikan bahwa itu tidak ada hubungannya dengan halaman tumpukan Ruby, jadi untuk kejelasan kita akan menggunakan istilah
tumpukan sistem .
Pengalokasi memori kemudian menetapkan bagian-bagian dari sistem yang menumpuk ke peneleponnya sampai ada ruang kosong. Dalam hal ini, pengalokasi memori mengalokasikan tumpukan sistem baru dari kernel. Ini mirip dengan bagaimana Ruby memilih objek dari halaman tumpukan Ruby.
Ruby mengalokasikan memori dari pengalokasi memori, yang pada gilirannya mengalokasikan memori dari kernelIntinya
Kernel hanya dapat mengalokasikan memori dalam 4 unit KB. Satu blok 4K seperti itu disebut halaman. Untuk menghindari kebingungan dengan halaman tumpukan Ruby, untuk kejelasan kami akan menggunakan istilah
halaman sistem (halaman OS).
Alasannya sulit untuk dijelaskan, tetapi inilah cara semua kernel modern bekerja.
Mengalokasikan memori melalui kernel memiliki dampak signifikan pada kinerja, itulah sebabnya mengapa pengalokasi memori mencoba untuk meminimalkan jumlah panggilan kernel.
Definisi penggunaan memori
Dengan demikian, memori dialokasikan pada beberapa level, dan setiap level mengalokasikan lebih banyak memori daripada yang sebenarnya dibutuhkan. Halaman tumpukan Ruby dapat memiliki slot gratis, juga tumpukan sistem. Karena itu, jawaban atas pertanyaan "Berapa banyak memori yang digunakan?" benar-benar tergantung pada level apa yang Anda tanyakan!
Alat seperti
top
atau
ps
menunjukkan penggunaan memori dari perspektif
kernel . Ini berarti bahwa level yang lebih tinggi harus bekerja bersamaan untuk membebaskan memori dari sudut pandang kernel. Seperti yang akan Anda pelajari nanti, ini lebih sulit daripada kedengarannya.
Apa itu fragmentasi?
Fragmentasi memori berarti bahwa alokasi memori tersebar secara acak. Ini dapat menyebabkan masalah yang menarik.
Fragmentasi Tingkat Ruby
Pertimbangkan pengumpulan sampah Ruby. Pengumpulan sampah untuk suatu objek berarti menandai slot halaman tumpukan Ruby sebagai gratis, yang memungkinkannya untuk digunakan kembali. Jika seluruh halaman tumpukan Ruby hanya berisi slot gratis, maka seluruh halamannya dapat dibebaskan kembali ke pengalokasi memori (dan, mungkin, kembali ke kernel).
Tetapi apa yang terjadi jika tidak semua slot gratis? Bagaimana jika kita memiliki banyak halaman tumpukan Ruby, dan pengumpul sampah membebaskan objek di tempat yang berbeda, sehingga pada akhirnya ada banyak slot gratis, tetapi di halaman yang berbeda? Dalam situasi ini, Ruby memiliki slot gratis untuk menempatkan objek, tetapi pengalokasi memori dan kernel akan terus mengalokasikan memori!
Memori Alokasi Fragmentasi
Pengalokasi memori memiliki masalah yang sama tetapi sangat berbeda. Dia tidak perlu segera menghapus seluruh tumpukan sistem. Secara teoritis, ini dapat membebaskan halaman sistem tunggal. Tetapi karena pengalokasi memori berurusan dengan alokasi memori dengan ukuran sewenang-wenang, mungkin ada beberapa alokasi pada halaman sistem. Itu tidak dapat membebaskan halaman sistem sampai semua pilihan dibebaskan.
Pikirkan apa yang terjadi jika kita memiliki alokasi 3 KB, serta alokasi 2 KB, dibagi menjadi dua halaman sistem. Jika Anda mengosongkan 3 KB pertama, kedua halaman sistem akan tetap terisi sebagian dan tidak dapat dibebaskan.
Oleh karena itu, jika keadaan gagal, akan ada banyak ruang kosong di halaman sistem, tetapi mereka tidak akan dibebaskan sepenuhnya.
Lebih buruk lagi: bagaimana jika ada banyak tempat gratis, tetapi tidak satu pun dari mereka yang cukup besar untuk memenuhi permintaan alokasi baru? Pengalokasi memori harus mengalokasikan tumpukan sistem yang sama sekali baru.
Apakah Ruby menumpuk fragmentasi halaman yang menyebabkan memori membengkak?
Kemungkinan fragmentasi menyebabkan penggunaan memori berlebihan di Ruby. Jika demikian, manakah dari dua fragmen yang lebih berbahaya? Ini ...
- Ruby menumpuk fragmentasi halaman? Atau
- Fragmentasi pengalokasi memori?
Opsi pertama cukup mudah untuk diperiksa. Ruby menyediakan dua API:
ObjectSpace.memsize_of_all
dan
GC.stat
. Berkat informasi ini, Anda dapat menghitung semua memori yang diterima Ruby dari pengalokasi.
ObjectSpace.memsize_of_all
mengembalikan memori yang ditempati oleh semua objek Ruby yang aktif. Artinya, semua ruang dalam slot mereka dan data eksternal apa pun. Dalam diagram di atas, ini adalah ukuran semua benda biru dan oranye.
GC.stat
memungkinkan
GC.stat
untuk mengetahui ukuran semua slot gratis, mis. Seluruh area abu-abu dalam ilustrasi di atas. Berikut algoritanya:
GC.stat[:heap_free_slots] * GC::INTERNAL_CONSTANTS[:RVALUE_SIZE]
Untuk meringkasnya, ini adalah semua memori yang diketahui Ruby, dan itu melibatkan fragmen halaman tumpukan Ruby. Jika, dari sudut pandang kernel, penggunaan memori lebih tinggi, maka memori yang tersisa pergi ke suatu tempat di luar kendali Ruby, misalnya, ke perpustakaan atau fragmentasi pihak ketiga.
Saya menulis sebuah program pengujian sederhana yang menciptakan banyak utas, yang masing-masing memilih garis dalam satu lingkaran. Ini hasilnya setelah beberapa saat:
itu ... hanya ... gila!
Hasilnya menunjukkan bahwa Ruby memiliki efek yang lemah pada jumlah total memori yang digunakan, tidak masalah jika halaman tumpukan Ruby terfragmentasi atau tidak.
Harus mencari pelakunya di tempat lain. Setidaknya sekarang kita tahu bahwa Ruby tidak bisa disalahkan.
Studi Fragmentasi Alokasi Memori
Kemungkinan tersangka lain adalah pengalokasi memori. Pada akhirnya, Nate Berkopek dan Heroku memperhatikan bahwa meributkan dengan pengalokasi memori (baik pengganti penuh untuk jemalloc atau mengatur variabel lingkungan ajaib
MALLOC_ARENA_MAX=2
) secara drastis mengurangi penggunaan memori.
Pertama mari kita lihat apa yang dilakukan
MALLOC_ARENA_MAX=2
dan mengapa itu membantu. Kemudian kami memeriksa fragmentasi di tingkat distributor.
Alokasi memori yang berlebihan dan glibc
Alasan
MALLOC_ARENA_MAX=2
membantu adalah
MALLOC_ARENA_MAX=2
multithreading. Ketika beberapa utas secara bersamaan mencoba mengalokasikan memori dari tumpukan sistem yang sama, mereka memperjuangkan akses. Hanya satu utas pada satu waktu dapat menerima memori, yang mengurangi kinerja alokasi memori multi-utas.
Hanya satu utas pada satu waktu yang dapat bekerja dengan tumpukan sistem. Dalam tugas multi-utas, konflik muncul dan, akibatnya, kinerja menurunDi pengalokasi memori untuk kasus seperti itu ada optimasi. Dia mencoba membuat beberapa tumpukan sistem dan menetapkannya ke utas berbeda. Sebagian besar utas hanya berfungsi dengan tumpukannya sendiri, menghindari konflik dengan utas lainnya.
Faktanya, jumlah maksimum tumpukan sistem yang dialokasikan dengan cara ini secara default sama dengan jumlah prosesor virtual dikalikan dengan 8. Artinya, dalam sistem dual-core dengan dua hyper-thread, masing-masing menghasilkan
2 * 2 * 8 = 32
tumpukan sistem! Inilah yang saya sebut
distribusi berlebihan .
Mengapa pengganda standar begitu besar? Karena pengembang pengalokasi memori terkemuka adalah Red Hat. Pelanggan mereka adalah perusahaan besar dengan server yang kuat dan satu ton RAM. Optimalisasi di atas memungkinkan Anda untuk meningkatkan kinerja multithreading rata-rata sebesar 10% karena peningkatan yang signifikan dalam penggunaan memori. Untuk pelanggan Red Hat, ini adalah kompromi yang bagus. Untuk sebagian besar sisanya - hampir tidak.
Nate dalam blognya dan artikel Heroku mengklaim bahwa meningkatkan jumlah tumpukan sistem meningkatkan fragmentasi, dan mengutip dokumentasi resmi. Variabel
MALLOC_ARENA_MAX
mengurangi jumlah maksimum tumpukan sistem yang dialokasikan untuk multithreading. Dengan logika ini, ini mengurangi fragmentasi.
Visualisasi tumpukan sistem
Apakah pernyataan oleh Nate dan Heroku benar bahwa meningkatkan jumlah tumpukan sistem meningkatkan fragmentasi? Bahkan, apakah ada masalah dengan fragmentasi di tingkat pengalokasi memori? Saya tidak mau menerima asumsi ini begitu saja, jadi saya mulai belajar.
Sayangnya, tidak ada alat untuk memvisualisasikan tumpukan sistem, jadi
saya menulis visualisasi seperti itu sendiri .
Pertama, Anda perlu mempertahankan skema distribusi tumpukan sistem. Saya mempelajari
sumber pengalokasi memori dan melihat bagaimana itu secara internal mewakili memori. Kemudian ia menulis perpustakaan yang beralih pada struktur data ini dan menulis skema ke file. Akhirnya, ia menulis alat yang mengambil file seperti input dan mengkompilasi visualisasi sebagai gambar HTML dan PNG (
kode sumber ).
Berikut adalah contoh memvisualisasikan satu tumpukan sistem tertentu (ada banyak lagi). Blok kecil dalam visualisasi ini mewakili halaman sistem.
- Area merah digunakan sel memori.
- Abu-abu adalah area bebas yang tidak dilepaskan kembali ke inti.
- Area putih dibebaskan untuk nukleus.
Kesimpulan berikut dapat diambil dari visualisasi:
- Ada beberapa fragmentasi. Bintik merah tersebar dari memori, dan beberapa halaman sistem hanya setengah merah.
- Yang mengejutkan saya, kebanyakan tumpukan sistem mengandung banyak halaman sistem gratis (abu-abu)!
Dan kemudian saya sadar:
Meskipun fragmentasi tetap menjadi masalah, bukan itu intinya!Sebaliknya, masalahnya adalah banyak abu-abu: pengalokasi memori
ini tidak mengirim memori kembali ke kernel !
Setelah mempelajari kembali kode sumber pengalokasi memori, ternyata secara default hanya mengirim halaman sistem ke kernel di akhir tumpukan sistem, dan bahkan
jarang melakukannya. Mungkin, algoritma semacam itu diterapkan untuk alasan kinerja.
Trik Sulap: Sunat
Untungnya, saya menemukan satu trik. Ada satu antarmuka pemrograman yang akan memaksa pengalokasi memori untuk merilis untuk kernel tidak hanya yang terakhir, tetapi
semua halaman sistem yang relevan. Ini disebut
malloc_trim .
Saya tahu tentang fungsi ini, tetapi saya pikir itu tidak berguna, karena manual mengatakan yang berikut:
Fungsi malloc_trim () mencoba membebaskan memori bebas di bagian atas heap.
Manualnya salah! Analisis kode sumber mengatakan bahwa program membebaskan semua halaman sistem yang relevan, bukan hanya bagian atas.
Apa yang terjadi jika fungsi ini dipanggil selama pengumpulan sampah? Saya memodifikasi kode sumber Ruby 2.6 untuk memanggil
malloc_trim()
di fungsi gc_start dari gc.c, misalnya:
gc_prof_timer_start(objspace); { gc_marks(objspace, do_full_mark);
Dan inilah hasil tesnya:
Sungguh perbedaan besar! Patch sederhana mengurangi konsumsi memori hingga hampir
MALLOC_ARENA_MAX=2
.
Begini tampilannya dalam visualisasi:
Kami melihat banyak area putih yang sesuai dengan halaman sistem yang dibebaskan kembali ke kernel.
Kesimpulan
Ternyata fragmentasi itu, pada dasarnya, tidak ada hubungannya dengan itu. Defragmentasi masih berguna, tetapi masalah utamanya adalah bahwa pengalokasi memori tidak ingin membebaskan memori kembali ke kernel.
Untungnya, solusinya ternyata sangat sederhana. Hal utama adalah menemukan akar permasalahannya.
Kode Sumber Visualizer
Kode sumberBagaimana dengan kinerja?
Kinerja tetap menjadi salah satu perhatian utama. Memanggil
malloc_trim()
tidak dapat
malloc_trim()
secara gratis, tetapi menurut kode, algoritme berfungsi dalam waktu linier. Jadi saya menoleh ke
Noah Gibbs , yang meluncurkan benchmark Rails Ruby Bench. Yang mengejutkan saya, tambalan itu menyebabkan sedikit
peningkatan kinerja.
Itu menghancurkan pikiran saya. Efeknya tidak bisa dipahami, tetapi beritanya bagus.
Perlu lebih banyak tes.
Sebagai bagian dari penelitian ini, hanya sejumlah kecil kasus yang telah diverifikasi. Tidak diketahui apa dampaknya terhadap beban kerja lainnya. Jika Anda ingin membantu dalam pengujian, silakan
hubungi saya .