Pemrograman utas Java di dunia nyata, Bagian 1

Semua program Java selain aplikasi berbasis konsol sederhana memiliki banyak thread, suka atau tidak suka. Masalahnya adalah bahwa Abstract Windowing Toolkit (AWT) memproses peristiwa sistem operasi (OS) di utasnya sendiri, sehingga metode pendengar Anda benar-benar berjalan di utas AWT. Metode pendengar yang sama ini biasanya mengakses objek yang juga diakses dari utas utama. Mungkin tergoda, pada titik ini, untuk mengubur kepala Anda di pasir dan berpura-pura tidak perlu khawatir tentang masalah threading, tetapi Anda biasanya tidak bisa lolos begitu saja. Dan, sayangnya, hampir tidak ada buku di Java yang membahas masalah threading secara cukup mendalam. (Untuk daftar buku yang bermanfaat tentang topik tersebut, lihat Sumberdaya.)

Artikel ini adalah yang pertama dari seri yang akan menyajikan solusi dunia nyata untuk masalah pemrograman Java di lingkungan multithread. Ini ditujukan untuk programmer Java yang memahami hal-hal tingkat bahasa ( synchronizedkata kunci dan berbagai fasilitas Threadkelas), tetapi ingin mempelajari cara menggunakan fitur bahasa ini secara efektif.

Ketergantungan platform

Sayangnya, janji kemerdekaan platform Jawa gagal total di arena utas. Meskipun mungkin untuk menulis program Java multithread yang tidak bergantung platform, Anda harus melakukannya dengan mata terbuka. Ini sebenarnya bukan kesalahan Java; hampir tidak mungkin untuk menulis sistem threading yang benar-benar tidak bergantung platform. (Kerangka kerja Doug Schmidt ACE [Lingkungan Komunikasi Adaptif] adalah upaya yang baik, meskipun rumit. Lihat Sumber untuk tautan ke programnya.) Jadi, sebelum saya dapat berbicara tentang masalah pemrograman Java hard-core dalam angsuran berikutnya, saya harus mendiskusikan kesulitan yang diperkenalkan oleh platform tempat mesin virtual Java (JVM) mungkin berjalan.

Energi Atom

Konsep tingkat OS pertama yang penting untuk dipahami adalah atomicity. Operasi atom tidak dapat dihentikan oleh utas lain. Java memang mendefinisikan setidaknya beberapa operasi atom. Secara khusus, penugasan ke variabel jenis apa pun kecuali longatau doubleatom. Anda tidak perlu khawatir tentang utas yang mendahului metode di tengah tugas. Dalam praktiknya, ini berarti Anda tidak perlu menyinkronkan metode yang tidak melakukan apa pun selain mengembalikan nilai (atau menetapkan nilai ke) variabel instance booleanatau int. Demikian pula, metode yang melakukan banyak komputasi hanya dengan menggunakan variabel dan argumen lokal, dan yang menugaskan hasil komputasi itu ke variabel instan seperti yang terakhir dilakukannya, tidak perlu disinkronkan. Sebagai contoh:

kelas some_class {int some_field; void f (some_class arg) // sengaja tidak disinkronkan {// Lakukan banyak hal di sini yang menggunakan variabel lokal // dan argumen metode, tetapi tidak mengakses // bidang apa pun di kelas (atau panggil metode apa pun // yang mengakses bidang kelas). // ... some_field = new_value; // lakukan ini yang terakhir. }}

Di sisi lain, saat menjalankan x=++yatau x+=y, Anda bisa didahului setelah kenaikan tetapi sebelum penugasan. Untuk mendapatkan atomicity dalam situasi ini, Anda harus menggunakan kata kunci synchronized.

Semua ini penting karena overhead sinkronisasi bisa jadi tidak sepele, dan dapat bervariasi dari OS ke OS. Program berikut menunjukkan masalahnya. Setiap loop secara berulang memanggil metode yang melakukan operasi yang sama, tetapi salah satu metode ( locking()) disinkronkan dan yang lainnya ( not_locking()) tidak. Dengan menggunakan VM JDK "performance-pack" yang berjalan di bawah Windows NT 4, program melaporkan perbedaan 1.2 detik dalam runtime antara dua loop, atau sekitar 1.2 mikrodetik per panggilan. Perbedaan ini mungkin tidak terlihat banyak, tetapi ini menunjukkan peningkatan 7,25 persen dalam waktu menelepon. Tentu saja, peningkatan persentase jatuh karena metode ini bekerja lebih banyak, tetapi sejumlah besar metode - setidaknya dalam program saya - hanya beberapa baris kode.

import java.util. *; sinkronisasi kelas { penguncian int tersinkronisasi (int a, int b) {kembalikan a + b;} int not_locking (int a, int b) {kembalikan a + b;} private static final int ITERATIONS = 1000000; public void main statis (String [] args) {synch tester = new synch (); mulai ganda = Tanggal baru (). getTime (); untuk (long i = ITERATIONS; --i> = 0;) tester.locking (0,0); double end = new Date (). getTime (); double locking_time = end - start; mulai = Tanggal baru (). getTime (); untuk (long i = ITERATIONS; --i> = 0;) tester.not_locking (0,0);akhir = Tanggal baru (). getTime (); double not_locking_time = end - start; double time_in_synchronization = locking_time - not_locking_time; System.out.println ("Waktu yang hilang untuk sinkronisasi (milidetik):" + time_in_synchronization); System.out.println ("Penguncian overhead per panggilan:" + (time_in_synchronization / ITERATIONS)); System.out.println (not_locking_time / locking_time * 100.0 + "% peningkatan"); }}

Meskipun VM HotSpot seharusnya menangani masalah overhead sinkronisasi, HotSpot bukanlah freebee - Anda harus membelinya. Kecuali Anda melisensikan dan mengirimkan HotSpot dengan aplikasi Anda, tidak ada yang tahu VM apa yang akan ada di platform target, dan tentu saja Anda ingin sesedikit mungkin kecepatan eksekusi program Anda bergantung pada VM yang menjalankannya. Bahkan jika masalah kebuntuan (yang akan saya diskusikan di bagian berikutnya dari seri ini) tidak ada, gagasan bahwa Anda harus "menyinkronkan semuanya" hanyalah salah jalan.

Konkurensi versus paralelisme

Masalah terkait OS berikutnya (dan masalah utama ketika menulis Java platform-independen) berkaitan dengan gagasan tentang konkurensi dan paralelisme. Sistem multithreading serentak memberikan tampilan beberapa tugas yang dijalankan sekaligus, tetapi tugas ini sebenarnya dibagi menjadi beberapa bagian yang berbagi prosesor dengan bagian dari tugas lain. Gambar berikut mengilustrasikan masalah tersebut. Dalam sistem paralel, dua tugas sebenarnya dilakukan secara bersamaan. Paralelisme membutuhkan sistem multi-CPU.

Kecuali jika Anda menghabiskan banyak waktu diblokir, menunggu operasi I / O selesai, program yang menggunakan beberapa utas bersamaan akan sering berjalan lebih lambat daripada program utas tunggal yang setara, meskipun sering kali akan diatur lebih baik daripada program tunggal yang setara versi benang. Program yang menggunakan banyak utas yang berjalan secara paralel pada banyak prosesor akan berjalan lebih cepat.

Meskipun Java mengizinkan threading untuk diterapkan sepenuhnya di VM, setidaknya secara teori, pendekatan ini akan menghalangi paralelisme apa pun dalam aplikasi Anda. Jika tidak ada thread tingkat sistem operasi yang digunakan, OS akan melihat instance VM sebagai aplikasi single-threaded, yang kemungkinan besar akan dijadwalkan ke satu prosesor. Hasil akhirnya adalah tidak ada dua thread Java yang berjalan di bawah instance VM yang sama yang akan berjalan secara paralel, meskipun Anda memiliki beberapa CPU dan VM Anda adalah satu-satunya proses yang aktif. Dua contoh VM yang menjalankan aplikasi terpisah dapat berjalan secara paralel, tentu saja, tetapi saya ingin melakukan yang lebih baik dari itu. Untuk mendapatkan paralelisme, VM harusmemetakan utas Java ke utas OS; jadi, Anda tidak dapat mengabaikan perbedaan antara berbagai model threading jika independensi platform itu penting.

Luruskan prioritas Anda

Saya akan menunjukkan cara masalah yang baru saja saya diskusikan dapat memengaruhi program Anda dengan membandingkan dua sistem operasi: Solaris dan Windows NT.

Java, setidaknya dalam teori, menyediakan sepuluh tingkat prioritas untuk utas. (Jika dua atau lebih utas sama-sama menunggu untuk dijalankan, utas dengan tingkat prioritas tertinggi akan dijalankan.) Di Solaris, yang mendukung 231 tingkat prioritas, ini tidak masalah (meskipun prioritas Solaris bisa rumit untuk digunakan - lebih lanjut tentang ini dalam sekejap). NT, sebaliknya, memiliki tujuh tingkat prioritas yang tersedia, dan ini harus dipetakan ke dalam sepuluh tingkat di Jawa. Pemetaan ini tidak ditentukan, sehingga banyak kemungkinan muncul dengan sendirinya. (Misalnya, tingkat prioritas Jawa 1 dan 2 mungkin sama-sama memetakan ke tingkat prioritas NT 1, dan tingkat prioritas Jawa 8, 9, dan 10 mungkin semuanya dipetakan ke NT tingkat 7.)

Kurangnya tingkat prioritas NT adalah masalah jika Anda ingin menggunakan prioritas untuk mengontrol penjadwalan. Hal-hal menjadi lebih rumit dengan fakta bahwa tingkat prioritas tidak tetap. NT menyediakan mekanisme yang disebut peningkatan prioritas, yang dapat Anda matikan dengan panggilan sistem C, tetapi tidak dari Java. Ketika peningkatan prioritas diaktifkan, NT meningkatkan prioritas utas dengan jumlah yang tidak ditentukan untuk jumlah waktu yang tidak ditentukan setiap kali menjalankan panggilan sistem terkait I / O tertentu. Dalam praktiknya, ini berarti bahwa tingkat prioritas utas bisa lebih tinggi daripada yang Anda pikirkan karena utas tersebut kebetulan melakukan operasi I / O pada waktu yang tidak tepat.

Inti dari peningkatan prioritas adalah untuk mencegah utas yang melakukan pemrosesan latar belakang memengaruhi respons nyata dari tugas-tugas UI yang berat. Sistem operasi lain memiliki algoritme yang lebih canggih yang biasanya menurunkan prioritas proses latar belakang. Kelemahan dari skema ini, terutama ketika diimplementasikan pada per-thread daripada tingkat per-proses, adalah sangat sulit untuk menggunakan prioritas untuk menentukan kapan thread tertentu akan berjalan.

Lebih buruk.

Di Solaris, seperti halnya di semua sistem Unix, proses memiliki prioritas seperti halnya utas. Rangkaian proses dengan prioritas tinggi tidak dapat diganggu oleh rangkaian proses dengan prioritas rendah. Selain itu, tingkat prioritas dari proses tertentu dapat dibatasi oleh administrator sistem sehingga proses pengguna tidak akan mengganggu proses OS penting. NT tidak mendukung semua ini. Proses NT hanyalah ruang alamat. Itu tidak memiliki prioritas per se, dan tidak dijadwalkan. Sistem menjadwalkan utas; kemudian, jika utas tertentu berjalan di bawah proses yang tidak ada di memori, proses itu ditukar. Prioritas utas NT termasuk dalam berbagai "kelas prioritas," yang didistribusikan di seluruh kontinum prioritas aktual. Sistemnya terlihat seperti ini:

The columns are actual priority levels, only 22 of which must be shared by all applications. (The others are used by NT itself.) The rows are priority classes. The threads running in a process pegged at the idle priority class are running at levels 1 through 6 and 15, depending on their assigned logical priority level. The threads of a process pegged as normal priority class will run at levels 1, 6 through 10, or 15 if the process doesn't have the input focus. If it does have the input focus, the threads run at levels 1, 7 through 11, or 15. This means that a high-priority thread of an idle priority class process can preempt a low-priority thread of a normal priority class process, but only if that process is running in the background. Notice that a process running in the "high" priority class only has six priority levels available to it. The other classes have seven.

NT provides no way to limit the priority class of a process. Any thread on any process on the machine can take over control of the box at any time by boosting its own priority class; there is no defense against this.

The technical term I use to describe NT's priority is unholy mess. In practice, priority is virtually worthless under NT.

So what's a programmer to do? Between NT's limited number of priority levels and it's uncontrollable priority boosting, there's no absolutely safe way for a Java program to use priority levels for scheduling. One workable compromise is to restrict yourself to Thread.MAX_PRIORITY, Thread.MIN_PRIORITY, and Thread.NORM_PRIORITY when you call setPriority(). This restriction at least avoids the 10-levels-mapped-to-7-levels problem. I suppose you could use the os.name system property to detect NT, and then call a native method to turn off priority boosting, but that won't work if your app is running under Internet Explorer unless you also use Sun's VM plug-in. (Microsoft's VM uses a nonstandard native-method implementation.) In any event, I hate to use native methods. I usually avoid the problem as much as possible by putting most threads at NORM_PRIORITY and using scheduling mechanisms other than priority. (I'll discuss some of these in future installments of this series.)

Cooperate!

There are typically two threading models supported by operating systems: cooperative and preemptive.

The cooperative multithreading model

Dalam sistem kooperatif , utas mempertahankan kontrol prosesornya sampai memutuskan untuk melepaskannya (yang mungkin tidak akan pernah). Berbagai utas harus bekerja sama satu sama lain atau semua kecuali satu utas akan "kelaparan" (artinya, tidak pernah diberi kesempatan untuk berjalan). Penjadwalan di sebagian besar sistem koperasi dilakukan secara ketat berdasarkan tingkat prioritas. Saat thread saat ini melepaskan kendali, thread menunggu dengan prioritas tertinggi mendapatkan kontrol. (Pengecualian untuk aturan ini adalah Windows 3.x, yang menggunakan model kooperatif tetapi tidak memiliki banyak penjadwal. Jendela yang memiliki fokus akan mengontrol.)