Pengunci diperiksa dua kali: Pandai, tetapi pecah

Dari Elemen Gaya Java yang sangat dihormati hingga halaman JavaWorld (lihat Petua Java 67), banyak guru Java yang baik mendorong penggunaan idiom penguncian dua kali (DCL). Hanya ada satu masalah dengannya - simpulan bahasa yang kelihatan pintar ini mungkin tidak berfungsi.

Penguncian diperiksa dua kali ganda boleh membahayakan kod anda!

Minggu ini JavaWorld memfokuskan kepada bahaya idiom penguncian dua kali. Baca lebih lanjut mengenai bagaimana jalan pintas yang nampaknya tidak berbahaya ini boleh merosakkan kod anda:
  • "Amaran! Threading dalam dunia multiprosesor," Allen Holub
  • Penguncian diperiksa dua kali: Pandai, tetapi rosak, "Brian Goetz
  • Untuk membincangkan lebih lanjut mengenai penguncian dua kali, pergi ke perbincangan Teori & Praktik Pengaturcaraan Allen Holub

Apa itu DCL?

Idiom DCL dirancang untuk menyokong inisialisasi malas, yang berlaku ketika kelas menolak inisialisasi objek yang dimiliki sehingga ia benar-benar diperlukan:

kelas SomeClass {sumber Sumber peribadi = null; sumber awam getResource () {if (sumber == null) sumber = sumber baru (); sumber pulangan; }}

Mengapa anda mahu menangguhkan inisialisasi? Mungkin membuat Resourceadalah operasi yang mahal, dan pengguna SomeClassmungkin tidak benar-benar memanggil getResource()dalam jangka masa tertentu. Sekiranya demikian, anda boleh mengelakkan membuat Resourcesepenuhnya. Apa pun, SomeClassobjek tersebut dapat dibuat lebih cepat jika tidak perlu dibuat Resourcepada waktu pembinaan. Menunda beberapa operasi permulaan sehingga pengguna benar-benar memerlukan hasilnya dapat membantu program dimulakan dengan lebih cepat.

Bagaimana jika anda cuba menggunakan SomeClassaplikasi multithreaded? Kemudian keputusan keadaan perlumbaan: dua utas dapat menjalankan ujian secara serentak untuk melihat apakah resourcenol dan, sebagai hasilnya, memulakan resourcedua kali. Dalam persekitaran yang multithreaded, anda perlu mengisytiharkan getResource()menjadi synchronized.

Malangnya, kaedah disegerakkan berjalan lebih perlahan - sebanyak 100 kali lebih perlahan - daripada kaedah tidak diselaraskan biasa. Salah satu motivasi untuk inisialisasi malas adalah kecekapan, tetapi nampaknya untuk mencapai permulaan program yang lebih cepat, anda harus menerima waktu pelaksanaan yang lebih lambat setelah program dimulakan. Itu tidak kedengaran seperti pertukaran yang hebat.

DCL bermaksud memberi yang terbaik bagi kedua-dua dunia. Menggunakan DCL, getResource()kaedahnya akan kelihatan seperti ini:

kelas SomeClass {sumber Sumber peribadi = null; sumber awam getResource () {if (sumber == null) {disegerakkan {if (sumber == null) sumber = sumber baru (); }} sumber pulangan; }}

Selepas panggilan pertama ke getResource(), resourcesudah diinisialisasi, yang mengelakkan hit penyegerakan di jalan kod yang paling biasa. DCL juga mengelakkan keadaan perlumbaan dengan memeriksa resourcekali kedua di dalam blok yang disegerakkan; yang memastikan bahawa hanya satu utas yang akan cuba dimulakan resource. DCL nampaknya seperti pengoptimuman yang bijak - tetapi tidak berjaya.

Temui Model Memori Java

Lebih tepat lagi, DCL tidak dijamin berfungsi. Untuk memahami mengapa, kita perlu melihat hubungan antara JVM dan persekitaran komputer di mana ia berjalan. Khususnya, kita perlu melihat Model Memori Java (JMM), yang ditentukan dalam Bab 17 Spesifikasi Bahasa Jawa , oleh Bill Joy, Guy Steele, James Gosling, dan Gilad Bracha (Addison-Wesley, 2000), yang memperincikan bagaimana Java menangani interaksi antara utas dan memori.

Tidak seperti kebanyakan bahasa lain, Java menentukan hubungannya dengan perkakasan yang mendasari melalui model memori formal yang diharapkan dapat bertahan di semua platform Java, yang memungkinkan janji Java untuk "Write Once, Run Anywhere." Sebagai perbandingan, bahasa lain seperti C dan C ++ tidak mempunyai model memori formal; dalam bahasa seperti itu, program mewarisi model memori platform perkakasan di mana program ini dijalankan.

Semasa berjalan dalam persekitaran segerak (utas tunggal), interaksi program dengan memori agak mudah, atau sekurang-kurangnya ia kelihatan begitu. Program menyimpan item ke lokasi memori dan menjangkakan bahawa ia masih akan berada di sana pada saat lokasi memori tersebut diperiksa.

Sebenarnya, kebenarannya agak berbeza, tetapi ilusi rumit yang dikendalikan oleh penyusun, JVM, dan perkakasan menyembunyikannya dari kami. Walaupun kami menganggap program dijalankan secara berurutan - mengikut urutan yang ditentukan oleh kod program - itu tidak selalu berlaku. Penyusun, pemproses, dan cache bebas menggunakan segala jenis kebebasan dengan program dan data kami, selagi tidak mempengaruhi hasil pengiraan. Sebagai contoh, penyusun dapat menghasilkan arahan dalam urutan yang berbeza dari tafsiran yang jelas yang dicadangkan oleh program dan menyimpan pemboleh ubah dalam daftar dan bukannya memori; pemproses boleh melaksanakan arahan secara selari atau tidak teratur; dan cache mungkin berbeza mengikut urutan penulisan komit ke memori utama. JMM mengatakan bahawa semua pelbagai penyusunan semula dan pengoptimuman ini dapat diterima,selagi persekitaran menjagasemantik seperti-jika-siri - selagi anda mencapai hasil yang sama seperti yang anda akan lakukan sekiranya arahan itu dilaksanakan dalam persekitaran yang berurutan.

Penyusun, pemproses, dan cache menyusun semula urutan operasi program untuk mencapai prestasi yang lebih tinggi. Dalam beberapa tahun kebelakangan ini, kami telah melihat peningkatan prestasi pengkomputeran yang luar biasa. Walaupun kadar jam pemproses yang meningkat telah memberikan sumbangan besar kepada prestasi yang lebih tinggi, peningkatan paralelisme (dalam bentuk unit pelaksanaan pipelined dan superscalar, penjadualan instruksi dinamik dan pelaksanaan spekulatif, dan cache memori bertingkat bertingkat) juga menjadi penyumbang utama. Pada masa yang sama, tugas menulis penyusun telah menjadi jauh lebih rumit, kerana penyusun mesti melindungi pengaturcara dari kerumitan ini.

Semasa menulis program bersiri tunggal, anda tidak dapat melihat kesan pelbagai susunan arahan atau operasi memori ini. Walau bagaimanapun, dengan program multithread, keadaannya agak berbeza - satu utas dapat membaca lokasi memori yang ditulis oleh benang lain. Sekiranya utas A mengubah beberapa pemboleh ubah dalam urutan tertentu, jika tidak ada penyegerakan, utas B mungkin tidak melihatnya dalam urutan yang sama - atau mungkin tidak melihatnya sama sekali, dalam hal ini. Ini boleh berlaku kerana penyusun menyusun semula arahan atau menyimpan pemboleh ubah sementara dalam daftar dan menuliskannya ke memori kemudian; atau kerana pemproses melaksanakan arahan secara selari atau mengikut urutan yang berbeza daripada penyusun yang ditentukan; atau kerana arahannya berada di kawasan memori yang berlainan,dan cache mengemas kini lokasi memori utama yang sesuai dalam urutan yang berbeza daripada tempat di mana ia ditulis. Apa pun keadaannya, program multithreaded pada dasarnya kurang dapat diramalkan, melainkan anda secara eksplisit memastikan bahawa utas mempunyai pandangan memori yang konsisten dengan menggunakan sinkronisasi.

Apa maksud sebenarnya penyegerakan?

Java memperlakukan setiap utas seolah-olah berjalan pada pemprosesnya sendiri dengan memori tempatan sendiri, masing-masing bercakap dan menyegerakkan dengan memori utama bersama. Walaupun pada sistem pemproses tunggal, model itu masuk akal kerana kesan cache memori dan penggunaan register pemproses untuk menyimpan pemboleh ubah. Apabila utas mengubah lokasi dalam memori tempatannya, pengubahsuaian itu akhirnya akan muncul di memori utama juga, dan JMM menentukan peraturan kapan JVM mesti memindahkan data antara memori tempatan dan utama. Arkitek Java menyedari bahawa model memori yang terlalu ketat akan menjejaskan prestasi program dengan serius. Mereka berusaha membuat model memori yang membolehkan program berfungsi dengan baik pada perkakasan komputer moden sambil tetap memberikan jaminan yang membolehkan benang berinteraksi dengan cara yang dapat diramalkan.

Alat utama Java untuk menjadikan interaksi antara utas dapat diramalkan adalah synchronizedkata kunci. Banyak pengaturcara memikirkan synchronizedsecara tegas dari segi menegakkan semaphore saling pengecualian ( mutex ) untuk mengelakkan pelaksanaan bahagian kritikal oleh lebih dari satu utas pada satu masa. Malangnya, intuisi itu tidak menggambarkan sepenuhnya synchronizedmaksudnya.

Semantik synchronizedmemang merangkumi pengecualian pelaksanaan berdasarkan status semaphore, tetapi mereka juga merangkumi peraturan mengenai interaksi benang penyegerakan dengan memori utama. Khususnya, pemerolehan atau pelepasan kunci mencetuskan penghalang memori - penyegerakan paksa antara memori tempatan benang dan memori utama. (Beberapa pemproses - seperti Alpha - mempunyai arahan mesin yang eksplisit untuk melakukan halangan memori.) Apabila utas keluar dari synchronizedblok, ia melakukan penghalang tulis - ia mesti membuang sebarang pemboleh ubah yang diubah dalam blok itu ke memori utama sebelum melepaskan kunci. Begitu juga ketika memasuki asynchronized blok, ia melakukan penghalang membaca - seolah-olah memori tempatan telah dibatalkan, dan mesti mengambil pemboleh ubah yang akan dirujuk di blok dari memori utama.

Penggunaan penyegerakan yang betul menjamin bahawa satu utas akan melihat kesan yang lain dengan cara yang dapat diramalkan. Hanya apabila benang A dan B diselaraskan pada objek yang sama, JMM akan menjamin bahawa benang B melihat perubahan yang dibuat oleh benang A, dan bahawa perubahan yang dibuat oleh benang A di dalam synchronizedblok muncul secara atomik ke benang B (sama ada keseluruhan blok dijalankan atau tidak ada Selanjutnya, JMM memastikan bahawa synchronizedblok yang disegerakkan pada objek yang sama akan kelihatan dalam urutan yang sama seperti yang mereka lakukan dalam program.

Jadi apa yang rosak mengenai 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 berkesan untuk memperbaiki simpulan bahasa DCL adalah dengan mengelakkannya. Cara paling mudah untuk mengelakkannya, tentu saja, adalah menggunakan penyegerakan. Setiap kali pemboleh ubah yang ditulis oleh satu utas dibaca oleh yang lain, anda harus menggunakan penyegerakan untuk memastikan bahawa pengubahsuaian dapat dilihat oleh utas lain dengan cara yang dapat diramalkan.

Pilihan lain untuk mengelakkan masalah dengan DCL adalah membuang inisialisasi malas dan sebaliknya menggunakan inisialisasi bersemangat . Daripada menunda permulaan resourcesehingga pertama kali digunakan, mulailah pada pembinaan. Pemuat kelas, yang menyegerakkan pada Classobjek kelas , melaksanakan blok pemula statik pada waktu permulaan kelas. Ini bermaksud bahawa kesan pemula statik secara automatik dapat dilihat oleh semua utas sebaik sahaja kelas dimuat.