Fork / join framework ดีกว่า thread pool อย่างไร?


137

ประโยชน์ของการใช้fork / join framework ใหม่เพียงแค่แบ่งงานใหญ่ออกเป็น N subtasks ในตอนเริ่มต้นส่งไปยังกลุ่มเธรดที่แคชไว้ (จากExecutors ) และรอให้แต่ละงานเสร็จสมบูรณ์ ฉันไม่เห็นว่าการใช้ fork / join abstract ทำให้ปัญหาง่ายขึ้นหรือทำให้การแก้ปัญหามีประสิทธิภาพมากขึ้นจากสิ่งที่เรามีมาหลายปีแล้วได้อย่างไร

ตัวอย่างเช่นอัลกอริทึมการเบลอแบบขนานในตัวอย่างบทช่วยสอนสามารถใช้งานได้ดังนี้:

public class Blur implements Runnable {
    private int[] mSource;
    private int mStart;
    private int mLength;
    private int[] mDestination;

    private int mBlurWidth = 15; // Processing window size, should be odd.

    public ForkBlur(int[] src, int start, int length, int[] dst) {
        mSource = src;
        mStart = start;
        mLength = length;
        mDestination = dst;
    }

    public void run() {
        computeDirectly();
    }

    protected void computeDirectly() {
        // As in the example, omitted for brevity
    }
}

แยกในการเริ่มต้นและส่งงานไปยังเธรดพูล:

// source image pixels are in src
// destination image pixels are in dst
// threadPool is a (cached) thread pool

int maxSize = 100000; // analogous to F-J's "sThreshold"
List<Future> futures = new ArrayList<Future>();

// Send stuff to thread pool:
for (int i = 0; i < src.length; i+= maxSize) {
    int size = Math.min(maxSize, src.length - i);
    ForkBlur task = new ForkBlur(src, i, size, dst);
    Future f = threadPool.submit(task);
    futures.add(f);
}

// Wait for all sent tasks to complete:
for (Future future : futures) {
    future.get();
}

// Done!

งานจะไปที่คิวของเธรดพูลซึ่งจะถูกดำเนินการเมื่อเธรดของผู้ปฏิบัติงานพร้อมใช้งาน ตราบใดที่การแยกมีความละเอียดเพียงพอ (เพื่อหลีกเลี่ยงการต้องรองานสุดท้ายเป็นพิเศษ) และเธรดพูลมีเธรด (อย่างน้อย N ของโปรเซสเซอร์) เพียงพอโปรเซสเซอร์ทั้งหมดจะทำงานด้วยความเร็วเต็มที่จนกว่าการคำนวณทั้งหมดจะเสร็จสิ้น

ฉันพลาดอะไรไปรึเปล่า? มูลค่าเพิ่มของการใช้ fork / join framework คืออะไร?

คำตอบ:


140

ฉันคิดว่าความเข้าใจผิดพื้นฐานคือตัวอย่าง Fork / Join ไม่ได้แสดงการขโมยงานแต่เป็นการแบ่งและพิชิตมาตรฐานบางประเภทเท่านั้น

การขโมยงานจะเป็นเช่นนี้คนงาน B ทำงานเสร็จแล้ว เขาเป็นคนใจดีดังนั้นเขาจึงมองไปรอบ ๆ และเห็นคนงาน A ยังคงทำงานหนักมาก เขาเดินเล่นและถามว่า: "เฮ้เด็กฉันช่วยให้คุณช่วยได้" คำตอบ "เจ๋งฉันมีงานนี้ 1,000 หน่วยจนถึงตอนนี้ฉันทำเสร็จแล้ว 345 เหลือ 655 โปรดช่วยทำงานกับหมายเลข 673 ถึง 1,000 ได้ไหมฉันจะทำ 346 ถึง 672" B พูดว่า "ตกลงเริ่มกันเลยเราไปผับกันก่อนดีกว่า"

คุณจะเห็น - คนงานต้องสื่อสารระหว่างกันแม้ว่าพวกเขาจะเริ่มงานจริงก็ตาม นี่คือส่วนที่ขาดหายไปในตัวอย่าง

ในทางกลับกันตัวอย่างจะแสดงเฉพาะบางสิ่งเช่น "ใช้ผู้รับเหมาช่วง":

คนงาน A: "แดงฉันมีงาน 1,000 หน่วยงานมากเกินไปสำหรับฉันฉันจะทำ 500 ตัวเองและรับเหมาช่วง 500 ให้คนอื่น" สิ่งนี้จะดำเนินต่อไปจนกว่างานใหญ่จะถูกแบ่งออกเป็นแพ็คเก็ตเล็ก ๆ ละ 10 หน่วย สิ่งเหล่านี้จะถูกดำเนินการโดยคนงานที่มีอยู่ แต่ถ้าซองหนึ่งเป็นยาพิษชนิดหนึ่งและใช้เวลานานกว่าแพ็คเก็ตอื่นมาก - โชคร้ายการแบ่งเฟสจะจบลง

ข้อแตกต่างที่เหลือเพียงอย่างเดียวระหว่าง Fork / Join และการแบ่งงานล่วงหน้าคือ: เมื่อแยกงานล่วงหน้าคุณจะมีคิวงานเต็มตั้งแต่เริ่มต้น ตัวอย่าง: 1,000 หน่วยเกณฑ์คือ 10 ดังนั้นคิวจึงมี 100 รายการ แพ็กเก็ตเหล่านี้แจกจ่ายให้กับสมาชิก threadpool

Fork / Join มีความซับซ้อนมากขึ้นและพยายามรักษาจำนวนแพ็กเก็ตในคิวให้เล็กลง:

  • ขั้นตอนที่ 1: ใส่หนึ่งแพ็คเก็ตที่มี (1 ... 1000) ลงในคิว
  • ขั้นตอนที่ 2: ผู้ปฏิบัติงานคนหนึ่งแสดงแพ็กเก็ต (1 ... 1000) และแทนที่ด้วยสองแพ็กเก็ต: (1 ... 500) และ (501 ... 1000)
  • ขั้นตอนที่ 3: ผู้ปฏิบัติงานคนหนึ่งแสดงแพ็กเก็ต (500 ... 1000) และผลักดัน (500 ... 750) และ (751 ... 1000)
  • ขั้นตอนที่ n: สแต็กประกอบด้วยแพ็กเก็ตเหล่านี้: (1..500), (500 ... 750), (750 ... 875) ... (991..1000)
  • ขั้นตอนที่ n + 1: แพ็คเก็ต (991..1000) ถูกเปิดและดำเนินการ
  • ขั้นตอนที่ n + 2: แพ็คเก็ต (981..990) ถูกเปิดขึ้นและดำเนินการ
  • ขั้นตอนที่ n + 3: แพ็คเก็ต (961..980) แตกออกเป็น (961 ... 970) และ (971..980) ....

คุณจะเห็น: ใน Fork / Join คิวจะเล็กกว่า (6 ในตัวอย่าง) และขั้นตอน "แยก" และ "งาน" จะแทรกสลับกัน

เมื่อมีคนงานหลายคนโผล่เข้ามาและผลักดันพร้อม ๆ กันการโต้ตอบจะไม่ชัดเจน


ฉันคิดว่านี่คือคำตอบแน่นอน ฉันสงสัยว่ามีตัวอย่าง Fork / Join จริงทุกที่ที่จะแสดงให้เห็นถึงความสามารถในการขโมยงานหรือไม่? ด้วยตัวอย่างเบื้องต้นปริมาณงานสามารถคาดเดาได้อย่างสมบูรณ์แบบจากขนาดของหน่วย (เช่นความยาวอาร์เรย์) ดังนั้นการแยกล่วงหน้าจึงทำได้ง่าย แน่นอนว่าการขโมยจะสร้างความแตกต่างในปัญหาที่ปริมาณงานต่อหน่วยไม่สามารถคาดเดาได้ดีจากขนาดของหน่วย
Joonas Pulakka

AH ถ้าคำตอบของคุณถูกต้องมันไม่ได้อธิบายว่าอย่างไร ตัวอย่างที่กำหนดโดย Oracle ไม่ส่งผลให้เกิดการขโมยงาน วิธีการแยกและเข้าร่วมทำงานดังตัวอย่างที่คุณอธิบายที่นี่? คุณช่วยแสดงโค้ด Java ที่จะสร้าง fork และเข้าร่วมขโมยแบบที่คุณอธิบายได้ไหม ขอบคุณ
Marc

@ มาร์ค: ฉันขอโทษ แต่ฉันไม่มีตัวอย่างให้
AH

6
ปัญหาเกี่ยวกับตัวอย่าง IMO ของ Oracle ไม่ใช่ว่ามันไม่ได้แสดงให้เห็นถึงการขโมยงาน (เป็นไปตามที่ AH อธิบายไว้) แต่เป็นเรื่องง่ายที่จะเขียนโค้ดอัลกอริทึมสำหรับ ThreadPool แบบธรรมดาซึ่งทำได้เช่นกัน (เช่นเดียวกับ Joonas) FJ มีประโยชน์มากที่สุดเมื่อไม่สามารถแยกงานล่วงหน้าออกเป็นงานอิสระได้เพียงพอ แต่สามารถแบ่งออกเป็นงานซ้ำ ๆ ที่มีความเป็นอิสระระหว่างกันได้ ดูคำตอบของฉันสำหรับตัวอย่าง
ashirley

2
ตัวอย่างบางส่วนที่อาจมีประโยชน์ในการขโมยงาน: h-online.com/developer/features/…
วอลเลย์

27

หากคุณมีเธรดที่ไม่ว่างทั้งหมดทำงานโดยอิสระ 100% นั่นจะดีกว่า n เธรดในกลุ่ม Fork-Join (FJ) แต่มันไม่เคยได้ผลเช่นนั้น

อาจไม่สามารถแบ่งปัญหาออกเป็นชิ้นส่วนเท่า ๆ กันได้อย่างแม่นยำ แม้ว่าคุณจะทำเช่นนั้นการตั้งเวลาเธรดก็เป็นวิธีที่ไม่ยุติธรรม คุณจะต้องรอเธรดที่ช้าที่สุด หากคุณมีงานหลายงานพวกเขาสามารถทำงานได้โดยมีความขนานน้อยกว่า n-way (โดยทั่วไปจะมีประสิทธิภาพมากกว่า) แต่ขึ้นไปยัง n-way เมื่องานอื่น ๆ เสร็จสิ้น

แล้วทำไมเราไม่ตัดปัญหาออกเป็นชิ้นขนาด FJ และมีเธรดพูลทำงานอยู่ การใช้งาน FJ โดยทั่วไปจะตัดปัญหาออกเป็นชิ้นเล็ก ๆ การทำสิ่งเหล่านี้ตามลำดับแบบสุ่มจำเป็นต้องมีการประสานงานกันมากในระดับฮาร์ดแวร์ ค่าใช้จ่ายจะเป็นนักฆ่า ใน FJ งานจะถูกวางลงในคิวที่เธรดอ่านออกในคำสั่ง Last In First Out (LIFO / stack) และการขโมยงาน (ในงานหลักโดยทั่วไป) จะเสร็จสิ้นก่อนเข้าก่อน (FIFO / "คิว") ผลลัพธ์ก็คือการประมวลผลอาร์เรย์แบบยาวสามารถทำได้ตามลำดับส่วนใหญ่แม้ว่าจะแบ่งออกเป็นชิ้นเล็ก ๆ (เป็นกรณีที่อาจไม่ใช่เรื่องเล็กน้อยที่จะแบ่งปัญหาออกเป็นชิ้นเล็ก ๆ ที่มีขนาดเท่า ๆ กันในบิ๊กแบงครั้งเดียวพูดว่าจัดการกับรูปแบบลำดับชั้นบางรูปแบบโดยไม่ทำให้สมดุล)

สรุป: FJ ช่วยให้สามารถใช้เธรดฮาร์ดแวร์ได้อย่างมีประสิทธิภาพมากขึ้นในสถานการณ์ที่ไม่สม่ำเสมอซึ่งจะเกิดขึ้นเสมอหากคุณมีเธรดมากกว่าหนึ่งเธรด


แต่ทำไม FJ ถึงไม่ยอมรอกระทู้ที่ช้าที่สุดด้วยล่ะ? มีงานย่อยที่กำหนดไว้ล่วงหน้าจำนวนหนึ่งและแน่นอนว่าบางงานจะเป็นงานสุดท้ายที่จะทำให้เสร็จ การปรับmaxSizeพารามิเตอร์ในตัวอย่างของฉันจะทำให้เกิดการแบ่งงานย่อยที่คล้ายกันเกือบจะเหมือนกับ "การแยกไบนารี" ในตัวอย่าง FJ (ทำได้ภายในcompute()วิธีการซึ่งคำนวณบางอย่างหรือส่งงานย่อยไปให้invokeAll())
Joonas Pulakka

เนื่องจากมีขนาดเล็กกว่ามากฉันจะเพิ่มคำตอบให้
Tom Hawtin - แท็กไลน์

โอเคถ้าจำนวนงานย่อยมีลำดับของขนาดใหญ่กว่าที่สามารถประมวลผลแบบขนานได้จริง (ซึ่งสมเหตุสมผลเพื่อหลีกเลี่ยงการรองานสุดท้าย) ฉันจะเห็นปัญหาการประสานงาน ตัวอย่าง FJอาจทำให้เข้าใจผิดหากการแบ่งควรเป็นแบบละเอียด: ใช้เกณฑ์ที่ 100000 ซึ่งสำหรับภาพขนาด 1000x1000 จะสร้างงานย่อยจริง 16 งานโดยแต่ละการประมวลผล 62500 องค์ประกอบ สำหรับภาพขนาด 10000x10000 จะมี 1024 งานย่อยซึ่งเป็นสิ่งที่มีอยู่แล้ว
Joonas Pulakka

19

เป้าหมายสูงสุดของเธรดพูลและ Fork / Join นั้นเหมือนกัน: ทั้งคู่ต้องการใช้พลังงาน CPU ที่มีอยู่ให้ดีที่สุดเพื่อให้ได้ปริมาณงานสูงสุด ปริมาณงานสูงสุดหมายความว่างานให้มากที่สุดเท่าที่จะทำได้ในระยะเวลาอันยาวนาน สิ่งที่จำเป็นในการทำเช่นนั้น? (สำหรับสิ่งต่อไปนี้เราจะถือว่างานคำนวณไม่ขาดแคลน: มีเพียงพอสำหรับการใช้งาน CPU 100% เสมอนอกจากนี้ฉันยังใช้ "CPU" สำหรับคอร์หรือคอร์เสมือนในกรณีที่มีไฮเปอร์เธรด)

  1. อย่างน้อยก็ต้องมีเธรดทำงานมากที่สุดเท่าที่มีซีพียูเพราะการรันเธรดน้อยลงจะทำให้คอร์ไม่ได้ใช้งาน
  2. สูงสุดจะต้องมีเธรดจำนวนมากที่รันเนื่องจากมีซีพียูเนื่องจากการรันเธรดมากขึ้นจะสร้างภาระเพิ่มเติมสำหรับผู้จัดกำหนดการที่กำหนดซีพียูให้กับเธรดที่แตกต่างกันซึ่งทำให้เวลา 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เรียกร้องของForkJoinTasks เท่านั้น ไม่สามารถทำได้สำหรับการดำเนินการบล็อกภายนอกเช่นรอเธรดอื่นหรือรอการดำเนินการ I / O ถ้าอย่างนั้นการรอให้ I / O เสร็จสมบูรณ์เป็นงานทั่วไปหรือไม่? ในกรณีนี้หากเราสามารถเพิ่มเธรดเพิ่มเติมใน Fork / Join pool ซึ่งจะหยุดอีกครั้งทันทีที่การดำเนินการบล็อกเสร็จสิ้นจะเป็นสิ่งที่ดีที่สุดอันดับสองที่ควรทำ และForkJoinPoolสามารถทำได้จริงถ้าเราใช้ManagedBlockers

ฟีโบนักชี

ใน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 สามารถปรับปรุงประสิทธิภาพได้อย่างมากเมื่อเกี่ยวข้องกับการบล็อก
  • ส้อม / เข้าร่วมหลีกเลี่ยงปัญหาการชะงักงัน

18

Fork / join แตกต่างจากเธรดพูลเนื่องจากใช้งานขโมย จากFork / Join

เช่นเดียวกับ ExecutorService เฟรมเวิร์ก fork / join จะกระจายงานไปยังเธรดของผู้ปฏิบัติงานในเธรดพูล เฟรมเวิร์ก fork / join มีความแตกต่างเนื่องจากใช้อัลกอริทึมการขโมยงาน เธรดผู้ปฏิบัติงานที่ไม่มีสิ่งที่ต้องทำอาจขโมยงานจากเธรดอื่นที่ยังไม่ว่าง

สมมติว่าคุณมีเธรด 2 เธรดและ 4 งาน a, b, c, d ซึ่งใช้เวลา 1, 1, 5 และ 6 วินาทีตามลำดับ เริ่มแรก a และ b ถูกกำหนดให้กับเธรด 1 และ c และ d ไปยังเธรด 2 ในเธรดพูลซึ่งจะใช้เวลา 11 วินาที เมื่อใช้ส้อม / เข้าร่วมเธรด 1 จะเสร็จสิ้นและสามารถขโมยงานจากเธรด 2 ดังนั้นงาน d จะจบลงด้วยการดำเนินการโดยเธรด 1 เธรด 1 รัน a, b และ d, เธรด 2 เพียงแค่ c เวลาโดยรวม: 8 วินาทีไม่ใช่ 11.

แก้ไข: ดังที่ Joonas ชี้ให้เห็นว่างานไม่จำเป็นต้องจัดสรรไว้ล่วงหน้าให้กับเธรด แนวคิดของ fork / join คือเธรดสามารถเลือกที่จะแบ่งงานออกเป็นชิ้นส่วนย่อย ๆ ได้ ดังนั้นเพื่ออธิบายข้างต้น:

เรามีสองงาน (ab) และ (cd) ซึ่งใช้เวลา 2 และ 11 วินาทีตามลำดับ เธรด 1 เริ่มดำเนินการ ab และแบ่งออกเป็นสองงานย่อย a & b ในทำนองเดียวกันกับเธรด 2 จะแบ่งออกเป็นสองงานย่อย c & d เมื่อเธรด 1 เสร็จสิ้น a & b มันสามารถขโมย d จากเธรด 2 ได้


5
เธรดพูลโดยทั่วไปคืออินสแตนซ์ThreadPoolExecutor ด้วยเหตุนี้งานจึงเป็นคิว ( BlockingQueueในทางปฏิบัติ) ซึ่งเธรดของผู้ปฏิบัติงานจะรับงานทันทีที่เสร็จสิ้นงานก่อนหน้า งานไม่ได้กำหนดไว้ล่วงหน้าสำหรับเธรดเฉพาะเท่าที่ฉันเข้าใจ แต่ละเธรดมี (มากที่สุด) ครั้งละ 1 งาน
Joonas Pulakka

4
AFAIK มีหนึ่งคิวสำหรับหนึ่ง ThreadPoolExecutor ซึ่งจะควบคุมหลายเธรด ซึ่งหมายความว่าการมอบหมายงานหรือ Runnables (ไม่ใช่ Threads!) ให้กับผู้ดำเนินการงานนั้นจะไม่ได้ถูกจัดสรรไว้ล่วงหน้าให้กับเธรดเฉพาะ วิธีที่ FJ ทำเช่นกัน จนถึงขณะนี้ไม่มีประโยชน์สำหรับการใช้ FJ
AH

1
@AH ใช่ แต่ fork / join ช่วยให้คุณสามารถแบ่งงานปัจจุบันได้ เธรดที่กำลังดำเนินการงานสามารถแบ่งออกเป็นสองงานที่แตกต่างกัน ดังนั้นด้วย ThreadPoolExecutor คุณจะมีรายการงานที่แน่นอน ด้วยส้อม / เข้าร่วมงานที่ดำเนินการสามารถแบ่งงานของตัวเองออกเป็นสองงานซึ่งเธรดอื่น ๆ สามารถรับได้เมื่อทำงานเสร็จแล้ว หรือคุณถ้าคุณเสร็จก่อน
Matthew Farwell

1
@Matthew Farwell: ในตัวอย่าง FJภายในแต่ละงานcompute()จะคำนวณงานหรือแยกออกเป็นสองงานย่อย ตัวเลือกที่จะเลือกขึ้นอยู่เพียงกับขนาดของงาน ( if (mLength < sThreshold)...) ดังนั้นจึงเป็นเพียงวิธีแฟนซีของการสร้างจำนวนคงที่ของงาน สำหรับภาพขนาด 1000x1000 จะมี 16 งานย่อยที่คำนวณบางสิ่งได้จริง นอกจากนี้ยังมีงาน "ระดับกลาง" 15 (= 16 - 1) ที่สร้างและเรียกใช้งานย่อยเท่านั้นและไม่ได้คำนวณอะไรเอง
Joonas Pulakka

2
@Matthew Farwell: เป็นไปได้ว่าฉันไม่เข้าใจ FJ ทั้งหมด แต่ถ้างานย่อยตัดสินใจที่จะใช้computeDirectly()วิธีการของมันก็ไม่มีทางขโมยอะไรได้อีกแล้ว การแยกทั้งหมดจะกระทำโดยสังเขปอย่างน้อยก็ในตัวอย่าง
Joonas Pulakka

14

ทุกคนข้างต้นถูกต้องผลประโยชน์ที่ได้รับจากการขโมยงาน แต่จะขยายความว่าเหตุใดจึงเป็นเช่นนั้น

ประโยชน์หลักคือการประสานงานที่มีประสิทธิภาพระหว่างเธรดของผู้ปฏิบัติงาน งานจะต้องแยกและประกอบขึ้นใหม่ซึ่งต้องมีการประสานงาน ดังที่คุณเห็นในคำตอบของ AH ด้านบนแต่ละหัวข้อมีรายการงานของตัวเอง คุณสมบัติที่สำคัญของรายการนี้คือการเรียงลำดับ (งานขนาดใหญ่ที่ด้านบนและงานเล็ก ๆ ที่ด้านล่าง) แต่ละเธรดดำเนินการงานที่ด้านล่างของรายการและขโมยงานจากด้านบนของรายการเธรดอื่น ๆ

ผลลัพธ์คือ:

  • ส่วนหัวและส่วนท้ายของรายการงานสามารถซิงโครไนซ์ได้อย่างอิสระช่วยลดความขัดแย้งในรายการ
  • ต้นไม้ย่อยที่สำคัญของงานจะถูกแยกและประกอบขึ้นใหม่ด้วยเธรดเดียวกันดังนั้นจึงไม่จำเป็นต้องมีการประสานเธรดระหว่างกันสำหรับต้นไม้ย่อยเหล่านี้
  • เมื่อเธรดขโมยงานจะใช้ชิ้นส่วนขนาดใหญ่ซึ่งจะแบ่งย่อยลงในรายการของตัวเอง
  • การรีดขึ้นรูปหมายถึงการใช้เธรดเกือบทั้งหมดจนสิ้นสุดกระบวนการ

การแบ่งและพิชิตโครงร่างอื่น ๆ โดยใช้เธรดพูลต้องการการสื่อสารและการประสานงานระหว่างเธรดมากขึ้น


13

ในตัวอย่างนี้ Fork / Join ไม่เพิ่มค่าใด ๆ เนื่องจากไม่จำเป็นต้องใช้ forking และปริมาณงานจะถูกแบ่งเท่า ๆ กันระหว่างเธรดของผู้ปฏิบัติงาน Fork / Join เพิ่มเฉพาะค่าใช้จ่าย

นี่คือบทความที่ดีเกี่ยวกับเรื่องนี้ อ้าง:

โดยรวมแล้วเราสามารถพูดได้ว่าควรเลือกใช้ ThreadPoolExecutor โดยที่ปริมาณงานจะถูกแบ่งเท่า ๆ กันระหว่างเธรดของผู้ปฏิบัติงาน เพื่อให้สามารถรับประกันสิ่งนี้คุณจำเป็นต้องทราบอย่างชัดเจนว่าข้อมูลที่ป้อนมีลักษณะอย่างไร ในทางตรงกันข้าม ForkJoinPool ให้ประสิทธิภาพที่ดีโดยไม่คำนึงถึงข้อมูลอินพุตดังนั้นจึงเป็นโซลูชันที่มีประสิทธิภาพมากกว่าอย่างเห็นได้ชัด


8

ความแตกต่างที่สำคัญอีกประการหนึ่งก็คือด้วย FJ คุณสามารถทำหลาย ๆ ขั้นตอน "เข้าร่วม" ที่ซับซ้อนได้ พิจารณาการเรียงลำดับการผสานจากhttp://faculty.ycp.edu/~dhovemey/spring2011/cs365/lecture/lecture18.htmlจะต้องมีการประสานงานมากเกินไปในการแยกงานนี้ล่วงหน้า เช่นคุณต้องทำสิ่งต่อไปนี้:

  • เรียงลำดับไตรมาสแรก
  • จัดเรียงไตรมาสที่สอง
  • รวม 2 ไตรมาสแรก
  • เรียงลำดับไตรมาสที่สาม
  • เรียงลำดับสี่
  • รวม 2 ไตรมาสล่าสุด
  • รวม 2 ครึ่ง

คุณจะระบุได้อย่างไรว่าคุณต้องทำประเภทต่างๆก่อนการผสานซึ่งเกี่ยวข้องกับพวกเขาเป็นต้น

ฉันได้ดูวิธีที่ดีที่สุดในการทำบางสิ่งสำหรับแต่ละรายการ ฉันคิดว่าฉันจะแยกรายการล่วงหน้าและใช้ ThreadPool มาตรฐาน FJ ดูเหมือนจะมีประโยชน์มากที่สุดเมื่อไม่สามารถแยกงานล่วงหน้าออกเป็นงานอิสระได้เพียงพอ แต่สามารถแบ่งออกเป็นงานที่เป็นอิสระระหว่างกันซ้ำ ๆ กันได้ (เช่นการจัดเรียงส่วนแบ่งครึ่งเป็นแบบอิสระ แต่การรวม 2 ส่วนที่แยกไว้เข้าด้วยกันไม่ได้)


6

F / J ยังมีข้อได้เปรียบที่แตกต่างเมื่อคุณมีการดำเนินการผสานราคาแพง เนื่องจากมันแยกออกเป็นโครงสร้างแบบทรีคุณจึงทำการผสาน log2 (n) เท่านั้นเมื่อเทียบกับ n ผสานกับการแยกเธรดเชิงเส้น (สิ่งนี้ทำให้สมมติฐานทางทฤษฎีว่าคุณมีตัวประมวลผลมากพอ ๆ กับเธรด แต่ก็ยังได้เปรียบ) สำหรับการบ้านเราต้องรวมอาร์เรย์ 2D หลายพันอาร์เรย์ (มิติเดียวกันทั้งหมด) โดยการรวมค่าที่ดัชนีแต่ละตัว ด้วยการรวมส้อมและโปรเซสเซอร์ P เวลาเข้าใกล้ log2 (n) เมื่อ P เข้าใกล้อินฟินิตี้

1 2 3 .. 7 3 1 .... 8 5 4
4 5 6 + 2 4 3 => 6 9 9
7 8 9 .. 1 1 0 .... 8 9 9


3

คุณจะประหลาดใจกับประสิทธิภาพของ ForkJoin ในแอปพลิเคชันเช่นซอฟต์แวร์รวบรวมข้อมูล นี่คือบทช่วยสอนที่ดีที่สุดที่คุณจะได้เรียนรู้

ตรรกะของ Fork / Join นั้นง่ายมาก: (1) แยก (แยก) งานขนาดใหญ่แต่ละงานออกเป็นงานเล็ก ๆ (2) ประมวลผลแต่ละงานในเธรดที่แยกจากกัน (แยกงานเหล่านั้นออกเป็นงานที่เล็กกว่าหากจำเป็น) (3) เข้าร่วมผลลัพธ์


3

หากปัญหาเป็นเช่นนั้นเราต้องรอให้เธรดอื่น ๆ ดำเนินการให้เสร็จสมบูรณ์ (เช่นในกรณีของการเรียงลำดับอาร์เรย์หรือผลรวมของอาร์เรย์) ควรใช้การรวมส้อมในฐานะผู้ดำเนินการ (Executors.newFixedThreadPool (2)) จะสำลักเนื่องจาก จำกัด จำนวนเธรด พูล forkjoin จะสร้างเธรดเพิ่มเติมในกรณีนี้เพื่อปกปิดเธรดที่ถูกบล็อกเพื่อรักษาความขนานเดียวกัน

ที่มา: http://www.oracle.com/technetwork/articles/java/fork-join-422606.html

ปัญหาเกี่ยวกับตัวดำเนินการในการใช้อัลกอริธึมการแบ่งและพิชิตไม่เกี่ยวข้องกับการสร้างงานย่อยเนื่องจาก Callable มีอิสระในการส่งงานย่อยใหม่ไปยังตัวดำเนินการและรอให้ผลลัพธ์เป็นแบบซิงโครนัสหรืออะซิงโครนัส ปัญหาคือความเท่าเทียมกัน: เมื่อ Callable รอผลลัพธ์ของ Callable อื่นมันจะอยู่ในสถานะรอซึ่งทำให้เสียโอกาสในการจัดการ Callable อื่นที่อยู่ในคิวสำหรับการดำเนินการ

เฟรมเวิร์ก fork / join ที่เพิ่มเข้าไปในแพ็คเกจ java.util.concurrent ใน Java SE 7 ผ่านความพยายามของ Doug Lea เติมเต็มช่องว่างนั้น

ที่มา: https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/ForkJoinPool.html

พูลจะพยายามรักษาเธรดที่แอ็คทีฟ (หรือพร้อมใช้งาน) ให้เพียงพอโดยการเพิ่มหยุดหรือเรียกใช้เธรดผู้ปฏิบัติงานภายในต่อแบบไดนามิกแม้ว่างานบางอย่างจะหยุดรอเพื่อเข้าร่วมผู้อื่น อย่างไรก็ตามไม่มีการรับประกันการปรับเปลี่ยนดังกล่าวเมื่อเผชิญกับ IO ที่ถูกบล็อกหรือการซิงโครไนซ์ที่ไม่มีการจัดการอื่น ๆ

public int getPoolSize () ส่งคืนจำนวนเธรดของผู้ปฏิบัติงานที่เริ่มต้น แต่ยังไม่สิ้นสุด ผลลัพธ์ที่ส่งคืนโดยวิธีนี้อาจแตกต่างจาก getParallelism () เมื่อเธรดถูกสร้างขึ้นเพื่อรักษาความเท่าเทียมกันเมื่อคนอื่นถูกบล็อกแบบร่วมมือกัน


2

ขอเพิ่มคำตอบสั้น ๆ สำหรับผู้ที่ไม่มีเวลาอ่านคำตอบยาว ๆ การเปรียบเทียบนำมาจากหนังสือ Applied Akka Patterns:

การตัดสินใจของคุณว่าจะใช้ fork-join-executor หรือ thread-pool-executor ส่วนใหญ่ขึ้นอยู่กับว่าการดำเนินการในตัวเลือกจ่ายนั้นจะถูกบล็อกหรือไม่ ตัวดำเนินการส้อมร่วมให้จำนวนเธรดที่ใช้งานอยู่สูงสุดในขณะที่เธรดพูลตัวดำเนินการให้จำนวนเธรดคงที่ หากเธรดถูกบล็อก fork-join-executor จะสร้างเพิ่มเติมในขณะที่ thread-pool-executor จะไม่ทำ สำหรับการดำเนินการบล็อกโดยทั่วไปคุณควรใช้ thread-pool-executor ดีกว่าเนื่องจากจะป้องกันไม่ให้จำนวนเธรดของคุณระเบิด การดำเนินการที่ "ตอบสนอง" มากขึ้นจะดีกว่าใน fork-join-executor

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.