async (launch :: async) ใน C ++ 11 ทำให้เธรดพูลล้าสมัยเนื่องจากหลีกเลี่ยงการสร้างเธรดที่มีราคาแพงหรือไม่


117

มันเกี่ยวข้องกับคำถามนี้อย่างหลวม ๆ : std :: thread รวมอยู่ใน C ++ 11 หรือไม่? . แม้ว่าคำถามจะแตกต่างกัน แต่ความตั้งใจก็เหมือนกัน:

คำถามที่ 1: การใช้เธรดพูลของคุณเอง (หรือไลบรารีของบุคคลที่สาม) เพื่อหลีกเลี่ยงการสร้างเธรดที่มีราคาแพงหรือไม่

ข้อสรุปในคำถามอื่นคือคุณไม่สามารถพึ่งพาstd::threadการรวมกลุ่มกันได้ (อาจเป็นหรือไม่ก็ได้) อย่างไรก็ตามstd::async(launch::async)ดูเหมือนว่าจะมีโอกาสสูงกว่าที่จะถูกรวมกลุ่ม

ไม่คิดว่าจะถูกบังคับโดยมาตรฐาน แต่ IMHO ฉันคาดหวังว่าการใช้งาน C ++ 11 ที่ดีทั้งหมดจะใช้การรวมเธรดหากการสร้างเธรดช้า เฉพาะบนแพลตฟอร์มที่มีราคาไม่แพงในการสร้างเธรดใหม่ฉันคาดหวังว่าพวกเขาจะสร้างเธรดใหม่เสมอ

คำถาม 2: นี่เป็นเพียงสิ่งที่ฉันคิด แต่ฉันไม่มีข้อเท็จจริงที่จะพิสูจน์ได้ ฉันอาจจะเข้าใจผิด เป็นการคาดเดาที่มีการศึกษาหรือไม่?

สุดท้ายนี้ฉันได้ให้โค้ดตัวอย่างที่แสดงให้เห็นก่อนว่าฉันคิดว่าการสร้างเธรดสามารถแสดงได้อย่างไรasync(launch::async):

ตัวอย่างที่ 1:

 thread t([]{ f(); });
 // ...
 t.join();

กลายเป็น

 auto future = async(launch::async, []{ f(); });
 // ...
 future.wait();

ตัวอย่างที่ 2: ไฟและลืมเธรด

 thread([]{ f(); }).detach();

กลายเป็น

 // a bit clumsy...
 auto dummy = async(launch::async, []{ f(); });

 // ... but I hope soon it can be simplified to
 async(launch::async, []{ f(); });

คำถามที่ 3: คุณต้องการasyncเวอร์ชันมากกว่าthreadเวอร์ชันหรือไม่?


ส่วนที่เหลือไม่ได้เป็นส่วนหนึ่งของคำถามอีกต่อไป แต่เพื่อการชี้แจงเท่านั้น:

เหตุใดจึงต้องกำหนดค่าตอบแทนให้กับตัวแปรดัมมี่

น่าเสียดายที่กองกำลังมาตรฐาน C ++ 11 ปัจจุบันที่คุณจับค่าส่งคืนstd::asyncไม่เช่นนั้นตัวทำลายจะถูกดำเนินการซึ่งบล็อกจนกว่าการดำเนินการจะสิ้นสุดลง บางคนถือว่าเป็นข้อผิดพลาดในมาตรฐาน (เช่นโดย Herb Sutter)

ตัวอย่างนี้จากcppreference.comแสดงให้เห็นอย่างสวยงาม:

{
  std::async(std::launch::async, []{ f(); });
  std::async(std::launch::async, []{ g(); });  // does not run until f() completes
}

คำชี้แจงอื่น ๆ :

ฉันรู้ว่าสระว่ายน้ำด้ายอาจจะมีการใช้งานถูกต้องตามกฎหมายอื่น ๆ แต่ในคำถามนี้ผมสนใจเฉพาะในด้านของการหลีกเลี่ยงค่าใช้จ่ายราคาแพงการสร้างหัวข้อ

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

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

  • การสร้างเธรดใหม่โดยstd::threadเริ่มต้นโดยไม่มีตัวแปรเธรดโลคัลเริ่มต้น บางทีนี่อาจไม่ใช่สิ่งที่คุณต้องการ
  • ในเธรดที่เกิดโดยasyncฉันค่อนข้างไม่ชัดเจนเพราะเธรดอาจถูกนำกลับมาใช้ใหม่ได้ จากความเข้าใจของฉันไม่รับประกันว่าตัวแปร thread-local จะถูกรีเซ็ต แต่ฉันอาจเข้าใจผิด
  • ในทางกลับกันการใช้เธรดพูล (ขนาดคงที่) ของคุณเองจะช่วยให้คุณควบคุมได้เต็มที่หากคุณต้องการจริงๆ

8
"อย่างไรก็ตามstd::async(launch::async)ดูเหมือนว่าจะมีโอกาสสูงกว่าที่จะถูกรวมกลุ่ม" ไม่ฉันเชื่อว่ามันstd::async(launch::async | launch::deferred)อาจจะถูกรวมเข้าด้วยกัน ด้วยlaunch::asyncงานที่ควรจะเปิดใช้งานบนเธรดใหม่โดยไม่คำนึงถึงงานอื่น ๆ ที่กำลังทำงานอยู่ เมื่อใช้นโยบายlaunch::async | launch::deferredแล้วการดำเนินการจะต้องเลือกนโยบาย แต่ที่สำคัญกว่านั้นคือต้องชะลอการเลือกนโยบาย นั่นคือสามารถรอจนกว่าเธรดในเธรดพูลจะพร้อมใช้งานจากนั้นจึงเลือกนโยบาย async
bames53

2
เท่าที่ฉันรู้มีเพียง VC ++ เท่านั้นที่ใช้เธรดพูลกับstd::async(). ฉันยังอยากรู้ว่าพวกเขาสนับสนุนตัวทำลาย thread_local ที่ไม่สำคัญในกลุ่มเธรดได้อย่างไร
bames53

2
@ bames53 ฉันก้าวผ่าน libstdc ++ ที่มาพร้อมกับ gcc 4.7.2 และพบว่าหากนโยบายการเปิดตัวไม่ตรงตาม launch::asyncนั้นก็จะถือว่ามันเป็นเพียงอย่างเดียวlaunch::deferredและไม่ดำเนินการแบบอะซิงโครนัส - ดังนั้น libstdc ++ เวอร์ชันนั้นจึง "เลือก" เพื่อใช้การรอการตัดบัญชีเสมอเว้นแต่จะถูกบังคับ
doug65536

3
@ doug65536 ประเด็นของฉันเกี่ยวกับตัวทำลาย thread_local คือการทำลายเธรดออกไม่ถูกต้องนักเมื่อใช้เธรดพูล เมื่องานถูกเรียกใช้แบบอะซิงโครนัสจะรัน 'ราวกับอยู่บนเธรดใหม่' ตามข้อมูลจำเพาะซึ่งหมายความว่างาน async ทุกงานจะได้รับวัตถุ thread_local ของตัวเอง การใช้งานโดยใช้เธรดพูลจะต้องใช้ความระมัดระวังเป็นพิเศษเพื่อให้แน่ใจว่างานที่แชร์เธรดสำรองเดียวกันยังคงทำงานราวกับว่ามีอ็อบเจ็กต์ thread_local ของตนเอง พิจารณาโปรแกรมนี้: pastebin.com/9nWUT40h
bames53

2
@ bames53 การใช้ "ราวกับว่าเป็นเธรดใหม่" ในข้อมูลจำเพาะเป็นข้อผิดพลาดอย่างมากในความคิดของฉัน std::asyncอาจเป็นสิ่งที่สวยงามสำหรับประสิทธิภาพ - อาจเป็นระบบการรันงานสั้น ๆ มาตรฐานซึ่งได้รับการสนับสนุนโดยเธรดพูล ตอนนี้มันเป็นเพียงแค่std::threadมีอึบางอย่างที่ติดอยู่เพื่อให้ฟังก์ชันเธรดสามารถคืนค่าได้ โอ้และพวกเขาได้เพิ่มฟังก์ชัน "รอการตัดบัญชี" ที่ซ้ำซ้อนซึ่งทับซ้อนกับงานโดยstd::functionสิ้นเชิง
doug65536

คำตอบ:


55

คำถามที่ 1 :

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

เห็นได้ชัดว่าไลบรารี C ++ มาตรฐานที่มาพร้อมกับ g ++ ไม่มีเธรดพูล แต่ฉันสามารถเห็นกรณีของพวกเขาได้อย่างแน่นอน แม้ว่าจะมีค่าใช้จ่ายในการโทรผ่านคิวระหว่างเธรดบางประเภท แต่ก็น่าจะถูกกว่าการเริ่มต้นเธรดใหม่ และมาตรฐานช่วยให้สิ่งนี้

IMHO ผู้ใช้เคอร์เนล Linux ควรทำงานเพื่อให้การสร้างเธรดถูกกว่าที่เป็นอยู่ในปัจจุบัน แต่ไลบรารี C ++ มาตรฐานควรพิจารณาใช้พูลเพื่อนำไปใช้launch::async | launch::deferredด้วย

และ OP ถูกต้องการใช้::std::threadเพื่อเปิดเธรดแน่นอนบังคับให้สร้างเธรดใหม่แทนที่จะใช้เธรดจากพูล ดังนั้นจึง::std::async(::std::launch::async, ...)เป็นที่ต้องการ

คำถาม 2 :

ใช่โดยทั่วไปแล้ว 'โดยนัย' นี้จะเปิดเธรด แต่จริงๆแล้วก็ยังค่อนข้างชัดเจนว่าเกิดอะไรขึ้น ดังนั้นฉันจึงไม่คิดว่าคำนี้โดยปริยายเป็นคำที่ดีเป็นพิเศษ

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

คำถาม 3 :

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

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

แต่จริงๆแล้วมันขึ้นอยู่กับว่าคุณกำลังทำอะไรอยู่

การทดสอบประสิทธิภาพ

ดังนั้นฉันจึงทดสอบประสิทธิภาพของวิธีการต่างๆในการโทรและหาตัวเลขเหล่านี้บนระบบ 8 คอร์ (AMD Ryzen 7 2700X) ที่รัน Fedora 29 ที่คอมไพล์ด้วยเสียงดังเวอร์ชัน 7.0.1 และ libc ++ (ไม่ใช่ libstdc ++):

   Do nothing calls per second:   35365257                                      
        Empty calls per second:   35210682                                      
   New thread calls per second:      62356                                      
 Async launch calls per second:      68869                                      
Worker thread calls per second:     970415                                      

และเนทีฟบน MacBook Pro 15 "(Intel (R) Core (TM) i7-7820HQ CPU @ 2.90GHz) ที่ใช้Apple LLVM version 10.0.0 (clang-1000.10.44.4)OSX 10.13.6 จะได้รับสิ่งนี้:

   Do nothing calls per second:   22078079
        Empty calls per second:   21847547
   New thread calls per second:      43326
 Async launch calls per second:      58684
Worker thread calls per second:    2053775

สำหรับเธรดผู้ปฏิบัติงานฉันเริ่มต้นเธรดจากนั้นใช้คิวแบบไม่ล็อกเพื่อส่งคำขอไปยังเธรดอื่นจากนั้นรอการตอบกลับ "เสร็จสิ้น" เพื่อส่งกลับ

"ไม่ต้องทำอะไรเลย" เป็นเพียงการทดสอบเหนือศีรษะของสายรัดทดสอบ

เป็นที่ชัดเจนว่าค่าใช้จ่ายในการเปิดเธรดนั้นมหาศาลมาก และแม้แต่เธรดผู้ปฏิบัติงานที่มีคิวระหว่างเธรดจะทำให้สิ่งต่าง ๆ ช้าลงโดยปัจจัย 20 หรือมากกว่านั้นบน Fedora 25 ใน VM และประมาณ 8 บน OS X ดั้งเดิม

ฉันสร้างโครงการ Bitbucket โดยถือรหัสที่ฉันใช้สำหรับการทดสอบประสิทธิภาพ สามารถพบได้ที่นี่: https://bitbucket.org/omnifarious/launch_thread_performance


3
ฉันเห็นด้วยกับโมเดลคิวงาน แต่ต้องมีโมเดล "ไปป์ไลน์" ซึ่งอาจใช้ไม่ได้กับการใช้งานการเข้าถึงพร้อมกันทุกครั้ง
Matthieu M.

1
สำหรับฉันดูเหมือนว่าเทมเพลตนิพจน์ (สำหรับตัวดำเนินการ) สามารถใช้เพื่อสร้างผลลัพธ์ได้สำหรับการเรียกใช้ฟังก์ชันคุณจะต้องใช้วิธีการโทรที่ฉันเดา แต่เนื่องจากการโอเวอร์โหลดอาจจะยากกว่าเล็กน้อย
Matthieu M.

3
"ถูกมาก" สัมพันธ์กับประสบการณ์ของคุณ ฉันพบว่าค่าใช้จ่ายในการสร้างเธรดของ Linux มีความสำคัญสำหรับการใช้งานของฉัน
เจฟฟ์

1
@ เจฟฟ์ - ฉันคิดว่ามันถูกกว่าที่เป็นอยู่มาก ฉันอัปเดตคำตอบเมื่อสักครู่ที่ผ่านมาเพื่อให้สอดคล้องกับการทดสอบที่ฉันทำเพื่อค้นหาต้นทุนจริง
Omnifarious

4
ในส่วนแรกคุณค่อนข้างประเมินว่าต้องทำมากน้อยเพียงใดในการสร้างภัยคุกคามและต้องทำเพียงเล็กน้อยเพื่อเรียกใช้ฟังก์ชัน การเรียกใช้ฟังก์ชันและการส่งคืนคือคำสั่ง CPU สองสามคำสั่งที่จัดการกับสองสามไบต์ที่ด้านบนของสแตก การสร้างภัยคุกคามหมายถึง: 1. จัดสรรสแต็ก, 2. ดำเนินการ syscall, 3. สร้างโครงสร้างข้อมูลในเคอร์เนลและเชื่อมโยงเข้าด้วยกัน, จับล็อกระหว่างทาง, 4. รอให้ตัวกำหนดตารางเวลาดำเนินการเธรด, 5. การสลับ บริบทของเธรด แต่ละขั้นตอนเหล่านี้ในตัวเองจะใช้เวลามากนานกว่าที่ซับซ้อนมากที่สุดเรียกฟังก์ชัน
cmaster - คืนสถานะ monica
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.