อัพเดตและแสดงผลในเธรดแยกกัน


12

ฉันกำลังสร้างเอ็นจิ้นเกม 2D ง่าย ๆ และฉันต้องการอัปเดตและแสดงสไปรต์ในชุดข้อความต่างๆเพื่อเรียนรู้วิธีการทำงาน

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

Thread 1 -------------------------- Thread 2
Update obj ------------------------ wait for swap
Create queue ---------------------- render the queue
Wait for render ------------------- notify render done
Swap render queues ---------------- notify swap done

ในการตั้งค่านี้ฉัน จำกัด FPS ของเธรดการแสดงผลเป็น FPS ของเธรดการปรับปรุง นอกจากนี้ฉันใช้sleep()เพื่อ จำกัด ทั้งเรนเดอร์และอัปเดต FPS ของเธรดเป็น 60 ดังนั้นฟังก์ชั่นรอทั้งสองจะไม่รอเวลามาก

ปัญหาคือ:

การใช้งาน CPU โดยเฉลี่ยอยู่ที่ประมาณ 0.1% บางครั้งมันสูงถึง 25% (ในพีซีแบบ quad core) หมายความว่าเธรดกำลังรอเธรดอื่นเนื่องจากฟังก์ชัน wait เป็น while loop พร้อมกับการทดสอบและการตั้งค่าการทำงานและ a while loop จะใช้ทรัพยากร CPU ทั้งหมดของคุณ

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

คำถามอื่น ๆ คือ; เนื่องจากโปรแกรมรันที่ 60 FPS ทำไมบางครั้งการใช้งาน CPU ถึง 25% หมายความว่าหนึ่งในสองการรอนั้นรออยู่มาก (ทั้งสองเธรดนั้น จำกัด ไว้ที่ 60fps ดังนั้นจึงไม่จำเป็นต้องทำการซิงโครไนซ์มากนัก)

แก้ไข: ขอบคุณสำหรับคำตอบทั้งหมด ก่อนอื่นฉันอยากจะบอกว่าฉันไม่ได้เริ่มหัวข้อใหม่ในแต่ละเฟรมเพื่อเรนเดอร์ ฉันเริ่มต้นทั้งการอัพเดตและเรนเดอร์ลูปที่จุดเริ่มต้น ฉันคิดว่าการมัลติเธรดสามารถประหยัดเวลา: ฉันมีฟังก์ชั่นต่อไปนี้: FastAlg () และ Alg () Alg () เป็นทั้ง obj Update ของฉันและ render obj และ Fastalg () คือ "คิวการแสดงผลส่งไปยัง" renderer "ของฉัน ในหัวข้อเดียว:

Alg() //update 
FastAgl() 
Alg() //render

ในสองหัวข้อ:

Alg() //update  while Alg() //render last frame
FastAlg() 

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

ฉันรู้ว่าการนอนไม่ได้เป็นความคิดที่ดี แต่ฉันไม่เคยมีปัญหา จะดีกว่านี้ไหม

While(true) 
{
   If(timer.gettimefromlastcall() >= 1/fps)
   Do_update()
}

แต่นี่จะไม่มีที่สิ้นสุดในขณะที่วนรอบที่จะใช้ CPU ทั้งหมด ฉันสามารถใช้โหมดสลีป (หมายเลข <15) เพื่อ จำกัด การใช้งานได้หรือไม่ ด้วยวิธีนี้มันจะทำงานที่ 100 fps และฟังก์ชั่นอัพเดทจะถูกเรียกเพียง 60 ครั้งต่อวินาที

ในการซิงโครไนซ์เธรดทั้งสองฉันจะใช้ waitforsingleobject กับ createSemaphore ดังนั้นฉันจะสามารถล็อคและปลดล็อกในเธรดที่แตกต่างกันได้ (whitout โดยใช้ลูป while) ใช่ไหม


5
"อย่าบอกมัลติเธรดของฉันไร้ประโยชน์ในกรณีนี้ฉันแค่ต้องการเรียนรู้วิธีการทำ" - ในกรณีนี้คุณควรเรียนรู้สิ่งต่าง ๆ อย่างถูกต้องนั่นคือ (a) อย่าใช้ sleep () เพื่อควบคุมเฟรมที่หายาก , ไม่เคย , และ (ข) หลีกเลี่ยงหัวข้อต่อองค์ประกอบการออกแบบและการหลีกเลี่ยงการทำงาน lockstep แทนที่จะแยกการทำงานในการทำงานและงานจับจากคิวงาน
Damon

1
@Damon (a) sleep () สามารถใช้เป็นกลไกอัตราเฟรมและในความเป็นจริงค่อนข้างเป็นที่นิยม แต่ฉันต้องยอมรับว่ามีตัวเลือกที่ดีกว่ามาก (b) ผู้ใช้ที่นี่ต้องการแยกทั้งการอัปเดตและการแสดงผลในสองเธรดที่แตกต่างกัน นี่คือการแยกแบบปกติในเอ็นจินเกมและไม่ใช่ "thread-per-component" มันให้ข้อดีชัดเจน แต่สามารถนำปัญหาถ้าทำไม่ถูกต้อง
Alexandre Desbiens

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

@Damon เมื่อฉันบอกว่ามันเป็นที่นิยมหรือทั่วไปฉันไม่ได้ตั้งใจจะพูดว่ามันถูกต้อง ฉันแค่หมายถึงมันถูกใช้โดยคนจำนวนมาก "... แม้ว่าฉันต้องยอมรับว่ามีตัวเลือกที่ดีกว่า" หมายความว่ามันไม่ใช่วิธีที่ดีในการซิงโครไนซ์เวลา ขอโทษที่เข้าใจผิด.
Alexandre Desbiens

@AlphSpirit: ไม่ต้องกังวล :-) โลกเต็มไปด้วยสิ่งที่หลายคนทำ (และไม่ได้มีเหตุผลที่ดีเสมอไป) แต่เมื่อคนเริ่มเรียนรู้เราก็ควรพยายามหลีกเลี่ยงสิ่งผิดที่เห็นได้ชัดที่สุด
Damon

คำตอบ:


25

สำหรับเครื่องยนต์ 2D ธรรมดาที่มีสไปรต์แนวทางแบบเธรดเดียวนั้นดีมาก แต่เนื่องจากคุณต้องการเรียนรู้วิธีทำมัลติเธรดคุณควรเรียนรู้ที่จะทำอย่างถูกต้อง

อย่า

  • ใช้ 2 เธรดที่รันขั้นตอนการล็อกมากหรือน้อยโดยใช้การทำงานแบบเธรดเดียวกับหลายเธรด สิ่งนี้มีความเท่าเทียมระดับเดียวกัน (ศูนย์) แต่เพิ่มค่าใช้จ่ายสำหรับสวิตช์บริบทและการซิงโครไนซ์ นอกจากนี้ตรรกะยากกว่าที่จะห้อมล้อม
  • ใช้sleepเพื่อควบคุมอัตราเฟรม ไม่เคย หากมีคนบอกคุณให้กดพวกเขา
    ครั้งแรกไม่ใช่จอภาพทั้งหมดที่ทำงานที่ 60Hz ประการที่สองตัวนับสองตัวที่ฟ้องในอัตราเดียวกันที่วิ่งเคียงข้างกันในที่สุดจะหลุดจากการซิงค์เสมอ (วางลูกปิงปองสองลูกลงบนโต๊ะจากความสูงเดียวกันและฟัง) ประการที่สามsleepคือโดยการออกแบบไม่แม่นยำและเชื่อถือได้ ความละเอียดอาจไม่ดีเท่า 15.6ms (อันที่จริงแล้วค่าเริ่มต้นใน Windows [1] ) และเฟรมมีเพียง 16.6ms ที่ 60fps ซึ่งเหลือเพียง 1 มิลลิวินาทีสำหรับทุกอย่างอื่น นอกจากนี้มันยากที่จะได้ 16.6 เป็นทวีคูณของ 15.6 ...
    นอกจากนี้ยังsleepอนุญาตให้ (และบางครั้งจะ!) กลับมาหลังจาก 30 หรือ 50 หรือ 100 มิลลิวินาทีหรือนานกว่านั้น
  • ใช้std::mutexเพื่อแจ้งเตือนเธรดอื่น นี่ไม่ใช่สิ่งที่มันมีไว้สำหรับ
  • สมมติว่า TaskManager นั้นดีในการบอกคุณว่าเกิดอะไรขึ้นโดยเฉพาะการตัดสินจากตัวเลขเช่น "25% CPU" ซึ่งอาจใช้ในรหัสของคุณหรือในไดรเวอร์ของ usermode หรือที่อื่น ๆ
  • มีหนึ่งเธรดต่อองค์ประกอบระดับสูง (แน่นอนว่ามีข้อยกเว้นบางประการ)
  • สร้างหัวข้อที่ "เวลาสุ่ม", เฉพาะกิจ, ต่องาน การสร้างเธรดอาจมีราคาแพงอย่างน่าประหลาดใจและอาจต้องใช้เวลานานอย่างน่าประหลาดใจก่อนที่พวกเขาจะทำสิ่งที่คุณบอกพวกเขาโดยเฉพาะ (โดยเฉพาะถ้าคุณโหลด DLLs จำนวนมาก!)

ทำ

  • ใช้มัลติเธรดเพื่อให้สิ่งต่าง ๆ ทำงานแบบอะซิงโครนัสเท่าที่คุณสามารถ ความเร็วไม่ใช่แนวคิดหลักในการทำเกลียว แต่การทำสิ่งต่าง ๆ ในแบบคู่ขนาน (ดังนั้นแม้ว่าจะใช้เวลารวมกันนานกว่า แต่ผลรวมของทั้งหมดยังน้อยกว่า)
  • ใช้การซิงค์แนวตั้งเพื่อ จำกัด อัตราเฟรม นั่นเป็นวิธีที่ถูกต้องเท่านั้น (และไม่ล้มเหลว) ที่จะทำ หากผู้ใช้แทนที่คุณในแผงควบคุมของโปรแกรมควบคุมจอแสดงผล ("บังคับ") ให้เป็นเช่นนั้น หลังจากทั้งหมดมันเป็นคอมพิวเตอร์ของเขาไม่ใช่ของคุณ
  • หากคุณต้อง "เห็บ" บางสิ่งบางอย่างในช่วงเวลาปกติใช้การจับเวลา จับเวลามีข้อได้เปรียบของการมีความถูกต้องที่ดีมากและความน่าเชื่อถือเมื่อเทียบกับ[2]sleep นอกจากนี้ตัวจับเวลาที่เกิดซ้ำจะทำตามเวลาอย่างถูกต้อง (รวมถึงเวลาที่ผ่านไป) ในขณะที่นอนหลับเป็นเวลา 16.6ms (หรือ 16.6ms ลบด้วยวัด _time_elapsed) ไม่ได้
  • เรียกใช้การจำลองทางฟิสิกส์ที่เกี่ยวข้องกับการรวมตัวเลขในขั้นตอนเวลาที่แน่นอน (หรือสมการของคุณจะระเบิด!), แทรกภาพกราฟิกระหว่างขั้นตอน (ซึ่งอาจเป็นข้อแก้ตัวสำหรับการแยกส่วนประกอบต่อเธรด แต่ก็สามารถทำได้โดยไม่ต้อง)
  • การใช้งานstd::mutexจะมีการเข้าถึงด้ายเพียงหนึ่งทรัพยากรที่เวลา ( "ร่วมกันไม่รวม") std::condition_variableและเพื่อให้สอดคล้องกับความหมายของแปลก
  • หลีกเลี่ยงการให้เธรดแย่งชิงทรัพยากร ล็อคน้อยที่สุดเท่าที่จำเป็น (แต่ไม่น้อยกว่า!) และล็อคล็อคไว้นานเท่าที่จำเป็นจริงๆ
  • อย่าแชร์ข้อมูลแบบอ่านอย่างเดียวระหว่างเธรด (ไม่มีปัญหาเกี่ยวกับแคชและไม่จำเป็นต้องล็อค) แต่อย่าแก้ไขข้อมูลในเวลาเดียวกัน (ต้องซิงค์และฆ่าแคช) ซึ่งรวมถึงการแก้ไขข้อมูลที่อยู่ใกล้กับสถานที่ซึ่งบุคคลอื่นอาจอ่าน
  • ใช้std::condition_variableเพื่อบล็อกเธรดอื่นจนกว่าเงื่อนไขบางอย่างเป็นจริง ซีแมนทิกส์ของstd::condition_variablemutex พิเศษนั้นเป็นที่ยอมรับว่าค่อนข้างแปลกและบิดเบี้ยว (ส่วนใหญ่เป็นเหตุผลทางประวัติศาสตร์ที่สืบทอดมาจากเธรด POSIX) แต่ตัวแปรเงื่อนไขเป็นตัวแปรดั้งเดิมที่ถูกต้องที่จะใช้สำหรับสิ่งที่คุณต้องการ
    ในกรณีที่คุณรู้สึกว่าstd::condition_variableแปลกเกินไปที่จะรู้สึกสบายใจคุณสามารถใช้เหตุการณ์ Windows (ช้ากว่าเล็กน้อย) แทนหรือถ้าคุณกล้าสร้างกิจกรรมง่ายๆของคุณเองรอบ ๆ NtKeyedEvents (เกี่ยวข้องกับสิ่งที่น่ากลัวระดับต่ำ) ในขณะที่คุณใช้ DirectX คุณต้องผูกพันกับ Windows อยู่แล้วดังนั้นการสูญเสียความสามารถในการพกพาจึงไม่ใช่เรื่องใหญ่
  • แบ่งงานออกเป็นงานที่มีขนาดพอสมควรซึ่งดำเนินการโดยกลุ่มเธรดของผู้ปฏิบัติงานที่มีขนาดคงที่ (ไม่เกินหนึ่งต่อแกนไม่ใช่การนับคอร์ที่มีเธรดมากเกินไป) ให้งานที่เสร็จสิ้นเข้าคิวงานที่ต้องพึ่งพา (การซิงโครไนซ์อัตโนมัติฟรี) ทำภารกิจที่มีการดำเนินการที่ไม่เกี่ยวข้องอย่างน้อยสองสามร้อยครั้ง (หรือการดำเนินการบล็อกแบบความยาวหนึ่งตัวเช่นการอ่านดิสก์) ชอบการเข้าถึงแคชที่ต่อเนื่องกัน
  • สร้างเธรดทั้งหมดเมื่อเริ่มต้นโปรแกรม
  • ใช้ประโยชน์จากฟังก์ชั่นแบบอะซิงโครนัสที่ระบบปฏิบัติการหรือกราฟิค API เสนอเพื่อการขนานที่ดีขึ้นไม่เพียง แต่ในระดับโปรแกรม แต่ยังกับฮาร์ดแวร์ (คิดว่าการถ่ายโอน PCIe, การขนาน CPU-GPU, ดิสก์ DMA เป็นต้น)
  • อีก 10,000 เรื่องที่ฉันลืมพูดถึง


[1] ใช่คุณสามารถกำหนดอัตราของตัวตั้งเวลาเป็น 1 มิลลิวินาที แต่สิ่งนี้เกิดขึ้นเพราะมันทำให้เกิดการสลับบริบทมากขึ้นและใช้พลังงานมากขึ้น (ในโลกที่อุปกรณ์มือถือเป็นอุปกรณ์พกพามากขึ้น) มันยังไม่ใช่วิธีแก้ปัญหาเพราะมันยังไม่ทำให้การนอนหลับเชื่อถือได้มากขึ้น
[2] ตัวจับเวลาจะช่วยเพิ่มลำดับความสำคัญของเธรดซึ่งจะช่วยให้สามารถขัดจังหวะเธรดที่มีลำดับความสำคัญเท่ากันในระดับกลางและกำหนดเป็นอันดับแรกซึ่งเป็นลักษณะการทำงานเสมือน-RT แน่นอนว่ามันไม่ใช่ RT จริง แต่ใกล้เข้ามามาก ตื่นจากการนอนหลับเพียงหมายความว่าด้ายพร้อมที่จะกำหนดในบางเวลาเมื่อใดก็ตามที่อาจจะ


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

3

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

เป้าหมายเมื่อแยกการอัปเดตและการแสดงผลในเธรดที่แตกต่างกันคือให้ทั้ง "เกือบ" เป็นอิสระจากกันเพื่อให้ GPU สามารถแสดงผล 500 FPS และตรรกะการอัปเดตยังคงอยู่ที่ 60 FPS คุณไม่ได้รับประสิทธิภาพที่สูงมากโดยการทำเช่นนั้น

แต่คุณบอกว่าคุณแค่อยากรู้ว่ามันทำงานยังไงและมันก็โอเค ใน C ++ mutex เป็นวัตถุพิเศษที่ใช้เพื่อล็อคการเข้าถึงทรัพยากรบางอย่างสำหรับเธรดอื่น กล่าวอีกนัยหนึ่งคุณใช้ mutex เพื่อให้สามารถเข้าถึงข้อมูลที่เหมาะสมได้โดยหนึ่งเธรดเท่านั้น เมื่อต้องการทำเช่นนั้นมันค่อนข้างง่าย:

std::mutex mutex;
mutex.lock();
// Do sensible stuff here...
mutex.unlock();

ที่มา: http://en.cppreference.com/w/cpp/thread/mutex

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

เธรดแรกที่ล็อก mutex จะสามารถเข้าถึงรหัสภายในได้ หากเธรดที่สองพยายามเรียกใช้ฟังก์ชัน lock () มันจะบล็อกจนกว่าเธรดแรกจะปลดล็อก ดังนั้น mutex เป็นฟังก์ชั่น blocing ซึ่งแตกต่างจาก while loop ฟังก์ชั่นการปิดกั้นจะไม่สร้างความเครียดให้กับ CPU


และบล็อกทำงานอย่างไร
Liuka

เมื่อเธรดที่สองจะเรียกใช้การล็อก () เธรดจะรอให้เธรดแรกปลดล็อก mutex อย่างอดทนและจะดำเนินการต่อในบรรทัดถัดไปหลังจาก (ในตัวอย่างนี้สิ่งที่สมเหตุสมผล) แก้ไข: กระทู้ที่สองจะล็อค mutex สำหรับตัวเอง
Alexandre Desbiens


1
การใช้งานstd::lock_guardหรือคล้ายกันไม่ได้/.lock() .unlock()RAII ไม่ได้มีไว้สำหรับจัดการหน่วยความจำเท่านั้น!
bcrist
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.