Penguncian yang diperiksa dua kali: Pintar, tapi rusak

Dari Elemen-Elemen Gaya Java yang sangat dihormati hingga halaman-halaman JavaWorld (lihat Tip Java 67), banyak pakar Java yang bermaksud baik mendorong penggunaan idiom penguncian yang diperiksa dua kali (DCL). Hanya ada satu masalah dengannya - idiom yang tampak pintar ini mungkin tidak berhasil.

Penguncian yang diperiksa dua kali dapat berbahaya bagi kode Anda!

Minggu ini JavaWorld berfokus pada bahaya idiom penguncian yang diperiksa dua kali. Baca lebih lanjut tentang bagaimana pintasan yang tampaknya tidak berbahaya ini dapat merusak kode Anda:
  • "Peringatan! Threading di dunia multiprosesor," Allen Holub
  • Penguncian diperiksa dua kali: Pintar, tapi rusak, "Brian Goetz
  • Untuk membahas lebih lanjut tentang penguncian yang diperiksa dua kali, buka diskusi Teori & Praktik Pemrograman Allen Holub

Apa itu DCL?

Idiom DCL dirancang untuk mendukung inisialisasi lambat, yang terjadi saat kelas menunda inisialisasi objek yang dimiliki hingga benar-benar diperlukan:

class SomeClass {private Resource resource = null; Sumber daya publik getResource () {jika (sumber daya == null) sumber daya = Sumber daya baru (); sumber daya kembali; }}

Mengapa Anda ingin menunda inisialisasi? Mungkin membuat Resourceoperasi yang mahal, dan pengguna SomeClassmungkin tidak benar-benar memanggil getResource()dalam proses tertentu. Dalam hal ini, Anda dapat menghindari pembuatan Resourcesepenuhnya. Terlepas dari itu, SomeClassobjek dapat dibuat lebih cepat jika tidak harus juga dibuat Resourcepada waktu konstruksi. Menunda beberapa operasi inisialisasi hingga pengguna benar-benar membutuhkan hasilnya dapat membantu program memulai lebih cepat.

Bagaimana jika Anda mencoba menggunakan SomeClassaplikasi multithread? Kemudian hasil kondisi balapan: dua utas dapat secara bersamaan menjalankan pengujian untuk melihat apakah resourcenol dan, sebagai hasilnya, menginisialisasi resourcedua kali. Dalam lingkungan multithread, Anda harus mendeklarasikan getResource()menjadi synchronized.

Sayangnya, metode tersinkronisasi berjalan jauh lebih lambat - hingga 100 kali lebih lambat - dibandingkan metode tidak tersinkronisasi biasa. Salah satu motivasi untuk inisialisasi malas adalah efisiensi, tetapi tampaknya untuk mencapai startup program yang lebih cepat, Anda harus menerima waktu eksekusi yang lebih lambat setelah program dimulai. Kedengarannya bukan pertukaran yang bagus.

DCL bermaksud memberi kita yang terbaik dari kedua dunia. Menggunakan DCL, getResource()metodenya akan terlihat seperti ini:

class SomeClass {private Resource resource = null; Sumber daya publik getResource () {if (resource == null) {disinkronkan {if (resource == null) resource = new Resource (); }} sumber daya kembali; }}

Setelah panggilan pertama ke getResource(), resourcesudah diinisialisasi, yang menghindari klik sinkronisasi di jalur kode yang paling umum. DCL juga menghindari kondisi balapan dengan memeriksa resourceuntuk kedua kalinya di dalam blok tersinkronisasi; yang memastikan bahwa hanya satu utas yang akan mencoba menginisialisasi resource. DCL tampak seperti pengoptimalan yang cerdas - tetapi tidak berhasil.

Perkenalkan Model Memori Java

Lebih tepatnya, DCL tidak dijamin dapat berfungsi. Untuk memahami alasannya, kita perlu melihat hubungan antara JVM dan lingkungan komputer tempat JVM dijalankan. Secara khusus, kita perlu melihat Java Memory Model (JMM), yang didefinisikan dalam Bab 17 dari Spesifikasi Bahasa Java , oleh Bill Joy, Guy Steele, James Gosling, dan Gilad Bracha (Addison-Wesley, 2000), yang merinci caranya Java menangani interaksi antara utas dan memori.

Tidak seperti kebanyakan bahasa lain, Java mendefinisikan hubungannya dengan perangkat keras yang mendasarinya melalui model memori formal yang diharapkan berlaku di semua platform Java, memungkinkan janji Java untuk "Tulis Sekali, Jalankan Di Mana Saja". Sebagai perbandingan, bahasa lain seperti C dan C ++ tidak memiliki model memori formal; dalam bahasa seperti itu, program mewarisi model memori dari platform perangkat keras tempat program berjalan.

Saat berjalan di lingkungan sinkron (utas tunggal), interaksi program dengan memori cukup sederhana, atau setidaknya tampak demikian. Program menyimpan item ke dalam lokasi memori dan berharap item tersebut akan tetap ada saat lokasi memori tersebut diperiksa lagi.

Sebenarnya, kebenarannya sangat berbeda, tetapi ilusi rumit yang dipertahankan oleh compiler, JVM, dan perangkat keras menyembunyikannya dari kami. Meskipun kami menganggap program dijalankan secara berurutan - dalam urutan yang ditentukan oleh kode program - itu tidak selalu terjadi. Kompiler, pemroses, dan cache bebas mengambil segala jenis kebebasan dengan program dan data kami, selama tidak memengaruhi hasil penghitungan. Misalnya, kompiler dapat menghasilkan instruksi dalam urutan yang berbeda dari interpretasi program yang menyarankan dan menyimpan variabel dalam register dan bukan memori; prosesor dapat menjalankan instruksi secara paralel atau rusak; dan cache dapat mengubah urutan penulisan commit ke memori utama. JMM mengatakan bahwa semua susunan ulang dan pengoptimalan ini dapat diterima,selama lingkungan tetap terjagasemantik as-if-serial - yaitu, selama Anda mencapai hasil yang sama seperti yang akan Anda dapatkan jika instruksi dijalankan dalam lingkungan yang benar-benar berurutan.

Kompiler, prosesor, dan cache mengatur ulang urutan operasi program untuk mencapai kinerja yang lebih tinggi. Dalam beberapa tahun terakhir, kami telah melihat peningkatan yang luar biasa dalam kinerja komputasi. Sementara peningkatan kecepatan jam prosesor telah berkontribusi besar pada kinerja yang lebih tinggi, peningkatan paralelisme (dalam bentuk unit eksekusi pipelined dan superscalar, penjadwalan instruksi dinamis dan eksekusi spekulatif, dan cache memori bertingkat yang canggih) juga menjadi kontributor utama. Pada saat yang sama, tugas menulis kompiler menjadi jauh lebih rumit, karena kompilator harus melindungi programmer dari kerumitan ini.

Saat menulis program single-threaded, Anda tidak dapat melihat efek dari berbagai instruksi atau penyusunan ulang operasi memori ini. Namun, dengan program multithread, situasinya sangat berbeda - satu utas dapat membaca lokasi memori yang telah ditulis utas lain. Jika utas A memodifikasi beberapa variabel dalam urutan tertentu, dengan tidak adanya sinkronisasi, utas B mungkin tidak melihatnya dalam urutan yang sama - atau mungkin tidak melihatnya sama sekali, dalam hal ini. Hal itu dapat terjadi karena kompilator menyusun ulang instruksi atau menyimpan sementara variabel dalam register dan menuliskannya ke memori nanti; atau karena prosesor menjalankan instruksi secara paralel atau dalam urutan yang berbeda dari yang ditentukan kompilator; atau karena instruksi berada di wilayah memori yang berbeda,dan cache memperbarui lokasi memori utama yang sesuai dalam urutan yang berbeda dari tempat mereka ditulis. Apa pun situasinya, program multithread secara inheren kurang dapat diprediksi, kecuali Anda secara eksplisit memastikan bahwa utas memiliki tampilan memori yang konsisten dengan menggunakan sinkronisasi.

Apa sebenarnya yang dimaksud dengan sinkronisasi?

Java memperlakukan setiap utas seolah-olah berjalan pada prosesornya sendiri dengan memori lokalnya sendiri, masing-masing berbicara dan menyinkronkan dengan memori utama bersama. Bahkan pada sistem prosesor tunggal, model tersebut masuk akal karena efek cache memori dan penggunaan register prosesor untuk menyimpan variabel. Ketika utas mengubah lokasi di memori lokalnya, modifikasi itu pada akhirnya akan muncul di memori utama juga, dan JMM menentukan aturan kapan JVM harus mentransfer data antara memori lokal dan utama. Arsitek Java menyadari bahwa model memori yang terlalu ketat akan sangat merusak kinerja program. Mereka mencoba membuat model memori yang akan memungkinkan program untuk bekerja dengan baik pada perangkat keras komputer modern sambil tetap memberikan jaminan yang akan memungkinkan utas berinteraksi dengan cara yang dapat diprediksi.

Alat utama Java untuk menampilkan interaksi antar utas yang dapat diprediksi adalah synchronizedkata kunci. Banyak programmer berpikir synchronizedsecara ketat dalam hal menegakkan semaphore pengecualian timbal balik ( mutex ) untuk mencegah eksekusi bagian penting oleh lebih dari satu utas pada satu waktu. Sayangnya, intuisi tersebut tidak sepenuhnya menjelaskan apa synchronizedartinya.

Semantik synchronizeddo memang menyertakan saling pengecualian eksekusi berdasarkan status semaphore, tetapi juga menyertakan aturan tentang interaksi thread sinkronisasi dengan memori utama. Secara khusus, akuisisi atau pelepasan kunci memicu penghalang memori - sinkronisasi paksa antara memori lokal thread dan memori utama. (Beberapa prosesor - seperti Alpha - memiliki instruksi mesin eksplisit untuk melakukan hambatan memori.) Ketika utas keluar dari synchronizedblok, ia melakukan penghalang tulis - ia harus membuang variabel apa pun yang dimodifikasi dalam blok itu ke memori utama sebelum melepaskan mengunci. Demikian pula saat memasukkan filesynchronized blok, itu melakukan penghalang baca - seolah-olah memori lokal telah tidak valid, dan itu harus mengambil variabel apa pun yang akan direferensikan di blok dari memori utama.

Penggunaan sinkronisasi yang tepat menjamin bahwa satu utas akan melihat efek utas lain dengan cara yang dapat diprediksi. Hanya ketika utas A dan B disinkronkan pada objek yang sama, JMM akan menjamin bahwa utas B melihat perubahan yang dibuat oleh utas A, dan bahwa perubahan yang dibuat oleh utas A di dalam synchronizedblok muncul secara atomik ke utas B (baik seluruh blok dijalankan atau tidak ada itu dilakukan.) Lebih lanjut, JMM memastikan bahwa synchronizedblok yang disinkronkan pada objek yang sama akan tampak dijalankan dalam urutan yang sama seperti yang mereka lakukan dalam program.

Jadi apa yang rusak tentang DCL?

DCL relies on an unsynchronized use of the resource field. That appears to be harmless, but it is not. To see why, imagine that thread A is inside the synchronized block, executing the statement resource = new Resource(); while thread B is just entering getResource(). Consider the effect on memory of this initialization. Memory for the new Resource object will be allocated; the constructor for Resource will be called, initializing the member fields of the new object; and the field resource of SomeClass will be assigned a reference to the newly created object.

However, since thread B is not executing inside a synchronized block, it may see these memory operations in a different order than the one thread A executes. It could be the case that B sees these events in the following order (and the compiler is also free to reorder the instructions like this): allocate memory, assign reference to resource, call constructor. Suppose thread B comes along after the memory has been allocated and the resource field is set, but before the constructor is called. It sees that resource is not null, skips the synchronized block, and returns a reference to a partially constructed Resource! Needless to say, the result is neither expected nor desired.

When presented with this example, many people are skeptical at first. Many highly intelligent programmers have tried to fix DCL so that it does work, but none of these supposedly fixed versions work either. It should be noted that DCL might, in fact, work on some versions of some JVMs -- as few JVMs actually implement the JMM properly. However, you don't want the correctness of your programs to rely on implementation details -- especially errors -- specific to the particular version of the particular JVM you use.

Other concurrency hazards are embedded in DCL -- and in any unsynchronized reference to memory written by another thread, even harmless-looking reads. Suppose thread A has completed initializing the Resource and exits the synchronized block as thread B enters getResource(). Now the Resource is fully initialized, and thread A flushes its local memory out to main memory. The resource's fields may reference other objects stored in memory through its member fields, which will also be flushed out. While thread B may see a valid reference to the newly created Resource, because it didn't perform a read barrier, it could still see stale values of resource's member fields.

Volatile doesn't mean what you think, either

A commonly suggested nonfix is to declare the resource field of SomeClass as volatile. However, while the JMM prevents writes to volatile variables from being reordered with respect to one another and ensures that they are flushed to main memory immediately, it still permits reads and writes of volatile variables to be reordered with respect to nonvolatile reads and writes. That means -- unless all Resource fields are volatile as well -- thread B can still perceive the constructor's effect as happening after resource is set to reference the newly created Resource.

Alternatives to DCL

Cara paling efektif untuk memperbaiki idiom DCL adalah dengan menghindarinya. Cara termudah untuk menghindarinya, tentu saja, adalah dengan menggunakan sinkronisasi. Setiap kali variabel yang ditulis oleh satu thread sedang dibaca oleh thread lain, Anda harus menggunakan sinkronisasi untuk menjamin bahwa modifikasi terlihat oleh thread lain dengan cara yang dapat diprediksi.

Pilihan lain untuk menghindari masalah dengan DCL adalah dengan menghentikan inisialisasi yang lambat dan sebagai gantinya menggunakan inisialisasi yang bersemangat . Daripada menunda inisialisasi resourcehingga pertama kali digunakan, lakukan inisialisasi pada konstruksi. Pemuat kelas, yang melakukan sinkronisasi pada objek kelas Class, mengeksekusi blok penginisialisasi statis pada waktu inisialisasi kelas. Itu berarti bahwa efek penginisialisasi statis secara otomatis terlihat ke semua utas segera setelah kelas dimuat.