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 được sử dụng để giải quyết bài toán này. Mỗi mô hình lựa chọn một cách khác nhau để tổ chức execution flow, quản lý shared state và xử lý blocking hoặc communication giữa các thành phần của hệ thống.
Multi-Process
Multi-Thread
Event-drivent/Event loop
Actor/Message-passing
Distributed coordination
Language Runtime
Các concurrency model này không chỉ ảnh hưởng tới kiến trúc hệ thống mà còn ảnh hưởng trực tiếp tới cách nhiều programming language và runtime được thiết kế.
Mỗi language thường ưu tiên một hoặc một vài mô hình concurrency khác nhau tùy theo mục tiêu của nó. Một số language tập trung vào multi-threading và shared memory, trong khi một số khác ưu tiên event-driven execution, message passing hoặc lightweight coroutine để giảm chi phí blocking và coordination.
Vì vậy, concurrency trong thực tế không chỉ là vấn đề của Operating System hay distributed system, mà còn là một phần quan trọng trong thiết kế của language runtime và execution model.
Java
Go
JavaScript/Node.js
Rust
Memory Management
Concurrency và execution model của một language thường gắn chặt với cách runtime quản lý memory. Khi nhiều execution flow cùng tồn tại và cùng thao tác trên object hoặc shared state, runtime cần giải quyết thêm các vấn đề như object lifetime, memory ownership, synchronization và garbage collection.
Vì vậy, memory management không chỉ đơn thuần là bài toán cấp phát và giải phóng memory, mà còn ảnh hưởng trực tiếp tới concurrency behavior, latency và hiệu năng tổng thể của hệ thống.
Stack and Heap
Garbage Collection
Ownership / Borrowing
Shared Heap Problems
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 các cơ chế 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 các abstraction như 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
- share kernel
- share machine
- share clock
RPC
- request/response
- serialization
- blocking/non-blocking
- timeout
- retry
- idempotency
Message Passing
- async communication
- queue/log
- decoupling
- eventual consistency
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. Tuy không đi sâu vào từng concept, từng khái niệm cụ thể nhưng bài giảng cũng mở mang được khá nhiều kiến thức cơ bản, đã từng được dạy trên trường Đại học nhưng chưa thực sự được liên kết, hệ thống hóa lại với nhau.
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. ↩