เนื้อหาในเว็บไซต์นี้ได้รับการแปลโดยใช้ปัญญาประดิษฐ์ (AI) หรือเทคโนโลยีการแปลด้วยเครื่อง และอาจมีข้อผิดพลาด

Skip to content

การใช้ Clang เพื่อลดการใช้ตัวแปรทั่วโลก

ทุกโปรแกรมที่ไม่ใช่เรื่องง่ายจะมีสถานะทั่วไปอย่างน้อยบางส่วน แต่ถ้ามากเกินไปก็อาจเป็นสิ่งที่ไม่ดี ใน C++ (ซึ่งคิดเป็นเกือบ 100% ของโค้ดเอนจินของ Roblox) สภาวะทั่วโลกนี้จะถูกเริ่มต้นก่อน main() และถูกทำลายหลังจากกลับจาก main() และสิ่งนี้เกิดขึ้นในลำดับที่ไม่แน่นอนเป็นส่วนใหญ่ นอกจากจะนำไปสู่ความหมายของการเริ่มต้นและสิ้นสุดที่สับสนซึ่งยากต่อการทำความเข้าใจ (หรือเปลี่ยนแปลง) แล้ว ยังอาจนำไปสู่ความไม่เสถียรอย่างรุนแรงได้อีกด้วย

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

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

แนะนำ -ฟังก์ชันของเครื่องดนตรี-

ไม่มีอะไรทำให้ฉันตื่นเต้นไปกว่าการได้เรียนรู้เกี่ยวกับตัวเลือกคอมไพเลอร์ที่ไม่ค่อยมีใครรู้จักซึ่งฉันไม่เคยใช้มาก่อนเลย ดังนั้นฉันจึงรู้สึกดีใจมากเมื่อเพื่อนร่วมงานชี้ให้ฉันเห็นตัวเลือกนี้ใน Clang Command Line Reference ฉันไม่เคยใช้มันมาก่อน แต่ฟังดูเจ๋งมาก แนวคิดคือถ้าเราสามารถทำให้คอมไพเลอร์บอกเราทุกครั้งที่มันเข้าและออกจากฟังก์ชัน เราสามารถกรองข้อมูลนี้ผ่านสัญลักษณ์บางประเภทและสร้างรายงานของฟังก์ชันที่ a) เกิดขึ้นก่อน main() และ b) เป็นฟังก์ชันแรกสุดใน call-stack (ซึ่งบ่งบอกว่ามันเป็นฟังก์ชันระดับโลก)

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

  1. คอมไพเลอร์กำลังแทรกการเรียกใช้ __cyg_profile_func_enter และ __cyg_profile_func_exit ภายในฟังก์ชันทุกตัว ไม่ว่าจะเป็นการเรียกใช้แบบอินไลน์หรือไม่ก็ตาม
  2. ความแตกต่างเพียงอย่างเดียวระหว่างตัวเลือกทั้งสองเกิดขึ้นที่ตำแหน่งการเรียกใช้ฟังก์ชันอินไลน์
  3. ด้วย -finstrument-functions การติดตั้งเครื่องมือสำหรับฟังก์ชันที่แทรกจะถูกใส่ไว้ที่ตำแหน่งเรียกใช้ ในขณะที่ด้วย -finstrument-functions-after-inlining เราจะมีเพียงการติดตั้งเครื่องมือสำหรับฟังก์ชันภายนอกเท่านั้น ซึ่งหมายความว่าเมื่อใช้ -finstrument-functions-after-inlining คุณจะไม่สามารถระบุได้ว่าฟังก์ชันใดถูกแทรกและอยู่ที่ใด

แน่นอนว่านี่ฟังดูเหมือนกับที่เอกสารระบุไว้ทุกประการ แต่บางครั้งคุณก็จำเป็นต้องตรวจสอบรายละเอียดเบื้องหลังเพื่อให้มั่นใจด้วยตัวเอง

หากจะพูดให้เข้าใจง่ายขึ้นอีกวิธีหนึ่ง หากเราต้องการทราบเกี่ยวกับการเรียกใช้ฟังก์ชันแบบอินไลน์ในร่องรอยนี้ เราจำเป็นต้องใช้ -finstrument-functions เพราะมิฉะนั้นการตรวจสอบฟังก์ชันเหล่านั้นจะถูกคอมไพเลอร์ลบออกโดยอัตโนมัติ น่าเสียดายที่ผมไม่เคยสามารถทำให้ -finstrument-functions ทำงานได้จริงกับตัวอย่างจริงเลย ผมมักจะเจอข้อผิดพลาดของลิงเกอร์ลึกเข้าไปในไลบรารีมาตรฐานของ C++ ซึ่งผมไม่สามารถหาสาเหตุได้ การคาดเดาที่ดีที่สุดของฉันคือการใส่โค้ดเข้าไปในตัวมักเป็นฮิวริสติก และสิ่งนี้อาจนำไปสู่การละเมิดกฎ ODR (one-definition rule) อย่างละเอียดอ่อนเมื่อตัวเพิ่มประสิทธิภาพทำการตัดสินใจเกี่ยวกับการใส่โค้ดเข้าไปในตัวที่แตกต่างกันจากหน่วยแปลที่แตกต่างกัน โชคดีที่คอนสตรัคเตอร์แบบโกลบอล (ซึ่งเป็นสิ่งที่เราสนใจ) ไม่สามารถถูกใส่โค้ดเข้าไปในตัวได้อยู่แล้ว ดังนั้นนี่จึงไม่ใช่ปัญหา

ผมคิดว่าผมควรกล่าวถึงด้วยว่าผมยังคงพบข้อผิดพลาดของลิงเกอร์จำนวนมากเมื่อใช้ -finstrument-functions-after-inlining แต่ผมได้แก้ไขปัญหาเหล่านั้นแล้ว จากที่ผมเข้าใจมากที่สุด ตัวเลือกนี้ดูเหมือนจะหมายถึงการใช้ semantics ของลิงเกอร์แบบ --whole-archive การอภิปรายเกี่ยวกับ --whole-archive นั้นอยู่นอกขอบเขตของบล็อกโพสต์นี้ แต่ขอสรุปว่าฉันแก้ไขปัญหาโดยใช้กลุ่มลิงเกอร์ (เช่น -Wl,--start-group และ -Wl,--end-group) ในบรรทัดคำสั่งคอมไพเลอร์ ฉันรู้สึกประหลาดใจเล็กน้อยที่เราไม่ได้รับข้อผิดพลาดของลิงเกอร์เหล่านี้เมื่อไม่มีตัวเลือกนี้ และยังคงไม่เข้าใจอย่างถ่องแท้ว่าทำไม หากคุณทราบเหตุผลว่าทำไมตัวเลือกนี้ถึงเปลี่ยนความหมายของลิงเกอร์ โปรดแจ้งให้ฉันทราบในความคิดเห็น!

การนำ Callback Hooks ไปใช้

หากคุณช่างสังเกต คุณอาจสงสัยว่า __cyg_profile_func_enter และ __cyg_profile_func_exit คืออะไรกันแน่ และทำไมโปรแกรมถึงสามารถลิงก์ได้สำเร็จตั้งแต่แรกโดยไม่มีข้อผิดพลาดเกี่ยวกับการอ้างอิงสัญลักษณ์ที่ไม่ได้กำหนดไว้ ทั้งที่ดูเหมือนว่าคอมไพเลอร์กำลังพยายามเรียกฟังก์ชันที่เราไม่เคยกำหนดไว้เลย โชคดีที่มีตัวเลือกบางอย่างที่ช่วยให้เราสามารถดูภายในอัลกอริทึมของตัวลิงก์ได้ เพื่อที่เราจะได้ทราบว่ามันนำสัญลักษณ์นี้มาจากที่ไหนตั้งแต่แรก โดยเฉพาะอย่างยิ่ง -y <symbol> จะบอกเราว่าตัวลิงก์กำลังแก้ไข <symbol> อย่างไร เราจะลองใช้กับโปรแกรมตัวอย่างและสัญลักษณ์ที่เราได้กำหนดเองก่อน จากนั้นเราจะลองใช้กับ __cyg_profile_func_enter

  zturner@ubuntu:~/src/sandbox$ cat instr.cpp<br>int main() {}
  zturner@ubuntu:~/src/sandbox$ clang++-9 -fuse-ld=lld -Wl,-y -Wl,main instr.cpp
  /usr/bin/../lib/gcc/x86_64-linux-gnu/crt1.o: reference to main<br>/tmp/instr-5b6c60.o: definition of main

ไม่มีอะไรน่าแปลกใจที่นี่ ไลบรารี C Runtime Library อ้างอิงถึง main() และไฟล์วัตถุของเราได้กำหนดไว้แล้ว ตอนนี้มาดูกันว่าเกิดอะไรขึ้นกับ __cyg_profile_func_enter และ -finstrument-functions-after-inlining

zturner@ubuntu:~/src/sandbox$ clang++-9 -fuse-ld=lld
  -finstrument-functions-after-inlining -Wl,-y -Wl,__cyg_profile_func_enter instr.cpp
  /tmp/instr-8157b3.o: reference to __cyg_profile_func_enter
  /lib/x86_64-linux-gnu/libc.so.6: shared definition of __cyg_profile_func_enter

ตอนนี้เราเห็นว่า libc ให้คำจำกัดความไว้แล้ว และไฟล์วัตถุของเราอ้างอิงถึงมัน การเชื่อมโยงทำงานแตกต่างออกไปเล็กน้อยบนแพลตฟอร์ม Unix-y เมื่อเทียบกับบน Windows แต่โดยพื้นฐานแล้วหมายความว่าถ้าเราจำกัดความฟังก์ชันนี้เองในไฟล์ cpp ของเรา ตัวลิงก์จะเลือกใช้เวอร์ชันนี้โดยอัตโนมัติแทนเวอร์ชันในไลบรารีที่ใช้ร่วมกัน ตัวอย่างการทำงานบน godbolt ที่ไม่มีผลลัพธ์การทำงานแบบ runtime สามารถดูได้ที่นี่ ตอนนี้คุณคงพอจะเห็นแล้วว่าเรื่องนี้กำลังจะไปทางไหน อย่างไรก็ตาม ยังมีปัญหาอีกสองสามข้อที่ต้องแก้ไข

  1. เราไม่ต้องการทำสิ่งนี้ตลอดทั้งโปรแกรม เราต้องการหยุดทันทีที่ถึง main
  2. เราต้องการวิธีที่จะสัญลักษณ์นี้ไว้

ปัญหาแรกแก้ไขได้ง่าย สิ่งที่เราต้องทำคือเปรียบเทียบที่อยู่ของฟังก์ชันที่ถูกเรียกกับที่อยู่ของ main แล้วตั้งค่าสถานะเพื่อระบุว่าเราควรหยุดการติดตามต่อไป (โปรดทราบว่า การนำที่อยู่ของ main มาใช้นั้นเป็นพฤติกรรมที่ไม่ถูกกำหนดไว้[1] แต่สำหรับวัตถุประสงค์ของเรา มันทำงานได้ และเราไม่ได้ส่งโค้ดนี้ออกไป ดังนั้น ¯\_(ツ)_/¯) ปัญหาที่สองอาจสมควรได้รับการหารือเพิ่มเติมเล็กน้อย

สัญลักษณ์แห่งร่องรอย

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

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

การรวบรวมทุกอย่างเข้าด้วยกัน

ตอนนี้เรามีไฟล์ในรูปแบบนี้บันทึกไว้ในดิสก์แล้ว สิ่งที่เราต้องทำคือแปลงที่อยู่เป็นสัญลักษณ์ addr2line เป็นตัวเลือกหนึ่ง แต่ฉันเลือกใช้ llvm-symbolizer เพราะฉันพบว่ามันมีความเสถียรมากกว่า ผมได้เขียนสคริปต์ Python เพื่อแยกวิเคราะห์ไฟล์และแสดงสัญลักษณ์แต่ละที่อยู่ จากนั้นพิมพ์ออกมาในรูปแบบลำดับชั้น "เชิงภาพ" เดียวกับไฟล์ผลลัพธ์ต้นฉบับ มีตัวเลือกหลายอย่างสำหรับการกรองรายการสัญลักษณ์ที่ได้ เพื่อให้คุณสามารถทำความสะอาดผลลัพธ์โดยแสดงเฉพาะสิ่งที่น่าสนใจสำหรับกรณีของคุณเท่านั้น ตัวอย่างเช่น ผมได้กรองตัวแปรสาธารณะที่มีคำว่า boost:: อยู่ในชื่อออก เพราะผมไม่สามารถแก้ไขโค้ดของ boost ให้ไม่ใช้ตัวแปรสาธารณะได้โดยตรง

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

ดังนั้นหลังจากทั้งหมดนี้ เราสามารถเรียกใช้เป้าหมายภายในใด ๆ ของเราเพื่อสร้างแผนผังการโทร จากนั้นรันผ่านสคริปต์ และรับผลลัพธ์เช่นนี้ (ผลลัพธ์จริงจากกระบวนการ Roblox, ข้อมูลไฟล์แหล่งที่มาถูกลบออก):

excluded_symbols = ['.*boost.*']
  excluded_modules = ['/usr.*']
  /usr/lib/x86_64-linux-gnu/libLLVM-9.so.1: 140 unique addresses
  InterestingRobloxProcess: 38928 unique addresses
  /usr/lib/x86_64-linux-gnu/libstdc++.so.6: 1 unique addresses<br>/usr/lib/x86_64-linux-gnu/libc++.so.1: 3 unique addresses<br>Printing call tree with depth 2 for 29276 global variables.
  __cxx_global_var_init.5 (InterestingFile1.cpp:418:22)
  RBX::InterestingRobloxClass2::InterestingRobloxClass2() (InterestingFile2.cpp.:415:0)
  &gt;__cxx_global_var_init.19 (InterestingFile2.cpp:183:34)
  (anonymous namespace)::InterestingRobloxClass2::InterestingRobloxClass2()<br>(InterestingFile2.cpp:171:0)
  __cxx_global_var_init.274 (InterestingFile3.cpp:2364:33)
  RBX::InterestingRobloxClass3::InterestingRobloxClass3()

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

งานในอนาคต

ผมนึกขึ้นได้หลังจากที่ได้นำสิ่งนี้ไปใช้แล้วว่า เราสามารถสร้างตัวเชื่อมต่อสำหรับการทำโปรไฟล์แบบทั่วไปที่เปิดเผยสัญลักษณ์สาธารณะบางส่วน (dllexport'ed ถ้าคุณพูดภาษา Windows) และอนุญาตให้โมดูลปลั๊กอินเชื่อมต่อกับสิ่งนี้ได้แบบไดนามิก โมดูลปลั๊กอินนี้สามารถกรองที่อยู่โดยใช้ตรรกะตามอำเภอใจใดๆ ที่สนใจได้ กรณีการใช้งานที่น่าสนใจที่ฉันคิดขึ้นมาคือ มันสามารถค้นหาข้อมูลการดีบัก ตรวจสอบว่าที่อยู่ปัจจุบันตรงกับตัวสร้างของฟังก์ชันท้องถิ่นแบบ static หรือไม่ และเขียนที่อยู่ออกมาหากเป็นเช่นนั้น ซึ่งจะทำให้เราเข้าใจลำดับการเริ่มต้นของ static แบบ lazy ได้ลึกซึ้งยิ่งขึ้น ความเป็นไปได้นั้นไม่มีที่สิ้นสุด

อ่านเพิ่มเติม

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

  1. หลากหลาย: มาตรฐานภาษา C++
  2. แมตต์ ก็อดโบลต์: บิตระหว่างบิต: วิธีที่เราไปถึง main()
  3. ไรอัน โอนีล: การเรียนรู้การวิเคราะห์ไบนารีของลินุกซ์
  4. ลิงเกอร์และโหลดเดอร์: จอห์น อาร์. เลวีน
  5. https://eel.is/c++ร่าง/พื้นฐาน.ดำเนินการ#พื้นฐาน.เริ่มต้น.หลัก-3

ทั้งบริษัท Roblox Corporation และบล็อกนี้ไม่ได้รับรองหรือสนับสนุนบริษัทหรือบริการใด ๆ ทั้งสิ้น นอกจากนี้ ไม่มีการรับประกันหรือคำมั่นสัญญาใด ๆ เกี่ยวกับความถูกต้อง ความน่าเชื่อถือ หรือความสมบูรณ์ของข้อมูลที่ปรากฏในบล็อกนี้

บทความบล็อกนี้ได้รับการเผยแพร่ครั้งแรกบนบล็อก Roblox Tech