เหตุใดการสร้างเธรดจึงมีราคาแพง


180

บทเรียน Java กล่าวว่าการสร้างกระทู้มีราคาแพง แต่ทำไมมันแพง จะเกิดอะไรขึ้นเมื่อสร้าง Java Thread ขึ้นมาซึ่งทำให้การสร้างนั้นแพง ฉันรับปากว่าเป็นความจริง แต่ฉันแค่สนใจกลไกการสร้างเธรดใน JVM

เธรดค่าใช้จ่ายตลอดวงจร การสร้างเธรดและการฉีกขาดไม่ฟรี ค่าใช้จ่ายจริงจะแตกต่างกันไปตามแต่ละแพลตฟอร์ม แต่การสร้างเธรดต้องใช้เวลาแนะนำเวลาแฝงในการประมวลผลคำขอและต้องการกิจกรรมการประมวลผลบางอย่างโดย JVM และ OS หากคำขอมีบ่อยและมีน้ำหนักเบาเช่นเดียวกับในแอพพลิเคชันเซิร์ฟเวอร์ส่วนใหญ่การสร้างเธรดใหม่สำหรับแต่ละคำร้องขอสามารถใช้ทรัพยากรการประมวลผลที่สำคัญ

จากJava Concurrency ในทางปฏิบัติ
โดย Brian Goetz, Tim Peierls, Joshua Bloch, Joseph Bowbeer, David Holmes, David Holmes, Doug Lea
พิมพ์ ISBN-10: 0-321-34960-1


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

9
@typoknig - แพงกว่าเมื่อไม่ได้สร้างกระทู้ใหม่ :)
willcodejavaforfood


1
threadpools สำหรับการชนะ ไม่จำเป็นต้องสร้างหัวข้อใหม่สำหรับงานเสมอ
Alexander Mills

คำตอบ:


149

การสร้างเธรด Java มีราคาแพงเนื่องจากมีงานที่เกี่ยวข้อง:

  • หน่วยความจำขนาดใหญ่ต้องถูกจัดสรรและกำหนดค่าเริ่มต้นสำหรับสแตกเธรด
  • จำเป็นต้องทำการเรียกระบบเพื่อสร้าง / ลงทะเบียนเธรดดั้งเดิมกับโฮสต์ระบบปฏิบัติการ
  • ตัวอธิบายจำเป็นต้องสร้างเริ่มต้นและเพิ่มลงในโครงสร้างข้อมูล JVM- ภายใน

นอกจากนี้ยังมีราคาแพงในแง่ที่ว่าเธรดผูกทรัพยากรตราบเท่าที่ยังมีชีวิตอยู่; เช่นเธรดสแต็กวัตถุใด ๆ ที่สามารถเข้าถึงได้จากสแต็กตัวอธิบายเธรด JVM ตัวบ่งชี้เธรด OS ดั้งเดิม

ค่าใช้จ่ายของทุกสิ่งเหล่านี้เป็นแพลตฟอร์มเฉพาะ แต่ไม่ถูกในแพลตฟอร์ม Java ใด ๆ ที่ฉันเคยเจอ


การค้นหาของ Google พบว่าฉันเป็นเกณฑ์มาตรฐานเก่าที่รายงานอัตราการสร้างเธรดที่ ~ 4000 ต่อวินาทีใน Sun Java 1.4.1 บนโปรเซสเซอร์วินเทจดูอัล Xeon ปี 2002 ใช้ Xeon วินเทจ 2002 แพลตฟอร์มที่ทันสมัยกว่านี้จะให้ตัวเลขที่ดีกว่า ... และฉันไม่สามารถให้ความเห็นเกี่ยวกับวิธีการ ... แต่อย่างน้อยมันก็ให้ ballpark สำหรับการสร้างเธรดราคาแพงที่น่าจะเป็น

การเปรียบเทียบของปีเตอร์ลอว์เรย์ระบุว่าการสร้างเธรดนั้นเร็วขึ้นอย่างมากในวันนี้ในแง่ที่แน่นอน แต่ก็ไม่มีความชัดเจนว่านี่เป็นการปรับปรุงใน Java และ / หรือ OS ... หรือความเร็วโปรเซสเซอร์ที่สูงขึ้น แต่ตัวเลขของเขายังคงบ่งบอกถึงการปรับปรุงแบบพับมากกว่า 150+ ถ้าคุณใช้กลุ่มเธรดกับการสร้าง / เริ่มเธรดใหม่ทุกครั้ง (และเขาชี้ให้เห็นว่านี่คือญาติทั้งหมด ... )


(ข้างต้นถือว่าเป็น "เธรดดั้งเดิม" แทนที่จะเป็น "เธรดสีเขียว" แต่ JVM สมัยใหม่ทั้งหมดใช้เธรดดั้งเดิมเพื่อเหตุผลด้านประสิทธิภาพเธรดสีเขียวอาจถูกกว่าในการสร้าง แต่คุณชำระในพื้นที่อื่น ๆ )


ฉันได้ทำการขุดเล็กน้อยเพื่อดูว่าสแต็กของเธรด Java ได้รับการจัดสรรอย่างไร ในกรณีของ OpenJDK 6 บน Linux เธรดสแต็กจะถูกจัดสรรโดยการเรียกไปpthread_createที่สร้างเธรดดั้งเดิม (JVM ไม่ผ่านpthread_createการจัดสรรล่วงหน้า)

จากนั้นภายในpthread_createสแต็กจะถูกจัดสรรโดยการเรียกmmapดังนี้:

mmap(0, attr.__stacksize, 
     PROT_READ|PROT_WRITE|PROT_EXEC, 
     MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)

ตามman mmapการMAP_ANONYMOUSตั้งค่าสถานะทำให้หน่วยความจำถูกเตรียมใช้งานเป็นศูนย์

ดังนั้นแม้ว่าอาจไม่จำเป็นที่สแต็กเธรด Java ใหม่จะเป็นศูนย์ (ตามข้อกำหนด JVM) ในทางปฏิบัติ (อย่างน้อยกับ OpenJDK 6 บน Linux) พวกเขาจะถูกทำให้เป็นศูนย์


2
@ Raedwald - เป็นส่วนเริ่มต้นที่มีราคาแพง บางสิ่งบางอย่าง (เช่น GC หรือระบบปฏิบัติการ) จะเป็นศูนย์ไบต์ก่อนที่บล็อกจะถูกเปลี่ยนเป็นเธรดสแต็ก ที่ใช้รอบหน่วยความจำกายภาพในฮาร์ดแวร์ทั่วไป
สตีเฟ่นซี

2
"บางสิ่งบางอย่าง (เช่น GC หรือ OS) จะเป็นศูนย์ไบต์" มันจะ? ระบบปฏิบัติการจะดำเนินการหากต้องการการจัดสรรเพจหน่วยความจำใหม่เพื่อเหตุผลด้านความปลอดภัย แต่นั่นจะผิดปกติ และระบบปฏิบัติการอาจเก็บแคชของหน้าศูนย์เป็นศูนย์อยู่แล้ว (IIRC, Linux ทำเช่นนั้น) ทำไม GC ถึงต้องกังวลเนื่องจาก JVM จะป้องกันไม่ให้โปรแกรม Java อ่านเนื้อหาของมัน โปรดทราบว่าmalloc()ฟังก์ชั่นC มาตรฐานซึ่ง JVM อาจใช้งานได้ดีไม่รับประกันว่าหน่วยความจำที่จัดสรรไว้จะไม่มีค่าศูนย์
Raedwald

1
stackoverflow.com/questions/2117072/…เห็นด้วยว่า "หนึ่งในปัจจัยสำคัญคือหน่วยความจำสแต็คที่จัดสรรให้กับแต่ละเธรด"
Raedwald

2
@Raedwald - ดูคำตอบที่ปรับปรุงแล้วสำหรับข้อมูลเกี่ยวกับการจัดสรรสแต็คจริง ๆ
สตีเฟ่นซี

2
เป็นไปได้ (อาจเกิดขึ้นได้) ว่าหน้าหน่วยความจำที่จัดสรรโดยการmmap()เรียกใช้การคัดลอกเมื่อเขียนถูกแมปไปที่หน้าศูนย์ดังนั้นการเริ่มต้นของพวกเขาจะไม่เกิดขึ้นภายในmmap()ตัวเอง แต่เมื่อหน้าแรกถูกเขียนไปแล้ว เวลา. นั่นคือเมื่อเธรดเริ่มดำเนินการโดยค่าใช้จ่ายของเธรดที่สร้างขึ้นแทนที่จะสร้างเธรด
Raedwald

76

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

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

อีกทางเลือกหนึ่งคือการใช้เธรดพูล เธรดพูลสามารถมีประสิทธิภาพมากขึ้นด้วยเหตุผลสองประการ 1) มันนำเธรดที่สร้างไปแล้วกลับมาใช้ใหม่ 2) คุณสามารถปรับ / ควบคุมจำนวนเธรดเพื่อให้แน่ใจว่าคุณมีประสิทธิภาพสูงสุด

โปรแกรมต่อไปนี้พิมพ์ ...

Time for a task to complete in a new Thread 71.3 us
Time for a task to complete in a thread pool 0.39 us
Time for a task to complete in the same thread 0.08 us
Time for a task to complete in a new Thread 65.4 us
Time for a task to complete in a thread pool 0.37 us
Time for a task to complete in the same thread 0.08 us
Time for a task to complete in a new Thread 61.4 us
Time for a task to complete in a thread pool 0.38 us
Time for a task to complete in the same thread 0.08 us

นี่คือการทดสอบสำหรับงานเล็ก ๆ น้อย ๆ ที่แสดงถึงค่าใช้จ่ายของแต่ละตัวเลือกเธรด (งานทดสอบนี้เป็นการเรียงลำดับของงานที่ทำได้ดีที่สุดในเธรดปัจจุบัน)

final BlockingQueue<Integer> queue = new LinkedBlockingQueue<Integer>();
Runnable task = new Runnable() {
    @Override
    public void run() {
        queue.add(1);
    }
};

for (int t = 0; t < 3; t++) {
    {
        long start = System.nanoTime();
        int runs = 20000;
        for (int i = 0; i < runs; i++)
            new Thread(task).start();
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in a new Thread %.1f us%n", time / runs / 1000.0);
    }
    {
        int threads = Runtime.getRuntime().availableProcessors();
        ExecutorService es = Executors.newFixedThreadPool(threads);
        long start = System.nanoTime();
        int runs = 200000;
        for (int i = 0; i < runs; i++)
            es.execute(task);
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in a thread pool %.2f us%n", time / runs / 1000.0);
        es.shutdown();
    }
    {
        long start = System.nanoTime();
        int runs = 200000;
        for (int i = 0; i < runs; i++)
            task.run();
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in the same thread %.2f us%n", time / runs / 1000.0);
    }
}
}

อย่างที่คุณเห็นการสร้างเธรดใหม่มีค่าใช้จ่ายเพียง 70 70s เรื่องนี้ถือได้ว่าเป็นเรื่องเล็กน้อยในหลาย ๆ กรณีถ้าไม่ใช่ส่วนใหญ่ใช้กรณี การพูดค่อนข้างจะแพงกว่าทางเลือกและในบางสถานการณ์เธรดพูลหรือไม่ใช้เธรดเลยเป็นวิธีที่ดีกว่า


8
นั่นเป็นโค้ดที่ยอดเยี่ยม กระชับตรงประเด็นและแสดงความชัดเจนออกมาอย่างชัดเจน
นิโคลัส

ในบล็อกสุดท้ายฉันเชื่อว่าผลลัพธ์นั้นเบ้เพราะในสองบล็อกแรกเธรดหลักจะถูกลบแบบขนานเมื่อเธรดของผู้ปฏิบัติงานวางอยู่ อย่างไรก็ตามในบล็อกสุดท้ายการกระทำของการดำเนินการทั้งหมดดำเนินการตามลำดับจึงเป็นการขยายมูลค่า คุณอาจใช้ queue.clear () และใช้ CountDownLatch แทนเพื่อรอเธรดให้เสร็จสมบูรณ์
Victor Grazi

@VictorGrazi ฉันถือว่าคุณต้องการรวบรวมผลลัพธ์จากส่วนกลาง มันกำลังทำงานคิวในปริมาณเท่ากันในแต่ละกรณี สลักนับถอยหลังจะเร็วขึ้นเล็กน้อย
Peter Lawrey

ที่จริงแล้วทำไมไม่เพียงให้มันทำอะไรบางอย่างที่รวดเร็วอย่างต่อเนื่องเช่นการเพิ่มตัวนับ ปล่อยสิ่ง BlockingQueue ทั้งหมด ตรวจสอบเคาน์เตอร์ในตอนท้ายเพื่อป้องกันไม่ให้คอมไพเลอร์เพิ่มประสิทธิภาพการดำเนินการที่เพิ่มขึ้น
Victor Grazi

@ grazi คุณสามารถทำเช่นนั้นได้ในกรณีนี้ แต่คุณจะไม่ได้ในกรณีที่สมจริงที่สุดเพราะการรอเคาน์เตอร์อาจไม่มีประสิทธิภาพ หากคุณทำอย่างนั้นความแตกต่างระหว่างตัวอย่างจะยิ่งใหญ่กว่า
Peter Lawrey

31

ในทางทฤษฎีสิ่งนี้ขึ้นอยู่กับ JVM ในทางปฏิบัติทุกเธรดมีหน่วยความจำสแต็คค่อนข้างมาก (256 KB ต่อค่าเริ่มต้นฉันคิดว่า) นอกจากนี้ยังมีการใช้เธรดเป็นเธรด OS ดังนั้นการสร้างเธรดจึงเกี่ยวข้องกับการเรียกใช้ OS เช่นการสลับบริบท

จงตระหนักว่า "ราคาแพง" ในการคำนวณนั้นสัมพันธ์กันเสมอ การสร้างเธรดมีราคาแพงมากเมื่อเทียบกับการสร้างวัตถุส่วนใหญ่ แต่ไม่แพงมากเมื่อเทียบกับการค้นหาฮาร์ดดิสก์แบบสุ่ม คุณไม่จำเป็นต้องหลีกเลี่ยงการสร้างเธรด แต่อย่างใด แต่การสร้างหลายร้อยเธรดต่อวินาทีไม่ใช่การย้ายที่ชาญฉลาด ในกรณีส่วนใหญ่หากการออกแบบของคุณเรียกใช้เธรดจำนวนมากคุณควรใช้กลุ่มเธรดที่มีขนาด จำกัด


9
Btw kb = กิโลไบต์บิต, kB = กิโลไบต์ไบต์ Gb = giga บิต, GB = giga ไบต์
Peter Lawrey

@PeterLawrey เราใช้ 'k' ใน 'kb' และ 'kB' เพื่อให้สมมาตรกับ 'Gb' และ 'GB' หรือไม่ สิ่งเหล่านี้ทำให้ฉันรำคาญ
แจ็ค

3
@ Jack มีK= 1024 และk= 1,000;) en.wikipedia.org/wiki/Kibibyte
Peter Lawrey

9

กระทู้มีสองชนิด:

  1. เธรดที่เหมาะสม : สิ่งเหล่านี้เป็น abstractions รอบสิ่งอำนวยความสะดวกการเธรดของระบบปฏิบัติการ การสร้างเธรดจึงมีราคาแพงเท่ากับระบบ - มีค่าใช้จ่ายอยู่เสมอ

  2. เธรด "สีเขียว" : สร้างและกำหนดเวลาโดย JVM สิ่งเหล่านี้ราคาถูกกว่า แต่ไม่มีการอัมพาตที่เหมาะสมเกิดขึ้น สิ่งเหล่านี้จะมีลักษณะคล้ายกับเธรด แต่จะดำเนินการภายในเธรด JVM ในระบบปฏิบัติการ พวกเขามักจะไม่ใช้ความรู้ของฉัน

ปัจจัยที่ใหญ่ที่สุดที่ฉันสามารถนึกได้ในค่าใช้จ่ายในการสร้างเธรดคือขนาดสแต็กที่คุณกำหนดไว้สำหรับเธรดของคุณ Thread stack-size สามารถส่งผ่านเป็นพารามิเตอร์เมื่อรัน VM

นอกเหนือจากนั้นการสร้างเธรดส่วนใหญ่ขึ้นอยู่กับระบบปฏิบัติการและขึ้นอยู่กับการติดตั้งใช้งาน VM

ตอนนี้ฉันขอชี้ให้เห็นบางอย่าง: การสร้างเธรดมีราคาแพงถ้าคุณวางแผนที่จะยิง2000 เธรดต่อวินาทีทุกวินาทีของรันไทม์ JVM ที่ไม่ได้ออกแบบมาเพื่อจับว่า หากคุณมีพนักงานมั่นคงสองคนที่จะไม่ถูกไล่ออกและถูกฆ่าตายให้ผ่อนคลาย


19
"... คนงานที่มั่นคงสองคนที่จะไม่ถูกไล่ออกและถูกฆ่าตาย ... "ทำไมฉันถึงเริ่มคิดถึงสภาพการทำงาน :-)
Stephen C

6

การสร้างThreadsต้องการการจัดสรรหน่วยความจำในปริมาณที่พอเหมาะเนื่องจากต้องสร้างหน่วยความจำไม่มากพอ แต่มีหน่วยความจำใหม่สองหน่วย (หน่วยสำหรับโค้ด java หนึ่งหน่วยสำหรับโค้ดเนทีฟ) ใช้Executors / สระว่ายน้ำของกระทู้สามารถหลีกเลี่ยงค่าใช้จ่ายโดยการนำหัวข้อสำหรับงานหลายรายการสำหรับผู้ปฏิบัติการ


@ Raedwald jvm ที่ใช้กองซ้อนแยกกันคืออะไร
bestsss

1
Philip JP พูดว่า 2 กอง
Raedwald

เท่าที่ฉันรู้ JVM ทั้งหมดจัดสรรสองสแต็คต่อเธรด มันจะมีประโยชน์สำหรับคอลเลกชันขยะในการรักษารหัส Java (แม้ว่า JITed) แตกต่างจากการหล่อฟรี
Philip JF

@Philip JF คุณช่วยอธิบายเพิ่มเติมได้ไหม? คุณหมายถึงอะไรโดย 2 กองหนึ่งสำหรับรหัส Java และหนึ่งสำหรับรหัสพื้นเมือง? มันทำอะไร?
Gurinder

"เท่าที่ฉันรู้ JVM ทั้งหมดจัดสรรสองกองต่อหนึ่งเธรด" - ฉันไม่เคยเห็นหลักฐานใด ๆ ที่จะสนับสนุนสิ่งนี้ บางทีคุณอาจเข้าใจผิดธรรมชาติที่แท้จริงของ opstack ในสเป็ค JVM (เป็นวิธีการสร้างแบบจำลองพฤติกรรมของ bytecodes ไม่ใช่สิ่งที่จำเป็นต้องใช้ในการรันไทม์เพื่อดำเนินการ)
Stephen C

1

เห็นได้ชัดว่าปมคำถามคือสิ่งที่ 'แพง' หมายถึงอะไร

เธรดจำเป็นต้องสร้างสแต็กและเริ่มต้นสแต็กตามวิธีการเรียกใช้

จำเป็นต้องตั้งค่าโครงสร้างสถานะการควบคุมเช่นสถานะที่อยู่ในสถานะรันได้รอ ฯลฯ

อาจมีการประสานที่ดีเกี่ยวกับการตั้งค่าสิ่งเหล่านี้

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