Pemrograman utas Java di dunia nyata, Bagian 2

Dalam artikelnya yang terbaru "Desain untuk keamanan utas" (bagian dari kolom Teknik Desain ), Bill Venners memperkenalkan gagasan kondisi balapan, situasi di mana dua utas secara bersamaan bersaing untuk objek yang sama dan, sebagai konsekuensinya, membiarkan objek di keadaan yang tidak ditentukan. Bill menunjukkan bahwa Jawasynchronizedkata kunci dalam bahasa untuk membantu menghindari masalah ini, dan dia memberikan contoh langsung penggunaannya. Artikel bulan lalu adalah yang pertama dalam seri utas. Artikel ini melanjutkan seri tersebut, yang membahas kondisi balapan secara mendalam. Ini juga membahas berbagai skenario kebuntuan, yang terkait dengan kondisi balapan tetapi jauh lebih sulit untuk ditemukan dan di-debug. Bulan ini, kami akan fokus terutama pada masalah yang dihadapi saat memprogram utas Java. Kolom Java Toolbox selanjutnya akan fokus sepenuhnya pada solusi.

Monitor, mutex, dan kamar mandi

Hanya untuk memastikan kita semua memulai dari tempat yang sama, ada sedikit ulasan tentang persyaratan. Konsep sentral sinkronisasi dalam model Java adalah monitor, yang dikembangkan sekitar 20 tahun lalu oleh CAR Hoare. Monitor adalah badan kode yang dilindungi oleh semaphore saling-pengecualian (atau, untuk menggunakan istilah yang diciptakan di Digital Equipment Corp., mutex). Gagasan sentral mutex menyangkut kepemilikan. Hanya satu utas yang dapat memiliki mutex dalam satu waktu. Jika utas kedua mencoba "memperoleh" kepemilikan, utas tersebut akan memblokir (ditangguhkan) sampai utas pemilik "melepaskan" mutex. Jika beberapa utas menunggu untuk mendapatkan mutex yang sama, semuanya akan dirilis secara bersamaan saat utas pemilik melepaskan mutex tersebut. Utas yang dirilis kemudian harus memilah di antara mereka sendiri siapa yang mendapatkan kepemilikan. (Biasanya, urutan prioritas, agar FIFO, atau kombinasinya digunakan untuk menentukan keuntungan benang kontrol.) Anda menjagablok kode dengan memperoleh mutex di bagian atas blok dan melepaskannya di bagian bawah. Kode yang terdiri dari monitor tidak harus bersebelahan: beberapa blok kode yang tidak bersebelahan semuanya dapat memperoleh dan melepaskan mutex yang sama, dalam hal ini semua kode ini dianggap berada dalam monitor yang sama karena menggunakan mutex yang sama.

Analogi terbaik yang pernah saya dengar untuk monitor adalah kamar mandi pesawat. Hanya satu orang yang bisa berada di kamar mandi dalam satu waktu (kami harap). Semua orang mengantri di lorong yang agak sempit menunggu untuk menggunakannya. Selama pintunya terkunci, kamar mandi tidak bisa diakses. Mengingat istilah-istilah ini, dalam analogi kita objeknya adalah pesawat, kamar mandi adalah monitor (dengan asumsi hanya ada satu kamar mandi), dan kunci pintu adalah mutex.

Di Java, setiap objek memiliki satu dan hanya satu monitor dan mutex yang terkait dengannya. Monitor tunggal memiliki beberapa pintu, bagaimanapun, masing-masing ditunjukkan oleh synchronizedkata kunci. Ketika synchronizedkata kunci melewati benang, kata kunci tersebut secara efektif mengunci semua pintu. Tentu saja, jika sebuah utas tidak melewati synchronizedkata kunci, itu belum mengunci pintu, dan beberapa utas lainnya bebas masuk setiap saat.

Ingatlah bahwa monitor dikaitkan dengan objek, bukan kelas. Beberapa utas semuanya dapat menjalankan metode yang sama secara paralel, tetapi objek penerima (seperti yang diidentifikasi oleh thisreferensi) harus berbeda. Misalnya, beberapa contoh antrian aman thread dapat digunakan oleh beberapa thread. Utas ini dapat secara bersamaan mengantrekan objek ke antrean yang berbeda, tetapi tidak dapat mengantrekan objek ke antrean yang sama pada waktu yang sama. Hanya satu utas pada satu waktu yang dapat berada di monitor untuk antrian tertentu.

Untuk menyempurnakan analogi sebelumnya, pesawat masih merupakan objek, tetapi monitor benar-benar merupakan gabungan semua kamar mandi (masing-masing potongan kode yang disinkronkan adalah kamar mandi). Ketika benang memasuki kamar mandi mana pun, pintu di semua kamar mandi terkunci. Contoh kelas yang berbeda adalah pesawat yang berbeda, namun, dan jika pintu kamar mandi di pesawat Anda tidak dikunci, Anda tidak perlu terlalu peduli dengan keadaan pintu di pesawat lain.

Mengapa stop () tidak digunakan lagi di JDK 1.2?

Fakta bahwa monitor dibangun di setiap objek Java sebenarnya agak kontroversial. Beberapa masalah yang terkait dengan penggabungan variabel kondisi dan mutex bersama-sama di dalam setiap objek Java telah diperbaiki di JDK 1.2, dengan cara yang sederhana dengan menghentikan metode Threadkelas yang paling bermasalah : stop()dan suspend(). Anda dapat mematikan utas jika Anda memanggil salah satu metode ini dari dalam metode tersinkron Anda sendiri. Perhatikan metode berikut, mengingat bahwa mutex adalah kunci pintu, dan utas yang mengunci pintu "memiliki" monitor. Itulah mengapa metode stop()dan suspend()tidak digunakan lagi di JDK 1.2. Pertimbangkan metode ini:

kelas some_class {// ... disinkronkan void f () {Thread.currentThread (). stop (); }}

Sekarang pertimbangkan apa yang terjadi ketika utas memanggil f(). Utas memperoleh kunci saat memasuki monitor tetapi kemudian berhenti. Mutex tidak dirilis olehstop() . Ini sama dengan seseorang pergi ke kamar mandi, mengunci pintu, dan menyiram dirinya ke toilet! Utas lain apa pun yang sekarang mencoba memanggil f()(atau metode kelas yang disinkronkan lainnya) pada objek yang sama akan diblokir selamanya. The suspend()metode (yang juga usang) memiliki masalah yang sama. The sleep()metode (yang tidak usang) bisa sama rumit. (Seseorang pergi ke kamar mandi, mengunci pintu, dan tertidur). Juga ingat bahwa objek Java, bahkan yang diperluas Threadatau diimplementasikanRunnable, terus ada, bahkan jika utas terkait telah berhenti. Anda memang dapat memanggil metode tersinkronisasi pada objek yang terkait dengan utas yang dihentikan. Hati-hati.

Kondisi balapan dan kunci putaran

Sebuah kondisi balapan terjadi ketika dua benang mencoba untuk mengakses objek yang sama pada saat yang sama, dan perilaku kode berubah tergantung pada siapa yang menang. Diagram berikut menunjukkan satu objek (tidak tersinkronisasi) yang diakses secara bersamaan oleh beberapa utas. Sebuah utas dapat ditempatkan terlebih dahulu fred()setelah memodifikasi satu bidang tetapi sebelum memodifikasi yang lain. Jika utas lain datang pada saat itu dan memanggil salah satu metode yang ditampilkan, objek akan dibiarkan dalam keadaan tidak stabil, karena utas awal pada akhirnya akan bangun dan mengubah bidang lain.

Biasanya, Anda memikirkan objek yang mengirim pesan ke objek lain. Dalam lingkungan multithread, Anda harus memikirkan tentang penangan pesan yang berjalan di thread. Pikirkan: utas ini menyebabkan objek ini mengirim pesan ke objek itu. Kondisi balapan dapat terjadi ketika dua utas menyebabkan pesan dikirim ke objek yang sama pada waktu yang sama.

Bekerja dengan wait()dannotify()

Kode berikut menunjukkan antrian pemblokiran yang digunakan untuk satu utas untuk memberi tahu yang lain ketika beberapa peristiwa terjadi. (Kita akan melihat versi yang lebih realistis dari ini di artikel mendatang, tetapi untuk saat ini saya ingin membuatnya tetap sederhana.) Gagasan dasarnya adalah bahwa utas yang mencoba untuk membatalkan antrean kosong akan diblokir hingga utas lain meletakkan sesuatu dalam antrian.

class Notifying_queue {private static final queue_size = 10; Objek pribadi [] antrian = Objek baru [queue_size]; private int head = 0; ekor int pribadi = 0; public void disinkronkan enqueue (Object item) {queue [++ head% = queue_size] = item; this.notify (); // "Ini" hanya untuk} // meningkatkan keterbacaan. Objek publik disinkronkan dequeue () {coba {if (head == tail) // <=== Ini adalah bug this.wait (); } catch (InterruptedException e) {// Jika kita sampai di sini, kita sebenarnya tidak diberitahu. // mengembalikan null tidak menunjukkan bahwa // antrian kosong, hanya saja penantian itu // ditinggalkan. kembali nol; } antrian kembali [++ tail% = queue_size]; }}

Dimulai dengan antrian kosong, mari ikuti urutan operasi yang sedang dimainkan jika satu thread melakukan dequeue dan thread lainnya (di lain waktu) mengantrekan item.

  1. Utas dequeueing memanggil dequeue(), memasuki monitor (dan mengunci utas lainnya) sampai monitor dilepaskan. Utas menguji antrian kosong ( head==tailtunggu (). (Saya akan menjelaskan mengapa ini adalah bug sebentar lagi.)

  2. Buka wait()kunci. (Thread saat ini meninggalkan monitor untuk sementara .) Kemudian thread tersebut memblokir objek sinkronisasi kedua yang disebut variabel kondisi. (Gagasan dasar dari variabel kondisi adalah bahwa thread memblokir hingga beberapa kondisi menjadi true. Dalam kasus variabel kondisi bawaan Java, thread akan menunggu hingga kondisi yang diberitahukan disetel ke true oleh beberapa pemanggilan thread lain notify.) penting untuk disadari bahwa benang tunggu telah melepaskan monitor pada saat ini.

  3. Utas kedua sekarang muncul dan mengantrekan objek, akhirnya memanggil notify(), sehingga melepaskan utas tunggu (dequeueing).

  4. Utas dequeueing sekarang harus menunggu untuk mendapatkan kembali monitor sebelum wait()dapat kembali, jadi ia memblokir lagi, kali ini pada kunci yang terkait dengan monitor.

  5. Utas en antrian kembali dari enqueue()metode melepaskan monitor.

  6. Utas dequeueing memperoleh monitor, wait()mengembalikan, objek di-dequeued, dan dequeue()kembali, melepaskan monitor.

Kondisi balapan setelah menunggu ()

Now, what sorts of problems can arise? The main ones are unexpected race conditions. First, what if you replaced the notify() call with a call to notifyAll()? Imagine that several threads were simultaneously trying to dequeue something from the same, empty, queue. All of these threads are blocked on a single condition variable, waiting for an enqueue operation to be executed. When enqueue() sets the condition to true by calling notifyAll(), the threads are all released from the wait (moved from a suspended to a runnable state). The released threads all try to acquire the monitor at once, but only one would "win" and get the enqueued object. The problem is that the others would then get the monitor too, in some undefined sequence, after the winning thread had dequeued the object and returned from dequeue(). Since these other threads don't retest for an empty queue, they will dequeue garbage. (Looking at the code, the dequeue() method will advance the tail pointer to the next slot, the contents of which are undefined since the queue is empty.)

Fix the problem by replacing the if statement with a while loop (called a spin lock). Now the threads that don't get the object will go back to waiting:

 public Object synchronized dequeue( ) { try { while( head == tail ) // used to be an if this.wait(); } catch( InterruptedException e ) { return null; } return queue[++tail %= queue_size ]; } 

That while loop also solves another, less obvious problem. What if we leave the notify() statement in place, and don't put in a notifyAll()? Since notify() releases only one waiting thread, won't that solve the problem? It turns out that the answer is no. Here's what can happen:

  1. notify() is called by the enqueueing thread, releasing the condition variable.

  2. The dequeueing thread is then preempted, after being released from the wait on the condition variable, but before it tries to reacquire the monitor's lock.

  3. A second thread calls dequeue() at exactly this point, and successfully enters the monitor because no other threads are officially waiting. This second thread successfully dequeues the object; wait() is never called since the queue isn't empty.

  4. The original thread is now allowed to run, it acquires the monitor, doesn't test for empty a second time, and then dequeues garbage.

This second scenario is easily fixed the same way as the first: replace the if statement with a while loop.

Note that the Java specification does not require that wait() be implemented as an atomic operation (that is, one that can't be preempted while moving from the condition variable to the monitor's lock). This means that using an if statement instead of a while loop might work in some Java implementations, but the behavior is really undefined. Using a spin lock instead of a simple if statement is cheap insurance against implementations that don't treat wait() as atomic.

Threads are not objects

Now let's move on to harder-to-find problems. The first difficulty is the commonplace confusion of threads and objects: Methods run on threads, objects do not. Put another way, the only way to get a method to run on a given thread is to call it (either directly or indirectly) from that thread's run() method. Simply putting it into the Thread derivative is not enough. For example, look at the following simple thread (which just prints its fields every so often):

kelas My_thread meluas Thread {private int field_1 = 0; private int field_2 = 0; public void run () {setDaemon (true); // utas ini tidak akan membuat aplikasi tetap hidup sementara (true) {System.out.println ("field_1 =" + field_1 "field_2 =" + field_2); tidur (100); }} modifikasi public void tersinkronisasi (int new_value) {field_1 = new_value; bidang_2 = nilai_baru; }}

Anda dapat memulai utas dan mengirimkannya pesan seperti ini:

Tes My_thread = new My_thread; test.start (); // ... test.modify (1);