Tujuan utama dari kumpulan utas dan Fork / Gabung adalah sama: Keduanya ingin memanfaatkan daya CPU yang tersedia sebaik mungkin untuk throughput maksimum. Throughput maksimum berarti bahwa sebanyak mungkin tugas harus diselesaikan dalam periode waktu yang lama. Apa yang diperlukan untuk melakukan itu? (Untuk yang berikut, kami akan menganggap bahwa tidak ada kekurangan tugas perhitungan: Selalu ada cukup untuk melakukan utilisasi CPU 100%. Selain itu saya menggunakan "CPU" yang setara untuk core atau virtual core jika terjadi hyper-threading).
- Setidaknya perlu ada banyak utas yang berjalan karena ada CPU yang tersedia, karena menjalankan lebih sedikit utas akan meninggalkan inti yang tidak digunakan.
- Maksimal harus ada sebanyak mungkin utas yang berjalan karena ada CPU yang tersedia, karena menjalankan lebih banyak utas akan membuat beban tambahan untuk Penjadwal yang menetapkan CPU pada utas berbeda yang menyebabkan beberapa waktu CPU untuk pergi ke penjadwal daripada tugas komputasi kami.
Jadi kami menemukan bahwa untuk throughput maksimum, kami harus memiliki jumlah utas yang sama persis dengan CPU. Dalam contoh Oracle yang kabur Anda bisa mengambil kumpulan utas ukuran tetap dengan jumlah utas sama dengan jumlah CPU yang tersedia atau menggunakan kumpulan utas. Itu tidak akan membuat perbedaan, Anda benar!
Jadi kapan Anda akan mendapat masalah dengan kolam utas? Itu jika sebuah thread memblokir , karena utas Anda sedang menunggu tugas lain untuk diselesaikan. Asumsikan contoh berikut:
class AbcAlgorithm implements Runnable {
public void run() {
Future<StepAResult> aFuture = threadPool.submit(new ATask());
StepBResult bResult = stepB();
StepAResult aResult = aFuture.get();
stepC(aResult, bResult);
}
}
Apa yang kita lihat di sini adalah algoritma yang terdiri dari tiga langkah A, B dan C. A dan B dapat dilakukan secara independen satu sama lain, tetapi langkah C membutuhkan hasil dari langkah A DAN B. Apa yang dilakukan algoritma ini adalah menyerahkan tugas A ke threadpool dan melakukan tugas b secara langsung. Setelah itu utas akan menunggu tugas A selesai juga dan melanjutkan dengan langkah C. Jika A dan B selesai pada saat yang sama, maka semuanya baik-baik saja. Tetapi bagaimana jika A membutuhkan waktu lebih lama dari B? Itu mungkin karena sifat tugas A yang mendiktekannya, tetapi mungkin juga demikian karena tidak ada utas untuk tugas A yang tersedia di awal dan tugas A perlu menunggu. (Jika hanya ada satu CPU yang tersedia dan dengan demikian threadpool Anda hanya memiliki satu utas ini bahkan akan menyebabkan kebuntuan, tetapi untuk saat ini yang tidak penting). Intinya adalah utas yang baru saja dieksekusi tugas Bmemblokir seluruh utas . Karena kami memiliki jumlah utas yang sama dengan CPU dan satu utas diblokir, itu artinya satu CPU idle .
Fork / Gabung menyelesaikan masalah ini: Di kerangka garpu / gabung Anda akan menulis algoritma yang sama sebagai berikut:
class AbcAlgorithm implements Runnable {
public void run() {
ATask aTask = new ATask());
aTask.fork();
StepBResult bResult = stepB();
StepAResult aResult = aTask.join();
stepC(aResult, bResult);
}
}
Terlihat sama, bukan? Namun petunjuknya adalah bahwa aTask.join
tidak akan memblokir . Alih-alih, di sinilah pencuri pekerjaan dilakukan: Utas akan mencari-cari tugas lain yang telah bercabang di masa lalu dan akan dilanjutkan dengan itu. Pertama, ia memeriksa apakah tugas-tugas yang telah dilakukan sendiri sudah mulai diproses. Jadi jika A belum dimulai oleh utas lain, ia akan melakukan selanjutnya, jika tidak akan memeriksa antrian utas lainnya dan mencuri pekerjaan mereka. Setelah tugas ini dari utas lain selesai, ia akan memeriksa apakah A selesai sekarang. Jika itu algoritma di atas dapat memanggil stepC
. Kalau tidak, ia akan mencari tugas lain untuk mencuri. Dengan demikian fork / join pools dapat mencapai utilisasi CPU 100%, bahkan dalam menghadapi tindakan memblokir .
Namun ada jebakan: Mencuri pekerjaan hanya mungkin untuk join
panggilan ForkJoinTask
s. Itu tidak dapat dilakukan untuk tindakan pemblokiran eksternal seperti menunggu utas lainnya atau menunggu tindakan I / O. Jadi bagaimana dengan itu, menunggu I / O untuk menyelesaikan adalah tugas bersama? Dalam hal ini jika kita bisa menambahkan utas tambahan ke kumpulan Garpu / Gabung yang akan dihentikan lagi segera setelah tindakan pemblokiran selesai akan menjadi hal terbaik kedua yang harus dilakukan. Dan ForkJoinPool
sebenarnya bisa melakukan hal itu jika kita menggunakan ManagedBlocker
s.
Fibonacci
Dalam JavaDoc untuk RecursiveTask adalah contoh untuk menghitung angka Fibonacci menggunakan Fork / Bergabung. Untuk solusi rekursif klasik, lihat:
public static int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
Seperti yang dijelaskan dalam JavaDocs, ini adalah cara yang cukup untuk menghitung angka fibonacci, karena algoritma ini memiliki kompleksitas O (2 ^ n) sementara cara yang lebih sederhana dimungkinkan. Namun algoritma ini sangat sederhana dan mudah dimengerti, jadi kami tetap menggunakannya. Mari kita asumsikan kita ingin mempercepat ini dengan Fork / Gabung. Implementasi naif akan terlihat seperti ini:
class Fibonacci extends RecursiveTask<Long> {
private final long n;
Fibonacci(long n) {
this.n = n;
}
public Long compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}
Langkah-langkah di mana Tugas ini dibagi terlalu pendek dan dengan demikian ini akan berkinerja buruk, tetapi Anda dapat melihat bagaimana kerangka kerja umumnya bekerja dengan sangat baik: Dua puncak dapat dihitung secara independen, tetapi kemudian kita membutuhkan keduanya untuk membangun final hasil. Jadi satu setengahnya dilakukan di utas lainnya. Bersenang-senang melakukan hal yang sama dengan kolam utas tanpa mendapatkan jalan buntu (mungkin, tapi tidak sesederhana).
Hanya untuk kelengkapan: Jika Anda benar-benar ingin menghitung angka Fibonacci menggunakan pendekatan rekursif ini di sini adalah versi yang dioptimalkan:
class FibonacciBigSubtasks extends RecursiveTask<Long> {
private final long n;
FibonacciBigSubtasks(long n) {
this.n = n;
}
public Long compute() {
return fib(n);
}
private long fib(long n) {
if (n <= 1) {
return 1;
}
if (n > 10 && getSurplusQueuedTaskCount() < 2) {
final FibonacciBigSubtasks f1 = new FibonacciBigSubtasks(n - 1);
final FibonacciBigSubtasks f2 = new FibonacciBigSubtasks(n - 2);
f1.fork();
return f2.compute() + f1.join();
} else {
return fib(n - 1) + fib(n - 2);
}
}
}
Ini membuat subtugas jauh lebih kecil karena mereka hanya terpecah ketika n > 10 && getSurplusQueuedTaskCount() < 2
benar, yang berarti ada lebih dari 100 pemanggilan metode yang harus dilakukan ( n > 10
) dan tidak ada tugas man yang sudah menunggu ( getSurplusQueuedTaskCount() < 2
).
Di komputer saya (4 inti (8 saat menghitung Hyper-threading), Intel (R) Core (TM) i7-2720QM CPU @ 2.20GHz) fib(50)
membutuhkan 64 detik dengan pendekatan klasik dan hanya 18 detik dengan pendekatan Fork / Bergabung yang adalah keuntungan yang cukup nyata, meskipun secara teori tidak sebanyak mungkin.
Ringkasan
- Ya, dalam contoh Anda Fork / Bergabung tidak memiliki keunggulan dibandingkan kolam utas klasik.
- Fork / Gabung dapat secara drastis meningkatkan kinerja saat pemblokiran terlibat
- Fork / Gabung menghindari beberapa masalah kebuntuan