UniCost Ghost #1: Giải phẫu SyncOrder - Tại sao cần tận 2 "Queue" để sinh tồn trên Main Thread?
Chào mừng các bạn đến với tập đầu tiên của series "UniCost Ghost ".
Nếu bài viết trước là tấm bản đồ chiến lược vĩ mô (và thú thật là khá "nặng đô"), thì series này sẽ mang đến những lát cắt vi phẫu nhẹ nhàng và trực diện hơn. Mục tiêu của tôi rất đơn giản: Trước khi phiên bản Open Source (OSS) chính thức trình làng, tôi muốn cùng các bạn đi sâu vào bên trong, mổ xẻ từng bánh răng, từng cơ chế kỹ thuật "hay ho" và cốt lõi nhất đã tạo nên sức mạnh của UniCost.
Hãy coi đây là những bản vẽ kỹ thuật (Scaffolding) giúp bạn hiểu rõ "nội tạng" của con quái vật này trước khi thực sự làm chủ nó. Chúng ta sẽ không bàn chuyện triết lý xa xôi nữa, mà sẽ đi thẳng vào câu hỏi: "Nó chạy như thế nào?".
Và để mở màn, hãy cùng soi vào nhịp đập quan trọng nhất của hệ thống.
1, Mở đầu: Nhịp tim của Main Thread
Nếu ví CPU là bộ não, thì Main Thread trong Unity chính là trái tim. Nó đập từng nhịp đều đặn: 16.6ms cho 60 FPS, hay khắc nghiệt hơn là 8ms cho 120 FPS. Một nhịp lỡ, một chút ngập ngừng, game của bạn sẽ "lên cơn đau tim" (stutter), và người chơi sẽ cảm nhận được ngay sự đứt gãy trong trải nghiệm.
Trong bài viết trước về triết lý của UniCost, chúng ta đã đồng ý với nhau một thỏa thuận: "Đẩy mọi thứ có thể xuống Sub-thread (luồng phụ)". Nghe thì rất lý tưởng. Logic tính toán, AI, Pathfinder... tất cả đều đang chạy mượt mà ở đâu đó dưới tầng sâu của OS mà không làm phiền đến Main Thread.
Nhưng cuộc đời không đơn giản thế. Dù bạn có tính toán cao siêu đến đâu ở "hậu trường", thì đến cuối cùng, kết quả vẫn phải được mang trở lại sân khấu chính để hiển thị. Bạn phải gán lại transform.position, bạn phải cập nhật text.text, bạn phải Instantiate prefab. Và nghiệt ngã thay, tất cả những việc đó chỉ được phép diễn ra ở Main Thread.
Đây chính là nút thắt cổ chai cuối cùng, là "cửa tử" mà mọi hệ thống Multi-threading đều phải đối mặt.
Nhiệm vụ của UniCost ở giai đoạn này không còn là tính toán nhanh nữa, mà là Quản trị rủi ro. Bài toán đặt ra cực kỳ tàn khốc:
"Làm sao nhồi nhét được hàng nghìn kết quả từ Sub-thread trả về vào cái khe cửa hẹp 16ms của Main Thread, tận dụng đến từng micro-giây cuối cùng, nhưng tuyệt đối không được làm 'vỡ' khung hình?"
Chúng ta không cần một người bảo vệ nhút nhát (chỉ dám chạy vài task rồi nghỉ cho an toàn), chúng ta cũng không cần một kẻ liều mạng (chạy cố để rồi gây lag). Chúng ta cần một Bác sĩ phẫu thuật với đôi tay chính xác tuyệt đối.
Và "con dao mổ" mà UniCost sử dụng cho ca phẫu thuật này mang tên: SyncOrder.
Hôm nay, tôi sẽ dẫn các bạn đi sâu vào "phòng mổ" của UniCost, để xem làm thế nào chúng ta có thể điều phối dòng chảy dữ liệu khổng lồ đó thông qua một kiến trúc Hybrid kỳ lạ: Sự kết hợp giữa FQ (Flood Queue) và SQ (Sub Queue).
2, Những nỗ lực thất bại (The Failed Attempts)
Trước khi tìm ra "Chén Thánh" SyncOrder, tôi cũng như các bạn, đã đi qua những con đường mòn. Chúng ta thường bắt đầu với những giải pháp đơn giản nhất, hiển nhiên nhất, để rồi nhận ra chúng sụp đổ thảm hại như thế nào trước áp lực của dữ liệu thực tế.
Dưới đây là biên bản khám nghiệm hiện trường của những nỗ lực đó.
Cách 1: Chiến thuật "Xả lũ" (The Floodgate Strategy)
Đây là cách implementation dễ nhất, phổ biến nhất, và cũng là cách mà các thư viện async nổi tiếng như UniTask hay cơ chế SynchronizationContext mặc định của C# thường sử dụng để điều hướng về Main Thread.
Mô hình
Chúng ta có một ConcurrentQueue<Action> toàn cục. Bất cứ khi nào một Sub-thread hoàn thành công việc, nó "ném" kết quả vào hàng đợi này.
Tại Main Thread, trong vòng lặp Update(), chúng ta có một đoạn code trông có vẻ vô hại như thế này:
void Update() {
while (queue.TryDequeue(out var action)) {
action.Invoke();
}
}
Tư duy: "Có việc thì làm cho hết. Để tồn đọng làm gì?"
Thực tế tàn khốc
Cách này hoạt động hoàn hảo khi bạn chỉ tải một tấm ảnh, hay request một API. Nhưng trong bối cảnh của UniCost – nơi chúng ta xử lý hàng nghìn entity cùng lúc – đây là một bản án tử hình cho Frame Rate.
Hãy tưởng tượng kịch bản "Vụ nổ lớn" (The Big Bang Scenario): Trong một frame, một quả lựu đạn nổ. Hệ thống vật lý và logic ở Sub-thread tính toán xong sát thương cho 500 con quái vật cùng một lúc. Tất cả 500 request cập nhật thanh máu (UI Update) và 500 request spawn hiệu ứng (VFX) được đẩy dồn dập vào hàng đợi.
Tại Main Thread, vòng lặp while ở trên trở thành kẻ "nghiện việc". Nó không biết điểm dừng.
- Nó lôi action 1 ra chạy: Tốn 0.1ms.
- ...
- Nó lôi action 1000 ra chạy: Tốn 0.1ms.
Tổng cộng: 1000 actions * 0.1ms = 100ms.
Hậu quả: Frame hiện tại bị kéo dài lên tới 100ms (tương đương 10 FPS). Game bị khựng lại (Freeze) một cách thô bạo. Main Thread bị "tắc đường" bởi chính đống dữ liệu mà nó nhận được.
Bài học: Sự nhiệt tình không kiểm soát là kẻ thù của hiệu năng. Main Thread không thể cứ nhắm mắt làm ngơ trước giới hạn vật lý 16ms chỉ vì "muốn làm cho xong việc". Chúng ta cần một cái phanh.
Cách 2: Sự ám ảnh về Kiểm soát (Micro-management) – Khi chiếc Đồng hồ trở thành Gánh nặng
Rút kinh nghiệm xương máu từ "Vụ nổ lớn" ở Cách 1, chúng ta chuyển sang thái cực ngược lại: Cẩn trọng tuyệt đối.
Tư duy lúc này là: "Nếu sợ lố giờ, thì cứ làm xong một việc nhỏ, ta lại xem đồng hồ một lần. Hết giờ thì dừng, mai làm tiếp."
Implementation
Chúng ta trang bị cho Main Thread một chiếc Stopwatch (đồng hồ bấm giờ độ phân giải cao).
void Update() {
stopwatch.Restart();
long budgetLimit = 4; // Ví dụ: 4ms ngân sách
while (queue.TryDequeue(out var action)) {
// Kẹp đồng hồ trước mỗi hành động
if (stopwatch.ElapsedMilliseconds > budgetLimit) {
// Hết tiền! Dừng ngay lập tức, trả lại quyền điều khiển cho Unity
break;
}
action.Invoke();
// Lặp lại quy trình...
}
}
Thoạt nhìn, đây là một thiết kế mẫu mực. An toàn, chặt chẽ, không bao giờ lo tụt FPS. Nhưng khi đưa vào thực chiến với UniCost, nó lộ ra một điểm yếu chết người: Chi phí quản lý (Management Overhead).
Vấn đề
Bản chất các task được gửi từ Sub-thread về Main-thread trong UniCost thường là Micro-tasks (Tác vụ siêu nhỏ).
- Gán
bool isDead = true: Mất khoảng vài nanosecond. - Update
transform.position: Mất cực ít thời gian.
Nhưng việc gọi stopwatch.Elapsed hay kiểm tra thời gian hệ thống không phải là miễn phí. Nó là một System Call (dù được tối ưu, nó vẫn tốn chỉ thị CPU).
Hãy làm một phép so sánh đời thường: Bạn thuê một công nhân chuyển gạch.
- Cách làm việc: Anh ta cầm một viên gạch lên (Task), đặt xuống, rồi quay sang hỏi bạn: "Sếp ơi, đến 5 giờ chiều chưa?" (Check Time).
- Anh ta cầm viên tiếp theo, đặt xuống, lại hỏi: "Sếp ơi, đến 5 giờ chiều chưa?"
Nếu viên gạch rất nặng (Task lớn), câu hỏi đó không đáng kể. Nhưng nếu nhiệm vụ là nhặt hạt gạo (Micro-task), thì thời gian anh ta dành để hỏi bạn còn nhiều hơn thời gian anh ta làm việc!
Hậu quả
- Lãng phí CPU: CPU nóng lên không phải vì xử lý logic game, mà vì nó phải liên tục "xem giờ". Một phần đáng kể của 4ms ngân sách quý giá bị đốt vào việc kiểm tra
if (time > budget). - Hiệu suất giả tạo: Bạn tưởng mình đang dùng hết 4ms để render, nhưng thực tế bạn chỉ dùng 2ms để làm việc, 2ms còn lại chỉ để... sợ hãi việc bị lố giờ.
Bi kịch của sự cứng nhắc
Việc kiểm tra thời gian quá chặt chẽ (Strict Budgeting) dẫn đến hai bài toán hóc búa mà nếu cố giải quyết theo cách thông thường, code của bạn sẽ trở thành một mớ hỗn độn:
a. Sự lãng phí khoảng trống (Time Fragmentation) Giả sử ngân sách cho phép là 5ms.
- Task 1 (nhẹ): Chạy mất 1ms. -> Còn dư 4ms.
- Task 2 (nặng): Dự kiến tốn 5ms.
Lúc này, logic an toàn sẽ gào lên: "Dừng lại! 4ms không đủ để chạy 5ms. Để frame sau!" Hệ quả là gì? 4ms còn thừa kia bị vứt vào sọt rác. Main Thread ngồi chơi xơi nước trong khi hàng đợi phía sau vẫn còn cả núi việc nhỏ (micro-tasks) có thể chạy lọt khe vào đó. Đây là sự phân mảnh tài nguyên cực kỳ lãng phí.
b. Kẹt hàng vĩnh viễn (Starvation) và Ảo tưởng Requeue Vậy Task 2 (5ms) kia sẽ đi về đâu? Nếu frame sau ngân sách vẫn là 5ms (hoặc ít hơn do tải game nặng lên), thì Task 2 sẽ mãi mãi bị từ chối. Nó trở thành "cục máu đông" làm tắc nghẽn toàn bộ mạch máu lưu thông.
Bạn có thể phản biện: "Thì đưa nó xuống cuối hàng đợi (Requeue) hoặc cho phép lố (Over-budget) một tí?"
Nghe thì hay, nhưng thực tế:
- Requeue: Việc lôi một item ra, thấy không vừa, rồi nhét lại vào đáy
ConcurrentQueuelà một thao tác cực kỳ tốn kém (Locking/Interlocked). Bạn lại đang đốt CPU cho việc sắp xếp thay vì xử lý. Và nếu cứ đẩy nó xuống cuối, bao giờ nó mới được chạy? - Intentional Over-budget (Cố tình lố): Nếu cho phép chạy lố, thì quy tắc "kiểm tra đồng hồ" ban đầu coi như vô nghĩa. Bạn sẽ phải viết hàng tá logic
if-elsephức tạp để quyết định xem "khi nào thì được lố, lố bao nhiêu là vừa".
Cuối cùng, mô hình Micro-management sụp đổ không chỉ vì overhead của việc xem giờ, mà vì nó không có khả năng thích ứng (non-adaptive). Nó biến Main Thread thành một công chức quan liêu: Hễ thấy hồ sơ hơi dày một chút là từ chối xử lý vì "sắp hết giờ hành chính", mặc dù vẫn còn cả tiếng đồng hồ rảnh rỗi.
Cách 3: Static Batching (Đóng gói cứng) – Cái bẫy của sự "Tiên tri"
Chán ngấy với việc Main Thread phải liên tục nhìn đồng hồ như một kẻ mắc chứng lo âu ở Cách 2, tôi quyết định thực hiện một cuộc đảo chính: Tước bỏ quyền kiểm soát thời gian của Main Thread.
Thay vào đó, quyền lực được chuyển giao cho Sub-thread – nơi có tài nguyên dồi dào hơn để suy nghĩ.
Cơ chế
Sub-thread sẽ đóng vai một "Thủ kho". Trước khi gửi hàng sang Main Thread, nó sẽ tính toán, cộng dồn "Cost" (chi phí dự kiến) của từng task.
- "Task A tốn 0.1 Cost, Task B tốn 0.5 Cost..."
- Nó gom các task lại vào một cái hộp (List) cho đến khi tổng Cost chạm ngưỡng ngân sách (ví dụ 4ms).
- Nó dán băng dính lại và gửi cái hộp đó sang Main Thread.
Main Thread lúc này chỉ việc nhận hộp, mở ra và chạy một mạch từ đầu đến cuối (foreach). Không kiểm tra thời gian, không Stopwatch, không điều kiện dừng. Tốc độ thực thi đạt mức tối đa (Bare-metal performance).
Thực tế: Sự trớ trêu của Dự đoán
Nghe rất "khoa học", nhưng phương pháp này vấp phải một sự thật hiển nhiên của ngành khoa học máy tính: Cost ước lượng không bao giờ bằng Thời gian thực tế.
Dù bạn có dùng những thuật toán điều khiển học xịn xò như PID Controller (Proportional-Integral-Derivative) để học từ quá khứ và điều chỉnh hệ số Cost cho tương lai, thì dự đoán vẫn chỉ là dự đoán. PID lái xe bằng cách nhìn gương chiếu hậu; nó biết frame trước bạn chạy nhanh hay chậm, nhưng nó không thể biết frame này CPU có đang bị OS bóp xung vì quá nhiệt hay không, hay Garbage Collector có bất ngờ chạy ngầm hay không.
Điều này dẫn đến một thế tiến thoái lưỡng nan:
1. Nếu bạn chọn sự "Hèn nhát" (An toàn cực đoan):
Bóng ma của việc tụt FPS ám ảnh bạn. Bạn không tin vào dự đoán Cost của chính mình (và bạn đúng), nên bạn quyết định trừ hao một "Vùng an toàn" (Safety Margin) khổng lồ để đề phòng rủi ro.
Giả sử ngân sách là 16ms (chuẩn 60 FPS). Scheduler rụt rè chỉ dám đóng gói một lượng task có Cost ước tính là 12ms.
Thực tế diễn ra thế nào? Những task đó chạy trơn tru và chỉ tốn mất 10ms. Main Thread hoàn thành công việc và nhìn đồng hồ: "Ơ, vẫn còn 6ms nữa mới hết frame à?" Nhưng vì đã đóng gói cứng (Static Batching), nó không thể với tay lấy thêm việc từ Sub-thread được nữa. Cửa kho đã đóng, chuyến xe đã chạy.
Hậu quả: 6ms tài nguyên vàng ngọc bị ném qua cửa sổ. Trong khi game vẫn load chậm, hàng nghìn vật thể vẫn đang xếp hàng chờ hiển thị, thì Main Thread lại ngồi chơi xơi nước.
Điều trớ trêu nhất và cũng đau đớn nhất ở đây là: Hiệu năng tổng thể lúc này có khi còn tệ hơn cả việc không dùng Sub-thread.
Tại sao? Vì bạn đã phải trả "phí nhập môn" cho đa luồng: chi phí Context Switching, chi phí quản lý Queue, chi phí cấp phát bộ nhớ cho các gói tin. Bạn chấp nhận tất cả những overhead đó để đổi lấy tốc độ, nhưng cuối cùng lại để CPU nhàn rỗi gần 40% thời gian. Nó giống như việc bạn bỏ tiền thuê một chiếc xe tải 10 tấn (Multi-thread infrastructure) nhưng mỗi chuyến chỉ dám chở đúng 1 gói mì tôm vì sợ xe nặng quá đi chậm. Đó là sự lãng phí không thể tha thứ.
2. Nếu bạn "liều" (Tối ưu hóa triệt để):
Bạn quyết định chơi tất tay. Bạn tin tưởng tuyệt đối vào con số tính toán và đóng gói sát nút 15.5ms vào chiếc hộp gửi đi.
Kết quả: Thảm họa. Tại sao? Vì bạn đã quên mất một sự thật cốt lõi: Cost bản chất chỉ là một ảo ảnh.
Con số "Cost" mà bạn gán cho một task là chủ quan. Con số mà Profiler (kể cả hệ thống PRP tối tân của UniCost v2.0) đo được cũng chỉ là thống kê trung bình trong quá khứ hoặc trên một thiết bị test lý tưởng. Nó không phải là chân lý. Trung bình cộng không đại diện cho từng cá thể.
Chỉ cần một sai lệch nhỏ trong dự đoán:
- Có thể do một hàm
Dictionary.Addbất ngờ chạm ngưỡng và phải resize mảng ngầm. - Có thể do đánh giá của bạn về độ nặng của task không đồng nhất với thực tế trên máy người dùng (CPU cache miss, thermal throttling).
Lúc này, con số 15.5ms lý thuyết tức khắc biến phình to thành 18ms hay 20ms thực tế.
Và bi kịch nằm ở chỗ: Vì Main Thread đã bị tước bỏ quyền kiểm tra thời gian (để tối ưu overhead như đã bàn ở Cách 2), nó giống như một chiếc xe đua bị cắt đứt dây phanh. Nó không thể dừng lại giữa chừng. Nó buộc phải chạy hết batch lệnh đó, đâm sầm vào bức tường giới hạn 16ms, và kéo theo cả trải nghiệm mượt mà của người chơi xuống vực thẳm.
Kết luận
Static Batching giải quyết được vấn đề overhead của Stopwatch, nhưng lại thất bại trong việc tận dụng tối đa băng thông của Main Thread. Chúng ta hoặc là lãng phí tài nguyên (Under-utilization), hoặc là gây rủi ro cho trải nghiệm (Overshoot).
Chúng ta cần một thứ gì đó linh hoạt hơn. Một thứ gì đó vừa dám chạy nhanh như Cách 3, nhưng vẫn giữ được sự tỉnh táo cuối cùng của Cách 2.
Và đó là lúc ánh sáng giác ngộ xuất hiện.
3, Chân lý: Sự ra đời của SyncOrder (Cấu trúc Hữu cơ)
Sau khi đi qua đủ mọi cung bậc cảm xúc từ sự tắc nghẽn của "Xả lũ" (Cách 1), sự ì ạch của "Vi quản lý" (Cách 2) đến sự lãng phí của "Đóng gói cứng" (Cách 3), tôi nhận ra mình không thể chọn một trong hai. Tôi cần cả hai. Tôi cần tốc độ của xe đua F1 và sự an toàn của xe bọc thép.
Từ đó, SyncOrder ra đời.
Đây không phải là một hàng đợi (Queue) đơn thuần như cách chúng ta vẫn thường tư duy. Nó là một Cấu trúc Hữu cơ (Organic Structure) được thiết kế để "thở" cùng nhịp với Frame Rate. SyncOrder xé nhỏ khái niệm "danh sách công việc" thành hai thực thể riêng biệt, hoạt động với hai quy tắc sinh tồn trái ngược nhau: FQ (Flood Queue) và SQ (Sub Queue).
A, Cấu trúc dữ liệu và Triết lý vận hành
Thay vì ném tất cả task vào một rọ, Scheduler của UniCost phân loại và đóng gói chúng vào hai khoang chứa riêng biệt bên trong một SyncOrder object:
1, FQ (Flood Queue) - "Đường Cao Tốc"
-
Bản chất: Là một mảng thuần túy
SyncJob[]. -
Ngân sách: Chiếm phần lớn "miếng bánh" tài nguyên của Frame (thường là n%, ví dụ 80% Cost khả dụng).
-
Triết lý vận hành (Internal Execution): Đây là sự tái sinh của Cách 3 (Static Batching) nhưng được kiểm soát. Khi Main Thread chạm vào FQ, nó sẽ chuyển sang chế độ "Blind Trust" (Tin tưởng mù quáng). Nó sẽ chạy một vòng lặp
for/foreachtừ đầu đến cuối mảng FQ mà tuyệt đối không kiểm tra đồng hồ (Stopwatch).Tại sao? Để đạt được tốc độ "Bare-metal". Không có
Stopwatch.Elapsed, không cóif (time > budget). Chỉ có dòng chảy chỉ thị CPU (Instruction Stream) liên tục và mượt mà, tận dụng tối đa Instruction Cache và Branch Prediction của phần cứng.Sự khác biệt với Cách 3: FQ không bao giờ ôm trọn 100% ngân sách. Nó chỉ ôm 80%. Nếu dự đoán sai lệch (như ví dụ
Dictionary.Addở trên), 20% dư địa còn lại chính là vùng đệm để hấp thụ cú sốc đó, ngăn chặn việc vỡ khung hình.
2, SQ (Sub Queue / Safe Queue) - "Đường Đô Thị"
-
Bản chất: Cũng là một danh sách
SyncJob[], nhưng thường ngắn hơn và chứa các task có độ ưu tiên thấp hơn hoặc các task nằm ở vùng ranh giới "nguy hiểm". -
Ngân sách: Chiếm phần còn lại (20%) cộng với bất kỳ khoảng thời gian dư thừa nào nếu FQ chạy xong sớm.
-
Triết lý vận hành (Internal Execution): Đây là nơi Cách 2 (Micro-management) được trọng dụng đúng chỗ. Khi Main Thread xử lý đến SQ, nó chuyển sang chế độ "Cautious" (Cẩn trọng).
Logic bên trong SQ cực kỳ nghiêm ngặt:
- Lấy 1 Job ra.
- Thực thi Job.
- Kiểm tra đồng hồ (Stopwatch Check).
- Nếu còn thời gian -> Lặp lại bước 1.
- Nếu hết thời gian -> Dừng ngay lập tức.
Sự khác biệt với Cách 2: Vì SQ chỉ chiếm một phần nhỏ khối lượng công việc, nên overhead (chi phí quản lý) của việc kẹp đồng hồ chỉ áp dụng lên số lượng nhỏ task này. Chúng ta chấp nhận trả một chút "phí bảo hiểm" ở đây để đảm bảo an toàn tuyệt đối cho những milisecond cuối cùng của frame.
Tóm lại
SyncOrder là một bản thiết kế "dị giáo" kết hợp hai thái cực:
- FQ: Chạy nhanh nhất có thể, chấp nhận rủi ro nhỏ để đổi lấy hiệu suất lớn.
- SQ: Chạy chậm hơn, an toàn tuyệt đối, đóng vai trò là "bộ giảm xóc" và "người dọn dẹp" tài nguyên thừa.
B, Luồng thực thi: Vũ điệu của Sự Liều lĩnh và Cẩn trọng
Sự kỳ diệu của SyncOrder không nằm ở việc nó chứa cái gì, mà là cách Main Thread "tiêu thụ" nó. Đây là một quy trình hai bước (2-Step Process) được thiết kế để tối đa hóa từng milisecond, biến Main Thread thành một cỗ máy ngấu nghiến dữ liệu nhưng vẫn biết dừng đúng lúc.
Bước 1: FQ - Cú nước rút (The Sprint)
Ngay khi nhận được SyncOrder, Main Thread lao vào xử lý FQ đầu tiên.
Như đã nói ở trên, FQ chạy trong chế độ "Không nhìn đồng hồ". Nó chạy một mạch từ index 0 đến index n.
Tại sao chúng ta dám làm điều này? Chính vì sự tồn tại của SQ đã trao cho chúng ta cái quyền được "liều". Trong các mô hình cũ (Cách 3), vì sợ lố giờ, ta thường phải đặt mức an toàn cực cao (ví dụ chỉ dám dùng 60% budget). Nhưng với UniCost, nhờ có SQ đứng sau làm tấm lưới bảo hiểm, ta có thể tự tin đẩy kích thước của FQ lên tới n% (80%, thậm chí 90%) của ngân sách dự kiến.
- Tham số n% (Confidence Factor): Đây không phải là hằng số chết. Nó là một con số sống động, thay đổi theo thời gian thực dựa trên độ "tự tin" của thuật toán dự đoán.
- Nếu Game đang chạy ổn định, độ lệch chuẩn (variance) thấp: Hệ thống tự tin đẩy
n = 90%. FQ phình to ra, Main Thread chạy hết tốc lực. - Nếu Game đang hỗn loạn, FPS trồi sụt: Hệ thống rụt rè kéo
nxuống 60%. FQ co lại để giảm rủi ro.
- Nếu Game đang chạy ổn định, độ lệch chuẩn (variance) thấp: Hệ thống tự tin đẩy
FQ hoàn thành nhiệm vụ của nó: Giải quyết phần lớn khối lượng công việc nặng nhọc nhất với chi phí quản lý (overhead) bằng 0.
Bước 2: SQ - Người dọn dẹp (The Sweeper)
Ngay khi FQ kết thúc, Main Thread mới bắt đầu bật chiếc đồng hồ bấm giờ (Stopwatch) lên và nhìn vào số dư tài khoản thời gian. Lúc này, SQ bước vào sân khấu.
SQ được thiết kế với tư duy: "Có thì ăn, không có thì nhịn. Chạy được bao nhiêu là lãi bấy nhiêu."
Sẽ có 3 kịch bản xảy ra tại đây, và kịch bản nào cũng dẫn đến một cái kết "có hậu":
- Kịch bản A: Dự đoán quá an toàn (Undershoot) FQ dự kiến tốn 12ms, nhưng thực tế chỉ chạy mất 8ms. Chúng ta còn thừa tận 8ms (với target 16ms). Hành động: SQ sướng rơn. Nó bắt đầu lôi các task trong hàng đợi của mình ra chạy. Vì cơ chế kẹp đồng hồ từng task, nó sẽ "ăn" trọn vẹn 8ms thừa thãi đó, xử lý thêm được cả mớ hiệu ứng VFX hay update UI phụ trợ. Kết quả: Không một milisecond nào bị lãng phí. Khoảng trống do sự thận trọng của FQ tạo ra đã được SQ lấp đầy hoàn toàn.
- Kịch bản B: Dự đoán sai lệch nhỏ (Slight Overshoot) FQ dự kiến 12ms, chạy thực tế mất 15ms. Còn dư 1ms. Hành động: SQ kiểm tra đồng hồ, thấy vẫn còn 1ms. Nó ráng chạy thêm 1-2 micro-task nhỏ nữa rồi tự động ngắt khi chạm vạch 16ms. Kết quả: Frame được lấp đầy vừa khít, tối ưu hóa triệt để.
- Kịch bản C: "Vỡ trận" (Major Overshoot)
FQ gặp sự cố (như ví dụ
Dictionaryresize), chạy lố lên tận 17ms. Hành động: Main Thread bước vào SQ, bật đồng hồ lên và thấy:Elapsed = 17ms > Budget. Điều gì xảy ra? SQ không chạy bất cứ task nào cả. Vòng lặp của SQ kết thúc ngay lập tức. Kết quả: Dù Frame đã bị lố (lag nhẹ), nhưng nhờ SQ "tự hủy" và nhường đường, mức độ thiệt hại được giảm thiểu tối đa. Nó ngăn không cho cú lag 17ms biến thành thảm họa 30ms.
Bước 3: Cơ chế Requeue – Sự chuẩn bị cho tương lai (Pre-frame Preparation)
Câu chuyện không kết thúc khi đồng hồ của SQ báo hết giờ. Những SyncJob chưa kịp chạy lúc này không nên bị coi là "hàng tồn kho" hay "kẻ thất bại" của Frame N. Thực chất, chúng đang ở trong trạng thái Pre-run cho các Frame tiếp theo (N+1, N+m).
Cơ chế xử lý ở đây tuân theo nguyên tắc Tăng tiến Ưu tiên (Priority Escalation):
-
Hoàn trả (Return): Toàn bộ các
SyncJobchưa được thực thi trong SQ sẽ được gửi trả ngược lại về Scheduler (bộ não trung tâm ở Sub-thread). -
Tăng hạng (Priority++): Tại đây, chúng không bị ném về cuối hàng đợi một cách thụ động. Scheduler sẽ đánh dấu chúng và thực hiện một thao tác quan trọng: Tăng nhẹ mức độ ưu tiên (Priority Increment).
Ví dụ: Một task có Priority gốc là
Normal, sau một lần "lỡ đò" ở SQ, nó quay về với PriorityNormal + 1. -
Tái tham gia cuộc đua: Với mức Priority mới cao hơn, trong lần tính toán đóng gói tiếp theo của Scheduler, các task này sẽ "nổi" lên trên bề mặt của bể chứa request (Request Pool).
Chúng ta cần hiểu đúng bản chất: Việc được đẩy vào SQ của frame hiện tại vốn dĩ đã là một sự "xếp hàng" cho tương lai. Nếu may mắn (dư thời gian), nó được chạy ngay (Early Execution). Nếu không, nó quay về và trở thành một ứng cử viên sáng giá hơn, cạnh tranh sòng phẳng (thậm chí lấn lướt) các request mới để giành vé vào các SyncOrder tiếp theo.
Cơ chế này đảm bảo sự công bằng tịnh tiến. Một task dù nhỏ bé đến đâu, nếu cứ liên tục lỡ nhịp, Priority của nó sẽ tích lũy dần (Priority accumulation) cho đến khi nó đủ lớn để Scheduler bắt buộc phải chú ý và xếp nó vào vị trí đẹp (có thể là top đầu SQ hoặc thậm chí lọt vào FQ) trong tương lai gần.
Tổng kết: Một hệ tuần hoàn khép kín
Mô hình FQ + SQ kết hợp với cơ chế Requeue (Priority++) đã đưa UniCost vượt xa khỏi khái niệm "hàng đợi" (Queue) thông thường. Chúng ta không chỉ xây dựng một cái kho chứa việc, mà chúng ta đã tạo ra một Trạng thái Cân bằng Động (Dynamic Equilibrium) cho Main Thread:
- FQ đóng vai trò là "Xương sống" (Backbone): Chịu tải trọng chính, đảm bảo hiệu suất nền tảng với tốc độ xử lý thô (bare-metal) nhanh nhất có thể.
- SQ đóng vai trò là "Cơ bắp" (Muscle): Linh hoạt co giãn. Nó là tấm đệm hấp thụ những cú sốc khi quá tải (Overshoot) và là cánh tay vươn dài để tận dụng tài nguyên khi rảnh rỗi (Undershoot).
- Cơ chế Requeue là "Huyết mạch" (Circulation): Đảm bảo dòng chảy dữ liệu không bao giờ bị tắc nghẽn hay ứ đọng. Những task chưa được chạy không bị vứt bỏ, chúng được bơm ngược trở lại hệ thống với vị thế cao hơn, tạo ra một cam kết chắc chắn về sự công bằng (Fairness).
Với kiến trúc này, chúng ta không còn phải đứng trước sự lựa chọn nghiệt ngã giữa Nhanh hay An toàn. Chúng ta có cả hai. Và quan trọng hơn, chúng ta có sự Bền bỉ. Hệ thống không gãy đổ khi quá tải, nó chỉ đơn giản là trì hoãn một cách thông minh và quay lại mạnh mẽ hơn ở nhịp thở tiếp theo.
Đó chính là định nghĩa của một hệ thống "Hữu cơ" (Organic).
4, Lời kết: Cánh tay nối dài và Sự "Ngây thơ" vĩ đại
Chúng ta vừa đi qua một hành trình dài để mổ xẻ SyncOrder. Nhìn vào cơ chế phối hợp nhịp nhàng giữa FQ và SQ, giữa sự liều lĩnh và cẩn trọng, có thể bạn sẽ nghĩ: "Đây rồi! Đây chính là trái tim của UniCost."
Nhưng không.
Nếu nhìn kỹ lại, bạn sẽ thấy SyncOrder thực chất rất... ngây thơ. Đặc biệt là FQ. Nó nhận một danh sách việc và cứ thế cắm đầu chạy, không cần biết trời trăng mây gió, không cần biết 16ms đã hết chưa. Nó tin tưởng tuyệt đối vào mệnh lệnh được giao. Sự "ngây thơ" đó là thứ mang lại tốc độ, nhưng cũng chính là rủi ro lớn nhất.
Thực tế, SyncOrder không phải là bộ não. Nó chỉ là cánh tay nối dài của một thế lực nằm sâu dưới Sub-thread.
Toàn bộ sự an toàn, tính đàn hồi, và cái vẻ ngoài "thông minh" của SyncOrder đều phụ thuộc vào người đã sắp xếp nó. Ai là người quyết định FQ chứa những gì? Ai là người dám khẳng định "Chạy đi, 80% budget này an toàn đấy"? Ai là người tính toán cost để nhét vừa khít chiếc hộp đó?
Đó là nhiệm vụ của Scheduler.
Và để Scheduler có thể điều khiển cánh tay "ngây thơ" này múa lượn giữa lằn ranh sinh tử của Frame Rate mà không gây họa, nó cần những cơ chế kiểm soát tối thượng. Trong nội bộ UniCost, tôi gọi chúng là hai gọng kìm: PIL và HTL.
PIL là gì? HTL quyền năng ra sao? Và tại sao nhờ chúng, sự ngây thơ của SyncOrder lại trở thành một nghệ thuật sắp đặt hoàn hảo?
Câu trả lời sẽ nằm trong bức tranh toàn cảnh ở bài viết tiếp theo. Hẹn gặp lại.
All rights reserved