Ini paling baik diilustrasikan dengan sebuah contoh.
Misalkan kita memiliki tugas sederhana yang ingin kita lakukan berkali-kali secara paralel, dan kita ingin melacak secara global berapa kali tugas itu dilakukan, misalnya menghitung penghitungan pada halaman web.
Ketika setiap utas sampai pada titik di mana ia menambah jumlah, pelaksanaannya akan terlihat seperti ini:
- Baca jumlah klik dari memori ke dalam register prosesor
- Tambahkan angka itu.
- Tulis nomor itu kembali ke memori
Ingatlah bahwa setiap utas dapat ditangguhkan pada titik mana pun dalam proses ini. Jadi jika utas A melakukan langkah 1, dan kemudian ditangguhkan, diikuti oleh utas B yang melakukan ketiga langkah, ketika utas melanjutkan, registernya akan memiliki jumlah klik yang salah: registernya akan dipulihkan, ia akan dengan senang hati menambah nomor lama dari hit, dan simpan angka yang bertambah itu.
Selain itu, sejumlah utas lain dapat berjalan selama waktu utas ditangguhkan, sehingga utas penghitungan A pada akhirnya mungkin jauh di bawah jumlah yang benar.
Untuk alasan itu, penting untuk memastikan bahwa jika utas melakukan langkah 1, ia harus melakukan langkah 3 sebelum utas lainnya diizinkan untuk melakukan langkah 1, yang dapat diselesaikan oleh semua utas yang menunggu untuk mendapatkan kunci tunggal sebelum mereka memulai proses ini , dan membebaskan kunci hanya setelah proses selesai, sehingga "bagian kritis" kode ini tidak dapat disisipkan secara tidak benar, menghasilkan jumlah yang salah.
Tetapi bagaimana jika operasi itu atomik?
Ya, di tanah unicorn ajaib dan pelangi, di mana operasi kenaikan adalah atom, maka penguncian tidak diperlukan untuk contoh di atas.
Penting untuk disadari, bahwa kita hanya menghabiskan sedikit waktu di dunia unicorn ajaib dan pelangi. Di hampir setiap bahasa pemrograman, operasi kenaikan dipecah menjadi tiga langkah di atas. Itu karena, bahkan jika prosesor mendukung operasi peningkatan atom, operasi itu secara signifikan lebih mahal: harus membaca dari memori, memodifikasi nomor, dan menulisnya kembali ke memori ... dan biasanya operasi penambahan atom adalah operasi yang bisa gagal, artinya urutan sederhana di atas harus diganti dengan loop (seperti yang akan kita lihat di bawah).
Karena, bahkan dalam kode multithreaded, banyak variabel disimpan lokal ke utas tunggal, program jauh lebih efisien jika mereka menganggap masing-masing variabel lokal untuk satu utas, dan biarkan programmer menjaga melindungi keadaan bersama di antara utas. Terutama mengingat bahwa operasi atom biasanya tidak cukup untuk menyelesaikan masalah threading, seperti yang akan kita lihat nanti.
Variabel yang mudah menguap
Jika kita ingin menghindari kunci untuk masalah khusus ini, pertama-tama kita harus menyadari bahwa langkah-langkah yang digambarkan dalam contoh pertama kita sebenarnya bukan apa yang terjadi dalam kode kompilasi modern. Karena kompilator menganggap hanya satu utas yang memodifikasi variabel, setiap utas akan menyimpan salinan variabel yang di-cache sendiri, hingga register prosesor diperlukan untuk sesuatu yang lain. Selama ia memiliki salinan yang di-cache, ia menganggap tidak perlu kembali ke memori dan membacanya lagi (yang akan mahal). Mereka juga tidak akan menulis variabel kembali ke memori selama itu disimpan dalam register.
Kita dapat kembali ke situasi yang kita berikan dalam contoh pertama (dengan semua masalah threading yang sama yang kita identifikasi di atas) dengan menandai variabel sebagai volatile , yang memberitahu kompiler bahwa variabel ini sedang dimodifikasi oleh orang lain, dan karenanya harus dibaca dari atau ditulis ke memori setiap kali diakses atau dimodifikasi.
Jadi variabel yang ditandai sebagai volatile tidak akan membawa kita ke tanah operasi kenaikan atom, itu hanya membuat kita sedekat yang kita kira sudah.
Membuat atom kenaikan
Setelah kami menggunakan variabel volatil, kami dapat membuat atom kenaikan operasi kami dengan menggunakan operasi set bersyarat tingkat rendah yang didukung sebagian besar CPU modern (sering disebut bandingkan dan atur atau bandingkan dan tukar ). Pendekatan ini diambil, misalnya, di kelas AtomicInteger Java :
197 /**
198 * Atomically increments by one the current value.
199 *
200 * @return the updated value
201 */
202 public final int incrementAndGet() {
203 for (;;) {
204 int current = get();
205 int next = current + 1;
206 if (compareAndSet(current, next))
207 return next;
208 }
209 }
Loop di atas berulang kali melakukan langkah-langkah berikut, hingga langkah 3 berhasil:
- Baca nilai variabel volatil langsung dari memori.
- Tambahkan nilai itu.
- Ubah nilainya (dalam memori utama) jika dan hanya jika nilai saat ini dalam memori utama sama dengan nilai yang kita baca pada awalnya, menggunakan operasi atom khusus.
Jika langkah 3 gagal (karena nilai diubah oleh utas berbeda setelah langkah 1), itu lagi membaca variabel langsung dari memori utama dan mencoba lagi.
Meskipun operasi membandingkan dan menukar mahal, itu sedikit lebih baik daripada menggunakan penguncian dalam kasus ini, karena jika utas ditangguhkan setelah langkah 1, utas lain yang mencapai langkah 1 tidak harus memblokir dan menunggu utas pertama, yang dapat mencegah pengalihan konteks yang mahal. Ketika utas pertama dilanjutkan, ia akan gagal dalam upaya pertamanya untuk menulis variabel, tetapi akan dapat melanjutkan dengan membaca kembali variabel, yang lagi-lagi kemungkinan lebih murah daripada pengalih konteks yang diperlukan dengan penguncian.
Jadi, kita bisa sampai ke tanah penambahan atom (atau operasi lain pada satu variabel) tanpa menggunakan kunci aktual, melalui perbandingan dan swap.
Jadi kapan penguncian sangat diperlukan?
Jika Anda perlu memodifikasi lebih dari satu variabel dalam operasi atom, maka penguncian akan diperlukan, Anda tidak akan menemukan instruksi prosesor khusus untuk itu.
Selama Anda mengerjakan variabel tunggal, dan Anda siap untuk pekerjaan apa pun yang telah Anda lakukan untuk gagal dan harus membaca variabel dan memulai lagi, perbandingan-dan-swap akan cukup baik.
Mari kita perhatikan contoh di mana setiap utas pertama menambahkan 2 ke variabel X, dan kemudian mengalikan X dengan dua.
Jika X awalnya adalah satu, dan dua utas berjalan, kami berharap hasilnya adalah (((1 + 2) * 2) + 2) * 2 = 16.
Namun, jika utas saling berhubungan, kita bisa, bahkan dengan semua operasi menjadi atom, sebagai gantinya kedua penambahan terjadi terlebih dahulu, dan multiplikasi muncul setelahnya, menghasilkan (1 + 2 + 2) * 2 * 2 = 20.
Ini terjadi karena penggandaan dan penambahan bukan operasi komutatif.
Jadi, operasi itu sendiri menjadi atom tidak cukup, kita harus membuat kombinasi operasi atom.
Kita bisa melakukannya dengan menggunakan penguncian untuk membuat serialisasi proses, atau kita bisa menggunakan satu variabel lokal untuk menyimpan nilai X ketika kita mulai perhitungan kita, variabel lokal kedua untuk langkah-langkah perantara, dan kemudian gunakan bandingkan-dan-tukar untuk atur nilai baru hanya jika nilai X saat ini sama dengan nilai asli X. Jika kita gagal, kita harus memulai dari awal lagi dengan membaca X dan melakukan perhitungan lagi.
Ada beberapa trade-off yang terlibat: saat kalkulasi menjadi lebih lama, maka kemungkinan besar thread yang berjalan akan ditangguhkan, dan nilainya akan dimodifikasi oleh utas lainnya sebelum kami melanjutkan, yang berarti kegagalan menjadi jauh lebih mungkin, yang menyebabkan pemborosan. waktu prosesor. Dalam kasus ekstrim dari sejumlah besar utas dengan perhitungan berjalan sangat lama, kita mungkin memiliki 100 utas membaca variabel dan terlibat dalam perhitungan, di mana dalam kasus ini hanya yang pertama selesai akan berhasil menulis nilai baru, 99 lainnya masih akan selesaikan perhitungan mereka, tetapi temukan setelah selesai bahwa mereka tidak dapat memperbarui nilai ... pada titik mana mereka masing-masing akan membaca nilai dan memulai perhitungan lebih dari itu. Kami kemungkinan memiliki 99 utas yang tersisa mengulangi masalah yang sama, menghabiskan banyak waktu prosesor.
Serialisasi lengkap bagian kritis melalui kunci akan jauh lebih baik dalam situasi itu: 99 utas akan ditangguhkan saat mereka tidak mendapatkan kunci, dan kami akan menjalankan setiap utas sesuai urutan kedatangan di titik kunci.
Jika serialisasi tidak kritis (seperti dalam kasus kenaikan kami), dan perhitungan yang akan hilang jika memperbarui nomor gagal minimal, mungkin ada keuntungan signifikan yang dapat diperoleh dari menggunakan operasi perbandingan-dan-tukar, karena operasi itu lebih murah daripada mengunci.