เป้าหมายสูงสุดของเธรดพูลและ Fork / Join นั้นเหมือนกัน: ทั้งคู่ต้องการใช้พลังงาน CPU ที่มีอยู่ให้ดีที่สุดเพื่อให้ได้ปริมาณงานสูงสุด ปริมาณงานสูงสุดหมายความว่างานให้มากที่สุดเท่าที่จะทำได้ในระยะเวลาอันยาวนาน สิ่งที่จำเป็นในการทำเช่นนั้น? (สำหรับสิ่งต่อไปนี้เราจะถือว่างานคำนวณไม่ขาดแคลน: มีเพียงพอสำหรับการใช้งาน CPU 100% เสมอนอกจากนี้ฉันยังใช้ "CPU" สำหรับคอร์หรือคอร์เสมือนในกรณีที่มีไฮเปอร์เธรด)
- อย่างน้อยก็ต้องมีเธรดทำงานมากที่สุดเท่าที่มีซีพียูเพราะการรันเธรดน้อยลงจะทำให้คอร์ไม่ได้ใช้งาน
- สูงสุดจะต้องมีเธรดจำนวนมากที่รันเนื่องจากมีซีพียูเนื่องจากการรันเธรดมากขึ้นจะสร้างภาระเพิ่มเติมสำหรับผู้จัดกำหนดการที่กำหนดซีพียูให้กับเธรดที่แตกต่างกันซึ่งทำให้เวลา CPU บางส่วนไปที่ตัวกำหนดตารางเวลาแทนที่จะเป็นงานคำนวณ
ด้วยเหตุนี้เราจึงพบว่าสำหรับปริมาณงานสูงสุดเราจำเป็นต้องมีจำนวนเธรดที่เท่ากันกับ CPU ในตัวอย่างการเบลอของ Oracle คุณสามารถใช้เธรดพูลขนาดคงที่โดยมีจำนวนเธรดเท่ากับจำนวนซีพียูที่มีอยู่หรือใช้เธรดพูล มันจะไม่สร้างความแตกต่างคุณพูดถูก!
คุณจะมีปัญหากับเธรดพูลเมื่อใด นั่นคือถ้าเธรดบล็อกเนื่องจากเธรดของคุณกำลังรอให้งานอื่นเสร็จสมบูรณ์ สมมติตัวอย่างต่อไปนี้:
class AbcAlgorithm implements Runnable {
public void run() {
Future<StepAResult> aFuture = threadPool.submit(new ATask());
StepBResult bResult = stepB();
StepAResult aResult = aFuture.get();
stepC(aResult, bResult);
}
}
สิ่งที่เราเห็นต่อไปนี้คืออัลกอริทึมที่ประกอบด้วยสามขั้นตอน A, B และ C A และ B สามารถดำเนินการได้โดยอิสระจากกัน แต่ขั้นตอน C ต้องการผลลัพธ์ของขั้นตอน A และ B สิ่งที่อัลกอริทึมนี้ทำคือส่งงาน A ไปยัง เธรดพูลและปฏิบัติงาน b โดยตรง หลังจากนั้นเธรดจะรอให้ภารกิจ A เสร็จสิ้นและดำเนินการต่อในขั้นตอน C หาก A และ B เสร็จสิ้นในเวลาเดียวกันทุกอย่างก็เรียบร้อยดี แต่ถ้า A ใช้เวลานานกว่า B ล่ะ? นั่นอาจเป็นเพราะลักษณะของงาน A สั่งการ แต่ก็อาจเป็นเช่นนั้นได้เช่นกันเนื่องจากไม่มีเธรดสำหรับงาน A พร้อมใช้งานในตอนเริ่มต้นและงาน A ต้องรอ (หากมีซีพียูเพียงตัวเดียวและเธรดพูลของคุณมีเธรดเพียงเธรดเดียวสิ่งนี้จะทำให้เกิดการชะงักงัน แต่ตอนนี้อยู่นอกเหนือจากจุดนั้น) ประเด็นคือเธรดที่เพิ่งเรียกใช้งาน Bบล็อกหัวข้อทั้งหมด เนื่องจากเรามีหมายเลขเดียวกันของหัวข้อเป็นซีพียูและเป็นหนึ่งในหัวข้อที่ถูกบล็อกนั่นหมายความว่าหนึ่ง CPU ไม่ได้ใช้งาน
Fork / Join แก้ปัญหานี้: ในกรอบการแยก / เข้าร่วมคุณจะต้องเขียนอัลกอริทึมเดียวกันดังนี้:
class AbcAlgorithm implements Runnable {
public void run() {
ATask aTask = new ATask());
aTask.fork();
StepBResult bResult = stepB();
StepAResult aResult = aTask.join();
stepC(aResult, bResult);
}
}
ดูเหมือนกันใช่ไหม อย่างไรก็ตามเบาะแสก็คือว่าจะไม่ปิดกั้นaTask.join
ที่นี่เป็นจุดที่การขโมยงานเข้ามามีบทบาทแทน: เธรดจะมองหางานอื่น ๆ ที่ถูกแยกในอดีตและจะดำเนินการต่อไป ขั้นแรกให้ตรวจสอบว่างานที่แยกตัวเองได้เริ่มดำเนินการแล้วหรือไม่ ดังนั้นหาก A ยังไม่ถูกเริ่มต้นด้วยเธรดอื่นมันจะทำ A ถัดไปมิฉะนั้นจะตรวจสอบคิวของเธรดอื่นและขโมยงานของพวกเขา เมื่องานอื่นของเธรดอื่นเสร็จสิ้นจะตรวจสอบว่า A เสร็จสมบูรณ์แล้วหรือไม่ หากเป็นอัลกอริทึมข้างต้นสามารถโทรstepC
. มิฉะนั้นจะมองหางานอื่นเพื่อขโมย ดังนั้นกลุ่มส้อม / เข้าร่วมจึงสามารถใช้งาน CPU ได้ 100% แม้ว่าจะเผชิญกับการบล็อกก็ตาม
อย่างไรก็ตามมีกับดัก: การขโมยงานเป็นไปได้สำหรับการjoin
เรียกร้องของForkJoinTask
s เท่านั้น ไม่สามารถทำได้สำหรับการดำเนินการบล็อกภายนอกเช่นรอเธรดอื่นหรือรอการดำเนินการ I / O ถ้าอย่างนั้นการรอให้ I / O เสร็จสมบูรณ์เป็นงานทั่วไปหรือไม่? ในกรณีนี้หากเราสามารถเพิ่มเธรดเพิ่มเติมใน Fork / Join pool ซึ่งจะหยุดอีกครั้งทันทีที่การดำเนินการบล็อกเสร็จสิ้นจะเป็นสิ่งที่ดีที่สุดอันดับสองที่ควรทำ และForkJoinPool
สามารถทำได้จริงถ้าเราใช้ManagedBlocker
s
ฟีโบนักชี
ในJavaDoc สำหรับ RecursiveTaskเป็นตัวอย่างสำหรับการคำนวณตัวเลข Fibonacci โดยใช้ Fork / Join สำหรับโซลูชันแบบวนซ้ำแบบคลาสสิกโปรดดู:
public static int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
ตามที่อธิบายไว้ใน JavaDocs นี่เป็นวิธีการถ่ายโอนข้อมูลที่ค่อนข้างสวยในการคำนวณตัวเลข fibonacci เนื่องจากอัลกอริทึมนี้มีความซับซ้อน O (2 ^ n) ในขณะที่วิธีที่ง่ายกว่านั้นเป็นไปได้ อย่างไรก็ตามอัลกอริทึมนี้เรียบง่ายและเข้าใจง่ายดังนั้นเราจึงยึดติดกับมัน สมมติว่าเราต้องการเร่งความเร็วด้วย Fork / Join การใช้งานที่ไร้เดียงสาจะมีลักษณะดังนี้:
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();
}
}
ขั้นตอนที่งานนี้แยกออกเป็นวิธีที่สั้นเกินไปดังนั้นสิ่งนี้จะทำงานได้อย่างน่ากลัว แต่คุณจะเห็นว่าเฟรมเวิร์กโดยทั่วไปทำงานได้ดีมากเพียงใด: summand ทั้งสองสามารถคำนวณได้อย่างอิสระ แต่เราต้องการให้ทั้งคู่สร้างขั้นสุดท้าย ผลลัพธ์. ดังนั้นครึ่งหนึ่งจะทำในเธรดอื่น ขอให้สนุกกับการทำเช่นเดียวกันกับเธรดพูลโดยไม่ต้องหยุดชะงัก (เป็นไปได้ แต่ไม่ง่ายเกือบเท่า)
เพื่อความสมบูรณ์: หากคุณต้องการคำนวณตัวเลข Fibonacci โดยใช้วิธีการเรียกซ้ำที่นี่เป็นเวอร์ชันที่ปรับให้เหมาะสม:
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);
}
}
}
สิ่งนี้ทำให้งานย่อยมีขนาดเล็กลงมากเนื่องจากจะแยกเฉพาะเมื่อn > 10 && getSurplusQueuedTaskCount() < 2
เป็นจริงซึ่งหมายความว่ามีการเรียกใช้เมธอดมากกว่า 100 วิธีอย่างมีนัยสำคัญ ( n > 10
) และไม่มีงานที่ต้องทำรออยู่แล้ว ( getSurplusQueuedTaskCount() < 2
)
บนคอมพิวเตอร์ของฉัน (4 คอร์ (8 เมื่อนับ Hyper-threading), Intel (R) Core (TM) i7-2720QM CPU @ 2.20GHz) fib(50)
ใช้เวลา 64 วินาทีด้วยวิธีการแบบคลาสสิกและเพียง 18 วินาทีด้วยวิธี Fork / Join ซึ่ง เป็นผลกำไรที่เห็นได้ชัดเจนแม้ว่าจะไม่มากเท่าที่เป็นไปได้ในทางทฤษฎี
สรุป
- ใช่ในตัวอย่างของคุณ Fork / Join ไม่มีข้อได้เปรียบเหนือเธรดพูลแบบคลาสสิก
- Fork / Join สามารถปรับปรุงประสิทธิภาพได้อย่างมากเมื่อเกี่ยวข้องกับการบล็อก
- ส้อม / เข้าร่วมหลีกเลี่ยงปัญหาการชะงักงัน