+5

Tam Giới Phần Mềm & Nghịch lý Ngân sách: Nền tảng Lý thuyết cho BBDS dưới góc nhìn Unity Engine.

Trong bài viết trước, chúng ta đã mổ xẻ "thể xác" của máy tính thông qua Kiến trúc Von Neumann và hiểu được tại sao tư duy đơn luồng lại ăn sâu vào tiềm thức của lập trình viên đến vậy. Nhưng một thể xác dù hoàn hảo đến đâu, nếu thiếu đi nhịp tim, nó cũng chỉ là một cỗ máy chết.

Trong thế giới phần mềm, nhịp tim đó chính là Runtime Loop (Vòng lặp thực thi).

Đây là cơ chế tối thượng biến những dòng code tĩnh lặng nằm trên ổ cứng thành một thực thể sống động, có khả năng phản hồi và tương tác. Để thực sự hiểu tại sao Unity lại vận hành như một cỗ máy tuần tự khổng lồ, và tại sao chúng ta bắt buộc phải cần đến những tư duy lập trình mới như BBDS (sẽ được giải mã ở bài sau) để thuần hóa nó, chúng ta không thể chỉ nhìn vào bề mặt. Chúng ta phải quay ngược thời gian, trở về điểm khởi đầu của mọi sự sống kỹ thuật số.

1, Khởi nguồn của tất cả: The Entry Point (Điểm Nhập)

Trước khi một ứng dụng có thể vẽ ra đồ họa 3D hào nhoáng hay xử lý hàng triệu request mỗi giây, nó phải trải qua một khoảnh khắc "chào đời" cực kỳ khiêm tốn.

Bất kể bạn đang viết code bằng ngôn ngữ gì – từ sự chặt chẽ của C/C++, Java, C# cho đến sự phóng khoáng của Python hay JavaScript – và bất kể ứng dụng của bạn có kiến trúc vĩ mô phức tạp đến đâu, tất cả đều quy tụ về một điểm khởi đầu duy nhất: Entry Point.

Trong C/C++/C#, đó là hàm void Main(). Trong Python, đó là dòng lệnh đầu tiên của script. Trong các hệ điều hành, đó là địa chỉ bộ nhớ đầu tiên được nạp vào.

Đây là nơi xảy ra vụ nổ Big Bang của phần mềm:

  1. Hệ điều hành (OS) cấp phát một không gian bộ nhớ (Process) cho chương trình.
  2. Nó tạo ra một Luồng chính (Main Thread) đầu tiên và duy nhất.
  3. Nó đặt Con trỏ lệnh (Instruction Pointer) vào dòng đầu tiên của hàm Main và nói: "Bắt đầu đi, giờ sân khấu là của ngươi."

Tại khoảnh khắc T=0T=0 đó, mọi phần mềm đều bình đẳng. Chúng đều là đơn luồng, đều là tuần tự. Sự khác biệt, hay sự "tiến hóa", chỉ bắt đầu xuất hiện dựa trên cách mà chúng ta quyết định điều hướng dòng chảy (Control Flow) ngay sau dòng lệnh đầu tiên đó.

Tùy thuộc vào mục đích tồn tại, thế giới phần mềm từ đây sẽ rẽ nhánh thành 3 Mô hình Sự sống (Life Archetypes) riêng biệt. Việc thấu hiểu sâu sắc bạn đang nắm giữ mô hình nào trong tay chính là chìa khóa đầu tiên để làm chủ hiệu năng hệ thống.

2, Ứng dụng Tuần tự Hữu hạn (Finite Sequential Apps)

Đây là hình thái sự sống sơ khai nhất, thuần khiết nhất, và quan trọng nhất: Nó là "Nguyên tử" cấu thành nên toàn bộ vũ trụ phần mềm. Trước khi chúng ta nói đến những hệ thống phức tạp như Game Engine hay Cloud Server, chúng ta phải thấu hiểu viên gạch đầu tiên này.

Bản chất: Sự Định tính Tuyệt đối (Absolute Determinism)

Nếu phải dùng một hình ảnh để mô tả loại ứng dụng này, thì đó chính là một Phương trình Toán học hoàn hảo:

y=f(x)y = f(x)

Trong thế giới này, mọi thứ đều được dự đoán trước.

  • Đầu vào (xx): Là các tham số, file cấu hình, hoặc dữ liệu thô.
  • Quy trình (ff): Là một chuỗi các phép biến đổi logic chạy từ dòng lệnh đầu tiên đến dòng lệnh cuối cùng.
  • Đầu ra (yy): Là kết quả duy nhất và bất biến.

Đặc điểm cốt tử của nó là Tính Hữu hạn (Finiteness). Nó có điểm khởi đầu (Start) và điểm kết thúc (End) rõ ràng. Nó giống như một viên đạn được bắn ra khỏi nòng súng: quỹ đạo của nó đã được định đoạt ngay khoảnh khắc bóp cò. Nó không lơ lửng giữa không trung để chờ đợi, nó không thay đổi hướng đi dựa trên tâm trạng của người dùng. Nó bay, nó xuyên qua mục tiêu, và nó dừng lại.

Trong mô hình này, thời gian là tuyến tính và đơn chiều. Trạng thái của chương trình tại thời điểm TT là hệ quả trực tiếp và duy nhất của trạng thái tại thời điểm T1T-1. Không có sự bất ngờ. Ngay cả sự 'ngẫu nhiên' (Randomness) trong máy tính thực chất cũng là định tính (Pseudo-random), nếu chúng ta biết trước hạt giống (Seed), chúng ta biết trước kết quả. Và quan trọng nhất: Không có sự chờ đợi.

Ví dụ điển hình: Chúng hiện diện ở khắp mọi nơi, là những "công nhân" thầm lặng của thế giới số:

  • Các công cụ dòng lệnh (CLI) như git commit, grep, ffmpeg.
  • Các trình biên dịch (Compiler) như gcc, csc.
  • Các script xử lý ảnh, batch processing.

Khi bạn gõ lệnh nén một file video, hệ điều hành sinh ra một process. Process đó ngốn CPU để tính toán, ghi dữ liệu ra ổ cứng, và sau đó hệ điều hành "giết chết" (terminate) nó ngay lập tức. Bộ nhớ được giải phóng, sự tồn tại của nó chấm dứt.

Trái tim của Mô hình: Sự thống trị của Thuật toán (ff)

Trong mô hình này, yếu tố quan trọng duy nhất, thứ định hình nên giá trị của phần mềm, chính là Logic Cốt lõi (ff).

Đây là nơi kết tinh của 4-5 năm đại học, của hàng ngàn giờ bạn vật lộn với Cấu trúc dữ liệu và Giải thuật.

  • Đó là cách bạn biến đổi xx thành yy.
  • Đó là việc lựa chọn Hash Map hay Binary Tree.
  • Đó là việc tối ưu độ phức tạp từ O(n2)O(n^2) xuống O(nlogn)O(n \log n).

Trong thế giới này, Lập trình viên là Chúa tể của thời gian trước khi chạy (Pre-runtime), nhưng là kẻ bất lực khi chương trình đã bắt đầu. Bạn không thể click chuột vào một nút bấm để thay đổi dòng chảy của chương trình khi nó đang chạy. Cách duy nhất để bạn tương tác với nó là thay đổi Thuật toán (viết lại code), biên dịch lại, và bắn viên đạn đi một lần nữa.

Sự thuần khiết này làm cho nó trở thành môi trường lý tưởng nhất để đo lường trí tuệ logic. Không có UI làm xao nhãng, không có sự kiện bất ngờ. Chỉ có Logic và Dữ liệu.

"Thuyết Nguyên tử" của Phần mềm: Nền tảng của mọi Kiến trúc

Đây là luận điểm quan trọng nhất, là chìa khóa để giải mã các mô hình phức tạp sau này: Ứng dụng Tuần tự Hữu hạn là đơn vị cơ bản không thể thay thế.

Chúng ta thường lầm tưởng rằng một Game Engine (như Unity) hay một Web Server là những sinh vật hoàn toàn khác biệt. Không phải vậy. Về bản chất, chúng chỉ là những Design Pattern (Mẫu thiết kế) khổng lồ được xây dựng bằng cách sắp xếp và tổ chức hàng tỷ "Ứng dụng Tuần tự Hữu hạn" lại với nhau.

  • Unity (Ứng dụng Vô hạn): Thực chất là gì? Nó là việc chạy một chương trình Tuần tự Hữu hạn (gọi là "Một Frame") lặp đi lặp lại 60 lần mỗi giây bên trong một vòng lặp while. Mỗi Frame đều có khởi đầu (Input), diễn biến (Update), và kết thúc (Render). Sự "vô hạn" chỉ là ảo giác của sự lặp lại.
  • Server (Ứng dụng Bất tuần tự): Thực chất là gì? Nó là một cỗ máy kích hoạt hàng nghìn chương trình Tuần tự Hữu hạn (gọi là "Request Handler") chạy song song với nhau. Mỗi Request đến và đi đều tuân theo quy luật sinh-lão-bệnh-tử của mô hình này.

Điểm khác biệt duy nhất giữa các mô hình phức tạp và mô hình nguyên tử này nằm ở Số phận của Trạng thái (State) sau khi yy được sinh ra.

  • Ở Mô hình Tuần tự Hữu hạn: Trạng thái bị hủy diệt.
  • Ở Unity: Trạng thái của Frame này (yy) sẽ trở thành đầu vào (xx) của Frame kế tiếp. Đây chính là nguồn gốc của sự sống (và cả sự hỗn loạn) trong Game.

Vì vậy, Mô hình Tuần tự Hữu hạn là chân lý. Các mô hình khác (Loop, Event-driven) chỉ là lớp vỏ bọc (Wrapper) hay các mẫu hình quản lý (Orchestration Patterns) để điều phối sự xuất hiện của các "nguyên tử" này theo thời gian hoặc không gian.

Đa luồng: Tốc độ là mục đích duy nhất

Trong bối cảnh của mô hình nguyên thủy này, vai trò của Đa luồng (Multi-threading) rất đơn giản và thực dụng.

  • Nó không dùng để giữ UI mượt mà (vì không có UI để mà giữ).
  • Nó không dùng để phục vụ nhiều người dùng (vì nó là đơn nhiệm).
  • Nó chỉ dùng cho Data Parallelism (Song song hóa dữ liệu).

Nếu bạn cần xử lý 1 triệu bức ảnh, bạn chia nó cho 8 luồng để làm nhanh hơn gấp 8 lần. Mục tiêu duy nhất là: Đến đích B càng nhanh càng tốt để kết thúc chương trình.

Nhưng chuyện gì sẽ xảy ra khi chúng ta muốn chương trình này không bao giờ kết thúc? Chuyện gì sẽ xảy ra khi chúng ta nhốt 'viên đạn' này vào một chiếc hộp thời gian và bắt nó phải quay đầu lại trước khi chạm đích? Chào mừng đến với Mô hình 3: Ứng dụng Tuần tự Vô hạn.

3, Ứng dụng Bất tuần tự Vô hạn (Infinite Non-sequential Apps) – "Tổng đài của Sự hỗn loạn"

Đây là nhánh tiến hóa thống trị thế giới Internet, hạ tầng Cloud và các hệ thống Backend: Web Server, Microservices (Node.js, ASP.NET Core, Nginx).

Nếu Mô hình 1 (Tuần tự Hữu hạn) là một viên đạn bay thẳng từ nòng súng đến bia đỡ, thì Mô hình này giống như một quả lựu đạn nổ tung: Biến một điểm khởi đầu duy nhất thành vô vàn mảnh đạn bay theo các quỹ đạo độc lập.

Bản chất: Sự Chờ đợi Thụ động (The Passive Reactor)

Khác với Unity (sẽ nói ở phần sau) phải chạy vòng lặp để "làm việc" (vẽ hình) liên tục, Server chạy vòng lặp để "chờ việc".

  • Cơ chế: Nó sở hữu một vòng lặp vô tận gọi là Event Loop (trong Node.js) hoặc cơ chế I/O Completion Port (trong .NET/Windows).
  • Hoạt động: Vòng lặp này không thực thi logic nghiệp vụ. Nó đóng vai trò là một Người trực tổng đài (Dispatcher). Nó chỉ làm một việc duy nhất: Lắng nghe tín hiệu từ thế giới bên ngoài.
    • Có một request HTTP vừa đến cổng 80?
    • Database đã trả dữ liệu về chưa?
    • File đã đọc xong chưa?

Khi một tín hiệu xuất hiện, Tổng đài viên này không tự mình xử lý (vì nếu hắn bận xử lý, ai sẽ nghe cuộc gọi tiếp theo?). Thay vào đó, hắn ngay lập tức khởi tạo một Sub-pipeline (một Task, một Thread, hoặc một chuỗi Middleware), giao việc cho "công nhân" đó, rồi quay lại ghế trực để chờ cuộc gọi tiếp theo.

Sự bùng nổ Song song: Từ một viên đạn thành cơn mưa mảnh đạn

Đây là điểm mấu chốt của kiến trúc này.

Trong Mô hình 1, bạn có một dòng chảy duy nhất: ABA \rightarrow B. Trong Mô hình Server, từ Entry Point, chương trình phân rã thành hàng nghìn dòng chảy song song.

  • Mỗi khi có một Request (Yêu cầu) từ người dùng, một "vũ trụ nhỏ" được sinh ra.
  • Server tạo ra một luồng xử lý (Pipeline) riêng biệt cho Request đó (Ta cần phân biệt Pipeline, Task và Thread, thứ tôi sẽ nói vào một lúc nào đó khác).
  • Task này chạy từ ABA \rightarrow B (nhận input, xử lý, trả output) giống hệt như một chương trình Tuần tự Hữu hạn.
  • Nhưng: Có hàng nghìn Pipeline như vậy đang chạy cùng một lúc.

Hình ảnh ẩn dụ chính xác nhất là: Server là một khẩu súng máy bắn ra hàng ngàn viên đạn (Request) cùng lúc. Mỗi viên đạn có quỹ đạo riêng, tốc độ riêng, và đích đến riêng.

Đặc điểm cốt tử: Sự Cô lập Tuyệt đối (Absolute Isolation)

Tại sao Server là "Thiên đường của Đa luồng"? Tại sao chúng ta có thể bắn hàng ngàn viên đạn mà không sợ chúng va vào nhau? Câu trả lời là: Sự Cô lập.

  1. Trạng thái Cô lập (Isolated State):
    • Dữ liệu của Request A (người dùng đăng nhập tại Việt Nam) hoàn toàn không liên quan đến dữ liệu của Request B (người dùng xem giỏ hàng tại Mỹ).
    • Hai luồng xử lý này là hai đường thẳng song song trong không gian bộ nhớ. Chúng không chia sẻ biến cục bộ, không chia sẻ trạng thái phiên làm việc.
    • Chính vì không dẫm chân lên nhau, chúng không cần Lock (Khóa). Không có Lock nghĩa là không có chờ đợi. Mọi core CPU đều được bung hết sức mạnh để đẩy từng viên đạn đi nhanh nhất có thể.
  2. Nghịch lý "Vô định nhưng Định tính": Đây là vẻ đẹp của mô hình này.
    • Ở cấp độ Vĩ mô (Toàn cục): Hệ thống là Vô định (Non-deterministic). Bạn không thể biết giây tiếp theo sẽ có bao nhiêu request, request nào xong trước, request nào xong sau. Nó là một sự hỗn loạn.
    • Ở cấp độ Vi mô (Từng Pipeline): Hệ thống lại là Định tính (Deterministic). Bên trong một request, code chạy tuần tự từ trên xuống dưới, logic y=f(x)y=f(x) vẫn được bảo toàn nguyên vẹn. Lập trình viên Backend viết code xử lý 1 request y hệt như viết một Console App, và hạ tầng Server lo việc nhân bản nó lên hàng ngàn lần.

Thời gian Co giãn (Elastic Time)

Mô hình này chịu áp lực về Thông lượng (Throughput) – xử lý được bao nhiêu việc trong 1 giây, chứ không phải Độ trễ cứng (Hard Latency).

  • Trong Game, nếu một frame quá hạn 16ms, hình ảnh bị xé, trải nghiệm sụp đổ. Thời gian là cái hộp cứng.
  • Trong Server, thời gian là cái túi cao su. Nếu server đang bận xử lý 10.000 viên đạn, viên đạn thứ 10.001 có thể bay chậm hơn một chút (mất 50ms thay vì 10ms). Người dùng web có thể chấp nhận sự chậm trễ này. Hệ thống không bị crash, không bị "vỡ hình". Nó chỉ đơn giản là... giãn ra.

Nhìn vào sự tự do, khả năng mở rộng và sức mạnh phân mảnh (fragmentation) tuyệt vời của Mô hình Server, chúng ta tự hỏi: Tại sao Game không làm như thế? Tại sao chúng ta không thể coi mỗi nhân vật, mỗi viên đạn trong game là một Request riêng biệt chạy song song trong vũ trụ của riêng nó?

Câu trả lời nằm ở mô hình cuối cùng, nơi mà chúng ta không có sự xa xỉ của trạng thái cô lập hay thời gian co giãn. Chào mừng đến với nhà tù của Mô hình Tuần tự Vô hạn.

4. Ứng dụng Tuần tự Vô hạn (Infinite Sequential Apps)

Đây là nhánh tiến hóa đã sinh ra những phần mềm tương tác phức tạp nhất mà con người từng tạo ra: Hệ điều hành, Trình duyệt Web, và Game Engine.

Để hiểu nó, chúng ta không thể nhìn nó như một khối liền mạch. Chúng ta phải nhìn nó như một chuỗi các sự kiện rời rạc được nối kết bởi một sợi dây vô hình gọi là Trạng thái (State).

Bản chất Toán học: Hàm Đệ quy theo Thời gian

Nếu Mô hình 1 (Tuần tự Hữu hạn) là phương trình y=f(x)y = f(x), thì Mô hình 3 là một Hệ phương trình Đệ quy (Recursive System) vận hành theo trục thời gian tt.

Bản chất của một "Vòng lặp Vô hạn" thực ra là việc gọi đi gọi lại một hàm số duy nhất FF tại các thời điểm rời rạc t0,t1,t2,...t_0, t_1, t_2, ...:

State(t)=F( State(t1), Input(t) )State_{(t)} = F(\ State_{(t-1)},\ Input_{(t)}\ )

Trong đó:

  • State(t)State_{(t)}: Là toàn bộ hiện trạng của thế giới game tại frame hiện tại (Vị trí nhân vật, lượng máu, điểm số, các pixel trên màn hình).
  • State(t1)State_{(t-1)}: Là di sản từ quá khứ - trạng thái của thế giới tại frame ngay trước đó.
  • Input(t)Input_{(t)}: Là những biến số mới phát sinh trong khoảnh khắc này (Người chơi bấm nút, gói tin mạng vừa tới, thời gian trôi qua deltaTime).
  • FF: Là bộ máy Logic (Game Engine + Code của bạn).

Ý nghĩa của phương trình này: Nó khẳng định rằng Ứng dụng Vô hạn thực chất là một chuỗi vô tận các Ứng dụng Hữu hạn được nối đuôi nhau. Mỗi lần hàm FF chạy, nó là một chương trình tuần tự hữu hạn hoàn chỉnh: Nó có bắt đầu, có kết thúc. Nhưng thay vì trả kết quả ra màn hình rồi "chết" hẳn (như Mô hình 1), nó truyền lại linh hồn (State) của nó cho lần chạy tiếp theo.

Sự kết hợp hoàn hảo: Finite App + State Machine (Cỗ máy lai)

Mô hình Tuần tự Vô hạn không tự nhiên sinh ra. Nó là đứa con lai của hai khái niệm nền tảng trong khoa học máy tính, kết hợp sự chặt chẽ của toán học với sự linh hoạt của bộ nhớ.

A. Tính chất của Ứng dụng Tuần tự Hữu hạn (The Finite Core)

Nếu chúng ta dùng một chiếc máy ảnh tốc độ cao để chụp lại khoảnh khắc một Frame đang chạy (trong khoảng 16.6ms), chúng ta sẽ thấy bóng dáng của Mô hình 1 hiện ra rõ rệt.

Trong phạm vi một Frame, mọi thứ là Tuyến tính (Linear)Định tính (Deterministic). Nó hoạt động như một dây chuyền sản xuất:

  1. Thu thập nguyên liệu (Input Sampling): Đây là điểm chạm đầu tiên với sự Bất định. Engine lấy mẫu dữ liệu từ thế giới thực (vị trí chuột, gói tin mạng). Dù chúng ta không biết người dùng sẽ làm gì (ngẫu nhiên), nhưng ngay khi dữ liệu đó được nạp vào biến Input, nó trở thành một hằng số cho phần còn lại của Frame.
  2. Chuỗi phản ứng nhân quả (Dependency Chain): Sau khi có Input, logic chạy theo một trật tự không thể đảo ngược:
    • Physics phải chạy trước để xác định va chạm.
    • Logic Game chạy để trừ máu, cộng điểm.
    • Animation chạy sau cùng để khớp xương với vị trí mới.
    • Camera phải cập nhật cuối cùng để không bị rung (jitter).

Trong 16.6ms đó, máy tính chỉ làm một việc duy nhất là giải phương trình y=f(x)y = f(x) với xx là Input vừa thu thập được. Không có sự song song thực sự ở đây. Main Thread bị khóa chặt vào việc giải quyết chuỗi phụ thuộc này.

B. Tính chất của State Machine (Máy trạng thái - The Memory)

Nếu Mô hình 1 là "Viên đạn" (bay rồi mất), thì yếu tố tạo nên sự "Vô hạn" cho Mô hình 3 chính là Bộ nhớ (RAM).

Giữa Frame 1 và Frame 2, CPU có thể nghỉ ngơi (idle), nhưng RAM thì không bao giờ ngủ.

  • RAM giữ lại giá trị của biến heroPosition, currentScore, inventoryList.
  • Đây chính là State (Trạng thái).

Chính bộ nhớ này tạo ra ảo giác về sự liên tục.

  • Nếu bạn xóa sạch RAM sau mỗi frame, game của bạn sẽ quay về màn hình Start sau mỗi 16ms.
  • Nhưng vì State được lưu giữ, đầu ra (yy) của Frame hôm nay sẽ trở thành một phần đầu vào (xx) của Frame ngày mai.

Yếu tố Bất định tích lũy: Tại đây, yếu tố Input không chỉ ảnh hưởng đến một Frame, mà nó làm biến đổi vĩnh viễn quỹ đạo của State Machine. Một cú click chuột ở Frame 100 sẽ làm thay đổi vị trí nhân vật ở Frame 1000. Đây là lý do tại sao chúng ta không thể "tua nhanh" game đến tương lai mà không tính toán từng bước một: Tương lai được xây đắp bởi sự tích lũy của hàng ngàn Input ngẫu nhiên trong quá khứ.

C. Unity là một State Machine khổng lồ được cập nhật tuần tự

Unity không phải là một dòng sông chảy liên tục. Nó là một cuốn sổ cái (State Machine) khổng lồ.

  • Mỗi 16.6ms, Main Thread (Finite App) thức dậy.
  • Nó đọc trang sổ cũ, nhìn vào Input mới.
  • Nó tính toán và viết sang trang sổ mới.
  • Nó gửi trang mới đó cho GPU vẽ lên màn hình.

Hiểu được cơ chế "Cập nhật tuần tự từng bước" (Step-by-step Sequential Update) này là chìa khóa để hiểu tại sao BBDS lại quan trọng: Nếu bước cập nhật này quá nặng, cuốn sổ cái không thể lật sang trang mới kịp giờ, và thế giới trong game sẽ bị "đóng băng".

Reactive: "Bộ phim Tương tác" và Vị Đạo diễn Thời gian thực

Để thực sự hiểu tính chất Phản ứng (Reactive) của mô hình này, hãy tưởng tượng về một cuộn phim nhựa trong rạp chiếu bóng.

Một bộ phim điện ảnh thông thường là một chuỗi các hình ảnh tĩnh (Frame) được sắp xếp sẵn. Tại giây thứ 60, nhân vật chính sẽ ngã xuống. Dù bạn có la hét, bấm nút hay đập vào màn hình, nhân vật vẫn sẽ ngã. Tại sao? Vì phương trình của bộ phim đó thiếu đi một biến số quan trọng: Đầu vào hiện tại (Input(t)Input_{(t)}). Tương lai của bộ phim đã được định đoạt từ trong quá khứ (Pre-rendered).

Unity (và Mô hình Tuần tự Vô hạn) hoạt động khác. Nó là một bộ phim được quay, dựng và chiếu ngay trong thời gian thực (Real-time).

Trong mô hình này, Game Engine đóng vai trò là một Vị Đạo diễn khắc nghiệt. Cứ mỗi 16.6 mili-giây (tương ứng 60 FPS), vị đạo diễn này lại hô to: "Diễn!". Và trong khoảnh khắc cực ngắn đó, một quy trình điên rồ diễn ra:

  1. Lắng nghe Khán giả (Sampling Input): Không giống như rạp phim nơi khán giả chỉ ngồi im, ở đây khán giả (User) cầm trong tay bàn phím, chuột, màn hình cảm ứng. Vị đạo diễn phải hỏi ngay lập tức: "Họ có bấm gì không?". Đây chính là biến số Input(t)Input_{(t)} trong phương trình. Nó đại diện cho Sự hỗn loạn của Tự do. Nếu người chơi bấm "Nhảy", kịch bản của Frame này phải được viết lại ngay lập tức so với dự định ban đầu.

  2. Quay phim tại chỗ (Reactive Calculation): Dựa trên Input(t)Input_{(t)} vừa nhận được và bối cảnh của Frame trước đó (State(t1)State_{(t-1)}), toàn bộ đoàn làm phim (CPU) phải sắp xếp lại đạo cụ (Memory), di chuyển diễn viên (Physics/Logic), và tính toán ánh sáng (Lighting).

    • Nếu không có Input: Nhân vật đứng thở (Idle animation).
    • Nếu có Input: Nhân vật lao về phía trước.

    Điều quan trọng là: Tất cả những việc này phải được thực hiện từ con số 0 trong mỗi Frame. Không có hình ảnh nào được vẽ sẵn cả.

  3. Công chiếu (Rendering): Bức ảnh vừa được tạo ra ngay lập tức được đẩy lên màn hình. Và ngay khi ánh sáng vừa lóe lên, vị đạo diễn lại hô "Cắt! Chuẩn bị Frame tiếp theo!".

Sự "Phản ứng" ở đây chính là khả năng biến đổi tương lai dựa trên đầu vào hiện tại. Mô hình Tuần tự Vô hạn không phải là một đoạn băng video chạy lặp lại. Nó là một State Machine liên tục bị nhiễu loạn bởi Input.

  • Nếu bạn tháo bàn phím và chuột ra, Game trở về thành một bộ phim (Mô hình 1 lặp lại theo quy luật).
  • Khi bạn cắm Input vào, bạn biến nó thành một thực tại sống động.

Chính sự "Phản ứng" này tạo ra áp lực khủng khiếp lên Main Thread. Vì nó phải đợi InputInput (xảy ra ở đầu Frame) thì mới biết phải tính LogicLogic gì, và tính xong thì mới có OutputOutput để vẽ. Nó không thể "đoán mò" hay "làm trước" quá xa được. Nó bị giam cầm trong hiện tại vĩnh cửu của vòng lặp.

Cái giá của sự Tuần tự (The Cost of Sequentiality)

Việc hiểu bản chất phương trình đệ quy State(t)=F(State(t1))State_{(t)} = F(State_{(t-1)}) cho thấy một chân lý khắc nghiệt: Chúng ta không thể tính toán Frame tương lai nếu chưa tính xong Frame hiện tại.

Sự phụ thuộc vào State(t1)State_{(t-1)} (Previous Output) tạo ra một khóa cứng (Hard Dependency) về mặt thời gian. Bạn không thể dùng 2 CPU để tính Frame 1 và Frame 2 cùng lúc, vì Frame 2 cần kết quả của Frame 1 làm đầu vào.

Đây chính là lý do tại sao Unity (và mô hình này nói chung) bị trói buộc vào Main Thread. Main Thread là người giữ cuốn sổ cái (State). Mọi sự thay đổi phải đi qua nó để đảm bảo cuốn sổ cái không bị rách nát vì tranh chấp.

Cơn ác mộng Đồng bộ hóa (The Synchronization Nightmare)

Cái giá của mô hình này không chỉ dừng lại ở việc không tận dụng được đa nhân. Nó còn tạo ra một rào cản vô hình nhưng cực kỳ tốn kém khi chúng ta cố gắng kết hợp với Bất đồng bộ (Async).

Khi bạn đưa một tác vụ ra khỏi Main Thread (để chạy Async), bạn đang bước ra khỏi "dòng chảy thời gian" của Unity. Khi bạn muốn quay lại, bạn phải đối mặt với hai vấn đề chí mạng:

Sự Vô định trong Tiên đoán và "Cái chết" của Shared Memory

Một câu hỏi thường gặp của các kỹ sư Backend khi chuyển sang làm Game là: "Tại sao không dùng Shared Memory? Tại sao không dùng lock, mutex hay atomic để đồng bộ hóa dữ liệu giữa luồng phụ và luồng chính như chúng ta vẫn làm trên Server?"

Câu trả lời nằm ở bản chất Bất định của State Machine trong mô hình Tuần tự Vô hạn.

  1. Tại sao Shared Memory khả thi với Server (Mô hình 3)? Trên Server, trạng thái thường được cô lập (Isolated). Khi một luồng xử lý Request A, nó ít khi đụng chạm tới dữ liệu của Request B. Nếu có tài nguyên chung (như biến đếm toàn cục), chúng ta dùng lock. Nếu một luồng phải chờ lock vài mili-giây, server chỉ chậm đi một chút, không ai chết cả.

  2. Tại sao Shared Memory là "Tự sát" với Unity (Mô hình 3)? Trong Unity, State Machine là một khối khổng lồ dính liền (Monolithic State). Hệ thống Render, Physics, Animation và Logic người dùng đều truy cập vào cùng một vùng nhớ (ví dụ: Transform hierarchy).

    • Sự Bất định: Dù quy trình Loop là định tính (Update -> Render), nhưng tại một thời điểm nano-giây cụ thể trong Update, chúng ta không thể biết chắc chắn Main Thread đang đọc/ghi vào ô nhớ nào.
    • Kịch bản thảm họa: Nếu bạn dùng lock để bảo vệ vùng nhớ chung:
      • Luồng phụ lock tài nguyên để ghi dữ liệu.
      • Đúng lúc đó, Main Thread cần tài nguyên đó để Render. Nó gặp lock và buộc phải dừng lại chờ.
      • Hậu quả: Thời gian chờ này nằm ngoài ngân sách 16.6ms. GPU không nhận được lệnh vẽ. Màn hình đứng yên. Đây không phải là chậm, đây là đứt gãy trải nghiệm.
  3. Sự Bắt buộc của Syncer (The Forced Expensive Sync) Vì không thể dùng lock (vì lock = chờ = lag), chúng ta bị tước đi quyền truy cập trực tiếp vào bộ nhớ. Chúng ta buộc phải chọn con đường duy nhất còn lại: Cơ chế Syncer (Đồng bộ hóa gián tiếp).

    • Thay vì ghi đè lên biến x, luồng phụ phải đóng gói giá trị mới của x vào một "gói tin" (Action/Callback).
    • Gói tin này được đẩy vào một hàng đợi an toàn (Queue).
    • Main Thread, tại một thời điểm định trước (đầu frame), sẽ mở hàng đợi và tự tay cập nhật biến x.

    Cái giá phải trả: Đây là một quy trình cực kỳ tốn kém so với việc ghi bộ nhớ trực tiếp. Nó tốn chi phí đóng gói (Allocation), chi phí hàng đợi, và chi phí ngữ cảnh (Context Switch). Nhưng đó là cái giá bắt buộc để bảo vệ sự toàn vẹn của State Machine trước sự hỗn loạn của đa luồng trong một môi trường thời gian thực khắc nghiệt.

Trong Mô hình này, sự "đồng bộ" (Sync) không phải là một lựa chọn tối ưu, mà là một cơ chế sinh tồn. Chúng ta chấp nhận đi đường vòng (Syncer) tốn kém hơn, chậm hơn về mặt lý thuyết, chỉ để đảm bảo rằng "con đường chính" (Main Thread) không bao giờ bị chặn lại bởi bất kỳ ai.

Nghịch lý Ngân sách: Khi giải pháp duy nhất lại là giải pháp "đắt đỏ" nhất

Chúng ta vừa đi đến kết luận rằng: Để bảo vệ State Machine khỏi bị xâu xé, chúng ta buộc phải từ bỏ Shared Memory (nhanh nhưng nguy hiểm) để chọn cơ chế Syncer (an toàn nhưng cồng kềnh).

Và ngay tại đây, chúng ta va phải bức tường đá lạnh lẽo của thực tại vật lý: Ngân sách thời gian mỏng như lá lúa.

Trong một Server, việc Context Switch tốn 1ms hay 5ms không quá quan trọng vì chúng ta tối ưu cho thông lượng tổng thể. Nhưng trong Unity, toàn bộ thế giới phải được vẽ xong trong 16.6ms (60 FPS).

  1. Cái giá của "Thủ tục Hành chính": Cơ chế Syncer (Queue, Polling, Marshaling) không miễn phí. Nó đòi hỏi CPU phải copy dữ liệu, quản lý hàng đợi, và chuyển đổi ngữ cảnh.
    • Nếu việc "xin đường" để quay lại Main Thread tốn mất 1-2ms (một con số rất thực tế với SynchronizationContext chuẩn), bạn đã ném qua cửa sổ ~10% ngân sách của cả frame chỉ cho các thủ tục hành chính.
  2. Bài toán Kinh tế học tàn nhẫn: Đây là lý do chính khiến chúng ta luôn có xu hướng "Stick to Main Thread" (Bám trụ luồng chính).
    • Giả sử bạn có một logic tốn 3ms.
    • Nếu chạy trên Main Thread: Tốn 3ms. Xong.
    • Nếu chạy Async: Tốn 3ms (tính toán) + 1.5ms (chi phí sync quay về) = 4.5ms.
    • Kết quả: Bạn dùng đa luồng để tối ưu, nhưng cuối cùng lại làm hệ thống chậm đi.
  3. Rào cản khổng lồ của Mô hình Tuần tự Vô hạn: Sự mâu thuẫn nền tảng này tạo ra một rào cản vô hình ngăn cách chúng ta với sức mạnh của phần cứng đa nhân.
    • Chúng ta buộc phải sync để cập nhật State.
    • Nhưng việc sync lại quá đắt so với ngân sách cho phép.

Chính vì vậy, trong mô hình này, Đa luồng thường chỉ hiệu quả với những tác vụ cực nặng và ít tương tác (như Pathfinding đường dài, nén file). Còn với hàng nghìn tác vụ logic nhỏ nhặt hàng ngày (như update AI hành vi, tính toán sát thương), cái giá của việc Sync đã triệt tiêu mọi lợi ích của việc chạy song song. Mô hình Tuần tự Vô hạn là một "nhà tù êm ái". Nó bảo vệ chúng ta khỏi sự hỗn loạn, nhưng nó cũng giam cầm chúng ta trong giới hạn của một luồng duy nhất. Muốn thoát khỏi nhà tù này mà không phá hỏng trật tự của nó, chúng ta không thể dùng sức mạnh thô bạo (Raw Async). Chúng ta cần một chiến thuật thông minh hơn, một cách để "buôn lậu" thời gian qua song sắt nhà tù.

5. Thay đổi Góc nhìn: Từ "Tối ưu hóa Code" sang "Tối ưu hóa Lịch trình"

Sự bế tắc mà chúng ta vừa phân tích ở Mô hình Tuần tự Vô hạn bắt nguồn từ một sai lầm trong tư duy: Chúng ta đang cố gắng làm cho công việc nhanh hơn, trong khi vấn đề thực sự là chúng ta đang làm sai việc vào sai thời điểm.

Các giải pháp truyền thống (tối ưu thuật toán) đều tập trung vào việc làm cho hàm $f(x)$ trong phương trình của chúng ta chạy nhanh hơn. Nhưng nếu bạn có 1000 hàm $f(x)$ cần chạy thì sao? Ngay cả khi mỗi hàm chỉ tốn 0.1ms, tổng cộng vẫn là 100ms – gấp 6 lần ngân sách của một Frame.

Để thoát khỏi nhà tù này, chúng ta cần một lăng kính mới để nhìn vào bản chất của các tác vụ trong một ứng dụng thời gian thực.

Tái định nghĩa Hành động: Tức thời vs. Nền

Không phải mọi dòng code được viết ra đều có giá trị ngang nhau về mặt thời gian. Trong một ứng dụng tương tác, chúng ta có thể phân loại mọi hành động logic chạy trên Main Thread thành hai loại chính:

  • Hành động Tức thời (Immediate Actions): Đây là xương sống của trải nghiệm. Chúng là những tác vụ phải được hoàn thành ngay trong frame hiện tại, bất kể chi phí.
    • Ví dụ: Xử lý Input của người chơi (nhấn nút nhảy), cập nhật vị trí Camera, logic va chạm cốt lõi.
    • Nếu những hành động này bị trì hoãn, người chơi sẽ cảm thấy game "trễ" hoặc "không phản hồi".
  • Hành động Nền (Deferred Actions): Đây là phần lớn các tác vụ còn lại. Chúng cần phải được hoàn thành, nhưng việc chúng hoàn thành ở frame này hay trễ một vài frame sau đó không ảnh hưởng nghiêm trọng đến trải nghiệm cốt lõi.
    • Ví dụ: Cập nhật số tiền trên UI, một con NPC ở xa quyết định đường đi tiếp theo, hệ thống tự động lưu game.
    • Bản chất của những hành động này đã mang tính "bất đồng bộ" về mặt tâm lý. Khi lập trình viên viết code cho chúng, họ đã có sẵn tư duy: "Khi nào nó xong thì xong."

Giải phẫu một Task: Ba giai đoạn của một Hành trình

Khi đã phân loại được các hành động, chúng ta cần mổ xẻ cấu trúc bên trong của chính các tác vụ thực hiện chúng. Một Task bất đồng bộ điển hình (đặc biệt trong Unity) luôn bao gồm 3 giai đoạn:

  1. Giai đoạn 1: Đồng bộ hóa Đầu vào (Pre-process / Main-Thread Sync): Đây là bước lấy "nguyên liệu" từ thế giới Unity. Vì 99% trạng thái game nằm trên Main Thread, giai đoạn này buộc phải chạy trên Main Thread.
    • Ví dụ: Lấy transform.position, đọc giá trị từ một Singleton Manager, lấy maxCoin từ một MonoBehaviour khác.
  2. Giai đoạn 2: Tính toán Cốt lõi (Core Logic / Thread-Agnostic): Đây là "bộ não" của Task. Nó là logic thuần túy, không động chạm đến Unity API.
    • Ví dụ: Một vòng lặp for phức tạp, parse JSON, tính toán thuật toán AI.
    • Phần này có thể chạy ở bất cứ đâu (Main Thread hoặc Sub-thread) mà không gây ra lỗi.
  3. Giai đoạn 3: Đồng bộ hóa Đầu ra (Post-process / Main-Thread Sync): Đây là bước "trình bày" kết quả trở lại thế giới Unity. Nó cũng buộc phải chạy trên Main Thread.
    • Ví dụ: Gán text.text = result, kích hoạt một hiệu ứng hình ảnh, di chuyển một GameObject.

Insight: Nút cổ chai thực sự nằm ở đâu?

Với lăng kính giải phẫu này, chúng ta nhận ra một sự thật quan trọng:

"Trong một Task bất đồng bộ, phần Tính toán Cốt lõi có thể được đẩy ra khỏi Main Thread. Tài nguyên hữu hạn duy nhất mà chúng ta thực sự phải tranh giành là thời gian Main Thread cho hai giai đoạn Pre-process và Post-process."

Vậy, nếu phần lớn các tác vụ là "Hành động Nền", và phần tính toán nặng nhất của chúng có thể chạy ở luồng phụ, tại sao game vẫn lag?

Câu trả lời: Vì chúng ta đang quá vội vã trong việc thực hiện Post-process.

Triết lý cốt lõi của BBDS

Và đây là nơi BBDS (Budget-Based Dynamic Scheduling) ra đời. Nó được xây dựng trên một giả định đơn giản nhưng mang tính cách mạng:

"Nếu một Hành động Nền đã hoàn thành xong phần Tính toán Cốt lõi (ở luồng phụ), thì việc trì hoãn phần Post-process (cập nhật lên UI) thêm vài frame để giữ cho Frame Time ổn định là một sự đánh đổi hoàn toàn chấp nhận được."

BBDS không cố gắng làm cho code chạy nhanh hơn. Nó đóng vai trò là một cảnh sát giao thông thông minh tại "ngã tư" Main Thread. Khi thấy có dấu hiệu ùn tắc (Frame Time sắp hết ngân sách), nó sẽ bật đèn đỏ, tạm giữ các "xe" (Post-process) của Hành động Nền lại, nhường đường cho các "xe cứu thương" (Hành động Tức thời) đi qua.

Bằng cách chấp nhận "Slow Sync" một cách có kiểm soát, BBDS đảm bảo rằng dòng chảy giao thông (Frame Rate) không bao giờ bị đứng lại. Nó biến một hệ thống có nguy cơ sụp đổ thành một hệ thống có khả năng tự điều tiết.

6. Giới thiệu BBDS: Nền kinh tế của Frame

Vậy, làm thế nào để hiện thực hóa triết lý "Trì hoãn Post-process" một cách có hệ thống? Câu trả lời nằm ở việc thay đổi hoàn toàn lăng kính của chúng ta: Hãy ngừng xem Main Thread như một dây chuyền sản xuất, và bắt đầu xem nó như một Nền kinh tế.

Chào mừng đến với BBDS (Budget-Based Dynamic Scheduling) – một mô hình kiến trúc không xem thời gian là một dòng chảy vật lý, mà xem nó như một nguồn vốn hữu hạn cần được quản lý và đầu tư một cách thông minh.

Trong nền kinh tế vi mô này, có ba quy luật sắt không thể phá vỡ:

A. Vốn lưu động (The Budget): Ngân sách của mỗi Nhịp tim

Mỗi Frame, hệ thống không được cấp phát một khoảng thời gian vô tận. Nó chỉ có một lượng vốn cố định, ví dụ 16.6ms. Tuy nhiên, không phải toàn bộ số vốn này đều khả dụng. Một phần lớn đã được "thế chấp" cho các chi phí cố định không thể tránh khỏi: Render, các tác vụ hệ thống của Unity, v.v.

Do đó, lượng vốn thực sự mà chúng ta có thể chi tiêu cho Logic chỉ là một phần nhỏ, ví dụ 8ms. Đây chính là Ngân sách (Budget). Nó là giới hạn chi tiêu cứng. Nếu tiêu hết, mọi hoạt động kinh doanh "không thiết yếu" trong Frame đó phải tạm dừng.

B. Bảng giá (The Cost): Chi phí của việc thay đổi Thế giới

Mỗi khi một Hành động Nền muốn thực hiện giai đoạn Post-process – tức là ghi kết quả của nó vào "Sổ cái" chung của State Machine – nó phải trả một khoản phí. Khoản phí này chính là Cost.

Điều tối quan trọng cần hiểu là: Cost không phải là chi phí của toàn bộ Task. Phần tính toán nặng nhất đã được thực hiện "miễn phí" ở các luồng phụ rồi. Cost chỉ đơn giản là cái giá mà Main Thread phải trả để thực hiện các lệnh Instantiate, text.text = ..., hay SetPosition. Nó là chi phí của việc thay đổi thực tại.

UpdateUICoin() có thể có Cost là 10. InstantiateNewCharacter() có thể có Cost là 200. "Bảng giá" này giúp chúng ta biết được món hàng nào đắt, món nào rẻ.

C. Nhà quản lý Quỹ (The Scheduler): Kế toán trưởng thông minh

Khi chúng ta có Vốn và có Bảng giá, chúng ta cần một người ra quyết định. Đó chính là Scheduler, trái tim của BBDS.

Nó không phải là một vòng lặp while ngây thơ. Nó là một nhà quản lý quỹ đầu tư, một kế toán trưởng thông minh. Vào đầu mỗi Frame, nó nhìn vào "ví tiền" (Budget còn lại) và danh sách các "Yêu cầu chi tiêu" (Post-processes) đang xếp hàng. Sau đó, nó thực hiện một loạt quyết định kinh tế:

  • "Ngân sách còn 8ms. Yêu cầu này tốn 2ms. Chấp thuận." -> Thực thi, ngân sách còn 6ms.
  • "Ngân sách còn 1ms. Yêu cầu tiếp theo tốn 5ms. Từ chối." -> Trì hoãn (defer) yêu cầu này sang đầu Frame sau.

Nói một cách đơn giản: BBDS biến Main Thread từ một "siêu thị mở cửa tự do" (ai vào trước lấy đồ trước, gây tắc nghẽn) thành một "quỹ đầu tư được quản lý chuyên nghiệp". Nó chỉ "giải ngân" cho những "dự án" (tác vụ) mà nó biết chắc rằng sẽ không làm "phá sản" (gây lag) cả hệ thống.

Sự đơn giản của Triết lý và Sự phức tạp của Thực tế

Dĩ nhiên, mô hình cơ bản này đặt ra rất nhiều câu hỏi hóc búa:

  • Làm sao để ước tính Cost cho chính xác?
  • Chuyện gì xảy ra nếu 1000 yêu cầu UpdateUICoin đến cùng lúc? Có chạy cả 1000 lần không?
  • Làm sao để ưu tiên tác vụ quan trọng hơn?

Đây chính là nơi một bản triển khai (implementation) cụ thể như UniCost tỏa sáng. Nó không chỉ áp dụng triết lý BBDS, mà còn xây dựng thêm các cơ chế phức tạp hơn như Hợp nhất Tác vụ (Task Aggregation), Leo thang Ưu tiên (Priority Escalation), và Hệ thống Chẩn đoán (Profiler) để giải quyết những vấn đề thực tế này.

Nhưng trước khi đi vào chi tiết kỹ thuật đó, việc thấu hiểu ba khái niệm cốt lõi (Budget, Cost, Scheduler) chính là bước đầu tiên để thay đổi hoàn toàn cách chúng ta nhìn nhận về tối ưu hóa hiệu năng.

7. Kết luận: Từ Nhận thức đến Hành động

Chúng ta đã đi qua một hành trình dài, từ việc mổ xẻ "nguyên tử" của phần mềm, đến việc giải mã 3 hình thái kiến trúc cốt lõi đã định hình nên thế giới kỹ thuật số. Chúng ta đã thấy sự tự do của Server và "nhà tù êm ái" của Unity.

Quan trọng nhất, chúng ta đã đi đến một insight cốt lõi: Để chinh phục bài toán hiệu năng trong môi trường thời gian thực, chúng ta không thể chỉ tối ưu hóa code, chúng ta phải tối ưu hóa lịch trình.

Triết lý BBDS chính là tấm bản đồ cho cuộc cách mạng tư duy đó. Nó cung cấp một ngôn ngữ mới – Ngân sách, Chi phí, và sự Trì hoãn có kiểm soát – để chúng ta có thể "thương lượng" với Main Thread thay vì chiến đấu với nó.

Nhưng lý thuyết thì luôn màu xám, chỉ có cây đời mãi xanh tươi.

Mô hình BBDS nghe có vẻ trừu tượng, nhưng nó hoàn toàn có thể được hiện thực hóa một cách thanh lịch và mạnh mẽ khi đứng trên vai người khổng lồ UniTask.

Trong bài viết tiếp theo, chúng ta sẽ bước từ "Tại sao" sang "Làm thế nào". Tôi sẽ trình bày UniCost – một bản triển khai (implementation) cụ thể của triết lý BBDS. Chúng ta sẽ cùng nhau viết code cho MainScheduler, định nghĩa [TaskCost] Attribute, và chứng kiến phép màu khi một game đang giật lag trở nên mượt mà chỉ bằng cách thay đổi một vài dòng code. Hãy cùng chờ xem nhé!


Thảo luận mở:

Trong quá trình phân tích, chúng ta đã lướt qua hai chủ đề cực lớn mà tôi tin rằng cũng rất thú vị:

  1. Kiến trúc Server (Mô hình 2): Chúng ta mới chỉ chạm vào bề mặt của các khái niệm như Event Loop, Non-blocking I/O, và triết lý "Let it Crash". Nếu các bạn quan tâm đến việc mổ xẻ sâu hơn cách ASP.NET Core hay Node.js xử lý hàng triệu request, hãy cho tôi biết trong phần bình luận. Nếu nhận được đủ sự quan tâm, tôi sẽ viết một bài riêng về chủ đề này.
  2. Lý thuyết Scheduler: Khái niệm "Scheduler" không mới. Chính Hệ điều hành (Windows, Linux) mà bạn đang dùng để đọc bài viết này cũng có một bộ lập lịch (OS Scheduler) cực kỳ tinh vi. Tuy nhiên, cách tiếp cận "Tiên đoán" (Predictive) dựa trên phân tích trước của BBDS là một hướng đi khác biệt. Nếu các bạn muốn tìm hiểu sâu hơn về lịch sử và lý thuyết của các thuật toán Scheduling, từ FIFO, Round-robin cho đến các cơ chế hiện đại, hãy comment để tôi biết!

Cảm ơn các bạn đã kiên nhẫn đọc đến đây. Hy vọng bài viết đã mang lại một góc nhìn mới mẻ. Hẹn gặp lại trong thế giới của UniCost.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí