Hindari kebuntuan sinkronisasi

Di artikel saya sebelumnya "Penguncian Tercentang Ganda: Pintar, tapi Rusak" ( JavaWorld,Februari 2001), saya menjelaskan bagaimana beberapa teknik umum untuk menghindari sinkronisasi ternyata tidak aman, dan merekomendasikan strategi "Jika ragu, sinkronkan". Secara umum, Anda harus menyinkronkan setiap kali Anda membaca variabel apa pun yang mungkin telah ditulis sebelumnya oleh utas berbeda, atau setiap kali Anda menulis variabel apa pun yang mungkin kemudian dibaca oleh utas lain. Selain itu, sementara sinkronisasi membawa hukuman kinerja, hukuman yang terkait dengan sinkronisasi yang tidak terkendali tidak sebesar yang disarankan beberapa sumber, dan telah berkurang secara stabil dengan setiap implementasi JVM yang berurutan. Jadi, tampaknya semakin sedikit alasan untuk menghindari sinkronisasi. Namun, risiko lain terkait dengan sinkronisasi yang berlebihan: kebuntuan.

Apa itu kebuntuan?

Kami mengatakan bahwa sekumpulan proses atau utas mengalami kebuntuan ketika setiap utas menunggu acara yang hanya dapat disebabkan oleh proses lain dalam kumpulan. Cara lain untuk mengilustrasikan kebuntuan adalah dengan membangun graf berarah yang simpulnya adalah utas atau proses dan yang ujungnya mewakili relasi "sedang menunggu". Jika grafik ini berisi siklus, sistem akan menemui jalan buntu. Kecuali jika sistem dirancang untuk pulih dari kebuntuan, kebuntuan menyebabkan program atau sistem macet.

Kebuntuan sinkronisasi dalam program Java

Kebuntuan dapat terjadi di Java karena synchronizedkata kunci menyebabkan thread yang menjalankan memblokir saat menunggu kunci, atau monitor, yang terkait dengan objek yang ditentukan. Karena utas mungkin sudah memegang kunci yang terkait dengan objek lain, dua utas masing-masing bisa menunggu yang lain melepaskan kunci; dalam kasus seperti itu, mereka akan menunggu selamanya. Contoh berikut menunjukkan serangkaian metode yang berpotensi menemui jalan buntu. Kedua metode memperoleh kunci pada dua objek kunci, cacheLockdan tableLock, sebelum mereka melanjutkan. Dalam contoh ini, objek yang bertindak sebagai kunci adalah variabel global (statis), teknik umum untuk menyederhanakan perilaku penguncian aplikasi dengan melakukan penguncian pada tingkat perincian yang lebih kasar:

Kode 1. Sebuah kebuntuan sinkronisasi potensial

Objek statis publik cacheLock = Objek baru (); publik objek statis tableLock = new Object (); ... public void oneMethod () {tersinkronisasi (cacheLock) {tersinkronisasi (tableLock) {doSomething (); }}} public void anotherMethod () {tersinkronisasi (tableLock) {tersinkronisasi (cacheLock) {doSomethingElse (); }}}

Sekarang, bayangkan thread A memanggil oneMethod()sementara thread B memanggil secara bersamaan anotherMethod(). Bayangkan lebih lanjut bahwa utas A memperoleh kunci cacheLock, dan, pada saat yang sama, utas B memperoleh kunci tersebut tableLock. Sekarang utasnya telah menemui jalan buntu: tidak ada utas yang akan melepaskan kuncinya sampai dia mendapatkan kunci lainnya, tetapi tidak ada yang dapat memperoleh kunci lainnya sampai utas lainnya melepaskannya. Ketika program Java mengalami deadlock, thread yang mengalami deadlock akan menunggu selamanya. Sementara utas lain mungkin terus berjalan, Anda pada akhirnya harus mematikan program, memulai ulang, dan berharap itu tidak menemui jalan buntu lagi.

Pengujian kebuntuan sulit dilakukan, karena kebuntuan bergantung pada waktu, beban, dan lingkungan, dan karenanya mungkin jarang terjadi atau hanya dalam keadaan tertentu. Kode dapat berpotensi mengalami kebuntuan, seperti Listing 1, tetapi tidak menunjukkan kebuntuan hingga beberapa kombinasi kejadian acak dan nonrandom terjadi, seperti program yang dikenakan tingkat pemuatan tertentu, dijalankan pada konfigurasi perangkat keras tertentu, atau terpapar pada konfigurasi perangkat keras tertentu. campuran tindakan pengguna dan kondisi lingkungan. Kebuntuan menyerupai bom waktu yang menunggu untuk meledak di kode kita; ketika mereka melakukannya, program kami akan hang.

Pengurutan kunci yang tidak konsisten menyebabkan kebuntuan

Untungnya, kami dapat menerapkan persyaratan yang relatif sederhana pada akuisisi kunci yang dapat mencegah kebuntuan sinkronisasi. Metode Listing 1 berpotensi mengalami kebuntuan karena setiap metode memperoleh dua kunci dalam urutan yang berbeda. Jika Daftar 1 telah ditulis sehingga setiap metode memperoleh dua kunci dalam urutan yang sama, dua atau lebih utas yang menjalankan metode ini tidak dapat menemui jalan buntu, terlepas dari waktu atau faktor eksternal lainnya, karena tidak ada utas yang dapat memperoleh kunci kedua tanpa memegang kunci pertama. Jika Anda dapat menjamin bahwa kunci akan selalu diperoleh dalam urutan yang konsisten, maka program Anda tidak akan mengalami kebuntuan.

Jalan buntu tidak selalu begitu jelas

Setelah memahami pentingnya pengurutan kunci, Anda dapat dengan mudah mengenali masalah Listing 1. Namun, masalah analog mungkin terbukti kurang jelas: mungkin kedua metode berada di kelas yang terpisah, atau mungkin kunci yang terlibat diperoleh secara implisit melalui pemanggilan metode tersinkronisasi, bukan secara eksplisit melalui blok tersinkronisasi. Pertimbangkan dua kelas yang bekerja sama ini, Modeldan View, dalam kerangka kerja MVC (Model-View-Controller) yang disederhanakan:

Kode 2. Sebuah kebuntuan sinkronisasi potensial yang lebih halus

Model kelas publik {private View myView; publik disinkronkan void updateModel (Object someArg) {doSomething (someArg); myView.somethingChanged (); } Objek tersinkronisasi publik getSomething () {return someMethod (); }} tampilan kelas publik {private Model underlyingModel; publik disinkronkan void sesuatuChanged () {doSomething (); } publik disinkronkan void updateView () {Object o = myModel.getSomething (); }}

Kode 2 memiliki dua objek yang bekerja sama yang memiliki metode tersinkronisasi; setiap objek memanggil metode tersinkronisasi lainnya. Situasi ini mirip dengan Kode 1 - dua metode memperoleh kunci pada dua objek yang sama, tetapi dalam urutan yang berbeda. Namun, pengurutan penguncian yang tidak konsisten dalam contoh ini jauh lebih tidak jelas daripada pengurutan di Daftar 1 karena akuisisi kunci adalah bagian implisit dari pemanggilan metode. Jika satu utas memanggil Model.updateModel()sementara utas lain memanggil secara bersamaan View.updateView(), utas pertama bisa mendapatkan Modelkunci dan menunggu Viewkunci, sementara utas lainnya mendapatkan Viewkunci dan menunggu selamanya untuk Modelkunci itu.

Anda dapat mengubur potensi kebuntuan sinkronisasi lebih dalam. Pertimbangkan contoh ini: Anda memiliki metode untuk mentransfer dana dari satu akun ke akun lainnya. Anda ingin mendapatkan kunci di kedua akun sebelum melakukan transfer untuk memastikan bahwa transfer bersifat atomic. Pertimbangkan penerapan yang tampak tidak berbahaya ini:

Kode 3. Kebuntuan sinkronisasi potensial yang bahkan lebih halus

 public void transferMoney (Account fromAccount, Account toAccount, DollarAmount amountToTransfer) {synchronized (fromAccount) {synchronized (toAccount) {if (fromAccount.hasSufficientBalance (amountToTransfer) {fromAccount.debit (amountToTransfer}); toAccount.credit (jumlah }Transfer}) } 

Bahkan jika semua metode yang beroperasi pada dua atau lebih akun menggunakan pengurutan yang sama, Daftar 3 berisi benih dari masalah kebuntuan yang sama seperti Daftar 1 dan 2, tetapi dengan cara yang lebih halus. Pertimbangkan apa yang terjadi ketika utas A dijalankan:

 transferMoney (accountOne, accountTwo, jumlah); 

Sementara pada saat yang sama, thread B menjalankan:

 transferMoney (accountTwo, accountOne, anotherAmount); 

Sekali lagi, kedua utas mencoba untuk mendapatkan dua kunci yang sama, tetapi dalam urutan yang berbeda; risiko kebuntuan masih membayang, tetapi dalam bentuk yang jauh lebih tidak jelas.

Bagaimana menghindari kebuntuan

Salah satu cara terbaik untuk mencegah potensi kebuntuan adalah dengan menghindari memperoleh lebih dari satu kunci dalam satu waktu, yang seringkali praktis. Namun, jika itu tidak memungkinkan, Anda memerlukan strategi yang memastikan Anda memperoleh banyak kunci dalam urutan yang ditentukan dan konsisten.

Bergantung pada bagaimana program Anda menggunakan kunci, mungkin tidak rumit untuk memastikan bahwa Anda menggunakan urutan penguncian yang konsisten. Dalam beberapa program, seperti pada Listing 1, semua kunci kritis yang mungkin berpartisipasi dalam penguncian ganda diambil dari sekumpulan kecil objek kunci tunggal. Dalam hal ini, Anda dapat menentukan urutan perolehan kunci pada kumpulan kunci dan memastikan bahwa Anda selalu memperoleh kunci dalam urutan itu. Setelah urutan kunci ditentukan, itu hanya perlu didokumentasikan dengan baik untuk mendorong penggunaan yang konsisten di seluruh program.

Kecilkan blok yang disinkronkan untuk menghindari penguncian ganda

Dalam Listing 2, masalahnya menjadi lebih rumit karena, sebagai hasil dari pemanggilan metode tersinkronisasi, kunci diperoleh secara implisit. Anda biasanya dapat menghindari semacam potensi kebuntuan yang terjadi dari kasus-kasus seperti Listing 2 dengan mempersempit cakupan sinkronisasi menjadi sekecil mungkin blok. Apakah Model.updateModel()benar-benar perlu untuk memegang Modelkunci sementara itu panggilanView.somethingChanged()? Seringkali tidak; seluruh metode kemungkinan besar disinkronkan sebagai pintasan, bukan karena seluruh metode perlu disinkronkan. Namun, jika Anda mengganti metode tersinkronisasi dengan blok tersinkronisasi yang lebih kecil di dalam metode, Anda harus mendokumentasikan perilaku penguncian ini sebagai bagian dari Javadoc metode. Penelepon perlu mengetahui bahwa mereka dapat memanggil metode ini dengan aman tanpa sinkronisasi eksternal. Penelepon juga harus mengetahui perilaku penguncian metode sehingga mereka dapat memastikan bahwa kunci diperoleh dalam urutan yang konsisten.

Teknik penguncian yang lebih canggih

Dalam situasi lain, seperti contoh rekening bank Listing 3, menerapkan aturan pesanan tetap menjadi semakin rumit; Anda perlu menentukan pengurutan total pada kumpulan objek yang memenuhi syarat untuk penguncian dan menggunakan pengurutan ini untuk memilih urutan perolehan kunci. Ini terdengar berantakan, tetapi sebenarnya langsung saja. Daftar 4 menggambarkan teknik itu; itu menggunakan nomor akun numerik untuk mendorong pengurutan pada Accountobjek. (Jika objek yang perlu Anda kunci tidak memiliki properti identitas alami seperti nomor akun, Anda dapat menggunakan Object.identityHashCode()metode ini untuk membuatnya .)

Kode 4. Gunakan urutan untuk mendapatkan kunci dalam urutan yang tetap

public void transferMoney (Account fromAccount, Account toAccount, DollarAmount amountToTransfer) {Account firstLock, secondLock; if (fromAccount.accountNumber () == toAccount.accountNumber ()) melempar Pengecualian baru ("Tidak dapat mentransfer dari akun ke akun itu sendiri"); lain jika (fromAccount.accountNumber () <toAccount.accountNumber ()) {firstLock = fromAccount; secondLock = toAccount; } lain {firstLock = toAccount; secondLock = fromAccount; } tersinkronisasi (firstLock) {disinkronkan (secondLock) {if (fromAccount.hasSufficientBalance (amountToTransfer) {fromAccount.debit (jumlahToTransfer); toAccount.credit (amountToTransfer);}}}}

Sekarang urutan di mana akun ditentukan dalam panggilan ke transferMoney()tidak masalah; kunci selalu diperoleh dengan urutan yang sama.

Bagian terpenting: Dokumentasi

A critical -- but often overlooked -- element of any locking strategy is documentation. Unfortunately, even in cases where much care is taken to design a locking strategy, often much less effort is spent documenting it. If your program uses a small set of singleton locks, you should document your lock-ordering assumptions as clearly as possible so that future maintainers can meet the lock-ordering requirements. If a method must acquire a lock to perform its function or must be called with a specific lock held, the method's Javadoc should note that fact. That way, future developers will know that calling a given method might entail acquiring a lock.

Few programs or class libraries adequately document their locking use. At a minimum, every method should document the locks that it acquires and whether callers must hold a lock to call the method safely. In addition, classes should document whether or not, or under what conditions, they are thread safe.

Focus on locking behavior at design time

Karena kebuntuan sering kali tidak jelas dan terjadi sesekali dan tidak terduga, hal ini dapat menyebabkan masalah serius pada program Java. Dengan memperhatikan perilaku penguncian program Anda pada waktu desain dan menentukan aturan kapan dan bagaimana mendapatkan banyak kunci, Anda dapat mengurangi kemungkinan kebuntuan secara signifikan. Ingatlah untuk mendokumentasikan aturan akuisisi kunci program Anda dan penggunaan sinkronisasi dengan hati-hati; waktu yang dihabiskan untuk mendokumentasikan asumsi penguncian sederhana akan terbayar dengan sangat mengurangi kemungkinan kebuntuan dan masalah konkurensi lainnya nanti.

Brian Goetz adalah pengembang perangkat lunak profesional dengan pengalaman lebih dari 15 tahun. Dia adalah konsultan utama di Quiotix, sebuah perusahaan konsultan dan pengembangan perangkat lunak yang berlokasi di Los Altos, California.