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 trụ cột 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.
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: Process A không thể đọc hoặc ghi trực tiếp vào memory của process B. Hai process có thể dùng cùng một virtual address (ví dụ
0x400000), nhưng thực tế chúng ánh xạ tới vùng memory vật lý khác nhau. Từ đó, quá trình thực thi các process không ảnh hưởng đến nhau và OS có thể kill bất cứ process nào mà không ảnh hưởng đến process khác. - 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 thuộc về process: OS cũng gán cho mỗi process file descriptor table (các file/socket đang mở) và quyền truy cập I/O (permission). Ví dụ: Process A không thể tự nhiên ghi vào file mà process B đang mở, trừ khi được OS cho phép (shared FD, fork, inheritance) hay khi process kết thúc, OS có thể cleanup toàn bộ resource mà process đó đang giữ.
- 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.
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, counterCác instruction này lần lượt thực hiện:
- Đọc giá trị của
countertừ memory vào register%rax - Thực hiện phép cộng
+1trên register - 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 → 1Kết quả cuối cùng của counter lúc này là 1 thay vì 2.
Để tránh tình huống này, 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).
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.
Hơn nữa, timestamp giữa các machines cũng không hoàn toàn đáng tin cậy do clock drift và network latency. Điều này khiến các node có thể tạm thời nhìn thấy state khác nhau hoặc quan sát cùng một sequence event theo thứ tự khác nhau.
Ví dụ, hai replicas có thể cùng nhận request cập nhật một object gần như đồng thời. Do replication delay hoặc message reordering, mỗi node có thể tạm thời nhìn thấy state khác nhau và cùng ghi đè dữ liệu của nhau, dẫn đến lost update hoặc inconsistency giữa các replicas.
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
Locks là cơ chế phổ biến nhất để giải quyết race condition. Nguyên nhân cốt lõi của race condition 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.
I/O locking in Linux kernel.
Một ví dụ cụ thể của race condition và cơ chế locking có thể thấy trong cách hệ điều hành xử lý I/O.
Khi nhiều process (hoặc thread) cùng ghi vào một file, các thao tác ghi này không nhất thiết được thực hiện như một operation duy nhất ở mức hệ thống. Thay vào đó, dữ liệu thường được chuyển xuống kernel theo từng bước (buffering, copy vào kernel space, write xuống disk), và các bước này có thể bị xen kẽ giữa nhiều execution flow khác nhau.
Do đó, nếu không có cơ chế đồng bộ, các ghi từ nhiều process có thể bị interleave, dẫn đến dữ liệu trong file không còn phản ánh đúng thứ tự logic ban đầu, thậm chí bị corrupted.
Để đảm bảo tính nhất quán, kernel sử dụng cơ chế lock ở mức file hoặc inode. Khi một process thực hiện thao tác ghi, nó cần acquire lock tương ứng trước khi tiến hành các bước I/O. Lock này bảo vệ toàn bộ quá trình ghi như một critical section, đảm bảo rằng các thao tác từ process khác không thể xen vào giữa.
Nhờ đó, mặc dù các thao tác I/O bên dưới không hoàn toàn atomic, hệ điều hành vẫn có thể đảm bảo rằng mỗi lần ghi được thực thi một cách tuần tự ở mức logic, tránh race condition trên shared resource là file.
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. Thay vì chỉ dựa vào một memory object trung tâm, các hệ thống này cố gắng đảm bảo rằng mọi node đều đồng thuận về thứ tự state transition hoặc lock ownership trước khi state mới được commit.
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
Khi hệ thống cần xử lý nhiều execution flow đồng thời, một trong những bài toán quan trọng nhất là làm thế nào để tổ chức concurrency và coordination giữa các workload đó một cách hiệu quả.
Trong thực tế, có nhiều concurrency model khác nhau. Mỗi model đưa ra một câu trả lời khác nhau cho ba câu hỏi:
- Execution flow được tạo ra và schedule như thế nào?
- State được chia sẻ bằng cách nào?
- Khi một operation bị blocking, phần còn lại của chương trình có tiếp tục chạy được không?
| Model | Execution unit | State model | Blocking story | Ví dụ gần gũi |
|---|---|---|---|---|
| Multi-process | Process | Isolated address space | Một process block không nhất thiết block process khác | pre-fork web server, Unix tools |
| Multi-thread | OS thread | Shared memory trong cùng process | Một thread block, thread khác vẫn chạy được | Java server, database engine |
| Event loop | Event/callback/task | Thường single-threaded state, explicit async I/O | Không được block event loop; I/O trả về bằng callback/promise | Node.js, browser JS |
| Coroutine / lightweight thread | Goroutine/fiber/task | Tuỳ runtime, thường share heap nhưng scheduler nhẹ hơn OS thread | Blocking có thể được runtime park/resume | Go goroutines, Kotlin coroutines |
| Actor/message passing | Actor/process mailbox | Không share memory trực tiếp; giao tiếp bằng message | Actor xử lý message tuần tự, failure cô lập hơn | Erlang/Elixir, Akka |
| Distributed coordination | Process/node | State nằm ngoài process, replicate hoặc persist | Blocking/failure qua network, timeout/retry là bắt buộc | Raft, ZooKeeper, etcd |
Điểm quan trọng không phải model nào “đúng” hơn, mà là mỗi model chọn một trade-off khác nhau giữa isolation, ease of reasoning, latency, throughput và failure handling.
Memory Model and Language Runtime
Các concurrency model ở trên không chỉ là pattern kiến trúc. Chúng thường được đóng gói trực tiếp vào runtime của từng ngôn ngữ. Vì vậy khi học distributed systems, việc hiểu sơ cách Java, Go, Rust hay Node.js quản lý memory/concurrency giúp mình dễ đoán hơn vì sao code chạy nhanh/chậm, vì sao race condition xảy ra, hoặc vì sao một request có thể block cả hệ thống.
Pass by value và pass by reference
Trước khi so sánh runtime, cần clear một điểm dễ gây nhầm: pass by value và pass by reference không đơn giản là “copy toàn bộ object” vs “không copy”.
Ở mức mental model, khi truyền dữ liệu vào function có hai kiểu thường gặp:
| Kiểu | Function nhận gì? | Hệ quả |
|---|---|---|
| Pass by value | Một bản copy của giá trị | Gán lại parameter không làm biến gốc đổi |
| Pass by reference | Một tham chiếu trực tiếp tới biến gốc | Function có thể làm biến gốc trỏ sang giá trị khác |
Nhưng nhiều ngôn ngữ hiện đại dùng kiểu trung gian: pass reference value by value.
Ví dụ trong JavaScript hoặc Java, khi truyền object vào function, bản thân reference/pointer được copy, nhưng object phía sau vẫn là cùng một object trên heap:
function mutate(user) {
user.name = "New name"; // mutate object phía sau reference
}
function reassign(user) {
user = { name: "Other" }; // chỉ đổi local copy của reference
}Vì vậy:
mutate object field -> caller thấy thay đổi
reassign parameter -> caller không thấy biến gốc trỏ sang object mớiTrong concurrency, điều này cực kỳ quan trọng. Nếu nhiều execution flow cùng giữ reference tới cùng một object mutable trên heap, chúng có thể tạo race condition dù function call nhìn rất vô hại.
Stack và heap
Một cách chia đơn giản:
| Memory area | Thường chứa gì? | Đặc điểm |
|---|---|---|
| Stack | function frames, local variables nhỏ, return address | scoped theo call stack/thread, cấp phát/thu hồi nhanh |
| Heap | objects sống lâu hơn một function call, shared objects | linh hoạt hơn nhưng cần GC/manual free/ownership |
Với multi-threading, mỗi thread thường có stack riêng nhưng nhiều thread trong cùng process có thể chia sẻ heap. Chính shared heap là nơi race condition thường xuất hiện:
Nếu object trên heap là mutable và nhiều thread cùng truy cập, runtime/language phải có chiến lược để quản lý an toàn.
Java: JVM, OS threads và shared heap
Java truyền object reference theo kiểu “reference value passed by value”. Object nằm trên JVM heap, nhiều thread có thể cùng giữ reference tới cùng một object.
Java concurrency model truyền thống dựa nhiều vào multi-threading + shared heap:
Java Thread
-> thường map gần với OS thread
-> cùng chạy trong một JVM process
-> chia sẻ heap objectsĐiều này giúp Java rất mạnh cho server workload: nhiều request có thể được xử lý song song bởi nhiều thread. Nhưng cái giá là programmer phải quản lý shared mutable state bằng các cơ chế như:
synchronizedLockvolatile- concurrent collections
- atomic classes
JVM còn có Garbage Collector để quản lý object lifetime. GC giúp programmer không phải free() thủ công, nhưng GC pause hoặc allocation pressure có thể ảnh hưởng latency, nhất là trong server/distributed systems.
Go: goroutines, channels và runtime scheduler
Go chọn hướng khác: thay vì bắt programmer làm việc trực tiếp với OS thread quá nhiều, Go cung cấp goroutine — lightweight execution unit do Go runtime schedule.
goroutine
-> nhẹ hơn OS thread
-> được Go runtime multiplex lên một pool OS threads
-> blocking I/O có thể được runtime quản lý để goroutine khác tiếp tục chạyGo vẫn có shared heap và pointer, nên race condition vẫn có thể xảy ra nếu nhiều goroutine cùng mutate object chung. Nhưng Go đẩy một idiom rất mạnh:
Do not communicate by sharing memory; share memory by communicating.
Tức là thay vì nhiều goroutine cùng sửa một object, ta cố gắng gửi data/ownership qua channel để mỗi thời điểm chỉ một goroutine chịu trách nhiệm xử lý state đó.
Dù vậy, channel không tự động làm mọi thứ safe. Nếu message chứa pointer tới mutable object và object đó vẫn bị dùng ở nhiều nơi, race vẫn có thể xảy ra.
Node.js: event loop và tránh shared-memory thread races
Node.js đi theo hướng event loop. JavaScript application code thường chạy trên một main thread, xử lý từng event/callback một:
event queue
-> event loop
-> run callback
-> schedule async I/O
-> run next callbackVì userland JS thường không có nhiều thread cùng mutate cùng một object tại cùng một thời điểm, Node tránh được nhiều loại race condition kiểu shared-memory multi-threading.
Nhưng trade-off là:
- CPU-bound work có thể block event loop;
- một callback chạy quá lâu làm request khác bị delay;
- async code dễ tạo race ở mức logical state/order, dù không phải data race ở CPU instruction level;
- libuv/thread pool vẫn tồn tại phía dưới cho một số I/O/file/crypto work.
Rust: ownership, borrowing và type-level concurrency safety
Rust chọn hướng đưa nhiều rule về memory safety và concurrency safety vào compile time bằng ownership và borrowing.
Mental model rất ngắn:
| Concept | Ý nghĩa |
|---|---|
| Ownership | Mỗi value có một owner rõ ràng |
| Borrowing | Có thể mượn reference nhưng phải theo rule |
| Mutable borrow | Tại một thời điểm chỉ có một mutable reference |
| Shared borrow | Có thể có nhiều immutable references |
| Move | Chuyển ownership sang biến/thread khác |
Rule này giúp Rust ngăn nhiều lỗi kiểu use-after-free, dangling pointer, và data race ngay lúc compile.
Với concurrency, Rust ép programmer biểu diễn rõ state nào được share qua thread và share bằng cơ chế nào (Arc, Mutex, channel,...). Điều này làm code ban đầu khó viết hơn, nhưng đổi lại mental model ownership rõ ràng hơn.
Chốt bridge từ runtime sang RPC
Từ góc nhìn distributed systems, các runtime trên đều đang giải quyết cùng một bài toán ở phạm vi local process:
nhiều execution flows
-> cần chia sẻ hoặc truyền state
-> cần tránh race / blocking / inconsistent view
-> cần quản lý lifetime của dataKhi bước sang distributed system, bài toán tương tự xuất hiện nhưng khó hơn vì không còn shared memory, shared kernel hay shared clock đáng tin cậy.
Communication
Khi execution flow cần phối hợp hoặc chia sẻ dữ liệu với nhau, hệ thống cần một cơ chế communication phù hợp. Trong môi trường local, các process thường giao tiếp thông qua IPC (Inter-Process Communication) như pipe, shared memory, message queue hoặc Unix socket do Operating System cung cấp.
Tuy nhiên, khi hệ thống được mở rộng thành nhiều machines khác nhau, các execution flow không còn chia sẻ cùng memory hay cùng kernel nữa. Lúc này, communication phải được thực hiện thông qua network communication và message passing giữa các node phân tán.
Đây là nền tảng cho RPC (Remote Procedure Call), nơi một process có thể gọi function hoặc service nằm trên machine khác gần giống như gọi local function, dù phía dưới thực chất phải trải qua serialization, network transfer, remote execution và response handling.
IPC
IPC vẫn nằm trong phạm vi một machine/kernel, nên thường còn có một số lợi thế:
- các process share cùng kernel;
- failure boundary rõ hơn distributed system;
- latency thấp hơn network call;
- có thể dùng shared memory hoặc Unix socket;
- clock và filesystem view thường gần nhau hơn.
RPC
RPC cố tình che bớt complexity của network để developer có cảm giác như đang gọi function:
result = service.getUser(id)Nhưng remote call không giống local call. Nó có thêm các failure mode:
- request có thể timeout;
- response có thể mất dù server đã xử lý xong;
- retry có thể tạo duplicate side effect;
- server có thể crash giữa lúc xử lý;
- client/server có thể disagree về schema/serialization;
- network có latency, packet loss, reordering.
Vì vậy RPC thường phải đi kèm:
- timeout;
- retry policy;
- idempotency key;
- request id / correlation id;
- serialization compatibility;
- observability/tracing.
Message Passing
Message passing chọn 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.
Một số đặc điểm:
- async communication;
- queue/log làm buffer giữa producer và consumer;
- producer/consumer decoupled hơn;
- dễ build retry/replay hơn;
- thường chấp nhận eventual consistency.
Ví dụ Kafka không làm consumer gọi function remote kiểu getResult(). Nó để producer append event vào log, consumer đọc event theo offset, và state được build dần từ stream event đó.
Từ thread sang RPC
Lecture tên là RPC and Threads vì hai thứ này chạm nhau ở điểm: distributed systems phải xử lý nhiều request đồng thời, và mỗi request thường đi qua network.
Một server RPC đơn giản có thể chọn:
one thread per request
thread pool
async event loop
actor/message passingMỗi lựa chọn lại kéo theo memory/concurrency trade-off khác nhau:
| Server model | Ưu điểm | Rủi ro |
|---|---|---|
| Thread per request | Dễ hiểu, code blocking tự nhiên | nhiều thread, context switch, shared-state races |
| Thread pool | kiểm soát resource tốt hơn | queueing, starvation, lock contention |
| Event loop | ít thread, scale I/O tốt | block event loop là chết chùm |
| Actor/message passing | state ownership rõ hơn | latency qua mailbox, cần thiết kế protocol |
Vì vậy trước khi học RPC sâu hơn, cần nắm thread/process/blocking/shared memory: chúng là nền để hiểu tại sao RPC server được implement theo nhiều kiểu khác nhau và vì sao failure/concurrency bugs xuất hiện.
My Summary
Về cơ bản thì bài giảng dàn trải qua nhiều chủ đề khác nhau và xen lẫn nhau vì câu hỏi của các học viên. Nhưng mục tiêu chung là giới thiệu và xây dựng lại basic CS mental model trước khi đi sâu hơn vào các chủ đề khó nhằn hơn.
Điểm tớ muốn giữ lại sau note này là: trước khi nói về distributed systems, cần hiểu một process local đã phức tạp thế nào. Trong một machine đã có process, thread, shared heap, lock, blocking I/O, event loop và runtime scheduler. Khi đưa communication qua network bằng RPC, các vấn đề đó không biến mất; chúng chỉ đổi hình dạng thành timeout, retry, duplicate request, partial failure và coordination giữa nhiều node.
Nói cách khác:
Threads giúp mình hiểu concurrency trong một process.
RPC giúp mình hiểu concurrency và communication giữa nhiều process/machines.Footnotes
-
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. ↩