Sử dụng Clang để giảm thiểu việc sử dụng biến toàn cục

Mọi chương trình không đơn giản đều có ít nhất một lượng trạng thái toàn cục nhất định, nhưng quá nhiều có thể gây ra vấn đề. Trong C++ (chiếm gần 100% mã động cơ của Roblox), trạng thái toàn cục này được khởi tạo trước hàm main() và bị hủy sau khi hàm main() kết thúc, và quá trình này diễn ra theo thứ tự chủ yếu không xác định. Ngoài việc dẫn đến các quy tắc khởi động và tắt máy phức tạp khó hiểu (hoặc thay đổi), điều này còn có thể gây ra sự không ổn định nghiêm trọng.
Mã nguồn Roblox cũng tạo ra rất nhiều luồng chạy lâu dài (những luồng không bao giờ được kết thúc và chỉ chạy cho đến khi chúng quyết định dừng lại, điều này có thể là mãi mãi). Hai yếu tố này kết hợp với nhau gây ra tương tác tiêu cực nghiêm trọng trong quá trình tắt máy, vì các luồng chạy lâu dài tiếp tục truy cập vào trạng thái toàn cục đang bị hủy bỏ. Điều này có thể dẫn đến tỷ lệ lỗi cao hơn, sự không ổn định của bộ thử nghiệm và sự không ổn định chung.
Bước đầu tiên để thoát khỏi tình trạng rối ren như thế này là hiểu rõ mức độ của vấn đề, vì vậy trong bài viết này, tôi sẽ nói về một kỹ thuật mà bạn có thể sử dụng để có cái nhìn rõ ràng hơn về luồng khởi động toàn cục của mình. Tôi cũng sẽ thảo luận về cách chúng tôi đang sử dụng kỹ thuật này để cải thiện tính ổn định trên toàn bộ nền tảng động cơ trò chơi Roblox bằng cách giảm thiểu việc sử dụng các biến toàn cục.
Giới thiệu -finstrument-functions
Không có gì khiến tôi hào hứng hơn việc tìm hiểu về một tùy chọn biên dịch ít người biết đến mà trước đây tôi chưa từng có cơ hội sử dụng, vì vậy tôi rất vui khi một đồng nghiệp chỉ cho tôi tùy chọn này trong Tài liệu tham khảo dòng lệnh Clang. Tôi chưa từng sử dụng nó trước đây, nhưng nó nghe có vẻ rất hay. Ý tưởng là nếu chúng ta có thể khiến trình biên dịch thông báo mỗi khi nó vào và ra khỏi một hàm, chúng ta có thể lọc thông tin này qua một công cụ phân tích biểu tượng nào đó và tạo ra một báo cáo về các hàm a) xuất hiện trước main(), và b) là hàm đầu tiên trong stack gọi (chỉ ra rằng đó là hàm toàn cục).
Thật không may, tài liệu chỉ đơn giản thông báo rằng tùy chọn này tồn tại mà không đề cập đến cách sử dụng nó hoặc liệu nó có thực sự làm được những gì nó nghe có vẻ như vậy hay không. Ngoài ra còn có hai tùy chọn khác nhau nghe có vẻ tương tự nhau (-finstrument-functions và -finstrument-functions-after-inlining), và tôi vẫn chưa hoàn toàn chắc chắn về sự khác biệt giữa chúng. Vì vậy, tôi quyết định tạo một mẫu nhanh trên godbolt để xem kết quả ra sao, bạn có thể xem tại đây. Lưu ý có hai bản mã assembly cho cùng một danh sách mã nguồn. Một bản sử dụng tùy chọn đầu tiên và bản còn lại sử dụng tùy chọn thứ hai, và chúng ta có thể so sánh mã assembly để hiểu sự khác biệt. Chúng ta có thể rút ra một số điểm chính từ mẫu này:
- Trình biên dịch đang chèn các lệnh gọi đến __cyg_profile_func_enter và __cyg_profile_func_exit vào bên trong mọi hàm, dù là hàm inline hay không.
- Sự khác biệt duy nhất giữa hai tùy chọn này xảy ra tại vị trí gọi của một hàm inline.
- Với tùy chọn -finstrument-functions, mã theo dõi cho hàm được nhúng sẽ được chèn tại vị trí gọi hàm, trong khi với tùy chọn -finstrument-functions-after-inlining, chúng ta chỉ có mã theo dõi cho hàm bên ngoài. Điều này có nghĩa là khi sử dụng -finstrument-functions-after-inlining, bạn sẽ không thể xác định được hàm nào được nhúng và ở đâu.
Tất nhiên, điều này nghe có vẻ chính xác như những gì tài liệu đã mô tả, nhưng đôi khi bạn cần phải xem xét kỹ hơn để tự mình xác nhận.
Nói cách khác, nếu chúng ta muốn biết về các cuộc gọi đến các hàm được nhúng trong bản ghi này, chúng ta cần sử dụng -finstrument-functions vì nếu không, mã theo dõi của chúng sẽ bị trình biên dịch loại bỏ một cách im lặng. Đáng tiếc, tôi chưa bao giờ có thể làm cho -finstrument-functions hoạt động trên một ví dụ thực tế. Tôi luôn gặp lỗi liên kết sâu trong Thư viện C++ Tiêu chuẩn mà tôi không thể giải quyết được. Phỏng đoán tốt nhất của tôi là việc gộp mã (inlining) thường là một thuật toán heuristic, và điều này có thể dẫn đến các vi phạm ODR (Quy tắc Một Định nghĩa) tinh vi khi trình tối ưu hóa đưa ra các quyết định gộp mã khác nhau từ các đơn vị dịch khác nhau. May mắn thay, các hàm tạo toàn cục (điều chúng ta quan tâm) không thể nào bị gộp mã được, nên đây không phải là vấn đề.
Tôi nghĩ cũng nên đề cập rằng tôi vẫn gặp rất nhiều lỗi liên kết khi sử dụng tùy chọn -finstrument-functions-after-inlining, nhưng tôi đã giải quyết được những lỗi đó. Theo như tôi hiểu, tùy chọn này dường như áp dụng ngữ nghĩa liên kết --whole-archive. Thảo luận về --whole-archive nằm ngoài phạm vi của bài viết này, nhưng tóm lại là tôi đã khắc phục bằng cách sử dụng nhóm liên kết (ví dụ: -Wl,--start-group và -Wl,--end-group) trên dòng lệnh biên dịch. Tôi hơi ngạc nhiên là chúng ta không gặp các lỗi liên kết tương tự khi không sử dụng tùy chọn này và vẫn chưa hoàn toàn hiểu tại sao. Nếu bạn biết lý do tại sao tùy chọn này lại thay đổi ngữ nghĩa của trình liên kết, vui lòng cho tôi biết trong phần bình luận!
Triển khai các hook callback
Nếu bạn tinh ý, có thể bạn đang thắc mắc __cyg_profile_func_enter và __cyg_profile_func_exit là gì và tại sao chương trình lại liên kết thành công mà không gặp lỗi tham chiếu biểu tượng chưa được định nghĩa, dù trình biên dịch dường như đang cố gọi một hàm mà chúng ta chưa từng định nghĩa. May mắn thay, có một số tùy chọn cho phép chúng ta xem bên trong thuật toán của trình liên kết để tìm hiểu xem nó lấy biểu tượng này từ đâu ngay từ đầu. Cụ thể, tùy chọn -y <symbol> sẽ cho chúng ta biết cách trình liên kết giải quyết <symbol>. Chúng ta sẽ thử với một chương trình mẫu trước và một biểu tượng mà chúng ta đã định nghĩa, sau đó thử với __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 mainKhông có gì bất ngờ ở đây. Thư viện thời gian chạy C tham chiếu đến main(), và tệp đối tượng của chúng ta định nghĩa nó. Bây giờ hãy xem điều gì xảy ra với __cyg_profile_func_enter và -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_enterBây giờ, chúng ta thấy rằng libc cung cấp định nghĩa, và tệp đối tượng của chúng ta tham chiếu đến nó. Quá trình liên kết hoạt động hơi khác một chút trên các nền tảng Unix so với trên Windows, nhưng về cơ bản điều này có nghĩa là nếu chúng ta tự định nghĩa hàm này trong tệp cpp của mình, trình liên kết sẽ tự động ưu tiên nó hơn phiên bản trong thư viện chia sẻ. Liên kết godbolt hoạt động mà không có đầu ra thời gian chạy có tại đây. Vì vậy, bây giờ bạn có thể phần nào thấy được hướng đi của vấn đề này, tuy nhiên vẫn còn một vài vấn đề cần giải quyết.
- Chúng ta không muốn thực hiện việc này cho toàn bộ quá trình chạy chương trình. Chúng ta muốn dừng lại ngay khi đến main.
- Chúng ta cần một cách để biểu tượng hóa dấu vết này.
Vấn đề đầu tiên rất dễ giải quyết. Tất cả những gì chúng ta cần làm là so sánh địa chỉ của hàm đang được gọi với địa chỉ của main, và đặt một cờ chỉ ra rằng chúng ta nên dừng theo dõi từ đó trở đi. (Lưu ý rằng lấy địa chỉ của main là hành vi không xác định[1], nhưng đối với mục đích của chúng ta, nó hoàn thành công việc, và chúng ta không phát hành mã này, nên ¯\_(ツ)_/¯). Tuy nhiên, vấn đề thứ hai có lẽ cần được thảo luận thêm một chút.
Biểu tượng hóa các bản ghi
Để biểu tượng hóa các bản ghi này, chúng ta cần hai thứ. Thứ nhất, chúng ta cần lưu trữ bản ghi ở đâu đó trên bộ nhớ bền vững. Chúng ta không thể mong đợi việc biểu tượng hóa theo thời gian thực với hiệu suất hợp lý. Bạn có thể viết một đoạn mã C để lưu bản ghi vào một tên tệp đặc biệt, hoặc bạn có thể làm như tôi đã làm và chỉ ghi nó vào stderr (cách này bạn có thể chuyển hướng stderr vào một tệp khi chạy chương trình).
Thứ hai, và có lẽ quan trọng hơn, đối với mỗi địa chỉ, chúng ta cần ghi ra đường dẫn đầy đủ đến mô-đun mà địa chỉ đó thuộc về. Chương trình của bạn tải nhiều thư viện chia sẻ, và để dịch một địa chỉ thành một biểu tượng, chúng ta phải biết địa chỉ đó thực sự thuộc về thư viện chia sẻ hay tệp thực thi nào. Ngoài ra, chúng ta phải cẩn thận ghi địa chỉ của biểu tượng vào tệp trên đĩa. Khi chương trình đang chạy, hệ điều hành có thể đã tải nó vào bất kỳ vị trí nào trong bộ nhớ. Và nếu chúng ta định gắn nhãn biểu tượng sau khi chương trình đã chạy, chúng ta cần đảm bảo vẫn có thể tham chiếu đến nó ngay cả khi thông tin về vị trí nó được tải trong bộ nhớ đã bị mất. Hàm dladdr() của Linux cung cấp cả hai thông tin cần thiết này. Một mẫu Godbolt hoạt động với triển khai chính xác các móc theo dõi (instrumentation hooks) như trong mã nguồn của chúng ta có thể được tìm thấy tại đây.
Tổng hợp tất cả
Bây giờ khi đã có tệp ở định dạng này được lưu trên đĩa, tất cả những gì chúng ta cần làm là gán nhãn cho các địa chỉ. addr2line là một lựa chọn, nhưng tôi đã chọn llvm-symbolizer vì thấy nó ổn định hơn. Tôi đã viết một skript Python để phân tích tệp và gán tên cho từng địa chỉ, sau đó in ra dưới định dạng phân cấp “hình ảnh” tương tự như tệp đầu ra gốc. Có nhiều tùy chọn để lọc danh sách tên đã gán, giúp bạn làm sạch đầu ra để chỉ bao gồm những phần quan trọng cho trường hợp cụ thể của mình. Ví dụ, tôi đã loại bỏ các biến toàn cục có chứa “boost::” trong tên, vì tôi không thể sửa lại Boost để không sử dụng biến toàn cục.
Skript này không đơn giản như bạn nghĩ, vì việc quét từng dòng và gán ký hiệu cho chúng sẽ quá chậm (khi tôi thử cách này, quá trình mất hơn 2 giờ trước khi tôi buộc phải dừng lại). Điều này là do cùng một địa chỉ có thể xuất hiện hàng nghìn lần, và không có lý do gì để chạy llvm-symbolizer trên cùng một địa chỉ nhiều lần. Vì vậy, có rất nhiều thuật toán thông minh trong đó để tiền xử lý danh sách địa chỉ và loại bỏ các bản sao trùng lặp. Tôi sẽ không thảo luận chi tiết về cách thực hiện vì nó không quá thú vị. Nhưng tôi sẽ làm tốt hơn nữa và cung cấp mã nguồn!
Vậy sau tất cả những điều này, chúng ta có thể chạy bất kỳ mục tiêu nội bộ nào để lấy cây gọi, chạy qua skript, và sau đó nhận được kết quả như thế này (kết quả thực tế từ một quá trình Roblox, thông tin tệp nguồn đã bị loại bỏ):
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)
>__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()Vậy là xong: nửa đầu của cuộc chiến đã kết thúc. Tôi có thể chạy kịch bản này trên mọi nền tảng, so sánh kết quả để hiểu thứ tự thực tế mà các biến toàn cục của chúng ta được khởi tạo trong thực tế, sau đó từ từ di chuyển mã này ra khỏi các trình khởi tạo toàn cục và vào hàm main, nơi nó có thể được thực thi một cách xác định và rõ ràng.
Công việc trong tương lai
Sau khi triển khai điều này, tôi nhận ra rằng chúng ta có thể tạo một hook phân tích hiệu suất đa năng, phơi bày một số biểu tượng công khai (được dllexport nếu bạn dùng Windows), và cho phép một mô-đun plugin kết nối vào đây một cách động. Mô-đun plugin này có thể lọc địa chỉ bằng bất kỳ logic tùy ý nào mà nó quan tâm. Một trường hợp sử dụng thú vị mà tôi nghĩ ra là nó có thể tra cứu thông tin gỡ lỗi, kiểm tra xem địa chỉ hiện tại có ánh xạ đến hàm tạo của một biến tĩnh cục bộ hay không, và ghi ra địa chỉ nếu đúng. Điều này thực sự cho phép chúng ta hiểu sâu hơn về thứ tự khởi tạo các biến tĩnh lười biếng. Khả năng ở đây là vô tận.
Đọc thêm
Nếu bạn quan tâm đến chủ đề này, tôi đã tổng hợp một số tài liệu tham khảo yêu thích của mình về chủ đề này.
- Nhiều nguồn: Tiêu chuẩn ngôn ngữ C++
- Matt Godbolt: Những bit giữa các bit: Cách chúng ta đến được main()
- Ryan O’Neill: Học phân tích nhị phân Linux
- Trình liên kết và trình tải: John R. Levine
- https://eel.is/c++draft/basic.exec#basic.start.main-3
Cả Roblox Corporation lẫn blog này đều không ủng hộ hay hỗ trợ bất kỳ công ty hay dịch vụ nào. Ngoài ra, chúng tôi không đảm bảo hay hứa hẹn về tính chính xác, độ tin cậy hay tính đầy đủ của thông tin trong blog này.
Bài đăng trên blog này ban đầu được xuất bản trên Roblox Tech Blog.


