Những Suy Nghĩ Linh Tinh (Phần Tiếp Theo)
Tiếp nối từ bài viết hôm qua.
Hôm qua chúng ta đã đề cập đến vấn đề quản lý vòng đời đối tượng. Hãy cùng phân tích cách hệ điều hành quản lý tài nguyên.
Đối với tập hợp tài nguyên, hệ điều hành trừu tượng hóa thành khái niệm tiến trình. Mỗi tác vụ có thể yêu cầu tài nguyên từ hệ thống, và hệ điều hành sẽ quản lý chúng trong tập hợp tiến trình. Khi tiến trình còn tồn tại, tài nguyên được giữ nguyên; khi tiến trình kết thúc, tài nguyên sẽ được thu hồi. Từ góc nhìn của hệ điều hành, mỗi đối tượng đều độc lập, không cần quan tâm đến mối quan hệ phụ thuộc lẫn nhau, chỉ cần quản lý handle của đối tượng. Thứ tự thu hồi các đối tượng không quan trọng, không liên quan đến thứ tự cấp phát ban đầu.
Đặc biệt quan trọng trong số này là tài nguyên bộ nhớ. Thực tế, hệ điều hành không trực tiếp cấp phát bộ nhớ vật lý cho người dùng, mà thay vào đó là không gian địa chỉ ảo. Người dùng chỉ nhìn thấy không gian địa chỉ ảo, và việc phân bổ thực sự diễn ra theo từng trang (page). Khi đến tay người dùng, họ tự phân chia và sử dụng không gian này.
Như vậy, quản lý bộ nhớ thực sự là phần phức tạp nhất. Khác với việc quản lý handle tệp tin - nơi chỉ cần đếm tham chiếu đơn giản để quản lý vòng đời, tài nguyên bộ nhớ đòi hỏi cơ chế quản lý đa tầng. Có thể trong tương lai khi hệ thống 64-bit phổ biến, vấn đề này sẽ đơn giản hơn nhiều. Tuy nhiên hiện tại chúng ta vẫn đang chạy trên nền tảng 32-bit. Hơn nữa, hệ thống 64-bit cũng có thể phát sinh vấn đề mới. Có lẽ ngày nay chúng ta nhìn hệ thống 64-bit cũng giống như thời DOS thực chế, từng mơ tưởng một ngày nào đó có thể thoải mái dùng 4GB bộ nhớ.
Ngoài tài nguyên, hệ điều hành còn trừu tượng hóa khái niệm luồng (thread) - luồng thực thi mã lệnh - và quản lý chúng một cách thống nhất. Thread bản thân là tài nguyên thuộc tập hợp tiến trình, nhưng hệ điều hành cũng cần lập lịch tổng thể cho toàn bộ thread. Từ góc độ này, việc quản lý phân tầng đơn thuần là chưa đủ.
Không chỉ thread, các tài nguyên như socket cũng không thể đơn giản đặt dưới quyền quản lý tiến trình. Một kết nối TCP không thể bị xóa ngay lập tức khi tiến trình kết thúc. Ngoài ra, mô-đun lõi xử lý mạng cần khả năng kiểm tra (polling) toàn bộ socket trong hệ thống.
Tổng kết lại, quản lý vòng đời đối tượng dường như cần hai đường giao thoa: một là tập hợp các đối tượng có cùng vòng đời, hai là tập hợp các đối tượng cùng loại.
Hãy tạm gác kết luận lại, cùng phân tích chiến lược quản lý trong engine do chúng tôi thiết kế và những hạn chế mới phát hiện gần đây.
Tôi cho rằng trong chương trình client game (có thể mở rộng cho nhiều phần mềm khác), dữ liệu trong bộ nhớ khi chạy chia làm hai loại: tài nguyên bền vững và dữ liệu tạm thời liên quan ngữ cảnh.
Tài nguyên bền vững bao gồm bản đồ, mô hình, texture, xương hoạt ảnh… được tạo trong giai đoạn phát triển và chỉ đọc khi chạy; các dữ liệu có thể tính toán hoặc tải qua mạng rồi cố định trong quá trình chạy như thông tin thế giới game nhận từ server; và một số dữ liệu tạm thời ít thay đổi như texture cảnh có bóng đổ tạo trước qua tiền xử lý.
Các tài nguyên này có mối quan hệ phụ thuộc phức tạp, không dễ mô tả bằng cấu trúc cây. Nhưng chúng có điểm chung: quy trình tạo dữ liệu tương đối đơn giản (chủ yếu qua tải file), và số lượng tổng thể có giới hạn. Đặc điểm thứ hai cực kỳ quan trọng - chúng ta luôn dự đoán được số lượng tài nguyên tối đa trong tiến trình.
Giải pháp đơn giản nhất là tải toàn bộ tài nguyên vào bộ nhớ khi khởi tạo và không xóa đi. Đối với dữ liệu có vòng đời không ngắn hơn tiến trình, ngay cả đếm tham chiếu cũng thừa thãi. Hầu hết phần mềm khác đều làm như vậy, nếu lo ngại thời gian khởi động lâu, có thể áp dụng tải theo nhu cầu.
Tiếc rằng game PC hiện đại không còn áp dụng được cách này. Nếu bạn từng cài đặt client World of Warcraft, dung lượng 7GB+ trong ổ cứng đã nói lên tất cả.
Dĩ nhiên quản lý động là giải pháp khả dĩ. Nhưng hãy thử suy nghĩ khác đi. Dù không thể tải toàn bộ dữ liệu vào bộ nhớ, chúng ta vẫn có thể giữ handle cố định và một ít thông tin cho mọi tài nguyên.
Khi tải một texture, engine sẽ cấp phát một handle. Dù texture này có còn được dùng hay không, handle đó sẽ mãi thuộc về nó. Khi bộ nhớ đầy, engine có thể xóa dữ liệu texture khỏi bộ nhớ, và khi cần dùng lại, tải từ đĩa vào. Chúng ta chỉ cần giữ lại phương pháp tái tạo (như tên file) là đủ.
Như vậy, việc quản lý vòng đời dữ liệu tài nguyên đã được tách khỏi tầng trên của engine. Chúng ta không còn quan tâm dữ liệu bị ai tham chiếu, mà chỉ cần liên kết với một handle duy nhất. Handle (thường là con trỏ bộ nhớ) và phương pháp tái tạo chỉ chiếm lượng bộ nhớ nhỏ, và tổng lượng có giới hạn, hoàn toàn có thể chứa trong không gian địa chỉ một tiến trình.
(P/S: Tách biệt tài nguyên còn mang lại lợi ích khác: dễ dàng mô tả mối quan hệ phụ thuộc trong header dữ liệu, hỗ trợ tiền tải đa luồng, tăng độ mượt khi chạy. Chi tiết thiết kế có thể tham khảo bài viết trước của tôi: Quản lý bộ nhớ tài nguyên và tiền tải đa luồng)
Ngoài tài nguyên, lượng dữ liệu còn lại không nhiều. Chúng ta có thể phân loại chi tiết hơn.
Ví dụ, đa số chuỗi ký tự (string) có thể được trích xuất và quản lý tập trung. Nhiều chuỗi trong chương trình là bất biến, dùng để liên kết lỏng lẻo giữa dữ liệu và dữ liệu, mô-đun và mô-đun. Giống như ngôn ngữ lập trình động, tôi ủng hộ việc xây dựng một “bể chuỗi” chứa các chuỗi bất biến hữu hạn, tra cứu qua bảng băm. Khi lập trình viên biết trước mã nguồn sẽ không sinh ra chuỗi mới vô hạn, vấn đề vòng đời chuỗi hoàn toàn không cần quan tâm. Cách làm này còn mang lại lợi ích phụ: so sánh chuỗi chỉ cần so sánh con trỏ.
Sau