İş parçacığı havuzlarının ve Çatal / Birleştirmenin nihai amacı aynıdır: Her ikisi de mevcut CPU gücünü maksimum verim için ellerinden geldiğince kullanmak ister. Maksimum verim, mümkün olduğunca çok görevin uzun bir süre içinde tamamlanması gerektiği anlamına gelir. Bunu yapmak için ne gerekiyor? (Aşağıdakiler için hesaplama görevlerinde bir eksiklik olmadığını varsayacağız:% 100 CPU kullanımı için her zaman yeterli olacaktır. Ayrıca, hiper iş parçacığı durumunda çekirdekler veya sanal çekirdekler için eşdeğer olarak "CPU" kullanıyorum).
- En azından mevcut CPU'lar kadar çalışan çok sayıda iş parçacığı olması gerekir, çünkü daha az iş parçacığı çalıştırmak bir çekirdeği kullanılmadan bırakır.
- Maksimumda, mevcut CPU'lar kadar çalışan çok sayıda iş parçacığı olmalıdır, çünkü daha fazla iş parçacığı çalıştırmak, CPU'ları farklı iş parçacıklarına atayan Zamanlayıcı için ek yük yaratır ve bu da CPU zamanının hesaplama görevimiz yerine zamanlayıcıya gitmesine neden olur.
Böylece, maksimum verim için CPU'larla aynı sayıda iş parçacığına sahip olmamız gerektiğini anladık. Oracle'ın bulanıklaştırma örneğinde, hem kullanılabilir CPU sayısına eşit olan iş parçacığı sayısına sahip sabit boyutlu bir iş parçacığı havuzu alabilir veya bir iş parçacığı havuzu kullanabilirsiniz. Fark etmeyecek, haklısın!
Peki, iş parçacığı havuzlarıyla ne zaman başınız belaya girecek? Bu, bir iş parçacığının bloke olmasıdır , çünkü iş parçacığınız başka bir görevin tamamlanmasını bekler. Aşağıdaki örneği varsayalım:
class AbcAlgorithm implements Runnable {
public void run() {
Future<StepAResult> aFuture = threadPool.submit(new ATask());
StepBResult bResult = stepB();
StepAResult aResult = aFuture.get();
stepC(aResult, bResult);
}
}
Burada gördüğümüz, A, B ve C olmak üzere üç adımdan oluşan bir algoritmadır. A ve B birbirinden bağımsız olarak gerçekleştirilebilir, ancak C adımının A ve B adımının sonucuna ihtiyacı vardır. Bu algoritmanın yaptığı şey A görevini iş parçacığı havuzunu ve doğrudan b görevini gerçekleştirin. Bundan sonra, iş parçacığı A görevinin de yapılmasını bekleyecek ve C adımına devam edecektir. A ve B aynı anda tamamlanırsa, her şey yolundadır. Peki ya A, B'den daha uzun sürerse? Bunun nedeni, A görevinin doğası gereği onu dikte etmesi olabilir, ancak başlangıçta A görevi için iş parçacığı olmadığı ve A görevinin beklemesi gerektiği için de geçerli olabilir. (Yalnızca tek bir CPU mevcutsa ve bu nedenle iş parçacığınızın yalnızca tek bir iş parçacığı varsa, bu bir kilitlenmeye bile neden olur, ancak şimdilik noktanın dışında). Mesele şu ki, B görevini yerine getiren iş parçacığıtüm iş parçacığını engeller . CPU'larla aynı sayıda iş parçacığına sahip olduğumuzdan ve bir iş parçacığı engellendiğinden, bu, bir CPU'nun boşta olduğu anlamına gelir .
Fork / Join bu sorunu çözer: fork / join çerçevesinde aşağıdaki gibi aynı algoritmayı yazarsınız:
class AbcAlgorithm implements Runnable {
public void run() {
ATask aTask = new ATask());
aTask.fork();
StepBResult bResult = stepB();
StepAResult aResult = aTask.join();
stepC(aResult, bResult);
}
}
Aynı görünüyor, değil mi? Ancak ipucu, bunun aTask.join
engellemeyeceğidir . Bunun yerine iş hırsızlığının devreye girdiği yer burasıdır : İş parçacığı, geçmişte çatallanmış diğer görevleri arayacak ve bunlarla devam edecek. İlk olarak, çatallandığı görevlerin işlemeye başlayıp başlamadığını kontrol eder. Yani A henüz başka bir iş parçacığı tarafından başlatılmadıysa, sonra A yapacak, aksi takdirde diğer iş parçacıklarının kuyruğunu kontrol edecek ve çalışmalarını çalacaktır. Başka bir iş parçacığının bu diğer görevi tamamlandığında, A'nın şimdi tamamlanıp tamamlanmadığını kontrol edecektir. Yukarıdaki algoritma ise arayabilir stepC
. Aksi takdirde çalacak başka bir görev daha arayacaktır. Böylece çatal / birleştirme havuzları, engelleme eylemleri karşısında bile% 100 CPU kullanımına ulaşabilir .
Ancak bir tuzak vardır: İş hırsızlığı yalnızca e-posta join
çağrıları için mümkündür ForkJoinTask
. Başka bir iş parçacığı beklemek veya bir G / Ç eylemi beklemek gibi harici engelleme eylemleri için yapılamaz. Peki buna ne dersiniz, G / Ç'nin tamamlanmasını beklemek ortak bir görev mi? Bu durumda, Çatal / Birleştirme havuzuna ek bir iş parçacığı ekleyebilirsek, bu, engelleme eylemi tamamlanır tamamlanmaz durdurulacak en iyi ikinci şey olacaktır. Ve ForkJoinPool
eğer biz ManagedBlocker
s kullanıyorsak aslında bunu yapabilir .
Fibonacci
Özyinelemeli Görev için JavaDoc'ta, Fibonacci sayılarının Fork / Join kullanılarak hesaplanması için bir örnek verilmiştir. Klasik bir özyinelemeli çözüm için bakınız:
public static int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
JavaDocs'ta açıklandığı gibi, bu algoritma O (2 ^ n) karmaşıklığa sahipken daha basit yollar mümkün olduğundan, bu fibonacci sayılarını hesaplamak için oldukça dökümlü bir yoldur. Ancak bu algoritma çok basit ve anlaşılması kolay, bu yüzden ona bağlı kalıyoruz. Bunu Fork / Join ile hızlandırmak istediğimizi varsayalım. Saf bir uygulama şöyle görünür:
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();
}
}
Bu Görevin bölündüğü adımlar çok kısa ve bu nedenle bu korkunç bir performans sergileyecek, ancak çerçevenin genel olarak nasıl çok iyi çalıştığını görebilirsiniz: İki zirve bağımsız olarak hesaplanabilir, ancak daha sonra finali oluşturmak için ikisine de ihtiyacımız var sonuç. Yani bir yarısı başka bir iş parçacığında yapılır. Bir kilitlenme olmadan iş parçacığı havuzlarında aynı şeyi yaparak eğlenin (mümkün, ancak bu kadar basit değil).
Tamlık için: Bu yinelemeli yaklaşımı kullanarak gerçekten Fibonacci sayılarını hesaplamak istiyorsanız, burada optimize edilmiş bir sürüm var:
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);
}
}
}
Bu, alt görevleri çok daha küçük tutar, çünkü bunlar yalnızca n > 10 && getSurplusQueuedTaskCount() < 2
doğru olduğunda bölünürler , bu, yapılacak 100'den fazla yöntem çağrısı olduğu n > 10
ve halihazırda bekleyen ( getSurplusQueuedTaskCount() < 2
) çok sayıda insan görevi olmadığı anlamına gelir .
Bilgisayarımda (4 çekirdekli (Hyper-threading sayılırken 8), Intel (R) Core (TM) i7-2720QM CPU @ 2.20GHz) fib(50)
klasik yaklaşımla 64 saniye ve Fork / Join yaklaşımı ile sadece 18 saniye sürer. teorik olarak mümkün olduğu kadar olmasa da oldukça dikkat çekici bir kazançtır.
özet
- Evet, örneğinizde Fork / Join, klasik iş parçacığı havuzlarına göre avantajlı değildir.
- Fork / Join, engelleme söz konusu olduğunda performansı önemli ölçüde artırabilir
- Fork / Join bazı kilitlenme sorunlarını çözer