RPC and Threads

MIT 6.824: Distributed Systems - Lecture 2: RPC and Threads

Bài giảng về RPC and Threads không tập trung vào một lý thuyết hay một chủ đề đơn lẻ nào, mà đúng hơn là nhằm gợi nhớ lại nhiều mảng kiến thức nền tảng của Computer Science cùng lúc. Vì vậy, về mặt nội dung, cá nhân tớ có cảm giác khá phân mảnh nếu nhìn theo góc độ tìm hiểu một chủ đề đến nơi đến chốn.

Tuy nhiên, nếu hệ thống lại, các khái niệm trong bài có thể được gom về bốn nhóm chính:

  • Execution Model: Process, Thread, Event-driven / Event loop, Context switching,…
  • Memory Model: Shared memory, Garbage Collector, Atomicity, Race condition,…
  • Communication Model: Message passing, RPC, Heartbeat,…
  • Failure Model: Thread crash, Remote failures,…

Tớ sẽ chọn cách đi sâu và hệ thống hóa lại từng nhóm này, vì nhận ra nền tảng Computer Science của mình vẫn còn chưa thực sự vững, và bài giảng này là một cơ hội tốt để xây lại nền tảng đó theo cách có hệ thống hơn.

Brief about Operating System

Trong cuốn sách Operating Systems: Three Easy Pieces, tác giả có chia các khái niệm về OS thành 3 nhóm chính: Virtualizing, Concurrency và Persistence. Và lý do đằng sau có vẻ là mong muốn chính mà hầu hết các công nghệ sau này như virtualization, containerization đều muốn làm đó là:

  • Isolation: Bug của chương trình A không ảnh hưởng đến chương trình B; Một workload không được chiếm sạch CPU / RAM;...
  • Abstraction/Standardization: Chuẩn hóa giao tiếp phần cứng, code có thể chạy trên nhiều phấn cứng khác nhau.
  • Optimization: Tối ưu về resource, tối đa hóa thời gian sử dụng CPU, phân chia memory, tránh IO blocking,...

Đồng thời, các vấn đề này cũng là những vấn đề mà một hệ thống distributed cũng cần quan tâm và cân nhắc, chỉ là ở môi trường khác hơn, nhiều máy hơn và ít linh hoạt hơn.

Trong phạm vi bài giảng trên, giảng viên giới thiệu về một số khái niệm cơ bản của Computer Science để mô tả cách chương trình được thực thi, quản lý dữ liệu và giao tiếp với nhau. Những khái niệm này bao gồm các mô hình thực thi như process và thread, các vấn đề liên quan đến quản lý bộ nhớ và concurrency, cũng như các abstraction đơn giản cho giao tiếp khi hệ thống được mở rộng.

Process

Process được định nghĩa đơn giản là một running program. Một program, về bản chất, chỉ là tập hợp các instructions (và có thể kèm theo static data) được lưu trên disk. Chính hệ điều hành là thành phần chịu trách nhiệm load các bytes này vào memory, cấp phát tài nguyên cần thiết và đưa program vào trạng thái đang chạy, từ đó hình thành một process.

Virtualization

Trong thực tế, một hệ thống máy tính thường chạy nhiều process cùng lúc: trình duyệt web, ứng dụng chat, music player, IDE, game, etc. Tuy nhiên, các tài nguyên vật lý như CPU hay memory là hữu hạn. Người dùng không (và không nên) phải quan tâm tới việc CPU đang bận hay rảnh, họ chỉ đơn giản là chạy chương trình. Điều này đặt ra một bài toán cốt lõi cho hệ điều hành: làm thế nào để tạo ra cảm giác rằng có rất nhiều CPU đang tồn tại, trong khi thực tế chỉ có một hoặc một vài CPU vật lý?

Hệ điều hành giải quyết bài toán này bằng cách virtualize CPU. Thay vì để một process chiếm CPU cho đến khi kết thúc, OS liên tục chuyển CPU giữa các process khác nhau trong những khoảng thời gian rất ngắn. Kỹ thuật này được gọi là time sharing. Nhờ đó, mỗi process có cảm giác như mình đang chạy song song với các process khác, dù thực tế CPU đang được chia sẻ.

Để hiện thực hóa cơ chế này, OS cần hai thành phần quan trọng:

  • Mechanisms: các cơ chế như context switch, cho phép OS tạm dừng một process và tiếp tục process khác.
  • Policies: các thuật toán quyết định process nào sẽ được chạy tiếp theo, dựa trên lịch sử chạy, loại workload, và mục tiêu tối ưu (độ phản hồi hay throughput).

Program Execution Model

Mỗi chương trình khi chạy thực chất chỉ là một chuỗi các machine instruction được thực thi tuần tự bởi CPU. Khi người dùng chạy một program, hệ điều hành sẽ load code và dữ liệu của chương trình từ disk vào memory, tạo ra một process và bắt đầu thực thi từ entry point của chương trình. CPU sau đó liên tục lặp lại chu trình: lấy instruction tiếp theo từ memory, thực thi instruction đó, rồi chuyển sang instruction tiếp theo.

Địa chỉ của instruction tiếp theo được lưu trong một thanh ghi (register) của CPU gọi là Program Counter (PC). Khi một instruction được thực thi xong, PC sẽ được cập nhật để trỏ tới instruction tiếp theo. Nhờ vậy, chương trình được thực thi từng bước một theo đúng thứ tự của code. Trong các chương trình phức tạp hơn như server hay browser, code thường chạy trong một vòng lặp dài (event loop) để liên tục xử lý các sự kiện như network request, input từ người dùng hoặc timer.

Ở mức phần cứng, CPU chỉ có một số lượng register vật lý rất hạn chế (ví dụ program counter, stack pointer và một tập các general-purpose registers). Vì vậy tại một thời điểm, CPU chỉ giữ execution context của process hoặc thread đang chạy trên core đó.

Khi hệ điều hành thực hiện context switch, nó phải lưu lại machine state của process đang chạy (bao gồm Program Counter, stack pointer và các register khác) vào Process Control Block (PCB) trong memory. PCB là cấu trúc dữ liệu mà kernel dùng để quản lý mỗi process, trong đó lưu trữ execution context và các thông tin cần thiết để process có thể được tạm dừng và tiếp tục thực thi sau này. Sau đó kernel sẽ nạp lại register state của process tiếp theo từ PCB của nó vào CPU.

Mô hình đơn giản hoá

Phần này đang mô tả một execution thread bằng một instruction stream. Một program có thể chứa nhiều thread, và các runtime như browser hay server còn có thể tổ chức execution quanh event loop. Ngoài ra, đơn vị mà OS scheduler đưa lên CPU thường là thread/task; process chủ yếu cung cấp address space và các resource dùng chung. Phần Thread bên dưới sẽ tách rõ hơn hai khái niệm này.

Machine State

Để có thể tạm dừng và tiếp tục thực thi một process bất kỳ lúc nào, hệ điều hành cần lưu lại toàn bộ trạng thái thực thi của process đó tại thời điểm hiện tại. Tập hợp các thông tin mô tả trạng thái này được gọi là machine state của process, và nó bao gồm.

  • Memory - address space: vùng nhớ chứa code và dữ liệu mà process có thể truy cập.
  • Registers: program counter (PC), stack pointer và các thanh ghi phục vụ việc thực thi.
  • I/O state: ví dụ như danh sách các file đang mở.

Machine state này không chỉ gắn riêng cho từng process mà nó còn được bảo vệ khỏi process khác. Từ đó OS xem process như là các đơn vị độc lập/cô lập. Cụ thể:

  • Mỗi process có một virtual address space riêng: Trong điều kiện thông thường, Process A không thể tùy ý đọc hoặc ghi vào address space của Process B. Hai process có thể cùng sử dụng một virtual address, chẳng hạn 0x400000, nhưng địa chỉ đó có thể được ánh xạ tới những vùng physical memory hoàn toàn khác nhau. Cơ chế này tạo ra memory isolation giữa các process; nếu muốn chia sẻ dữ liệu, chúng phải sử dụng những cơ chế do OS cung cấp như shared memory hoặc memory-mapped files. Tuy nhiên, memory isolation không có nghĩa các process hoàn toàn độc lập ở mức application. Việc một process bị terminate vẫn có thể ảnh hưởng tới process khác nếu nó đang cung cấp service, giữ shared resource hoặc đóng vai trò parent/supervisor.
  • Registers và execution context là riêng biệt: Mỗi process có context riêng. Khi OS thực hiện context switch, toàn bộ machine state của process hiện tại sẽ được lưu lại, và machine state của process tiếp theo được khôi phục. Điều này đảm bảo process tiếp tục chạy đúng tại instruction đang dở và execution của các process không lẫn vào nhau.
  • I/O state được quản lý theo từng process: OS theo dõi những file, socket và I/O resource mà mỗi process đang sử dụng, thường thông qua file descriptor. Khi process kết thúc, OS đóng các descriptor và thu hồi những resource tương ứng khi chúng không còn được sử dụng.
  • Process không giao tiếp trực tiếp với process khác: Mọi hình thức giao tiếp giữa các process đều phải thông qua abstraction do OS cung cấp, ví dụ: IPC - Inter-Process Communication (pipe, message queue), Shared memory, Socket, RPC (distributed).

Context Switching

Context switching nói chung là việc tạm dừng một đơn vị thực thi (execution context), lưu lại trạng thái hiện tại của nó, và khôi phục trạng thái của một đơn vị thực thi khác để tiếp tục chạy.

Trong OS, context switching giữa các process là hệ quả trực tiếp của việc CPU time sharing. Vì số lượng process thường lớn hơn số CPU vật lý, hệ điều hành buộc phải tạm dừng process đang chạy để nhường CPU cho process khác, từ đó tạo ra cảm giác rằng nhiều process đang chạy song song.

Để thực hiện điều này, OS phải lưu lại toàn bộ machine state của process hiện tại và khôi phục machine state của process tiếp theo trước khi cho nó tiếp tục chạy.

Context switching là một thao tác đắt đỏ vì nhiều lý do. Trước hết, trong quá trình context switch, CPU không thực thi logic ứng dụng mà chỉ chạy code của kernel để lưu và khôi phục machine state của các process. Thời gian này hoàn toàn là overhead của hệ thống.

Ngoài ra, chi phí còn đến từ kiến trúc bộ nhớ của CPU. Bên cạnh các thanh ghi (registers), CPU sử dụng nhiều tầng cache (L1, L2, L3) để tăng tốc truy cập dữ liệu so với việc đọc trực tiếp từ RAM. Các cache này thường chứa instruction và dữ liệu của process đang chạy. Khi CPU chuyển sang process khác, dữ liệu trong cache và TLB1 nhiều khả năng không còn hữu ích nữa. Process mới phải nạp lại instruction, dữ liệu và page mapping của nó từ các tầng cache thấp hơn hoặc từ RAM, dẫn đến nhiều cache miss và memory stall.

Thread

Process giúp hệ điều hành đạt được isolation và quản lý tài nguyên an toàn. Tuy nhiên trong nhiều chương trình thực tế, một process thường phải thực hiện nhiều hoạt động có thể xảy ra đồng thời. Ví dụ, một web server cần xử lý nhiều request cùng lúc, hoặc một ứng dụng vừa đọc dữ liệu từ network vừa xử lý logic bên trong.

Nếu chỉ sử dụng một luồng thực thi duy nhất, chương trình sẽ phải xử lý các công việc này tuần tự. Đặc biệt khi một thao tác I/O bị blocking (ví dụ chờ dữ liệu từ network hoặc disk), toàn bộ chương trình sẽ bị dừng lại.

Một cách tiếp cận là tạo nhiều process khác nhau để thực thi các công việc này. Tuy nhiên process tương đối nặng và không chia sẻ chung memory, khiến việc giao tiếp giữa chúng phải thông qua các cơ chế IPC phức tạp.

Để giải quyết bài toán concurrency bên trong một process, hệ điều hành cung cấp khái niệm thread. Thread là một đơn vị thực thi nhẹ hơn process, cho phép một chương trình có nhiều execution flow cùng tồn tại.

Các thread trong cùng một process chia sẻ chung address space và dữ liệu, nhưng mỗi thread vẫn có execution context riêng như program counter, stack và register set. Nhờ đó nhiều thread có thể thực thi đồng thời, và trên các hệ thống nhiều CPU core chúng có thể chạy song song thực sự trên phần cứng.

Để hiểu rõ vai trò của thread, cần nhìn vào cách chương trình được thực thi ở mức phần cứng. CPU thực chất không trực tiếp chạy process mà chạy thread. Mỗi CPU core tại một thời điểm chỉ thực thi một thread, và execution context của thread đó được lưu trong các register của CPU như program counter, stack pointer và các general-purpose registers.

Vì vậy, context switching trong hệ điều hành thực chất là quá trình chuyển CPU từ thread này sang thread khác. Nếu hai thread thuộc cùng một process, hệ điều hành chỉ cần lưu và khôi phục register state của thread. Nhưng nếu chuyển sang thread thuộc process khác, hệ điều hành còn phải thay đổi address space và các cấu trúc quản lý memory như page table, khiến chi phí context switch lớn hơn.

Có thể hình dung mối quan hệ giữa CPU, thread và memory như sau:

Trong mô hình này, mỗi thread có execution context riêng (registers và stack), nhưng nhiều thread trong cùng một process vẫn chia sẻ chung address space của process. Nhờ vậy các thread có thể truy cập cùng một code và dữ liệu, trong khi vẫn giữ được trạng thái thực thi độc lập với nhau.

Shared Memory

Để hiểu rõ hơn mô hình shared memory này, cần nhìn vào cách memory của một process được tổ chức. Một process thường có một address space bao gồm nhiều vùng khác nhau như code segment, global/static variables, heap và stack. Cần nói trước rằng đây là một simplified model — đủ để reason về concurrency, nhưng chi tiết thật phụ thuộc vào OS, compiler và runtime.

Trong đó code, global variables và heap được chia sẻ giữa các thread trong cùng một process. Nhờ vậy các thread có thể truy cập cùng một dữ liệu và giao tiếp với nhau thông qua shared memory.

Ngược lại, mỗi thread có một stack riêng để lưu trạng thái thực thi của mình, bao gồm call stack của các function đang chạy, các biến cục bộ (local variables) và địa chỉ quay lại (return address) khi một function kết thúc.

Nhờ cơ chế shared memory, các thread có thể giao tiếp với nhau một cách trực tiếp bằng cách đọc và ghi vào cùng một vùng dữ liệu. Đây là một lợi thế lớn so với giao tiếp giữa các process, vốn thường phải thông qua các cơ chế IPC như pipe, socket hay message queue.

Tuy nhiên, việc nhiều thread cùng truy cập và thay đổi một vùng dữ liệu chung cũng tạo ra các vấn đề về concurrency. Khi nhiều thread có thể đồng thời đọc và ghi vào cùng một biến hoặc cấu trúc dữ liệu trong shared memory, kết quả cuối cùng của chương trình có thể trở nên khó dự đoán nếu các thao tác này không được đồng bộ hóa đúng cách.

Trong distributed system, vấn đề tương tự vẫn tiếp tục xuất hiện, nhưng shared memory lúc này được thay bằng shared state giữa nhiều process hoặc nhiều node khác nhau.

Tuỳ vào cách triển khai, shared state này có thể được lưu trong database, cache, object storage hoặc distributed log và được đồng bộ giữa nhiều machines thông qua replication.

Shared State Implementations Example

Trong Kubernetes, etcd thường đóng vai trò như một shared state store lưu toàn bộ cluster state (pods, deployments, config, leases,…). Các component như API Server, Scheduler hay Controller Manager đều đọc và cập nhật state thông qua etcd để phối hợp hoạt động với nhau.

Trong khi đó, một số hệ thống distributed khác như ZooKeeper, Kafka (KRaft) hoặc Raft-based systems lại tổ chức shared state dưới dạng append-only log được replicate giữa nhiều node. Thay vì các node cùng sửa trực tiếp một vùng memory dùng chung, state được thay đổi thông qua một sequence các event hoặc state transition được ghi tuần tự vào log. Các node sau đó replay log này để tái tạo lại cùng một logical state.

Khi nhiều execution flow cùng đọc và thay đổi shared state này, hệ thống cũng phải đối mặt với các vấn đề tương tự race condition như lost update, stale read hoặc inconsistency giữa các replicas.

Vì vậy, bài toán không còn chỉ là đồng bộ truy cập memory bên trong một process, mà mở rộng thành bài toán coordination và consistency giữa nhiều execution flow phân tán qua network.

Race Condition

Trong thực tế, nhiều thao tác trong chương trình tưởng như là một bước đơn giản, tuy nhiên ở mức CPU chúng thường được thực hiện thông qua nhiều machine instruction khác nhau. Trong đó, một số instructions đảm bảo tính atomicity, nhưng một số khác thì không.

Trong trường hợp đó, các instruction của nhiều thread có thể bị xen kẽ với nhau theo nhiều cách khác nhau tùy vào thời điểm mỗi thread được CPU thực thi. Vì vậy kết quả cuối cùng của chương trình có thể thay đổi giữa các lần chạy, dù logic của chương trình không hề thay đổi. Hiện tượng này được gọi là race condition.

Trong bài giảng, giảng viên có minh họa bằng một ví dụ đơn giản.

Giả sử chương trình có một biến đếm dùng chung giữa nhiều thread và mỗi thread thực hiện phép toán counter++ .

Ở mức code, đây chỉ là một phép tăng biến đếm. Tuy nhiên trong lecture, giảng viên minh họa rằng khi biên dịch xuống mức machine instruction, thao tác này thực chất được thực hiện qua nhiều bước, ví dụ:

mov counter, %rax 
add $1, %rax 
mov %rax, counter

Các instruction này lần lượt thực hiện:

  1. Đọc giá trị của counter từ memory vào register %rax
  2. Thực hiện phép cộng +1 trên register
  3. Ghi giá trị mới từ register trở lại memory

Mỗi bước trên là một instruction riêng biệt. Vì vậy toàn bộ thao tác counter++ thực chất không phải là một hành động atomic ở mức CPU. Nếu hai thread cùng thực hiện counter++ trên cùng một biến trong shared memory, các instruction của chúng có thể bị xen kẽ với nhau theo nhiều cách khác nhau.

Ví dụ:

Thread A: read counter → 0
Thread B: read counter → 0
Thread A: add 1 → 1
Thread A: write → 1
Thread B: add 1 → 1
Thread B: write → 1

Kết quả cuối cùng của counter lúc này là 1 thay vì 2. Đây là một lost update: một trong hai lần tăng biến biến mất vì cả hai thread đều đọc cùng một giá trị cũ.

Để tránh tình huống vừa rồi, các chương trình concurrent cần một cơ chế đảm bảo rằng tại một thời điểm chỉ có một thread được phép truy cập hoặc thay đổi một vùng dữ liệu quan trọng. Cơ chế phổ biến nhất để làm điều này là locks (hay còn gọi là mutual exclusion). Cần lưu ý là lock xử lý được đúng nhóm data race như trên; các race phụ thuộc thứ tự/timing ở mức logic thì phải giải quyết bằng cách thiết kế lại luồng xử lý, chứ không chỉ bằng việc thêm lock.

Trong distributed system, các vấn đề tương tự vẫn tiếp tục xuất hiện, nhưng thay vì nhiều thread cùng thao tác trên shared memory, hệ thống phải xử lý nhiều process hoặc nhiều node cùng đọc và thay đổi shared state thông qua network.

Tuy nhiên, khác với môi trường local memory, distributed system không có shared memory trực tiếp hay một execution order tuyệt đối được chia sẻ giữa mọi node. Mỗi node chỉ quan sát được state của hệ thống thông qua message và network communication, trong khi message có thể bị delay, arrive out-of-order hoặc được retry nhiều lần.

Việc các node tạm thời nhìn thấy state khác nhau chủ yếu đến từ replication delay và communication delay, cùng với consistency model mà hệ thống chọn: một write đã commit ở nơi này có thể chưa được apply ở nơi khác tại thời điểm đọc.

Clock drift là một vấn đề riêng, nằm cạnh chứ không phải nguyên nhân của việc trên: nó khiến physical timestamp không đáng tin cậy để làm căn cứ sắp thứ tự giữa các event xảy ra ở những machine khác nhau. Nói cách khác, delay giải thích vì sao ta thấy dữ liệu cũ, còn clock drift giải thích vì sao ta không thể chỉ so timestamp để biết event nào xảy ra trước.

Ví dụ điển hình cho lost update ở tầng distributed: hai client cùng đọc một record ở version v5, mỗi bên tính toán trên giá trị đó rồi ghi lại. Cả hai write đều hợp lệ khi xét riêng lẻ, nhưng bên ghi sau đè mất thay đổi của bên ghi trước, và hệ thống không có cách nào tự biết rằng có một update đã bị mất. Đây là lý do các hệ thống thường yêu cầu conditional write theo version (chỉ ghi nếu version vẫn là v5) thay vì ghi vô điều kiện.

Khác với local concurrency - nơi CPU vẫn thực thi instructions theo một thứ tự cụ thể tại một thời điểm - distributed system không tồn tại một global execution order được chia sẻ tức thời giữa toàn bộ hệ thống. Điều này khiến các bài toán coordination và consistency trong distributed system thường phức tạp hơn đáng kể so với môi trường local.

Locks

Lock là cơ chế phổ biến để bảo vệ shared mutable state và ngăn data race trong critical section. Nguyên nhân cốt lõi của loại race này nằm ở việc các thao tác tưởng như đơn giản (ví dụ counter++) thực chất được thực hiện qua nhiều machine instruction khác nhau, và các instruction này có thể bị xen kẽ giữa nhiều thread. Vì vậy, cần một cơ chế đảm bảo toàn bộ chuỗi thao tác được thực thi một cách liền mạch (atomic ở mức logic), không bị thread khác chen vào.

Locks giải quyết vấn đề này bằng cách bảo vệ một critical section, đảm bảo rằng tại một thời điểm chỉ có một thread được phép thực thi đoạn code đó (mutual exclusion).

Ở mức triển khai, một lock thường được biểu diễn như một shared memory object lưu trạng thái (ví dụ free hoặc taken). Để truy cập vào critical section, các thread cần acquire lock, tức là cố gắng chuyển trạng thái của lock từ free sang taken.

Thao tác này được thực hiện thông qua các atomic read-modify-write instructions (như test-and-set hoặc compare-and-swap), đảm bảo việc kiểm tra và cập nhật trạng thái diễn ra như một bước không thể bị xen kẽ.

  • Với test-and-set (TAS), thread sẽ atomically đặt lock thành taken và kiểm tra giá trị trước đó. Nếu giá trị cũ là free, thread acquire thành công; nếu không, nó phải retry.
  • Với compare-and-swap (CAS), thread chỉ cập nhật trạng thái nếu giá trị hiện tại vẫn là free. Nếu trạng thái đã bị thread khác thay đổi, thao tác thất bại và thread phải retry.

Nhờ các atomic instructions này, nhiều thread có thể cùng cạnh tranh để acquire lock, nhưng chỉ một thread có thể thành công tại một thời điểm. Các thread còn lại sẽ tiếp tục retry (spin) hoặc chuyển sang trạng thái blocking, tùy theo cách implement của hệ thống.

Khi một thread không acquire được lock, nó có thể chờ theo hai cách chính:

  • Spin (busy-wait): thread liên tục retry để acquire lock, giữ CPU ở trạng thái active. Cách này tránh được chi phí context switch và có thể đạt độ trễ thấp nếu lock được release nhanh, nhưng gây lãng phí CPU khi thời gian chờ dài.
  • Blocking: thread được OS đưa vào trạng thái sleep và chỉ được đánh thức khi lock được release. Cách này tiết kiệm CPU, nhưng phải trả giá bằng chi phí context switch và độ trễ khi thread được đánh thức.

Hai cách tiếp cận này thể hiện một trade-off cơ bản: spin ưu tiên latency, trong khi blocking ưu tiên hiệu quả sử dụng CPU.

Locking in Distributed Systems

Trong distributed system, bài toán locking không còn chỉ xoay quanh shared memory giữa nhiều thread trong cùng một process, mà trở thành bài toán coordination giữa nhiều process hoặc nhiều node cùng thao tác lên shared state thông qua network.

Một cách tiếp cận đơn giản là sử dụng single coordinator hoặc single writer để serialize toàn bộ thao tác ghi. Ví dụ, trong Apache Spark, Driver đóng vai trò như một coordinator quản lý execution state và phân phối task cho các executors. Thay vì để các executors tự do thay đổi shared state cùng lúc, phần lớn coordination được thực hiện tập trung thông qua Driver.

Một hướng tiếp cận phổ biến khác là sử dụng một shared state store có độ trễ rất thấp như Redis để triển khai distributed lock. Về mặt ý tưởng, cách tiếp cận này khá giống với lock trong Operating System: nhiều node cùng cố gắng acquire một shared lock object, và chỉ một node được phép chuyển trạng thái lock từ free sang taken tại một thời điểm.

Tuy nhiên, khác với local memory, distributed lock phải đối mặt với nhiều vấn đề phức tạp hơn như network delay, retry, node crash hoặc partial failure. Ví dụ, một node có thể acquire lock thành công nhưng bị crash trước khi release; hoặc network partition có thể khiến nhiều node cùng tin rằng mình đang giữ lock hợp lệ.

Vì vậy, trong các hệ thống yêu cầu consistency cao hơn, shared state thường được replicate giữa nhiều node và coordination được thực hiện thông qua các consensus algorithm như Raft hoặc Paxos. Điều consensus cung cấp là một committed order: một quorum thống nhất về thứ tự các state transition được commit, sau đó các replica còn lại theo kịp trạng thái đó. Hai điểm cần chú ý là consensus không chờ mọi node đồng thuận (chỉ cần quá bán, nên một số follower có thể offline hoặc lag mà hệ thống vẫn commit được), và bản thân Raft/Paxos cũng không hiểu khái niệm "lock". Lock semantics được xây phía trên nó: một coordination service như etcd hay ZooKeeper biểu diễn lock như các operation trong replicated state machine của nó (tạo key kèm lease, so sánh revision,…), rồi consensus chỉ đảm bảo thứ tự cho các operation đó.

Blocking

Blocking xảy ra khi một execution flow không thể tiếp tục thực thi và buộc phải chờ một operation hoàn thành trước khi tiếp tục chạy. Điều này thường xảy ra khi thread không acquire được lock hoặc phải chờ I/O operation như đọc file, network request hoặc database response.

Nếu thread tiếp tục liên tục retry trong lúc chờ (spin/busy-wait), CPU vẫn bị giữ ở trạng thái active dù không thực hiện thêm computation hữu ích nào. Trong trường hợp thời gian chờ dài hơn, hệ điều hành thường sẽ chuyển thread sang trạng thái blocking (sleep), cho phép CPU được sử dụng cho thread hoặc process khác thay vì lãng phí vào việc chờ. Khi operation hoàn thành hoặc lock được release, OS sẽ đánh thức thread và đưa nó quay trở lại execution.

Một trong những nguyên nhân phổ biến nhất của blocking là I/O operations như đọc file, network communication hoặc database request. Ví dụ, một web server có thể cần gửi query tới database để lấy thông tin user. Khi thread gọi query này, request sẽ được gửi qua network tới database server và việc xử lý thực tế có thể mất một khoảng thời gian đáng kể: database cần parse query, tìm dữ liệu trong memory hoặc disk, sau đó gửi response ngược trở lại qua network.

Trong khoảng thời gian đó, thread không cần liên tục tự kiểm tra xem response đã quay về hay chưa. Thay vào đó, kernel có thể đưa thread vào trạng thái sleep và cho CPU tiếp tục xử lý workload khác. Điều này dẫn tới một câu hỏi quan trọng: làm thế nào hệ điều hành biết thời điểm operation đã hoàn thành để đánh thức thread trở lại?

Trong khi thread đang sleep, network card (NIC) và kernel network stack vẫn tiếp tục hoạt động ở background để nhận packet từ network. Khi response từ database thực sự quay trở lại, NIC sẽ nhận packet và gửi interrupt cho CPU để thông báo rằng đã có network event mới xảy ra.

CPU lúc này sẽ tạm dừng execution hiện tại để chạy interrupt handler bên trong kernel. Kernel kiểm tra socket tương ứng, cập nhật trạng thái của operation và đánh thức thread đang chờ bằng cách đưa nó quay trở lại scheduler queue để tiếp tục thực thi phần logic còn lại.

NIC có processor/controller riêng.

Trong máy tính hiện đại, CPU không phải là thành phần duy nhất có khả năng xử lý công việc. Nhiều thiết bị ngoại vi như card mạng (NIC), SSD controller hay GPU đều có chip/controller riêng để tự xử lý một phần công việc song song với CPU.

Ví dụ với network I/O, sau khi application gửi request, CPU và kernel chỉ cần chuẩn bị dữ liệu và giao nhiệm vụ cho NIC. Từ thời điểm đó, NIC có thể tiếp tục xử lý việc gửi và nhận packet ở background mà không cần CPU phải trực tiếp theo dõi từng bước.

Khi packet hoặc response thực sự quay trở lại, NIC mới gửi interrupt hoặc event notification để báo cho CPU biết rằng có dữ liệu mới cần xử lý. Nhờ vậy, CPU có thể tiếp tục chạy workload khác thay vì phải đứng chờ network operation hoàn thành

Về bản chất, blocking không phải là thread liên tục polling để kiểm tra operation đã hoàn thành hay chưa, mà là kernel và hardware chủ động notify khi event phù hợp xảy ra. Ý tưởng này cũng là nền tảng cho nhiều mô hình concurrency như event-driven hoặc event loop.

Concurrency & Coordination Models

Sau khi đã có một ít nền về process, thread, memory sharing, race condition và blocking, câu hỏi thực tế hơn là: vậy một chương trình thật sự tổ chức concurrency như thế nào?

Trong thực tế, có rất nhiều cách triển khai concurrency khác nhau. Bản thân tớ cũng không thể nắm hết tất cả các phương án, và cũng không thực sự muốn đi quá sâu vào chủ đề này; mục tiêu chủ yếu là củng cố mental model của bản thân. Vì vậy phần này chỉ tập trung vào một số mô hình chính như multi-process, multi-thread, event loop, và có thể là coroutine — những cách mà thầy nhắc tới trong bài giảng.

Concurrency và Parallelism

Giả sử một server đang xử lý hai request A và B.

Nếu chỉ có một CPU core, server không thể thực thi code của cả hai request tại đúng cùng một thời điểm. Tuy nhiên, nó vẫn có thể xử lý xen kẽ: chạy A một lúc, A chờ network, CPU chuyển sang chạy B; B chờ database, CPU lại quay về chạy tiếp A. Trong khoảng thời gian đó, cả A và B đều chưa hoàn thành và cùng đang được hệ thống xử lý. Đây là concurrency: nhiều công việc cùng tồn tại trong một khoảng thời gian, nhưng chúng có thể thay phiên nhau sử dụng CPU.

Nếu máy có nhiều CPU core, A có thể chạy trên một core trong khi B chạy trên core khác tại cùng một thời điểm. Đây là parallelism: nhiều công việc thật sự được thực thi đồng thời.

Nói ngắn gọn:

  • Concurrency: nhiều công việc cùng đang được xử lý, có thể bằng cách xen kẽ.
  • Parallelism: nhiều công việc thật sự chạy đồng thời.

Hay gọn hơn nữa: concurrency là overlap theo khoảng thời gian, còn parallelism là chạy cùng đúng một thời điểm.

Multi-process

Multi-process là cách làm khá dễ hình dung: thay vì để nhiều đơn vị công việc chạy chung trong một process, hệ thống tách chúng ra nhiều process khác nhau. Một process chính nhận hoặc phân phối việc, sau đó có thể fork() process con để xử lý, hoặc dùng sẵn một nhóm worker process đã được tạo trước.

Điểm mạnh lớn nhất của model này là isolation. Mỗi worker có address space riêng, nên workload A và workload B không vô tình đọc/ghi cùng một biến trong heap như khi dùng thread. Nếu chương trình không chủ động dùng shared memory, phần lớn bug kiểu shared mutable state đơn giản là không có đất xảy ra. Một worker leak memory hoặc bị kill cũng thường không kéo sập worker khác; process quản lý phía trên chỉ cần tạo worker mới thay thế.

Đổi lại, process là một execution unit tương đối nặng. Mỗi process cần address space, page table, file descriptor table, stack và các metadata riêng của kernel. Vì vậy nếu tạo quá nhiều process, hệ thống sẽ tốn memory và kernel cũng phải quản lý nhiều hơn. Ngoài ra, vì memory bị tách riêng, các worker không thể chia sẻ object bằng cách truyền pointer như thread. Nếu cần dùng chung state như cache, counter hoặc session, chúng phải đi qua IPC, shared memory có kiểm soát, hoặc một service/store bên ngoài như Redis/PostgreSQL.

Ví dụ: Gunicorn & PostgreSQL

Gunicorn là một HTTP server thường được dùng để chạy Python web app như Flask hoặc FastAPI trong production. Thay vì để toàn bộ app chạy trong một process duy nhất, Gunicorn có thể tạo nhiều worker process. Mỗi worker là một process riêng, nhận một phần workload từ process chính và xử lý độc lập trong address space của nó.

Ví dụ với sync worker, mỗi worker thường chỉ xử lý một HTTP request tại một thời điểm. Nếu một request chạy quá lâu, bị kẹt ở database call, gọi API ngoài không timeout, hoặc rơi vào vòng lặp vô hạn, worker đó sẽ bị giữ lại và không nhận thêm việc mới. Nếu có timeout, Gunicorn master có thể kill worker bị treo rồi spawn worker mới; request đang chạy sẽ fail, nhưng phần còn lại của app vẫn còn các worker khác tiếp tục xử lý. Nếu không có timeout hoặc toàn bộ worker đều bị kẹt, hệ thống sẽ hết worker rảnh và request mới phải chờ hoặc bị reject.

Đổi lại, mỗi worker là một process riêng nên tốn memory riêng. Nếu các worker cần chia sẻ state thì không thể truyền object trực tiếp như thread; chúng phải đi qua database, cache như Redis, hoặc một cơ chế IPC khác.

PostgreSQL cũng là một ví dụ gần với hướng multi-process: nhìn đơn giản thì mỗi client connection thường được gắn với một backend process riêng để xử lý query. Tuy nhiên đằng sau PostgreSQL phức tạp hơn khá nhiều, nên ví dụ này chỉ nên được hiểu ở mức mental model chung.

Multi-thread

Nếu multi-process tách workload bằng nhiều process riêng, thì multi-thread đi theo hướng nhẹ hơn: một process có thể tạo nhiều thread, và mỗi thread là một execution flow bên trong cùng address space của process đó.

Điểm mạnh của multi-thread là việc tạo và switch thread thường rẻ hơn process, trong khi các thread vẫn có thể tận dụng nhiều CPU core. Vì các thread cùng nằm trong một process, chúng cũng có thể chia sẻ dữ liệu rất dễ: cùng đọc/ghi object trên heap, cùng dùng chung connection pool, cache nội bộ, config, file descriptor, v.v.

Nhưng chính chỗ “dễ chia sẻ” này cũng là phần nguy hiểm nhất. Khi nhiều thread cùng chạm vào mutable state, chương trình phải tự bảo vệ bằng lock, atomic, queue hoặc các cơ chế đồng bộ khác. Nếu không, các bug kiểu race condition, deadlock hoặc visibility issue rất dễ xuất hiện, và thường khó tái hiện vì chúng phụ thuộc vào interleaving cụ thể giữa các thread.

Nói ngắn gọn: multi-thread đổi bớt isolation của process để lấy chi phí thấp hơn và chia sẻ state dễ hơn. Nó rất mạnh cho workload cần chạy song song hoặc cần nhiều execution flow trong cùng một process, nhưng correctness bắt đầu phụ thuộc nhiều hơn vào cách chương trình quản lý shared state.

Thread per workload

Cách dùng multi-thread trực tiếp nhất là gán mỗi đơn vị công việc cho một thread riêng. Với server truyền thống, biến thể quen thuộc là thread-per-request: mỗi request được một OS thread xử lý từ đầu tới cuối.

Điểm dễ chịu của model này là code đọc rất tự nhiên. Logic xử lý được viết tuần tự từ trên xuống dưới: gọi database rồi chờ kết quả, gọi service khác rồi chờ tiếp. Khi một thread block ở I/O, OS scheduler tự đưa thread khác vào chạy, nên lập trình viên gần như không phải tự viết logic “nhường chỗ”.

Đổi lại, model này không scale tốt nếu số workload đồng thời quá lớn. Mỗi thread cần stack riêng và metadata riêng; nếu có hàng chục nghìn việc cùng đang chờ I/O, memory có thể bị tiêu tốn chủ yếu chỉ để giữ các thread đang chờ. Số thread càng lớn thì scheduler cũng càng phải context switch nhiều hơn, kéo theo overhead như đã nói ở Context Switching.

Chờ I/O không cần một thread

Một thread đang block chờ network response thực ra không làm gì cả; nó chỉ đang giữ lại vị trí đang dở của computation để khi dữ liệu về thì chạy tiếp.

Vì vậy thread-per-workload dùng một tài nguyên khá đắt — OS thread kèm stack — để lưu một thứ khá nhẹ: trạng thái đang chờ của một workload. Thread pool, event loop và coroutine là các cách khác nhau để xử lý đúng sự bất cân xứng này.

Thread pool

Thay vì tạo một thread mới cho mỗi workload, chương trình tạo sẵn một số lượng worker thread và dùng lại chúng để xử lý nhiều workload khác nhau.

Khi một workload mới đến, nó được đưa vào queue. Worker nào rảnh sẽ lấy một workload ra xử lý. Sau khi xử lý xong, worker quay lại lấy workload tiếp theo. Ví dụ, nếu pool có 10 worker thì tối đa 10 workload có thể được xử lý bởi worker thread tại cùng một thời điểm. Những workload đến sau phải chờ trong queue cho tới khi có worker rảnh.

Thread pool vẫn cho phép viết blocking code theo cách tuần tự:

nhận workload
  -> gọi database và chờ response
  -> xử lý kết quả
  -> trả kết quả

Khi một worker block để chờ I/O, các worker khác vẫn có thể tiếp tục xử lý workload của chúng. Tuy nhiên, nếu tất cả worker đều đang block, workload mới sẽ không được xử lý ngay mà phải chờ trong queue. Điểm chính của thread pool là giới hạn số thread được tạo ra. Nhờ đó, chương trình kiểm soát được lượng memory và số execution flow đang hoạt động, thay vì tạo thread không giới hạn khi workload tăng lên.

thread-per-workload:
  mỗi việc đến -> tạo thread mới

thread pool:
  mỗi việc đến -> xếp hàng -> dùng một worker có sẵn

Ví dụ: Tomcat thread pool

Tomcat là web server/application server phổ biến trong Java. Khi có HTTP request đi vào, Tomcat không tạo một thread mới cho từng request. Thay vào đó, nó dùng một pool worker thread có sẵn. Request mới được đưa vào queue, worker thread nào rảnh thì lấy request ra xử lý.

Bên trong worker, code application vẫn có thể viết theo kiểu blocking quen thuộc: gọi database thì chờ database, gọi service ngoài thì chờ service ngoài. Nếu một request bị kẹt quá lâu, worker xử lý request đó cũng bị giữ lại. Nhưng số worker thread vẫn bị giới hạn bởi cấu hình của pool, ví dụ maxThreads, nên hệ thống không tạo thread vô hạn khi traffic tăng.

Event loop

Event loop chọn một hướng khác với thread pool. Thay vì tạo nhiều thread để nhiều workload có thể block song song, nó cố gắng không để thread chính bị block ngay từ đầu.

Ý tưởng là: khi gặp một operation phải chờ I/O, chương trình không đứng yên chờ kết quả. Nó đăng ký với runtime/kernel rằng “khi việc này xong thì gọi tiếp đoạn code này”, rồi trả quyền điều khiển lại cho event loop. Trong lúc I/O đang chờ ở bên dưới, event loop có thể lấy việc khác ra chạy.

Điểm quan trọng là trên một event loop, tại một thời điểm thường chỉ có một callback/user code đang chạy. Vì vậy ta không cần nhiều thread chỉ để giữ hàng nghìn workload đang chờ I/O. Mỗi workload đang chờ chỉ cần một ít state trong memory: đang chờ cái gì, và khi xong thì chạy tiếp đoạn nào.

Ví dụ: Node.js trong bài giảng

Node.js là ví dụ quen thuộc của event loop. Code JavaScript của ứng dụng thường chạy trên một main thread. Khi handler cần đọc file, gọi network, hoặc chờ database response, nó không nên block main thread để chờ. Thay vào đó, operation được giao xuống runtime/kernel; khi kết quả sẵn sàng, callback/promise continuation được đưa lại vào event loop để chạy tiếp.

Nhờ vậy một Node.js process có thể quản lý nhiều HTTP request đang chờ I/O cùng lúc mà không cần tạo một thread riêng cho từng request. Nhưng nếu một handler chạy CPU-bound quá lâu, dùng API synchronous, hoặc parse một payload quá lớn ngay trên main thread, event loop sẽ bị giữ lại. Khi đó các request khác cũng không được xử lý tiếp dù chúng không liên quan. Đây là ý của câu “đừng block event loop”.

Event loop vì vậy rất hợp với I/O-bound workload: nhiều việc chủ yếu là chờ network, disk, timer hoặc database. Nhưng nó không miễn phí cho CPU-bound workload. Nếu một đoạn code cần tính toán lâu, nó phải được chia nhỏ, chuyển sang worker thread/process khác, hoặc dùng một runtime/model khác phù hợp hơn.

Cũng cần lưu ý: event loop giảm rất nhiều vấn đề data race kiểu nhiều OS thread cùng sửa một object tại đúng cùng lúc, nhưng không xoá hết mọi loại race. Các callback vẫn có thể chạy theo thứ tự phụ thuộc vào thời điểm I/O hoàn thành, nên ứng dụng vẫn có thể gặp logical race nếu nhiều async flow cùng đọc/ghi chung một state theo thứ tự không lường trước.

Coroutine

Trước hết, routine là tên gọi chung cho một đoạn code thực hiện một công việc cụ thể. Function là một dạng routine quen thuộc. Khi một function thông thường được gọi, nó bắt đầu chạy và cuối cùng return kết quả cho caller. Nếu function thực hiện một blocking operation, chẳng hạn chờ database response, thread đang chạy function đó cũng phải chờ. Coroutine khác ở chỗ nó có thể tạm dừng khi chưa hoàn thành, nhường execution thread cho công việc khác, rồi tiếp tục chạy từ đúng vị trí cũ khi điều kiện cần thiết đã sẵn sàng.

Điểm quan trọng là coroutine không phải một OS thread. Coroutine vẫn cần một thread để thực thi code, nhưng khi coroutine đang chờ I/O, runtime có thể dùng thread đó để chạy coroutine khác. Nhờ vậy, code vẫn có thể được viết theo trình tự dễ đọc:

gọi database
  -> chờ kết quả
  -> xử lý kết quả

Nhưng việc “chờ” không nhất thiết giữ một OS thread đứng yên. Runtime có thể tạm dừng coroutine và tiếp tục nó khi kết quả quay về.

Có thể hình dung coroutine như một dạng “sub-thread” ở tầng runtime: nó là một execution flow nhẹ hơn OS thread, được runtime tạm dừng/resume và phân phối lên các OS thread thật. OS scheduler chia CPU core cho các thread; còn runtime scheduler chia các thread đó cho nhiều coroutine.

Ví dụ: goroutine trong Go

Goroutine trong Go có mental model gần với ý tưởng này: Go runtime tạo một execution flow nhẹ để chạy một công việc. Nhiều goroutine có thể được runtime phân phối lên một số lượng OS thread nhỏ hơn. Khi một goroutine chờ network I/O, runtime có thể tạm dừng nó và dùng thread để chạy goroutine khác.

Goroutine thường được gọi là lightweight thread hơn là một coroutine thuần túy, nhưng trong phạm vi note này, điểm chung cần giữ lại là: runtime có thể quản lý nhiều execution flow nhẹ và chuyển đổi giữa chúng mà không cần tạo một OS thread riêng cho từng execution flow.

Tất nhiên coroutine không tự động xoá hết vấn đề concurrency. Nếu nhiều coroutine cùng đọc/ghi shared state, vẫn cần lock, channel, queue hoặc cơ chế đồng bộ khác.

Model Comparison

Nhìn lại các model trên, điểm khác nhau không nằm ở chuyện “có concurrency hay không”, mà nằm ở cách mỗi model trả lời ba câu hỏi thực tế:

  1. Dùng đơn vị nào để biểu diễn một việc đang chạy?
  2. Khi việc đó phải chờ I/O, tài nguyên nào bị giữ lại?
  3. State được cô lập hay chia sẻ tới mức nào?
ModelCách tổ chứcKhi chờ I/OĐiểm mạnhCái giá phải trảVí dụ
Multi-processMỗi worker là một process riêngProcess đó chờ, process khác vẫn chạyIsolation tốt, ít shared-memory raceTốn memory hơn, giao tiếp qua IPC/store ngoàiGunicorn worker, PostgreSQL backend process
Multi-threadNhiều thread trong cùng một processThread đang chờ bị giữ, thread khác vẫn chạyNhẹ hơn process, tận dụng nhiều core, share state dễShared heap dễ sinh race/deadlockJava server truyền thống
Thread poolCó sẵn N worker thread, workload vào queueWorker đang chờ bị giữ; hết worker thì workload mới phải đợiGiới hạn số thread, vẫn viết blocking code tuần tựQueue chờ, worker có thể bị giữ bởi I/O chậmTomcat thread pool
Event loopMột main loop chạy callback/task khi event readyKhông giữ thread để chờ I/O; callback chạy khi kết quả sẵn sàngÍt thread, hợp I/O-bound workloadKhông được block event loop; CPU-bound làm đứng các việc khácNode.js
CoroutineRuntime quản lý nhiều execution flow nhẹ trên ít OS thread hơnCoroutine bị pause; OS thread có thể chạy coroutine khácCode vẫn tuần tự, nhẹ hơn OS threadVẫn share state; phụ thuộc runtime schedulerGoroutine trong Go

Có thể tóm lại như sau: multi-process mua isolation bằng resource, multi-thread mua performance bằng shared memory, thread pool đặt giới hạn cho blocking thread, event loop tránh giữ thread khi chờ I/O, còn coroutine để runtime quản lý nhiều execution flow nhẹ mà code vẫn đọc khá tuần tự.

Trong thực tế các model này cũng thường xếp lớp lên nhau chứ không loại trừ nhau. Ví dụ một service Go có thể dùng goroutine ở tầng application, Go runtime multiplex goroutine lên một số OS thread, còn bên dưới runtime vẫn dựa vào cơ chế event notification của kernel như epoll/kqueue để xử lý network I/O.

From Local Concurrency to Distributed Coordination

Tới đây, các execution flow mà tớ nói tới vẫn chủ yếu nằm trong cùng một machine. Chúng có thể là nhiều thread hoặc nhiều process, nhưng phía dưới vẫn có một OS quản lý việc thực thi và cung cấp các cơ chế để chúng giao tiếp với nhau.

Khi các execution flow được đặt trên nhiều machine khác nhau, chúng không còn chia sẻ memory hay một OS chung. Cách duy nhất để chúng phối hợp là gửi message qua network. Điều này làm mất đi một số điều vốn khá hiển nhiên trong môi trường local:

  • Một node không thể đọc trực tiếp state nằm trong memory của node khác.
  • Message có thể đến chậm, thất lạc hoặc được gửi lại.
  • Khi một node không phản hồi, ta không biết chắc nó đã chết hay chỉ đang chậm.
  • Một node có thể gặp lỗi trong khi những node khác vẫn tiếp tục chạy.

Ví dụ, hai thread trong cùng một process có thể dùng lock để cùng cập nhật một biến. Nhưng nếu hai worker chạy trên hai machine khác nhau, chúng không thể dùng chung một memory lock. State lúc này phải được đặt ở một nơi mà cả hai cùng truy cập được, chẳng hạn database hoặc coordination store, và việc cập nhật phải tính tới network delay cũng như trường hợp một worker chết giữa chừng.

Từ đây xuất hiện những câu hỏi đặc trưng của distributed systems:

  • Các node có đang nhìn thấy cùng một state không?
  • Nếu dữ liệu có nhiều replica, chúng đồng bộ với nhau ra sao?
  • Nếu một operation timeout, nó đã thất bại hay thực ra đã được xử lý?
  • Nếu một node chết giữa chừng, node khác có thể tiếp tục công việc thế nào?
  • Khi nhiều node cùng muốn thay đổi state, hệ thống quyết định thứ tự ra sao?

Nhìn chung, các vấn đề về concurrency trên distributed system đều được mở rộng hơn và có vẻ là khó hơn và theo tớ tìm hiểu thì có thể tạm phân loại thành các research topic sau:

  • Communication model: network có thể delay, drop, duplicate hoặc reorder message như thế nào; RPC khác local function call ở đâu.
  • Time and ordering: vì không có global clock đáng tin, hệ thống dùng logical clock, vector clock, timestamp hoặc log offset để nói về thứ tự event.
  • Consistency models: khi state nằm trên nhiều node hoặc nhiều replica, hệ thống định nghĩa “đọc thấy gì” bằng strong consistency, linearizability, sequential consistency, causal consistency hoặc eventual consistency.
  • Replication: dữ liệu được copy ra nhiều node bằng leader-follower replication, quorum replication, log replication hoặc state machine replication như thế nào.
  • Partitioning and sharding: dữ liệu/workload được chia ra nhiều partition ra sao, request được route thế nào, và hệ thống rebalance khi scale bằng cách nào.
  • Fault tolerance: node chết, process pause, network partition hoặc disk lỗi thì hệ thống phát hiện, retry, recover và tiếp tục phục vụ ra sao.
  • Consensus and coordination: nhiều node thống nhất leader, membership, lock, metadata hoặc một chuỗi state transition bằng các protocol như Raft/Paxos như thế nào.

Concurrency Across Language Runtimes

Có một điểm khá thú vị từ trong bài giảng của giảng viên mà tớ muốn tìm hiểu rộng hơn một chút: Concurrency không chỉ là chuyện architecture của server, nó còn nằm ngay trong runtime và design philosophy của từng ngôn ngữ. Tất nhiên vì năng lực có hạn, nên tớ chỉ có thể dừng lại ở việc tìm hiểu sơ bộ, nắm mô hình chung chung và có thể chưa thực sự chính xác.

Python: Threads, the GIL, and Multiprocessing

Python là một ví dụ khá thú vị vì nó cho thấy có nhiều thread không đồng nghĩa với việc Python code sẽ chạy song song trên nhiều CPU core.

Trong CPython - implementation Python được sử dụng phổ biến nhất - có một cơ chế gọi là GIL (Global Interpreter Lock). Với CPython truyền thống, một thread phải giữ GIL trước khi thực thi Python bytecode. Vì vậy, tại một thời điểm, thường chỉ có một thread trong cùng process đang chạy Python code.

Thread A giữ GIL -> chạy Python bytecode
Thread B         -> chờ

Thread A nhường GIL
Thread B giữ GIL -> tiếp tục chạy

GIL là một trade-off trong thiết kế của CPython. Runtime phải quản lý nhiều shared internal state, trong đó có object và reference count của chúng. Thay vì yêu cầu nhiều thành phần của interpreter cùng bảo vệ state bằng những lock nhỏ khác nhau, CPython truyền thống sử dụng một global lock để đơn giản hóa việc quản lý memory và giữ cho interpreter an toàn khi nhiều thread cùng tồn tại.

Tuy nhiên, GIL không có nghĩa Python thread hoàn toàn vô dụng. Khi một thread đang chờ network, file I/O hoặc database response, nó có thể nhường GIL để thread khác tiếp tục chạy. Vì vậy thread vẫn phù hợp với I/O-bound workload, nơi phần lớn thời gian được dùng để chờ.

Với CPU-bound workload, câu chuyện khác đi. Nếu nhiều thread đều cần chạy Python code nặng CPU trong cùng một process, chúng vẫn phải lần lượt giữ GIL nên không tận dụng được nhiều CPU core để thực thi Python bytecode song song.

I/O-bound:
Thread A chờ network -> Thread B có thể chạy

CPU-bound:
Thread A và B đều cần CPU -> cùng cạnh tranh GIL

Nói ngắn gọn: Python có thread và có concurrency, nhưng trong CPython truyền thống, nhiều thread chạy Python code không mặc định tạo ra CPU parallelism.

Vì vậy Python thường chọn concurrency model khác nhau tuỳ loại workload:

  • threading: phù hợp hơn với I/O-bound workload, nơi phần lớn thời gian là chờ.
  • asyncio: dùng event loop để quản lý nhiều I/O task mà không cần nhiều OS thread.
  • multiprocessing: tạo nhiều process riêng để tận dụng nhiều CPU core cho CPU-bound workload, vì mỗi process có interpreter/GIL riêng.
  • native extension: một số thư viện C/C++/NumPy có thể release GIL khi chạy phần tính toán native, nên vẫn tận dụng parallelism ở tầng dưới.

Điểm dễ hiểu sai là GIL không xoá race condition trong Python application. Nó bảo vệ nhiều internal state của CPython, nhưng nếu hai thread cùng mutate một object shared ở mức logic application, code vẫn có thể sai vì interleaving không như mình nghĩ. GIL làm cho một số data race ở tầng memory khó xảy ra hơn, nhưng không biến shared mutable state thành an toàn tuyệt đối.

GIL đang được thay đổi

Từ Python 3.13, CPython bắt đầu có free-threaded build cho phép chạy không GIL. Tới Python 3.14, hướng này đã được hỗ trợ chính thức hơn, nhưng vẫn là một mode/build riêng chứ chưa phải mặc định cho toàn bộ CPython.

Nếu sau này GIL bị bỏ hoặc trở thành optional rộng rãi hơn, bài toán không biến mất; trách nhiệm sẽ chuyển nhiều hơn sang runtime, extension authors và programmer để bảo vệ shared mutable state đúng cách.

Java/Scala: JVM, OS Threads, and Shared Objects

Java và Scala là ví dụ gần với model multi-thread truyền thống hơn Python. Cả hai đều chạy trên JVM (Java Virtual Machine), nên cùng thừa hưởng một nền runtime rất mạnh: thread model trưởng thành, JIT compiler, Garbage Collector, profiling tool, monitoring ecosystem, và rất nhiều thư viện production-grade.

Trong nhiều năm, JVM thread thường được hiểu khá gần với OS thread: mỗi request/task có thể được giao cho một thread, và nhiều thread trong cùng JVM process có thể thật sự chạy song song trên nhiều CPU core.

Điểm mạnh của model này là nó rất tự nhiên cho server workload và data workload. Programmer có thể viết code blocking theo kiểu tuần tự: nhận request, gọi database, gọi service khác, xử lý kết quả, trả response. Trong khi một thread đang chờ I/O, thread khác vẫn có thể chạy. Nếu có nhiều core, nhiều JVM thread cũng có thể chạy CPU work song song thật sự.

Cái giá nằm ở shared heap. Object trong Java/Scala thường nằm trên JVM heap, và nhiều thread có thể cùng giữ reference tới cùng một object. Điều này làm JVM rất linh hoạt, nhưng cũng làm race condition, visibility bug và lock contention trở thành những vấn đề trung tâm.

Vì vậy JVM ecosystem không chỉ có thread, mà còn có sẵn nhiều primitive và data structure phục vụ concurrency: lock, atomic operation, concurrent collection, executor/thread pool, future, actor-like library, stream-processing abstraction, v.v. Tớ không cần đi sâu từng cái ở đây; điểm cần nhớ là Java/Scala có cả một ecosystem lớn để viết concurrent/distributed code theo nhiều style khác nhau.

Một phần quan trọng khác của JVM là Garbage Collector. Java/Scala programmer thường không cần free() object thủ công; JVM theo dõi object nào còn reachable và thu hồi memory khi không còn dùng nữa. Điều này làm việc với object/shared state dễ hơn nhiều so với manual memory management.

Nhưng GC cũng là một runtime trade-off. Khi allocation nhiều hoặc heap lớn, GC phải tiêu tốn CPU để scan/mark/copy/compact object. Một số collector có thể tạo pause ngắn hoặc dài tuỳ workload và configuration. Với server/distributed systems, GC pause rất đáng chú ý vì nó làm process vẫn còn sống nhưng tạm thời không phản hồi kịp. Từ bên ngoài nhìn vào, một node đang GC pause có thể trông giống node chậm hoặc gần như chết.

Đây cũng là một lý do trước đây rất nhiều distributed systems được triển khai bằng Java/Scala: JVM cho performance đủ tốt, thread/concurrency primitives đủ trưởng thành, networking library tốt, GC giúp quản lý object lifetime, và ecosystem tooling rất mạnh để chạy service lâu dài. Scala đặc biệt phổ biến trong data/distributed systems vì nó chạy trên JVM nhưng cho syntax functional hơn, abstraction tốt hơn cho collection/stream/computation graph, và interop trực tiếp với Java ecosystem. Những project như Akka, Kafka và Spark đều phản ánh giai đoạn mà JVM/Scala là lựa chọn rất hợp lý để build hệ thống phân tán lớn.

Go: Goroutines, Channels, and Runtime Scheduler

Go chọn hướng khác với JVM. Thay vì để programmer nghĩ quá nhiều về OS thread, Go đưa ra goroutine như một execution flow nhẹ ở tầng runtime. Khi programmer tạo goroutine cho một công việc, Go runtime không tạo một OS thread riêng cho từng goroutine. Nó để scheduler của Go phân phối rất nhiều goroutine lên một số OS thread thật.

Điểm này làm Go rất hợp với server/network workload: mỗi connection, request hoặc background task có thể được biểu diễn bằng một goroutine riêng mà không phải trả cost lớn như tạo một OS thread riêng cho từng việc. Khi một goroutine chờ network I/O, runtime có thể park goroutine đó và dùng OS thread để chạy goroutine khác. Vì vậy code vẫn đọc khá tuần tự, nhưng runtime có thể multiplex nhiều execution flow nhẹ lên ít thread hơn.

Go cũng nổi tiếng với câu trong Effective Go, được giải thích thêm trong Go blog codelab về sharing memory by communicating:

Do not communicate by sharing memory; instead, share memory by communicating.

Ý tưởng là thay vì nhiều goroutine cùng sửa một object chung, ta cố gắng truyền data/ownership qua channel để mỗi thời điểm chỉ một goroutine chịu trách nhiệm xử lý state đó.

Nhưng Go không bắt buộc mọi thứ phải đi qua channel. Go vẫn cho phép dùng shared memory, pointer, lock, atomic operation và các cấu trúc dữ liệu mutable. Vì vậy race condition vẫn xảy ra nếu nhiều goroutine cùng mutate một object mà không đồng bộ đúng cách. Channel là một idiom mạnh, không phải phép màu.

Điểm thực dụng của Go là runtime cho phép tạo rất nhiều execution flow nhẹ, trong khi language vẫn giữ code khá đơn giản. Đổi lại, programmer phải hiểu lifetime của goroutine, channel blocking, goroutine leak và shared mutable state. Một goroutine bị kẹt chờ channel không bao giờ có data cũng là một dạng resource leak, chỉ là nhẹ hơn OS thread leak.

Node.js: Event Loop and Shared-Memory Race Avoidance

Node.js chọn hướng rất khác Java/Scala và Go: thay vì tạo nhiều execution flow chạy song song trong userland, Node.js đặt phần lớn JavaScript application code trên một event loop.

Mental model đơn giản là: JavaScript callback chạy từng cái một trên main thread. Khi gặp việc phải chờ, như network I/O, timer, file I/O hoặc database driver async, Node không để main thread đứng chờ. Nó đăng ký việc đó với runtime/kernel/libuv, rồi quay lại event loop để xử lý task khác. Khi kết quả sẵn sàng, callback hoặc promise continuation được đưa lại vào queue để chạy sau.

Điểm mạnh của model này là nó tránh được nhiều shared-memory race kiểu multi-threading truyền thống. Vì userland JavaScript thường chỉ có một callback chạy tại một thời điểm trên main thread, hai callback không cùng lúc mutate cùng một object theo nghĩa CPU-level data race.

Nhưng điều đó không có nghĩa Node.js không có race condition. Race vẫn có thể xuất hiện ở mức logical ordering: request A đọc state, await database; trong lúc A đang chờ, request B chạy và thay đổi state; sau đó A resume với assumption cũ. Không có hai thread cùng ghi một memory address tại đúng một CPU instant, nhưng thứ tự async vẫn có thể làm logic sai.

A đọc state = 10
A await I/O
B chạy, đổi state = 20
A resume, vẫn xử lý theo state cũ

Trade-off lớn nhất của Node.js là không được block event loop. Nếu một callback làm CPU-bound work quá lâu, parse JSON quá lớn, chạy regex nặng, hoặc dùng API sync blocking, toàn bộ request khác cũng bị delay vì main thread không quay lại event loop kịp.

event loop
  -> callback CPU-bound chạy quá lâu
  -> request khác phải chờ
  -> timer/I/O callback cũng bị delay

Ngoài ra, Node.js không phải hoàn toàn “một thread ở mọi tầng”. Bên dưới, libuv vẫn có thread pool cho một số việc như file system, DNS, crypto hoặc compression. Nhưng điểm cần giữ ở đây là: userland JavaScript chủ yếu đạt concurrency bằng event loop và async I/O, không phải bằng nhiều OS thread cùng chạy JavaScript code.

Rust: Ownership, Borrowing, and Type-Level Concurrency Safety

Rust là một ví dụ nổi tiếng vì nó chọn đưa nhiều rule về memory safety và concurrency safety lên compile time. Ở mức rất cao, Rust luôn hỏi: ai đang sở hữu dữ liệu này?ai được phép thay đổi nó?

Ý tưởng chính là ownership: mỗi value có một owner rõ ràng. Khi value được chuyển sang nơi khác, quyền sở hữu cũng chuyển theo. Nếu code chỉ muốn dùng tạm value, nó phải “borrow” theo rule của Rust.

Trong concurrency, điểm đáng chú ý là Rust không để shared mutable state diễn ra quá dễ dàng. Muốn nhiều thread cùng chạm tới một state, programmer phải biểu diễn rõ cơ chế chia sẻ đó, ví dụ dùng lock, channel, hoặc các type an toàn cho multi-threading. Nhiều trường hợp “hai execution flow cùng mutate một object” sẽ không qua được compiler nếu ownership/shared mutability không rõ.

Nên ở đây tớ không cần đi sâu cú pháp Rust. Điều cần giữ lại là trade-off: Rust làm code ban đầu khó viết hơn, nhưng đổi lại nó chuyển nhiều lỗi memory/race condition từ runtime sang compile time. Rust không xoá hết concurrency bug — deadlock hoặc logical race vẫn có thể xảy ra — nhưng nó làm shared mutable state trở thành thứ phải được khai báo và kiểm soát rõ hơn rất nhiều.

Communication

Sau khi nói về process, thread, event loop, coroutine và runtime, câu hỏi tiếp theo là: các execution flow giao tiếp với nhau bằng cách nào? Nếu mọi thứ nằm trong cùng một process, chúng có thể chia sẻ memory. Nếu nằm trong cùng một machine nhưng khác process, chúng có thể dùng IPC. Nhưng khi execution flow nằm trên nhiều machine khác nhau, không còn shared memory hay shared kernel nữa. Cách phối hợp duy nhất còn lại là gửi dữ liệu qua network. Đây là lý do communication trở thành phần trung tâm của distributed systems. RPC chỉ là một trong các abstraction phổ biến nhất trên network communication: nó cố làm việc gọi một service ở xa trông gần giống như gọi một function local. Bài giảng không đi quá sâu vào RPC protocol cụ thể, nhưng RPC vẫn là trọng tâm vì nó là nơi thread/concurrency ở local gặp network/failure ở distributed.

IPC

IPC (Inter-Process Communication) là communication giữa các process trong cùng một machine. Hai process không share address space mặc định, nhưng chúng vẫn nằm dưới cùng một OS/kernel, nên OS có thể cung cấp các cơ chế để trao đổi dữ liệu:

  • pipe;
  • Unix domain socket;
  • shared memory;
  • message queue;
  • file hoặc socket local.

IPC vẫn khác function call trong cùng process, nhưng failure model của nó còn khá “local”: các process share cùng kernel, latency thấp hơn network, clock/filesystem view gần nhau hơn, và OS còn có thể quản lý resource/failure boundary rõ hơn.

Network Communication

Khi process nằm trên nhiều machine, IPC không còn đủ nữa. Message phải đi qua network. Điều này thêm vào các vấn đề mà local communication ít khi phải nghĩ nhiều:

  • message có thể đến chậm;
  • message có thể mất;
  • message có thể bị gửi lại;
  • response có thể mất dù server đã xử lý xong;
  • client và server có thể disagree về schema/version;
  • một bên có thể crash trong khi bên kia vẫn chạy.

Vì vậy network communication không chỉ là “IPC chậm hơn”. Nó có failure model khác hẳn. Đây là điểm làm distributed system khó hơn local system.

RPC

RPC (Remote Procedure Call) là một abstraction đặt trên network communication. Mục tiêu của nó là làm remote call nhìn giống local function call:

result = userService.getUser(id)

Nhìn từ application code, ta gọi một method và nhận lại result. Nhưng phía dưới, RPC phải làm rất nhiều việc:

Chính vì RPC cố làm remote call trông giống local call, nó cũng dễ che mất phần nguy hiểm nhất: local function call và remote call có failure model rất khác nhau.

Local function call thường chỉ có vài khả năng dễ hiểu: function chạy xong, throw exception, hoặc cả process crash. Remote call thì có nhiều trạng thái mơ hồ hơn:

  • request chưa tới server;
  • request đã tới nhưng server chưa xử lý;
  • server đã xử lý nhưng response bị mất;
  • server xử lý xong rồi crash;
  • client timeout nhưng server vẫn tiếp tục chạy;
  • retry tạo ra duplicate side effect.

Vì vậy khi dùng RPC, chương trình thường phải nghĩ thêm về:

  • timeout;
  • retry policy;
  • idempotency;
  • request id / correlation id;
  • serialization và schema compatibility;
  • observability/tracing.

RPC makes remote look local

RPC hữu ích vì nó cho developer một interface quen thuộc giống function call. Nhưng nếu quên rằng phía dưới là network, ta rất dễ viết code như thể remote call có cùng latency và failure model với local call. Đây là nguồn gốc của nhiều bug distributed systems: timeout không rõ trạng thái, retry tạo duplicate side effect, hoặc client/server hiểu khác schema.

Message Passing

Message passing chọn một cách ít giả vờ hơn RPC. Thay vì che network thành function call, hệ thống biểu diễn communication như message/event rõ ràng.

Ví dụ, producer không gọi một function remote để lấy result ngay. Nó append event vào queue/log. Consumer đọc event, xử lý, commit offset hoặc ack message. Producer và consumer vì vậy được decouple hơn về thời gian: producer có thể gửi trước, consumer xử lý sau.

Message passing thường hợp khi hệ thống chấp nhận async processing hoặc eventual consistency. Nó cũng làm retry/replay/buffering rõ ràng hơn. Đổi lại, application phải xử lý ordering, duplicate message, consumer lag và trạng thái “event đã gửi nhưng downstream chưa xử lý xong”.

Why Threads Still Matter for RPC

Lecture tên là RPC and Threads vì RPC server không chỉ là network protocol. Sau khi request đi qua network và được deserialize, server vẫn phải chọn cách thực thi handler ở phía sau.

Một server RPC đơn giản có thể chọn:

one thread per request
thread pool
async event loop
coroutine / lightweight thread

Mỗi lựa chọn lại kéo theo đúng các trade-off đã nói ở phần trước:

Server execution modelKhi dùng với RPCTrade-off
Thread per requestMỗi request giữ một OS thread từ đầu tới cuốiDễ viết blocking code, nhưng tốn thread/memory
Thread poolRequest vào queue, worker thread xử lýKiểm soát resource tốt hơn, nhưng có queueing/starvation
Event loopRequest handler không block main loop, I/O quay lại bằng callback/promiseScale I/O tốt, nhưng CPU-bound/blocking code làm nghẽn loop
Coroutine/goroutineMỗi request có execution flow nhẹ, runtime multiplex lên threadCode đọc tuần tự hơn, nhưng vẫn cần scheduler và sync đúng

Nói cách khác, RPC nối hai phần của bài lại với nhau: network communication ở bên ngoài và concurrency model ở bên trong server. Muốn hiểu RPC server hoạt động và fail như thế nào, phải nhìn cả hai phía: request có thể timeout/retry trên network, và handler có thể block, race, queue hoặc bị scheduler giữ lại trong runtime.

My Summary

Về cơ bản bài giảng của giảng viên thực sự rất rất rộng, nó cover rất nhiều chủ đề khác nhau trong một buổi học. Tớ đã phải dành một vài tháng chỉ để vượt qua được bài giảng này với mức độ bổ sung tương đối ổn - ít nhất là với cá nhân tớ. Đôi khi trong quá trình note, bản thân tớ nhìn lại cũng thấy khá nản, text book thì nhiều, kiến thức thì nửa mùa, có thể là phần nào đó hời hợt hoặc không được sâu cho lắm. Nhiều concept tớ tìm hiểu hoặc trình bày ra như thể bản thân đã hiểu nhưng thực sự phía dưới tớ chỉ nắm được mô hình. Dù sao thì bài cũng khá dài, khá oải, tớ cũng không muốn public kiểu này đâu vì nó không giống bản tính của tớ lắm, tuy nhiên tớ nghĩ tớ nên tôn trọng công sức tìm hiểu của mình. Đến đâu hay đến đấy vậy.

Footnotes

  1. Translation Lookaside Buffer là một cache nhỏ trong CPU dùng để tăng tốc việc dịch địa chỉ virtual sang physical khi truy cập memory.

On this page