- Menggunakan counter konkuren Rust yang rusak sebagai contoh, artikel ini mengungkap masalah yang terlewat oleh uji beban thread biasa lewat kontrol urutan eksekusi yang dapat direproduksi dan diminimalkan
- Pembungkus
AtomicU32untuk pengujian menyisipkanpause(), dan managed thread berhenti sebelum dan sesudah operasi atomik lalu dilanjutkan lagi sesuai urutan yang dipilih pengujian - Pengujian sederhana dapat membuat kegagalan seperti
9598alih-alih nilai yang diharapkan10000ketika100thread masing-masing menaikkan nilai100kali, tetapi karena bergantung pada timing, kasus ini sulit direproduksi, di-debug, dan diperkecil - Uji properti berbasis
arbtestdapat mereproduksi interleaving yang sama dengan seed yang sama, dan meminimalkan kasus gagal hingga menjadi0: increment,1: increment,0: unpause,1: unpause - Jika struktur yang sama diperluas dengan
exhaustigen, semua interleaving hingga maksimum5kali increment dapat didaftarkan, dan setelah diperbaiki denganfetch_add,81133interleaving lolos
Counter konkuren yang tidak atomik
- Contohnya memakai
AtomicU32dari Rust, tetapiincrement()melakukanloadlalustore(value + 1), sehingga operasi increment itu sendiri tidak atomik - Struktur
Countersederhanavalue: AtomicU32increment()membaca nilai denganSeqCst, menambahkan1ke nilai yang dibaca, lalu menyimpannya kembaliget()membaca nilai saat ini denganSeqCst
- Dua thread dapat membaca nilai yang sama lalu menyimpan hasil increment yang sama, sehingga ada update yang hilang
Mengapa pengujian thread biasa tidak cukup
- Verifikasi paling sederhana adalah membuat beberapa thread berulang kali menaikkan counter yang sama lalu memeriksa nilai akhirnya
thread_count = 100increment_count = 100- nilai yang diharapkan adalah
10000
- Contoh eksekusi gagal dengan
left: 9598,right: 10000 - Pendekatan ini sangat bergantung pada timing penjadwalan
- sulit mereproduksi kegagalan yang sama secara deterministik
- sulit di-debug
- jika jumlah thread atau jumlah increment dikurangi, pengujian bisa lolos karena kebetulan, sehingga sulit meminimalkan kasus gagal
Menangani interleaving dengan pengujian berbasis properti
- Pengujian berbasis properti (PBT) cocok dengan pengujian state machine
- mudah menghasilkan input acak
- dapat menetapkan properti bahwa hasil eksekusi konkuren harus sama dengan model eksekusi sekuensial
- juga cocok dengan kebutuhan untuk meminimalkan input yang gagal
- Tantangannya adalah sulit menjalankan thread OS sungguhan selangkah demi selangkah pada waktu yang diinginkan
- Solusinya adalah struktur yang pada setiap iterasi memilih thread acak untuk dijalankan satu langkah
- harus bisa menyisipkan thread lain di antara
loaddanstoremilik satu thread - untuk itu dibangun API managed thread yang bisa mengendalikan thread secara langsung
- harus bisa menyisipkan thread lain di antara
AtomicU32 untuk pengujian dan penyisipan pause
- Pada build pengujian, digunakan
managed_thread::AtomicU32buatan sendiri alih-alihstd::sync::atomic::AtomicU32#[cfg(test)] use managed_thread::AtomicU32#[cfg(not(test))] use std::sync::atomic::AtomicU32
- Pembungkus
AtomicU32memanggilpause()sebelum dan sesudahload()maupunstore()load:pause()→loadsebenarnya →pause()store:pause()→storesebenarnya →pause()
- Berkat titik sisip ini, pengujian dapat menghentikan dan melanjutkan thread di sekitar operasi atomik sambil mengendalikan urutan eksekusi
Bentuk API managed thread
- Pengujian membuat dua managed thread di dalam
std::thread::scope- karena memakai scoped thread, data lokal di stack bisa dipinjam
- referensi ke counter diteruskan sebagai state, misalnya
spawn(scope, &counter)
- Managed thread tidak langsung menjalankan fungsi
maintertentu sejak awal, melainkan mengeksekusi closure yang dikirim thread pengendali lewatsubmit()t.submit(|c| c.increment())- thread mengeksekusi closure terhadap state
Tmiliknya
- Selama masih ada entropi, loop pengujian memilih aksi acak untuk setiap thread
- jika thread sedang berhenti, panggil
unpause() - jika tidak berhenti, jalankan
increment()lewatsubmit() - model sekuensial
counter_modeljuga dinaikkan dengan jumlah yang sama
- jika thread sedang berhenti, panggil
- Pada akhirnya, semua thread di-
join()dancounter_modeldibandingkan dengancounter.get()yang sebenarnya
Implementasi pause dan unpause
- Agar API
Counteryang diuji tidak perlu diubah,pause()mencari konteks managed thread saat ini melaluithread_local!- konteks dibagikan sebagai
Arc<SharedContext> SharedContextmemilikiMutex<State>danCondvar
- konteks dibagikan sebagai
- Status dibagi menjadi
Ready,Running, danPausedReady: sedang menunggu closure berikutnyaRunning: managed thread sedang berjalanPaused: berhenti di titikpause()
- Saat managed thread mencapai
pause(), status diubah dariRunningmenjadiPauseddan thread pengendali diberi tahu lewat condition variable unpause()mengubah status dariPausedmenjadiRunning, membangunkan managed thread, lalu menunggu lagi sampai statusnya bukanRunning- ini mencegah thread pengendali dan managed thread terus berjalan bersamaan
- pada waktu mana pun hanya salah satu yang berjalan, sehingga nondeterminisme berkurang
Reproduksi dan minimisasi kegagalan
- Eksekusi
arbtestmenemukan kegagalan pada counter yang rusak- contoh kegagalan: nilai model
4, nilai aktual3 - seed kegagalan:
0x4fd7ddff00000020
- contoh kegagalan: nilai model
- Dengan seed yang sama, interleaving yang sama bisa didapat kembali, sehingga reproduksi kegagalan menjadi mudah
- Dengan
.minimize(), kasus gagal diperkecil menjadi eksekusi yang lebih singkat- seed akhir yang minimal adalah
0x9c2a13a600000001 - trace minimal memiliki empat langkah
0: increment1: increment0: unpause1: unpause
- seed akhir yang minimal adalah
- Pada kasus minimal ini, nilai yang diharapkan adalah
2tetapi nilai aktual1, sehingga cacat pada increment berbasisload/storeterlihat jelas
Diperluas dengan enumerasi semua interleaving
- Struktur yang sama bisa diubah dari interleaving acak menjadi pendekatan berbasis enumerasi
- Dengan
exhaustigen, dibuat pengujian yang menjelajahi semua interleaving hingga maksimum5kali increment- pengujian disusun agar menghindari iterasi dummy, dan selalu melakukan
unpausepada thread atau mengirimincrement
- pengujian disusun agar menghindari iterasi dummy, dan selalu melakukan
- Implementasi yang rusak menemukan bug yang sama
- contoh kegagalan:
left: 2,right: 1
- contoh kegagalan:
- Setelah
Counter::increment()diperbaiki menjadifetch_add(1, SeqCst), pengujian lolos- pembungkus
AtomicU32juga ditambahkanpause()sebelum dan sesudahfetch_add() - hasil eksekusi:
all 81133 interleavings are fine! - waktu eksekusi:
real 8.65s, CPU8.16s, RSS63.91mb
- pembungkus
Perluasan ke model memori lemah dan model checking
- Implementasi
AtomicU32mainan saat ini mendelegasikan ke atomic sungguhan - Ide pengembangannya adalah setiap atomic menyimpan himpunan nilai yang pernah ditulis, lalu saat membaca mengembalikan nilai acak yang konsisten dengan model memori lemah
- Penjelajahan interleaving juga bisa dibuat lebih cerdas daripada sekadar acak
- dengan pendekatan model checking, kita bisa memastikan apakah semua interleaving yang bermakna berbeda sudah dipertimbangkan
- seperti pendekatan Generate All The Things, semua interleaving dalam cakupan kecil dapat didaftarkan
Mengapa minimisasi bisa dilakukan tanpa shrinking
arbtestyang digunakan tampak seperti antarmuka PRNG yang familier, tetapi memakai PRNG hingga- jika nilai acak terus diminta, pada suatu titik ia akan mengembalikan
Err(OutOfEntropy) - karena itu di kode pengujian muncul
?danwhile !rng.is_empty()
- jika nilai acak terus diminta, pada suatu titik ia akan mengembalikan
- Saat pengujian menghabiskan entropi, eksekusi berakhir lebih cepat, sehingga jika entropi yang tersedia dikurangi, eksekusi pengujian juga menjadi lebih pendek
- Implementasi internalnya secara konseptual mirip
&mut &[u8]- setiap kali angka acak diminta, irisan byte menjadi lebih pendek
- makin pendek irisan awalnya, makin sederhana pengujiannya
- Berkat pendekatan ini, kasus gagal bisa dipersingkat tanpa perlu mengimplementasikan logika shrinking terpisah secara manual
- Kode sumber contohnya ada di properly-concurrent
1 komentar
Komentar Hacker News
Di Rust, ada yang sedang membuat pustaka bernama Temper dengan pendekatan serupa: https://github.com/reitzensteinm/temper/tree/main
Namun untuk memodelkan implikasi aneh yang ditimbulkan oleh keseluruhan model memori Rust, perlu melangkah jauh lebih dalam, sehingga dibutuhkan semacam buku besar yang melacak penulisan mana yang telah diketahui oleh tiap thread. Bergantung pada urutan memori atomik, fence baca/tulis, dan sebagainya, bisa muncul jaminan seperti: jika penulisan X diketahui, maka penulisan Y juga harus diketahui
Saya rasa ini termasuk kumpulan kasus uji model memori C++/Rust yang paling banyak; hampir semua yang bisa ditemukan dari buku, standar C++, Stack Overflow, blog, dan lain-lain sudah dikumpulkan. Misalnya, berkas untuk Rust Atomics and Locks karya Mara Bos ada di sini: https://github.com/reitzensteinm/temper/blob/main/memlog/tes...
Loom yang disebutkan dalam tulisan tersebut serupa, tetapi merupakan pustaka yang jauh lebih matang, dan memungkinkan pengujian menyeluruh terhadap komponen tingkat lebih tinggi seperti mutex atau queue: https://github.com/tokio-rs/loom Namun model memorinya sendiri tidak dimodelkan serinci Temper, dan saya sempat berpikir untuk memindahkan kasus-kasus uji tersebut ke Loom
Saya terinspirasi oleh presentasi Will Wilson tentang pengujian FoundationDB, dan sekarang ia sedang membuat solusi berbasis hypervisor di Antithesis untuk melakukan pengujian semacam ini pada container Docker arbitrer: https://www.youtube.com/watch?v=4fFDFbi3toc, https://antithesis.com/
Saya sangat yakin area ini akan tumbuh jauh lebih besar dalam 10 tahun ke depan. WebAssembly berada di titik yang pas: cukup lengkap untuk mengompilasi perangkat lunak arbitrer, tetapi cukup sederhana sehingga membangun sesuatu seperti Antithesis tidak perlu menjadi proyek 5 tahun bagi tim elite yang sudah pernah merilis database
Saya mengimplementasikan snapshot atomik memori bersama di Rust, dan juga menangani pengujian otomatis seserius mungkin: https://github.com/kaymanb/todc/tree/main/todc-mem
Awalnya saya memakai Loom yang disebut dalam tulisan, tetapi kemudian beralih ke shuttle: https://github.com/tokio-rs/loom, https://github.com/awslabs/shuttle
shuttle memakai pendekatan teracak alih-alih exhaustive search seperti Loom, tetapi scheduler-nya tetap memberikan jaminan probabilistik untuk menemukan bug. Dari pengalaman saya, shuttle lebih cepat dan dapat diskalakan hingga skenario pengujian yang lebih kompleks
Mirip dengan pendekatan dalam tulisan, jika jadwal tertentu menyebabkan pengujian gagal, seed acaknya bisa disimpan. Kemampuan mereproduksi pengujian yang gagal dengan cepat sangat penting, dan memungkinkan kita menulis test case eksplisit untuk bug yang sebelumnya ditemukan dan sudah diperbaiki: https://github.com/kaymanb/todc/blob/0e2874a70ec8beed8fae773...
Di sisi Kotlin/Java, Lincheck dari JetBrains adalah pustaka yang bagus untuk pekerjaan seperti ini: https://github.com/JetBrains/lincheck
Saya terutama suka karena sifatnya deklaratif dan caranya menampilkan hasil linearizability
Saya penasaran apakah ada pustaka seperti Loom untuk C++. Ada beberapa struktur data lock-free yang ingin saya uji
Ini alat yang cukup lama dan mudah digunakan. Pembuatnya Dmitry Vyukov, seorang pakar di bidang konkurensi
https://github.com/facebook/folly/blob/main/folly/test/Deter...
Jika saya memahaminya dengan benar, pendekatan ini punya keterbatasan terkait jaminan kemajuan maju yang lemah.
Perhitungan di artikel memang tidak sepenuhnya sepele, tetapi di perangkat keras nyata dan scheduler nyata, kita bisa membayangkan loop
cmpxchgyang kemungkinannya berhenti di CPU tertentu sangat kecil. Jika jumlah CPU adalahn, dalam kasus terburuk peluang untuk maju adalah1/n, tetapi dalam metode pengujian ini menjadi1/t^p. Di sinitadalah jumlah task, yang bisa jauh lebih besar daripada jumlah CPU, danpadalah jumlah jeda di dalam body loop tersebut, yang dengan mudah bisa 3 atau lebih. Ini sudah cukup untuk membuat algoritma yang sebenarnya bekerja terlihat rusak.Sebaliknya, bahkan jika kita ingin menangkap kemajuan maju yang lemah sebagai bug sehingga menuntut kemajuan maju yang kuat, metode ini tampaknya juga tidak menyediakan alat yang berguna.
Meski begitu, untuk banyak masalah konkurensi metode ini jelas berguna.
1/t^prasanya tidak tepat; saya melihatnya hanya sebagai1/t. Pada akhirnya, setelah waktutberlalu, pasti ada satu task yang maju, dan jika adattask, peluang bahwa task yang maju itu adalah task saya adalah1/t.Kebingungan utamanya sepertinya ada pada anggapan bahwa jika berhenti, itu pasti berarti kalah di CAS, padahal tidak begitu.
Mengenai bagian “sejujurnya, ada sedikit pengetahuan sebelumnya di sini. Kecuali melakukan sesuatu yang sangat terkutuk dengan assembly inline, tampaknya kita tidak bisa menghindari pembuatan thread sungguhan. Jika sesuatu memanggil fungsi
pause()dan kita ingin membuatnya berhenti sampai ada instruksi berikutnya, hal itu harus terjadi di dalam thread yang mempertahankan stack terpisah dari stack pengujian”, saya penasaran apakah tidak bisa memakai semacam runtime asinkron.Ini terlihat seperti mencapai multitasking kooperatif dengan menginstrumentasi operasi atomik. Mungkin saya perlu minum lebih banyak kopi, tetapi tampaknya lebih sederhana jika dilakukan tanpa thread.
Salah satu kekurangan pendekatan ini adalah kode yang diuji sendiri harus dimodifikasi agar sesuai dengan kode pengujian.
Rasanya hal yang sama bisa dilakukan dengan menjalankan dua thread, lalu memakai
ptraceuntuk single-step dan menyisipkan eksekusi instruksi secara “acak”. Mirip mode chaos di rr.Namun karena beberapa instruksi mungkin tidak atomik, jika memang memungkinkan tanpa emulasi, sepertinya diperlukan cara untuk melakukan single-step pada satuan “mikrokode atomik”.
Untuk memakai Loom, sepertinya diperlukan kompilasi kondisional, dan itu mungkin oke saat menguji satu library, tetapi cukup invasif.
#[cfg(loom)]pub(crate) use loom::sync::atomic::AtomicUsize;#[cfg(not(loom))]pub(crate) use std::sync::atomic::AtomicUsize;Saya penasaran apakah ada bahasa yang memungkinkan penggunaan scheduler sendiri dengan lebih baik.
Jika ingin benar-benar menyeluruh, sepertinya pengujian bisa dijalankan dengan
ptrace, lalu thread dijalankan secara single-step untuk membuat interleaving berbeda di tingkat instruksi. Saya penasaran apakah ada yang pernah melihat pendekatan seperti itu dalam praktik.Jika kode tidak bisa diinstrumentasi seperti di sini, apakah ada alternatif untuk pengujian black-box?
ninstruksi, kita hanya perlunkali eksekusi, yaitu menjalankan 0 sampaininstruksi sebelum menyisipkan sinyal; setelah itu handler sinyal berjalan sampai selesai, lalu thread utama juga berjalan sampai selesai. Total waktunyaO(n^2).Namun jika ada
tthread yang masing-masing menjalankanninstruksi, dan semuanya bisa saling menghentikan di setiap batas, pendekatan ini sulit diterapkan untuk nilainyang realistis. Sepertinya perlu dipersempit dengan memilih dan menyimulasikan hanya operasi yang memiliki perilaku menarik.Terlihat cukup keren, jadi saya harus mencobanya. Namun ini tidak akan menangkap semua jenis kesalahan. Setiap pemanggilan
pause()menciptakan sinkronisasi antar-thread, jadi bukankah sebagian masalah data race bisa tertutupi? Di Rust mungkin ini bukan masalah.